Skip to content

[UX] customer-portal — Landing page ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 8 Patterns: navigation/link

Summary ​

The landing page is in good shape for a marketing front door: it is the one audited customer-portal page that actually has a real <h1>, its heading levels do not skip, its copy is fully translated in both en and nb with no key drift, and the decorative "window chrome" dots are correctly aria-hidden. The defects are all in how the entry points are wired: every one of the four sign-in / sign-up controls on the screen is a PrimeVue <Button> wrapped in a <RouterLink>, producing a nested <a><button> that gives each CTA two tab stops, the outer one with no visible focus indicator. The header's home link also loses its accessible name entirely on mobile, which is the app's own primary target viewport.

Findings ​

1. Every sign-in / sign-up CTA is a <button> nested inside an <a> — Medium · A11Y-03 ​

Where: apps/web/src/pages/LandingPage.vue:55-62, :63-71; apps/web/src/layouts/DefaultLayout.vue:34-41, :42-47What: All four public entry points use the shape <RouterLink :to="…"><Button :label="…" /></RouterLink>. RouterLink's default slot renders an <a href>, and PrimeVue's Button renders a native <button>, so the emitted markup is an anchor whose only child is a button — interactive content nested inside interactive content, which HTML disallows. Both elements are natively focusable, so each CTA takes two Tab presses, and the outer <a> carries no class of its own: the theme's focus ring belongs to the PrimeVue button, so the anchor stop shows no visible focus indicator at all. A screen reader announces a link that contains a button with the same name, i.e. the label is read twice per control. This is not a general repo idiom — grep across apps/web/src shows the shape occurs only in these two files, i.e. only on the unauthenticated shell, so it is the first thing a prospective user tabs through. Why it matters: A keyboard user tabbing the landing page hits eight stops to reach four destinations, and four of those eight are invisible — focus appears to vanish between the header logo and the language switcher, and again between each hero CTA. Screen-reader users hear "Sign in, link, Sign in, button". Fix: Drop the wrapper and let the button be the link. PrimeVue Button accepts as="router-link" with :to, or asChild with a v-slot; either renders one <a> styled as a button, with one tab stop and the theme focus ring. E.g. <Button as="router-link" :to="{ name: 'auth.sign-in' }" :label="t('landing.cta.primary')" icon="pi pi-arrow-right" size="large" />. Apply to all four sites.

Where: apps/web/src/layouts/DefaultLayout.vue:18-28What: The link to home.index contains <img class="h-8 shrink-0 sm:h-10" src="/aadalen-logo.svg"> — with no alt attribute at all — plus a text <span class="mobile:hidden">Portal</span>. The mobile: variant (≤760px per the comment on :26) applies display: none, which removes the span from the accessibility tree. So above 760px the link's accessible name computes to "Portal"; at or below it, the link has no text content and no alt, and its name falls back to whatever the browser derives from the src — typically the filename "aadalen-logo.svg", or nothing. Why it matters: apps/web/CLAUDE.md states the app is used primarily on phones. On exactly those viewports the only route back to the landing page from sign-in / sign-up / forgot-password is an unnamed link. This is also a plain WCAG 1.1.1 failure: an <img> with no alt is never correct — the attribute is required, alt="" being the deliberate "decorative" value. Fix: Give the <img> alt="" (it is redundant next to the text) and put the name on the link: :aria-label="t('navigation.home')" on the RouterLink, so the name survives the mobile:hidden span. Add navigation.home to both en.yml and nb.yml.

3. The three feature headings hang under the preview card's heading — Low · A11Y-04 ​

Where: apps/web/src/pages/LandingPage.vue:104 (<h2> preview title) vs :136 (<h3> feature title, ×3) What: No level is skipped, so the letter of A11Y-04 passes: the page has exactly one <h1> (:45) and the sequence is h1 → h2 → h3. But the h2 belongs to the portal preview card inside the hero section, while the three h3s belong to the sibling "Feature row" <section> (:127), which has no heading of its own. The document outline therefore nests "Everything in one place", "Your whole fleet, any brand" and "Free to use" as children of "What you'll manage". Why it matters: A screen-reader user navigating by heading (a primary way of skimming a marketing page) is told the three selling points are sub-content of the preview card rather than a separate section, which misdescribes the page. Fix: Either give the feature <section> its own <h2> (visible, or class="sr-only") and keep the h3s beneath it, or demote the preview card's title to <h3>/<p> — it is a decorative mock-up label, not a section heading — and promote the feature titles to <h2>.

