Zum Inhalt springen

Sub-system 14 — Design System

Status: Active (added 2026-05-31 via ADR-0006 / master D41) Depends on: D17 (Astro + islands), D18 (Starlight), ADR-0006 (the binding decision), spec 07 (public site — owns the palette), spec 13 (quality bar) Pattern source: shadcn-style own-the-code primitives; Tailwind v4 CSS-first @theme; framework-agnostic CSS-custom-property tokens In scope: packages/design-tokens (the brand single-source), Tailwind v4 wiring in apps/web, packages/ui (Astro presentational primitives + Preact/Ark island primitives), styling every currently-undefined class hook across the public site + dashboard, retrofitting the OG generator and the (future) Starlight brand file onto the tokens, dark mode. Out of scope: new product features, the admin-console frontend itself (spec 10 — this spec only guarantees packages/ui is reusable by it), the real-time island logic (spec 09 owns CalendarLive/LiveMetricsPanel behaviour; this spec owns their styling + the renderer/primitive substrate they sit on).

Binding decisions live in ADR-0006. This spec is the implementation contract: the frozen token set, the primitive catalogue, the surface-by-surface class map, the testing strategy, and the phase/issue breakdown.


Goals

  1. One brand single-source (@nowyourlink/design-tokens) consumed by all four surfaces (public site, dashboard, admin, Starlight docs) and the OG image generator — drift becomes structurally impossible.
  2. Turn the public site + dashboard from unstyled HTML into a styled product by defining every class hook that exists today (§7), adding zero JavaScript to the public site (presentational primitives are .astro).
  3. Give the dashboard/admin accessible interactive primitives (Dialog, Tabs, DatePicker, Toast, Combobox) with real focus/keyboard semantics.
  4. Hit the spec-13 quality bar on everything we add: 100% coverage, zero warnings, no Lighthouse regression.

Non-goals

  • A visual redesign or new brand identity — we codify the existing brand (navy/blue, Inter) already used by the OG generator and spec 07.
  • A theme marketplace, multi-brand theming, or design-token Figma round-tripping (Style Dictionary is explicitly deferred — ADR-0006 follow-ups).

Layer 3 @nowyourlink/ui Astro presentational primitives (0 JS)
+ Preact island primitives (Ark UI, hydrated)
Layer 2 Tailwind v4 @tailwindcss/postcss, CSS-first @theme inline
Layer 1 @nowyourlink/design-tokens tokens.css (CSS custom properties)

Layer 1 is renderer-agnostic and ships no JS. Layer 2 is build-time only (zero-runtime, purged). Layer 3 splits by JS need: presentational = .astro (no hydration), interactive = .island.tsx (hydrated only where mounted).

Stack (frozen by ADR-0006, corrected against the 2026 ecosystem):

