Skip to content

Plan 4: the Paper redesign — landing page, results at /sok, combobox, measured a11y and performance bar - #12

Merged
malinfossum merged 24 commits into
mainfrom
feat/redesign
Sep 9, 2026
Merged

malinfossum merged 24 commits into
mainfrom
feat/redesign

Conversation

@malinfossum

Copy link
Copy Markdown
Owner

Summary

The plan 4 redesign: brand tokens ("Paper"), self-hosted type, a search-first landing page,
a real header and acute strip, a municipality combobox, a restructured results and detail
page, restyled empty/loading/error states, light-first theming, and — this task — lazy
routes, the performance measurement pass, the README and a brand brief for whoever designs
the mark next.

Merging this deploys the web frontend and runs the pending API migration (three national
emergency rows: 110, 112, 113).

What changed

13 tasks, each reviewed and reworked to clean before the next started (see
.superpowers/sdd/2026-09-08-varde-redesign/progress.md for the full task-by-task ledger,
including every defect found and fixed along the way):

  1. Paper tokens in Tailwind, design-system/ removed, a contrast test against the spec floors
  2. Light-first theme init with a system fallback and a toggle
  3. Self-hosted Fraunces/Figtree with a drift test
  4. Three national emergency rows seeded (110, 112, 113)
  5. Emergency constants wired to the acute strip, matched against the seed by a test
  6. Routes (/, /sok, legacy redirect), navigation focus, catalog cache
  7. Header, acute strip, footer
  8. Landing page: hero, search, category chips, trust strip, emergency section
  9. Municipality combobox (react-aria-components), filter bar
  10. Results layout, cards, pagination
  11. Detail page: call button, copy/share, back navigation
  12. Restyled empty/loading/error/not-found states, axe on every page in both themes
  13. Lazy routes, performance measurement, README, this brief

Performance (measured against vite preview of this production build)

Medians of 3 Lighthouse runs each, Edge headless, default simulated-mobile config.

Metric Budget Landing / Results /sok
Performance >= 95 99 67-74 — still misses (LCP-bound, see below)
Accessibility >= 95 100 100
Best Practices >= 95 100 100
SEO >= 95 100 100
Initial JS (gzip) </=120 KB /, </=180 KB /sok 69.70 KB 132.06 KB
LCP </=2.0 s (/ only) 1.86 s 4.8-7.9 s (no budget line, stub-dependent — see below)
CLS </=0.05 0.0005 0.0103 — pass
Font bytes on / </=80 KB 41,024 B
Font requests 2-4 same-origin 3
Requests on / before interaction no API call confirmed (HTML, theme-init.js, 3 fonts, 1 JS chunk, 1 CSS — no API, no third party, no cookie)

/sok is measured against a local throwaway stub API returning 12 real-shaped resources and
the real nine categories from api/Varde.Data/Seed/Categories.cs (a one-category stub
understates how much the filter sidebar grows once the catalog loads, which turned out to
matter — see below).

