Sub-system 14 — Design System
Sub-system 14 — Design System
Section titled “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.
§1 Goals & non-goals
Section titled “§1 Goals & non-goals”Goals
- 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. - 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). - Give the dashboard/admin accessible interactive primitives (Dialog, Tabs, DatePicker, Toast, Combobox) with real focus/keyboard semantics.
- 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).
§2 Architecture — three layers
Section titled “§2 Architecture — three layers”Layer 3 @nowyourlink/ui Astro presentational primitives (0 JS) + Preact island primitives (Ark UI, hydrated)Layer 2 Tailwind v4 @tailwindcss/postcss, CSS-first @theme inlineLayer 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):
| Concern | Choice | Note |
|---|---|---|
| Tokens | plain tokens.css (:root + [data-theme="dark"]) | no build step; exports the raw .css |
| Utility engine | Tailwind 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 |
| Variants | class-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/compat | no @ark-ui/preact; Zag.js WAI-ARIA state machines |
| Dark mode | @custom-variant dark ([data-theme="dark"]) + token swap | SSR data-theme from cookie to avoid FOUC |
§3 Token contract (FROZEN)
Section titled “§3 Token contract (FROZEN)”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).
§4 Tailwind wiring (apps/web)
Section titled “§4 Tailwind wiring (apps/web)”pnpm --filter @nowyourlink/web add tailwindcss @tailwindcss/postcsspnpm --filter @nowyourlink/web add @nowyourlink/design-tokens@workspace:*export default { plugins: { "@tailwindcss/postcss": {} } };@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.
§5 packages/ui — primitive catalogue
Section titled “§5 packages/ui — primitive catalogue”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):
| Primitive | Variants | Replaces hooks (§7) |
|---|---|---|
Button | intent: primary/ghost/danger · size: sm/md/lg | spotlight-cta, like-button, share-copy, auction-raise-cta, auction-roas__cta |
Card | padded/flush · elevated? | spotlight-card, tile, library-cards, settings-block |
Badge / StatusBadge | tone: neutral/success/warning/danger | calendar-cell--*, library-status--*, auction-status, invoices-processing |
Input / Field | + label/error slots | settings forms, library upload |
Table | striped? compact? | bids-table, invoices-table |
EmptyState | — | spotlight-empty, *-empty, tile-empty |
Alert | info/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 / Tile | countdown/bids/invoices/roas | tile-grid, tile--*, tile-value, tile-meta |
Interactive (.island.tsx, Ark UI under preact/compat, hydrated):
| Island | Ark primitive | Where | a11y the lib provides |
|---|---|---|---|
Dialog | @ark-ui/react/dialog | confirm raise-bid, settings | focus trap+restore, Esc, aria-modal, scroll-lock |
Toast | @ark-ui/react/toast | global notifier | ARIA live-region, queue, keyboard dismiss |
Tabs | @ark-ui/react/tabs | bids-filter, library views | roving tabindex, arrow keys, aria-selected |
DatePicker/grid affordances | @ark-ui/react/date-picker | calendar-grid cells (styling substrate for spec 09’s CalendarLive) | keyboard grid nav, aria-* |
Tooltip/Popover | @ark-ui/react/{tooltip,popover} | calendar-cell__bid-badge | hover/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.
§6 Surface profiles
Section titled “§6 Surface profiles”- 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 onclient:visible/client:idle.noindex, so JS budget is looser but still lean. - Admin (spec 10, future) —
apps/adminis a Hono worker (master D42), so it consumespackages/uithrough the renderer-agnostic layer: the shared CVA*.variants.tsvariant configs (§5) +@nowyourlink/design-tokens, rendered into its own server HTML — NOT the.astroprimitives (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):
.astropresentational → Astro Container API (experimental_AstroContainer):renderToString(Button, { props, slots }), assert emitted classes + slot content + every variant branch..island.tsxinteractive →@testing-library/preact+user-event: render, assert the WAI-ARIA contract (role,aria-*, focus, Esc/keyboard) — this is how owned interactive code reaches 100%.- 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)”| Phase | Deliverable | Blocks | Reviewers |
|---|---|---|---|
| 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 green | P4 | 1 |
| P1 | packages/design-tokens (frozen §3 tokens.css); repoint og-svg.ts at token values; (docs-site brand wiring tracked with spec 11) | P2 | 1 |
| P2 | Tailwind v4 (@tailwindcss/postcss) + global.css @theme inline + dark variant; one utility used end-to-end; build green on Astro 6 | P3 | 1 |
| P3 | packages/ui presentational primitives (§5) + define every public-site/shell class hook (§7); public site goes styled, 0 JS added | — | 1 |
| P4 | island primitives (Dialog/Toast/Tabs/Tooltip/date substrate); wire dashboard /app/* hooks; coordinate with spec 09 islands | — | 1 |
| P5 | admin-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 |
| P6 | dark-mode data-theme cookie + SSR set; verify four-surface consistency; Lighthouse budget locked | — | 1 |
| P7 | public-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.
§10 Skill recipe (per spec 13 §10.2)
Section titled “§10 Skill recipe (per spec 13 §10.2)”- New primitive →
superpowers:test-driven-development(write the Container/island test first, then the component). - Token/Tailwind wiring →
superpowers:systematic-debuggingif 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.
§11 Public-site layout system (P7)
Section titled “§11 Public-site layout system (P7)”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.
| Primitive | Role | Variants |
|---|---|---|
.center | container: max-width + margin-inline:auto + clamp gutter | --prose (42rem) |
.stack | vertical rhythm via owl > * + * margin-block-start | --tight / --loose |
.cluster | wrapping horizontal group (button/meta rows) | --between |
.page-header | hero/title block; restores h1 size | .title-xl / .title-lg |
.prose | scoped 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.
§11.6 Testing (per §8 / spec 13)
Section titled “§11.6 Testing (per §8 / spec 13)”.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.
Acceptance criteria
Section titled “Acceptance criteria”- AC1
@nowyourlink/design-tokensemits the frozen §3 token set;og-svg.tsreferences token values (no new hardcoded hexes). - AC2 Tailwind v4 builds on Astro 6 via
@tailwindcss/postcss;@theme inlineutilities respond todata-themeswap. - 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 verifygreen: lint + typecheck + 100% coverage + build, zero warnings, across the new packages andapps/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).