ConcernChoiceNote
Tokensplain tokens.css (:root + [data-theme="dark"])no build step; exports the raw .css
Utility engineTailwind v4 via @tailwindcss/postcss@tailwindcss/vite breaks on Astro 6 rolldown-vite (#16542)
Token→utility@theme inline { --color-*: var(--nyl-*) }inline required or dark-mode swaps are shadowed
Variantsclass-variance-authority (CVA) + a cn() (tailwind-merge)lighter than tailwind-variants; we own the code
Islands renderer@astrojs/preact (compat: true)~4 kB; React libs via preact/compat
Headless behaviour@ark-ui/react under preact/compatno @ark-ui/preact; Zag.js WAI-ARIA state machines
Dark mode@custom-variant dark ([data-theme="dark"]) + token swapSSR data-theme from cookie to avoid FOUC

Naming: --nyl-<category>-<name>. Two tiers — raw primitives (a fixed scale) and semantic aliases (what consumers use). Only the semantic tier flips in dark mode. Palette is seeded from the existing brand in apps/web/src/lib/og-svg.ts (navy #0f172a, blue #1e3a8a, white, light-blue #cbd5f5, slate #475569/#64748b, border #e2e8f0) so nothing visually regresses; spec 07 remains palette owner and may extend this set.

/* packages/design-tokens/tokens.css — the frozen v1 contract */
:root {
/* ── primitives (raw scale; never consumed directly by components) ── */
--nyl-navy-900: #0f172a;
--nyl-blue-800: #1e3a8a;
--nyl-blue-200: #cbd5f5;
--nyl-slate-600: #475569;
--nyl-slate-500: #64748b;
--nyl-slate-200: #e2e8f0;
--nyl-white: #ffffff;
--nyl-green-600: #16a34a;
--nyl-amber-500: #f59e0b;
--nyl-red-600: #dc2626;
/* ── semantic (light defaults; THIS is what components/@theme consume) ── */
--nyl-color-bg: var(--nyl-white);
--nyl-color-surface: var(--nyl-white);
--nyl-color-surface-alt: #f8fafc;
--nyl-color-fg: var(--nyl-navy-900);
--nyl-color-fg-muted: var(--nyl-slate-600);
--nyl-color-fg-subtle: var(--nyl-slate-500);
--nyl-color-brand: var(--nyl-blue-800);
--nyl-color-brand-strong:var(--nyl-navy-900);
--nyl-color-brand-fg: var(--nyl-white);
--nyl-color-border: var(--nyl-slate-200);
--nyl-color-success: var(--nyl-green-600);
--nyl-color-warning: var(--nyl-amber-500);
--nyl-color-danger: var(--nyl-red-600);
/* type */
--nyl-font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--nyl-font-mono: ui-monospace, "SF Mono", "JetBrains Mono", monospace;
--nyl-text-xs: 0.75rem; --nyl-text-sm: 0.875rem; --nyl-text-base: 1rem;
--nyl-text-lg: 1.25rem; --nyl-text-xl: 1.5rem; --nyl-text-2xl: 2rem; --nyl-text-3xl: 3rem;
/* space (4px base) / radius / shadow / motion / z */
--nyl-space-1: 0.25rem; --nyl-space-2: 0.5rem; --nyl-space-3: 0.75rem;
--nyl-space-4: 1rem; --nyl-space-6: 1.5rem; --nyl-space-8: 2rem;
--nyl-radius-sm: 0.25rem; --nyl-radius-md: 0.5rem; --nyl-radius-card: 0.75rem; --nyl-radius-full: 9999px;
--nyl-shadow-sm: 0 1px 2px rgb(15 23 42 / 0.06);
--nyl-shadow-md: 0 4px 12px rgb(15 23 42 / 0.10);
--nyl-motion-fast: 120ms; --nyl-motion-base: 200ms; --nyl-ease-standard: cubic-bezier(.2,0,0,1);
--nyl-z-dropdown: 1000; --nyl-z-overlay: 1100; --nyl-z-modal: 1200; --nyl-z-toast: 1300;
}
[data-theme="dark"] {
/* override SEMANTIC tokens only — primitives stay fixed */
--nyl-color-bg: var(--nyl-navy-900);
--nyl-color-surface: #14161c;
--nyl-color-surface-alt: #1b1e27;
--nyl-color-fg: #e8eaed;
--nyl-color-fg-muted: #9aa0a6;
--nyl-color-fg-subtle: #6b7280;
--nyl-color-brand: var(--nyl-blue-200);
--nyl-color-brand-fg: var(--nyl-navy-900);
--nyl-color-border: #2a2e37;
}

Status-color contract (used by calendar/library/invoices/auction status classes in §7): success→green, warning→amber, danger→red, neutral→border. Auction/library/invoice status enums map onto these three + neutral; the exact enum→token map is fixed in the relevant primitive (StatusBadge).


Terminal window
pnpm --filter @nowyourlink/web add tailwindcss @tailwindcss/postcss
pnpm --filter @nowyourlink/web add @nowyourlink/design-tokens@workspace:*
apps/web/postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };
apps/web/src/styles/global.css
@import "tailwindcss";
@import "@nowyourlink/design-tokens/tokens.css";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme inline {
--color-bg: var(--nyl-color-bg);
--color-surface: var(--nyl-color-surface);
--color-fg: var(--nyl-color-fg);
--color-fg-muted: var(--nyl-color-fg-muted);
--color-brand: var(--nyl-color-brand);
--color-brand-fg: var(--nyl-color-brand-fg);
--color-border: var(--nyl-color-border);
--color-success: var(--nyl-color-success);
--color-warning: var(--nyl-color-warning);
--color-danger: var(--nyl-color-danger);
--font-sans: var(--nyl-font-sans);
--font-mono: var(--nyl-font-mono);
--radius-card: var(--nyl-radius-card);
}

astro.config.ts keeps the existing paraglideVitePlugin in vite.plugins[] and adds preact({ compat: true }) to a new integrations[] array (the two arrays are independent — verified, no conflict with @astrojs/cloudflare). The global stylesheet is imported once per layout (AppShell.astro, LegalPageLayout.astro, and the public <head>). data-theme is set on <html> server-side from a cookie to avoid FOUC.


Package shape mirrors packages/config (@nowyourlink/ui, private, type: module, scripts build/lint/test/typecheck). Exports both .astro primitives and .island.tsx islands plus the shared CVA variant configs.

Presentational (.astro, ZERO JS — usable on the public site):

PrimitiveVariantsReplaces hooks (§7)
Buttonintent: primary/ghost/danger · size: sm/md/lgspotlight-cta, like-button, share-copy, auction-raise-cta, auction-roas__cta
Cardpadded/flush · elevated?spotlight-card, tile, library-cards, settings-block
Badge / StatusBadgetone: neutral/success/warning/dangercalendar-cell--*, library-status--*, auction-status, invoices-processing
Input / Field+ label/error slotssettings forms, library upload
Tablestriped? compact?bids-table, invoices-table
EmptyState—spotlight-empty, *-empty, tile-empty
Alertinfo/warning/error*-error, comments-consent
Prose / LegalLayout styling—legal pages, last-updated, skip-link
AppShell styling—app-shell/-rail/-topbar/-main/-brand/-rail__disabled
TileGrid / Tilecountdown/bids/invoices/roastile-grid, tile--*, tile-value, tile-meta

Interactive (.island.tsx, Ark UI under preact/compat, hydrated):

IslandArk primitiveWherea11y the lib provides
Dialog@ark-ui/react/dialogconfirm raise-bid, settingsfocus trap+restore, Esc, aria-modal, scroll-lock
Toast@ark-ui/react/toastglobal notifierARIA live-region, queue, keyboard dismiss
Tabs@ark-ui/react/tabsbids-filter, library viewsroving tabindex, arrow keys, aria-selected
DatePicker/grid affordances@ark-ui/react/date-pickercalendar-grid cells (styling substrate for spec 09’s CalendarLive)keyboard grid nav, aria-*
Tooltip/Popover@ark-ui/react/{tooltip,popover}calendar-cell__bid-badgehover/focus, dismiss

Variant logic is shared via *.variants.ts (CVA) so an .astro primitive and its island counterpart render byte-identical classes. A single cn() helper (wrapping tailwind-merge) resolves conflicts.


  • Public site (/, /day, /archive, legal) — presentational primitives only, 0 JS beyond the existing tiny inline handlers (like/share). Lighthouse budget is the gate.
  • Dashboard (/app/*) — presentational primitives + islands on client:visible/client:idle. noindex, so JS budget is looser but still lean.
  • Admin (spec 10, future) — apps/admin is a Hono worker (master D42), so it consumes packages/ui through the renderer-agnostic layer: the shared CVA *.variants.ts variant configs (§5) + @nowyourlink/design-tokens, rendered into its own server HTML — NOT the .astro primitives (those require an Astro renderer; public-site only). This spec guarantees the variant contract + tokens are renderer-agnostic (the reuse surface), not the admin pages.
  • Docs (Starlight, spec 11) — docs-site/src/styles/brand.css (to be created per spec 11 §151) imports the token package and aliases --sl-color-accent-* etc. onto --nyl-*. This spec provides the tokens; spec 11’s PR wires them.

§7 Surface migration map (the 60+ undefined class hooks)

Section titled “§7 Surface migration map (the 60+ undefined class hooks)”

Every class below exists in markup today with no stylesheet. The full file/line inventory is the implementation checklist; grouped here by owning primitive. (Source: apps/web/src/** audit, 2026-05-31.)

  • Homepage/archive: spotlight*, headline, advertiser, body, display-url, live-countdown, share*, like*, comments*, order, archive-list, pagination, winning-at → Card/Button/Badge/Prose/Table.
  • App shell: app-shell, app-rail, app-rail__disabled, app-brand, app-topbar, app-main, skip-link, lang-switch(er), last-updated → AppShell styling + Button/links.
  • Dashboard landing: tile-grid, tile, tile--{countdown,bids,invoices,roas}, tile-empty, tile-value, tile-meta → TileGrid/Tile/EmptyState.
  • Calendar: calendar-grid, calendar-cell, calendar-cell--${status}, calendar-cell__{day,bid-badge,anti-snipe} → calendar substrate + StatusBadge + Tooltip island.
  • Bids/library/invoices/settings/auction-detail: bids-*, library*, library-status--${status}, invoices-*, settings*, auction-* (incl. auction-roas*, auction-user-bid__*, auction-supersede-chain) → Table/Card/StatusBadge/Alert/Tabs/Dialog.

No markup/class= value changes are required where avoidable — primitives emit the same class names so the migration is additive (define the CSS, optionally swap raw <button>→<Button>), keeping diffs reviewable and tests stable.


§8 Testing strategy (100% coverage, spec 13)

Section titled “§8 Testing strategy (100% coverage, spec 13)”

Two component categories, two harnesses, in a separate Vitest project from @cloudflare/vitest-pool-workers (which runs in workerd and cannot render DOM):

  1. .astro presentational → Astro Container API (experimental_AstroContainer): renderToString(Button, { props, slots }), assert emitted classes + slot content + every variant branch.
  2. .island.tsx interactive → @testing-library/preact + user-event: render, assert the WAI-ARIA contract (role, aria-*, focus, Esc/keyboard) — this is how owned interactive code reaches 100%.
  3. Tokens → snapshot the emitted tokens.css / assert the documented token keys exist (pure data; trivially 100%).

vitest.config.ts uses Astro’s getViteConfig (so astro:container resolves) and aliases react/react-dom→preact/compat. Container tests do not exercise client:* hydration branches — islands cover those. Add a Lighthouse-budget assertion to the public-site CI job (no a11y/perf regression).


§9 Phases → GitHub issues (epic + P0–P6)

Section titled “§9 Phases → GitHub issues (epic + P0–P6)”
PhaseDeliverableBlocksReviewers
P0@astrojs/preact (compat:true) + @ark-ui/react installed; one reference island (Dialog or Toast) in packages/ui; vitest preact-alias config + @testing-library/preact test greenP41
P1packages/design-tokens (frozen §3 tokens.css); repoint og-svg.ts at token values; (docs-site brand wiring tracked with spec 11)P21
P2Tailwind v4 (@tailwindcss/postcss) + global.css @theme inline + dark variant; one utility used end-to-end; build green on Astro 6P31
P3packages/ui presentational primitives (§5) + define every public-site/shell class hook (§7); public site goes styled, 0 JS added—1
P4island primitives (Dialog/Toast/Tabs/Tooltip/date substrate); wire dashboard /app/* hooks; coordinate with spec 09 islands—1
P5admin-console frontend (Hono worker, master D42) consumes packages/ui’s CVA variant contract + @nowyourlink/design-tokens — not the .astro primitives (with spec 10)—per CODEOWNERS
P6dark-mode data-theme cookie + SSR set; verify four-surface consistency; Lighthouse budget locked—1
P7public-site layout system (§11) — PublicLayout.astro shell + layout primitives + heading recovery; turn the page (not just its components) styled—1

Each phase: workflows:tdd-cycle (red→green→refactor) → pnpm verify → PR → workflows:full-review + pr-review-toolkit:review-pr. P0–P2 are foundational and independently shippable; P3 is the user-visible payoff. P7 is independent of P5/P6 (admin frontend, dark mode) and is prioritized: it fixes a live, user-visible defect (the public site renders unstyled — §11), whereas P5/P6 are unshipped future surfaces. Do not gate P7 behind them.

  • New primitive → superpowers:test-driven-development (write the Container/island test first, then the component).
  • Token/Tailwind wiring → superpowers:systematic-debugging if the Astro 6 rolldown/PostCSS build misbehaves (it is the known risk — #16542).
  • Before each phase PR → superpowers:verification-before-completion + pr-review-toolkit:review-pr.

Added 2026-05-31. Research: research/public-site-layout-research.md. Closes the gap §7 left open.

§11.1 Why this exists (the §7 / AC3 blind spot)

Section titled “§11.1 Why this exists (the §7 / AC3 blind spot)”

§7 enumerated only component-internal class hooks (.spotlight, .share, …). The public pages each hand-roll their own <html>/<body>/<main> with no shared layout wrapper, and <main>/<h1>/<footer> carry no class — so P3’s deliberately additive-only sweep had nothing to hook onto at the page level. Result: with Tailwind preflight active, bare headings collapse and every section except .spotlight (which self-centers at max-width:42rem) spills full-width flush-left — the page renders unstyled even though “every §7 hook is defined”. AC3 was satisfiable without the page being laid out; §11.5’s AC7 fixes that by asserting rendered layout, not selector existence.

§11.2 Delivery vehicle — PublicLayout.astro (the central decision)

Section titled “§11.2 Delivery vehicle — PublicLayout.astro (the central decision)”

Add apps/web/src/components/PublicLayout.astro, parallel to the existing AppShell.astro (dashboard) and LegalPageLayout.astro (legal). It owns, once: the <html>/<head> (via SeoHead), the skip-link, the centered content container, the page <main id="main">, and the <footer>. Public pages (index, archive/*, day/[day], login) compose into it instead of re-declaring document scaffolding — killing the structural duplication the audit found and giving every page the same container/skip-link/footer for free. LegalPageLayout is refactored to delegate to (or share the container of) PublicLayout so legal pages get the same shell. 0 JS — .astro only.

§11.3 Layout primitives (class hooks in hooks.css, layer(components))

Section titled “§11.3 Layout primitives (class hooks in hooks.css, layer(components))”

“Every Layout” composable model — each primitive owns one spacing responsibility, intrinsically responsive (no media queries, no JS), consuming var(--nyl-*) directly (NOT via @theme inline). Reading width is 42rem, reusing the existing .spotlight value (migrate its literal 42rem to the token). Spacing steps 1,2,3,4,6,8 only.

PrimitiveRoleVariants
.centercontainer: max-width + margin-inline:auto + clamp gutter--prose (42rem)
.stackvertical rhythm via owl > * + * margin-block-start--tight / --loose
.clusterwrapping horizontal group (button/meta rows)--between
.page-headerhero/title block; restores h1 size.title-xl / .title-lg
.prosescoped descendant heading recovery for long-form pages—

.prose-grid (3-column: 1fr min(--nyl-width-prose, 100% - gutter*2) 1fr) is available for centered-column + .full-bleed breakouts.

§11.4 New dimensional tokens (packages/design-tokens/tokens.css :root)

Section titled “§11.4 New dimensional tokens (packages/design-tokens/tokens.css :root)”

Added to the :root block alongside --nyl-space-*. Dimensional → NOT in @theme inline, NOT overridden under [data-theme="dark"] (only semantic color tokens flip):

--nyl-width-prose: 42rem; /* ≈75ch; matches existing .spotlight */
--nyl-width-content: 72rem; /* wide content shell */
--nyl-width-wide: 80rem; /* gallery max */
--nyl-gutter: clamp(var(--nyl-space-4), 5vw, var(--nyl-space-8)); /* 1rem→2rem */
--nyl-text-display: clamp(2rem, 4vw + 1rem, 3rem); /* hero headings */

§11.5 Scope — MUST-FIX vs DEFERRED (fence off the refactor)

Section titled “§11.5 Scope — MUST-FIX vs DEFERRED (fence off the refactor)”

Must-fix (the defect): PublicLayout.astro + .center/.stack container & rhythm + clamp gutter + heading recovery (.page-header + scoped .prose for legal), applied to the bare public pages so the page renders centered, width-constrained, with sized headings and a gutter that never collapses on mobile. Add the §11.4 tokens.

Deferred / optional (low-risk follow-ups only): consolidating already-working hooks (.spotlight-card/.tile/.share/.day-stats) into .box/.cluster/ .grid, and the 4-tier .content-grid (popout/feature). These touch working code including the dashboard and must not regress it — do them separately, not as part of the P7 must-fix.

.astro layout primitives + PublicLayout → Astro Container API: assert emitted container classes, slot content, and each variant. Add a rendered-layout assertion to the public-site CI (DOM/visual or Lighthouse) so the page is proven centered + width-constrained with non-collapsed headings — see AC7. 100% coverage, zero warnings.

  • AC1 @nowyourlink/design-tokens emits the frozen §3 token set; og-svg.ts references token values (no new hardcoded hexes).
  • AC2 Tailwind v4 builds on Astro 6 via @tailwindcss/postcss; @theme inline utilities respond to data-theme swap.
  • AC3 Every class hook in §7 is defined; public site + dashboard render styled.
  • AC4 Public site ships no new JS; Lighthouse perf/a11y not regressed.
  • AC5 Interactive islands pass an asserted WAI-ARIA/keyboard test.
  • AC6 pnpm verify green: lint + typecheck + 100% coverage + build, zero warnings, across the new packages and apps/web.
  • AC7 (P7, §11) Public pages render through PublicLayout.astro: content is centered and width-constrained (.center), separated by vertical rhythm (.stack), with a non-mobile-collapsing clamp gutter, and headings render at their token sizes (<h1> via .page-header/.title-xl; legal <h2>/<h3> via scoped .prose). Asserted by a rendered-layout check (DOM/visual or Lighthouse), not merely “the hook selector is defined” — i.e. it cannot be satisfied the way AC3 was while the page stayed unstyled (§11.1). No new JS on the public site; dark-mode token swap still applies (dimensional tokens excluded).