CLS is now fixed. Performance still misses, honestly reported rather than tuned away:

  • /sok CLS, originally 0.123-0.150 against budget 0.05, is now 0.0103 — a pass.
    Lighthouse's layout-shifts audit named the results grid as the element that moved, which
    read as "the loading skeleton is too short" — and that was real (fixed in LoadingState.tsx
    and ListPage.tsx, reshaping the skeleton to the real card count and giving the loading
    heading the same visible height the ready heading has). But reading the raw trace's
    LayoutShift events showed the dominant shift was a position change, not a size change:
    the results grid was being pushed down by the filter sidebar (<aside> in ListPage.tsx)
    growing once the catalog loaded — its municipality combobox and nine category chips render
    nothing until then. Below the lg breakpoint the sidebar stacks above the results, so that
    growth pushed everything below it, results included. Fixed by reserving the sidebar's loaded
    height (min-h-[28rem] lg:min-h-0 on <aside>, still inside ListPage.tsx) so it doesn't
    grow after mount — held for every state except a catalog failure, where the sidebar has
    almost nothing to render and the reservation would push the error panel off-screen (see the
    fix wave below). Full trace evidence and measurements in
    .superpowers/sdd/2026-09-08-varde-redesign/task-13-report.md's fix report.
  • /sok Performance (67-74) still misses the 95 budget, LCP-bound. A follow-up isolation
    pass (own stub, own build, three runs each) checked two things directly rather than by
    inference: whether the LoadingState reshape (6->12 cards, visible heading) itself cost any
    of this, and what element Lighthouse actually names as the LCP candidate. Both conditions —
    reshape present or reverted, everything else identical — measured the same Performance
    (0.74), the same CLS (0.0103), and LCP within 60 ms of each other (4.84 s vs. 4.85 s median);
    the LCP element in both was div.grid > ul.grid > li.grid > h2.text-lg, the first real
    result card's title
    , never anything from the loading skeleton. So the reshape is
    confirmed inert on this metric — not a cost, not a saving — and stays as shipped. Separately,
    that same follow-up's own stub didn't reproduce the ~7.8-7.9 s figure below (it measured
    ~4.84 s against a comparably realistic nine-category catalog), which weakens without fully
    disproving the "catalog realism" explanation for that specific number — full writeup and the
    stub's exact shape in task-13-report.md's final section. The 70-88 / 2.85-4.8 s and
    67-68 / 7.8-7.9 s figures below are this task's own two measurement passes, kept for the
    record rather than erased:
    • Original (one-category toy stub): Performance 70-88, LCP 2.85-4.8 s.
    • Fix-report re-test (nine-category stub, LoadingState reshape + <aside> fix both in
      place): Performance 67-68, LCP ~7.8-7.9 s — confirmed via an on/off comparison to be a
      property of testing against the real nine-category catalog rather than a side effect of
      the CLS fix: more categories push the first result card further down the page, changing
      which element Chrome's LCP algorithm treats as the largest visible candidate.
      This is the same class of miss flagged from the start — Performance on a data-driven route
      measured under simulated-mobile throttling against a local stub, dominated by LCP and
      sensitive to that stub's exact shape. Not chased: no data-loading restructure, no more
      favourable run condition, no changes made purely to move this number.
  • 320 px width no longer has a horizontal scroll, on every page. The header's action row
    (language toggle, theme toggle, "Forlat siden" quick-exit button) now wraps to its own line
    instead of overflowing — flex-wrap on the header container, no fixed height, no breakpoint
    needed since flexbox wraps exactly when the row doesn't fit. Nothing shrinks or hides; every
    button keeps its 44 px minimum tap target on every route, confirmed via
    scrollWidth/clientWidth reads and bounding-rect checks on /, /sok, and
    /resources/:id. A follow-up pass actually rendered and screenshotted the wrap at 320 px
    (it had only been deduced from the CSS before): the action row lands flush-left under the
    wordmark and fills all but 22 px of the line it wraps onto, reading as a deliberate two-row
    header rather than a broken one. Screenshot and measurements in task-13-report.md's final
    section. Left as shipped, no change made.

Everything else meets budget, including both bundle-size lines (/sok's combobox chunk did
not need splitting further) and the landing page's SEO, which was fixed in this task: there
was no robots.txt, so the SPA's catch-all route served index.html for it and Lighthouse
scored the audit 0/1. Added web/public/robots.txt and excluded it from the SPA rewrite in
staticwebapp.config.json; landing SEO went from 91 to 100.

