Plan 4: the Paper redesign — landing page, results at /sok, combobox, measured a11y and performance bar - #12
Merged
Merged
Conversation
…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.
…ocus, catalog cache
…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.
…ing, results grid
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdfor the full task-by-task ledger,including every defect found and fixed along the way):
design-system/removed, a contrast test against the spec floors/,/sok, legacy redirect), navigation focus, catalog cachePerformance (measured against
vite previewof this production build)Medians of 3 Lighthouse runs each, Edge headless, default simulated-mobile config.
//sok/, </=180 KB/sok/only)//before interaction/sokis measured against a local throwaway stub API returning 12 real-shaped resources andthe real nine categories from
api/Varde.Data/Seed/Categories.cs(a one-category stubunderstates 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:
/sokCLS, originally 0.123-0.150 against budget 0.05, is now 0.0103 — a pass.Lighthouse's
layout-shiftsaudit named the results grid as the element that moved, whichread as "the loading skeleton is too short" — and that was real (fixed in
LoadingState.tsxand
ListPage.tsx, reshaping the skeleton to the real card count and giving the loadingheading the same visible height the ready heading has). But reading the raw trace's
LayoutShiftevents showed the dominant shift was a position change, not a size change:the results grid was being pushed down by the filter sidebar (
<aside>inListPage.tsx)growing once the catalog loaded — its municipality combobox and nine category chips render
nothing until then. Below the
lgbreakpoint the sidebar stacks above the results, so thatgrowth pushed everything below it, results included. Fixed by reserving the sidebar's loaded
height (
min-h-[28rem] lg:min-h-0on<aside>, still insideListPage.tsx) so it doesn'tgrow 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./sokPerformance (67-74) still misses the 95 budget, LCP-bound. A follow-up isolationpass (own stub, own build, three runs each) checked two things directly rather than by
inference: whether the
LoadingStatereshape (6->12 cards, visible heading) itself cost anyof 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 realresult 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 and67-68 / 7.8-7.9 s figures below are this task's own two measurement passes, kept for the
record rather than erased:
LoadingStatereshape +<aside>fix both inplace): 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.
(language toggle, theme toggle, "Forlat siden" quick-exit button) now wraps to its own line
instead of overflowing —
flex-wrapon the header container, no fixed height, no breakpointneeded 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/clientWidthreads 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 finalsection. Left as shipped, no change made.
Everything else meets budget, including both bundle-size lines (
/sok's combobox chunk didnot 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 servedindex.htmlfor it and Lighthousescored the audit 0/1. Added
web/public/robots.txtand excluded it from the SPA rewrite instaticwebapp.config.json; landing SEO went from 91 to 100.Manual accessibility passes
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. Onecaveat: 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'suserEvent.type(..., "vold{Enter}")tests, which dispatch real keyboardevents in jsdom and pass.
shows at 200% zoom; this tool has no real OS-zoom control) — no horizontal scroll, no content
loss.
instead —
.entranceanimations sit only inside@media (prefers-reduced-motion: no-preference), so nothing animates when reduced motion is requested. Mechanism lookscorrect; the live toggle itself is unverified.
Final review fix wave
The whole-branch review found seven items, all fixed in this branch before the PR:
NEXT_VERIFICATION_PASSread2026-02-17; the seeded rows carryLastVerified2026-08-13/08-17, so six months out is2027-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.
min-h-11set height only. Measuredin 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.
<aside>reservation wasunconditional, so during an API outage — when
FilterBarrenders only the search box and thenational 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.
/sokCLS 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.
aria-labelwas 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.toggleListkey.picker.*i18n entries left behind whenKommunePickerwas deleted, removedfrom both
nb.jsonanden.json.i18n/fallbacks.ts(the numbersshown precisely when the API is down) and
services/categories.tsare checked againstSeedData.csandCategories.csbytests/seedDrift.test.ts, the same waytests/emergency.test.tsalready pins the acute strip.axe.test.tsx, across all six routes in both themes — axe'sown
page-has-heading-oneonly checks that at least one exists, and exactly one is theinvariant 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
FilterBaris fully rendered — measured at 430 px of real content againstthe 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
npx vitest run— 151/151 (29 files). 149/149 (28 files) before the final fix wave,which added the two
seedDrift.test.tscases. Lazy-loadingListPageandResourceDetailbehindSuspenserequired zero test migrations: everytest that renders
<App />and reaches/sokor a resource detail already awaits an asyncfindBy…/waitForas its first assertion, before any synchronousgetBy…— checked file byfile (
list.test.tsx,axe.test.tsx,errorRecovery.test.tsx,landing.test.tsx,shell.test.tsx);detail.test.tsxrendersResourceDetaildirectly, never throughApp.Header.tsx,ListPage.tsx, andLoadingState.tsx, and updated one existing assertion inlist.test.tsxfor the loadingheading'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 fixreport.
dotnet test— 85/85, untouched by this task and by the fix wave (nothing underapi/was modified; the new drift test only reads from it).npx tsc --noEmit— clean.npx biome ci .— clean (this task also fixedweb/scripts/sync-fonts.mjs's pre-existingformatting violation, the only file outside its own scope it touched).
npm run build— succeeds; chunk sizes above.After merge
GET /api/resources?category=nodtjenestershows rows 23-25(Brannvesen 110, Politi 112, Ambulanse 113).
/fonts/on the deployed site.docs/superpowers/specs/2026-09-08-varde-redesign-design.md.