4. The same destination is offered under two different labels, and nb matches neither — Low · CONTENT-02 ​

Where: apps/web/src/layouts/DefaultLayout.vue:42-47 (navigation.signUp) vs apps/web/src/pages/LandingPage.vue:63-71 (landing.cta.secondary); values at i18n/messages/en.yml:40 / :461 and nb.yml:40 / :461What: Both controls route to auth.sign-up, but the header says navigation.signUp and the hero says landing.cta.secondary. In en these are "Sign up" and "Create account", against a destination titled "Create your account" — close enough for the hero, loose for the header. In nb they are "Registrer" and "Opprett konto", against a destination titled "Opprett konto": the Norwegian header label shares no words with either the sibling control that leads to the same page or the heading that greets the user there. (The sign-in half of this — CTA "Sign in" → destination titled "Welcome back" — is already covered by the sign-in draft's A11Y-04/CONTENT-02 finding and is not re-filed here.) Why it matters: Two visibly different labels on one screen read as two different destinations; a Norwegian visitor who clicks "Registrer" lands on a page whose heading gives no confirmation that the click did what they expected. Fix: Point both controls at one key. Simplest is to use landing.cta.secondary semantics for the header too — set navigation.signUp to "Create account" / "Opprett konto" so control, sibling control and destination heading all agree.

Unverified ​

  • A11Y-01 (contrast) — cannot be settled by reading code. Needs checking on a rendered page: text-accent-ink on the hero headline second line (LandingPage.vue:47), the muted text-text-3 uppercase 12px overline with tracking-[0.14em] (:99), text-text-2 body copy (:50, :107, :139), and text-white on bg-ink icon tiles (:133).
  • A11Y-06 (short viewport / responsive) — needs a rendered viewport. The hero is lg:grid-cols-[1.05fr_0.95fr] with a stacked mobile fallback and reads as correctly mobile-first, but the header at 320px carries logo + language switcher + two buttons on one row and should be checked at 320 / 360 px in nb, where the labels are longer ("Logg inn", "Registrer").
  • PrimeVue Button internals — node_modules is not installed in this checkout, so finding 1 rests on PrimeVue's documented default (Button renders a native <button> unless as/asChild is passed) rather than on read source. If the installed version's default root element differs, finding 1 changes shape but the double tab stop remains.
  • A11Y-02 (target size) — the header's size="small" buttons and the icon-only language switcher look adequate against PrimeVue's small preset (≈32px), but this was not measured.

Baseline additions ​

None new. Two observations that reinforce rules already proposed elsewhere, so they should be folded into the reconciliation rather than given fresh IDs:

  • CONTENT-05(b) (no terms/privacy link) — the public shell has no footer at all (DefaultLayout.vue:52-55 is <main> and nothing else), and the terms route sits inside the requiresAuth AppLayout tree behind abilityCheck: { action: "read", subject: "Terms" } (router/index.ts:211-218). There is therefore no terms or privacy page an anonymous visitor can reach from the front door of the product, which is also where account creation starts.
  • A11Y-07(a) (<html lang> reflects active locale) — apps/web/index.html:2 is a static lang="en" while the locale switcher sits in this page's header. App-wide, one file; belongs with the project-level items, not as a per-feature finding.

Not re-filed here, per PROJECT-LEVEL.md: NAV-03 (one static document title, index.html:22 "Ådalen App") and MSG-02. CONTENT-01 passes on this feature — every landing.* key exists in both en.yml and nb.yml with real Norwegian values, no # TODO placeholders.

Cross-project note ​

The <RouterLink><Button/></RouterLink> shape in finding 1 is worth grepping for in the other three. It is the natural way to write a "link that looks like a button" in any Vue + component-library stack, and it fails identically everywhere: playout (@playout/ui), tt-time-tracker (PrimeVue 4 — same fix, as="router-link") and members (@bcc-code/component-library-vue). It also sits adjacent to the already-confirmed cross-project A11Y-03 row (click-only div/li rows) — same rule, opposite error: there, non-interactive elements made clickable; here, interactive elements stacked two deep.

Finding 2 (image with no alt inside a link whose text label is hidden at small widths) is a generic header pattern and should be checked in the other three app shells.