Manual accessibility passes

  • Keyboard-only, landing -> results -> detail -> back: done live against vite preview
    typed a search, submitted via the Søk button, opened a result's Detaljer link, used the back
    button. Both lazy chunks (ListPage, ResourceDetail) loaded and rendered correctly. One
    caveat: the browser-automation tool's synthetic Enter key didn't trigger the search form's
    native submit (a tool quirk) — Enter-to-submit is instead verified by
    landing.test.tsx's userEvent.type(..., "vold{Enter}") tests, which dispatch real keyboard
    events in jsdom and pass.
  • 320 px width: run, now passes — see above.
  • 200% zoom: approximated via a 640x800 viewport (the effective width a 1280 px display
    shows at 200% zoom; this tool has no real OS-zoom control) — no horizontal scroll, no content
    loss.
  • Reduced motion: not exercised via a live OS toggle in this environment. Reviewed the CSS
    instead — .entrance animations sit only inside @media (prefers-reduced-motion: no-preference), so nothing animates when reduced motion is requested. Mechanism looks
    correct; the live toggle itself is unverified.
  • NVDA pass: not run — no screen reader available in this environment. Unverified.
  • Acute strip, phone tap test: not run — no physical phone available. Unverified.

Final review fix wave

The whole-branch review found seven items, all fixed in this branch before the PR:

  1. The trust panel published a date seven months in the past. NEXT_VERIFICATION_PASS read
    2026-02-17; the seeded rows carry LastVerified 2026-08-13/08-17, so six months out is
    2027-02-17. The wrong year came from the design spec, which is corrected in place with a
    dated note rather than silently rewritten — it is a decision record.
  2. The emergency numbers' tap targets were ~20 px wide. min-h-11 set height only. Measured
    in Edge against the production build with the real vendored Figtree: 110 was 20.8 px wide,
    112 19.7, 113 19.5, 116 117 42.5. All four are now 44 x 44 px at 320, 375 and 1280 px
    viewport widths, single-line, with no horizontal overflow at 320 px.
  3. 448 px of reserved blank space above the outage state. The <aside> reservation was
    unconditional, so during an API outage — when FilterBar renders only the search box and the
    national toggle — it left ~340 px of nothing above "Noe gikk galt". Measured at 375 x 812
    with the API stopped: the error heading sat at y=782 and the Legevakt fallback number at
    y=920, below the fold, during exactly the outage that panel exists for. The reservation
    now lifts on a catalog error: heading y=442, Legevakt number y=580, above the fold.
    /sok CLS re-measured the same way as the table above (production build, vite preview,
    Lighthouse, medians of 3, same stub): 0.0103 before, 0.0103 after — unchanged.
  4. The combobox toggle announced a selection that wasn't true. Its aria-label was a static
    "Kommune: Alle kommuner", so after choosing Hamar a screen reader still heard "Alle
    kommuner". It is now a plain action name under a new filter.toggleList key.
  5. Twelve dead picker.* i18n entries left behind when KommunePicker was deleted, removed
    from both nb.json and en.json.
  6. Two more copied-from-seed tables now have drift tests. i18n/fallbacks.ts (the numbers
    shown precisely when the API is down) and services/categories.ts are checked against
    SeedData.cs and Categories.cs by tests/seedDrift.test.ts, the same way
    tests/emergency.test.ts already pins the acute strip.
  7. A one-h1-per-page guard in axe.test.tsx, across all six routes in both themes — axe's
    own page-has-heading-one only checks that at least one exists, and exactly one is the
    invariant that broke three times on this branch.

One review finding was not implemented, because measuring it showed it was wrong: the report
said the empty state suffers the same 448 px reservation. It does not. The empty state has a
resolved catalog, so FilterBar is fully rendered — measured at 430 px of real content against
the 448 px reservation, an 18 px difference, not 448. That 18 px is also why the first attempted
fix (dropping the reservation as soon as the catalog settles either way) pushed CLS from 0.0103
to 0.0309: releasing the floor is its own shift. Lifting it only on a catalog error holds
both the outage fix and the CLS number.

