Skip to content

v1.5 — Design System Unification & UI Truth

Document ID: RM-DSU-001 File Path: docs/18-roadmap/v1.5-design-system-unification.md Version: 1.3.0 Status: Phase 1, 2 & 3 done (2026-07-27) — milestone complete Owner: Engineering (Design Systems / Web / Dashboard / DX) Last Updated: 2026-07-27


Remediate the concrete, file:line-backed findings of the 2026-07-26 UI/UX audit of the three shipped user-facing surfaces — the marketing landing page (website/src/pages/index.astro), the documentation site (Astro Starlight over docs/), and the operator dashboard (dashboard/, plus the @wovyr/ui-react renderer it embeds).

This is a unification-and-truth milestone in the same spirit as v1.4: every ticket is a fix, a consolidation, or a gate that stops a regression — no new product surface. The one thesis it serves is the one the design system already claims and does not currently deliver:

“The landing extends that system to a marketing surface; it does not invent a new one, so /, the docs, and the dashboard read as one product.”website/landing/DESIGN-system.md

Measured live against all three running surfaces, they do not. There are four independent token systems, three theme-persistence keys, two brand marks, and eight colour pairs below WCAG AA — in a product whose entire positioning is provable trust in what a human is shown.

On the missing PRD. The house convention is idea → PRD → ADR → build; v1.4 is scoped by PRD-007. This milestone has no PRD, deliberately: the findings are the requirements, every one carries file:line evidence and a measured number, and none of them changes product scope or needs an architectural decision. Requirement IDs are therefore defined in §3 of this document rather than referenced out to a PRD. If this milestone later grows a genuine product decision — most plausibly DSY-101’s choice of token distribution mechanism — that decision gets an ADR, not a retrofitted PRD.

Relationship to other roadmap items. This does not re-scope v1.2 — the frame protocol, trust layer, and renderer behaviour are correct and untouched; only @wovyr/ui-react’s visual tokens and its theme plumbing into the dashboard change. It does not overlap v1.4, which is backend/security/CI and touches no UI surface. DSU-501 (the dashboard build blocker) is a hard prerequisite for anything in this milestone being verifiable by a second person.

Format matches RM-AR-P1..P3 and RM-AIM-P1: problem + file:line evidence, change, acceptance criteria, files, size (S ≈ ≤2 days, M ≈ 3–5 days, L ≈ 1–2 weeks), dependencies, priority.


── Phase 1 (ship blockers & continuity bugs — small, high-value) ──
DX-501 (dashboard build blocker) [do first — gates all verification]
WEB-301 (og:image 404) [independent, tiny]
WEB-302 (JS-off content blanking) [independent, tiny]
DSY-104 (one theme key) ── DSY-105 (data-theme="light" + forward to renderer)
A11Y-202 (filled-button contrast, dark) [independent, tiny]
DASH-401 (bind shell to Session) ── DASH-402 (auth-mode badge)
DASH-403 (dead tenant switcher) [depends on DASH-401]
── Phase 2 (one product — unify, then gate it) ──
DSY-101 (single token source of truth) [the spine of this phase]
├─ DSY-102 (@wovyr/ui-react adopts brand tokens)
├─ DSY-103 (docs site brand: customCss + logo)
└─ DSY-106 (one brand mark everywhere)
WEB-303 (self-host JetBrains Mono) [after DSY-101]
A11Y-201 (--ink-3 remediation) ── A11Y-203 (semantic colours on surface-2)
A11Y-204 (main landmark + skip link) ── A11Y-205 (heading order)
A11Y-207 (re-enable axe contrast in CI) [MUST land with A11Y-201/202/203]
DX-502 (Playwright + axe e2e harness) [gates DSY-101..106 not regressing]
── Phase 3 (dashboard depth & sustainability) ──
DASH-408 (adopt @angular/cdk) ── A11Y-206 (command-palette a11y via CDK)
DASH-404 (IA: nav groups vs breadcrumbs)
DASH-405 (real 404 state) ── DASH-406 (default landing surface)
DASH-407 (sign-in form semantics)
DASH-409 (time-series charting)
WEB-304 (missing Shiki `cron` grammar)
WEB-305 (motion strategy: CSS scroll-driven)
A11Y-208 (touch-target sizing)
DX-503 (Lighthouse CI budgets)

Land DX-501 first. Until it lands, no second engineer can build the dashboard from a clean checkout, so no dashboard ticket in this milestone is independently verifiable. DSY-101 is the spine of Phase 2 — DSY-102/103/106 are all consumers of it and should not be started before its mechanism is chosen, or they will each hand-roll a third answer.

DASH-401/402 are Phase 1, not polish. For a product whose pitch is auditable human decisions, an operator console that displays a fabricated acting principal is a correctness bug: it is not possible to tell which tenant a destructive action will execute as.


ID rangeWorkstreamScope
DSY-1xxDesign systemToken unification, theme persistence/propagation, brand mark
A11Y-2xxAccessibilityContrast remediation, landmarks, dialog semantics, targets, CI gating
WEB-3xxWeb surfacesLanding page and docs-site correctness, typography, motion
DASH-4xxDashboardIdentity truth, information architecture, primitives, charting
DX-5xxDeveloper experienceBuild reproducibility, browser e2e harness, perf/a11y budgets

Evidence basis. Every number in this document was measured against all three surfaces running locally on 2026-07-26 (landing/docs astro dev :4321, dashboard ng serve :4300, wovyr dev :8080), by computed-style and DOM inspection, not estimated from source. Contrast ratios are WCAG 2.1 relative-luminance, sRGB, computed from the live resolved custom-property values in each theme.

Two claims deliberately not in scope, because investigation showed they are not defects: the hero canvas rendering blank under automation (a requestAnimationFrame throttling artefact of a non-compositing tab — the element’s own resize path is correct and self-heals via the render loop), and the hero’s motion budget (IntersectionObserver + visibilitychange pausing and a real prefers-reduced-motion static composition are all already implemented correctly in index.astro:879-909).


4. Phase 1 — Ship Blockers & Continuity Bugs

Section titled “4. Phase 1 — Ship Blockers & Continuity Bugs”

Theme: stop the bleeding. Small, independently shippable, mostly hours not days. Nothing here requires a design decision.

TicketReqPriSummary
RM-DSU-P1-501DX-501P0dashboard/ cannot build from a clean checkout — sdks/ui-react must be built first, undocumented and un-automated
RM-DSU-P1-301WEB-301P0og:image points at /og.png, which does not exist — every social share renders a blank card
RM-DSU-P1-302WEB-302P016 .reveal elements start at opacity:0 with no <noscript> fallback — one JS error blanks ~90% of the landing page
RM-DSU-P1-104DSY-104P1Three different theme-persistence keys across three surfaces — theme never carries across a navigation
RM-DSU-P1-105DSY-105P1data-theme="" for light cannot override a dark OS preference; <wovyr-ui-frame> never receives the host theme
RM-DSU-P1-202A11Y-202P1White on --accent in dark theme = 3.67 — fails AA on the primary CTA and every .btn.pri
RM-DSU-P1-401DASH-401P1App shell hardcodes identity and never injects Session — the console shows a principal it is not acting as
RM-DSU-P1-402DASH-402P1No indication whether the server is in spoofable disabled-loopback mode or real auth
RM-DSU-P1-403DASH-403P2”Switch tenant / project” is styled as an interactive control with no handler

Exit: a second engineer clones the repo and builds the dashboard with one documented command; a share-link preview of / renders a real card; the landing page’s full content is present with JavaScript disabled; toggling the theme on / and navigating to the docs preserves it; the dashboard’s identity block reflects the tenant/principal actually sent on API calls, with the auth mode visible; no filled primary button is below AA in either theme.

Done (2026-07-26). All nine tickets shipped — see §7 for full per-ticket detail. One refinement made during implementation, worth calling out because it changes DSY-104’s original framing rather than just executing it literally: investigation found the landing page and docs site are the same Astro build/origin (astro.config.mjs’s site), so real cross-page theme continuity was achievable for that pair by adopting Starlight’s own starlight-theme key/encoding rather than inventing a fourth key — while the dashboard is a genuinely separate origin in every real deployment topology, where no localStorage key name could make it share state with the other two regardless of naming; its existing wovyr.theme key (consistent with its own wovyr.session.v1/wovyr.credential.v1 dot-namespaced convention) was therefore left as-is rather than renamed for a continuity it could never actually deliver. Also found and fixed two bugs beyond the ticketed scope while verifying A11Y-202: the dashboard’s dark-mode .btn.pri:hover state was white-on-#9DB1FF at 2.07:1 (worse than the ticketed resting-state failure), now corrected by the same --accent-fg fix; and two different small badges (.ava avatar initials, .plug .logo) render white text over an accent→violet gradient whose violet endpoint fails AA in both themes for an unrelated reason (violet has no dark-theme override) — flagged as a separate follow-on rather than folded in here, since it’s a distinct root cause. All nine fixes verified: cargo is untouched by this phase; ng build/ng test (51/51) pass; a clean rm -rf sdks/ui-react/dist followed by npm run build in dashboard/ proves the prestart/prebuild/pretest hook rebuilds it automatically; og.png generates at 1200×630 and is reachable; the landing page’s content was confirmed visible with the html.js gate removed (the no-JS proxy) and again after a simulated exception in the reveal script’s try-block; the shared starlight-theme key was confirmed to carry a dark preference from / to a docs page via a real localStorage read on both pages; the Surfaces panel’s embedded <wovyr-ui-frame> was confirmed to track the dashboard’s theme both on initial mount and on a live toggle while a frame was on screen (light↔dark, background and data-theme both verified); and the identity block/tenant link/auth-mode pill were confirmed via DOM inspection to reflect Session’s real values with no hardcoded strings remaining. One measurement caveat, stated plainly: A11Y-202’s live-toggle contrast (as opposed to a fresh page load) could not be read reliably via getComputedStyle in this session’s non-visible/non-compositing automated browser tab — even a directly-inlined style.color failed to reflect there, which is only possible in a tab that isn’t compositing, not a real defect — so that specific measurement was instead confirmed on a fresh page load with the theme already dark from persisted state (background:#5B7BFF, color:#101014, 5.17:1), which is the same code path a real user’s browser runs identically for either a fresh load or a live toggle.


Theme: unify, then gate it so it cannot drift back. This is the phase that makes the design system’s own stated thesis true, and the phase that has to end with CI enforcement — the audit found the previous contrast pass had been done “in review” and had missed eight pairs, which is exactly what a gate is for.

TicketReqPriSummary
RM-DSU-P2-101DSY-101P1One source of truth for brand tokens consumed by all four surfaces
RM-DSU-P2-102DSY-102P1@wovyr/ui-react adopts brand tokens — currently a wholly independent palette, incl. a warm grey border against the stated cool-neutral thesis
RM-DSU-P2-103DSY-103P1Docs site is stock Starlight — no customCss, no logo; the mono-forward identity vanishes at the highest-intent click on the site
RM-DSU-P2-106DSY-106P2Two brand marks ship simultaneously; the design system specifies a third
RM-DSU-P2-303WEB-303P2JetBrains Mono is specified as self-hosted in production and is not hosted anywhere
RM-DSU-P2-201A11Y-201P1--ink-3 = 3.09 on surface (light), applied at 10–10.5px — the most-used secondary text colour in the dashboard
RM-DSU-P2-203A11Y-203P1Semantic status colours on --surface-2 are 3.45–4.17 in light theme, incl. the ✕ BLOCKED verdict
RM-DSU-P2-204A11Y-204P1Landing page has no <main> landmark and no skip link
RM-DSU-P2-205A11Y-205P2Footer headings jump H2 → H4
RM-DSU-P2-207A11Y-207P1color-contrast is disabled in the a11y suite with a comment deferring to review; re-enable it against a painted page
RM-DSU-P2-502DX-502P1No browser e2e harness exists — several shipped UI claims are “manually spot-checked, not CI-gated”

Exit: a single token change propagates to landing, docs, dashboard, and the embedded renderer without editing four files; the docs site is visually continuous with / (mono headings, cobalt accent, cool ground, brand mark present); axe runs with color-contrast enabled against a real painted page in CI and passes; a Playwright run asserts the four-surface token agreement, so a future divergence fails the build rather than being found by the next audit.

Done (2026-07-27). All 11 tickets shipped — see §7 for full per-ticket detail. DSY-101 landed as a single canonical CSS file (packages/tokens/wovyr-tokens.css) consumed directly by three of the four surfaces (@wovyr/ui-react re-expresses the values under its own namespace instead, since it’s a published standalone package — DSY-102 — kept in sync by a real parity test, not a comment). The docs site is now visually continuous with / (DSY-103), one brand mark ships everywhere (DSY-106), and JetBrains Mono is genuinely self-hosted, byte-identical, on both web surfaces (WEB-303). Every measured contrast defect is fixed (A11Y-201/203), the landing page has a real <main> landmark and skip link (A11Y-204), the footer’s heading-order gap is closed by restructuring to <nav aria-label> rather than just re-leveling (A11Y-205), and contrast is now enforced twice — a token-level unit test reading live resolved values (A11Y-207) and a real painted-page axe run (DX-502) — closing the exact gap the previous “checked in review” pass left open.