Testing

  • Web: npx vitest run151/151 (29 files). 149/149 (28 files) before the final fix wave,
    which added the two seedDrift.test.ts cases. Lazy-loading
    ListPage and ResourceDetail behind Suspense required zero test migrations: every
    test that renders <App /> and reaches /sok or a resource detail already awaits an async
    findBy…/waitFor as its first assertion, before any synchronous getBy… — checked file by
    file (list.test.tsx, axe.test.tsx, errorRecovery.test.tsx, landing.test.tsx,
    shell.test.tsx); detail.test.tsx renders ResourceDetail directly, never through App.
  • A follow-up fix (CLS + 320 px overflow, see above) touched Header.tsx, ListPage.tsx, and
    LoadingState.tsx, and updated one existing assertion in list.test.tsx for the loading
    heading's now-visible styling — still 149/149. The CLS and overflow fixes themselves aren't
    independently unit-tested: both are layout-only defects that jsdom can't reproduce (it does
    no layout), so the real verification is the Lighthouse CLS number and live
    scrollWidth/clientWidth/bounding-rect reads against a production build, both in the fix
    report.
  • API: dotnet test85/85, untouched by this task and by the fix wave (nothing under
    api/ was modified; the new drift test only reads from it).
  • npx tsc --noEmit — clean.
  • npx biome ci . — clean (this task also fixed web/scripts/sync-fonts.mjs's pre-existing
    formatting violation, the only file outside its own scope it touched).
  • npm run build — succeeds; chunk sizes above.

After merge

  • Confirm the migration ran: GET /api/resources?category=nodtjenester shows rows 23-25
    (Brannvesen 110, Politi 112, Ambulanse 113).
  • Confirm fonts load from /fonts/ on the deployed site.
  • Confirm no cookie is set (Application -> Cookies, empty).
  • Tick the remaining DoD lines in docs/superpowers/specs/2026-09-08-varde-redesign-design.md.

…test

I move the whole Paper palette into web/src/styles/tokens.css, expose it
through Tailwind v4's @theme inline, and delete the stale bundled
design-system (two majors behind) along with its loader import and the
Biome exclusion that carved it out.

tests/tokens.test.ts reads tokens.css and checks every colour against the
spec's contrast floors via a small contrast.ts service (relative luminance,
contrast ratio, and a tokens.css parser). That test is what caught the
spec's light border - #e3ddd2 measures 1.21:1 on paper, under the 1.3:1
floor - so I corrected it to #d9d2c6 (1.35:1) in both tokens.css and the
spec table, with a note explaining the change.

I also update the two comments (index.html, ErrorState.tsx) that described
the now-deleted design-system, since a comment pointing at nothing is worse
than no comment.

One thing not in the task brief: this project has no @types/node, and I
needed three Node built-ins (readFileSync, path helpers, fileURLToPath) to
read tokens.css from the test - the literal new URL(relative, import.meta.url)
approach fails on this machine because jsdom's global URL mis-resolves a
relative path against a file:// base on Windows. I added a narrow
tests/node-builtins.d.ts declaring just those three functions rather than
installing @types/node.
…just ready

A dangling pending flag from a requestFocus() call would survive an
error or an empty result forever, waiting for the next ready render
to consume it — which could be a later, unrelated load (e.g. typing
a new search after a retry). useArrivalFocus now takes a settled
parameter (defaults to ready) that clears pending on any terminal
state; ListPage passes state.kind !== "loading" so an error or an
empty page count as settled too.
… and emergency sections

Replaces the Task 6 LandingPlaceholder with the real search-first design: hero with
entrance animation, LandingSearch (catalog prefetch on focus/pointer-enter only, no
request on render), nine static category chips from the new categories.ts, a trust
strip and an emergency section reusing emergency.ts.

Fixes folded in while implementing:
- Entrance stagger uses explicit entrance-2..5 classes instead of :nth-of-type, which
  counts per tag name and put the lone <div> and <ul> at the same delay.
- landing.test.tsx asserts the actual nine category labels (read from nb.json) instead
  of a near-vacuous regex, and scopes the chip-count query to <main> so the acute
  strip's own /sok?category=nodtjenester link doesn't double-count.
- Suggestions.tsx restyled with token utilities (it lost its styling when the bundled
  design-system was deleted): 44px tap targets and the list reset are preserved.
- shell.test.tsx's landing test now checks the real headline instead of the old
  placeholder's "Varde".
…me dispatch centre

The emergency intro falsely stated that all Norwegian emergency numbers reach the same
dispatch centre. In reality, Norway runs four separate systems: 110 (fire), 112 (police),
113/AMK (health), and 116 117 (non-acute). I''ve kept the actionable advice and removed
the false sentence.

Verified against DSB, Nkom, and Digidel during review.
…r bar

Replaces the thirty-button KommunePicker with a react-aria ComboBox
that groups municipalities by county under Norwegian collation and
folds diacritics through the existing matchesEitherWay matcher. Gathers
search, the combobox, the national toggle and category chips into one
FilterBar consumed by ListPage.

Two fixes beyond the plan's own combobox sketch, both verified against
react-aria's source: defaultFilter is disabled so the library's own
locale-collator filter can't re-strip a match matchesEitherWay already
kept, and the input row is wrapped in a Group with
data-react-aria-top-layer so the Clear and toggle buttons stay reachable
while the listbox is open instead of getting aria-hidden.
`items` is the prop ComboBox checks before filtering, so setting it makes
react-aria skip filterCollection and render `visible` as it is — which is
what the component already meant.

Without it react-aria swapped its section nodes for plain clones on the
first keystroke, and React 19.2's development-only render logger walked
that diff straight into a `childNodes` getter that throws on purpose. The
exception escaped React's commit, so every later render was dropped: the
input text froze and the popover never closed, while the URL still
updated. Development mode only — the logger is absent from the production
build, and the built app was never affected.
…gian

"Alle kommuner" only rendered while the input was empty, so it vanished the
moment a municipality was selected — exactly when someone wants to undo that
choice, leaving "Tøm" as the only way out. It now shows whenever the user has
not typed a new query, which is what the spec means by "Choosing 'Alle
kommuner' clears the filter".

The field's text was seeded once at mount and never followed selectedId again,
so "Nullstill", the national toggle, a municipality suggestion, EmptyState's
clear button and browser back/forward all left it naming a filter that was no
longer applied — and, because the visible list derives from that text, still
narrowing the list to it. It now adjusts during render when selectedId changes.
Keying the component would work too, but a remount would throw keyboard focus
out of the field right after a selection.

react-aria names the listbox and counts options from its own locale, which fell
back to navigator.language: an English-locale browser narrated the Norwegian UI
in English. I18nProvider now takes the lang the toggle sets, which is what makes
leaving the native announcements unwrapped the right call.

FilterBar had no tests at all, so its national toggle, category chips, Nullstill
gating and reset patch are covered now, along with the external sync above. The
collation assertions compared indexOf positions, which pass on a county that
stopped rendering (-1 sorts before every real index); they now read the group
labels and compare the ordered list.
A catalog failure isn't mutually exclusive with the resources state, so
ErrorState's <h1> could land next to LoadingState's, EmptyState's, or the
results heading. ErrorState now takes a level prop (same pattern as
NotFoundState) and ListPage passes level 2 whenever something else is the
page's primary content, keeping /sok at exactly one h1 in every state.
…te leaks

NotFoundState is the sole content of the page whether it renders for the
route-level 404 or embedded in ResourceDetail, so both now render level 1 -
the level prop had no remaining reason to exist and is gone. Also adds
badge-order coverage to the detail page and stops window.history state and
a history.back spy from leaking between detail.test.tsx tests.
…ge on every page

Install vitest-axe and axe-core; add axe matchers to setup. Run axe on 6 pages × 2 themes
before asserting a heading by role is present (proves the page, not an empty mount). Disable
color-contrast (jsdom has no layout) — token contrast is tested in tokens.test.ts.

ErrorState now shows only Legevakt (116 117) as a fallback, since 110/112/113 already sit
in the strip above every page. Restyle all three states to Paper tokens: error and empty
get the new card treatment (rounded-xl, border, grid gap-3, p-5), not-found gets a centred
flex layout with two nav buttons.

Add landing.toHome ("Back to home") to both locales; maintain i18n parity (101 keys).
…plan 4

Wraps ListPage and ResourceDetail in lazy() + Suspense so the landing page's
bundle stays small, then measures the redesign against its own performance
budget with Lighthouse against a production build.