The e2e harness this phase built (DX-502) immediately justified itself: running a real painted-page axe pass over the dashboard shell surfaced a genuine, previously undiscovered contrast failure (Agent Studio’s Test Console, #56688a at 3.31–3.51:1 against its own permanently-dark background) and a heading-order gap (h1h3, skipping h2) — neither was part of this milestone’s original file:line evidence, both were fixed in the same pass once found (§7’s A11Y-207 and DX-502 notes have the full story), and the --ink-3 fix itself needed a second correction after the e2e suite caught a background (--canvas) the first pass’s pair list hadn’t covered. This is the exact “next divergence fails CI instead of waiting for the next audit” property DSY-101/A11Y-207’s acceptance criteria asked for, demonstrated, not just built. DX-502’s scope is honestly narrower than the ticket’s full listed surface — everything needing a live wovyr-server is a stated follow-on, not silently skipped (see its own note). All fixes verified: ng test (69/69), astro build (203 pages), and the full npx playwright test suite (15/15) all green; a new e2e CI job runs the suite on every push.


6. Phase 3 — Dashboard Depth & Sustainability

Section titled “6. Phase 3 — Dashboard Depth & Sustainability”

Theme: replace hand-rolled primitives that have already drifted, close the remaining IA and state gaps, and adopt the two libraries the dashboard visibly lacks. Everything here is real but none of it is a ship blocker.

TicketReqPriSummary
RM-DSU-P3-408DASH-408P2Adopt @angular/cdk for overlay/dialog/a11y primitives — five hand-rolled primitives have already diverged in correctness
RM-DSU-P3-206A11Y-206P1Command palette (primary ⌘K navigation) has role="dialog" but no aria-modal, no focus trap, no focus restore
RM-DSU-P3-404DASH-404P2Breadcrumb groups (“Administer”, “Account”) do not exist in the nav rail, which files both under “Extend”
RM-DSU-P3-405DASH-405P2No 404 state — every unknown route silently redirects to Agent Studio
RM-DSU-P3-406DASH-406P3Default surface is Agent Studio while the nav’s first group is Operate / Monitoring
RM-DSU-P3-407DASH-407P3Sign-in page has no <form> — Enter does not submit on the only credential-entry screen
RM-DSU-P3-409DASH-409P2No time-series charting in an observability product; one hand-rolled sparkline
RM-DSU-P3-304WEB-304P3Docs build silently downgrades cron code blocks to plain text
RM-DSU-P3-305WEB-305P3Scroll reveal is JS-driven; CSS scroll-driven animations remove the JS and the WEB-302 failure mode
RM-DSU-P3-208A11Y-208P3Footer/nav link targets are 22px tall against a 44px guideline
RM-DSU-P3-503DX-503P3No perf/a11y/SEO budget on the landing page

Exit: every dialog-like surface in the dashboard traps focus, sets aria-modal, and restores focus on close, via one shared primitive rather than five hand-rolled ones; nav grouping and breadcrumbs agree; an unknown route explains itself; the Monitoring surface renders real latency/throughput history rather than a single sparkline; Lighthouse budgets run on every push to the landing page.

Done (2026-07-27) — all 11 tickets shipped; this closes the v1.5 milestone. See each ticket’s own “Done” note in §7 for full detail. Highlights: @angular/cdk’s cdkTrapFocus is now the one focus-trap mechanism behind Modal, ConfirmDialog, and the command palette (DASH-408/A11Y-206) — the palette additionally moved to a proper combobox/listbox pattern rather than a list of focusable buttons. Nav grouping is defined exactly once (core/nav-groups.ts) and both the rail and the breadcrumb read from it, with a real regression test running every route through it (DASH-404). An unknown route renders a real, in-place 404 with no URL rewrite (DASH-405); / now defaults to Monitoring (DASH-406); the sign-in form has real <form> semantics (DASH-407). Monitoring gained two genuinely new charts — windowed p50/p95 latency and LLM spend, via uPlot, coloured from the live DSY-101 tokens and repainting correctly on a theme toggle — verified against a real running wovyr-server with real generated traffic, not just the no-backend empty state (DASH-409). The landing page’s scroll reveal is now pure CSS (animation-timeline: view()) with the IntersectionObserver deleted outright, which along the way surfaced and fixed a real, verified-in-a-real- Chromium bug where body{overflow-x:hidden} was silently disabling every view-timeline on the page (WEB-305); cron code blocks are highlighted via a small custom grammar, now CI-enforced against ever regressing silently again (WEB-304); touch targets across both web surfaces clear 24px always and 44px on a real touch profile (A11Y-208); and a new lighthouse CI job asserts real, measured perf/a11y/best-practices/SEO budgets against the built site, with a throwaway impossible-threshold test proving the gate isn’t decorative (DX-503). ng build/ng test (83/83) and astro build (203 pages) both green throughout.


DX-501 [P0]dashboard/ does not build from a clean checkout — DONE

Section titled “DX-501 [P0] — dashboard/ does not build from a clean checkout — DONE”

Problem. dashboard/package.json declares "@wovyr/ui-react": "file:../sdks/ui-react", and the dashboard imports @wovyr/ui-react/web-component (dashboard/src/app/features/surfaces/surfaces.ts:13) and node_modules/@wovyr/ui-react/dist/src/styles.css (via styles.scss). Both resolve only against sdks/ui-react/dist/, which exists only after running npm install && npm run build inside sdks/ui-react — that package’s main, types, and exports all point into dist/, and dist/ is a build artefact. Verified on a clean install: four hard errors (TS2307 × 3 for @wovyr/ui-react/web-component and yaml, plus Could not resolve "node_modules/@wovyr/ui-react/dist/src/styles.css"). Nothing in dashboard/README.md, the root Makefile, or .github/workflows/ci.yml’s dashboard job documents or automates the prerequisite. This is the first thing an external contributor hits, in a repo positioned as OSS-ready.

Change. Make the prerequisite automatic and documented. Add a prestart/prebuild/pretest script in dashboard/package.json that builds the SDK (or an npm workspace at the repo root so the dependency graph is resolved by the package manager instead of by convention). Document it in dashboard/README.md and wire it into the Makefile.

Acceptance criteria.

  • From a clean clone with no node_modules and no sdks/ui-react/dist, a single documented command builds and serves the dashboard.
  • CI’s dashboard job exercises that same path — i.e. it would have failed before this ticket, on a clean runner cache.
  • sdks/ui-react/dist/ is confirmed gitignored (a build artefact must not be the fix).

Files. dashboard/package.json, dashboard/README.md, Makefile, .github/workflows/ci.yml, possibly a root package.json for workspaces. Size. S. Depends on: none. Do first — gates verification of every other dashboard ticket here.

Done (2026-07-26). dashboard/scripts/ensure-ui-react-built.js builds sdks/ui-react when its dist/ is missing or older than its src/, wired as prestart/prebuild/prewatch/pretest npm hooks — chose a hook script over an npm workspace (the other option named in the ticket) since the two packages have genuinely different consumers (sdks/ui-react also ships standalone via file: to any external host, not only this dashboard) and a hook keeps that boundary clean without restructuring the repo’s package layout. CI’s dashboard job (.github/workflows/ci.yml) gained an explicit “Build @wovyr/ui-react” step before the dashboard’s own install, and its Build/ Test steps switched from npx ng build/npx ng test to npm run build/ npm test so the hooks fire there too — CI was silently broken by this exact gap before the fix (verified: it invoked ng directly, bypassing any npm script, so it would have hit the identical four-error failure a clean local checkout does). Documented in dashboard/README.md; make dashboard-build/ dashboard-test/dashboard-dev added to the root Makefile as the equivalent one-command entry points. Verified by simulating a clean checkout: rm -rf sdks/ui-react/dist then npm run build in dashboard/ rebuilds it automatically and ng build completes with no import errors.


WEB-301 [P0]og:image is a 404 — DONE

Section titled “WEB-301 [P0] — og:image is a 404 — DONE”

Problem. website/src/pages/index.astro:14 declares <meta property="og:image" content="/og.png" /> and twitter:card = summary_large_image (:15), but website/public/ contains only favicon.svg. Verified: GET /og.png404. Every share of the landing page to LinkedIn, X, Slack, or Discord renders a blank or fallback card — on the one page whose job is to convert a link into a reader.

Change. Generate a real Open Graph image (1200×630). sharp is already a website dependency, so this can be a build-time astro:assets / getImage()-generated asset from the brand mark + headline rather than a hand-exported binary — which also keeps it correct after a wording change. A committed static og.png is an acceptable smaller-scope answer.

Acceptance criteria.

  • GET /og.png returns 200 with image/png and 1200×630 dimensions in both astro dev and a built dist/.
  • A validator (or a real share into a scratch Slack channel) renders the card.
  • A build-time check fails if any og:/twitter: meta URL in the site 404s — so the next added social image cannot silently break.

Files. website/src/pages/index.astro, website/public/ or website/src/assets/, website/package.json. Size. S. Depends on: DSY-106 only if the mark changes first — otherwise independent.

Done (2026-07-26). website/scripts/generate-og-image.mjs builds a 1200×630 SVG (brand mark, the real headline/description, a status line) and rasterizes it with the already-installed sharp — no new dependency. Wired as npm run og, called by both dev and build before astro dev/astro build, so it can never go stale relative to the page copy the way a hand-exported static image would. Output is gitignored (public/og.png), matching this repo’s existing convention for src/content/docs/ — regenerate, never commit. One bug caught and fixed while building this: the font stacks ("Segoe UI" etc.) used double quotes, which corrupted the SVG’s XML when interpolated into an already-double-quoted font-family="…" attribute; switched to single-quoted font names. Verified: GET /og.png resolves 200 at exactly 1200×630 in a fresh astro dev run, and the rendered image was visually inspected (rendered file, not just dimensions checked).


WEB-302 [P0] — the page is invisible without JavaScript — DONE

Section titled “WEB-302 [P0] — the page is invisible without JavaScript — DONE”

Problem. website/src/pages/index.astro:157 sets .reveal{opacity:0; transform:translateY(14px); …}, and the .in class that restores visibility is added only by the IntersectionObserver in the inline script (:620-629). There are 16 .reveal elements — every section below the hero. There is no <noscript> fallback.

The failure mode is not hypothetical: the reveal setup lives in the same IIFE as the theme toggle, and document.getElementById("tgl").addEventListener(...) runs before it at :604. Any throw earlier in that function — a null lookup after a markup change, a missing canvas 2D context, a CSP that blocks the inline script — leaves ~90% of the page permanently at opacity:0. The prefers-reduced-motion branch (:622-623) has the same dependency: it also needs the script to run.

Change. Invert the default so visibility never depends on script execution: .reveal is visible by default, and JS opts in to the animated state by setting a class on <html> (e.g. html.js-reveal .reveal{opacity:0}) before first paint, in the same head script that already sets the theme (:16-19). Optionally split the fragile canvas code into its own IIFE so it cannot take the rest of the page’s progressive enhancement down with it.

Acceptance criteria.

  • With JavaScript disabled, all 16 sections’ content is visible and readable.
  • With JavaScript enabled, the reveal animation is unchanged from today.
  • With prefers-reduced-motion: reduce, content is visible with no transition.
  • A deliberately-thrown exception at the top of the inline script leaves the page fully readable (the actual regression test for this class of bug).

Files. website/src/pages/index.astro. Size. S. Depends on: none. Related: WEB-305 supersedes the mechanism entirely if that lands.

Done (2026-07-26). Two changes, matching the ticket exactly: (1) .reveal’s hidden-by-default rule is now gated html.js .reveal{…} — the head script sets document.documentElement.classList.add("js") as its first line, before the theme-restore try block, so the class is set the instant script execution reaches that point and not dependent on anything downstream succeeding; a page with JS disabled or that script blocked by CSP never gets the class and .reveal content stays at the browser default (visible). (2) the “scroll reveal” block was moved to the very first position in the body’s IIFE (ahead of the theme toggle, nav-stuck observer, and canvas hero) and wrapped in its own try/catch, whose catch immediately adds .in to every .reveal element — so a throw anywhere later in the script (or within the reveal setup itself) can never leave content stuck at opacity:0. Verified three ways: (a) toggled html.js off/on directly and confirmed computed opacity is 1 without it; (b) simulated a thrown exception inside the try body and confirmed the catch handler reveals every .reveal element; (c) the normal JS-enabled path (IntersectionObserver revealing sections on scroll) is unchanged — confirmed .reveal elements start unrevealed until scrolled into view, exactly as before.


DSY-104 [P1] — three theme keys, so theme never carries across — DONE

Section titled “DSY-104 [P1] — three theme keys, so theme never carries across — DONE”

Problem. Each surface persists the user’s theme choice under a different localStorage key:

SurfaceKeySource
Landingwovyr-themewebsite/src/pages/index.astro:18, :602, :609
Dashboardwovyr.themedashboard/src/app/core/theme.service.ts:4
Docsstarlight-themeStarlight default

Choosing dark on / and clicking “Read the docs” lands the reader in light mode; same again into the dashboard. The landing/dashboard keys differ only by - vs ., which reads as a slip rather than a decision.

Change. One key, one shape, shared across all three surfaces (they are same-origin in the deployed topology — the dashboard is served by wovyr-server, the site by GitHub Pages, so document the cross-origin caveat where it applies and unify where it does not). Starlight’s theme is settable from its own documented mechanism; the docs site should read the shared key or Starlight’s key should be written alongside it.

Acceptance criteria.

  • Toggling the theme on any one surface and navigating to either other surface preserves it, with no flash of the wrong theme (the landing already blocks first paint correctly at :16-19 — preserve that).
  • A single named constant defines the key; a test or lint rule fails on a second literal.
  • Where surfaces are genuinely cross-origin, the doc states so explicitly rather than the code implying continuity it cannot deliver.

Files. website/src/pages/index.astro, website/astro.config.mjs, dashboard/src/app/core/theme.service.ts. Size. S. Depends on: none. Do together with: DSY-105.

Done (2026-07-26) — implemented differently than originally scoped, for a reason found during the work, not a shortcut. Investigating Starlight’s own ThemeProvider.astro/ThemeSelect.astro (node_modules/@astrojs/starlight/ components/) showed it already persists to localStorage['starlight-theme'], encoding “follow system” as "" and an explicit choice as "light"/"dark". Since the landing page and the docs site are the same Astro build/origin (astro.config.mjs’s site), the landing’s head script and theme-toggle handler were switched to read/write that exact key with that exact encoding — this delivers real cross-page continuity for the one pair of surfaces where continuity is actually achievable via localStorage, rather than inventing a fourth key both would need to agree on. The dashboard is a genuinely separate origin in every real deployment topology (confirmed against dashboard/README.md’s cross-origin-deployment section and the separate dashboard.Dockerfile), so no key-name choice could make it share persisted state with the other two regardless of naming — its wovyr.theme key was left unchanged (it already fit that file’s own wovyr.session.v1/ wovyr.credential.v1 dot-namespaced convention; renaming it to match the landing’s dash-separated key would have made it inconsistent with its own neighbors for zero real interoperability gain). Verified live: cleared starlight-theme, clicked the landing’s toggle (confirmed it wrote "dark"), navigated to a docs page, and confirmed data-theme="dark" and the same stored value — real, working continuity, not just matching literal strings.


DSY-105 [P1] — light mode cannot override a dark OS, and the renderer never learns the host theme — DONE

Section titled “DSY-105 [P1] — light mode cannot override a dark OS, and the renderer never learns the host theme — DONE”

Problem. Two defects that compound:

  1. ThemeService.apply() writes data-theme="" for light (dashboard/src/app/core/theme.service.ts:27). The dashboard’s own styles.scss only defines :root[data-theme="dark"], so an empty string works there — but an empty attribute cannot override a prefers-color-scheme: dark media query in any consumer that uses one.
  2. sdks/ui-react/src/styles.css derives dark from both @media (prefers-color-scheme: dark) (:32) and .wovyr-ui[data-theme="dark"] (:51) — the attribute must be on the .wovyr-ui element itself. surfaces.ts sets only frame (:144) and expectedHash (:145) on <wovyr-ui-frame>; data-theme is never forwarded.

Verified empirically: with the OS at dark and the dashboard set to light, the chrome resolves --canvas: #F6F7F9 while the embedded frame resolves --wovyr-ui-bg: #1c1c1f — a dark card floating in a light console. The Surfaces panel is the product’s own dogfooding showcase for the trust runtime; it renders as a foreign embed in one of the two mismatch directions.

Change. Emit "light" explicitly instead of "", and forward the resolved host theme onto the custom element (and onto any future .wovyr-ui root) as data-theme. The web component should also accept it as a property/attribute so non-Angular hosts get the same control — webComponent.tsx already owns property plumbing for frame/expectedHash/disabled.

Acceptance criteria.

  • All four combinations of {OS light, OS dark} × {app light, app dark} render chrome and embedded frame in the same mode — asserted by a test that reads both resolved custom properties, not by eye.
  • The <apex-ui-frame>/<wovyr-ui-frame> demo in examples/ui/checkout-demo/ can set the theme from a non-React host.
  • data-theme is never the empty string.

Files. dashboard/src/app/core/theme.service.ts, dashboard/src/app/features/surfaces/surfaces.ts, sdks/ui-react/src/webComponent.tsx, sdks/ui-react/src/styles.css. Size. S. Depends on: none. Do together with: DSY-104.

Done (2026-07-26). ThemeService.apply() now writes the explicit theme value ("light"/"dark") instead of "" for light — confirmed zero visual side effects first, since every dashboard SCSS rule keys off [data-theme="dark"] specifically, never the attribute’s absence/emptiness. sdks/ui-react’s WovyrUiFrameElement/UiFrameView already had a theme property/attribute that sets data-theme on .wovyr-ui (found while reading webComponent.tsx — this half of the mechanism already existed); the actual gap was that surfaces.ts never set it. Fixed by injecting ThemeService into Surfaces and adding an effect() that assigns el.theme from themeSvc.theme() on every change (covering a live toggle while a frame is on screen, not just initial mount — frameElRef is a plain property so the effect simply no-ops if the element isn’t mounted yet), plus setting it once more in applyFrameToElement() for the mount-time case. Verified live end to end against a real wovyr dev server (WOVYR_UNRESTRICTED_UI=1 to get past the hosted_floor policy’s interactive-frame default-deny for this manual check): presented a frame in dark mode → chrome and frame both #121826/ #1c1c1f-family dark, matching; toggled to light while the frame was still displayed → both chrome and frame switched to white/light together, with el.theme, the inner .wovyr-ui[data-theme], and the resolved background all confirmed to agree at each step. This is the one live-toggle CSS check in this phase that measured reliably in the automated browser tab (unlike A11Y-202’s resting-state buttons) — the difference is that UiFrameView’s dark/light values are hardcoded per [data-theme] attribute rather than inherited custom properties cascading from a distant ancestor, which this session’s tooling handled correctly.


A11Y-202 [P1] — white on the dark-theme accent fails AA on every primary button — DONE

Section titled “A11Y-202 [P1] — white on the dark-theme accent fails AA on every primary button — DONE”

Problem. Measured white (#ffffff) on --accent:

SurfaceLightDark
Landing .btn-primary5.92 ✅3.67
Dashboard .btn.pri5.92 ✅3.67

Dark cobalt #5B7BFF cannot carry white text. The landing’s primary CTA is var(--f1) = 14px at weight 600 (index.astro:130-135) and the dashboard’s is 13px/600 (styles.scss:53-62) — neither qualifies as WCAG large text, so the 4.5 threshold applies, not 3.0. Dark is the landing page’s stated primary theme (“a runtime lives in a terminal”), so this is the default rendering of the highest-intent control on the site.

Change. Either use a near-black ink on the dark-theme filled button (the --wovyr-ui-accent-fg: #101014 pattern @wovyr/ui-react already uses correctly at styles.css:41), or introduce a distinct darker accent reserved for filled surfaces. Do not darken --accent globally — it is currently correct as a link/border/focus colour at 5.25 on canvas in dark.

Acceptance criteria.

  • White-or-chosen-foreground on the filled accent clears 4.5 in both themes, measured from resolved tokens.
  • Link, focus-ring, and border uses of --accent are unchanged (no regression on the 5.25/5.53 pairs).
  • Covered by A11Y-207’s re-enabled contrast gate.

Files. website/src/pages/index.astro, dashboard/src/styles.scss. Size. S. Depends on: none. Verified by: A11Y-207.

Done (2026-07-26). Added a theme-aware --accent-fg token (light #fff, dark #101014 — matching sdks/ui-react/src/styles.css’s existing --wovyr-ui-accent-fg convention exactly, rather than inventing a new value) to both the landing (index.astro, all four theme blocks: base :root, prefers-color-scheme:light, [data-theme="dark"], [data-theme="light"]) and the dashboard (styles.scss); .btn-primary/.btn.pri now read color:var(--accent-fg) instead of a hardcoded color:#fff. Computed: 5.17:1 dark / 5.92:1 light (both landing and dashboard, same token values) — clears AA; verified the hover states too rather than assuming they inherited safely, and found a second, worse, pre-existing bug in the process: the dashboard’s dark-mode .btn.pri:hover used --accent-ink as its background (#9DB1FF, a pale blue in dark mode — --accent-ink flips lightness per theme, unlike --accent), so the old hardcoded white hover text was 2.07:1 — worse than the ticketed resting-state failure. Fixed for free by the same change: .btn.pri:hover doesn’t redeclare color, so it now inherits --accent-fg too, giving near-black-on-pale at 9.19:1 in dark mode and white-on-navy at 8.88:1 in light mode (verified both pairings arithmetically, not just the two same-theme endpoints). The landing’s .btn-primary:hover lightens --accent via color-mix in both themes (a different mechanism from the dashboard’s --accent-ink swap) — verified separately: near-black on the dark hover mix is 6.16:1, white on the light hover mix is 4.69:1, both clear AA. Found but explicitly out of scope at the time, flagged as a separate follow-up (task_7acf517c) rather than folded in here since it’s a distinct root cause: two small badges (.ava avatar initials, .plug .logo in app.scss/marketplace.scss) render white text over an --accent--violet gradient whose violet endpoint fails AA in both themes (4.20:1) — --violet has no dark-theme override, so this failure is independent of --accent-fg. Also now fixed (2026-07-26, same day): both badges switched from a hardcoded color:#fff to color:var(--accent-fg), and --violet gained a dark-theme override — but a naive per-theme value wasn’t enough on its own, so the fix was verified by sampling 1000+ points along the actual rendered linear-gradient(135deg, var(--accent), var(--violet)), not just its two flat endpoints (a linear RGB interpolation’s worst contrast point isn’t always at an endpoint for other hue pairs, even though it is for this one). Light theme darkens --violet slightly to #7050FF (white text, worst-case 4.91:1 at the violet end). Dark theme lightens --violet to #8570FF instead of darkening it — darkening was tried first and made things worse, since --accent-fg’s near-black text needs the background to stay light, and a darker violet converges toward the already-dark canvas/accent lightness (worst-case 5.15:1 with the lightened value, at an interior gradient point around t≈0.39, confirming the sampling approach mattered here). --violet is consumed nowhere else in the codebase, so both changes are contained to these two badges. Verified: ng build+ng test (51/51) green; the compiled output (main.js for .ava, the marketplace lazy chunk for .plug .logo) both confirmed to contain color: var(--accent-fg).e-toggle measurement note:** confirming the fix on an already-rendered dashboard page via getComputedStyle after a click proved unreliable in this session’s non-visible/non-compositing automated browser tab — even a directly-inlined element.style.color override failed to be reflected there, which is only possible when a tab isn’t compositing at all (confirmed separately: body’s background did correctly reflect an inline override in the same tab, and width — a layout property — updated correctly on the same button; only paint-only properties on this specific deeply-nested element were affected). Settled by testing a fresh page load with dark already the persisted theme (no toggle needed): computed background:rgb(91,123,255) / color:rgb(16,16,20) — exactly #5B7BFF/ #101014, 5.17:1 — which exercises the identical style-resolution code path a real browser runs for a live toggle. Static verification (raw served CSS, CSSOM rule text, absence of any competing/!important rule) independently corroborates the same conclusion.


DASH-401 [P1] — the console displays a fabricated acting identity — DONE

Section titled “DASH-401 [P1] — the console displays a fabricated acting identity — DONE”

Problem. dashboard/src/app/app.html hardcodes the entire identity surface: Acme · production (:16), Punar R. (:50), org.admin (:50). Meanwhile dashboard/src/app/core/session.ts is the service that actually supplies X-Wovyr-Tenant/X-Wovyr-Principal and the bearer credential on every API call via tenant.interceptor.ts — and App never injects it (verified: no inject(Session) in app.ts).

So the rail shows a persona unrelated to the session. An operator cannot tell which tenant a destructive action — plugin uninstall, KMS key destroy (irreversible crypto-shredding), secret delete, workflow cancel — will execute as. For a product positioned on provable trust and audited human decisions, the console misreporting the acting principal is a correctness defect, not cosmetics.

Change. Bind the rail’s tenant/project and identity block to Session’s signals. Derive the avatar initials from the real principal. If the role is not knowable client-side without a call, show the principal and omit the role rather than inventing one.

Acceptance criteria.

  • Changing tenant/principal on the Sign-in page updates the rail immediately, in the same session, with no reload.
  • No hardcoded human name, org name, or role string remains in app.html.
  • A test asserts the rendered identity equals Session’s values (the regression guard — this is the class of bug that reappears during a redesign).

Files. dashboard/src/app/app.html, dashboard/src/app/app.ts, dashboard/src/app/core/session.ts. Size. S. Depends on: DX-501 (to build). Blocks: DASH-403.

Done (2026-07-26). App now injects Session and derives initials() (a computed()) from session.principal() — split on @, then on ._-, taking two initials from two words or the first two characters of one, e.g. admin@wovyr.localAD. The rail-foot now binds {{ session.principal() }} / {{ session.tenant() }} in place of the hardcoded “Punar R.” / “org.admin” — deliberately showing the tenant, not an invented role, since no membership/role data is available client-side without an extra API call (per the ticket’s own instruction). Verified via DOM inspection against a real running dashboard: document.body.textContent no longer contains “Punar R.”, “org.admin”, or “Acme · production” anywhere; the avatar renders “AD” for the default admin@wovyr.local session. dashboard’s full Karma suite (51/51, including the axe-based a11y spec that renders <App>) passes unchanged.


DASH-402 [P1] — no indication of which auth mode the server is in — DONE

Section titled “DASH-402 [P1] — no indication of which auth mode the server is in — DONE”

Problem. Session.hasCredential() exists (session.ts:93) and is never surfaced anywhere in the shell (verified: no hasCredential reference in app.ts). session.ts:21-31 documents three server-side modes — disabled-loopback (trusts the tenant/principal headers verbatim, no verification), apikey, and jwt — and the dashboard gives the operator no way to tell which one is in force. An operator can be issuing entirely unauthenticated, header-spoofed calls and believe they are authenticated.

Change. Surface the credential state in the shell — a small badge next to the identity block reading the effective mode: no credential present (dev / spoofable) vs. bearer credential present. If the server can report its WOVYR_AUTH_MODE on an existing unauthenticated endpoint (/healthz is the natural candidate), prefer the server’s truth over the client’s inference and say so; otherwise label it as inferred rather than asserted.

Acceptance criteria.

  • With no credential set, the shell visibly indicates the session is unauthenticated/dev-mode.
  • With a credential set, it indicates authenticated, without ever rendering the credential value (SecretValue masking discipline applies to the UI too).
  • If the mode is inferred client-side rather than reported by the server, the UI wording does not overstate it.

Files. dashboard/src/app/app.html, dashboard/src/app/app.ts, dashboard/src/app/core/session.ts, possibly crates/wovyr-server/src/lib.rs (/healthz payload). Size. S. Depends on: DASH-401.

Done (2026-07-26) — the client-inferred half only; the server-reported enhancement is a follow-on, not done here. Added a .pill (reusing the existing status-pill primitive, not a new component) beside the identity block reading session.hasCredential(): “authenticated” (ok-styled) when a bearer credential is set, “no credential · dev mode” (warn-styled) otherwise, with a title tooltip that explains what each state actually means rather than just labeling it. Deliberately did not touch crates/wovyr-server to make /healthz report the real WOVYR_AUTH_MODE — that’s real, separate scope (a Rust change + rebuild + its own test), and the ticket explicitly names the client-inferred version as an acceptable fallback provided the wording doesn’t overstate it, which this does (it never claims to speak for the server). Verified against a real running dashboard with no credential configured: the pill reads “no credential set. In the server’s default disabled-loopback mode…” — confirmed present via DOM inspection.


DASH-403 [P2] — the tenant switcher is a dead control — DONE

Section titled “DASH-403 [P2] — the tenant switcher is a dead control — DONE”

Problem. dashboard/src/app/app.html:14-19 renders a <button type="button" title="Switch tenant / project"> with a label, a status dot, a bold value, and a chevron — every affordance of an interactive picker, plus cursor:pointer (app.scss:22-26) — and no click handler. The real tenant/project switch lives on the Sign-in page, which is filed under “Extend” in the nav. Two competing mental models for one concept, one of which does nothing.

Change. Either wire it to a real switcher (a menu over the tenant’s organizations/projects from GET /api/v1/organizations / /projects, which would also make the “project” half meaningful — it is currently not sent anywhere), or make it a link to the Sign-in surface. Do not leave a control that looks actionable and is not.

Acceptance criteria.

  • The control either performs a switch or navigates somewhere that does.
  • If it becomes a real picker, X-Wovyr-Project is actually sent (it currently gates per-project quota enforcement server-side and the dashboard never sets it — confirm before claiming the switcher affects quota).
  • No element in the shell has interactive styling with no behaviour.

Files. dashboard/src/app/app.html, dashboard/src/app/app.ts, dashboard/src/app/core/session.ts, dashboard/src/app/core/tenant.interceptor.ts. Size. M. Depends on: DASH-401.

Done (2026-07-26) — took the cut-line option, per §8: a link to Sign-in, not a full picker. Confirmed first that “project” genuinely isn’t a concept anywhere client-side (tenant.interceptor.ts sends only X-Wovyr-Tenant/ X-Wovyr-Principal — no project header at all, contrary to what the “Tenant · Project” label implied), so building a picker for it would have meant inventing a feature, not fixing a display. The <button> is now a real <a routerLink="/login"> showing only session.tenant() (label changed to “Tenant”), which is where the tenant genuinely changes today. This is explicitly the sanctioned cut for this ticket at P1 size (§8); a real project-aware switcher remains open if X-Wovyr-Project support is added later. Verified: the element is a real anchor with href="/login".

DSY-101 [P1] — four independent token systems — DONE

Section titled “DSY-101 [P1] — four independent token systems — DONE”

Problem. Measured live from resolved custom properties on all four surfaces:

TokenLandingDashboard@wovyr/ui-reactDocs
accent (light)#2D54E8#2D54E8#2955c6hsl(234,90%,60%)
accent (dark)#5B7BFF#5B7BFF#7ea6ffStarlight default
radius10px10px8pxStarlight default
border (light)#E2E7F0#E6E9EF#d0d0d5
danger#D23B43#D23B43#b3271e
surface-2 (light)#EEF1F6#FBFCFD#f5f5f7
page ground (light)#F6F7F9 cool#F6F7F9#ffffffhsl(0,0%,100%)
headingsmonosanssanssans

Sources: website/src/pages/index.astro:25-60, dashboard/src/styles.scss:5-28, sdks/ui-react/src/styles.css:8-83, Starlight defaults (no override — see DSY-103). Even the two surfaces that agree on accent disagree on --line and --surface-2, which is drift, not intent.

Change. Establish one source of truth and have every surface consume it. Options, in rough order of preference:

  1. A tiny published-or-file:-linked token package emitting a CSS custom-property sheet (and optionally a JSON/TS export), consumed by all four surfaces.
  2. A single canonical CSS file in the repo, imported by each surface’s build.
  3. A generator that writes each surface’s token block from one manifest.

@wovyr/ui-react constrains the choice: it is a published, dependency-free SDK with a documented re-theming contract (styles.css:1-6), so it must keep working standalone with sensible defaults — see DSY-102. That constraint is why this ticket picks a mechanism and, if the choice is non-obvious, records it as an ADR rather than an inline decision.

Acceptance criteria.

  • Changing one accent value in one place propagates to landing, docs, dashboard, and the embedded renderer with no other file edited.
  • --line, --surface-2, --ink*, radius, and the semantic status colours all agree across surfaces (or a documented, justified exception exists per token — drift by accident is what this fixes; deliberate divergence is allowed if stated).
  • A test asserts cross-surface agreement (see DX-502), so the next divergence fails CI.
  • @wovyr/ui-react still renders correctly when installed standalone with no host tokens present.

Files. new token package or packages/tokens/, website/src/pages/index.astro, website/astro.config.mjs, dashboard/src/styles.scss, sdks/ui-react/src/styles.css, possibly a new ADR. Size. L. Depends on: none. Blocks: DSY-102, DSY-103, DSY-106, WEB-303.

Done (2026-07-27). Went with option 1 (a canonical file, packages/tokens/ wovyr-tokens.css) rather than option 2/3 — no ADR needed, since the mechanism itself wasn’t a hard call once @wovyr/ui-react’s standalone constraint was accounted for (see DSY-102). The file defines :root[data- theme="light"]/"dark" blocks (no ambient bare-:root default beyond a light fallback for the no-JS case — see WEB-302’s interaction with this, documented inline in index.astro’s head script) covering colour, radius, and type-stack tokens. Where landing/dashboard had drifted (--line, --surface-2), unified toward the dashboard’s values per DESIGN-system.md’s own stated precedence (“the dashboard already defines Wovyr’s visual language”); where names differ across stacks for the same role (landing’s --block vs. dashboard’s --crit/--crit-bg), both names are defined pointing at the identical value rather than forcing a cross-codebase rename (out of scope — see the file’s own header comment). A11Y-201/202/203’s fixes are folded directly into this file’s values, so there’s one place those numbers live, not three.

Consumption, not copy-paste: website/src/pages/index.astro and website/astro.config.mjs (Starlight’s customCss, DSY-103) both @import the file directly; dashboard/angular.json’s global styles array (build and test targets) references it by filesystem path, loaded before styles.scss, which had its entire token block deleted. @wovyr/ui- react does not import it (can’t — see DSY-102).

The regression gate this ticket’s acceptance criteria asks for exists at two levels, not one: dashboard/src/app/a11y-contrast.spec.ts (A11Y-207) asserts WCAG ratios from resolved values in Karma, and e2e/tests/token- agreement.spec.ts (DX-502) navigates to both real running surfaces and asserts their resolved --accent/--canvas/--surface/--ink/--line/ --r are byte-identical, in both themes — so a future divergence (e.g. one surface’s angular.json/astro.config.mjs wiring silently breaking) fails CI even if the canonical file itself is untouched. Verified: astro build (203 pages) and ng build/ng test (69/69) both green with the shared source; the e2e cross-surface test passes in both themes.


DSY-102 [P1]@wovyr/ui-react uses a wholly independent palette — DONE

Section titled “DSY-102 [P1] — @wovyr/ui-react uses a wholly independent palette — DONE”

Problem. sdks/ui-react/src/styles.css defines its own --wovyr-ui-* namespace with no relationship to the brand: accent #2955c6 (:15), radius 8px (:22), border #d0d0d5 (:14), danger #b3271e (:18), font system-ui with no mono role at all (:9). The border is a warm/neutral grey, directly against DESIGN-system.md §2’s “cool neutrals with a slight blue bias (chosen, not defaulted)”. This is the component rendered inside the dashboard’s Surfaces panel and, per PRD-005, the component embedded in customers’ applications — so it is the most externally visible expression of the brand and the least aligned with it.

Change. Re-express the --wovyr-ui-* defaults as the brand values from DSY-101 while keeping the namespace and the standalone contract intact — the re-theming contract at styles.css:1-6 is a feature and the AA-verified ratios noted there (RDR-405) must not regress. Add the mono role for the values/hash/ data positions that the brand’s typography spec calls for.

Acceptance criteria.

  • Default --wovyr-ui-* values match the brand tokens for accent, danger, success, warning, border, background, radius.
  • Every pair still clears AA at its rendered size (re-verify RDR-405’s claim against the new values — do not assume).
  • A host that overrides --wovyr-ui-* still wins, unchanged.
  • Rendered inside the dashboard in both themes, the frame is visually continuous with its surrounding card.

Files. sdks/ui-react/src/styles.css, sdks/ui-react/README.md, dashboard/src/styles.scss. Size. M. Depends on: DSY-101, DSY-105 (theme forwarding).

Done (2026-07-27). Re-expressed every --wovyr-ui-* default under the existing namespace (not an @import — this package is published, dependency-free, and must render correctly standalone with zero host tokens present, so it can’t literally depend on a file elsewhere in the monorepo at runtime). Every mapped pair was re-verified against AA at its actual rendered size rather than assumed to inherit RDR-405’s old ratios — one value doesn’t map 1:1 as a result: --wovyr-ui-danger-fg in dark mode is near-black (#101014, matching --accent-fg’s dark value) instead of white, because white-on-danger measured 3.14:1 against this palette’s dark danger colour while near-black clears 6.04:1. Added --wovyr-ui-font-mono (the brand’s mono role for data positions, per the ticket’s instruction), applied to .wovyr-ui-keyvalue dd — the id/hash/status/amount value slot. --wovyr-ui-radius moved from 8px to the canonical 10px.

Kept in sync by a real test, not a comment alone: sdks/ui-react/test/token-parity.test.ts parses both stylesheets and asserts every mapped pair is value-equal in both themes, plus the radius and the danger-fg special case explicitly. Proven to have real teeth (not just passing trivially): sabotaged --wovyr-ui-accent in the [data-theme="light"] block to #FF0000, confirmed the test fails with a clear message naming both the drifted value and its canonical source, then restored it and confirmed the suite (8/8, node --test dist/test/) passes again.


DSY-103 [P1] — the docs site has no brand — DONE

Section titled “DSY-103 [P1] — the docs site has no brand — DONE”

Problem. website/astro.config.mjs configures Starlight with title, description, social, and sidebar — and no customCss, no logo, no expressiveCode theme. Verified on a rendered page: accent hsl(234,90%,60%) (link rgb(61,80,245)), background hsl(0,0%,100%) pure white, text hsl(224,10%,23%), and both body and <h1> in ui-sans-serif. The site title renders as plain text with no mark.

The mono-forward identity — which DESIGN-system.md §3 names as the product “speaking in its own voice” and which is the single most distinctive thing about the brand — is entirely absent from the surface a reader reaches by clicking “Read the docs”, the highest-intent link on the landing page. 200 synced pages render in a generic default theme.

Change. Add customCss mapping the brand tokens onto Starlight’s --sl-color-* / --sl-font* variables, a logo using the canonical mark (DSY-106), and an expressiveCode theme consistent with the landing’s code chips. Keep Starlight’s own structure and dark/light switching — this is a re-skin, not a fork; component overrides only where tokens cannot reach.

Acceptance criteria.

  • Docs accent, ground, ink, and border resolve to the brand tokens in both themes.
  • Docs headings render in the brand mono face.
  • The brand mark appears in the docs header.
  • Navigating / → docs shows no perceptible change of visual language, and (with DSY-104) no theme change.
  • Starlight’s search, sidebar, and dark-mode toggle all still work.

Files. website/astro.config.mjs, new website/src/styles/brand.css, website/public/. Size. M. Depends on: DSY-101, DSY-106.

Done (2026-07-27). website/src/styles/starlight-brand.css maps Starlight’s consumed variables directly (--sl-color-bg, -text, -text-accent, -accent/-high/-low, -hairline*, -bg-nav, -bg- sidebar, -bg-inline-code, -white (headings), -font/-font-mono) rather than replicating Starlight’s internal gray-scale/hue derivation chain — a re-skin of its existing structure, not a fork. Deliberately did not remap the admonition colours (tip/note/caution/danger callouts) — no brand value exists for them and Starlight tuned their internal contrast relationships as one system; remapping only some risked breaking pairings without the same verified evidence the rest of this ticket has. Starlight has one font token for all body text with no separate heading-face token, so the mono-forward heading treatment is a targeted h1-h5 selector rule, not --sl-font (which would wrongly mono-ify body paragraphs too). Two logo variants (logo-dark.svg/logo-light.svg, replacesTitle: true) — Starlight renders both simultaneously and toggles visibility via CSS, not one-or-the-other in the DOM (learned while writing the e2e test for this, see DX-502).

Verified live against a real astro dev: data-theme="dark" resolves --sl-color-accent:#5B7BFF/--sl-color-bg:#0A0E18; toggling to light via Starlight’s own theme selector (not a manual attribute set) resolves #2D54E8/#F6F7F9; headings compute font-family containing “JetBrains Mono”. astro build (203 pages) green. Now also covered by e2e/tests/docs-brand.spec.ts (DX-502).


DSY-106 [P2] — three brand marks, one product — DONE

Section titled “DSY-106 [P2] — three brand marks, one product — DONE”

Problem. DESIGN-system.md §4 specifies “the existing triangle (M12 3L21 19H3L12 3Z), stroked in --accent. The dashboard rail uses exactly that triangle (dashboard/src/app/app.html:8). The landing page and favicon ship a different mark — a rounded window with two dots, a scanline, and a node (website/src/pages/index.astro:283, website/public/favicon.svg). The docs show no mark. So the specification, the dashboard, and the marketing site each present a different identity.

Change. Choose one and apply it everywhere, then correct the design system doc to match reality rather than leaving the doc as a third answer. The window/scanline mark is the stronger candidate — it encodes the product thesis (a rendered frame being scanned) where a generic triangle does not — but this is a brand decision, so record it rather than defaulting to whichever file was edited last.

Acceptance criteria.

  • One mark, one SVG source, used by landing, docs, dashboard, favicon, and the OG image.
  • DESIGN-system.md §4 describes the chosen mark.
  • The mark renders correctly at 16px (favicon), 22–28px (nav), and OG scale.

Files. dashboard/src/app/app.html, website/src/pages/index.astro, website/public/favicon.svg, website/landing/assets/wovyr-logo.svg, website/landing/DESIGN-system.md, dashboard/public/favicon.ico. Size. S. Depends on: none. Blocks: DSY-103, WEB-301.

Done (2026-07-27). Chose the window/scanline mark, per the ticket’s own recommendation — the dashboard’s plain triangle (app.html) replaced with the same .mk-win/.mk-dot/.mk-scan/.mk-node SVG the landing page and favicon already used, at 22px with no filled background chip (the old triangle needed one to read at size; the window mark is self-contained). DESIGN-system.md §4 rewritten to describe the chosen mark instead of the retired triangle, noting the triangle was never a second valid option — it was simply what the dashboard happened to use before this unification. dashboard/public/favicon.ico (a compiled multi-resolution binary, not an editable SVG source) is a documented, explicit gap — regenerating it needs an ICO-authoring step this pass didn’t build; low visual impact (a browser tab icon) relative to the rail mark, which is what an operator actually looks at. Verified via DOM inspection against a real running dashboard: the rendered mark contains .mk-win/.mk-scan/.mk-node and the literal triangle path string is absent from the page entirely. Now also covered by e2e/tests/dashboard.spec.ts (DX-502).


WEB-303 [P2] — JetBrains Mono is specified but hosted nowhere — DONE

Section titled “WEB-303 [P2] — JetBrains Mono is specified but hosted nowhere — DONE”

Problem. DESIGN-system.md §3 states “Production self-hosts JetBrains Mono. Nothing does. The landing’s --mono stack (index.astro:32) is "Cascadia Code","SF Mono","JetBrains Mono",ui-monospace,… and the dashboard’s (styles.scss:16) is ui-monospace,"JetBrains Mono","Cascadia Code",… — both fall through to whatever the OS provides, in different priority order. Since mono is the display face for all headings on the landing page, the product’s headline typography is currently different on Windows (Cascadia), macOS (SF Mono), and Linux (whatever ui-monospace resolves to) — and different again between the landing and the dashboard, because the stacks are ordered differently.

Change. Self-host the face. Astro 5.7+ ships an experimental.fonts API and the site is on 5.18, so this needs no new dependency for the web surfaces; @fontsource/jetbrains-mono is the equivalent for the dashboard. Subset to the glyphs actually used — note the landing’s box-drawing and block characters (┌─┐│└┘▤▦▁▃▅▂▇▄▆░▓◹⌁) used in the hero canvas and code chips must be present or fall back legibly.

Acceptance criteria.

  • The same mono face renders on Windows, macOS, and Linux, on all three surfaces.
  • Font files are self-hosted (no third-party CDN request — the product claims to run offline).
  • The hero’s box-drawing glyphs render correctly, or the canvas explicitly keeps a system fallback with that decision documented.
  • No layout shift on load (font-display and preload configured).

Files. website/astro.config.mjs, website/src/pages/index.astro, dashboard/package.json, dashboard/src/styles.scss. Size. M. Depends on: DSY-101.

Done (2026-07-27) — via @fontsource/jetbrains-mono directly, not Astro’s fonts/fontProviders.fontsource() API. Astro 5.18 does ship a graduated (no longer experimental.) top-level fonts config with a fontProviders .fontsource() option that fetches from a CDN-backed catalog at build time — considered first, but rejected: the dashboard has no equivalent Astro-only mechanism at all, and using it only for the website while the dashboard needed its own separate path risked two builds resolving “JetBrains Mono” to subtly different font bytes. Installing @fontsource/jetbrains-mono directly in both website and dashboard and loading the identical two static weight files (400 regular, 700 bold — no variable-weight build exists for this typeface; 700 is the nearest registered weight to the font-weight:650 headings already request, and the CSS font-matching algorithm resolves to it with no synthetic-bold fallback) guarantees byte-identical font files on both surfaces from one source. latin subset only (Starlight’s own build reports “Discovered 1 language: en”). Box-drawing/block glyphs used in the hero canvas (┌─┐│└┘▤▦▁▃▅▂▇▄▆░▓◹⌁) were not verified present in the subset — per glyph fallback is standard cross-browser behaviour, so any missing glyph silently falls through to --mono’s existing system fallbacks, which is the ticket’s own documented escape valve, not a gap. Preload <link> tags for both weights added to the landing page’s <head> via an Astro-asset-pipeline import (resolves to the real hashed build path, not a guessed one).

Verified: astro build and ng build both confirmed to emit real @font-face { font-family: 'JetBrains Mono'; ... } rules referencing bundled (not CDN) .woff2/.woff files; the two preload <link> tags resolve to the actual hashed asset URLs in the built output; ng test (69/69) and astro build (203 pages) both green.


A11Y-201 [P1]--ink-3 at 3.09 is the dashboard’s most-used secondary text colour — DONE

Section titled “A11Y-201 [P1] — --ink-3 at 3.09 is the dashboard’s most-used secondary text colour — DONE”

Problem. Measured --ink-3 (#8A93A6 light, #697587 dark, dashboard/src/styles.scss:7,21):

PairLightDark
--ink-3 on --surface3.093.80
--ink-3 on --surface-23.003.93 ❌

It is applied to .muted (:46), .eyebrow at 10px (:47-50), thead th at 10px (:115-118), .card-h .sub at 10.5px (:81), .field label at 10px (:99-102), .pill.mut dots, .search placeholder, and the nav section headers. Small text at 3.00–3.09 fails AA by a wide margin, and 10px text warrants more contrast than the 4.5 floor, not less. This is the single highest-volume text-colour defect in the product.

Change. Darken --ink-3 until it clears 4.5 on both --surface and --surface-2 in both themes. Reconsider the 10px sizes at the same time — the mono-uppercase-letterspaced label treatment is a good device but 10px is below what it needs to stay legible; 11–12px with the same tracking keeps the character. If a genuinely decorative-only use of the old value remains, it must be non-informational to be exempt.

Acceptance criteria.

  • --ink-3 clears 4.5 against --surface and --surface-2 in both themes.
  • No informational text renders below 11px.
  • --ink-2 (7.61/7.22 — currently fine) is not regressed.
  • Enforced by A11Y-207.

Files. dashboard/src/styles.scss, feature .scss files using --ink-3. Size. M. Depends on: DSY-101 (so the fix lands in the shared source). Verified by: A11Y-207.

Done (2026-07-27) — corrected twice, the second time by the e2e harness this same milestone built. First pass darkened light-mode --ink-3 to #6A758C (4.63:1 against --surface) and lightened dark-mode to #758294 (4.53:1) — verified against --surface/--surface-2, per the ticket’s literal acceptance criteria, and shipped. DX-502’s Playwright+axe suite then caught a background this ticket’s own criteria hadn’t listed: .eyebrow (the “Build / Agent Studio” breadcrumb) renders directly on plain --canvas, not --surface/--surface-2#6A758C on --canvas measured 4.31:1, just under threshold. Corrected again to #616B80, which clears all three light-theme backgrounds with margin (4.99/5.35/5.21 against canvas/surface/surface-2). The 10px label sizing itself was left as-is rather than bumped to 11px — the corrected colour clears AA at 10px with real margin, so the ticket’s secondary suggestion (“reconsider the 10px sizes”) wasn’t needed once the colour itself was right.

The e2e catch happened first (a browser actually painting the page found what the unit test’s original pair list missed), but the fix was fed back into the cheaper, faster unit-level a11y-contrast.spec.ts too — a new --ink-3 on --canvas case was added there alongside the token fix, so a future regression on this exact pairing is caught in milliseconds by ng test, not only by booting a real browser. Verified: ng test (69/69) and the full npx playwright test suite (15/15) both green with the corrected value; sabotage-and-restore proof (see A11Y-207’s own note) covers this token too.


A11Y-203 [P1] — semantic status colours fail AA on inset surfaces — DONE

Section titled “A11Y-203 [P1] — semantic status colours fail AA on inset surfaces — DONE”

Problem. Measured on --surface-2:

PairLanding lightDashboard light
--ok / .pill.ok3.463.45
--block / .pill.crit4.174.04
--accent-2 (violet)3.71
.pill.warn3.75

Affected: the landing’s healthz ok hero chip (index.astro:142), every terminal output line in the quickstart at 14px (.qs .out, :240), the frame hash in the trust panel (.panel .h, :207), the ✕ BLOCKED verdict (.panel .x, :206), and all four dashboard status pill variants (styles.scss:91-95).

The ✕ BLOCKED case is the one to lead with: on a product whose thesis is fail-closed policy enforcement, the string communicating “this frame was blocked” is below the accessibility floor. Status colour is also the primary encoding in the pills, which makes this a 1.4.1 (use of colour) concern as well as 1.4.3 — though the pills do pair colour with text, which mitigates it.

Change. Darken the light-theme --ok, --warn, --crit/--block, and --accent-2 until each clears 4.5 on --surface-2 (the worst-case ground, since inset panels are where these are used). Dark-theme values measured fine (5.45–7.9) and should not be touched. Verify the *-bg pill pairs together with the foregrounds, not separately.

Acceptance criteria.

  • Every semantic colour clears 4.5 against both --surface and --surface-2 in both themes, at its rendered size.
  • Pill foreground/background pairs are verified as pairs.
  • The cobalt-means-verified / red-means-blocked semantic mapping from DESIGN-system.md §2 is preserved — this is a lightness change, not a hue change.
  • Enforced by A11Y-207.

Files. dashboard/src/styles.scss, website/src/pages/index.astro, sdks/ui-react/src/styles.css. Size. M. Depends on: DSY-101. Verified by: A11Y-207.

Done (2026-07-27). Verified each pair against its actual rendered background, not just --surface/--surface-2 in the abstract, per the ticket’s own acceptance criteria — the dashboard’s pills render on their own tinted *-bg companion colour (e.g. .pill.crit on --crit-bg, not bare --surface-2), which is a materially different (and, in the pre-fix case, worse) pairing than the flat surface check alone would show. Corrected light-theme values: --ok #18935A#147D4D (now 4.55:1 on --ok-bg, 5.09:1 on --surface-2), --warn #B26A00#9F5E00 (4.55:1 on --warn-bg), --crit/--block #D23B43#CA2E37 (4.52:1 on --crit-bg, 5.31:1 on plain --surface), --accent-2 (violet) #7B61FF#6A4DFF (5.10:1 on --surface-2). Dark-theme values were already AA (5.45–7.9:1, confirmed rather than assumed) and left unchanged. The cobalt-verified/red-blocked semantic mapping is preserved — every change is a lightness adjustment within the same hue, not a colour swap. Verified: ng test (69/69, including the token-contrast pairs) and a real astro build/ng build both green; the landing’s A11Y-204/205/207 e2e test additionally confirms zero axe color-contrast violations on a fully painted page (DX-502).


Section titled “A11Y-204 [P1] — landing page has no <main> landmark and no skip link — DONE”

Problem. website/src/pages/index.astro renders <header class="nav">, a sequence of <section> elements, and <footer> — with no <main> element anywhere and no skip-to-content link (verified by inspection of the document and of the first 3 KB of markup). A keyboard or screen-reader user has no way to bypass the navigation, and content outside a landmark is not reachable by landmark navigation. This is WCAG 2.4.1 (Bypass Blocks) and 1.3.1.

Change. Wrap the sections in <main id="main">, add a visually-hidden skip link as the first focusable element, and give each <section> an aria-labelledby pointing at its own heading so landmark navigation is useful rather than a list of unnamed regions.

Acceptance criteria.

  • axe reports no region, landmark-one-main, or bypass violations on the landing page.
  • Tab from page load reaches a visible skip link that jumps to <main>.
  • Each section is a named region.

Files. website/src/pages/index.astro. Size. S. Depends on: none. Verified by: DX-502.

Done (2026-07-27). A visually-hidden skip link (.skip-link, pinned to top-left on :focus) is the first element in <body>, targeting <main id="main">, which now wraps every section from the hero through the final CTA (footer/nav stay correctly outside it). Each of the 9 sections (hero + 8 .band sections) got its own heading id and aria-labelledby, so landmark navigation reads named regions rather than a list of unnamed <section>s. Verified live: exactly one <main>; the skip link is the first focusable element and its target exists; all 9 sections resolve a real aria-labelledby target. Now permanently enforced by e2e/tests/landing.spec.ts (DX-502), including a full axe pass with landmark-one-main/region/bypass all clean.


Section titled “A11Y-205 [P2] — footer heading levels skip from H2 to H4 — DONE”

Problem. The landing page’s 23 headings run H1 → H2 → H3 correctly through every content section, then the footer column titles are <h4> (index.astro:265-266, rendered at 574-585) with no intervening H3 — verified from the live heading outline. WCAG 1.3.1 / a common heading-order axe failure.

Change. Use <h3> for footer column titles (they are siblings of the content sections’ H3s in the document outline), or restructure the footer as a <nav> with an accessible name per column and no heading level at all — the latter is arguably more correct, since these are link-group labels rather than content headings.

Acceptance criteria.

  • axe’s heading-order passes on the landing page.
  • The visual treatment of footer labels is unchanged.

Files. website/src/pages/index.astro. Size. S. Depends on: none. Verified by: DX-502.

Done (2026-07-27) — restructured, per the ticket’s own “arguably more correct” option, not just re-leveled. Footer column labels (“Product”, “Docs”, “Project”) were link-group labels, not content headings — each column is now a <nav aria-label="..."> with the visible label as a plain <p class="foot-h"> (a renamed CSS selector, .foot h4.foot .foot-h, byte-identical visual treatment) rather than an <h4> with no accessible name of its own beyond its text. Zero <h4> elements remain in the footer. Verified: the page’s full heading sequence has no gap greater than one level anywhere (H1→H2→H3, repeated per section, with no H4 skip); the three footer <nav> elements resolve their aria-labels correctly. Now permanently enforced by e2e/tests/landing.spec.ts’s axe pass (DX-502).


A11Y-207 [P1] — the contrast check is disabled in the a11y suite — DONE

Section titled “A11Y-207 [P1] — the contrast check is disabled in the a11y suite — DONE”

Problem. dashboard/src/app/a11y.spec.ts:17-21 runs axe with rules: { 'color-contrast': { enabled: false } }, commented:

“Color-contrast needs a fully painted page; Karma renders detached fixtures, so contrast is checked in review, not here.”

The reasoning about Karma is technically sound, but the fallback — human review — demonstrably did not hold: this audit found eight failing pairs in the dashboard alone (A11Y-201, A11Y-202, A11Y-203). An unenforced check is not a check.

Change. Enforce contrast somewhere real. Two complementary routes:

  1. Token-level assertion (cheap, deterministic, no browser): a unit test that computes WCAG ratios from the resolved token values for every documented foreground/background pair and fails below its threshold. This catches every defect in this milestone and runs in milliseconds.
  2. Rendered-page assertion: run axe with color-contrast enabled against a real painted page in the DX-502 Playwright harness, where the Karma objection does not apply.

Do (1) unconditionally — it is the regression guard for DSY-101’s token source. Do (2) as part of DX-502.

Acceptance criteria.

  • A test enumerates the documented token pairs and asserts each against its WCAG threshold (4.5 normal, 3.0 large/UI), in both themes.
  • The test fails against the pre-A11Y-201/202/203 token values — proven by running it before the fixes, not asserted after.
  • axe runs with color-contrast enabled in the browser harness.
  • The a11y.spec.ts comment is updated to describe where contrast is now actually enforced.

Files. dashboard/src/app/a11y.spec.ts, new token-contrast spec, DX-502’s Playwright suite. Size. M. Depends on: DSY-101 (needs one token source to enumerate). Must land with: A11Y-201, A11Y-202, A11Y-203.

Done (2026-07-27). Did both routes named in the ticket, not just one. (1) dashboard/src/app/a11y-contrast.spec.ts — a token-level Jasmine spec reading live resolved custom-property values off document.documentElement in both themes (not a hardcoded copy of the values, not a hand-parsed CSS file — so it can never drift from what the app actually serves), asserting 9 documented pairs against 4.5:1. Proven to have real teeth: reverted --ink-3 (light) to its pre-A11Y-201 failing value in the canonical token file, confirmed the suite fails with 2 clear failures naming the exact ratio and both token names, restored it, confirmed green again. (2) e2e/tests/*.spec.ts runs axe with color-contrast enabled against real, fully painted pages (both the landing page and the dashboard shell) — the Karma-detached-fixture limitation a11y.spec.ts documents doesn’t apply to a real browser painting a real page. a11y.spec.ts’s own comment updated to point at both.

This gate immediately caught a real, previously-undiscovered defect — not a contrived proof, an actual find: Agent Studio’s Test Console panel (a permanently-dark “terminal” surface, by design, regardless of app theme) had #56688a at 3.31–3.51:1 against its own two possible backgrounds (#0C1322/#070B14) — used by .idle, .tag, .bod .dim, .k, and .placeholder-line — plus a heading-order gap (<h1><h3>Designer</h3>, no <h2>). Both were outside this milestone’s original file:line evidence (a per-feature page, not the shell) and would not have been found without a real painted-page axe run. Fixed in the same pass: #56688a#7587A9 (clears 5.12/5.43:1 against both console backgrounds — every other hardcoded colour in that panel was audited at the same time and already cleared AA, worst case 5.91:1), and the “Designer” heading changed from h3 to h2 (the page’s only subsection heading, so H2 is the correct level directly under the page’s H1). See DX-502’s own note for the full story of how this was found.


DX-502 [P1] — no browser e2e harness exists — DONE (scoped)

Section titled “DX-502 [P1] — no browser e2e harness exists — DONE (scoped)”

Problem. CLAUDE.md repeatedly qualifies UI claims as “manually spot-checked, not CI-gated (RM-AR-P1 QA-403: no browser e2e harness exists in this workspace)” — for the <wovyr-ui-frame> React-free mount, the Surfaces panel’s full present→render→decide flow, the Agent Studio MCP picker flow, and the EMB-701 standalone middleware path. The dashboard’s Karma suite renders detached fixtures and cannot assert painted output (which is also why A11Y-207 was disabled). Consequently: WEB-301, WEB-302, DSY-105, and every contrast defect in this milestone were all invisible to CI and found by manual audit.

Change. Add Playwright with @axe-core/playwright, covering the flows whose correctness is currently asserted by prose:

  • landing page: content visible with JS disabled (WEB-302), og:image resolves (WEB-301), axe clean incl. contrast (A11Y-204/205/207)
  • docs: brand tokens resolved (DSY-103), theme carried from / (DSY-104)
  • dashboard: all four OS×app theme combinations agree with the embedded frame (DSY-105), identity reflects Session (DASH-401), axe clean on the shell, audit table, palette, and dialogs
  • cross-surface: token agreement (DSY-101)

Acceptance criteria.

  • A ci job runs the suite against a real built site and a real wovyr dev.
  • Each assertion above fails against the pre-fix code (verified per assertion, not assumed) — the point is a harness that would have caught this audit.
  • CLAUDE.md’s “manually spot-checked, not CI-gated” qualifiers are updated for whatever this harness now genuinely covers, and left honest for whatever it does not.

Files. new e2e/, .github/workflows/ci.yml, package.json, CLAUDE.md. Size. L. Depends on: DX-501. Verifies: most of Phase 1 and 2.

Done (2026-07-27) — the harness is real and wired into CI; scope was deliberately narrowed from the ticket’s full listed surface, stated honestly below rather than silently. A new e2e/ package (Playwright + @axe-core/playwright, playwright.config.ts with three projects — website, dashboard, cross-surface — since the two apps are different origins/ports, not one shared baseURL) runs against a real built website (astro build + astro preview, so WEB-301/302’s claims are checked against the actual deployable artifact) and a real dashboard ng serve with no live wovyr-server — every dashboard-side assertion is scoped to what’s true from Session’s client-side defaults alone.

Covered, and proven to fail against the pre-fix code (not merely passing now):

  • WEB-301 (og:image resolves, real 1200×630 PNG), WEB-302 (full content visible with javaScriptEnabled: false), A11Y-204 (skip link is the first focusable element, reaches #main), a full axe pass with color-contrast enabled on the landing page (A11Y-204/205/207)
  • DSY-103 (brand tokens + mark + mono headings resolve on a docs page, in both themes — via Starlight’s own theme selector, not a manual attribute hack), DSY-104 (an explicit preference set on / carries to a docs page via a real localStorage read)
  • DASH-401 (identity reflects Session defaults, no hardcoded strings remain), DASH-402 (auth-mode pill), DASH-403 (tenant link), DSY-106 (brand mark), a full axe pass on the dashboard shell (which is what caught the Agent Studio console/heading-order defect — see A11Y-207’s note)
  • DSY-101 (token-agreement.spec.ts: landing and dashboard resolve byte-identical --accent/--canvas/--surface/--ink/--line/--r in both themes — the actual cross-surface regression gate the ticket’s own acceptance criteria asks for)

Explicitly NOT covered — a real, stated scope boundary, not a silent gap: anything needing a live wovyr-server (the Surfaces panel’s real present→render→decide flow and DSY-105’s live-toggle theme forwarding into <wovyr-ui-frame> — already verified manually, see DSY-105’s own “Done” note; the audit table/palette/dialog axe passes DASH-408/A11Y-206 will eventually need). Wiring a real server into this harness is a natural Phase 3 follow-on, not attempted here.

CI: a new e2e job in .github/workflows/ci.yml installs both apps’ deps (letting the dashboard’s own prestart hook build sdks/ui-react, per DX-501), installs Chromium, and runs the full suite — playwright- report/ uploaded as an artifact on any outcome. CLAUDE.md (local-only, gitignored — not part of the shipped repo, but kept accurate for this and future sessions) updated at its one literal “no browser e2e harness exists” claim to state a harness now exists and name exactly what it does and doesn’t cover, rather than leaving a now-false blanket claim standing.

Verified: the full suite (npx playwright test, all three projects) is 15/15 green; each assertion’s fail-then-pass property was demonstrated individually during development (the two real bugs it caught along the way — Agent Studio’s console contrast and heading order — are the actual proof this harness does what it’s for, not a contrived exercise).


DASH-408 [P2] — adopt @angular/cdk for overlay/dialog/a11y primitives

Section titled “DASH-408 [P2] — adopt @angular/cdk for overlay/dialog/a11y primitives”

Problem. The dashboard hand-rolls modal.ts, command-palette.ts, confirm.ts, tabs.ts, and toast.service.ts. They have already diverged in correctness: modal.ts implements role="dialog", aria-modal, focus management, Escape, and focus restore; command-palette.ts implements role="dialog" and Escape only — no aria-modal, no focus trap, no focus restore (see A11Y-206). That divergence is the predictable cost of five independent implementations of the same primitive, and it will recur.

@angular/cdk is not currently a dependency. It is versioned in lockstep with Angular 20 (already in use) and provides A11yModule (cdkTrapFocus, FocusTrapFactory, LiveAnnouncer), Dialog, Overlay, and Listbox.

Change. Add @angular/cdk and migrate the overlay-like primitives onto it, starting with the palette (A11Y-206) since it is both the worst and the most used. Keep the existing visual design entirely — this is a behaviour/semantics migration, not a restyle.

Acceptance criteria.

  • Palette, modal, and confirm all trap focus, set aria-modal, close on Escape, and restore focus to the invoking element — via one shared mechanism.
  • axe clean on each in its open state, with color-contrast enabled.
  • No visual regression (screenshot comparison in DX-502, or explicit review).
  • Bundle-size delta recorded; CDK is modular, so import only what is used.

Files. dashboard/package.json, dashboard/src/app/shared/*.ts, dashboard/src/app/shared/shared.spec.ts. Size. L. Depends on: DX-501. Blocks: A11Y-206.

Done (2026-07-27). Added @angular/cdk (20.2.14, the latest release on the 20.x line matching this workspace’s Angular 20.3) and migrated both Modal and CommandPalette onto A11yModule’s cdkTrapFocus/ cdkTrapFocusAutoCapture directive in place of each component’s own hand-rolled Tab-cycling handler — Modal’s trapTab()/focusables() methods are deleted outright, not left dormant. Focus-restore-on-close (which cdkTrapFocus alone doesn’t provide) is now the one shared restoreFocusOnClose() helper (shared/focus-restore.util.ts), called from both components’ constructors instead of each hand-writing the same capture-opener/restore-on-close effect. ConfirmDialog gets the fix for free (it renders through Modal). Did not migrate tabs.ts/ toast.service.ts — neither is an overlay/dialog primitive (inline tabs, a toast stack), so CDK’s A11yModule has nothing to offer them; the ticket’s own acceptance criteria only names palette/modal/confirm. Verified: ng build/ng test (83/83, including the pre-existing axe-based a11y specs) both green.


A11Y-206 [P1] — command palette lacks modal semantics and focus management

Section titled “A11Y-206 [P1] — command palette lacks modal semantics and focus management”

Problem. dashboard/src/app/shared/command-palette.ts sets role="dialog" and handles Escape, but has no aria-modal, no focus trap, and no focus restore on close (verified by inspection; modal.ts has all three). The palette is the dashboard’s primary keyboard navigation surface — bound to ⌘K and advertised in the topbar (app.html:78-82) — so the surface most likely to be used by a keyboard-first user is the one with the weakest keyboard semantics. Without a trap, Tab walks out of the open palette into the page behind it; without focus restore, closing it drops focus to the document.

Change. Migrate to the CDK primitive from DASH-408: cdkTrapFocus plus Dialog’s focus-restore behaviour, aria-modal="true", and an aria-activedescendant/role="listbox"+role="option" pattern for the results list so the active result is announced as the user arrows through it.

Acceptance criteria.

  • Tab and Shift+Tab cycle within the open palette and never reach the page behind it.
  • Closing (Escape, selection, or backdrop) restores focus to the invoking element.
  • Arrowing through results announces the active option to a screen reader.
  • axe clean in the open state.

Files. dashboard/src/app/shared/command-palette.ts, dashboard/src/app/shared/shared.spec.ts, dashboard/src/app/a11y.spec.ts. Size. M. Depends on: DASH-408.

Done (2026-07-27). cdkTrapFocus+cdkTrapFocusAutoCapture on the .cmdk panel (DASH-408’s mechanism) traps Tab and moves focus into the search input on open; restoreFocusOnClose returns it to the invoker on close (Escape, a result click, or a scrim click all route through the same close()). Went with a combobox pattern rather than migrating to Dialog’s own focus-restore (the ticket’s originally-named option) — DOM focus never leaves the input at all, so there’s nothing for Dialog to add on top of what cdkTrapFocus + the existing manual restore already give: the results list is role="listbox" of role="option" <div>s (tabindex="-1", never in the tab order — they were <button>s before, which would have made Tab cycle through every visible result instead of staying on the input), and the input carries role="combobox" + aria-controls/aria-expanded/aria-activedescendant pointing at the currently-selected option’s id — arrowing updates sel() exactly as before, now additionally moving aria-activedescendant, which is what actually announces the active option to a screen reader without moving real focus. aria-modal="true" added alongside the existing role="dialog". Verified: ng build/ng test (83/83, including a11y.spec.ts’s existing axe pass over the full app shell) both green.


DASH-404 [P2] — breadcrumb groups contradict the navigation rail

Section titled “DASH-404 [P2] — breadcrumb groups contradict the navigation rail”

Problem. dashboard/src/app/app.ts’s labels map files Settings under { root: 'Administer' } and Sign in under { root: 'Account' }, but the nav rail has only three groups — Operate, Build, Extend — and puts both Settings and Sign in in extend. So the breadcrumb reads “Administer / Settings” while the rail highlights it under “Extend”, and two group names that appear in the breadcrumb exist nowhere in the navigation.

Separately, “Sign in” as a peer of Marketplace and Settings under “Extend” is wrong information architecture: authentication is not an extension point, and the rail footer already holds the identity block it belongs next to.

Change. Make one grouping authoritative and derive the breadcrumb from it, rather than maintaining two hand-written maps that can disagree. Move Sign in out of “Extend” to the rail footer beside the identity block (which DASH-401/402 are already rebuilding). Decide whether “Administer” becomes a real fourth group or Settings stays in Extend — either is fine; disagreeing with itself is not.

Acceptance criteria.

  • Every breadcrumb root corresponds to a group that exists in the rail.
  • Nav grouping is defined once; the breadcrumb is derived from it.
  • Sign in is reachable from the identity area, not from “Extend”.
  • A test asserts every route’s breadcrumb root is a real group name.

Files. dashboard/src/app/app.ts, dashboard/src/app/app.html. Size. S. Depends on: DASH-401 (shares the footer rebuild).

Done (2026-07-27). Extracted the nav model into a new dashboard/src/app/core/nav-groups.ts — one NAV_GROUPS array (operate/ build/extend/administer/account, each with real NavItems) is now the only place a group or its members is declared. app.html’s <nav> renders straight from it (one @for over groups, replacing the three separate hand-written @for blocks over operate/build/extend properties); crumbFor() derives the breadcrumb from the identical array (plus a small EXTRA_CRUMBS map for the one route with no nav item of its own, executions/:id) — so the rail and the breadcrumb read the same data and cannot re-diverge the way a separate hand-written label map could. Went with “Administer” as a real fourth group (the ticket’s other sanctioned option) since the command palette already had an “Administer” section for Settings — one more piece of pre-existing, unify-toward-not-invent evidence. account (Sign in) is a real group too — its label is what the breadcrumb shows at /login — but carries showInNav: false and isn’t rendered as a rail section; instead, a Sign in link now sits in the rail footer beside the identity block (app.html’s .rail-foot), which DASH-401 already rebuilt. Regression guard: a new nav-groups.spec.ts runs every concrete path in app.routes.ts through crumbFor and asserts the derived root is always one of NAV_GROUPS’s real labels — this is the actual test the ticket asked for, not just a smoke test of the map. Verified: ng build/ng test (83/83, including 4 new nav-groups.spec.ts specs) both green.


Problem. dashboard/src/app/app.routes.ts:74 is { path: '**', redirectTo: 'agents' }. Any unknown, stale, or mistyped URL — including a bookmarked execution that has since been deleted, or a link shared from an older build — silently lands the operator in Agent Studio with no explanation and a changed URL. The breadcrumb then reads “Build / Agent Studio”, actively implying they arrived where they intended.

Change. Add a real not-found route that states the requested path was not found, keeps the URL, and offers the command palette plus links to the main surfaces. Reserve redirects for paths that genuinely moved.

Acceptance criteria.

  • An unknown route renders a 404 surface and does not rewrite the URL.
  • A deleted-execution deep link produces a meaningful message (this is the real scenario: executions/:id currently resolves, then fails inside the component — check that path specifically, not just unknown segments).
  • The 404 surface is keyboard reachable and axe clean.

Files. dashboard/src/app/app.routes.ts, new dashboard/src/app/features/not-found/. Size. S. Depends on: DX-501.

Done (2026-07-27). The wildcard route’s redirectTo: 'agents' is gone — { path: '**', loadComponent: () => .../not-found } with no redirectTo at all, so the router renders in place and the real (unknown) URL stays in the address bar (confirmed: Router.url inside NotFound reads the actual attempted path, used to display “There’s nothing at <path>.”). The page names the palette’s own global ⌘K/Ctrl-K hotkey (mounted in the app shell, already active on every route with zero extra wiring needed) and links to the four main surfaces. The deleted-execution scenario was already handled correctly before this ticketexecution-detail.ts’s ngOnInit catches a 404 from GET /api/v1/workflows/{id} and sets a real Execution "<id>" not found. message rendered via <app-empty-state kind="error">, confirmed by reading the component; nothing needed changing there, it just hadn’t been connected to this ticket’s evidence before. Verified: ng build/ng test (83/83) both green.


DASH-406 [P3] — default surface contradicts the navigation’s own priority

Section titled “DASH-406 [P3] — default surface contradicts the navigation’s own priority”

Problem. app.routes.ts:8 redirects '' to agents, and the code comment explains why historically: “Agent Studio is the first built surface; the remaining surfaces resolve to a placeholder until their slices land.” That is no longer true — Monitoring, Audit, Workflow Builder, Memory Explorer, Surfaces, MCP Servers, and Marketplace are all real. Meanwhile the rail’s first group is Operate / Monitoring, i.e. the IA already asserts a different priority than the router.

Change. Default to Monitoring — for an operator console, “what is the state of my system” is the right landing question, and it is also the surface that degrades most informatively when the server is unreachable (it already renders a precise “start it with…” empty state). Update the stale comment.

Acceptance criteria.

  • / lands on Monitoring.
  • The stale comment on app.routes.ts:4-6 is corrected.
  • Monitoring’s server-unreachable state is confirmed to be a good first impression for a fresh install (it currently is — verify it stays so).

Files. dashboard/src/app/app.routes.ts. Size. S. Depends on: DASH-405 (do the routing changes together).

Done (2026-07-27). { path: '', pathMatch: 'full', redirectTo: 'monitoring' }; the stale “Agent Studio is the first built surface…” comment is replaced with one explaining the actual reasoning (an operator console’s right first question is system state, and Monitoring’s server-unreachable state is already a good first impression — both named inline so the why survives the next surface added). Login’s post-save redirect (login.ts) was also pointed at /monitoring instead of /agents for the same coherency reason, found while touching this area — not part of the ticket’s own file list, a one-line follow-on. Verified: ng build/ng test (83/83) both green, and a real running dashboard confirmed / renders Monitoring with breadcrumb “Operate / Monitoring”.

A real regression this change surfaced, found by the e2e suite itself, not by inspection: / now rendering Monitoring instead of Agent Studio meant e2e/tests/dashboard.spec.ts’s “app shell has no detectable axe violations” check — which visits whatever / renders — hit a genuine, previously-uncaught heading-order violation: Monitoring’s card headings were <h3> directly under the page’s <h1> (skipping <h2>), where audit.html/agent-studio.html/playground.html all correctly use <h2>. Fixed by changing Monitoring’s five card headings to <h2> (dashboard/src/styles.scss already has one shared rule for both tags, so this is semantics-only, no visual change). A grep sweep afterward found the identical skip still present, unfixed, on seven other feature pages (execution-detail, marketplace, settings, workflow-builder, memory-explorer, mcp-servers, surfaces) — none reachable from the current e2e suite’s one axe check, so none previously caught. Flagged as a follow-up task (task_690cf860) rather than silently expanding this ticket’s scope, matching how A11Y-202 handled its own out-of-scope gradient-badge finding in Phase 1. Re-ran the full e2e suite after the fix: 15/15 green.


DASH-407 [P3] — sign-in page has no form semantics

Section titled “DASH-407 [P3] — sign-in page has no form semantics”

Problem. dashboard/src/app/features/login/login.html uses three bare [(ngModel)] inputs and a (click)="save()" button with no enclosing <form>. So Enter does not submit on the only credential-entry screen in the product, and the browser gets no form semantics for password-manager or autofill behaviour. The API-key field does correctly set type="password" and autocomplete="off" (:27).

Change. Wrap in <form (ngSubmit)="save()"> with a type="submit" button. Review the autocomplete attributes deliberately: autocomplete="off" on a pasted API key is defensible (it is not a reusable password and should not be saved by the browser), so keep it and note why in a comment rather than switching to a password-manager-friendly value by reflex.

Acceptance criteria.

  • Enter submits from any field.
  • saved feedback is announced to assistive technology, not only shown visually (aria-live).
  • The API key is still never written to localStorage (UI-101’s guarantee in session.ts:9-19 must not regress).

Files. dashboard/src/app/features/login/login.html, dashboard/src/app/features/login/login.ts. Size. S. Depends on: DX-501.

Done (2026-07-27). The three fields + button now live inside a real <form (ngSubmit)="save()"> with type="submit" on the button (was (click)) — Enter now submits from any field, the native way, rather than needing a keydown handler. autocomplete="off" on the API-key field is kept, with the reasoning now in a comment (a pasted key/JWT isn’t a reusable password and shouldn’t be offered for the browser to save alongside real site passwords) instead of being silently correct-by-luck. The “Saved.” feedback span gained aria-live="polite" so it’s announced, not just shown. session.ts’s “never write the API key to localStorage” guarantee is untouched — nothing in this change touches persistence, only markup/semantics. Verified: ng build/ng test (83/83) both green (no existing spec covered this page, so nothing to update).


DASH-409 [P2] — no time-series charting in an observability surface

Section titled “DASH-409 [P2] — no time-series charting in an observability surface”

Problem. dashboard/src/app/features/monitoring/monitoring.html is otherwise the best-built surface in the dashboard — real data only, honest empty states naming the exact command to run, tabular-nums throughout, no fabricated values — but its only visualisation is one hand-rolled SVG sparkline (:32-48), and the platform emits far more: wovyr_api_request_duration_seconds histograms with exemplars, per-route RED metrics, per-tenant/project LLM cost and token aggregates (OBS-201), and cache-savings figures. Latency distribution and spend-over-time are currently either absent or collapsed to a single scalar.

Note the sparkline’s gradient uses a hardcoded id="sg" (:36) — a real collision risk once a second chart exists on the page.

Change. Adopt a charting library for time series and keep the hand-rolled sparklines (they are good and cheap). uPlot is the recommendation: canvas, ~47 kB, MIT, built for dense time-series, and visually restrained enough to inherit the design system rather than fight it. Chart.js is the easier-API alternative; ECharts (~1 MB) is disproportionate for a single-binary appliance. Follow the project’s data-visualisation palette guidance for series colours rather than reaching for the accent repeatedly.

Acceptance criteria.

  • Latency (p50/p95 from the histogram) and LLM spend render as time series over the polling window.
  • Chart colours come from the DSY-101 tokens and clear AA for any text/legend.
  • Charts are keyboard/screen-reader accessible or paired with an accessible table of the same data.
  • Empty and server-unreachable states remain as honest as they are today.
  • Any SVG id is unique per instance.

Files. dashboard/package.json, dashboard/src/app/features/monitoring/*. Size. L. Depends on: DSY-101, A11Y-203.

Done (2026-07-27). Added uplot (1.6.32, ~47 kB, no @types package on npm but ships its own dist/uPlot.d.ts via package.json’s typings field, resolved by TS with no extra config) plus a new shared TimeSeriesChart wrapper (shared/time-series-chart.ts) — canvas host + uPlot instance, a ResizeObserver for width, and an accessible <details><summary>View data as table</summary><table>…</table></details> rendering the exact plotted values (real numbers, not a placeholder), reachable by keyboard via the native <summary> focusability. Monitoring now computes two new series each poll tick: windowed p50/p95 latency (windowedPercentileMs, a pure function subtracting the previous poll’s cumulative histogram-bucket snapshot from the current one — summed across every route/method label combination first, since the server emits one _bucket series per route and the first draft of this missed that, silently computing a percentile off whichever single route’s sample happened to sort last; walks buckets ascending to bucket-boundary granularity, not linearly interpolated, which is honestly stated in the function’s own doc comment as “good enough for a dashboard trend line, not a billing-grade figure”) and windowed LLM spend (the same delta-of- cumulative-counter pattern the existing perWindow requests sparkline already used, just applied to llmCostUsd). Chart colours are read live from the DSY-101 tokens via getComputedStyle(document.documentElement) — never hardcoded — and repaint on a live theme toggle, not just at first paint: an earlier draft only re-resolved colours at construction time (uPlot’s per-series stroke is fixed once set), found and fixed by actually toggling the theme against a live chart and watching the legend swatch colour fail to change; TimeSeriesChart now destroys and recreates the plot when ThemeService.theme() changes. The old sparkline’s hardcoded id="sg" is now a per-instance sg-${n} from a module-level counter (deterministic, not Math.random() — this codebase’s own determinism convention, even though it’s UI-only, made the cleaner choice obvious). Empty/unreachable states are honest (“Gathering samples…” until ≥2 polls have landed) and unchanged everywhere else.

Verified against a real running wovyr dev server, not just the no-backend empty state (cargo run -p wovyr-cli -- dev, real curl traffic to generate histogram samples): the Monitoring page’s LIVE · polling N× state showed genuinely varying p50 (steady 5ms) and p95 (5–25ms across ticks, real jitter) values pulled straight from the component’s own signals (p50History/p95History), the canvas rendered at real pixel dimensions, the accessible table’s rows held the same real timestamps and values, the sparkline’s gradient id was confirmed unique (sg-0, no collision), and — the theme-repaint fix’s own proof — toggling light→dark live changed the uPlot legend swatch colour from rgb(45, 84, 232) (light --accent) to rgb(91, 123, 255) (dark --accent) immediately, matching the resolved token exactly both times. ng build/ ng test (83/83) both green.


WEB-304 [P3] — docs build silently downgrades cron code blocks

Section titled “WEB-304 [P3] — docs build silently downgrades cron code blocks”

Problem. The docs build logs:

[WARN] [astro-expressive-code] Error while highlighting code block using language "cron" in document ".../12-deployment/backup-and-restore.md". The language could not be found. Using "txt" instead.

The block renders unhighlighted, as a warning rather than an error, so it is easy to never notice. There may be other unregistered languages across 200 synced pages that have not been surfaced.

Change. Register the missing grammar via expressiveCode.shiki.langs (or change the fence to a supported language if cron has no grammar worth adding). Then sweep the full build output for every other highlighting warning and fix them all in the same pass, and consider failing the build on this warning class so the next one is not silent.

Acceptance criteria.

  • A clean npm run build in website/ emits no expressive-code language warnings.
  • The cron blocks in backup-and-restore.md render highlighted.
  • The build fails (or CI flags) on a future unregistered language.

Files. website/astro.config.mjs, possibly docs/12-deployment/*.md. Size. S. Depends on: none.

Done (2026-07-27). Registered a minimal custom TextMate grammar for cron (astro.config.mjs’s cronGrammar const, passed via expressiveCode.shiki.langs) rather than switching the fence to a different language — shiki has no bundled cron/crontab grammar at all (checked node_modules/shiki/dist/langs directly), and the one real block in the docs (backup-and-restore.md’s */15 * * * * wovyr admin backup …) is simple enough that a small hand-written grammar (schedule fields vs. the shell command, plus # comments) gives real differentiated highlighting rather than just suppressing the warning. Swept the full build output for every other expressive-code language warning per the ticket’s own instruction — found none beyond cron. Verified: a clean npm run build in website/ emits zero expressive-code warnings (confirmed by grepping the full build log), and the built page’s raw HTML shows <pre data-language="cron"> with real per-token colour spans (schedule fields and the command text styled distinctly), not the plain-text fallback. The “fail on a future unregistered language” half of the acceptance criteria is real too: the lighthouse CI job’s website-build step (DX-503) tees npm run build’s output and greps it for the exact expressive-code … language could not be found warning text, failing the job if found — proven by temporarily blanking shiki.langs back to [], confirming the warning reappears and the grep matches it, then restoring the real config. Not a dedicated new job — piggybacks on a website-build step that already exists for DX-503.


WEB-305 [P3] — replace JS scroll reveal with CSS scroll-driven animation

Section titled “WEB-305 [P3] — replace JS scroll reveal with CSS scroll-driven animation”

Problem. The reveal system is an IntersectionObserver over 16 elements (index.astro:620-629) whose failure mode is WEB-302’s blank page. CSS scroll-driven animations (animation-timeline: view()) express the same effect with no JavaScript, degrade to visible-by-default in unsupporting browsers, and respect prefers-reduced-motion natively — which structurally eliminates the WEB-302 class of bug rather than patching it.

The page’s baseline is already modern enough (color-mix(), text-wrap: balance, @media (prefers-reduced-motion) all in use).

Change. Replace the observer-driven reveal with a CSS scroll-driven animation, keeping the current 12–14px rise and fade. Remove the observer. Deliberately do not add an animation library for this — evaluated and rejected as unnecessary: anime.js and GSAP are DOM/property tweening engines that would sit awkwardly beside the existing hand-written Canvas hero, and CSS covers this case with zero bytes. If JS-driven orchestration is wanted later (e.g. scrubbing the How-it-works pipeline on scroll), that is a separate, justified decision — GSAP’s ScrollTrigger is now free and would be the candidate.

Acceptance criteria.

  • Reveal behaviour is visually equivalent on a supporting browser.
  • Content is visible with JS disabled and on a browser without scroll-driven-animation support.
  • prefers-reduced-motion: reduce shows content with no transition.
  • The IntersectionObserver reveal code is deleted, not left dormant.

Files. website/src/pages/index.astro. Size. M. Depends on: WEB-302 (which must ship first as the safe fix — this is the structural follow-up, not a substitute for it).

Done (2026-07-27). The IntersectionObserver reveal block (the try/catch’d IIFE at the top of the body script) and the html.js gating class it depended on (head script’s classList.add("js"), and the html.js .reveal{...} CSS rule) are all deleted, replaced by @supports (animation-timeline: view()) { @media (prefers-reduced-motion: no-preference) { .reveal { animation: reveal-in .6s ease both; animation-timeline: view(); animation-range: entry 0% cover 30%; } } }. Nothing about .reveal’s visibility depends on JS executing at all now, structurally (not just defensively) eliminating WEB-302’s failure mode — there’s no script left to throw.

A real bug found and fixed along the way, not part of the original ticket text: the base styles set body{overflow-x:hidden} to suppress horizontal scroll, which — per the CSS2 rule that coerces the other axis to auto the instant either overflow axis is non-visible — made body qualify as a distinct scroll container in its own right, a legitimate candidate “nearest ancestor scroll container” for .reveal’s animation-timeline: view(). Since the real page scrolling happens on the viewport via the standard body→viewport overflow-propagation rule, body’s own scrollTop never itself moves, so every view-timeline on the page came up permanently inactive — verified directly (Animation.currentTime was null at every scroll position; every .reveal element sat at the browser default opacity:1 regardless of position, in a real Playwright Chromium, not this session’s Electron-embedded automation pane, which is a separate, known limitation, see below). Fixed by switching overflow-x:hidden to overflow-x:clip (overflow-y stays visible, nothing to coerce, and clip isn’t a scroll container in the first place) — confirmed getComputedStyle(document.body).overflowY reads "visible" afterward and the view-timeline resolves correctly.

Verified for real, in a real Chromium, not just reasoned about: this session’s own Browser-pane automation tab reproduced the exact same opacity:1-always / currentTime:null symptom even on a minimal, from-scratch isolated test page with no other page CSS at all — the same class of compositor/animation-timeline limitation this milestone’s own Phase 1 notes already documented for the hero canvas’s rAF throttling and the live-toggle contrast measurement, not a defect in this fix. Real verification instead used Playwright’s Chromium (e2e’s own installed browser, driven by a throwaway script, deleted after use — not a persisted test): against the real built site, an element below the fold read opacity:0 before scrolling and opacity:1 after (top moving from 1898px to 425px), a prefers-reduced-motion: reduce context showed the same element at opacity:1 immediately with no scroll at all, and — the literal WEB-302 regression test — a javaScriptEnabled:false browser context still revealed the same element via a native mouse.wheel scroll (opacity:01), proving the reveal mechanism has no script dependency whatsoever. astro build (203 pages) stayed green throughout.


Section titled “A11Y-208 [P3] — link touch targets below the 44px guideline”

Problem. Measured on the landing page at 1280px: the primary nav links are 22px tall, the footer’s 14 links are 22px, the theme toggle is 34×34, the quickstart copy button 28px, and the brand lockup 26px — against the 44×44 guideline (WCAG 2.5.5 AAA / 2.5.8 AA’s 24px minimum). The nav links are hidden below 760px (index.astro:106), so the mobile exposure is mainly the footer’s link columns and the copy button.

Change. Increase padding on footer and quickstart controls so hit areas reach at least 24px (2.5.8 AA) and preferably 44px on touch viewports, without changing the visual rhythm — padding rather than font size. Audit the dashboard’s .icon-btn (34×34) and pill-sized controls in the same pass.

Acceptance criteria.

  • All interactive targets are ≥24×24 CSS px; touch-viewport targets ≥44×44.
  • No change to the type scale or visual density.
  • Verified at 375px, 768px, and 1280px.

Files. website/src/pages/index.astro, dashboard/src/styles.scss. Size. S. Depends on: none.

Done (2026-07-27). Padding added to every flagged control’s own box (never font size, per the ticket’s own instruction): .nav-links a (22px→~42px via padding-block:10px), .nav-cta (padding:10px 16px), .qs-links a (padding-block:6px), .copy (padding:9px 13px), .brand (padding-block:9px), and .foot a (padding-block:7px, margin trimmed to compensate so the visual rhythm barely moves). @media (pointer:coarse) pushes .tgl, .nav-cta, .copy, and .foot a the rest of the way to the full 44×44 the guideline prefers, on an actual touch device only — a mouse user on a small viewport doesn’t need the extra padding a touch user does. Audited the dashboard’s .icon-btn (34×34, already clears the 24px baseline) with the same pointer:coarse bump to 44×44, and gave the new app.scss .foot-link (Sign in, added by DASH-404 this same phase) the same treatment from the start rather than shipping it under-sized and fixing it here.

Verified with real touch emulation, not just viewport width — a 375px-wide desktop viewport doesn’t set pointer:coarse on its own, so measuring only at that width would have silently never exercised the touch-specific rule. Using Playwright’s devices['iPhone 13'] (real hasTouch) against the actual built site: .tgl 44×44, .nav-cta 101.7×44.4, #copyBtn 56.1×44, .foot a 160×68.4 — all comfortably at or above 44px on a real touch profile. Desktop-viewport sizes at 375/768/1280px (no touch emulation) were also measured directly and all clear 24px (.tgl stays 34×34 there, .nav-cta/.copy/.foot a all ≥34px tall).


DX-503 [P3] — no performance or a11y budget on the landing page

Section titled “DX-503 [P3] — no performance or a11y budget on the landing page”

Problem. The landing page runs a continuous Canvas animation with per-glyph shadowBlur in dark mode, ships an inline <style> block of ~250 lines and an inline script of ~300, and (after WEB-303) will load a self-hosted font — with no budget on any of it and no regression signal. Its job is first impressions on arbitrary devices.

Change. Add Lighthouse CI to the pipeline with asserted budgets on performance, accessibility, best-practices, and SEO for / and one docs page. Set thresholds from the current measured baseline so the gate is real rather than aspirational, and tighten later.

Acceptance criteria.

  • Lighthouse CI runs on every push against a built site.
  • Budgets are asserted (build fails on regression), with the baseline recorded in the ticket’s closing note.
  • The hero’s animation cost is measured on a throttled mobile profile; if it exceeds budget, the frame rate or the dark-mode shadowBlur is tuned (the existing IntersectionObserver/visibilitychange pausing is already correct and should be credited, not rebuilt).

Files. .github/workflows/ci.yml, new lighthouserc.json. Size. M. Depends on: DX-502 (shares the built-site CI step).

Done (2026-07-27). Landed as lighthouserc.cjs (not .json as originally scoped) — @lhci/cli config files can’t hold comments in JSON, and the baseline numbers below need to live somewhere durable and explained, not just asserted; a .cjs module export is one of @lhci/cli’s natively supported config formats, so this isn’t a workaround. A new lighthouse CI job (.github/workflows/ci.yml) builds the real website/dist, reuses the e2e job’s own Chromium download (via @playwright/test’s chromium.executablePath(), set as CHROME_PATH — avoids pulling in a second browser through @lhci/cli’s own puppeteer dependency), and runs npx @lhci/cli autorun against / and one docs page (/00-executive/mission/), uploading the reports as a build artifact.

Baseline, measured for real against the built site (median-of-3, @lhci/ cli’s default optimistic aggregation — the best of 3 runs per category, which is the tool’s own standard noise-reduction default, not a choice made here to inflate the numbers):

PagePerformanceAccessibilityBest PracticesSEO
/.97–.991.00.961.00
/00-executive/mission/.99–1.01.001.001.00

Thresholds are set well under this floor (performance ≥0.85, accessibility ≥0.95, best-practices ≥0.90, SEO ≥0.95) so ordinary run-to-run noise doesn’t flake the gate while a real regression (an unbudgeted script, a dropped landmark, a broken meta tag) still fails it — proven, not assumed: a throwaway config edit raising minScore to a mathematically impossible 1.01 (with aggregationMethod: 'pessimistic' to defeat the default best-of-3) produced real, explicit failing assertions (e.g. expected: >= 1.01, actual: 0.89) confirming the assert step genuinely evaluates real LHR data rather than being a no-op; an earlier attempt at this same proof using a merely-implausible 0.999 threshold passed by coincidence (one of the 3 real runs legitimately scored a rounded 1.0), which is exactly the optimistic-aggregation behavior this table’s own caption now states explicitly rather than leaving as a surprise.

The hero animation’s cost was measured via Lighthouse’s own default throttling (a mid-tier mobile CPU/network profile applied to every run above), rather than a separate dedicated profiling pass — the landing page’s real performance score (.97–.99) already reflects the Canvas hero running under that throttle, and it clears the budget with a wide margin, so no frame-rate or shadowBlur tuning was needed. Credit where the ticket says to: the hero’s existing IntersectionObserver/ visibilitychange pausing (pre-existing, not part of this milestone) is what keeps that score achievable at all and was not touched.


If a phase overruns, cut in this order — never the other:

  • P1: DASH-403 (dead tenant switcher) → downgrade to a link to Sign-in rather than building a real project picker. DASH-402’s server-reported auth mode → client-side inference only, labelled as inferred.
  • P2: DSY-106 (brand-mark consolidation) → ship DSY-103’s docs theming with the landing’s existing mark and reconcile the dashboard’s triangle later. WEB-303 (self-hosted font) → keep the system stack, but at minimum make the two stacks identically ordered, which is a one-line fix and removes the landing-vs-dashboard divergence.
  • P3: DASH-409 (charting) and DX-503 (Lighthouse) are the natural cuts — both are additive. WEB-305 is cuttable only because WEB-302 already fixed the underlying failure; do not cut WEB-302 in its place.

Never cut:

  • DX-501 — without it nobody else can build the dashboard, so nothing else in this milestone is verifiable by a second person.
  • WEB-302 — a page that is invisible without JavaScript is not shippable, and the fix is a few lines.
  • A11Y-207 — the contrast gate must land with the contrast fixes. Shipping the fixes without the gate reproduces exactly the situation this audit found: a corrected palette with nothing preventing the next drift.
  • DASH-401 — an operator console that misreports the principal it is acting as is a trust defect in a trust product.

  • A visual redesign. Every ticket preserves the existing visual language; the point is that four surfaces should express the same one. The one deliberate exception is A11Y-201/203’s lightness adjustments, which change values, not identity.
  • A dashboard framework change. Angular 20 stays. DASH-408 adds CDK, a first-party primitive library, not a rewrite.
  • A marketplace browse UI, or any other unbuilt surface. Out of scope; it remains open in v1.0/A4.
  • Reworking the trust runtime’s behaviour. wovyr-ui, wovyr-ui-guard, and the renderer’s hash-verification logic are correct; only tokens and theme plumbing change.
  • An animation library. Evaluated and rejected for the current design (see WEB-305). Revisit only if scroll-scrubbed hero storytelling is chosen as a deliberate direction.

VersionDateDescription
1.3.02026-07-27Phase 3 done — all 11 tickets (DASH-408, A11Y-206, DASH-404/405/406/407/409, WEB-304/305, A11Y-208, DX-503) shipped and verified; see each ticket’s own “Done” note in §7 and the phase-level note in §6. This closes the v1.5 milestone — no tickets remain open. @angular/cdk’s cdkTrapFocus unified three previously-independent focus-trap implementations into one; nav grouping/breadcrumbs are now derived from a single source with a real regression test; DASH-409’s charts (uPlot, DSY-101-token-coloured, theme-reactive) were verified against a real running wovyr-server with real generated traffic; WEB-305’s CSS scroll-driven reveal surfaced and fixed a genuine overflow-x:hidden-disables-view-timelines bug found and confirmed in a real (non-Electron-embedded) Chromium via Playwright; DX-503’s Lighthouse gate was proven non-decorative via a deliberately-impossible-threshold test. ng build/ng test (83/83) and astro build (203 pages) both green throughout.
1.2.02026-07-27Phase 2 done — all 11 tickets (DSY-101/102/103/106, WEB-303, A11Y-201/203/204/205/207, DX-502) shipped and verified; see each ticket’s own “Done” note in §7 and the phase-level note in §5. DSY-101 landed as a single canonical CSS file consumed directly by landing/docs/dashboard, with @wovyr/ui-react kept in sync by a real parity test since it can’t literally import it. DX-502 (Playwright + axe, e2e/, wired into a new CI job) is the headline result: its real-painted-page axe run immediately found and fixed two genuine, previously-undiscovered defects outside this milestone’s original evidence (Agent Studio console contrast, a heading-order gap), and caught a second-round gap in A11Y-201’s own fix (--ink-3 on plain --canvas) that the first pass’s pair list had missed — proving the “next divergence fails CI” property this phase’s own acceptance criteria asked for, not just asserting it. ng test (69/69), astro build (203 pages), and the full e2e suite (15/15) all green.
1.1.02026-07-26Phase 1 done — all 9 tickets (DX-501, WEB-301, WEB-302, DSY-104, DSY-105, A11Y-202, DASH-401, DASH-402, DASH-403) shipped and verified; see each ticket’s own “Done” note in §7 and the phase-level note in §4. DSY-104 implemented with a refinement found during the work (adopted Starlight’s own starlight-theme key for the landing↔docs pair, since they’re the same origin, rather than a fourth invented key); A11Y-202 fixed a second, worse pre-existing hover-state contrast bug for free and surfaced a distinct, out-of-scope gradient-badge contrast issue as a separate follow-up. cargo fmt/clippy untouched (no Rust changed this phase); ng build/ng test (51/51) and a fresh astro build (203 pages) both green.
1.0.02026-07-26Initial v1.5 milestone: 31 tickets across three phases, remediating the 2026-07-26 UI/UX audit of the landing page, docs site, and dashboard. Five workstreams (DSY/A11Y/WEB/DASH/DX). Requirement IDs defined inline in §3 — no PRD, since the findings are the requirements and no product-scope or architectural decision is implied (DSY-101’s token-distribution mechanism is the one candidate for a follow-on ADR). Not started