Landing meets every budget line, including a robots.txt fix that closed a
Lighthouse SEO gap (the SPA's catch-all route was serving index.html for
/robots.txt). The results page misses on Performance and CLS, and every page
has a real horizontal scroll at 320px from the header's action row - both are
pre-existing issues in components outside this change's scope, reported in
full in the ledger and the drafted PR body rather than patched here.

Also updates the README's stack line and web section, adds the brand brief
for the mark and pack, and fixes a leftover Biome formatting violation in
sync-fonts.mjs so `biome ci` passes in CI.
…n, wrap header at 320px

Fixes the two DoD gaps from task 13's measurement pass. CLS on /sok (0.15 -> 0.01,
budget 0.05) was dominated by the filter sidebar growing once the catalog loads
(its combobox and nine category chips render nothing until then), stacked above
the results below the lg breakpoint - not just the loading skeleton's own size,
confirmed by reading the raw layout-shift trace rather than trusting the audit's
node label. Reserve its loaded height on <aside> in ListPage.tsx, and separately
reshape LoadingState's skeleton to the real card count and give the loading
heading the same visible height the ready heading has.

320px overflow on every route came from the header's action row not wrapping.
Drop the fixed height and let flexbox wrap the row onto its own line exactly
when it doesn't fit next to the wordmark - no breakpoint, nothing shrinks below
its 44px tap target, no label lost.
…tage state

The acute strip's four numbers had min-h-11 but no width, so the real targets
measured 20.8, 19.7, 19.5 and 42.5 px wide against the spec's 44 px — the four
controls that exist for someone in distress holding a phone. min-w-11 with
justify-center makes all four 44x44 at 320, 375 and 1280 px, and whitespace-nowrap
keeps "116 117" on one line under flex shrink.

/sok's <aside> reserved 28rem unconditionally. During an API outage FilterBar
renders only the search box and the national toggle, so at 375x812 the error
heading sat at y=782 and the Legevakt fallback number inside it at y=920, below
the fold, during exactly the outage that panel exists for. The reservation now
lifts on a catalog error: 442 and 580.

Lifting it as soon as the catalog resolves instead would cost CLS and buy nothing
— the loaded column is 430 px against the 448 px floor, so the release is its own
shift (/sok CLS 0.0103 -> 0.0309, measured), and a resolved catalog means the
column is full anyway. On for loading and ready, off for error, holds both:
CLS stays at 0.0103 against the 0.05 budget.
The trust panel promised a re-check date seven months in the past. The seeded
rows carry LastVerified 2026-08-13/08-17, so six months out is 2027-02-17.

The wrong year came from the design spec, which is a decision record — so I
corrected the line in place and appended a note saying what it used to read and
why the year changed, rather than rewriting the record silently.
…cker keys

The municipality toggle's aria-label was a static "Kommune: Alle kommuner", so
after choosing Hamar a screen reader still announced "Alle kommuner". It is now
a plain action name under a new filter.toggleList key in both languages.

The six picker.* keys in each file were left behind when KommunePicker was
deleted and filter.* replaced them. Nothing in src or tests reads them, by
literal or by template literal — the only template-literal lookups build
strip., category. and hint. prefixes.
…uard one h1

emergency.ts is checked against SeedData.cs by a test, but fallbacks.ts and
categories.ts carry the same "copied, never from memory" comment with nothing
enforcing it. The fallback numbers are the ones shown precisely when the API is
down, so nothing else catches a mistake there at runtime. seedDrift.test.ts now
reads SeedData.cs rows 1-4 and Categories.cs and fails if either copy drifts.

axe's page-has-heading-one only checks that at least one h1 exists. Exactly one
is the invariant that broke three times on this branch, so axe.test.tsx now
asserts the count across all six routes in both themes.
@malinfossum
malinfossum merged commit 3934730 into main Sep 9, 2026
6 checks passed
@malinfossum
malinfossum deleted the feat/redesign branch September 9, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant