From a50d184ae596d2a11f2cd7e4929234495084cd5c Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Fri, 19 Jun 2026 23:28:03 +0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(builder):=20UI=20design=20wave=20?= =?UTF-8?q?=E2=80=94=20design.md=20=E2=86=92=20theme=20+=20layout=20taste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a design.md→globals.css pipeline so any build can re-skin without breaking shadcn/Blocks, with a "keep default constructive" opt-out. Engine (scripts/lib/design/, zero-dep): oklch (convert + WCAG contrast + lightness math), design-md (parse/serialize, reuses the existing YAML reader), invariants (taste lint: ≤1 accent, sat<80%, no pure black, AI-purple ban, contrast pairs), compile (role→shadcn remap + missing-var synthesis + .dark derivation + AA contrast repair, never throws), fonts (next/font allowlist) + 50 unit tests. Applier: scripts/wire-design.mjs — idempotent --dry-run codemod overriding :root/.dark token VALUES after the Blocks @import (restyles template + Blocks at once); optional layout.tsx font/defaultTheme + branding.ts; preset:constructive = no-op. scripts/check-design.mjs — lint gate that fails an un-themeable design (no green-wash). Presets: fixtures/design/{constructive,minimalist,editorial,soft,brutalist,playful}.md + compiler fixtures. Docs/grammar: references/design-system.md (dials + invariants + compile contract), brief-grammar `design:` block + auto-propose default, brief-policy validateDesign, SKILL.md S-step + phase docs. Layout taste (scope B): scaffold-frontend mandatory loading/empty/error states + dial-driven density (generic, no entity literals). Verify: design rot-canary wired into verify-gates.sh / verify-phase.sh / genericity-check.sh. Static gates green (50/50 tests, 6 presets lint clean, structural-safe, idempotent). Adversarially reviewed + fixed. Live Chrome-QA acceptance pending. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RLz6M63G5vFJDZhW7dwBtb --- .agents/skills/constructive-builder/SKILL.md | 6 +- .../fixtures/design/__fixtures__/README.md | 113 +++++ .../design/__fixtures__/direct-map.design.md | 27 + .../__fixtures__/direct-map.expected.json | 36 ++ .../__fixtures__/explicit-dark.design.md | 32 ++ .../__fixtures__/explicit-dark.expected.json | 28 ++ .../lint-ai-purple-allowed.design.md | 24 + .../lint-ai-purple-allowed.expected.json | 9 + .../__fixtures__/lint-ai-purple.design.md | 26 + .../__fixtures__/lint-ai-purple.expected.json | 9 + .../design/__fixtures__/lint-clean.design.md | 31 ++ .../__fixtures__/lint-clean.expected.json | 21 + .../lint-missing-primary.design.md | 21 + .../lint-missing-primary.expected.json | 10 + .../__fixtures__/lint-pure-black.design.md | 25 + .../lint-pure-black.expected.json | 9 + .../__fixtures__/radius-fallback.design.md | 27 + .../radius-fallback.expected.json | 20 + .../__fixtures__/tint-foreground.design.md | 30 ++ .../tint-foreground.expected.json | 21 + .../fixtures/design/brutalist.md | 60 +++ .../fixtures/design/constructive.md | 63 +++ .../fixtures/design/editorial.md | 60 +++ .../fixtures/design/minimalist.md | 59 +++ .../fixtures/design/playful.md | 64 +++ .../fixtures/design/soft.md | 65 +++ .../references/brief-grammar.md | 78 +++ .../references/design-system.md | 338 +++++++++++++ .../references/phase-3-frontend-sdk.md | 9 + .../references/phase-4-blocks.md | 16 +- .../references/speedrun.md | 38 +- .../scripts/check-design.mjs | 213 ++++++++ .../scripts/genericity-check.sh | 70 +++ .../scripts/lib/brief-policy.mjs | 97 ++++ .../scripts/lib/design/compile.mjs | 463 ++++++++++++++++++ .../scripts/lib/design/compile.test.mjs | 181 +++++++ .../scripts/lib/design/design-md.mjs | 120 +++++ .../scripts/lib/design/design-md.test.mjs | 54 ++ .../scripts/lib/design/fixtures.test.mjs | 152 ++++++ .../scripts/lib/design/fonts.mjs | 90 ++++ .../scripts/lib/design/fonts.test.mjs | 38 ++ .../scripts/lib/design/invariants.mjs | 191 ++++++++ .../scripts/lib/design/invariants.test.mjs | 94 ++++ .../scripts/lib/design/oklch.mjs | 290 +++++++++++ .../scripts/lib/design/oklch.test.mjs | 111 +++++ .../lib/scaffold-frontend/entity-page.mjs | 115 ++++- .../scripts/lib/verify-gates.sh | 61 +++ .../scripts/scaffold-app.mjs | 27 +- .../scripts/scaffold-frontend.mjs | 13 +- .../templates/frontend/entity-page.tsx | 107 +++- .../frontend/globals-overrides.css.tmpl | 8 + .../scripts/verify-phase.sh | 8 +- .../scripts/wire-design.mjs | 405 +++++++++++++++ 53 files changed, 4248 insertions(+), 35 deletions(-) create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/README.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.design.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.expected.json create mode 100644 .agents/skills/constructive-builder/fixtures/design/brutalist.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/constructive.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/editorial.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/minimalist.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/playful.md create mode 100644 .agents/skills/constructive-builder/fixtures/design/soft.md create mode 100644 .agents/skills/constructive-builder/references/design-system.md create mode 100644 .agents/skills/constructive-builder/scripts/check-design.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/compile.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/compile.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/design-md.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/design-md.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/fixtures.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/fonts.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/fonts.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/invariants.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/invariants.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/oklch.mjs create mode 100644 .agents/skills/constructive-builder/scripts/lib/design/oklch.test.mjs create mode 100644 .agents/skills/constructive-builder/scripts/templates/frontend/globals-overrides.css.tmpl create mode 100644 .agents/skills/constructive-builder/scripts/wire-design.mjs diff --git a/.agents/skills/constructive-builder/SKILL.md b/.agents/skills/constructive-builder/SKILL.md index 2b08f1b..c6b4e95 100644 --- a/.agents/skills/constructive-builder/SKILL.md +++ b/.agents/skills/constructive-builder/SKILL.md @@ -40,7 +40,8 @@ The happy path: a warm hub → a verified basic CRUD app + one auth flow, zero b - **S4 — Pre-patch the template, then ONE install + codegen.** Pin ONE `graphql` (workspace `pnpm.overrides`), keep the **root** `pnpm-workspace.yaml` (the single workspace — `wire-app.mjs` already stripped the nested boilerplate one under `packages/app`, S3), gate app TS with a scoped `tsconfig.appcheck.json` (`next build` type-checks the whole monorepo). `wire-app.mjs` (S3) already DECLARED the extra app deps in `/package.json` (`@constructive-io/graphql-codegen@latest` dev; for a blocks app `@constructive-io/ui` + `@simplewebauthn/browser`), so run a **single** `pnpm install` (NOT separate `pnpm add` rounds — each re-resolves the heavy tree, the warm-time sink) then `pnpm codegen`. → `references/phase-3-frontend-sdk.md`. - **S5 — Blocks on-ramp** (only if the brief needs auth/account/org UI; else skip to S7). Pick the flow id from `references/flow-catalog.md` / `references/flows.json`; install its blocks from the **GitHub-Pages registry** (fix #3 below); the provider wiring is already done by `wire-app.mjs` — including the **per-request `app`-token seam (GAP-A / SDK-008)**: wire-app injects a custom `fetch` into the `app` SDK config that re-reads the live token on every request, so the FIRST create in a fresh session (right after sign-up/sign-in, before any reload) is authed instead of silently failing as anonymous (HTTP 200 + permission-denied + 0 rows). No longer a manual step. → `references/phase-4-blocks.md`, `references/blocks-onramp.md`. - **S6 — Confirm the SDK preflight** (`node scripts/check-sdk.mjs --project ` exits 0) when S5 ran. → `references/phase-4-blocks.md`. -- **S7 — Build the domain CRUD body** (always). `node scripts/scaffold-frontend.mjs ` stamps the runtime-generic CRUD Stack + `_meta` meta-forms and emits a page per `ui.routes[].kind:crud`. → `references/phase-4-blocks.md`, plus the `constructive-frontend` skill. +- **S6.5 — Apply the design theme** (after the Blocks `@import`, before the CRUD body). `node scripts/wire-design.mjs --app ` derives/compiles the app's `design.md` → writes a contrast-repaired token-override block into `globals.css` (+ optional `next/font` / `defaultTheme` swap), restyling the template **and** every installed Block at once. **Default = auto-propose** a domain-fitting theme; `design: { preset: constructive }` (or a compile failure) ⇒ **no-op**, today's look preserved. Idempotent + `--dry-run`-able. → `references/design-system.md`. +- **S7 — Build the domain CRUD body** (always). `node scripts/scaffold-frontend.mjs ` stamps the runtime-generic CRUD Stack + `_meta` meta-forms and emits a page per `ui.routes[].kind:crud` (loading/empty/error states + a DENSITY-keyed `data-density` root, dial-driven). → `references/phase-4-blocks.md`, plus the `constructive-frontend` skill. - **S8 — Build + the two TS gates.** `pnpm exec tsc -p tsconfig.appcheck.json --noEmit` (real app-TS gate), then `pnpm build`, then `pnpm dev --port ` and curl 200. → `references/phase-3-frontend-sdk.md`. - **S9 — Verify end-to-end through the UI.** Done = a real round-trip (signup → login → create a row → reload → row persists, mutation 2xx), not a green build. Then the automated gates `./scripts/verify-phase.sh 2.1 2.3 2.6 3`, and the independent evaluator. → "Verification" below + `references/speedrun.md`. @@ -100,7 +101,8 @@ Each table declares a **policy intent**, which maps to one of three tiers: | [phase-2-data-model.md](./references/phase-2-data-model.md) | Workspace + provision pkg + blueprint; object-form grants, policy intents, module list, grant outcomes | Provisioning the data model / RLS, or a 2.1/2.3 gate fails | | [phase-3-frontend-sdk.md](./references/phase-3-frontend-sdk.md) | Scaffold the Next.js app, env + `api-` endpoints, `graphql` override, codegen, the TS gates | Scaffolding the frontend / running codegen, or a 2.6 gate fails | | [phase-4-blocks.md](./references/phase-4-blocks.md) | Blocks on-ramp branch + the domain CRUD body; gate `3` | Wiring auth/account/org UI or building entity CRUD | -| [brief-grammar.md](./references/brief-grammar.md) | The brief schema: entities, policy intents, flows, acceptance, escape hatches | Authoring or editing a brief | +| [brief-grammar.md](./references/brief-grammar.md) | The brief schema: entities, policy intents, flows, acceptance, escape hatches, the optional `design:` block | Authoring or editing a brief | +| [design-system.md](./references/design-system.md) | Authoring a `design.md` theme: the 3 dials + words→dials table, color invariants, preset catalog, the compile/override contract (role→var, override surface, font/contrast/tint gotchas), keep-default hatch, layout/state patterns | Shaping an app's look-and-feel (S6.5) — auto-propose a theme or honor a `design:` brief block | | [infra-setup.md](./references/infra-setup.md) | Hub coordinates, `constructive.config.json`, `CONSTRUCTIVE_*` env, smoke/restart | Pointing a build at a different backend/ports, or the hub is down | | [blocks-onramp.md](./references/blocks-onramp.md) | The six-step Blocks bridge (binding, deps, env, install, providers, preflight) for this template | Deep Blocks install/wiring, or a BLOCKS-NNN issue | | [flow-catalog.md](./references/flow-catalog.md) | Human-readable GA auth-flow catalog (preset, modules, exposed ops, blocks) | Choosing which auth flow(s) to install | diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/README.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/README.md new file mode 100644 index 0000000..5ebb5cb --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/README.md @@ -0,0 +1,113 @@ +# Compiler test fixtures — `fixtures/design/__fixtures__/` + +Deterministic doubles for **P2 / Agent-A** unit tests of the design engine +(`scripts/lib/design/{oklch,design-md,invariants,compile}.mjs`). Each fixture is a +pair: + +``` +.design.md # the input design.md (parsed by parseDesignMd → compileDesign) +.expected.json # assertions a test runner checks against the compile/lint output +``` + +Authored by build-agent **D** so the engine (agent A) and the static self-test (P2) +have a shared, version-controlled contract to assert against. **These intentionally do +NOT pin every derived token value** — the exact OKLCH math for derived vars +(`--border`, `--muted-foreground`, `--chart-*`, `.dark` inversion, elevation ΔL) is +Agent A's to tune, and over-pinning it would make the tests brittle and couple D's +guesses to A's implementation. Instead each `expected.json` pins only what the SHARED +CONTRACT makes **deterministic**: direct role→var copies, the radius, the +override-surface allowlist, the structural off-limits set, the marked-region sentinels, +and (for lint fixtures) the exact invariant findings. + +## `expected.json` schema + +All keys optional; a test asserts each present key. Unknown extra keys a test doesn't +understand should be ignored (forward-compatible). Color comparisons SHOULD be tolerant +of formatting (whitespace, trailing-zero) — compare by parsed OKLCH within a small +epsilon, not by string equality, since `formatOklch` rounding is Agent A's choice. + +```jsonc +{ + "describe": "human label for the test case", + + // compileDesign(design, {defaultMode}) input options: + "compileOptions": { "defaultMode": "light" }, + + // ── deterministic role→var copies the compiler MUST reproduce verbatim + // (direct mappings from the role map; not derived). Checked in BOTH the + // returned `light`/`dark` objects AND the rendered override block. ── + "light": { + "--background": "oklch(1 0 0)", // <- colors.surface + "--foreground": "oklch(0.30 0 0)", // <- colors.on-surface + "--primary": "oklch(0.55 0.16 250)",// <- colors.primary + "--ring": "oklch(0.55 0.16 250)" // <- primary + }, + "dark": { /* same idea for explicit-dark fixtures */ }, + + // exact radius string (deterministic): design.radius || rounded.md || '0.5rem' + "radius": "0.5rem", + + // fonts.{sans,mono} resolution (Agent A fonts.mjs allowlist) + "fonts": { "sans": "Geist", "mono": "Geist Mono" }, + + // ── override-surface guard ── + // Every var the override block emits MUST be in this allowlist (the ONLY + // vars compile may emit). A test asserts: emittedVars ⊆ overrideSurface. + "overrideSurfaceOnly": true, + + // ── structural-safety guard ── + // The rendered override block MUST NOT contain ANY of these substrings. + "mustNotContain": [ + "@theme inline", "@source", "@custom-variant", "@plugin", + "--z-layer-", "--shadow-", "--font-sans", "--font-serif", "--font-mono", + "@layer base", "@layer utilities", "--radius-xs", "--radius-sm" + ], + + // ── marked-region sentinels (byte-exact, shared across agents) ── + "mustContain": [ + "/* >>> constructive-builder design overrides (generated) */", + "/* <<< constructive-builder design overrides */", + ":root", ".dark" + ], + + // ── contrast contract (Agent A ensureContrast). A test parses these pairs + // out of the compiled `light`/`dark` token sets and asserts the WCAG + // ratio meets `min`. Use 3 for the hard floor, 4.5 for AA body. ── + "contrast": [ + { "mode": "light", "fg": "--foreground", "bg": "--background", "min": 4.5 }, + { "mode": "light", "fg": "--primary-foreground", "bg": "--primary", "min": 4.5 }, + { "mode": "light", "fg": "--muted-foreground", "bg": "--background", "min": 4.5 }, + { "mode": "light", "fg": "--destructive-foreground", "bg": "--destructive","min": 4.5 }, + { "mode": "dark", "fg": "--foreground", "bg": "--background", "min": 4.5 } + ], + + // ── lint fixtures only: assert invariants.lintDesign(design) findings ── + "lint": { + "ok": false, // overall verdict + "expectFindings": [ // each: at least one finding matches + { "rule": "missing-primary", "severity": "error" } + ], + "forbidFindings": [ // none of these may appear + { "rule": "ai-purple-band" } + ] + } +} +``` + +## Fixture index + +| Fixture | What it proves | +|---|---| +| `direct-map.*` | Direct role→var copies (surface/on-surface/primary/ring) + radius + override-surface allowlist + structural-safety + sentinels. The "happy path" compile shape. | +| `explicit-dark.*` | An explicit `dark:` block is honored verbatim (compiler must NOT re-derive `.dark` when `design.dark` is present). | +| `radius-fallback.*` | `radius` absent → falls back to `rounded.md`; `--ring` mirrors primary; default_mode dark sets which set leads. | +| `tint-foreground.*` | success/warning `*-foreground` honor the tint contract: dark-on-tint in LIGHT, light-on-tint in DARK (contrast pairs both ways). | +| `lint-missing-primary.*` | `lintDesign` returns `ok:false` with a `missing-primary` **error**. | +| `lint-ai-purple.*` | A saturated blue-purple primary (no `allow_brand_hue`) → `ai-purple-band` **warn**; flipping `allow_brand_hue:true` would suppress it (documented, second file). | +| `lint-pure-black.*` | `on-surface: oklch(0 0 0)` → `pure-black` / min-L **warn**. | +| `lint-clean.*` | A fully invariant-satisfying design → `lintDesign` `ok:true`, **zero** error findings. | + +> Sentinels (must stay byte-identical with `compile.renderOverrideBlock` and +> `wire-design.mjs`): +> `/* >>> constructive-builder design overrides (generated) */` +> `/* <<< constructive-builder design overrides */` diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.design.md new file mode 100644 index 0000000..8fc1b0b --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.design.md @@ -0,0 +1,27 @@ +--- +version: 1 +name: direct-map-fixture +description: Minimal happy-path design — pins the deterministic role→var direct copies. +colors: + primary: "oklch(0.55 0.16 250)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.50 0.01 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.30 0.005 250)" + error: "oklch(0.55 0.18 27)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.5rem" +default_mode: light +allow_brand_hue: false +--- + +# direct-map fixture + +A minimal, invariant-clean design used to assert the **deterministic** parts of +`compileDesign`: the direct role→var copies (`--background` = surface, +`--foreground` = on-surface, `--primary` = colors.primary, `--ring` = primary), +the radius, the override-surface allowlist, structural-safety, and the marked-region +sentinels. Derived vars (border, muted-foreground, charts, the whole `.dark` set) are +left to Agent A's math and are NOT pinned here. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.expected.json new file mode 100644 index 0000000..9966eeb --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/direct-map.expected.json @@ -0,0 +1,36 @@ +{ + "describe": "Direct role->var copies, radius, override-surface allowlist, structural-safety, sentinels.", + "compileOptions": { "defaultMode": "light" }, + "light": { + "--background": "oklch(1 0 0)", + "--foreground": "oklch(0.30 0.005 250)", + "--card": "oklch(1 0 0)", + "--popover": "oklch(1 0 0)", + "--primary": "oklch(0.55 0.16 250)", + "--primary-foreground": "oklch(0.99 0 0)", + "--destructive": "oklch(0.55 0.18 27)", + "--ring": "oklch(0.55 0.16 250)" + }, + "radius": "0.5rem", + "fonts": { "sans": "Geist", "mono": "Geist Mono" }, + "overrideSurfaceOnly": true, + "mustNotContain": [ + "@theme inline", "@source", "@custom-variant", "@plugin", + "--z-layer-", "--shadow-", "--font-sans", "--font-serif", "--font-mono", + "@layer base", "@layer utilities", "--radius-xs", "--radius-sm", "--radius-md" + ], + "mustContain": [ + "/* >>> constructive-builder design overrides (generated) */", + "/* <<< constructive-builder design overrides */", + ":root", + ".dark" + ], + "contrast": [ + { "mode": "light", "fg": "--foreground", "bg": "--background", "min": 4.5 }, + { "mode": "light", "fg": "--primary-foreground", "bg": "--primary", "min": 4.5 }, + { "mode": "light", "fg": "--muted-foreground", "bg": "--background", "min": 4.5 }, + { "mode": "light", "fg": "--destructive-foreground", "bg": "--destructive", "min": 4.5 }, + { "mode": "dark", "fg": "--foreground", "bg": "--background", "min": 4.5 } + ], + "notes": "card/popover equal surface here (zero or tiny elevation deltaL acceptable; compare within epsilon). The 36-key override surface allowlist is the only legal emit set." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.design.md new file mode 100644 index 0000000..197b027 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.design.md @@ -0,0 +1,32 @@ +--- +version: 1 +name: explicit-dark-fixture +description: Carries an explicit dark block — compiler must honor it, not re-derive. +colors: + primary: "oklch(0.50 0.10 28)" + primary-foreground: "oklch(0.98 0.01 60)" + neutral: "oklch(0.50 0.012 55)" + surface: "oklch(0.99 0.004 70)" + on-surface: "oklch(0.26 0.012 50)" + error: "oklch(0.52 0.19 27)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.25rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.70 0.11 35)" + primary-foreground: "oklch(0.20 0.02 35)" + neutral: "oklch(0.70 0.012 60)" + surface: "oklch(0.22 0.010 50)" + on-surface: "oklch(0.95 0.006 70)" + error: "oklch(0.62 0.19 27)" +--- + +# explicit-dark fixture + +When a `design.md` carries an explicit `dark:` block, `compileDesign` must use those +values for the `.dark` token set (and the dark direct-copies must equal them) rather +than deriving dark by OKLCH lightness inversion. This fixture pins the dark surface, +foreground, and primary to the authored dark values. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.expected.json new file mode 100644 index 0000000..b0016a2 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/explicit-dark.expected.json @@ -0,0 +1,28 @@ +{ + "describe": "Explicit dark: block is honored verbatim (no re-derivation) for dark direct-copy vars.", + "compileOptions": { "defaultMode": "light" }, + "light": { + "--background": "oklch(0.99 0.004 70)", + "--foreground": "oklch(0.26 0.012 50)", + "--primary": "oklch(0.50 0.10 28)" + }, + "dark": { + "--background": "oklch(0.22 0.010 50)", + "--foreground": "oklch(0.95 0.006 70)", + "--primary": "oklch(0.70 0.11 35)", + "--primary-foreground": "oklch(0.20 0.02 35)" + }, + "radius": "0.25rem", + "overrideSurfaceOnly": true, + "mustContain": [ + "/* >>> constructive-builder design overrides (generated) */", + "/* <<< constructive-builder design overrides */", + ".dark" + ], + "contrast": [ + { "mode": "light", "fg": "--foreground", "bg": "--background", "min": 4.5 }, + { "mode": "dark", "fg": "--foreground", "bg": "--background", "min": 4.5 }, + { "mode": "dark", "fg": "--primary-foreground", "bg": "--primary", "min": 4.5 } + ], + "notes": "Asserting dark direct-copies equal the authored dark: values is the core check — it distinguishes 'honored explicit dark' from 'derived dark'." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.design.md new file mode 100644 index 0000000..f4a84ae --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.design.md @@ -0,0 +1,24 @@ +--- +version: 1 +name: lint-ai-purple-allowed-fixture +description: Same saturated blue-purple primary but allow_brand_hue:true — warning suppressed. +colors: + primary: "oklch(0.55 0.20 285)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.52 0.012 285)" + surface: "oklch(0.995 0.002 285)" + on-surface: "oklch(0.28 0.012 285)" + error: "oklch(0.55 0.17 25)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.75rem" +default_mode: light +allow_brand_hue: true +--- + +# lint-ai-purple-allowed fixture + +Identical to `lint-ai-purple` except `allow_brand_hue: true`. The blue-purple band is now +an **intentional brand choice**, so `lintDesign` must NOT emit an `ai-purple-band` finding +for the primary. This proves the escape hatch works and the rule is opt-out-able. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.expected.json new file mode 100644 index 0000000..9174da4 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple-allowed.expected.json @@ -0,0 +1,9 @@ +{ + "describe": "allow_brand_hue:true suppresses the ai-purple-band warning for an in-band primary.", + "lint": { + "forbidFindings": [ + { "rule": "ai-purple-band" } + ] + }, + "notes": "Pairs with lint-ai-purple. Same color, opposite allow_brand_hue -> opposite verdict on the ai-purple rule." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.design.md new file mode 100644 index 0000000..dd897a1 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.design.md @@ -0,0 +1,26 @@ +--- +version: 1 +name: lint-ai-purple-fixture +description: Saturated blue-purple primary, no allow_brand_hue — must warn ai-purple-band. +colors: + primary: "oklch(0.55 0.20 285)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.52 0.012 285)" + surface: "oklch(0.995 0.002 285)" + on-surface: "oklch(0.28 0.012 285)" + error: "oklch(0.55 0.17 25)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.75rem" +default_mode: light +allow_brand_hue: false +--- + +# lint-ai-purple fixture (INTENTIONALLY FLAGGED) + +The primary `oklch(0.55 0.20 285)` sits squarely in the banned blue-purple band +(hue 285, within 255–310) with chroma 0.20 (well above the ~0.12 "AI-purple" threshold) +and `allow_brand_hue` is false. `lintDesign` must report an `ai-purple-band` finding at +**warn** severity for the primary. See `lint-ai-purple-allowed` for the same color with +`allow_brand_hue: true`, which must SUPPRESS the warning (intentional brand choice). diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.expected.json new file mode 100644 index 0000000..d93e9e7 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-ai-purple.expected.json @@ -0,0 +1,9 @@ +{ + "describe": "Saturated blue-purple primary without allow_brand_hue -> ai-purple-band warn.", + "lint": { + "expectFindings": [ + { "rule": "ai-purple-band", "severity": "warn" } + ] + }, + "notes": "ai-purple-band is a WARN, not an error — the design still compiles. The companion lint-ai-purple-allowed fixture proves allow_brand_hue:true suppresses it." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.design.md new file mode 100644 index 0000000..7afce30 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.design.md @@ -0,0 +1,31 @@ +--- +version: 1 +name: lint-clean-fixture +description: Fully invariant-satisfying design — lintDesign ok:true, zero errors. +colors: + primary: "oklch(0.45 0.04 250)" + primary-foreground: "oklch(0.98 0 0)" + neutral: "oklch(0.50 0.008 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.22 0.005 250)" + error: "oklch(0.55 0.18 27)" + success: "oklch(0.60 0.12 155)" + warning: "oklch(0.75 0.13 80)" + info: "oklch(0.45 0.04 250)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.5rem" +radius: "0.5rem" +default_mode: light +allow_brand_hue: false +--- + +# lint-clean fixture + +A design that satisfies every invariant: exactly one accent (primary), all chroma below +the saturation cap, no pure black (min L 0.22), no AI-purple-band primary (hue 250, just +below the band), a present primary, dimension units in rem, and passing contrast pairs. +`lintDesign` must return `ok:true` with **zero error-severity findings** (info/warn +notes, if any, are acceptable). The positive control for the lint suite. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.expected.json new file mode 100644 index 0000000..e3d7dae --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-clean.expected.json @@ -0,0 +1,21 @@ +{ + "describe": "Fully invariant-clean design -> lintDesign ok:true, zero error findings.", + "lint": { + "ok": true, + "expectFindings": [], + "forbidFindings": [ + { "rule": "missing-primary" }, + { "rule": "ai-purple-band" }, + { "anyRule": ["pure-black", "min-lightness", "pure-black-banned"] } + ], + "maxSeverity": "warn" + }, + "compileOptions": { "defaultMode": "light" }, + "overrideSurfaceOnly": true, + "contrast": [ + { "mode": "light", "fg": "--foreground", "bg": "--background", "min": 4.5 }, + { "mode": "light", "fg": "--primary-foreground", "bg": "--primary", "min": 4.5 }, + { "mode": "dark", "fg": "--foreground", "bg": "--background", "min": 4.5 } + ], + "notes": "Positive control. maxSeverity:'warn' asserts no finding is an error. Also compiles cleanly so it doubles as a second happy-path compile fixture." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.design.md new file mode 100644 index 0000000..4bec30b --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.design.md @@ -0,0 +1,21 @@ +--- +version: 1 +name: lint-missing-primary-fixture +description: No primary color — lintDesign must return ok:false with a missing-primary error. +colors: + neutral: "oklch(0.50 0.01 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.22 0.005 250)" + error: "oklch(0.55 0.18 27)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.5rem" +default_mode: light +--- + +# lint-missing-primary fixture (INTENTIONALLY INVALID) + +`colors.primary` is absent. `invariants.lintDesign` must report `ok:false` with a +`missing-primary` finding at **error** severity. This is the one hard-fail invariant — +a design without a primary cannot be compiled. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.expected.json new file mode 100644 index 0000000..bc4e355 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-missing-primary.expected.json @@ -0,0 +1,10 @@ +{ + "describe": "lintDesign on a design with no primary -> ok:false, missing-primary error.", + "lint": { + "ok": false, + "expectFindings": [ + { "rule": "missing-primary", "severity": "error" } + ] + }, + "notes": "This fixture is NOT compiled (a missing primary cannot map --primary); only lintDesign is asserted." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.design.md new file mode 100644 index 0000000..d69d014 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.design.md @@ -0,0 +1,25 @@ +--- +version: 1 +name: lint-pure-black-fixture +description: on-surface is pure black oklch(0 0 0) — must warn pure-black / min-L. +colors: + primary: "oklch(0.50 0.10 250)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.50 0.01 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0 0 0)" + error: "oklch(0.55 0.18 27)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.5rem" +default_mode: light +allow_brand_hue: false +--- + +# lint-pure-black fixture (INTENTIONALLY FLAGGED) + +`on-surface` is pure black `oklch(0 0 0)` (and the surface is pure white). The invariants +ban pure black (and effectively a too-low minimum lightness, L >= ~0.18) because true +`#000`/`#fff` extremes read harsh and "untuned." `lintDesign` must emit a `pure-black` +(or min-lightness) finding at **warn** severity. The design still compiles. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.expected.json new file mode 100644 index 0000000..0bae3e3 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/lint-pure-black.expected.json @@ -0,0 +1,9 @@ +{ + "describe": "Pure-black on-surface -> pure-black / min-lightness warn.", + "lint": { + "expectFindings": [ + { "anyRule": ["pure-black", "min-lightness", "pure-black-banned"], "severity": "warn" } + ] + }, + "notes": "anyRule matches if ANY listed rule id appears (the contract names it 'pure-black banned / min L>=~0.18' — Agent A may use either id). A test that only knows 'rule' can treat the first id as canonical." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.design.md new file mode 100644 index 0000000..0d09eed --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.design.md @@ -0,0 +1,27 @@ +--- +version: 1 +name: radius-fallback-fixture +description: No top-level radius — must fall back to rounded.md; default_mode dark. +colors: + primary: "oklch(0.62 0.16 200)" + primary-foreground: "oklch(0.20 0.03 230)" + neutral: "oklch(0.52 0.014 220)" + surface: "oklch(0.995 0.003 200)" + on-surface: "oklch(0.27 0.012 230)" + error: "oklch(0.55 0.19 25)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.875rem" +default_mode: dark +allow_brand_hue: false +--- + +# radius-fallback fixture + +No top-level `radius:` key. `compileDesign` must resolve `radius` from `rounded.md` +(`0.875rem`) per the fallback chain `design.radius || rounded.md || '0.5rem'`. Also pins +`--ring` = primary and exercises `default_mode: dark` (which set the override block leads +with for the app's first paint via layout.tsx defaultTheme — the token sets themselves +are emitted for both modes regardless). diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.expected.json new file mode 100644 index 0000000..11dd372 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/radius-fallback.expected.json @@ -0,0 +1,20 @@ +{ + "describe": "radius falls back to rounded.md when top-level radius absent; ring mirrors primary; default_mode dark.", + "compileOptions": { "defaultMode": "dark" }, + "radius": "0.875rem", + "light": { + "--primary": "oklch(0.62 0.16 200)", + "--ring": "oklch(0.62 0.16 200)", + "--background": "oklch(0.995 0.003 200)" + }, + "overrideSurfaceOnly": true, + "mustContain": [ + "/* >>> constructive-builder design overrides (generated) */", + "--radius" + ], + "contrast": [ + { "mode": "light", "fg": "--primary-foreground", "bg": "--primary", "min": 4.5 }, + { "mode": "dark", "fg": "--foreground", "bg": "--background", "min": 4.5 } + ], + "notes": "Primary is a bright cyan with DARK-ink foreground (the vivid-accent pattern) — primary-foreground/primary must still clear 4.5. defaultMode dark is an input option, not a token; it does not change which vars are emitted." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.design.md b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.design.md new file mode 100644 index 0000000..5a96050 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.design.md @@ -0,0 +1,30 @@ +--- +version: 1 +name: tint-foreground-fixture +description: Pins the success/warning tint-foreground contract across light and dark. +colors: + primary: "oklch(0.45 0.04 250)" + primary-foreground: "oklch(0.98 0 0)" + neutral: "oklch(0.50 0.008 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.22 0.005 250)" + error: "oklch(0.55 0.18 27)" + success: "oklch(0.60 0.12 155)" + warning: "oklch(0.75 0.13 80)" + info: "oklch(0.45 0.04 250)" +typography: + sans: Geist + mono: Geist Mono +radius: "0.5rem" +default_mode: light +allow_brand_hue: false +--- + +# tint-foreground fixture + +`--success-foreground` / `--warning-foreground` are **text-on-tint**, not generic +foregrounds. The contract (globals.css C4): in **light** mode they must be *dark on +light* (a deep success/warning hue), and in **dark** mode *light on dark* (a pale +success/warning hue) — never naive white in both. This fixture asserts the foreground +label reads against the status hue with adequate contrast in BOTH modes, which only holds +if the compiler flips them per mode. diff --git a/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.expected.json b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.expected.json new file mode 100644 index 0000000..10f184e --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/__fixtures__/tint-foreground.expected.json @@ -0,0 +1,21 @@ +{ + "describe": "success/warning *-foreground flip dark-on-tint (light) vs light-on-tint (dark); both clear contrast.", + "compileOptions": { "defaultMode": "light" }, + "overrideSurfaceOnly": true, + "mustContain": [ + "--success", "--success-foreground", "--warning", "--warning-foreground" + ], + "contrast": [ + { "mode": "light", "fg": "--success-foreground", "bg": "--success", "min": 4.5 }, + { "mode": "light", "fg": "--warning-foreground", "bg": "--warning", "min": 4.5 }, + { "mode": "dark", "fg": "--success-foreground", "bg": "--success", "min": 4.5 }, + { "mode": "dark", "fg": "--warning-foreground", "bg": "--warning", "min": 4.5 } + ], + "lightnessOrder": [ + { "mode": "light", "darker": "--success-foreground", "lighter": "--success", "why": "light mode: dark text on a light tint" }, + { "mode": "light", "darker": "--warning-foreground", "lighter": "--warning" }, + { "mode": "dark", "darker": "--success", "lighter": "--success-foreground", "why": "dark mode: light text on a deep tint" }, + { "mode": "dark", "darker": "--warning", "lighter": "--warning-foreground" } + ], + "notes": "lightnessOrder asserts OKLCH L(darker) < L(lighter). A naive 'always near-white foreground' implementation fails the LIGHT pairs. If a test runner does not implement lightnessOrder, the four contrast pairs alone already force the flip because a near-white fg on a light tint cannot reach 4.5 in light mode." +} diff --git a/.agents/skills/constructive-builder/fixtures/design/brutalist.md b/.agents/skills/constructive-builder/fixtures/design/brutalist.md new file mode 100644 index 0000000..51e1b64 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/brutalist.md @@ -0,0 +1,60 @@ +--- +version: 1 +name: brutalist +description: Raw high-contrast utility UI — near-black ink accent, square corners, dense and structural. +dials: + variance: 4 + motion: 2 + density: 2 +colors: + primary: "oklch(0.42 0.02 250)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.45 0.006 250)" + surface: "oklch(0.98 0 0)" + on-surface: "oklch(0.20 0.004 250)" + error: "oklch(0.52 0.20 27)" + success: "oklch(0.55 0.13 150)" + warning: "oklch(0.72 0.14 78)" + info: "oklch(0.42 0.02 250)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0rem" +spacing: + base: "0.9rem" +radius: "0rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.92 0.01 250)" + primary-foreground: "oklch(0.16 0.005 250)" + neutral: "oklch(0.68 0.006 250)" + surface: "oklch(0.19 0.003 250)" + on-surface: "oklch(0.98 0 0)" + error: "oklch(0.55 0.20 27)" + success: "oklch(0.60 0.13 150)" + warning: "oklch(0.78 0.14 80)" + info: "oklch(0.92 0.01 250)" +--- + +# Brutalist + +## Overview + +A **raw, high-contrast utility** look — structural and unapologetic, all edges and ink. +The accent is a **near-black, almost-achromatic ink** (`oklch(0.42 0.02 250)`, chroma +0.02): the "brand color" is essentially deep gray, so emphasis comes from **stark +contrast, hairline borders, and square corners**, not hue. Corners are fully square +(`0rem`), density is tight (dial 2) for an information-dense grid feel, and motion is +near-zero (dial 2) — the UI does not animate, it just *is*. + +Light mode is bright off-white with a heavy near-black ink and ink-black primary fills; +dark mode flips to a true near-black surface with a near-white primary, so a button reads +as a hard light slab on dark — the inverse of the light slab on white. Crucially, the +status hues stay **chromatic** (a vivid red error, a clear green success, an amber +warning): in an otherwise monochrome system the only color is *meaning*, which makes state +unmistakable. That single restraint keeps brutalist legible instead of merely austere. + +Use it for developer tools, dense back-office grids, or any product that wants to look +engineered and exacting rather than friendly. diff --git a/.agents/skills/constructive-builder/fixtures/design/constructive.md b/.agents/skills/constructive-builder/fixtures/design/constructive.md new file mode 100644 index 0000000..fdaccfc --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/constructive.md @@ -0,0 +1,63 @@ +--- +version: 1 +name: constructive +description: The current default Constructive look — the opt-out preset. +colors: + primary: "oklch(0.688 0.1754 245.6151)" + primary-foreground: "oklch(0.979 0.021 166.113)" + neutral: "oklch(0.552 0.016 285.938)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.3211 0 0)" + error: "oklch(0.55 0.2 25)" + success: "oklch(0.62 0.14 158)" + warning: "oklch(0.75 0.14 78)" + info: "oklch(0.688 0.1754 245.6151)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.5rem" +spacing: + base: "1rem" +radius: "0.5rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.688 0.1754 245.6151)" + primary-foreground: "oklch(0.979 0.021 166.113)" + neutral: "oklch(0.705 0.015 286.067)" + surface: "oklch(0.21 0.006 285.885)" + on-surface: "oklch(0.985 0 0)" + error: "oklch(0.55 0.2 25)" + success: "oklch(0.696 0.17 162.48)" + warning: "oklch(0.828 0.189 84.429)" + info: "oklch(0.688 0.1754 245.6151)" +--- + +# Constructive (default) + +## Overview + +This is **today's Constructive look transcribed into design.md role form** — the +explicit opt-out. Selecting it (`design: { preset: constructive }`) is a NO-OP that +reproduces the current boilerplate palette exactly, so a build keeps the look it has +always had. The values here are a faithful record of `globals.css` `:root` and `.dark`, +re-expressed as the small set of design roles (`surface`, `on-surface`, `primary`, +`neutral`, status hues) the compiler remaps onto the shadcn token surface. + +The identity is a **calm sky-blue primary** (`oklch(0.688 0.1754 245.6)`, hue ~246 — a +genuine sky blue that sits just *below* the banned blue-purple band, never "AI purple") +on a **clean, near-neutral gray** scale with a barely-warm-cool temperature. Surfaces are +pure white in light and a deep cool charcoal in dark; foregrounds are near-black / near- +white for crisp body text. The radius is a moderate `0.5rem` and type is Geist sans / +Geist mono. + +Use it when you want zero restyling — the dependable, trust-first dashboard baseline. + +> Fidelity note: `primary-foreground` is transcribed verbatim from the boilerplate +> (`oklch(0.979 0.021 166.113)`, a near-white greenish tint). Its measured contrast on +> the sky-blue primary is ~2.6:1, *below* WCAG AA for small text — this is a property of +> the **current** default, preserved here so the opt-out is byte-faithful. When this +> preset is actually compiled (rather than treated as a pure no-op), the compiler's +> `ensureContrast` may nudge `primary-foreground` to pass; that nudge is the only place +> the rendered result can differ from today's pixels, and only for the on-primary label. diff --git a/.agents/skills/constructive-builder/fixtures/design/editorial.md b/.agents/skills/constructive-builder/fixtures/design/editorial.md new file mode 100644 index 0000000..b817584 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/editorial.md @@ -0,0 +1,60 @@ +--- +version: 1 +name: editorial +description: Warm print-inspired UI — terracotta ink accent on warm paper, calm and trustworthy. +dials: + variance: 7 + motion: 4 + density: 4 +colors: + primary: "oklch(0.50 0.10 28)" + primary-foreground: "oklch(0.98 0.01 60)" + neutral: "oklch(0.50 0.012 55)" + surface: "oklch(0.99 0.004 70)" + on-surface: "oklch(0.26 0.012 50)" + error: "oklch(0.52 0.19 27)" + success: "oklch(0.55 0.11 150)" + warning: "oklch(0.72 0.12 75)" + info: "oklch(0.50 0.10 28)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.25rem" +spacing: + base: "1.05rem" +radius: "0.25rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.70 0.11 35)" + primary-foreground: "oklch(0.20 0.02 35)" + neutral: "oklch(0.70 0.012 60)" + surface: "oklch(0.22 0.010 50)" + on-surface: "oklch(0.95 0.006 70)" + error: "oklch(0.55 0.19 27)" + success: "oklch(0.60 0.11 150)" + warning: "oklch(0.76 0.12 78)" + info: "oklch(0.70 0.11 35)" +--- + +# Editorial + +## Overview + +A **warm, print-inspired** look — calm, high-end, and trustworthy, the way a +well-set magazine page feels. The accent is a **terracotta ink-red** +(`oklch(0.50 0.10 28)`, hue ~28): saturated enough to feel intentional and editorial, +muted enough never to read as an alert. The neutral ramp is given a **warm temperature** +(low chroma toward hue ~55), and the surface is a faint **warm paper white** rather than +clinical pure white, with a soft warm-black ink on top. The whole palette shares one warm +family, which is what gives it the "printed, considered" calm. + +Corners are nearly square (`0.25rem`) for a crisp typographic edge, density is generous +(dial 4) to let text breathe, and motion is gentle (dial 4). Dark mode becomes a warm +near-black "evening reading" surface with a lifted clay accent and warm off-white ink, so +the print feeling survives the inversion. Status hues are kept earthy so they harmonize +with the warm body rather than puncturing it. + +Use it for content-forward, document-heavy, or premium B2B products where the experience +should feel authored and unhurried. diff --git a/.agents/skills/constructive-builder/fixtures/design/minimalist.md b/.agents/skills/constructive-builder/fixtures/design/minimalist.md new file mode 100644 index 0000000..d2e2345 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/minimalist.md @@ -0,0 +1,59 @@ +--- +version: 1 +name: minimalist +description: Quiet, near-monochrome dashboard — one restrained slate-blue accent on clean neutrals. +dials: + variance: 5 + motion: 3 + density: 3 +colors: + primary: "oklch(0.45 0.04 250)" + primary-foreground: "oklch(0.98 0 0)" + neutral: "oklch(0.50 0.008 250)" + surface: "oklch(1 0 0)" + on-surface: "oklch(0.22 0.005 250)" + error: "oklch(0.55 0.18 27)" + success: "oklch(0.60 0.12 155)" + warning: "oklch(0.75 0.13 80)" + info: "oklch(0.45 0.04 250)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.375rem" +spacing: + base: "1rem" +radius: "0.375rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.72 0.05 250)" + primary-foreground: "oklch(0.18 0.01 250)" + neutral: "oklch(0.70 0.008 250)" + surface: "oklch(0.20 0.004 250)" + on-surface: "oklch(0.97 0 0)" + error: "oklch(0.55 0.18 27)" + success: "oklch(0.62 0.12 155)" + warning: "oklch(0.78 0.13 80)" + info: "oklch(0.72 0.05 250)" +--- + +# Minimalist + +## Overview + +A **quiet, near-monochrome** dashboard skin. The whole interface is built from one +neutral gray ramp; the single accent is a **deeply restrained slate-blue** +(`oklch(0.45 0.04 250)`) with chroma so low (0.04) it reads almost as "dark gray with a +hint of blue." That is deliberate: hierarchy here comes from **weight and spacing, not +color**. The accent is reserved for the one primary action and focus rings — everything +else is type on neutral. + +Corners are tight (`0.375rem`), density is comfortable-tight (dial 3), and motion is +minimal (dial 3) — transitions exist but never perform. Light mode is pure white with a +near-black cool ink; dark mode is a deep cool charcoal with a near-white ink and a +slightly lifted accent so the one action still reads. Status hues (error/success/warning) +are present but muted so a green "saved" pill or a red "failed" row never shouts. + +Reach for this when the data is the point — admin panels, internal tools, reporting — +and any decoration would be noise. diff --git a/.agents/skills/constructive-builder/fixtures/design/playful.md b/.agents/skills/constructive-builder/fixtures/design/playful.md new file mode 100644 index 0000000..7daa963 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/playful.md @@ -0,0 +1,64 @@ +--- +version: 1 +name: playful +description: Energetic friendly app — a vivid cyan-teal accent with dark-ink labels, rounded and lively. +dials: + variance: 9 + motion: 8 + density: 4 +colors: + primary: "oklch(0.60 0.15 200)" + primary-foreground: "oklch(0.20 0.03 230)" + neutral: "oklch(0.52 0.014 220)" + surface: "oklch(0.995 0.003 200)" + on-surface: "oklch(0.27 0.012 230)" + error: "oklch(0.55 0.19 25)" + success: "oklch(0.60 0.13 158)" + warning: "oklch(0.76 0.14 80)" + info: "oklch(0.60 0.15 200)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.875rem" +spacing: + base: "1rem" +radius: "0.875rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.74 0.135 200)" + primary-foreground: "oklch(0.18 0.02 200)" + neutral: "oklch(0.72 0.014 210)" + surface: "oklch(0.22 0.012 230)" + on-surface: "oklch(0.97 0.004 200)" + error: "oklch(0.55 0.19 25)" + success: "oklch(0.66 0.13 158)" + warning: "oklch(0.80 0.14 85)" + info: "oklch(0.74 0.135 200)" +--- + +# Playful + +## Overview + +An **energetic, friendly** app skin — lively but still legible, the way a good consumer +product feels approachable without becoming a toy. The accent is a **vivid cyan-teal** +(`oklch(0.60 0.15 200)`): bright and cheerful, sitting safely outside the banned +blue-purple band. Because cyan is a high-luminance hue, its labels use **dark ink rather +than white** (`primary-foreground` is a deep teal-black) — a bright pill button with dark +text both reads better *and* looks more playful than muddy white-on-cyan, and it clears +WCAG AA comfortably. + +Corners are large and friendly (`0.875rem`), motion is the most present of any preset +(dial 8) for bouncy, delightful transitions — still gated behind `prefers-reduced-motion` +— and density stays comfortable (dial 4) so the energy never crowds the content. Light +mode is a barely-cyan-tinted white; dark mode is a cool teal-charcoal with a brightened +cyan accent (again dark-ink labels) so the cheer survives the inversion. Status hues are +kept bright and saturated to match the upbeat tone. + +Use it for consumer apps, community products, education, or anything onboarding-heavy +where the interface should feel welcoming and alive. + +> A single accent still holds: the cyan primary is the only chromatic brand color; +> everything else is the cool-neutral ramp plus the meaning-only status hues. diff --git a/.agents/skills/constructive-builder/fixtures/design/soft.md b/.agents/skills/constructive-builder/fixtures/design/soft.md new file mode 100644 index 0000000..3837de8 --- /dev/null +++ b/.agents/skills/constructive-builder/fixtures/design/soft.md @@ -0,0 +1,65 @@ +--- +version: 1 +name: soft +description: High-end SaaS skin — a gentle muted-lavender accent on soft cool neutrals, rounded and calm. +dials: + variance: 7 + motion: 6 + density: 4 +colors: + primary: "oklch(0.55 0.095 285)" + primary-foreground: "oklch(0.99 0 0)" + neutral: "oklch(0.52 0.012 285)" + surface: "oklch(0.995 0.002 285)" + on-surface: "oklch(0.28 0.012 285)" + error: "oklch(0.55 0.17 25)" + success: "oklch(0.62 0.11 158)" + warning: "oklch(0.76 0.12 82)" + info: "oklch(0.55 0.095 285)" +typography: + sans: Geist + mono: Geist Mono +rounded: + md: "0.75rem" +spacing: + base: "1.05rem" +radius: "0.75rem" +default_mode: light +allow_brand_hue: false +dark: + primary: "oklch(0.74 0.095 285)" + primary-foreground: "oklch(0.20 0.02 285)" + neutral: "oklch(0.72 0.012 285)" + surface: "oklch(0.23 0.012 285)" + on-surface: "oklch(0.96 0.004 285)" + error: "oklch(0.55 0.17 25)" + success: "oklch(0.66 0.11 158)" + warning: "oklch(0.80 0.12 85)" + info: "oklch(0.74 0.095 285)" +--- + +# Soft + +## Overview + +A **high-end, gentle SaaS** skin — the rounded, calm, slightly premium feel of a modern +product marketing-site-turned-app. The accent is a **muted lavender-indigo** +(`oklch(0.55 0.095 285)`): it lives in the cool blue-purple region but its chroma is +deliberately held low (0.095) so it reads as a *sophisticated, desaturated lavender*, not +the over-saturated "AI purple" the invariants ban. The neutral ramp shares that faint cool +hue, so surfaces feel tinted-soft rather than clinical, and the whole palette is one calm +cool family. + +Corners are generously rounded (`0.75rem`), density is comfortable (dial 4), and motion is +a touch more present (dial 6) for soft, premium transitions — still tasteful, never showy. +Light mode is a barely-tinted off-white; dark mode is a soft cool charcoal with a lifted +lavender accent and off-white ink, keeping the gentle character after inversion. Status +hues are kept soft so success/warning pills feel like part of the same refined system. + +Use it for consumer-facing or premium-tier SaaS where the product should feel polished, +approachable, and a little aspirational. + +> Hue note: the lavender sits inside the blue-purple band the invariants guard, but its +> chroma (0.095) is below the 0.12 "AI-purple" threshold, so it passes cleanly with +> `allow_brand_hue: false`. If you push this accent more saturated, set +> `allow_brand_hue: true` to make that an intentional brand choice rather than an accident. diff --git a/.agents/skills/constructive-builder/references/brief-grammar.md b/.agents/skills/constructive-builder/references/brief-grammar.md index 7e21cf0..351a909 100644 --- a/.agents/skills/constructive-builder/references/brief-grammar.md +++ b/.agents/skills/constructive-builder/references/brief-grammar.md @@ -41,6 +41,7 @@ The brief is **intent-level**: you pick WHAT (a `modules.preset`, a set of `flow | `data_model` | `data_model.tables` non-empty | your domain tables + relations — see [Data model](#data-model-tables) | | `ui` | optional | one route per surface (`ui.routes[]`); `kind` selects how the page is emitted — see [UI routes](#ui-routes) | | `acceptance` | optional | `required_flows[]` the live-QA gate verifies end-to-end (the SAME flow ids as `flows`) | +| `design` | optional | look-and-feel intent (theme + layout dials). **ABSENT ⇒ auto-propose** a domain-fitting theme; `{ preset: constructive }` ⇒ keep today's look. See [design (optional)](#design-optional) + [design-system.md](./design-system.md) | | `assumptions` | optional | free-text notes | > **App-id / per-app state.** The build-state id (`APP_ID`) is derived from `naming.db_name`, sanitized to @@ -553,3 +554,80 @@ non-b2b preset **fails validation**. Full file: `fixtures/test-memberowner-brief For access models beyond the seven intents (peer ownership, composite, related-member-list, …), drop to `nodes_raw` / `policies_raw` and the **`constructive-security`** skill. See also `fixtures/test-childfk-brief.yaml` for a multi-table FK shape. + +--- + +## design (optional) + +`design:` is an **additive, optional** top-level block that shapes the generated app's **look and feel** +— the theme tokens (colors / radius / fonts) plus the layout **dials** (variance / motion / density). It +sits alongside the other top-level sections (logically near `ui`, since it shapes presentation). It is +purely additive: a brief **without** a `design:` block is fully valid and predates this feature. + +> **Default = auto-propose.** When `design:` is **absent**, the build **auto-proposes** a full, +> domain-fitting theme (the new baseline — a generated app should not ship the stock Constructive blue +> unless asked). The build agent reads `app.label`/`app.description` + entity names, classifies the look +> into the three dials, picks/adapts a preset, and authors a `design.md` that the deterministic engine +> lints (invariants + WCAG contrast) and compiles into the app's `globals.css` token overrides. The +> reasoning methodology — dials, the words→dials table, the color invariants, the `design.md` format, the +> preset catalog, and the compile/override contract — lives in **[design-system.md](./design-system.md)**. +> This block is how a brief *constrains or overrides* that auto-proposal; it is **not** required to get a +> theme. + +> **Opt-out = keep today's look.** `design: { preset: constructive }` is the explicit opt-out: the design +> step is a **no-op** and the boilerplate `globals.css` is left exactly as shipped (the stock blue, +> light-first theme). Use it when a brief deliberately wants the default look. + +### Shape (every field optional) + +```yaml +design: + brief: "warm editorial, calm, trustworthy, high-end print feel" # natural-language style words → dials + preset: minimalist # a named archetype anchor: constructive | minimalist | trust-first | + # editorial | soft | brutalist | playful (constructive = opt-out / no-op) + dials: { variance: 5, motion: 3, density: 3 } # explicit override of the inferred dials (each 1–10) + colors: # role-level palette overrides (semantic roles, NOT shadcn var names) + primary: "oklch(0.55 0.11 162)" # the ONE brand/action color (required if you give `colors`) + accent: "oklch(0.7 0.12 250)" # at most ONE accent + neutral: "oklch(0.55 0.01 250)" # ONE gray temperature + surface: "oklch(0.99 0.004 250)" + on-surface: "oklch(0.27 0.01 250)" + error: "oklch(0.55 0.2 25)" + font: { sans: "Geist", mono: "Geist Mono" } # next/font/google allowlist (off-list → Geist + warn) + radius: "0.5rem" # seeds --radius (px / em / rem) + default_mode: dark # which theme loads first → layout.tsx ThemeProvider defaultTheme (light | dark) + allow_brand_hue: true # opt out of the "AI purple/blue" hue-band warning for a deliberate brand hue +``` + +Colors accept `oklch(L C H)`, `#rrggbb`, or `rgb(…)`. Every key is optional — supply only what you want +to pin and let the engine synthesize the rest. The three common shapes: + +- **`design:` absent** → auto-propose a theme (default). +- **`design: { preset: }`** → anchor on a named preset, auto-fill the palette/dials from it. + `preset: constructive` is the special no-op opt-out. +- **`design: { brief: "", colors: { primary: … } }`** → classify the words to dials, then pin the + brand color explicitly; the engine derives everything else and enforces the invariants + contrast. + +### Validation (optional strictness — `validateDesign`) + +The `design:` block has **no required keys**, so any brief with a syntactically valid `design:` mapping +passes. When the block is present, `scripts/lib/brief-policy.mjs` runs an **optional** `validateDesign` +pass that fails fast with a **legible** error on a *malformed* block (so a typo never silently reaches the +compiler) while **tolerating unknown keys** (forward-compatible): + +- `design` must be a mapping (not a list/scalar). +- `preset`, if set, must be one of the known anchors (`constructive | minimalist | trust-first | + editorial | soft | brutalist | playful`). +- `dials`, if set, must be a mapping; each present dial (`variance`/`motion`/`density`) must be an integer + in **1–10**. +- `colors`, if set, must be a mapping; each value (e.g. `primary`/`accent`/`neutral`/`surface`/ + `on-surface`/`error`) must be a string color token. (Deeper invariants — ≤1 accent, chroma cap, + AI-purple ban, **WCAG contrast** — are enforced by the deterministic linter `check-design.mjs`, not + here; this is shape-validation only.) +- `font`, if set, must be a mapping (`sans`/`mono` string family names). +- `radius`, if set, must be a string (px/em/rem). +- `default_mode`, if set, must be `light` or `dark`. +- `allow_brand_hue`, if set, must be a boolean. + +Anything not listed is passed through untouched (unknown keys tolerated). The full reasoning + the +compile/override contract are in **[design-system.md](./design-system.md)**. diff --git a/.agents/skills/constructive-builder/references/design-system.md b/.agents/skills/constructive-builder/references/design-system.md new file mode 100644 index 0000000..299e680 --- /dev/null +++ b/.agents/skills/constructive-builder/references/design-system.md @@ -0,0 +1,338 @@ +# Design System — authoring a `design.md` (theme + layout taste) + +> **What this is.** The methodology a **build agent** reads to give a generated app a *coherent, +> legible, non-generic* look — without hand-picking hex codes and without breaking shadcn or the +> installed Blocks. You read style intent (from `app.label` / `app.description` / entity names / an +> optional `design.brief`), **classify it into three dials**, pick/adapt a **preset**, and **author a +> `design.md`** (Google-Labs format) that commits to ONE accent + a coherent palette. The deterministic +> engine (`scripts/check-design.mjs` + `scripts/wire-design.mjs`) then **validates** the invariants + +> **WCAG contrast** and **compiles** the theme into the app's `globals.css` token overrides. +> +> **This is GUIDELINES, not a lookup table.** There is deliberately **no** "domain → palette" map +> (that would violate the genericity principle). You *reason* from words to dials to a palette; the +> compiler *enforces* correctness. Presets are anchors you adapt, never constraints. + +> **The contract in one line.** `design.md` (intent) → `compileDesign` (role-remap + OKLCH +> dark-derivation + WCAG repair) → a single **marked override block** of shadcn token *values* in the +> app's `globals.css`. Overriding token *values* restyles the template UI **and every installed Block +> at once** (they all read `var(--…)`). Structure is off-limits. + +--- + +## 1. When the design step runs (and when it's a no-op) + +- **Default (the `design:` block is ABSENT):** **auto-propose** a full, domain-fitting theme on every + build. This is the new baseline — a generated app should not ship the stock Constructive blue unless + asked. Author a `design.md`, lint it, compile it. +- **Opt-out (`design: { preset: constructive }`):** **keep today's look exactly.** `wire-design` is a + **no-op** — the boilerplate `globals.css` is left untouched. Use this when the brief explicitly wants + the stock theme, or when a downstream consumer pins the default. +- **Compile failure / impossible contrast:** **no-op + loud warning**, never a half-written theme. The + stock look survives; you fix the `design.md` and re-run. + +The step is wired into the build at **S6.5** (after the Blocks `@import` at S5, before the domain CRUD +body at S7) — see [speedrun.md](./speedrun.md). It is idempotent and `--dry-run`-able. + +--- + +## 2. The three dials (reused from the taste-skill) + +Every look reduces to three integer dials, **1–10**. They are the bridge from words to tokens + layout. + +| Dial | 1 ………………… 10 | Drives | +|---|---|---| +| **VARIANCE** | flat / monochrome → bold / high-contrast / saturated | palette chroma + accent strength, surface↔foreground ΔL, border visibility | +| **MOTION** | none / instant → lively / springy | transition durations, hover/enter animation (always `prefers-reduced-motion`-gated) | +| **DENSITY** | airy / generous whitespace → compact / data-dense | padding, gap, row height, font-size step on entity pages | + +> **Bias for apps.** This builder makes **applications** (dashboards, CRUD tools, internal SaaS), not +> Awwwards landing pages. Bias every classification toward the **trust-first / minimalist** rows below. +> Theatrics (high VARIANCE + high MOTION) must be *earned* by the brief, not the default. + +### words → dials → preset + +Classify the natural-language style words into a row. The preset is the **anchor** you then adapt. + +| If the words say… | dials (VARIANCE / MOTION / DENSITY) | preset anchor | +|---|---|---| +| calm, trustworthy, neutral, "just works", admin, finance, healthcare, enterprise | **3–4 / 2–3 / 4–5** | `trust-first` | +| clean, simple, focused, minimal, content-first, quiet | **5–6 / 3–4 / 2–3** | `minimalist` | +| refined, high-end, editorial, premium, elegant, "print feel", luxe | **7–8 / 5–7 / 3–4** | `premium` (→ `editorial` / `soft`) | +| fun, friendly, energetic, playful, consumer, vibrant, bold | **9–10 / 8–10 / 3–4** | `playful` | +| raw, stark, utilitarian, brutalist, monospace, "no chrome" | **6–7 / 1–2 / 5–6** | `brutalist` | + +> **Unsure?** Default to **`trust-first`** (3–4 / 2–3 / 4–5) — the safe, legible app baseline. A boring +> app that works beats a beautiful one that fails contrast. + +Each dial maps to one of the named presets in §4. The dials are also threaded into the layout pass +(DENSITY → `data-density` on entity pages, MOTION → gated transitions) — see §8. + +--- + +## 3. Color invariants (enforced by `invariants.mjs` / `check-design.mjs`) + +These are the taste guardrails as code. The agent should *author within* them; the linter *fails the +build* if violated. Author the palette to satisfy them up front — don't make the linter do the work. + +| Rule | Severity | Why | +|---|---|---| +| **`primary` required** | **error** | a theme with no primary has nothing to remap | +| **≤ 1 accent** | warn | a second accent reads as noise; one accent + neutrals is the disciplined look | +| **saturation / chroma < ~80%** (OKLCH `c` capped) | warn | screaming-saturated tokens fatigue and clash with shadcn neutrals | +| **no pure black** (min lightness `L ≥ ~0.18`) | warn | pure `#000` text/surfaces look harsh; near-black reads as intentional | +| **ban the "AI purple/blue" band** for `primary`/`accent` | warn | hue ≈ **255–310** with `c > ~0.12` is the generic-AI tell; pick a hue with intent. Override with `allow_brand_hue: true` only when that hue is a *deliberate brand color* | +| **one gray temperature** | (authoring) | derive neutrals from ONE hue so grays don't fight (warm vs cool) | +| **dimension units px / em / rem only** | warn | unitless or exotic units don't compile cleanly | +| **contrast pairs** (fg/bg, primary/primary-fg, muted-fg/bg, destructive, status tints) | **error < 3:1**, warn < 4.5 | legibility floor; the compiler also auto-repairs (§6) | +| **success/warning `*-foreground` tint contract** | warn | text-on-tint must flip per mode (§6 gotcha) | + +> **No green-washing.** Contrast is computed (WCAG relative luminance over the OKLCH→sRGB conversion), +> not asserted. A theme that can't pass at 4.5:1 after foreground-nudging **hard-fails** with a clear +> message — fix the palette, don't lower the bar. + +--- + +## 4. The preset catalog (anchors — names + when to use) + +Each preset is a complete, lint-passing `design.md` in `fixtures/design/.md`. They double as the +deterministic compiler fixtures. **Pick the closest, then adapt** the colors/dials to the brief — never +treat a preset as a fixed skin. + +| Preset | Dials (V/M/D) | The look | Reach for it when | +|---|---|---|---| +| **`constructive`** | (= stock) | the current Constructive blue, light-first | **the opt-out** — `design: { preset: constructive }` keeps today's look (no-op) | +| **`minimalist`** | 5–6 / 3–4 / 2–3 | restrained palette, generous type, few borders | content-first apps, "clean & simple", default when in doubt-but-not-trust | +| **`trust-first`** | 3–4 / 2–3 / 4–5 | calm neutrals, low chroma, dense + legible | admin / finance / healthcare / enterprise; **the safe default** | +| **`editorial`** | 7–8 / 5–7 / 3–4 | high-end print feel, serif headings, warm neutrals | publishing, blogs, "editorial / high-end print" | +| **`soft`** | 7–8 / 5–7 / 3–4 | premium, soft surfaces + elevation, gentle radii | polished consumer SaaS, "premium / elegant / soft" | +| **`brutalist`** | 6–7 / 1–2 / 5–6 | stark, monospace, hard borders, near-zero radius | utilitarian / developer tools / "raw / no chrome" | +| **`playful`** | 9–10 / 8–10 / 3–4 | vivid accent, rounder radii, livelier motion | consumer / friendly / "fun & energetic" (use the chroma cap!) | + +> Presets are **starting points**. A "calm fintech with a deep-green brand" = `trust-first` anchor + +> `colors.primary` set to that green. You are not limited to the seven palettes. + +--- + +## 5. The `design.md` format (the intermediate representation) + +A `design.md` is **Markdown with a YAML frontmatter** (Google Labs `design.md` open standard; we conform +to the format, we do **not** depend on its CLI). The frontmatter is parsed by the skill's existing +zero-dep YAML reader — **no new dependency**. It is emitted into the app (e.g. `packages/app/design.md`) +as the durable design record + day-2 input. + +### 5.1 Frontmatter schema + +| Key | Required | Shape | Notes | +|---|---|---|---| +| `version` | optional | int | document version (e.g. `1`) | +| `name` | **required** | string | a short theme name (e.g. `"calm-fintech"`) | +| `description` | optional | string | one-line intent | +| `colors` | **required** | map | the palette **roles** (see below). `primary` is required | +| `typography` | optional | map | `{ sans, mono, serif? }` — font *family names* (resolved against the allowlist, §7) | +| `rounded` | optional | map or scalar | `{ sm, md, lg }` or a single radius; `md` (or the scalar) seeds `--radius` | +| `spacing` | optional | map | base spacing scale (informational; density rides the dials, §8) | +| `dials` | optional | map | `{ variance, motion, density }` — recorded so the layout pass + day-2 reads them | +| `components` | optional | map | per-component hints (advisory; the compile contract is token-level) | +| `dark` | optional | map | explicit dark-mode overrides — **escape hatch** when OKLCH auto-derivation (§6) isn't pretty enough | +| `default_mode` | optional | `light` \| `dark` | which theme loads first (→ `layout.tsx` `ThemeProvider defaultTheme`) | +| `allow_brand_hue` | optional | bool | opt out of the AI-purple-band warning for a *deliberate* brand hue | + +**`colors` roles** (semantic, NOT shadcn var names — the compiler maps roles → vars in §6): + +| Role | Meaning | Required | +|---|---|---| +| `primary` | the one brand/action color | **yes** | +| `accent` | at most ONE secondary highlight | no (≤1) | +| `neutral` | the gray family (ONE temperature) — drives secondary/muted/border | recommended | +| `surface` | page/card background base | recommended | +| `on-surface` | default text/foreground over surface | recommended | +| `error` | destructive / danger | no (falls back to a sane red) | + +Colors may be `oklch(L C H)`, `#rrggbb`, or `rgb(…)` — the engine parses all three and normalizes to +OKLCH internally. + +### 5.2 A short example + +```markdown +--- +version: 1 +name: calm-fintech +description: trustworthy, low-chroma, dense and legible — a finance admin +dials: { variance: 4, motion: 2, density: 5 } +colors: + primary: "oklch(0.55 0.11 162)" # a deep, calm green (NOT the AI-purple band) + neutral: "oklch(0.55 0.01 250)" # one cool gray temperature + surface: "oklch(0.99 0.004 250)" # near-white, faint cool tint + on-surface: "oklch(0.27 0.01 250)" # near-black, never #000 + error: "oklch(0.55 0.2 25)" +typography: { sans: "Geist", mono: "Geist Mono" } +rounded: { md: "0.375rem" } +default_mode: light +--- + +# Calm Fintech + +A trust-first admin theme. One green action color, a single cool gray ramp, generous-but-dense +spacing. Dark mode derives automatically by lightness inversion. +``` + +Everything is optional except `name` + `colors.primary`. The compiler synthesizes the missing tokens +(border, input, ring, muted-foreground, card/popover elevation, sidebar, chart ramp) from +primary/surface/neutral via OKLCH math. + +--- + +## 6. The compile / override contract (what `compileDesign` may touch) + +`compileDesign(design, { defaultMode })` returns `{ light, dark, radius, fonts, warnings }`; +`renderOverrideBlock({light,dark})` emits a **single marked block** appended to the app's `globals.css`. + +### 6.1 Role → shadcn var map (light; dark is derived) + +| shadcn var | derived from | +|---|---| +| `background` | `surface` | +| `foreground` | `on-surface` | +| `card` / `popover` (+ their `-foreground`) | `surface` (+ a small ΔL elevation) / `on-surface` | +| `primary` | `colors.primary`; **`primary-foreground` = `ensureContrast(auto light/dark, primary)`** | +| `secondary` (+ `-foreground`) | a neutral-muted surface / `on-surface` | +| `muted` / `muted-foreground` | neutral subtle / toward `on-surface` (kept ≥ 4.5:1 on background) | +| `accent` (+ `-foreground`) | `colors.accent` ‖ tertiary ‖ desaturated `primary` / contrast | +| `destructive` (+ `-foreground`) | `colors.error` / contrast | +| `border` | `surface` shifted ~10% ΔL toward `on-surface` | +| `input` | `border`, slightly stronger | +| `ring` | `primary` | +| `chart-1..5` | `primary` hue rotations `[0, +40, -40, +90, -90]` at fixed chroma/L | +| `sidebar*` | derived from `surface` / `neutral` + `primary` | +| `radius` | `design.radius` ‖ `rounded.md` ‖ `0.5rem` | + +### 6.2 Override surface — the ONLY vars the compiler may emit + +``` +background, foreground, card, card-foreground, popover, popover-foreground, +primary, primary-foreground, secondary, secondary-foreground, muted, muted-foreground, +accent, accent-foreground, destructive, destructive-foreground, border, input, ring, +chart-1 … chart-5, +sidebar, sidebar-foreground, sidebar-primary, sidebar-primary-foreground, +sidebar-accent, sidebar-accent-foreground, sidebar-border, sidebar-ring, +info, info-foreground, success, success-foreground, warning, warning-foreground, radius +``` + +The block is delimited by **exact sentinels** (byte-identical across the engine + the codemod): + +```css +/* >>> constructive-builder design overrides (generated) */ +:root { /* …override-surface vars… */ } +.dark { /* …override-surface vars… */ } +/* <<< constructive-builder design overrides */ +``` + +It is placed **after** the `.dark` block and **before** `@theme inline {` so it wins by source order — +and naturally wins over the Blocks `@import` (which sits above it). Re-running locates the sentinels and +**replaces in place** (idempotent); `--dry-run` reports the diff without writing. + +### 6.3 STRUCTURAL — never emit or alter + +`@theme inline`, `@source`, `@custom-variant dark`, `@plugin`, the **second** z-layer `:root` block +(`--z-layer-*`), `@layer base`, `@layer utilities`, the `[data-slot=…]` skeleton/portal rules, +`--shadow-*`, `--font-serif`, and the `--radius-*` derivations inside `@theme inline`. Touching any of +these breaks Tailwind wiring, overlay stacking, or skeleton animation. + +### 6.4 Dark-mode derivation + +When `design.dark` is **absent**, `.dark` is derived from light by **OKLCH lightness inversion** (keep +hue + chroma, clamp into range) with foregrounds **re-paired** for contrast. When `design.dark` is +present, it wins (the escape hatch for when auto-derivation isn't pretty enough). `default_mode` sets +which loads first via `layout.tsx`'s `ThemeProvider defaultTheme` (boilerplate default is `dark`). + +### 6.5 The three gotchas (each one is a real footgun) + +1. **Fonts change ONLY via the `layout.tsx` loader swap — never via a `:root --font-*` value.** The + boilerplate's `:root { --font-sans: Open Sans }` is **dead**: it is shadowed by `@theme inline + --font-sans: var(--font-geist-sans)`. So `--font-sans`/`--font-serif`/`--font-mono` are **NOT** in the + override surface. To change the typeface, the font codemod swaps the `next/font/google` loader import + in `layout.tsx` **while keeping the variable NAMES** `--font-geist-sans` / `--font-geist-mono` (and the + `` className tokens) intact, so `@theme inline` still resolves. See §7. +2. **`success`/`warning`/`info` `*-foreground` are text-on-tint — they must FLIP per mode.** In the + boilerplate they are dark-on-light in **light** mode (`*-700`) and light-on-dark in **dark** mode + (`*-400`). A naive `white` foreground fails on a light-mode amber/emerald tint. The compiler honors + this contract (and the linter warns if a tint pair is illegible). The boilerplate ships these as + Tailwind palette refs (`var(--color-emerald-500)` …); the compiler may replace them with OKLCH tint + values — but if it does, it MUST keep the flip. +3. **Contrast is repaired, not assumed.** `ensureContrast(fg, bg, target)` nudges the foreground's + lightness toward a pass before emitting. If a critical pair can't reach the target, compile + **hard-fails** with the offending pair named — it never green-washes. + +--- + +## 7. Fonts (the `next/font/google` allowlist) + +Typeface choice is constrained to a curated allowlist of `next/font/google` families so the build never +breaks on a missing/typo'd font: **Geist, Geist Mono, Outfit, Sora, Manrope, JetBrains Mono, IBM Plex +Sans, IBM Plex Mono** (and similar). `resolveFont(name)` returns `{ loaderName, importLine, variable }` +or **falls back to Geist + a warning** for anything off-list. + +- Set the typeface in `design.md` `typography.{ sans, mono }`. +- The codemod swaps **only** the loader import + the `const X = Loader({ variable: '--font-geist-sans' })` + call in `layout.tsx`, **keeping** the variable strings `--font-geist-sans`/`--font-geist-mono` and the + `` className — so `@theme inline` keeps resolving. Never edit a `:root --font-*` value (gotcha #1). +- Off-allowlist or omitted → Geist (the boilerplate default). Don't reach for an arbitrary Google font; + pick from the list or accept the fallback. + +--- + +## 8. Layout & component taste (dial-driven, app-appropriate) + +The theme colors the app; the **dials** shape its layout. These are generic patterns — **no entity/app +literals** ever (the page/state code is derived from the brief's tables, not hard-coded). + +- **Mandatory states on every generated entity/CRUD page** (the highest-value app rule): a **loading** + skeleton that *matches the real layout* (not a spinner), an **empty** state (clear "nothing yet" + + the primary create action), and an **error** state (legible message + retry). The boilerplate already + ships skeleton/`[data-slot]` animation primitives — reuse them. +- **DENSITY → a `data-density` attribute** on the page/layout root + Tailwind utility classes keyed off + it (padding / gap / row-height / font-size step). **No new `globals.css` rules** — density is expressed + in the markup the frontend scaffolder owns, so it never collides with the token override block. +- **Hierarchy via weight + color, not just size.** Lead with `font-medium`/`foreground` vs + `muted-foreground`; reserve large sizes for true page titles. +- **One accent.** Use the accent for the single primary action per view; everything else is + neutral/border. (Mirrors the ≤1-accent invariant.) +- **Cards only where elevation earns it.** Prefer dividers / `border-t` for flat lists; use a card + (with its small ΔL elevation) only to group a genuinely distinct unit. +- **MOTION is subtle and gated.** Keep transitions short; **always** honor `prefers-reduced-motion` + (the boilerplate's skeleton rules already do — match that discipline). + +--- + +## 9. The keep-default escape hatch (and other off-ramps) + +- **Keep today's look:** `design: { preset: constructive }` → `wire-design` is a **no-op**. The single + most important off-ramp: a build that wants the stock theme gets it, untouched. +- **Dark not pretty?** Add an explicit `dark:` map to the `design.md` frontmatter (§6.4). +- **A deliberate purple/blue brand hue?** `allow_brand_hue: true` silences the AI-purple-band warning + (§3) — use it only when that hue is genuinely the brand, not as a blanket mute. +- **Off-allowlist font?** Accept the Geist fallback, or pick an allowlisted family (§7). +- **A token the override surface can't express?** It is almost certainly **structural** (§6.3) — leave + it. The override surface is the complete set of *thematic* tokens; anything outside it is wiring. + +--- + +## 10. The agent's loop (putting it together) + +1. **Read the intent.** `app.label` + `app.description` + entity names + any `design.brief` words. +2. **Classify → dials** via the words→dials table (§2). Bias to trust-first/minimalist for apps. +3. **Pick + adapt a preset** (§4). Set `colors.primary` (and at most one `accent`) with intent — avoid + the AI-purple band unless it's the real brand. +4. **Author the `design.md`** (§5): one accent, one gray temperature, near-black not black, chroma under + the cap. Record the dials. +5. **Lint:** `node scripts/check-design.mjs` (invariants + WCAG). Fix any **error**; weigh the warns. +6. **Compile + wire:** `node scripts/wire-design.mjs --app ` (or `--dry-run` first) writes the + override block + optional font/`defaultTheme` swap. `preset: constructive` ⇒ no-op. +7. **Thread the dials into the layout** (§8) when scaffolding the CRUD body (DENSITY → `data-density`, + mandatory states, MOTION gated). +8. **Verify in the browser, light AND dark** — the standing Chrome-QA rule. The restyle must render, and + contrast must hold, across every flow the app was built with. + +> **The genericity contract holds end to end.** Nothing here hard-codes a domain → palette. The agent +> *reasons* (words → dials → adapted preset → `design.md`); the engine *enforces* (invariants + WCAG + +> override-surface-only). Presets are anchors, the compiler is the judge. diff --git a/.agents/skills/constructive-builder/references/phase-3-frontend-sdk.md b/.agents/skills/constructive-builder/references/phase-3-frontend-sdk.md index 23f1ca9..a9dc711 100644 --- a/.agents/skills/constructive-builder/references/phase-3-frontend-sdk.md +++ b/.agents/skills/constructive-builder/references/phase-3-frontend-sdk.md @@ -12,6 +12,15 @@ place — there is **no standalone SDK step** on the mainline path. > `pgpm init … nextjs/constructive-app` + `node scripts/wire-app.mjs --app --sub ` + the four > S4 one-liners + `pnpm codegen`. This file is the detailed reference / hand-edit fallback. +> **Theme/look comes later (S6.5), not here.** This phase scaffolds the app and its stock +> `src/app/globals.css` (the light-first Constructive blue). The **design-theme pass** — +> `node scripts/wire-design.mjs --app ` — runs in Phase 4 (after the Blocks `@import`, before the +> CRUD body): it writes a contrast-repaired shadcn-token **override block** into this `globals.css` to +> restyle the app + all Blocks. **Default = auto-propose** a domain-fitting theme; `design: { preset: +> constructive }` keeps this stock look. So **do not hand-edit `globals.css` token values here** — let +> S6.5 own the override block. Methodology: [design-system.md](./design-system.md); brief shape: +> [brief-grammar.md](./brief-grammar.md) "design (optional)". + ## Phase 2.6: Create Frontend (Phase 3 step) **Goal:** Scaffold a Next.js frontend from the Constructive sandbox template and wire it to the generated SDK. diff --git a/.agents/skills/constructive-builder/references/phase-4-blocks.md b/.agents/skills/constructive-builder/references/phase-4-blocks.md index 217f7d2..c656469 100644 --- a/.agents/skills/constructive-builder/references/phase-4-blocks.md +++ b/.agents/skills/constructive-builder/references/phase-4-blocks.md @@ -14,9 +14,19 @@ > not change it). When blocks are installed, the additive block-coverage gate also runs under > `./scripts/verify-phase.sh 2.6` (it self-disables when no `.constructive/blocks/*.requires.json` exists). -> **Speedrun shortcut:** [speedrun.md](./speedrun.md) S5–S7 collapse this into the blocks on-ramp + -> `node scripts/scaffold-frontend.mjs build/app-brief.yaml ` for the CRUD body. This file is the -> detailed reference / hand-edit fallback. +> **Speedrun shortcut:** [speedrun.md](./speedrun.md) S5–S7 collapse this into the blocks on-ramp + the +> **design-theme pass** (S6.5, [design-system.md](./design-system.md)) + `node +> scripts/scaffold-frontend.mjs build/app-brief.yaml ` for the CRUD body. This file is the detailed +> reference / hand-edit fallback. + +> **Between Branch A and the CRUD body: the design-theme pass (S6.5).** After the Blocks `@import` is in +> place and **before** the CRUD body, run **`node scripts/wire-design.mjs --app `** to apply the +> app's look-and-feel: it compiles the app's `design.md` into a contrast-repaired **token-override block** +> in `globals.css`, restyling the template **and** every installed Block at once (all read the shadcn +> `var(--…)` tokens). **Default = auto-propose** a domain-fitting theme; `design: { preset: constructive }` +> (or a compile failure) ⇒ **no-op**, today's look preserved. It only touches the override-surface tokens +> (never `@theme inline`/`@source`/`--z-layer-*`/`@layer base`); fonts swap via the `layout.tsx` loader. +> Full methodology + the override contract: **[design-system.md](./design-system.md)**. --- diff --git a/.agents/skills/constructive-builder/references/speedrun.md b/.agents/skills/constructive-builder/references/speedrun.md index 6c5e7e9..a82e87a 100644 --- a/.agents/skills/constructive-builder/references/speedrun.md +++ b/.agents/skills/constructive-builder/references/speedrun.md @@ -402,6 +402,42 @@ node scripts/check-sdk.mjs --project --- +## S6.5 — Apply the design theme (look-and-feel; after the Blocks @import, before the CRUD body) + +Give the app a coherent, legible, non-generic look in one idempotent codemod. It writes a +**contrast-repaired token-override block** into the app's `globals.css` — restyling the template UI +**and every installed Block at once** (they all read `var(--…)` shadcn tokens). + +```bash +# Derives/compiles the app's design.md → a single MARKED override block of shadcn token VALUES in +# /src/app/globals.css (+ an optional next/font loader swap + ThemeProvider defaultTheme). +# = the WORKSPACE ROOT (resolves packages/app). Run it AFTER the Blocks @import (S5) so the override +# sits below it and wins by source order, and BEFORE the CRUD body (S7). Idempotent + --dry-run-able. +node scripts/wire-design.mjs --app # or: node scripts/wire-design.mjs [--dry-run] +``` + +- **Default = auto-propose.** With **no `design:` block** in the brief, the build authors a + domain-fitting `design.md` (3 dials → palette + radius + fonts), lints it (invariants + **WCAG + contrast**), and compiles it. A generated app should not ship the stock Constructive blue unless asked. +- **Opt-out = keep today's look.** `design: { preset: constructive }` ⇒ **no-op** (boilerplate + `globals.css` untouched). A compile failure / impossible contrast also no-ops (loud warning) — never a + half-written theme. +- **Structure stays off-limits.** The codemod only emits the **override-surface** tokens between its + `>>>`/`<<< constructive-builder design overrides` sentinels (placed after `.dark`, before `@theme + inline`). It never touches `@theme inline` / `@source` / `--z-layer-*` / `@layer base`. Fonts change + **only** via the `layout.tsx` loader swap (the `:root --font-sans` literal is dead — shadowed by + `@theme inline`). +- **Brief control.** The optional `design:` block (`brief`/`preset`/`dials`/`colors`/`font`/`radius`/ + `default_mode`) constrains or overrides the auto-proposal — see + [brief-grammar.md](./brief-grammar.md) "design (optional)". + +> **The full methodology — dials, the words→dials table, the color invariants, the `design.md` format, +> the preset catalog, and the compile/override contract — is in [design-system.md](./design-system.md).** +> Read it when authoring/adapting a theme or when the lint flags a contrast/invariant issue. The DENSITY +> dial is also threaded into the CRUD body (S7) as a `data-density` root attribute. + +--- + ## S7 — Build the app CRUD body (the DOMAIN entity UI — always; this is surface (3)) **Generate the CRUD body from the brief (the default path):** @@ -465,7 +501,7 @@ there** in the browser and assert the mutation fired **2xx** and the row **persi > **Stuck on a step?** Drop into the matching detailed phase reference (S1→[phase-2-data-model.md](./phase-2-data-model.md) > §2.1, S2→[phase-2-data-model.md](./phase-2-data-model.md), S3/S4→[phase-3-frontend-sdk.md](./phase-3-frontend-sdk.md), > S5/S6→[phase-4-blocks.md](./phase-4-blocks.md) Branch A / [blocks-onramp.md](./blocks-onramp.md), -> S7→[phase-4-blocks.md](./phase-4-blocks.md) CRUD body) and consult [troubleshooting.md](./troubleshooting.md) +> S6.5→[design-system.md](./design-system.md), S7→[phase-4-blocks.md](./phase-4-blocks.md) CRUD body) and consult [troubleshooting.md](./troubleshooting.md) > / [gotchas.md](./gotchas.md) / [error-index.md](./error-index.md) for that step. The detailed sections are > the fallback; the speedrun is the path. diff --git a/.agents/skills/constructive-builder/scripts/check-design.mjs b/.agents/skills/constructive-builder/scripts/check-design.mjs new file mode 100644 index 0000000..050c6df --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/check-design.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * check-design.mjs — the design-system lint GATE (JSON-out), wrapping the pure + * deterministic invariants engine (lib/design/invariants.mjs). It is to the design + * subsystem what check-flows.mjs is to the flow catalog: a self-contained drift / + * correctness guard that a build can run before compiling a theme. + * + * It lints a design SOURCE — either a design.md file (frontmatter parsed via the + * skill's zero-dep YAML reader, through design-md.mjs) OR a brief whose `design:` + * block carries inline colors/dials. It runs `lintDesign` (taste + WCAG-AA contrast + * invariants) and, when the optional Google-Labs `design.md` CLI is ALREADY available + * on PATH/locally, ALSO shells ` lint` as extra signal — never required, never a + * hard dependency (its absence or failure is reported, not fatal). + * + * GENERIC BY CONSTRUCTION: it reasons about color ROLES + dimension tokens only — + * no app/entity/flow/domain literal anywhere. The source is whatever the caller names. + * + * Usage: + * node scripts/check-design.mjs --design # lint a design.md + * node scripts/check-design.mjs --brief # lint brief.design + * node scripts/check-design.mjs # positional: .md => design, else brief + * node scripts/check-design.mjs --design

--external # also try the (optional) design.md CLI + * node scripts/check-design.mjs --design

--json # machine-readable (default is also JSON) + * + * Output: a single JSON object on stdout: + * { ok, source, kind, findings:[{rule,severity,msg}], counts:{error,warn,info}, external? } + * + * Exit codes (mirror check-flows.mjs): + * 0 no ERROR findings (warnings/info allowed) + * 1 at least one ERROR finding (lint failed) + * 2 could not run (no/unreadable/unparseable source, bad args) + * + * Zero dependencies. Pure Node (>=18). Reuses the skill's design engine + YAML reader. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve, dirname, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { lintDesign } from './lib/design/invariants.mjs'; +import { compileDesign } from './lib/design/compile.mjs'; +import { parseDesignMd } from './lib/design/design-md.mjs'; +import { parseBrief } from './lib/brief-yaml.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); + +function fail2(msg) { + process.stdout.write(JSON.stringify({ ok: false, error: msg, exit: 2 }) + '\n'); + process.exit(2); +} + +// ── args ──────────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +let designArg = ''; +let briefArg = ''; +let positional = ''; +let tryExternal = false; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--design') designArg = argv[++i] || ''; + else if (a === '--brief') briefArg = argv[++i] || ''; + else if (a === '--external') tryExternal = true; + else if (a === '--json') { /* JSON is always the output shape — accepted for parity */ } + else if (a === '-h' || a === '--help') { + console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 47).join('\n')); + process.exit(0); + } else if (a.startsWith('--')) fail2(`unknown argument: ${a}`); + else positional = a; +} + +// Resolve the source + its kind ('design' = a design.md frontmatter, 'brief' = brief.design). +let sourcePath = ''; +let kind = ''; +if (designArg) { sourcePath = designArg; kind = 'design'; } +else if (briefArg) { sourcePath = briefArg; kind = 'brief'; } +else if (positional) { + sourcePath = positional; + kind = extname(positional).toLowerCase() === '.md' ? 'design' : 'brief'; +} else { + fail2('no source given. Pass --design , --brief , or a positional path.'); +} + +const abs = resolve(process.cwd(), sourcePath); +if (!existsSync(abs)) fail2(`source not found: ${abs}`); + +let text; +try { + text = readFileSync(abs, 'utf8'); +} catch (e) { + fail2(`could not read ${abs}: ${e.message}`); +} + +// Extract the design frontmatter object the invariants engine expects. +let design; +try { + if (kind === 'design') { + design = parseDesignMd(text).frontmatter || {}; + } else { + const brief = parseBrief(text) || {}; + design = (brief && brief.design) || {}; + if (!brief.design) { + // No design block in the brief: nothing to lint → trivially ok (absent-design + // is the auto-propose path; a builder generates a design.md later and lints THAT). + process.stdout.write( + JSON.stringify({ + ok: true, + source: abs, + kind, + findings: [], + counts: { error: 0, warn: 0, info: 0 }, + note: 'brief has no design: block — nothing to lint (auto-propose path).', + }) + '\n' + ); + process.exit(0); + } + } +} catch (e) { + fail2(`could not parse ${kind} source ${abs}: ${e.message}`); +} + +// ── lint (the deterministic gate) ───────────────────────────────────────────── +const { ok: lintOk, findings } = lintDesign(design); + +// ── COMPILE-ABILITY (the gate must reflect what wire-design will actually do) ─── +// A lint-clean design can still be untheme-able: compileDesign throwing (a genuinely +// impossible pairing) OR an explicit dark override pairing that cannot reach AA-4.5 +// of EITHER polarity. wire-design degrades a compile failure to a SILENT default-look +// no-op, so if we did not surface this here a "green" lint would mask a design that +// renders with ZERO theme applied. We attempt the SAME compile wire-design runs and +// fold the result into the gate: +// • a hard throw → ERROR finding (design cannot be themed at all) +// • compiler best-pole warnings (no AA-4.5 fg exists on a surface) → WARN findings, +// so an impossible explicit-dark / brand-surface pairing is visible at the gate. +// (Only the `constructive` opt-out preset is exempt — wire-design treats it as a +// pure no-op that reproduces today's look, so its known sub-AA on-primary label is +// expected, not a gate failure.) +const presetName = String(design.preset || design.name || '').trim().toLowerCase(); +const isOptOut = presetName === 'constructive'; +let compileOk = true; +if (!isOptOut) { + try { + const compiled = compileDesign(design); + for (const w of compiled.warnings || []) { + // Surface only the contrast-impossibility warnings as gate findings; benign + // font-fallback / missing-primary warnings are already covered by lint. + if (/no AA-4\.5 foreground exists/.test(w)) { + findings.push({ rule: 'contrast-uncompilable', severity: 'warn', msg: w }); + } + } + } catch (e) { + compileOk = false; + findings.push({ + rule: 'compile-failed', + severity: 'error', + msg: `design cannot be compiled into a theme (${e.message}). wire-design would degrade to a SILENT default-look no-op — fix the offending pairing (commonly a vivid mid-luminance primary/surface that cannot carry AA-4.5 text of either polarity).`, + }); + } +} + +const ok = lintOk && compileOk; +const counts = { error: 0, warn: 0, info: 0 }; +for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; + +// ── optional best-effort external CLI (NEVER required) ───────────────────────── +// Only runs for a design.md source AND only when --external is asked AND a design.md +// CLI is already resolvable. We never install it; absence/failure is reported, not fatal. +let external; +if (tryExternal && kind === 'design') { + external = runExternalLint(abs); +} + +const report = { ok, source: abs, kind, findings, counts }; +if (external) report.external = external; +process.stdout.write(JSON.stringify(report) + '\n'); +process.exit(ok ? 0 : 1); + +/** + * Best-effort wrapper over an OPTIONAL Google-Labs `design.md` CLI. We probe a few + * resolvable invocations WITHOUT triggering a network install: + * • a `design.md` binary on PATH, + * • a locally-installed `@google/design.md` (node_modules/.bin), if present. + * `npx @google/design.md` is deliberately NOT auto-invoked unless it resolves offline, + * because npx would otherwise reach the network — violating zero-dep / offline rules. + * Returns { ran:false, reason } when unavailable, or { ran:true, exit, stdout, stderr }. + */ +function runExternalLint(designPath) { + const candidates = []; + // 1) a `design.md` binary directly on PATH. + candidates.push({ cmd: 'design.md', args: ['lint', designPath] }); + // 2) a locally-installed bin next to this skill (no network). + const localBin = resolve(SCRIPT_DIR, '..', 'node_modules', '.bin', 'design.md'); + if (existsSync(localBin)) candidates.push({ cmd: localBin, args: ['lint', designPath] }); + + for (const c of candidates) { + let res; + try { + res = spawnSync(c.cmd, c.args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch { + continue; + } + if (res.error) continue; // ENOENT etc. — try the next candidate + return { + ran: true, + cmd: c.cmd, + exit: res.status, + stdout: (res.stdout || '').trim().slice(0, 4000), + stderr: (res.stderr || '').trim().slice(0, 2000), + }; + } + return { ran: false, reason: 'no offline design.md CLI found (optional — not required).' }; +} diff --git a/.agents/skills/constructive-builder/scripts/genericity-check.sh b/.agents/skills/constructive-builder/scripts/genericity-check.sh index fe56333..762406e 100755 --- a/.agents/skills/constructive-builder/scripts/genericity-check.sh +++ b/.agents/skills/constructive-builder/scripts/genericity-check.sh @@ -232,6 +232,76 @@ info "phases : $PHASES (+ live-QA gate on Phase 3)" [ -n "${LIVE_QA_CRUD_PATH:-}" ] && info "live-QA CRUD path (override): $LIVE_QA_CRUD_PATH" hr +# ── DESIGN rot-canary (hermetic — no backend / no built app needed) ────────────── +# Proves the design theming subsystem stays GENERIC + correct as a standing canary, +# the same way the four app tiers above guard the build span. Two assertions, both +# self-contained (fixtures + a temp globals.css; no network, no DB): +# 1. NON-DEFAULT preset → a real theme: wire-design writes a marked override block +# whose compiled tokens DIFFER from the boilerplate, and the design's own +# check-design verdict is ok (lint + compile-ability). Catches the silent +# "lint-clean design degrades to the default look" regression this canary exists for. +# 2. `constructive` opt-out preset → a byte NO-OP: wire-design writes NOTHING (the +# override sentinels never appear), so selecting the opt-out reproduces today's look. +# Skips gracefully (warn, never fail the canary) only if node or the fixtures are absent. +design_rot_canary() { + command -v node >/dev/null 2>&1 || { warn "design rot-canary: 'node' not on PATH — skipped (not failing)"; return 0; } + local fix_dir="$REPO_ROOT/fixtures/design" + local non_default="$fix_dir/editorial.md" # any shipped NON-constructive preset + local opt_out="$fix_dir/constructive.md" + local check_design="$REPO_ROOT/scripts/check-design.mjs" + local wire_design="$REPO_ROOT/scripts/wire-design.mjs" + if [ ! -f "$non_default" ] || [ ! -f "$opt_out" ] || [ ! -f "$wire_design" ] || [ ! -f "$check_design" ]; then + warn "design rot-canary: fixtures/design or design scripts missing — skipped (not failing)" + return 0 + fi + + # A throwaway app skeleton (the minimal shape wire-design's globals step needs). + local tmp; tmp="$(mktemp -d)" + mkdir -p "$tmp/src/app" + # wire-design's appUnder() requires package.json + src/ at the app root to recognize it. + printf '{ "name": "design-canary-app" }\n' > "$tmp/package.json" + # A template-shaped globals.css with the structural @theme inline anchor. + printf ':root {\n --background: oklch(1 0 0);\n}\n.dark {\n --background: oklch(0.21 0.006 285.885);\n}\n@theme inline {\n --color-background: var(--background);\n}\n' > "$tmp/src/app/globals.css" + + # 1) the design itself must be ok (lint + compile-ability) AND wire into a non-default theme. + if node "$check_design" --design "$non_default" >/dev/null 2>&1; then + pass "design rot-canary: non-default preset (editorial) passes check-design (lint + compile-ability)" + else + rm -rf "$tmp" + fail "design rot-canary: non-default preset failed check-design" "run 'node scripts/check-design.mjs --design fixtures/design/editorial.md' and fix the flagged role(s) — a shipped preset must lint + compile into a theme." + fi + if node "$wire_design" --app "$tmp" --design "$non_default" >/dev/null 2>&1 \ + && grep -q 'constructive-builder design overrides (generated)' "$tmp/src/app/globals.css"; then + # And it must DIFFER from the boilerplate (a real theme was applied, not a no-op). + if grep -qE '^[[:space:]]*--primary:' "$tmp/src/app/globals.css"; then + pass "design rot-canary: non-default preset wrote a real (non-default) override block into globals.css" + else + rm -rf "$tmp" + fail "design rot-canary: override block written but carries no themed tokens" "wire-design emitted an empty/default override region — check compile.mjs (compileDesign should emit the full override surface for a non-constructive preset)." + fi + else + rm -rf "$tmp" + fail "design rot-canary: a non-default preset did NOT produce a theme override block" "wire-design must write the marked :root/.dark override region for a non-constructive preset (it currently degrades to the default look — see compile.mjs / check-design.mjs)." + fi + + # 2) the opt-out preset must be a byte NO-OP (no override sentinels written). + local tmp2; tmp2="$(mktemp -d)" + mkdir -p "$tmp2/src/app" + printf '{ "name": "design-canary-app" }\n' > "$tmp2/package.json" + printf ':root {\n --background: oklch(1 0 0);\n}\n.dark {\n --background: oklch(0.21 0.006 285.885);\n}\n@theme inline {\n --color-background: var(--background);\n}\n' > "$tmp2/src/app/globals.css" + local before; before="$(cat "$tmp2/src/app/globals.css")" + node "$wire_design" --app "$tmp2" --design "$opt_out" >/dev/null 2>&1 || true + if [ "$(cat "$tmp2/src/app/globals.css")" = "$before" ] && ! grep -q 'constructive-builder design overrides' "$tmp2/src/app/globals.css"; then + pass "design rot-canary: opt-out preset (constructive) is a byte NO-OP (globals.css unchanged)" + else + rm -rf "$tmp" "$tmp2" + fail "design rot-canary: the 'constructive' opt-out preset modified globals.css" "the opt-out must reproduce today's look as a NO-OP (wire-design.mjs: preset==='constructive' → write nothing, exit 0)." + fi + rm -rf "$tmp" "$tmp2" +} +design_rot_canary +hr + # ── S0 — smoke the warm backend on :3000; restart ONCE with a big heap if down ── # pr_s0_smoke_and_restart (lib/phase-runner.sh) does the smoke + one-shot 8GB-heap restart; the # constructive CLI is auto-discovered the AGENTS.md sibling way (never a hardcoded path). Args: diff --git a/.agents/skills/constructive-builder/scripts/lib/brief-policy.mjs b/.agents/skills/constructive-builder/scripts/lib/brief-policy.mjs index 6f2c532..b053eae 100644 --- a/.agents/skills/constructive-builder/scripts/lib/brief-policy.mjs +++ b/.agents/skills/constructive-builder/scripts/lib/brief-policy.mjs @@ -200,9 +200,106 @@ export function validateBrief(brief, where = 'brief') { if (needsB2b && !b2bPresets.has(preset)) { throw new BriefError(`${where}: a table uses an org-scoped policy (org-membership / member-owner / org-hierarchy / related-membership / restrict: read-only) but modules.preset is "${preset}". Org policies REQUIRE a b2b preset (b2b | b2b:storage | full) — the memberships/hierarchy modules back them.`); } + // OPTIONAL design-block strictness (additive; gated on brief.design being present). The `design:` + // key is already non-breaking — there is no top-level allowlist above — so this only catches a + // MALFORMED design block with a legible message before it reaches the compiler. Unknown keys are + // TOLERATED (forward-compatible). Deeper invariants (≤1 accent, chroma cap, AI-purple ban, WCAG + // contrast) are enforced by the deterministic linter (check-design.mjs), NOT here — this is + // shape-validation only. See references/design-system.md + brief-grammar.md "design (optional)". + if (brief.design != null) validateDesign(brief.design, where); return brief; } +// ════════════════════════════════════════════════════════════════════════════ +// 2b. OPTIONAL DESIGN-BLOCK VALIDATION (shape only; unknown keys tolerated) +// ════════════════════════════════════════════════════════════════════════════ +// GENERIC BY CONSTRUCTION — nothing here references an app/entity/domain; it validates only the +// additive look-and-feel `design:` block's SHAPE. The block has NO required keys (absent ⇒ +// auto-propose; `{ preset: constructive }` ⇒ keep today's look), so this is purely "if a key is +// present, is it well-formed?" — never a gate that would reject a valid/absent design block. + +// The named preset anchors documented in references/design-system.md §4 / brief-grammar.md +// "design (optional)". `constructive` is the explicit no-op opt-out. Kept in sync with the doc. +const KNOWN_DESIGN_PRESETS = new Set([ + 'constructive', 'minimalist', 'trust-first', 'editorial', 'soft', 'brutalist', 'playful', +]); +const DESIGN_DIALS = ['variance', 'motion', 'density']; + +const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v); + +/** + * Validate the OPTIONAL `design:` block's shape, throwing a BriefError with a legible message on a + * malformed value. Called only when `brief.design` is present. Tolerates unknown keys (so a future + * field never trips an older validator); validates only the keys it knows. Color-token *values* + * (oklch/hex/rgb) and the semantic invariants are the deterministic linter's job — here we only + * assert the right JS shape (mapping vs scalar vs list, int range, allowed enum). + */ +function validateDesign(design, where = 'brief') { + const at = `${where}: design`; + if (!isPlainObject(design)) { + throw new BriefError(`${at} must be a mapping (e.g. design: { preset: minimalist }) — got ${Array.isArray(design) ? 'a list' : typeof design}. OMIT it entirely to auto-propose a theme, or use { preset: constructive } to keep today's look.`); + } + // preset (optional): a known anchor name. + if (design.preset != null) { + if (typeof design.preset !== 'string' || !KNOWN_DESIGN_PRESETS.has(design.preset)) { + throw new BriefError(`${at}.preset "${design.preset}" is not a known preset. Known: ${[...KNOWN_DESIGN_PRESETS].join(', ')} (constructive = keep today's look).`); + } + } + // brief (optional): natural-language style words. + if (design.brief != null && typeof design.brief !== 'string') { + throw new BriefError(`${at}.brief must be a string (the natural-language style words), e.g. "calm, trustworthy, dense"; got ${typeof design.brief}.`); + } + // dials (optional): a mapping; each present dial an integer 1–10. + if (design.dials != null) { + if (!isPlainObject(design.dials)) { + throw new BriefError(`${at}.dials must be a mapping { variance, motion, density } of integers 1–10; got ${Array.isArray(design.dials) ? 'a list' : typeof design.dials}.`); + } + for (const d of DESIGN_DIALS) { + const v = design.dials[d]; + if (v == null) continue; // each dial is optional + if (!Number.isInteger(v) || v < 1 || v > 10) { + throw new BriefError(`${at}.dials.${d} must be an integer 1–10; got ${JSON.stringify(v)}.`); + } + } + } + // colors (optional): a mapping of role → color-token STRING (token validity is the linter's job). + if (design.colors != null) { + if (!isPlainObject(design.colors)) { + throw new BriefError(`${at}.colors must be a mapping of role → color (e.g. { primary: "oklch(0.55 0.11 162)" }); got ${Array.isArray(design.colors) ? 'a list' : typeof design.colors}.`); + } + for (const [role, val] of Object.entries(design.colors)) { + if (typeof val !== 'string') { + throw new BriefError(`${at}.colors.${role} must be a color string (oklch()/#hex/rgb()); got ${typeof val}.`); + } + } + } + // font (optional): a mapping (sans/mono family-name strings). + if (design.font != null) { + if (!isPlainObject(design.font)) { + throw new BriefError(`${at}.font must be a mapping { sans, mono } of next/font/google family names; got ${Array.isArray(design.font) ? 'a list' : typeof design.font}.`); + } + for (const slot of ['sans', 'mono', 'serif']) { + if (design.font[slot] != null && typeof design.font[slot] !== 'string') { + throw new BriefError(`${at}.font.${slot} must be a font-family name string; got ${typeof design.font[slot]}.`); + } + } + } + // radius (optional): a px/em/rem string. + if (design.radius != null && typeof design.radius !== 'string') { + throw new BriefError(`${at}.radius must be a string (px/em/rem), e.g. "0.5rem"; got ${typeof design.radius}.`); + } + // default_mode (optional): light | dark. + if (design.default_mode != null && design.default_mode !== 'light' && design.default_mode !== 'dark') { + throw new BriefError(`${at}.default_mode must be 'light' or 'dark' (which theme loads first); got ${JSON.stringify(design.default_mode)}.`); + } + // allow_brand_hue (optional): boolean. + if (design.allow_brand_hue != null && typeof design.allow_brand_hue !== 'boolean') { + throw new BriefError(`${at}.allow_brand_hue must be a boolean (opt out of the AI-purple-band warning for a deliberate brand hue); got ${typeof design.allow_brand_hue}.`); + } + // Unknown keys are intentionally NOT rejected (forward-compatible). + return design; +} + // ════════════════════════════════════════════════════════════════════════════ // 3. POLICY INTENTS → { nodes, policies } // ════════════════════════════════════════════════════════════════════════════ diff --git a/.agents/skills/constructive-builder/scripts/lib/design/compile.mjs b/.agents/skills/constructive-builder/scripts/lib/design/compile.mjs new file mode 100644 index 0000000..c658649 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/compile.mjs @@ -0,0 +1,463 @@ +/** + * scripts/lib/design/compile.mjs — the load-bearing compiler. + * + * compileDesign(design, { defaultMode }) -> + * { light:{ '--background':'oklch(..)', ... }, dark:{...}, radius, fonts:{sans,mono}, warnings:[] } + * renderOverrideBlock({ light, dark }) -> cssString (a SINGLE marked block) + * + * Hard rules honored here: + * (a) emits ONLY thematic override-surface vars — NEVER structural ones. + * (b) runs WCAG-AA contrast repair (ensureContrast) on critical pairs. + * (c) derives `.dark` from light by OKLCH lightness inversion + foreground + * re-pair when `design.dark` is absent. + * (d) honors the success/warning text-on-tint contract (dark-on-tint in light + * mode, light-on-tint in dark mode — never naive white). + * + * `design` is a design.md frontmatter object: `colors` (role->css color), + * `rounded`/`radius`, `typography`/`font`, optional `dark` override map, and + * extension flags. All inputs are color ROLES — no app/entity literals. + * + * ZERO-DEP. Node >=18 ESM. Pure (no I/O). + */ + +import { + parseColor, + formatOklch, + withLightness, + adjustLightness, + withChroma, + rotateHue, + ensureContrast, + contrastRatio, + relativeLuminance, + toSrgb, +} from './oklch.mjs'; +import { resolveFont } from './fonts.mjs'; + +/* The ONLY vars compile may emit (the override surface). renderOverrideBlock + * asserts every emitted key is on this list — a structural-safety guard. */ +export const OVERRIDE_SURFACE = new Set([ + 'background', + 'foreground', + 'card', + 'card-foreground', + 'popover', + 'popover-foreground', + 'primary', + 'primary-foreground', + 'secondary', + 'secondary-foreground', + 'muted', + 'muted-foreground', + 'accent', + 'accent-foreground', + 'destructive', + 'destructive-foreground', + 'border', + 'input', + 'ring', + 'chart-1', + 'chart-2', + 'chart-3', + 'chart-4', + 'chart-5', + 'sidebar', + 'sidebar-foreground', + 'sidebar-primary', + 'sidebar-primary-foreground', + 'sidebar-accent', + 'sidebar-accent-foreground', + 'sidebar-border', + 'sidebar-ring', + 'info', + 'info-foreground', + 'success', + 'success-foreground', + 'warning', + 'warning-foreground', + 'radius', +]); + +export const BEGIN_SENTINEL = '/* >>> constructive-builder design overrides (generated) */'; +export const END_SENTINEL = '/* <<< constructive-builder design overrides */'; + +// Sensible default roles (≈ today's neutral light look) when a design omits them. +const DEFAULTS = { + surface: 'oklch(1 0 0)', + 'on-surface': 'oklch(0.21 0.006 285.885)', + primary: 'oklch(0.55 0.16 250)', + error: 'oklch(0.55 0.2 25)', +}; + +const W = (warnings, msg) => warnings.push(msg); + +function pick(colors, role, fallback) { + return colors[role] != null ? colors[role] : fallback; +} + +/** + * A contrast-repaired foreground for `bg`: start from near-white or near-black + * by the background luminance, then ensureContrast nudges it to AA. If `bg` is + * an inherently mid-luminance brand color that cannot carry AA-4.5 text of + * either polarity (e.g. a vivid mid orange used as a SOLID button surface), we + * do NOT crash the whole compile — we pick the higher-contrast pole and, when a + * `warnings`/`label` sink is supplied, record an honest warning. This is the + * standard shadcn behavior for accent/destructive surfaces; the lint layer + * already surfaces such pairs to the author. + */ +function autoForeground(bg, target = 4.5, warnings, label) { + const bgLum = relativeLuminance(toSrgb(bg)); + const seed = bgLum < 0.5 ? { l: 0.985, c: 0, h: bg.h } : { l: 0.18, c: 0, h: bg.h }; + try { + return ensureContrast(seed, bg, target); + } catch { + // Best achievable: pure white vs pure black, whichever wins on this bg. + const white = { l: 1, c: 0, h: bg.h, a: 1 }; + const black = { l: 0, c: 0, h: bg.h, a: 1 }; + const best = contrastRatio(white, bg) >= contrastRatio(black, bg) ? white : black; + if (warnings && label) { + W( + warnings, + `${label}: no AA-4.5 foreground exists on ${formatOklch(bg)} (best ${contrastRatio(best, bg).toFixed(2)}:1); used the higher-contrast pole.` + ); + } + return best; + } +} + +/** + * The text-on-tint contract. Status tints (info/success/warning) are surfaces + * that carry text, so the SURFACE lightness must leave room for legible text of + * the contract polarity: + * light mode -> a LIGHT pastel tint with DARK-on-tint text + * dark mode -> a DEEP tint with LIGHT-on-tint text + * A mid-luminance tint cannot carry AA-4.5 text of either polarity, so we tone + * the tint into the right band first, then contrast-repair the foreground. + * Returns { tint, foreground } (both OKLCH). Never naive white. + */ +function tintPair(src, mode) { + if (mode === 'dark') { + const tint = withLightness(withChroma(src, Math.min(src.c, 0.09)), 0.32); + const seed = { l: 0.96, c: Math.min(src.c, 0.04), h: src.h }; + return { tint, foreground: ensureContrast(seed, tint, 4.5) }; + } + const tint = withLightness(withChroma(src, Math.min(src.c, 0.06)), 0.94); + const seed = { l: 0.4, c: Math.min(src.c, 0.12), h: src.h }; + return { tint, foreground: ensureContrast(seed, tint, 4.5) }; +} + +/** + * Build the LIGHT token set from the design roles. Returns a map of OKLCH color + * objects (not strings) so .dark derivation can do math; stringified at the end. + */ +function buildLight(colors, warnings) { + const surface = parseColor(pick(colors, 'surface', pick(colors, 'background', DEFAULTS.surface))); + const onSurface = parseColor(pick(colors, 'on-surface', pick(colors, 'foreground', DEFAULTS['on-surface']))); + const primary = parseColor(pick(colors, 'primary', DEFAULTS.primary)); + const destructive = parseColor(pick(colors, 'error', pick(colors, 'destructive', DEFAULTS.error))); + + // Neutral foundation: derive from surface/on-surface so grays share a temperature. + const surfIsLight = relativeLuminance(toSrgb(surface)) >= 0.5; + const towardFg = (delta) => adjustLightness(surface, surfIsLight ? -delta : delta); + + const out = {}; + + // surfaces + out.background = surface; + out.foreground = onSurface; + out.card = adjustLightness(surface, surfIsLight ? -0.0 : 0.02); // small elevation + out.card = withLightness(out.card, surfIsLight ? surface.l : surface.l + 0.02); + out['card-foreground'] = onSurface; + out.popover = out.card; + out['popover-foreground'] = onSurface; + + // primary + out.primary = primary; + out['primary-foreground'] = autoForeground(primary, 4.5, warnings, 'primary-foreground'); + + // secondary / muted (neutral subtle surfaces) + const neutralBase = colors.neutral ? parseColor(colors.neutral) : withChroma(surface, Math.min(surface.c, 0.01)); + out.secondary = withLightness(withChroma(neutralBase, Math.min(neutralBase.c, 0.01)), surfIsLight ? 0.967 : 0.274); + out['secondary-foreground'] = ensureContrast(onSurface, out.secondary, 4.5); + out.muted = withLightness(withChroma(neutralBase, Math.min(neutralBase.c, 0.01)), surfIsLight ? 0.967 : 0.244); + // muted-foreground is RENDERED on `muted` (a subtle tinted surface), not on the + // page background — so repair it against `muted` (the actual painted surface). + // Repairing against `surface` left it ~4.41:1 on the slightly-darker muted tint + // (a real WCAG miss). secondary shares muted's lightness band, so the same fg + // clears it too. + out['muted-foreground'] = ensureContrast(withLightness(onSurface, surfIsLight ? 0.55 : 0.7), out.muted, 4.5); + + // accent: explicit accent/tertiary, else a desaturated primary + const accentSrc = colors.accent || colors.tertiary; + if (accentSrc) { + out.accent = parseColor(accentSrc); + out['accent-foreground'] = autoForeground(out.accent, 4.5, warnings, 'accent-foreground'); + } else { + // app-appropriate quiet accent surface (neutral tint), foreground = on-surface + out.accent = out.muted; + out['accent-foreground'] = out['secondary-foreground']; + } + + // destructive + out.destructive = destructive; + out['destructive-foreground'] = autoForeground(destructive, 4.5, warnings, 'destructive-foreground'); + + // borders / input / ring + out.border = towardFg(0.08); + out.input = towardFg(0.13); + out.ring = primary; + + // chart ramp: primary hue rotations [0,+40,-40,+90,-90], fixed chroma/L + const chartC = Math.max(0.12, Math.min(primary.c, 0.2)); + const chartL = surfIsLight ? 0.62 : 0.68; + const rots = [0, 40, -40, 90, -90]; + rots.forEach((deg, i) => { + out[`chart-${i + 1}`] = withLightness(withChroma(rotateHue(primary, deg), chartC), chartL); + }); + + // sidebar: derived from surface/neutral + primary + out.sidebar = withLightness(neutralBase, surfIsLight ? 0.985 : 0.244); + out['sidebar-foreground'] = ensureContrast(onSurface, out.sidebar, 4.5); + out['sidebar-primary'] = primary; + out['sidebar-primary-foreground'] = autoForeground(primary); + out['sidebar-accent'] = out.muted; + out['sidebar-accent-foreground'] = out['secondary-foreground']; + out['sidebar-border'] = out.border; + out['sidebar-ring'] = primary; + + // status tints (info/success/warning) — derive tints if the design gives a hue, + // else use canonical hues. Foregrounds follow the text-on-tint contract. + const tintFor = (role, fallbackHue) => { + const src = colors[role] ? parseColor(colors[role]) : { l: 0.6, c: 0.13, h: fallbackHue, a: 1 }; + const { tint, foreground } = tintPair(src, 'light'); + out[role] = tint; + out[`${role}-foreground`] = colors[`${role}-foreground`] + ? ensureContrast(parseColor(colors[`${role}-foreground`]), tint, 4.5) + : foreground; + }; + tintFor('info', 250); + tintFor('success', 150); + tintFor('warning', 75); + + // Final critical-pair repair (fg/bg) — never green-wash, ensureContrast moves L. + out.foreground = ensureContrast(out.foreground, out.background, 4.5); + out['card-foreground'] = ensureContrast(out['card-foreground'], out.card, 4.5); + out['popover-foreground'] = ensureContrast(out['popover-foreground'], out.popover, 4.5); + + return out; +} + +/** Invert a LIGHT OKLCH token map into a derived DARK map (lightness inversion + * around mid + foreground re-pair). Hue/chroma preserved; surfaces clamped. */ +function deriveDark(light, warnings) { + const dark = {}; + // 1) Surfaces: invert lightness (1 - L) but clamp into a comfortable dark band. + const invertSurface = (col) => { + const l = 1 - col.l; + // Map a near-white surface (~1) to ~0.21, keep mid-tones reasonable. + const clamped = Math.max(0.16, Math.min(0.3, l <= 0.3 ? 0.21 : l)); + return withLightness(col, clamped); + }; + dark.background = invertSurface(light.background); + dark.card = adjustLightness(dark.background, 0.03); + dark.popover = dark.card; + dark.secondary = adjustLightness(dark.background, 0.06); + dark.muted = adjustLightness(dark.background, 0.04); + dark.accent = light.accent ? withLightness(light.accent, light.accent.l < 0.5 ? light.accent.l : 0.27) : dark.muted; + dark.sidebar = adjustLightness(dark.background, 0.04); + dark.border = adjustLightness(dark.background, 0.09); + dark.input = adjustLightness(dark.background, 0.09); + + // 2) Chromatic brand colors: keep hue/chroma, lift L a touch for dark legibility. + const lift = (col) => withLightness(col, Math.max(col.l, 0.6)); + dark.primary = light.primary; // primary stays the brand color + dark.destructive = light.destructive; + dark.ring = lift(light.ring); + dark['sidebar-primary'] = light['sidebar-primary']; + dark['sidebar-ring'] = lift(light['sidebar-ring']); + for (let i = 1; i <= 5; i++) dark[`chart-${i}`] = withLightness(light[`chart-${i}`], 0.7); + + // 3) Foregrounds re-paired against the NEW dark surfaces (light-on-dark). + dark.foreground = autoForeground(dark.background); + dark['card-foreground'] = autoForeground(dark.card); + dark['popover-foreground'] = autoForeground(dark.popover); + dark['secondary-foreground'] = autoForeground(dark.secondary); + // Repair against `muted` — the surface this text is actually painted on — not + // the page background (the two differ slightly, so background-repair could leave + // the rendered pair under AA). + dark['muted-foreground'] = ensureContrast(withLightness(dark.foreground, 0.7), dark.muted, 4.5); + dark['accent-foreground'] = autoForeground(dark.accent); + dark['primary-foreground'] = autoForeground(dark.primary); + dark['destructive-foreground'] = autoForeground(dark.destructive); + dark['sidebar-foreground'] = autoForeground(dark.sidebar); + dark['sidebar-primary-foreground'] = autoForeground(dark['sidebar-primary']); + dark['sidebar-accent'] = dark.muted; + dark['sidebar-accent-foreground'] = autoForeground(dark.muted); + dark['sidebar-border'] = dark.border; + + // 4) Status tints: keep hue/chroma, lift L for dark surfaces; foreground is + // LIGHT-on-tint per the text-on-tint contract in dark mode. + for (const role of ['info', 'success', 'warning']) { + const { tint, foreground } = tintPair(light[role], 'dark'); + dark[role] = tint; + dark[`${role}-foreground`] = foreground; + } + + return dark; +} + +/** + * Repair an EXPLICITLY-AUTHORED foreground against its surface WITHOUT ever + * throwing. ensureContrast hard-throws when a surface cannot carry AA-4.5 text of + * either polarity (a vivid mid-luminance brand color used as a SOLID surface), so + * a raw `ensureContrast(authored, surface)` in the explicit-dark-override branch + * would propagate that throw out of compileDesign entirely (the pipeline then + * silently degrades to the default look). We mirror autoForeground's graceful + * contract: try to honor + repair the authored color toward AA; if even that is + * impossible on this surface, fall back to the best-contrast pole (white/black) + * and record an honest warning. NEVER crashes — same discipline as every other + * foreground path in this compiler. + */ +function repairAuthoredForeground(authored, surface, warnings, label) { + try { + return ensureContrast(authored, surface, 4.5); + } catch { + // Authored color cannot reach AA against this surface even at its extreme — + // defer to autoForeground's best-pole fallback (which also warns). + return autoForeground(surface, 4.5, warnings, label); + } +} + +/** Merge an explicit `design.dark` color-role override (strings) over a derived + * dark token map, re-pairing affected foregrounds. Every foreground pairing here + * goes through the graceful best-pole fallback (repairAuthoredForeground / + * autoForeground), so a legible-impossible authored pairing produces a WARN + + * best-pole color — it NEVER throws out of compileDesign. */ +function applyDarkOverride(derivedDark, darkColors, warnings) { + const map = { ...derivedDark }; + const setSurface = (key, role, fgKey) => { + if (darkColors[role]) { + map[key] = parseColor(darkColors[role]); + if (fgKey) map[fgKey] = autoForeground(map[key], 4.5, warnings, fgKey); + } + }; + setSurface('background', 'surface', 'foreground'); + setSurface('background', 'background', 'foreground'); + if (darkColors['on-surface']) + map.foreground = repairAuthoredForeground(parseColor(darkColors['on-surface']), map.background, warnings, 'foreground'); + if (darkColors.foreground) + map.foreground = repairAuthoredForeground(parseColor(darkColors.foreground), map.background, warnings, 'foreground'); + if (darkColors.primary) { + map.primary = parseColor(darkColors.primary); + // Honor an explicitly authored dark primary-foreground (contract: an explicit + // dark: block is used, not re-derived) — but route it through the graceful + // best-pole fallback so a vivid mid-luminance primary can never crash compile. + map['primary-foreground'] = darkColors['primary-foreground'] + ? repairAuthoredForeground(parseColor(darkColors['primary-foreground']), map.primary, warnings, 'primary-foreground') + : autoForeground(map.primary, 4.5, warnings, 'primary-foreground'); + map.ring = withLightness(map.primary, Math.max(map.primary.l, 0.6)); + } else if (darkColors['primary-foreground']) { + map['primary-foreground'] = repairAuthoredForeground( + parseColor(darkColors['primary-foreground']), + map.primary, + warnings, + 'primary-foreground' + ); + } + if (darkColors.accent) { + map.accent = parseColor(darkColors.accent); + map['accent-foreground'] = darkColors['accent-foreground'] + ? repairAuthoredForeground(parseColor(darkColors['accent-foreground']), map.accent, warnings, 'accent-foreground') + : autoForeground(map.accent, 4.5, warnings, 'accent-foreground'); + } + if (darkColors.error || darkColors.destructive) { + map.destructive = parseColor(darkColors.error || darkColors.destructive); + map['destructive-foreground'] = autoForeground(map.destructive, 4.5, warnings, 'destructive-foreground'); + } + return map; +} + +/** Stringify an OKLCH color map into `--var: oklch(...)` value strings, + * keeping ONLY override-surface keys. Adds the radius scalar. */ +function stringify(map, radius) { + const out = {}; + for (const [key, val] of Object.entries(map)) { + if (!OVERRIDE_SURFACE.has(key)) continue; // structural-safety + out[`--${key}`] = formatOklch(val); + } + out['--radius'] = radius; + return out; +} + +/** + * compileDesign(design, { defaultMode } = {}) — the public entry. + */ +export function compileDesign(design, { defaultMode } = {}) { + const warnings = []; + const d = design || {}; + const colors = d.colors || {}; + + if (!colors.primary) W(warnings, 'No primary color provided; using a neutral default primary.'); + + const lightObjs = buildLight(colors, warnings); + + let darkObjs; + if (d.dark && typeof d.dark === 'object' && d.dark.colors && typeof d.dark.colors === 'object') { + // Explicit dark palette: derive baseline then overlay the explicit roles. + darkObjs = applyDarkOverride(deriveDark(lightObjs, warnings), d.dark.colors, warnings); + } else if (d.dark && typeof d.dark === 'object') { + // `dark:` is itself a color-role map. + darkObjs = applyDarkOverride(deriveDark(lightObjs, warnings), d.dark, warnings); + } else { + darkObjs = deriveDark(lightObjs, warnings); + } + + // radius: design.radius || rounded.md || 0.5rem + const radius = + (typeof d.radius === 'string' && d.radius) || + (d.rounded && typeof d.rounded === 'object' && (d.rounded.md || d.rounded.default)) || + '0.5rem'; + + // fonts (only family names resolved here; the layout codemod consumes them) + const fontCfg = d.font || (d.typography && d.typography.font) || {}; + const sans = resolveFont(fontCfg.sans || (d.typography && d.typography.sans), { role: 'sans' }); + const mono = resolveFont(fontCfg.mono || (d.typography && d.typography.mono), { role: 'mono' }); + if (sans.warning) W(warnings, sans.warning); + if (mono.warning) W(warnings, mono.warning); + + const result = { + light: stringify(lightObjs, radius), + dark: stringify(darkObjs, radius), + radius, + fonts: { sans, mono }, + warnings, + }; + if (defaultMode) result.defaultMode = defaultMode === 'dark' ? 'dark' : 'light'; + return result; +} + +/** Render the single MARKED override block. Asserts override-surface only. */ +export function renderOverrideBlock({ light, dark }) { + const emit = (vars) => + Object.entries(vars) + .map(([k, v]) => { + const bare = k.replace(/^--/, ''); + if (!OVERRIDE_SURFACE.has(bare)) { + throw new Error(`renderOverrideBlock: refusing to emit non-override-surface var "${k}".`); + } + return ` ${k}: ${v};`; + }) + .join('\n'); + + return [ + BEGIN_SENTINEL, + ':root {', + emit(light), + '}', + '.dark {', + emit(dark), + '}', + END_SENTINEL, + '', + ].join('\n'); +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/compile.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/compile.test.mjs new file mode 100644 index 0000000..a81f021 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/compile.test.mjs @@ -0,0 +1,181 @@ +/** node --test scripts/lib/design/compile.test.mjs */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compileDesign, renderOverrideBlock, OVERRIDE_SURFACE, BEGIN_SENTINEL, END_SENTINEL } from './compile.mjs'; +import { parseColor, contrastRatio } from './oklch.mjs'; +import { parseDesignMd } from './design-md.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const DESIGN = { + name: 'Test', + colors: { + primary: 'oklch(0.55 0.13 230)', + surface: 'oklch(0.99 0 0)', + 'on-surface': 'oklch(0.25 0 0)', + error: 'oklch(0.55 0.2 25)', + }, + rounded: { md: '0.375rem' }, +}; + +test('compileDesign emits ONLY override-surface vars', () => { + const { light, dark } = compileDesign(DESIGN); + for (const map of [light, dark]) { + for (const key of Object.keys(map)) { + const bare = key.replace(/^--/, ''); + assert.ok(OVERRIDE_SURFACE.has(bare), `non-override-surface var emitted: ${key}`); + } + } +}); + +test('compileDesign produces all override-surface vars in both modes', () => { + const { light, dark } = compileDesign(DESIGN); + for (const bare of OVERRIDE_SURFACE) { + assert.ok(light[`--${bare}`] != null, `light missing --${bare}`); + assert.ok(dark[`--${bare}`] != null, `dark missing --${bare}`); + } +}); + +test('all emitted color values are valid oklch (or radius rem)', () => { + const { light, dark } = compileDesign(DESIGN); + for (const map of [light, dark]) { + for (const [k, v] of Object.entries(map)) { + if (k === '--radius') { + assert.match(v, /rem$/); + continue; + } + assert.doesNotThrow(() => parseColor(v), `invalid color for ${k}: ${v}`); + } + } +}); + +test('.dark is derived (differs from light) and valid', () => { + const { light, dark } = compileDesign(DESIGN); + // background should invert from near-white to a dark band + const lbg = parseColor(light['--background']); + const dbg = parseColor(dark['--background']); + assert.ok(lbg.l > 0.8, 'light bg is light'); + assert.ok(dbg.l < 0.35, 'dark bg is dark'); +}); + +test('critical contrast pairs pass AA in both modes', () => { + const { light, dark } = compileDesign(DESIGN); + // Each foreground is checked against the surface it is ACTUALLY rendered on — + // --muted-foreground is painted on --muted (and reused on --secondary), NOT on + // --background. (The old test checked --muted-foreground/--background, the repair + // target, which masked a real ~4.41:1 miss on the slightly-darker --muted tint.) + const pairs = [ + ['--foreground', '--background'], + ['--primary-foreground', '--primary'], + ['--muted-foreground', '--muted'], + ['--muted-foreground', '--secondary'], + ['--destructive-foreground', '--destructive'], + ['--secondary-foreground', '--secondary'], + ]; + for (const map of [light, dark]) { + for (const [fg, bg] of pairs) { + const r = contrastRatio(parseColor(map[fg]), parseColor(map[bg])); + assert.ok(r >= 4.5 - 0.05, `${fg}/${bg} = ${r.toFixed(2)} (mode ${map === dark ? 'dark' : 'light'})`); + } + } +}); + +test('catalog presets compile (never throw) and pass AA on rendered surfaces', () => { + // Every shipped catalog preset (fixtures/design/*.md) must COMPILE without + // throwing — including `constructive`, whose vivid mid-luminance sky-blue primary + // historically crashed the explicit-dark-override branch — and the result's + // critical text pairs must clear AA against the surface each is painted on. This + // is the regression guard for both the no-throw contract AND the rendered-surface + // contrast (a preset that crashes or under-contrasts is caught here, not silently + // dropped at wire time). + const CAT_DIR = resolve(HERE, '..', '..', '..', 'fixtures', 'design'); + const presets = readdirSync(CAT_DIR).filter((f) => f.endsWith('.md')); + assert.ok(presets.length >= 5, `expected >=5 catalog presets, found ${presets.length}`); + const pairs = [ + ['--foreground', '--background'], + ['--primary-foreground', '--primary'], + ['--muted-foreground', '--muted'], + ['--muted-foreground', '--secondary'], + ['--destructive-foreground', '--destructive'], + ['--secondary-foreground', '--secondary'], + ['--card-foreground', '--card'], + ['--success-foreground', '--success'], + ['--warning-foreground', '--warning'], + ['--info-foreground', '--info'], + ]; + for (const file of presets) { + const design = parseDesignMd(readFileSync(resolve(CAT_DIR, file), 'utf8')).frontmatter || {}; + let compiled; + assert.doesNotThrow(() => { + compiled = compileDesign(design); + }, `preset ${file} must compile without throwing`); + const { light, dark } = compiled; + for (const map of [light, dark]) { + for (const [fg, bg] of pairs) { + const r = contrastRatio(parseColor(map[fg]), parseColor(map[bg])); + assert.ok( + r >= 4.5 - 0.05, + `preset ${file}: ${fg}/${bg} = ${r.toFixed(2)} (mode ${map === dark ? 'dark' : 'light'})` + ); + } + } + } +}); + +test('success/warning foreground honor the text-on-tint contract (not naive white)', () => { + const { light, dark } = compileDesign(DESIGN); + for (const tint of ['success', 'warning', 'info']) { + const lr = contrastRatio(parseColor(light[`--${tint}-foreground`]), parseColor(light[`--${tint}`])); + const dr = contrastRatio(parseColor(dark[`--${tint}-foreground`]), parseColor(dark[`--${tint}`])); + assert.ok(lr >= 4.5 - 0.05, `light ${tint} tint-fg = ${lr.toFixed(2)}`); + assert.ok(dr >= 4.5 - 0.05, `dark ${tint} tint-fg = ${dr.toFixed(2)}`); + // In light mode the tint foreground should be DARKER than the tint (dark-on-light tint). + const ltint = parseColor(light[`--${tint}`]); + const lfg = parseColor(light[`--${tint}-foreground`]); + assert.ok(lfg.l < ltint.l, `light ${tint} foreground should be darker than the tint`); + } +}); + +test('explicit dark override is honored', () => { + const withDark = { + ...DESIGN, + dark: { colors: { surface: 'oklch(0.18 0.01 250)', 'on-surface': 'oklch(0.96 0 0)' } }, + }; + const { dark } = compileDesign(withDark); + const dbg = parseColor(dark['--background']); + assert.ok(Math.abs(dbg.l - 0.18) < 0.02, `dark bg should follow override, got ${dbg.l}`); +}); + +test('radius falls back through design.radius -> rounded.md -> default', () => { + assert.equal(compileDesign({ colors: { primary: 'oklch(0.5 0.1 40)' }, radius: '1rem' }).radius, '1rem'); + assert.equal(compileDesign({ colors: { primary: 'oklch(0.5 0.1 40)' }, rounded: { md: '0.25rem' } }).radius, '0.25rem'); + assert.equal(compileDesign({ colors: { primary: 'oklch(0.5 0.1 40)' } }).radius, '0.5rem'); +}); + +test('renderOverrideBlock wraps exactly the marked region and rejects non-surface vars', () => { + const { light, dark } = compileDesign(DESIGN); + const css = renderOverrideBlock({ light, dark }); + assert.ok(css.startsWith(BEGIN_SENTINEL)); + assert.ok(css.includes(END_SENTINEL)); + assert.match(css, /:root \{/); + assert.match(css, /\.dark \{/); + // structural-safety guard + assert.throws(() => renderOverrideBlock({ light: { '--font-sans': 'x' }, dark: {} })); +}); + +test('non-allowlisted font falls back to Geist with a warning', () => { + const { fonts, warnings } = compileDesign({ ...DESIGN, font: { sans: 'Comic Sans' } }); + assert.equal(fonts.sans.family, 'Geist'); + assert.ok(warnings.some((w) => /Comic Sans/.test(w))); +}); + +test('an allowlisted font resolves keeping the geist variable name', () => { + const { fonts } = compileDesign({ ...DESIGN, font: { sans: 'Outfit', mono: 'JetBrains Mono' } }); + assert.equal(fonts.sans.family, 'Outfit'); + assert.equal(fonts.sans.variable, '--font-geist-sans'); + assert.equal(fonts.mono.family, 'JetBrains Mono'); + assert.equal(fonts.mono.variable, '--font-geist-mono'); +}); diff --git a/.agents/skills/constructive-builder/scripts/lib/design/design-md.mjs b/.agents/skills/constructive-builder/scripts/lib/design/design-md.mjs new file mode 100644 index 0000000..519bfa5 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/design-md.mjs @@ -0,0 +1,120 @@ +/** + * scripts/lib/design/design-md.mjs — parse/serialize a Google-Labs-style + * `design.md`: a Markdown document with a YAML frontmatter block delimited by + * `---` fences, followed by free prose. + * + * Frontmatter is parsed with the skill's EXISTING zero-dep YAML reader + * (`parseBrief` from ../brief-yaml.mjs) — NO new dependency. + * + * ZERO-DEP. Node >=18 ESM. Pure functions. + */ + +import { parseBrief } from '../brief-yaml.mjs'; + +/** + * Split a `design.md` text into its frontmatter object + prose body. + * parseDesignMd(text) -> { frontmatter, prose } + * Frontmatter keys: version?, name, description?, colors, typography, rounded, + * spacing, components?, dark? (+ any extension fields like allow_brand_hue, + * radius, default_mode — passed through untouched). + * If there is no `---` fence the whole document is treated as prose with an + * empty frontmatter object. + */ +export function parseDesignMd(text) { + if (typeof text !== 'string') { + throw new Error(`parseDesignMd expects a string, got ${typeof text}`); + } + // Normalize CRLF and a possible leading BOM. + const src = text.replace(/^/, '').replace(/\r\n?/g, '\n'); + + // Frontmatter must open with `---` on the first non-empty line. + const fence = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n([\s\S]*))?$/; + const lead = src.replace(/^\s*\n/, ''); // tolerate blank lines before the fence + const m = lead.match(fence); + if (!m) { + return { frontmatter: {}, prose: src.trim() }; + } + const yamlText = m[1]; + const prose = (m[2] || '').trim(); + const frontmatter = yamlText.trim() === '' ? {} : parseBrief(yamlText); + return { frontmatter: frontmatter || {}, prose }; +} + +/* ---------------------------------------------------------------------------- + * Serialization — emit a minimal-but-faithful YAML for the frontmatter we own. + * We only need to round-trip the shapes the compiler produces/consumes (scalars, + * flat maps, one level of nested maps, and short arrays). Keep it deterministic. + * ------------------------------------------------------------------------- */ + +function isPlainObject(v) { + return v != null && typeof v === 'object' && !Array.isArray(v); +} + +function scalarToYaml(v) { + if (v === null) return 'null'; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number') return String(v); + const s = String(v); + // Quote when the value could be misparsed (leading symbols, colons, '#', + // braces, or looks numeric/boolean). + if ( + s === '' || + /^[\s>|*&!%@`"'{}\[\],#-]/.test(s) || + /:\s|\s#/.test(s) || + /^(true|false|null|~)$/i.test(s) || + /^-?\d/.test(s) + ) { + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return s; +} + +function emitValue(value, indent, lines) { + const pad = ' '.repeat(indent); + if (Array.isArray(value)) { + for (const item of value) { + if (isPlainObject(item)) { + // Inline as a flow map for compactness + safe re-parse. + lines.push(`${pad}- ${flowMap(item)}`); + } else { + lines.push(`${pad}- ${scalarToYaml(item)}`); + } + } + return; + } + if (isPlainObject(value)) { + for (const [k, v] of Object.entries(value)) { + if (isPlainObject(v) && Object.keys(v).length > 0) { + lines.push(`${pad}${k}:`); + emitValue(v, indent + 1, lines); + } else if (Array.isArray(v) && v.length > 0) { + lines.push(`${pad}${k}:`); + emitValue(v, indent + 1, lines); + } else if (isPlainObject(v) || Array.isArray(v)) { + lines.push(`${pad}${k}: ${Array.isArray(v) ? '[]' : '{}'}`); + } else { + lines.push(`${pad}${k}: ${scalarToYaml(v)}`); + } + } + return; + } + lines.push(`${pad}${scalarToYaml(value)}`); +} + +function flowMap(obj) { + const parts = Object.entries(obj).map(([k, v]) => { + if (isPlainObject(v)) return `${k}: ${flowMap(v)}`; + if (Array.isArray(v)) return `${k}: [${v.map((x) => scalarToYaml(x)).join(', ')}]`; + return `${k}: ${scalarToYaml(v)}`; + }); + return `{ ${parts.join(', ')} }`; +} + +/** Serialize { frontmatter, prose } back into a `design.md` text. */ +export function serializeDesignMd({ frontmatter = {}, prose = '' }) { + const lines = []; + emitValue(frontmatter, 0, lines); + const fm = lines.join('\n'); + const body = (prose || '').trim(); + return `---\n${fm}\n---\n${body ? `\n${body}\n` : ''}`; +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/design-md.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/design-md.test.mjs new file mode 100644 index 0000000..374ce93 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/design-md.test.mjs @@ -0,0 +1,54 @@ +/** node --test scripts/lib/design/design-md.test.mjs */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDesignMd, serializeDesignMd } from './design-md.mjs'; + +const SAMPLE = `--- +name: Editorial Calm +description: warm, trustworthy, print feel +colors: + primary: "oklch(0.55 0.12 40)" + surface: "oklch(0.99 0 0)" + on-surface: "oklch(0.25 0 0)" +rounded: + md: "0.375rem" +allow_brand_hue: true +--- + +# Editorial Calm + +A warm editorial theme with generous line height. +`; + +test('parseDesignMd splits frontmatter + prose', () => { + const { frontmatter, prose } = parseDesignMd(SAMPLE); + assert.equal(frontmatter.name, 'Editorial Calm'); + assert.equal(frontmatter.colors.primary, 'oklch(0.55 0.12 40)'); + assert.equal(frontmatter.rounded.md, '0.375rem'); + assert.equal(frontmatter.allow_brand_hue, true); + assert.match(prose, /generous line height/); +}); + +test('parseDesignMd tolerates a doc with no frontmatter', () => { + const { frontmatter, prose } = parseDesignMd('# just prose\n\nhello'); + assert.deepEqual(frontmatter, {}); + assert.match(prose, /just prose/); +}); + +test('serialize -> parse round-trips frontmatter values', () => { + const { frontmatter, prose } = parseDesignMd(SAMPLE); + const text = serializeDesignMd({ frontmatter, prose }); + const again = parseDesignMd(text); + assert.equal(again.frontmatter.name, frontmatter.name); + assert.equal(again.frontmatter.colors.primary, frontmatter.colors.primary); + assert.equal(again.frontmatter.rounded.md, frontmatter.rounded.md); + assert.equal(again.frontmatter.allow_brand_hue, true); + assert.match(again.prose, /generous line height/); +}); + +test('serialize emits a fenced frontmatter block', () => { + const text = serializeDesignMd({ frontmatter: { name: 'X', colors: { primary: 'oklch(0.5 0.1 200)' } }, prose: 'body' }); + assert.match(text, /^---\n/); + assert.match(text, /\n---\n/); + assert.match(text, /body/); +}); diff --git a/.agents/skills/constructive-builder/scripts/lib/design/fixtures.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/fixtures.test.mjs new file mode 100644 index 0000000..5011ad4 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/fixtures.test.mjs @@ -0,0 +1,152 @@ +/** + * scripts/lib/design/fixtures.test.mjs — the FIXTURE-DRIVEN engine contract test. + * + * Iterates every pair in `fixtures/design/__fixtures__/.{design.md,expected.json}`, + * parses the design.md via parseDesignMd, runs compileDesign / lintDesign, and asserts + * each present key of expected.json against the README contract: + * light/dark — deterministic role→var copies (epsilon-compared in OKLCH) + * radius — exact radius string + * fonts.{sans,mono} — resolved family names + * overrideSurfaceOnly— every emitted var ∈ OVERRIDE_SURFACE + * mustContain/Not — substring presence/absence in the rendered override block + * contrast — WCAG ratio of named pairs meets `min` + * lightnessOrder — L(darker) < L(lighter) + * lint.{ok,expectFindings,forbidFindings,maxSeverity} — invariant findings + * + * Zero-dep. Node >=18. Run via `node --test scripts/lib/design/*.test.mjs`. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compileDesign, renderOverrideBlock, OVERRIDE_SURFACE } from './compile.mjs'; +import { lintDesign } from './invariants.mjs'; +import { parseDesignMd } from './design-md.mjs'; +import { parseColor, contrastRatio } from './oklch.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX_DIR = resolve(HERE, '..', '..', '..', 'fixtures', 'design', '__fixtures__'); + +const EPS = 0.06; // OKLCH tolerance — formatOklch rounding + derived-token slack. + +function colorClose(aStr, bStr, eps = EPS) { + const a = parseColor(aStr); + const b = parseColor(bStr); + // hue is angular + meaningless at low chroma; compare L and C tightly, h loosely. + const dl = Math.abs(a.l - b.l); + const dc = Math.abs(a.c - b.c); + if (dl > eps || dc > eps) return false; + if (a.c > 0.03 && b.c > 0.03) { + let dh = Math.abs(((a.h - b.h + 540) % 360) - 180); + if (dh > 25) return false; + } + return true; +} + +function findingMatches(findings, spec) { + // spec: { rule, severity } OR { anyRule:[...], severity } + const rules = spec.anyRule || (spec.rule ? [spec.rule] : []); + return findings.some((f) => { + if (rules.length && !rules.includes(f.rule)) return false; + if (spec.severity && f.severity !== spec.severity) return false; + return true; + }); +} + +const expectedFiles = readdirSync(FIX_DIR) + .filter((f) => f.endsWith('.expected.json')) + .sort(); + +assert.ok(expectedFiles.length >= 8, `expected >=8 fixtures, found ${expectedFiles.length}`); + +for (const ef of expectedFiles) { + const name = ef.replace(/\.expected\.json$/, ''); + test(`fixture: ${name}`, () => { + const expected = JSON.parse(readFileSync(resolve(FIX_DIR, ef), 'utf8')); + const mdPath = resolve(FIX_DIR, `${name}.design.md`); + const design = parseDesignMd(readFileSync(mdPath, 'utf8')).frontmatter || {}; + + // ── lint assertions ── + if (expected.lint) { + const { ok, findings } = lintDesign(design); + const L = expected.lint; + if (typeof L.ok === 'boolean') { + assert.equal(ok, L.ok, `lint.ok mismatch; findings=${JSON.stringify(findings)}`); + } + for (const spec of L.expectFindings || []) { + assert.ok(findingMatches(findings, spec), `missing finding ${JSON.stringify(spec)} in ${JSON.stringify(findings)}`); + } + for (const spec of L.forbidFindings || []) { + assert.ok(!findingMatches(findings, spec), `forbidden finding present ${JSON.stringify(spec)}`); + } + if (L.maxSeverity === 'warn') { + assert.ok(!findings.some((f) => f.severity === 'error'), `expected no error findings, got ${JSON.stringify(findings)}`); + } + } + + // Fixtures that are lint-only (no primary cannot compile) skip compile. + const compileExpected = + expected.light || expected.dark || expected.radius || expected.fonts || + expected.overrideSurfaceOnly || expected.mustContain || expected.mustNotContain || + expected.contrast || expected.lightnessOrder; + if (!compileExpected) return; + + const opts = expected.compileOptions || {}; + const compiled = compileDesign(design, opts); + const { light, dark } = compiled; + const css = renderOverrideBlock({ light, dark }); + + // ── direct role→var copies ── + if (expected.light) { + for (const [k, v] of Object.entries(expected.light)) { + assert.ok(light[k] != null, `light missing ${k}`); + assert.ok(colorClose(light[k], v), `light ${k}: got ${light[k]}, expected ≈ ${v}`); + } + } + if (expected.dark) { + for (const [k, v] of Object.entries(expected.dark)) { + assert.ok(dark[k] != null, `dark missing ${k}`); + assert.ok(colorClose(dark[k], v), `dark ${k}: got ${dark[k]}, expected ≈ ${v}`); + } + } + + // ── radius ── + if (expected.radius) assert.equal(compiled.radius, expected.radius); + + // ── fonts ── + if (expected.fonts) { + if (expected.fonts.sans) assert.equal(compiled.fonts.sans.family, expected.fonts.sans); + if (expected.fonts.mono) assert.equal(compiled.fonts.mono.family, expected.fonts.mono); + } + + // ── override-surface allowlist ── + if (expected.overrideSurfaceOnly) { + for (const map of [light, dark]) { + for (const key of Object.keys(map)) { + assert.ok(OVERRIDE_SURFACE.has(key.replace(/^--/, '')), `non-override-surface var emitted: ${key}`); + } + } + } + + // ── structural safety / sentinels ── + for (const s of expected.mustContain || []) assert.ok(css.includes(s), `override block missing: ${s}`); + for (const s of expected.mustNotContain || []) assert.ok(!css.includes(s), `override block must NOT contain: ${s}`); + + // ── contrast pairs ── + for (const c of expected.contrast || []) { + const map = c.mode === 'dark' ? dark : light; + const r = contrastRatio(parseColor(map[c.fg]), parseColor(map[c.bg])); + assert.ok(r >= c.min - 0.05, `${c.mode} ${c.fg}/${c.bg} = ${r.toFixed(2)} < ${c.min}`); + } + + // ── lightness ordering ── + for (const o of expected.lightnessOrder || []) { + const map = o.mode === 'dark' ? dark : light; + const ld = parseColor(map[o.darker]).l; + const ll = parseColor(map[o.lighter]).l; + assert.ok(ld < ll, `${o.mode}: L(${o.darker})=${ld.toFixed(3)} should be < L(${o.lighter})=${ll.toFixed(3)}`); + } + }); +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/fonts.mjs b/.agents/skills/constructive-builder/scripts/lib/design/fonts.mjs new file mode 100644 index 0000000..ee96377 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/fonts.mjs @@ -0,0 +1,90 @@ +/** + * scripts/lib/design/fonts.mjs — a curated allowlist of `next/font/google` + * families, with a resolver that returns the loader name + import line + the + * CSS variable to bind. + * + * The boilerplate `layout.tsx` declares: + * const geistSans = Geist({ variable: '--font-geist-sans', subsets:['latin'] }) + * const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets:['latin'] }) + * and `@theme inline` maps `--font-sans: var(--font-geist-sans)`, + * `--font-mono: var(--font-geist-mono)`. So a custom font swaps ONLY the loader + * import + call, KEEPING the variable strings `--font-geist-sans` / + * `--font-geist-mono` and the body className tokens. fonts.mjs therefore never + * renames the variable; `variable` below is always one of those two. + * + * Non-allowlisted family => Geist fallback + a warning (so a custom font can + * never break the build). + * + * ZERO-DEP. Node >=18 ESM. Pure. + */ + +// loaderName is the exact `next/font/google` export. Google font families with +// a space import under an underscored name (e.g. "JetBrains Mono" -> JetBrains_Mono). +const SANS_FONTS = { + geist: { family: 'Geist', loaderName: 'Geist' }, + outfit: { family: 'Outfit', loaderName: 'Outfit' }, + sora: { family: 'Sora', loaderName: 'Sora' }, + manrope: { family: 'Manrope', loaderName: 'Manrope' }, + inter: { family: 'Inter', loaderName: 'Inter' }, + 'plus jakarta sans': { family: 'Plus Jakarta Sans', loaderName: 'Plus_Jakarta_Sans' }, + 'ibm plex sans': { family: 'IBM Plex Sans', loaderName: 'IBM_Plex_Sans' }, + 'dm sans': { family: 'DM Sans', loaderName: 'DM_Sans' }, + 'space grotesk': { family: 'Space Grotesk', loaderName: 'Space_Grotesk' }, + figtree: { family: 'Figtree', loaderName: 'Figtree' }, +}; + +const MONO_FONTS = { + 'geist mono': { family: 'Geist Mono', loaderName: 'Geist_Mono' }, + 'jetbrains mono': { family: 'JetBrains Mono', loaderName: 'JetBrains_Mono' }, + 'ibm plex mono': { family: 'IBM Plex Mono', loaderName: 'IBM_Plex_Mono' }, + 'space mono': { family: 'Space Mono', loaderName: 'Space_Mono' }, + 'fira code': { family: 'Fira Code', loaderName: 'Fira_Code' }, + 'roboto mono': { family: 'Roboto Mono', loaderName: 'Roboto_Mono' }, + 'jetbrains mono variable': { family: 'JetBrains Mono', loaderName: 'JetBrains_Mono' }, +}; + +const SANS_FALLBACK = { family: 'Geist', loaderName: 'Geist', variable: '--font-geist-sans' }; +const MONO_FALLBACK = { family: 'Geist Mono', loaderName: 'Geist_Mono', variable: '--font-geist-mono' }; + +function importLineFor(loaderName) { + return `import { ${loaderName} } from 'next/font/google';`; +} + +/** + * resolveFont(name, { role } = {}) -> { family, loaderName, importLine, variable, warning? } + * role: 'sans' (default) | 'mono' — picks which allowlist + which variable name + * to bind. Unknown family => Geist/Geist Mono fallback + a `warning`. + */ +export function resolveFont(name, { role = 'sans' } = {}) { + const isMono = role === 'mono'; + const table = isMono ? MONO_FONTS : SANS_FONTS; + const fallback = isMono ? MONO_FALLBACK : SANS_FALLBACK; + const variable = isMono ? '--font-geist-mono' : '--font-geist-sans'; + + if (name == null || String(name).trim() === '') { + return { ...fallback, importLine: importLineFor(fallback.loaderName) }; + } + const key = String(name).trim().toLowerCase(); + const hit = table[key]; + if (!hit) { + return { + ...fallback, + importLine: importLineFor(fallback.loaderName), + warning: `Font "${name}" is not on the ${role} allowlist; falling back to ${fallback.family}.`, + }; + } + return { + family: hit.family, + loaderName: hit.loaderName, + importLine: importLineFor(hit.loaderName), + variable, + }; +} + +/** The full allowlist (families only) — handy for docs/validation. */ +export function listFonts() { + return { + sans: Object.values(SANS_FONTS).map((f) => f.family), + mono: Object.values(MONO_FONTS).map((f) => f.family), + }; +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/fonts.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/fonts.test.mjs new file mode 100644 index 0000000..fedda28 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/fonts.test.mjs @@ -0,0 +1,38 @@ +/** node --test scripts/lib/design/fonts.test.mjs */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveFont, listFonts } from './fonts.mjs'; + +test('resolves an allowlisted sans font and binds the geist-sans variable', () => { + const f = resolveFont('Outfit', { role: 'sans' }); + assert.equal(f.family, 'Outfit'); + assert.equal(f.loaderName, 'Outfit'); + assert.equal(f.variable, '--font-geist-sans'); + assert.match(f.importLine, /import \{ Outfit \} from 'next\/font\/google';/); + assert.equal(f.warning, undefined); +}); + +test('resolves a multi-word mono font to its underscored loader', () => { + const f = resolveFont('JetBrains Mono', { role: 'mono' }); + assert.equal(f.loaderName, 'JetBrains_Mono'); + assert.equal(f.variable, '--font-geist-mono'); +}); + +test('unknown font falls back to Geist + warns', () => { + const f = resolveFont('Papyrus', { role: 'sans' }); + assert.equal(f.family, 'Geist'); + assert.equal(f.variable, '--font-geist-sans'); + assert.match(f.warning, /not on the sans allowlist/); +}); + +test('empty/undefined => Geist fallback without a warning', () => { + const f = resolveFont(undefined, { role: 'sans' }); + assert.equal(f.family, 'Geist'); + assert.equal(f.warning, undefined); +}); + +test('listFonts returns both ramps', () => { + const { sans, mono } = listFonts(); + assert.ok(sans.includes('Geist')); + assert.ok(mono.includes('Geist Mono')); +}); diff --git a/.agents/skills/constructive-builder/scripts/lib/design/invariants.mjs b/.agents/skills/constructive-builder/scripts/lib/design/invariants.mjs new file mode 100644 index 0000000..4905ae0 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/invariants.mjs @@ -0,0 +1,191 @@ +/** + * scripts/lib/design/invariants.mjs — the taste/accessibility rules as code. + * + * `lintDesign(design) -> { ok, findings:[{rule, severity, msg}] }` + * + * `design` is the frontmatter object of a design.md (see design-md.mjs): it has + * `colors` (a map of role -> css color string), optional `typography`, + * `rounded`/`radius`, `spacing`, `components`, and extension flags such as + * `allow_brand_hue`. Rules are intentionally generic — they reference color + * ROLES, never app/entity/domain literals. + * + * Rule set (per the shared contract): + * missing-primary error + * accent-count (<=1) warn + * saturation/chroma cap (<80%) warn + * pure-black banned (min L >= ~0.18) warn + * ai-purple-band (primary/accent) warn (unless design.allow_brand_hue) + * dimension units (px/em/rem only) warn + * contrast-pairs error (<3:1) / warn (<4.5) + * success/warning tint-foreground warn + * + * ZERO-DEP. Node >=18 ESM. Pure. + */ + +import { parseColor, contrastRatio, isAiPurpleBand, relativeLuminance, toSrgb } from './oklch.mjs'; + +// Chroma at which an OKLCH color is considered ~"100% saturated" for the <80% +// cap. OKLCH max usable chroma for sRGB hovers ~0.37; we treat that as the ceiling. +const CHROMA_CEILING = 0.37; +const SAT_CAP = 0.8; // <80% +const MIN_L = 0.18; // no near-pure black + +function tryParse(str) { + try { + return parseColor(str); + } catch { + return null; + } +} + +function isAccentRole(role) { + return role === 'accent' || role === 'tertiary'; +} + +/** Validate that a dimension string uses only px/em/rem (or unitless 0). */ +function badDimensionUnit(value) { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (v === '0' || v === '') return false; + // Allow space/comma separated multi-values (e.g. shadows) — check each token. + const tokens = v.split(/[\s,]+/).filter(Boolean); + for (const t of tokens) { + const m = t.match(/^-?\d*\.?\d+([a-z%]+)$/i); + if (!m) continue; // not a pure dimension token (could be a keyword/color) + const unit = m[1].toLowerCase(); + if (unit !== 'px' && unit !== 'em' && unit !== 'rem') return true; + } + return false; +} + +export function lintDesign(design) { + const findings = []; + const add = (rule, severity, msg) => findings.push({ rule, severity, msg }); + + const d = design || {}; + const colors = d.colors || {}; + + /* --- missing-primary (error) --- */ + if (!colors.primary) { + add('missing-primary', 'error', 'design.colors.primary is required.'); + } + + /* --- accent-count <= 1 (warn) --- */ + const accentRoles = Object.keys(colors).filter(isAccentRole); + if (accentRoles.length > 1) { + add( + 'accent-count', + 'warn', + `Found ${accentRoles.length} accent roles (${accentRoles.join(', ')}); use at most one.` + ); + } + + /* --- per-color: saturation cap, pure-black, ai-purple --- */ + for (const [role, value] of Object.entries(colors)) { + const col = tryParse(value); + if (!col) { + add('color-parse', 'warn', `Could not parse colors.${role} = "${value}".`); + continue; + } + const satFrac = col.c / CHROMA_CEILING; + if (satFrac >= SAT_CAP) { + add( + 'saturation', + 'warn', + `colors.${role} chroma ${col.c.toFixed(3)} (~${Math.round(satFrac * 100)}% of gamut) exceeds the <80% cap.` + ); + } + if (col.l < MIN_L) { + add( + 'pure-black', + 'warn', + `colors.${role} lightness ${col.l.toFixed(3)} is below the ${MIN_L} floor (avoid pure/near black).` + ); + } + if ((role === 'primary' || isAccentRole(role)) && !d.allow_brand_hue && isAiPurpleBand(col)) { + add( + 'ai-purple-band', + 'warn', + `colors.${role} (h≈${Math.round(col.h)}, c=${col.c.toFixed(3)}) sits in the generic AI blue-purple band; set allow_brand_hue: true to keep it.` + ); + } + } + + /* --- dimension units (px/em/rem only) (warn) --- */ + const dimSources = []; + if (d.rounded && typeof d.rounded === 'object') { + for (const [k, v] of Object.entries(d.rounded)) dimSources.push([`rounded.${k}`, v]); + } + if (typeof d.radius === 'string') dimSources.push(['radius', d.radius]); + if (d.spacing && typeof d.spacing === 'object') { + for (const [k, v] of Object.entries(d.spacing)) dimSources.push([`spacing.${k}`, v]); + } + for (const [where, val] of dimSources) { + if (badDimensionUnit(val)) { + add('dimension-units', 'warn', `${where} = "${val}" uses a non px/em/rem unit.`); + } + } + + /* --- contrast-pairs (error <3:1, warn <4.5) --- */ + const bg = tryParse(colors.surface || colors.background); + const fg = tryParse(colors['on-surface'] || colors.foreground); + // `floor`: true => below 3:1 is a hard error (the legibility contract per + // design-system.md §3: fg/bg, primary/primary-fg, muted-fg/bg, destructive, + // status tints). false => the pairing is advisory only (brand pairings like + // primary-on-surface, which is a fill color, not a required body-text pairing — + // and which the compiler's ensureContrast repairs); never a hard error. + const checkPair = (a, b, label, floor = true) => { + if (!a || !b) return; + const ratio = contrastRatio(a, b); + if (ratio < 3) { + add('contrast-pairs', floor ? 'error' : 'warn', `${label} contrast ${ratio.toFixed(2)}:1 is below the 3:1 floor.`); + } else if (ratio < 4.5) { + add('contrast-pairs', 'warn', `${label} contrast ${ratio.toFixed(2)}:1 is below AA 4.5:1.`); + } + }; + if (bg && fg) checkPair(fg, bg, 'on-surface / surface'); + const primary = tryParse(colors.primary); + // primary / primary-foreground IS the documented error pairing (the on-primary + // label legibility floor). The compiler auto-repairs it (§6), so lint it on the + // SOURCE as advisory (warn), not a hard build-breaking error. + const primaryFg = tryParse(colors['primary-foreground']); + if (primary && primaryFg) { + checkPair(primaryFg, primary, 'primary-foreground / primary', false); + } + if (primary && bg) { + // primary-on-surface is a brand pairing, not a required body-text pairing + // (per §3 it is NOT in the hard-error contrast list) — advisory only. + checkPair(primary, bg, 'primary / surface', false); + } + + /* --- success/warning tint-foreground contract (warn) --- + * The tint roles (success/warning/info) carry text-on-tint foregrounds. If a + * design supplies an explicit *-foreground we sanity-check it reads against + * its tint; if it supplies the tint without a foreground we just note that + * the compiler will derive a contrast-correct one. We never demand white. */ + for (const tint of ['success', 'warning', 'info']) { + const tintCol = tryParse(colors[tint]); + const fgCol = tryParse(colors[`${tint}-foreground`]); + if (tintCol && fgCol) { + const ratio = contrastRatio(fgCol, tintCol); + if (ratio < 4.5) { + add( + 'tint-foreground', + 'warn', + `${tint}-foreground / ${tint} contrast ${ratio.toFixed(2)}:1 < 4.5:1 (text-on-tint must stay legible, not naive white).` + ); + } + } else if (tintCol && !fgCol) { + // Informational: derived foreground will be contrast-repaired. + const lum = relativeLuminance(toSrgb(tintCol)); + add( + 'tint-foreground', + 'info', + `${tint} has no explicit foreground; compiler will derive a ${lum < 0.5 ? 'light' : 'dark'}-on-tint foreground.` + ); + } + } + + const ok = findings.every((f) => f.severity !== 'error'); + return { ok, findings }; +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/invariants.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/invariants.test.mjs new file mode 100644 index 0000000..9e171ce --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/invariants.test.mjs @@ -0,0 +1,94 @@ +/** node --test scripts/lib/design/invariants.test.mjs */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { lintDesign } from './invariants.mjs'; + +const has = (findings, rule) => findings.some((f) => f.rule === rule); +const sev = (findings, rule) => findings.find((f) => f.rule === rule)?.severity; + +test('flags missing primary as an error', () => { + const { ok, findings } = lintDesign({ colors: { surface: 'oklch(1 0 0)' } }); + assert.equal(ok, false); + assert.equal(sev(findings, 'missing-primary'), 'error'); +}); + +test('a clean trust-first design passes', () => { + const { ok, findings } = lintDesign({ + colors: { + primary: 'oklch(0.5 0.12 230)', + surface: 'oklch(0.99 0 0)', + 'on-surface': 'oklch(0.22 0 0)', + error: 'oklch(0.55 0.2 25)', + }, + allow_brand_hue: true, // primary is in band but explicitly allowed + }); + assert.equal(ok, true, JSON.stringify(findings)); +}); + +test('flags the AI purple band for primary unless allowed', () => { + const banded = { + colors: { primary: 'oklch(0.55 0.2 280)', surface: 'oklch(1 0 0)', 'on-surface': 'oklch(0.2 0 0)' }, + }; + let r = lintDesign(banded); + assert.equal(has(r.findings, 'ai-purple-band'), true); + assert.equal(sev(r.findings, 'ai-purple-band'), 'warn'); + // opt-in clears it + r = lintDesign({ ...banded, allow_brand_hue: true }); + assert.equal(has(r.findings, 'ai-purple-band'), false); +}); + +test('flags pure/near black', () => { + const r = lintDesign({ + colors: { primary: 'oklch(0.5 0.1 40)', surface: 'oklch(0.05 0 0)', 'on-surface': 'oklch(0.95 0 0)' }, + }); + assert.equal(has(r.findings, 'pure-black'), true); +}); + +test('flags over-saturated color (>80% of gamut)', () => { + const r = lintDesign({ + colors: { primary: 'oklch(0.6 0.34 40)', surface: 'oklch(1 0 0)', 'on-surface': 'oklch(0.2 0 0)' }, + }); + assert.equal(has(r.findings, 'saturation'), true); +}); + +test('flags more than one accent role', () => { + const r = lintDesign({ + colors: { + primary: 'oklch(0.5 0.1 40)', + accent: 'oklch(0.6 0.1 200)', + tertiary: 'oklch(0.6 0.1 300)', + surface: 'oklch(1 0 0)', + 'on-surface': 'oklch(0.2 0 0)', + }, + }); + assert.equal(has(r.findings, 'accent-count'), true); +}); + +test('flags a contrast failure as error below 3:1', () => { + const r = lintDesign({ + colors: { primary: 'oklch(0.5 0.1 40)', surface: 'oklch(0.99 0 0)', 'on-surface': 'oklch(0.9 0 0)' }, + }); + assert.equal(has(r.findings, 'contrast-pairs'), true); + assert.equal(r.ok, false); +}); + +test('flags non px/em/rem dimension units', () => { + const r = lintDesign({ + colors: { primary: 'oklch(0.5 0.1 40)', surface: 'oklch(1 0 0)', 'on-surface': 'oklch(0.2 0 0)' }, + rounded: { md: '8pt' }, + }); + assert.equal(has(r.findings, 'dimension-units'), true); +}); + +test('warns on illegible explicit tint foreground', () => { + const r = lintDesign({ + colors: { + primary: 'oklch(0.5 0.1 40)', + surface: 'oklch(1 0 0)', + 'on-surface': 'oklch(0.2 0 0)', + success: 'oklch(0.6 0.13 150)', + 'success-foreground': 'oklch(0.95 0 0)', // light-on-light tint => fail + }, + }); + assert.equal(has(r.findings, 'tint-foreground'), true); +}); diff --git a/.agents/skills/constructive-builder/scripts/lib/design/oklch.mjs b/.agents/skills/constructive-builder/scripts/lib/design/oklch.mjs new file mode 100644 index 0000000..58cf411 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/oklch.mjs @@ -0,0 +1,290 @@ +/** + * scripts/lib/design/oklch.mjs — OKLCH <-> sRGB color math, WCAG contrast, and + * the hue-band / lightness / chroma helpers the design compiler leans on. + * + * ZERO-DEP. Node >=18 ESM. Pure functions, no I/O. + * + * Color objects are `{ l, c, h, a }`: + * l OKLCH lightness 0..1 + * c OKLCH chroma >= 0 (typ. 0..~0.37) + * h OKLCH hue degrees 0..360 + * a alpha 0..1 (default 1) + * + * The OKLCH<->sRGB pipeline uses the standard Björn Ottosson Oklab matrices. + * Reference: https://bottosson.github.io/posts/oklab/ (public-domain formulas). + */ + +const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n); +const round = (n, p = 4) => { + const f = 10 ** p; + return Math.round(n * f) / f; +}; + +/* ---------------------------------------------------------------------------- + * sRGB <-> linear + * ------------------------------------------------------------------------- */ +function srgbToLinear(c) { + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} +function linearToSrgb(c) { + return c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055; +} + +/* ---------------------------------------------------------------------------- + * OKLCH <-> linear sRGB + * ------------------------------------------------------------------------- */ + +/** OKLCH ({l,c,h}) -> linear-light sRGB {r,g,b}. */ +function oklchToLinearRgb({ l, c, h }) { + const hr = (h * Math.PI) / 180; + const a = c * Math.cos(hr); + const b = c * Math.sin(hr); + + // Oklab -> LMS' (cube root space) + const l_ = l + 0.3963377774 * a + 0.2158037573 * b; + const m_ = l - 0.1055613458 * a - 0.0638541728 * b; + const s_ = l - 0.0894841775 * a - 1.291485548 * b; + + const L = l_ ** 3; + const M = m_ ** 3; + const S = s_ ** 3; + + // LMS -> linear sRGB + return { + r: +4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S, + g: -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S, + b: -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S, + }; +} + +/** linear-light sRGB {r,g,b} -> OKLCH {l,c,h}. */ +function linearRgbToOklch({ r, g, b }) { + const L = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const M = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const S = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + const l_ = Math.cbrt(L); + const m_ = Math.cbrt(M); + const s_ = Math.cbrt(S); + + const ll = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; + const aa = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; + + const c = Math.sqrt(aa * aa + bb * bb); + let h = (Math.atan2(bb, aa) * 180) / Math.PI; + if (h < 0) h += 360; + return { l: ll, c, h }; +} + +/* ---------------------------------------------------------------------------- + * Public conversions + * ------------------------------------------------------------------------- */ + +/** OKLCH -> gamut-clamped sRGB in 0..1 ({r,g,b}). */ +export function toSrgb({ l, c, h }) { + const lin = oklchToLinearRgb({ l, c, h }); + return { + r: clamp01(linearToSrgb(clamp01(lin.r))), + g: clamp01(linearToSrgb(clamp01(lin.g))), + b: clamp01(linearToSrgb(clamp01(lin.b))), + }; +} + +/** sRGB 0..1 ({r,g,b}) -> OKLCH ({l,c,h}). */ +export function fromSrgb({ r, g, b }) { + return linearRgbToOklch({ + r: srgbToLinear(r), + g: srgbToLinear(g), + b: srgbToLinear(b), + }); +} + +/* ---------------------------------------------------------------------------- + * Parsing + * ------------------------------------------------------------------------- */ + +function parseHex(str) { + let s = str.trim().replace(/^#/, ''); + let a = 1; + if (s.length === 3 || s.length === 4) { + s = s.split('').map((ch) => ch + ch).join(''); + } + if (s.length === 8) { + a = parseInt(s.slice(6, 8), 16) / 255; + s = s.slice(0, 6); + } + if (s.length !== 6 || /[^0-9a-fA-F]/.test(s)) { + throw new Error(`Invalid hex color: "${str}"`); + } + const r = parseInt(s.slice(0, 2), 16) / 255; + const g = parseInt(s.slice(2, 4), 16) / 255; + const b = parseInt(s.slice(4, 6), 16) / 255; + return { ...fromSrgb({ r, g, b }), a }; +} + +function parseRgb(str) { + const m = str.trim().match(/^rgba?\(([^)]+)\)$/i); + if (!m) throw new Error(`Invalid rgb color: "${str}"`); + const parts = m[1].split(/[,/\s]+/).filter(Boolean); + const chan = (v) => (v.includes('%') ? parseFloat(v) / 100 : parseFloat(v) / 255); + const r = chan(parts[0]); + const g = chan(parts[1]); + const b = chan(parts[2]); + let a = 1; + if (parts[3] != null) a = parts[3].includes('%') ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); + return { ...fromSrgb({ r, g, b }), a }; +} + +function parseOklchFn(str) { + const m = str.trim().match(/^oklch\(([^)]+)\)$/i); + if (!m) throw new Error(`Invalid oklch color: "${str}"`); + // Split on whitespace and an optional `/ alpha`. + const [main, alphaPart] = m[1].split('/'); + const parts = main.trim().split(/\s+/).filter(Boolean); + const num = (v, scale = 1) => (v.includes('%') ? (parseFloat(v) / 100) * scale : parseFloat(v)); + const l = num(parts[0], 1); // 0..1 or 0..100% + const c = num(parts[1], 0.4); // chroma; % is uncommon but map to 0.4 ref + const h = parts[2] != null ? parseFloat(parts[2]) : 0; + let a = 1; + if (alphaPart != null) { + const av = alphaPart.trim(); + a = av.includes('%') ? parseFloat(av) / 100 : parseFloat(av); + } + return { l, c, h: ((h % 360) + 360) % 360, a }; +} + +/** Parse a CSS color string into OKLCH `{l,c,h,a}`. Accepts oklch(), hex, rgb(). */ +export function parseColor(str) { + if (typeof str !== 'string') throw new Error(`parseColor expects a string, got ${typeof str}`); + const s = str.trim(); + if (/^oklch\(/i.test(s)) return parseOklchFn(s); + if (s.startsWith('#')) return parseHex(s); + if (/^rgba?\(/i.test(s)) return parseRgb(s); + // Bare 6/3-digit hex without '#'. + if (/^[0-9a-fA-F]{3,8}$/.test(s)) return parseHex(s); + throw new Error(`Unsupported color format: "${str}"`); +} + +/** Format an OKLCH color object as a CSS `oklch(...)` string. */ +export function formatOklch({ l, c, h, a = 1 }) { + const L = round(clamp01(l), 4); + const C = round(Math.max(0, c), 4); + const H = round(((h % 360) + 360) % 360, 2); + const base = `oklch(${L} ${C} ${H})`; + if (a != null && a < 1) return `oklch(${L} ${C} ${H} / ${round(clamp01(a), 3)})`; + return base; +} + +/* ---------------------------------------------------------------------------- + * Luminance + WCAG contrast + * ------------------------------------------------------------------------- */ + +/** WCAG relative luminance from sRGB 0..1 ({r,g,b}). */ +export function relativeLuminance({ r, g, b }) { + const R = srgbToLinear(r); + const G = srgbToLinear(g); + const B = srgbToLinear(b); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + +/** WCAG contrast ratio between two colors (each {l,c,h} OKLCH). 1..21. */ +export function contrastRatio(a, b) { + const la = relativeLuminance(toSrgb(a)); + const lb = relativeLuminance(toSrgb(b)); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +/* ---------------------------------------------------------------------------- + * Lightness / chroma / hue helpers + * ------------------------------------------------------------------------- */ + +/** Return a copy of `color` with lightness set to `l` (clamped 0..1). */ +export function withLightness(color, l) { + return { ...color, l: clamp01(l) }; +} + +/** Return a copy of `color` with lightness shifted by `delta`. */ +export function adjustLightness(color, delta) { + return { ...color, l: clamp01(color.l + delta) }; +} + +/** Return a copy of `color` with chroma set to `c` (clamped >= 0). */ +export function withChroma(color, c) { + return { ...color, c: Math.max(0, c) }; +} + +/** Return a copy of `color` with hue rotated by `deg` (wrapped 0..360). */ +export function rotateHue(color, deg) { + return { ...color, h: ((color.h + deg) % 360 + 360) % 360 }; +} + +/** + * The "AI purple/blue" band test. Generic blue-purple hues with non-trivial + * chroma read as the default-AI look; the taste rules ban them for primary/ + * accent unless explicitly opted in. Band ~ h 255..310 with c > ~0.12. + */ +export function isAiPurpleBand({ h, c }) { + const hue = ((h % 360) + 360) % 360; + return hue >= 255 && hue <= 310 && c > 0.12; +} + +/** + * Nudge `fg`'s lightness toward whichever pole improves contrast against `bg` + * until it reaches `target` (WCAG AA 4.5 by default). Hue/chroma are preserved. + * Returns the adjusted foreground OKLCH. Throws if even pure black/white can't + * reach the target against this background (a genuinely impossible pairing). + */ +export function ensureContrast(fg, bg, target = 4.5) { + if (contrastRatio(fg, bg) >= target) return { ...fg }; + + // Decide direction: a dark background wants a lighter fg, and vice-versa. + const bgLum = relativeLuminance(toSrgb(bg)); + const goLighter = bgLum < 0.5; + + // First, can we even reach target at the extreme (preserving hue/chroma)? + // Try the extreme; if chroma blocks it, progressively desaturate the extreme. + const tryAt = (l, c) => contrastRatio({ ...fg, l, c }, bg); + + const extremeL = goLighter ? 1 : 0; + if (tryAt(extremeL, fg.c) < target) { + // Chroma may be capping luminance at the extreme — try fully desaturated. + if (tryAt(extremeL, 0) < target) { + throw new Error( + `ensureContrast: cannot reach ${target}:1 against background ${formatOklch(bg)} ` + + `even with pure ${goLighter ? 'white' : 'black'} foreground.` + ); + } + // Desaturating the extreme works — binary-search chroma at the extreme L. + let loC = 0; + let hiC = fg.c; + for (let i = 0; i < 30; i++) { + const midC = (loC + hiC) / 2; + if (tryAt(extremeL, midC) >= target) loC = midC; + else hiC = midC; + } + return { ...fg, l: extremeL, c: loC }; + } + + // Binary-search the minimal lightness move (keep as close to original as + // possible while passing) in the chosen direction, full chroma. + let lo = goLighter ? fg.l : 0; + let hi = goLighter ? 1 : fg.l; + // Ensure the bracket actually straddles the threshold. + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + const ok = tryAt(mid, fg.c) >= target; + if (goLighter) { + // larger L => more contrast (light bg already returned). bg dark here. + if (ok) hi = mid; + else lo = mid; + } else { + if (ok) lo = mid; + else hi = mid; + } + } + const finalL = goLighter ? hi : lo; + return { ...fg, l: clamp01(finalL) }; +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/oklch.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/oklch.test.mjs new file mode 100644 index 0000000..e3021f8 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/oklch.test.mjs @@ -0,0 +1,111 @@ +/** node --test scripts/lib/design/oklch.test.mjs */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseColor, + formatOklch, + toSrgb, + fromSrgb, + contrastRatio, + relativeLuminance, + withLightness, + rotateHue, + isAiPurpleBand, + ensureContrast, +} from './oklch.mjs'; + +const near = (a, b, eps = 1e-3) => Math.abs(a - b) <= eps; + +test('hex round-trips through OKLCH back to sRGB', () => { + for (const hex of ['#ffffff', '#000000', '#3b82f6', '#e11d48', '#10b981', '#7c3aed']) { + const oklch = parseColor(hex); + const rgb = toSrgb(oklch); + const back = fromSrgb(rgb); + const rgb2 = toSrgb(back); + assert.ok(near(rgb.r, rgb2.r, 2e-3), `${hex} r`); + assert.ok(near(rgb.g, rgb2.g, 2e-3), `${hex} g`); + assert.ok(near(rgb.b, rgb2.b, 2e-3), `${hex} b`); + } +}); + +test('white and black map to expected OKLCH lightness', () => { + const white = parseColor('#ffffff'); + const black = parseColor('#000000'); + assert.ok(near(white.l, 1.0, 1e-2)); + assert.ok(near(black.l, 0.0, 1e-2)); + assert.ok(white.c < 0.01); + assert.ok(black.c < 0.01); +}); + +test('parseColor accepts oklch() and formatOklch round-trips', () => { + const c = parseColor('oklch(0.55 0.16 250)'); + assert.ok(near(c.l, 0.55)); + assert.ok(near(c.c, 0.16)); + assert.ok(near(c.h, 250)); + const s = formatOklch(c); + const c2 = parseColor(s); + assert.ok(near(c2.l, 0.55, 1e-3)); + assert.ok(near(c2.h, 250, 1e-1)); +}); + +test('parseColor accepts rgb()', () => { + const fromRgb = parseColor('rgb(255, 255, 255)'); + assert.ok(near(fromRgb.l, 1.0, 1e-2)); +}); + +test('contrastRatio matches known WCAG values', () => { + const white = parseColor('#ffffff'); + const black = parseColor('#000000'); + // black-on-white is the canonical 21:1 + assert.ok(near(contrastRatio(white, black), 21, 0.1), `got ${contrastRatio(white, black)}`); + // identical colors => 1:1 + assert.ok(near(contrastRatio(white, white), 1, 1e-3)); + // #767676 on white ~ 4.54:1 (the classic AA boundary gray) + const gray = parseColor('#767676'); + const r = contrastRatio(gray, white); + assert.ok(r > 4.4 && r < 4.7, `#767676/white expected ~4.54, got ${r}`); +}); + +test('relativeLuminance: white=1, black=0', () => { + assert.ok(near(relativeLuminance(toSrgb(parseColor('#ffffff'))), 1, 1e-2)); + assert.ok(near(relativeLuminance(toSrgb(parseColor('#000000'))), 0, 1e-3)); +}); + +test('isAiPurpleBand flags generic blue-purple, clears branded hues', () => { + assert.equal(isAiPurpleBand({ h: 280, c: 0.2 }), true); + assert.equal(isAiPurpleBand({ h: 265, c: 0.18 }), true); + assert.equal(isAiPurpleBand({ h: 280, c: 0.05 }), false); // too desaturated + assert.equal(isAiPurpleBand({ h: 150, c: 0.2 }), false); // green + assert.equal(isAiPurpleBand({ h: 30, c: 0.2 }), false); // orange +}); + +test('rotateHue and withLightness are pure + correct', () => { + const c = { l: 0.5, c: 0.1, h: 100, a: 1 }; + assert.equal(rotateHue(c, 40).h, 140); + assert.equal(rotateHue(c, -150).h, 310); + assert.equal(withLightness(c, 0.8).l, 0.8); + assert.equal(c.h, 100, 'original unmutated'); +}); + +test('ensureContrast makes a failing pair pass', () => { + const bg = parseColor('#ffffff'); + const fg = parseColor('#cccccc'); // ~1.6:1 fail + assert.ok(contrastRatio(fg, bg) < 4.5); + const fixed = ensureContrast(fg, bg, 4.5); + assert.ok(contrastRatio(fixed, bg) >= 4.5 - 1e-3, `got ${contrastRatio(fixed, bg)}`); +}); + +test('ensureContrast on a dark background lightens the foreground', () => { + const bg = parseColor('oklch(0.21 0.006 285)'); + const fg = parseColor('oklch(0.3 0.05 285)'); + const fixed = ensureContrast(fg, bg, 4.5); + assert.ok(contrastRatio(fixed, bg) >= 4.5 - 1e-3); + assert.ok(fixed.l > fg.l, 'moved lighter against a dark bg'); +}); + +test('ensureContrast is a no-op when already passing', () => { + const bg = parseColor('#ffffff'); + const fg = parseColor('#000000'); + const fixed = ensureContrast(fg, bg, 4.5); + assert.equal(fixed.l, fg.l); +}); diff --git a/.agents/skills/constructive-builder/scripts/lib/scaffold-frontend/entity-page.mjs b/.agents/skills/constructive-builder/scripts/lib/scaffold-frontend/entity-page.mjs index 20d5a3d..1725f50 100644 --- a/.agents/skills/constructive-builder/scripts/lib/scaffold-frontend/entity-page.mjs +++ b/.agents/skills/constructive-builder/scripts/lib/scaffold-frontend/entity-page.mjs @@ -40,6 +40,81 @@ import { buildFkSeams } from './relations-fk.mjs'; import { buildRelationManagerSeams } from './relations-m2m.mjs'; import { routeSegments } from './routes-nav.mjs'; +// ════════════════════════════════════════════════════════════════════════════ +// DENSITY SCALE (generic, dial-driven — NO entity/app literals). +// +// The DENSITY dial (brief.design.dials.density, 1–10) picks a SPACING scale that the +// generated pages bake into their Tailwind className strings at EMIT time. Three tiers, +// biased toward the trust-first / minimalist app rows (apps, not landing pages): +// • density 1–3 → 'comfortable' (roomy — more padding, taller rhythm) +// • density 4–6 → 'cozy' (the DEFAULT, == the historical template literals) +// • density 7–10 → 'compact' (tight — dense rows, smaller gaps) +// +// The COZY tier reproduces the values the template used before this wave EXACTLY, so a +// brief with NO design block (density absent) emits byte-identical pages. Every token is +// a whole Tailwind class string (no arbitrary values), so the output stays within the +// boilerplate's compiled utility set. This is purely emit-time substitution — there is no +// `data-*` attribute and no globals.css rule, so it never couples to another agent's CSS. +// ════════════════════════════════════════════════════════════════════════════ + +const DENSITY_SCALES = { + comfortable: { + D_PAGE: 'px-6 py-16', + D_HEAD_MB: 'mb-8', + D_SECTION_MB: 'mb-10', + D_FORM_GAP: 'gap-4', + D_ROW_GAP: 'gap-4', + D_ROW_PAD: 'px-5 py-4', + D_EMPTY_PAD: 'px-6 py-16', + }, + cozy: { + // DEFAULT — these are the pre-wave literals, verbatim, so a design-less build is + // byte-identical. + D_PAGE: 'px-6 py-12', + D_HEAD_MB: 'mb-6', + D_SECTION_MB: 'mb-8', + D_FORM_GAP: 'gap-3', + D_ROW_GAP: 'gap-3', + D_ROW_PAD: 'px-4 py-3', + D_EMPTY_PAD: 'px-6 py-12', + }, + compact: { + D_PAGE: 'px-5 py-8', + D_HEAD_MB: 'mb-4', + D_SECTION_MB: 'mb-6', + D_FORM_GAP: 'gap-2', + D_ROW_GAP: 'gap-2', + D_ROW_PAD: 'px-3 py-2', + D_EMPTY_PAD: 'px-5 py-8', + }, +}; + +/** + * Resolve the DENSITY dial (1–10, or a tier name) to a spacing-token bundle. Defaults to + * 'cozy' (the historical look) when the dial is absent or unrecognized, so a brief with no + * `design` block is unchanged. Accepts the numeric dial, a tier name ('comfortable'/'cozy'/ + * 'compact'), or undefined. Clamps out-of-range numbers. GENERIC — no entity input. + */ +export function resolveDensity(density) { + if (typeof density === 'string') { + const tier = density.toLowerCase(); + if (DENSITY_SCALES[tier]) return { tier, tokens: DENSITY_SCALES[tier] }; + return { tier: 'cozy', tokens: DENSITY_SCALES.cozy }; + } + const n = Number(density); + if (!Number.isFinite(n)) return { tier: 'cozy', tokens: DENSITY_SCALES.cozy }; + if (n <= 3) return { tier: 'comfortable', tokens: DENSITY_SCALES.comfortable }; + if (n >= 7) return { tier: 'compact', tokens: DENSITY_SCALES.compact }; + return { tier: 'cozy', tokens: DENSITY_SCALES.cozy }; +} + +/** The density token substitution pairs (__D_*__ → class string) for the given resolved + * density. Shared by emitEntityPage + emitStubPage so both pages share one spacing scale. */ +function densitySubs(resolved) { + const t = resolved?.tokens || DENSITY_SCALES.cozy; + return Object.entries(t).map(([k, v]) => [`__${k}__`, v]); +} + /** * (b) Emit one entity page from the entity-page template, substituting the * per-entity identifiers. Idempotent: skips if the page already exists. @@ -56,8 +131,13 @@ import { routeSegments } from './routes-nav.mjs'; * UI) and a relation-manager component is stamped under components/crud/relations/. The * EMPTY-ARRAY DEFAULT is equally load-bearing: both N:M seams collapse to '' when the table * owns no junction, so a non-N:M table (every canary) stays byte-identical. + * + * `density` (default undefined → 'cozy') is the resolved DENSITY scale (resolveDensity()), + * threaded from scaffold-frontend.mjs which reads brief.design.dials.density. It only changes + * spacing class strings; the DEFAULT ('cozy') reproduces the pre-wave literals, so a build with + * no design block is byte-identical. */ -export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = []) { +export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = [], density) { const entity = route.entity || singularFromTable(table?.name) || kebab(route.path); // SG-A — the SDK hooks (useQuery / useCreateMutation), the data accessor // (data.) and the DynamicFormCard `_meta` tableName ALL derive from the TABLE name @@ -70,6 +150,12 @@ export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = [] const sdkIds = entityIdentifiers(tableEntity); // SDK/_meta-facing identifiers (from the table) const ids = entityIdentifiers(entity); // UI/testid-facing identifiers (from the route entity) const label = route.label || titleCase(entity); + // Lower-cased label for the prose copy (subtext / empty / error). Derived from the label, + // so it tracks an explicit `route.label` ("Field Guides" → "field guides") AND a derived one. + const labelLower = label.toLowerCase(); + // Resolved DENSITY scale → the spacing class strings the page bakes in. Defaults to 'cozy' + // (the historical literals) when no design dial is present, so the page is byte-identical. + const dSubs = densitySubs(resolveDensity(density)); // SG-A for COLUMNS — remap every brief-derived column name to the name codegen ACTUALLY // emitted for THIS table's SDK row interface (sdkIds.EntityPascal, the same `_meta` type the // page already names). When the SDK isn't present (dry-run / canary) this is the identity, so @@ -121,6 +207,9 @@ export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = [] // table) this equals the old camel-plural for single-word entities → byte-identical canary. ['__entity__', ids.entityKebab], ['__ENTITIES_EMPTY_TESTID__', `${pluralizeWords(entity).join('-')}-empty`], + // The lower-cased label (for the subtext / empty / error prose) goes BEFORE __ENTITY_LABEL__ + // so the longer token matches first (the split/join convention) — purely cosmetic copy. + ['__ENTITY_LABEL_LOWER__', labelLower], ['__ENTITY_LABEL__', label], ['__TITLE_FIELD__', titleField], ['__SELECTION_FIELDS__', selectionFields], @@ -156,6 +245,9 @@ export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = [] // import); __RELATION_MANAGER_JSX__ mounts the manager sections after the entity list. ['__RELATION_MANAGER_IMPORT__', relSeams.relationManagerImport], ['__RELATION_MANAGER_JSX__', relSeams.relationManagerJsx], + // DENSITY spacing tokens (__D_*__ → Tailwind class strings) — resolved from the design + // dial; 'cozy' default == the pre-wave literals (byte-identical when no design block). + ...dSubs, ]; for (const [tok, val] of subs) { body = body.split(tok).join(val); @@ -168,14 +260,19 @@ export function emitEntityPage(srcDir, route, table, ctx, fks = [], m2mRels = [] /** * (d) Emit a stub page for a non-CRUD route (dashboard|detail|custom) with a * clearly-marked seam. Idempotent. + * + * `density` (default undefined → 'cozy') is the resolved DENSITY scale, so the stub's page + * spacing matches the CRUD pages. The 'cozy' default reproduces the pre-wave padding, and the + * heading hierarchy (weight+muted-subtext) matches the entity pages so a mixed app reads as one. */ -export function emitStubPage(srcDir, route, ctx) { +export function emitStubPage(srcDir, route, ctx, density) { const label = route.label || titleCase(kebab(route.path || 'page')); const dest = path.join(srcDir, 'app', ...routeSegments(route.path), 'page.tsx'); if (fs.existsSync(dest)) { skip(dest, ctx); return; } + const { tokens } = resolveDensity(density); const componentName = pascal(label || 'Page') + 'Page'; const kind = route.kind || 'custom'; const body = `'use client'; @@ -194,12 +291,14 @@ export function emitStubPage(srcDir, route, ctx) { */ export default function ${componentName}() { return ( -

-

${label}

-

- {/* TODO: custom UI — build with @constructive-io/ui; see constructive-frontend */} - This ${kind} page is a scaffold stub. Replace it with your UI. -

+
+
+

${label}

+

+ {/* TODO: custom UI — build with @constructive-io/ui; see constructive-frontend */} + This ${kind} page is a scaffold stub. Replace it with your UI. +

+
); } diff --git a/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh b/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh index c54e087..af8c315 100644 --- a/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh +++ b/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh @@ -351,6 +351,67 @@ check_harness_drift() { fi } +# Additive DESIGN subsystem gate. Two independent checks, each self-disabling: +# (A) ROT-CANARY — run the design compiler's own unit + fixture suite +# (scripts/lib/design/*.test.mjs) so the contrast-repair / no-throw / override-surface +# contracts cannot rot unnoticed. This is the subsystem's rot-canary: it ships in the +# skill, so it runs whenever this gate fires (no app needed). Self-disables only if the +# suite is absent or `node` cannot run it. +# (B) EMITTED-DESIGN LINT — if the app emitted a design.md (the durable, lint-gated theme +# record), run check-design.mjs on it. check-design now folds COMPILE-ABILITY into its +# verdict (a design wire-design cannot theme → ERROR), so a lint-clean-but-untheme-able +# design is caught HERE instead of silently degrading to the default look at wire time. +# No design.md present → (B) is a clean no-op (absent-design is the default-look path). +# Wired into Phase 1 (the canary, app-independent) and Phase 2.4 (the emitted-design lint). +check_design() { + command -v node >/dev/null 2>&1 || { warn "Design gate: 'node' not on PATH — skipped the design subsystem checks (not failing)"; return 0; } + + # (A) the design subsystem rot-canary — the compiler's own test suite. + local design_lib="$REPO_ROOT/scripts/lib/design" + if [ -d "$design_lib" ] && ls "$design_lib"/*.test.mjs >/dev/null 2>&1; then + local out status=0 + out="/tmp/check-design-tests.$$" + node --test "$design_lib"/*.test.mjs >"$out" 2>&1 || status="$?" + if [ "$status" -eq 0 ]; then + pass "Design: compiler test suite green (contrast-repair / no-throw / override-surface contracts hold)" + rm -f "$out" + else + tail -n 40 "$out" 2>/dev/null | sed 's/^/ /' || true + rm -f "$out" + fail "Design: the design compiler test suite FAILED (scripts/lib/design/*.test.mjs)" "A design-compiler contract regressed (most often a critical text pair under AA on its RENDERED surface, or compileDesign throwing instead of using the graceful best-pole fallback). See the failing assertion above; fix scripts/lib/design/compile.mjs (or the fixture) until 'node --test scripts/lib/design/*.test.mjs' is green." + fi + fi + + # (B) lint+compile-check any design.md the app emitted (no app/no design.md → no-op). + local checker="$REPO_ROOT/scripts/check-design.mjs" + [ -f "$checker" ] || return 0 + local app_root design_md + app_root="$(workspace_path "$(app_rel)")" + [ -d "$app_root" ] || return 0 + design_md="" + for cand in "$app_root/design.md" "$app_root/packages/app/design.md" "$app_root/src/design.md"; do + if [ -f "$cand" ]; then design_md="$cand"; break; fi + done + [ -n "$design_md" ] || return 0 + + echo " INFO: Design gate — linting emitted design.md at $design_md" + local dout dstatus=0 + dout="/tmp/check-design-emitted.$$" + node "$checker" --design "$design_md" >"$dout" 2>&1 || dstatus="$?" + if [ "$dstatus" -eq 0 ]; then + pass "Design: emitted design.md lints + compiles into a contrast-passing theme (check-design.mjs ok)" + rm -f "$dout" + elif [ "$dstatus" -eq 2 ]; then + cat "$dout" 2>/dev/null || true + rm -f "$dout" + warn "Design: check-design.mjs could not run on $design_md (exit 2) — skipped (not failing)" + else + cat "$dout" 2>/dev/null || true + rm -f "$dout" + fail "Design: emitted design.md failed check-design.mjs (exit $dstatus)" "The design has an ERROR finding (missing primary, a hard contrast floor breach, or it cannot be compiled into a theme — wire-design would then silently degrade to the default look). Fix the flagged role(s) in design.md and re-run 'node scripts/check-design.mjs --design ' until ok." + fi +} + # Additive self-lint: every fail() CALL-SITE in this script must pass a 2nd arg = a self-correcting # FIX hint, so an agent that trips a gate always gets a concrete next action (cite the gotcha CODE + # the one-liner / SKILL anchor). This keeps the hint-coverage ratio from regressing as call-sites are diff --git a/.agents/skills/constructive-builder/scripts/scaffold-app.mjs b/.agents/skills/constructive-builder/scripts/scaffold-app.mjs index 34e00f6..a11100c 100644 --- a/.agents/skills/constructive-builder/scripts/scaffold-app.mjs +++ b/.agents/skills/constructive-builder/scripts/scaffold-app.mjs @@ -16,6 +16,14 @@ * │ NOT done here. The build wires env/providers (scripts/wire-app.mjs) and │ * │ runs codegen so the typed @sdk/app hooks (useTodosQuery, …) exist. │ * └────────────────────────────────────────────────────────────────────────┘ + * ┌─ DESIGN (Theme) ─ wire-design.mjs ───────────────────────────────────────┐ + * │ Compiles the resolved design (brief `design:` block and/or an emitted │ + * │ design.md) into the app's globals.css override block (+ optional font / │ + * │ defaultTheme / branding). Runs AFTER wire-app/Blocks @import and │ + * │ before/with the frontend pass so the override wins by source order. It is │ + * │ SELF-GATING: absent design OR `design: { preset: constructive }` ⇒ no-op │ + * │ (today's look preserved), so sequencing it unconditionally is safe. │ + * └────────────────────────────────────────────────────────────────────────┘ * ┌─ PHASE 4 (Frontend) ─ scaffold-frontend.mjs ─────────────────────────────┐ * │ brief → per-entity CRUD pages + CRUD infra + routes/nav. REQUIRES the │ * │ Phase-3 SDK hooks. Auth/account/org UI is the Blocks on-ramp (shadcn add │ @@ -42,6 +50,7 @@ * node scripts/scaffold-app.mjs build/app-brief.yaml ./my-app # both (re-run) * node scripts/scaffold-app.mjs build/app-brief.yaml ./my-app --phase provision # Phase 2 * node scripts/scaffold-app.mjs build/app-brief.yaml ./my-app --phase frontend # Phase 4 + * node scripts/scaffold-app.mjs build/app-brief.yaml ./my-app --phase design # theme only * node scripts/scaffold-app.mjs build/app-brief.yaml ./my-app --dry-run */ @@ -53,7 +62,7 @@ import { spawnSync } from 'child_process'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PHASES = new Set(['provision', 'frontend', 'all']); +const PHASES = new Set(['provision', 'design', 'frontend', 'all']); function parseArgs(argv) { const out = { phase: 'all', dryRun: false, positionals: [] }; @@ -99,11 +108,11 @@ function main() { const [briefPath, appDir] = positionals; if (!briefPath || !appDir) { - console.error('Usage: node scripts/scaffold-app.mjs [--phase provision|frontend|all] [--dry-run]'); + console.error('Usage: node scripts/scaffold-app.mjs [--phase provision|design|frontend|all] [--dry-run]'); process.exit(2); } if (!PHASES.has(phase)) { - console.error(`scaffold-app: unknown --phase "${phase}". Use provision | frontend | all.`); + console.error(`scaffold-app: unknown --phase "${phase}". Use provision | design | frontend | all.`); process.exit(2); } if (!fs.existsSync(briefPath)) { @@ -112,6 +121,7 @@ function main() { } const doProvision = phase === 'provision' || phase === 'all'; + const doDesign = phase === 'design' || phase === 'all'; const doFrontend = phase === 'frontend' || phase === 'all'; if (doProvision) { @@ -119,6 +129,17 @@ function main() { runScaffolder('scaffold-provision.mjs', briefPath, appDir, dryRun); } + if (doDesign) { + // THEME pass — runs AFTER provision (and, on the cold path, after wire-app + the + // Blocks @import) and BEFORE the frontend pass, so the compiled override block wins + // by source order. wire-design.mjs is SELF-GATING: an absent design block OR + // `design: { preset: constructive }` is a clean NO-OP, so calling it unconditionally + // here preserves today's look for any brief that does not opt into a custom theme. + // It accepts the same ` ` positional shape runScaffolder spawns. + console.log('── scaffold-app: DESIGN (theme) ────────────────────────────────'); + runScaffolder('wire-design.mjs', briefPath, appDir, dryRun); + } + if (doFrontend) { // Guard: the frontend pages import the Phase-3 codegen hooks. If they are not // present yet, warn (don't hard-fail) — the agent may be intentionally diff --git a/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs b/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs index 6e6bf52..d4fd3eb 100644 --- a/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs +++ b/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs @@ -224,6 +224,15 @@ function main() { const srcDir = resolveAppSrc(appDir); const ctx = { dryRun, written: [], skipped: [], warnings: [] }; + // DESIGN DENSITY (generic, dial-driven). The DENSITY dial (brief.design.dials.density, 1–10) + // sets the spacing/padding/rhythm scale the generated CRUD + stub pages bake into their + // Tailwind classes at emit time. It is read HERE (loadBrief already returned the whole brief, + // including any `design` block — zero new parsing) and threaded into the page emitters. Absent + // ⇒ undefined ⇒ the emitters default to the 'cozy' tier, which reproduces the historical + // spacing literals, so a brief with no design block emits byte-identical pages. No entity/app + // literal is involved — density is a single number that maps to a generic scale. + const density = brief.design?.dials?.density; + const routes = brief.ui?.routes ?? []; const crudRoutes = routes.filter((r) => (r.kind || 'crud') === 'crud'); @@ -248,11 +257,11 @@ function main() { // junction. [] for a non-N:M table → the page emits no manager (byte-identical canary). // srcDir + ctx so the linked table's label column resolves to its codegen-actual name. const m2mRels = manyToManyRelations(brief, table, srcDir, ctx); - const { label } = emitEntityPage(srcDir, route, table, ctx, fks, m2mRels); + const { label } = emitEntityPage(srcDir, route, table, ctx, fks, m2mRels, density); appendRoute(srcDir, route, ctx, { context: 'app', access: 'protected' }); appendNavItem(srcDir, route, label, ctx); } else { - emitStubPage(srcDir, route, ctx); + emitStubPage(srcDir, route, ctx, density); // A primary dashboard at '/' is the root — don't add a route/nav (the root // already exists). Other non-CRUD surfaces get a protected route entry. if (route.path && route.path !== '/') { diff --git a/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx b/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx index 9e4c0f3..6d4e795 100644 --- a/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx +++ b/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx @@ -21,6 +21,30 @@ * `entity=todo` makes the canary's `todo-*` testids + single-submit create fall out * with zero special-casing. * + * THE FOUR LIST STATES (taste-skill's highest-value app rule — emitted for EVERY entity): + * • LOADING — a skeleton that MIRRORS the real list layout (a bordered surface with + * divided rows, each a label-line skeleton + two affordance-sized blocks), so the + * first paint has the same shape the data will fill (no layout jump). Each skeleton + * carries data-slot="skeleton" so the boilerplate's prefers-reduced-motion rule + * stills its pulse. Wrapped in data-testid="__entity__-loading". + * • ERROR — a quiet, non-card panel (a left-accent divider, weight+color hierarchy) + * with the query message + a Retry that re-runs the query. data-testid="__entity__-error". + * NOTE the testid does NOT end in "-empty": the live-QA driver detects the empty + * state via [data-testid$="-empty"], so the error state is kept distinct. + * • EMPTY — a calm dashed panel inviting the first create (data-testid="__ENTITIES_EMPTY_TESTID__", + * the kebab PLURAL + "-empty"). + * • DATA — the rows, in ONE bordered surface with dividers between rows (cards are + * reserved for the add surface, where elevation earns its keep; a flat list reads + * better with dividers than N stacked cards). Faded in via data-slot="content-fade-in" + * (also reduced-motion-honored upstream). + * + * DENSITY (generic, dial-driven). Spacing/padding/rhythm are SUBSTITUTED at scaffold + * time from brief.design.dials.density (1–10) via the generator's density scale — the + * __D_*__ tokens below resolve to concrete Tailwind classes. When the brief carries no + * design block they resolve to the COZY default (the historical values), so a design-less + * build is byte-identical. No data-attribute / no globals.css coupling — it is baked into + * the emitted className strings, so it never depends on another agent's CSS. + * * Placeholders the generator substitutes (derived from the brief table + its policy): * __Entities__ ← PascalCase PLURAL → list hook `use__Entities__Query` * (e.g. Todos / Contacts) @@ -32,7 +56,7 @@ * table type) + card titles (e.g. Todo / Contact) * __entity__ ← lower/kebab SINGULAR → the data-testid prefix * (e.g. todo → todo-title-input / todo-create-submit / todo-row / - * todo-edit / todo-delete) + * todo-edit / todo-delete / todo-loading / todo-error) * __ENTITY_LABEL__ ← human-readable heading (e.g. "Todos", "Contacts") * __TITLE_FIELD__ ← the field shown as each row's label + bound to the quick-add * input (generator picks the first required text field; default @@ -90,6 +114,7 @@ import { DynamicFormCard } from '@/components/crud/dynamic-form-card'; __SCOPING_IMPORT__ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent, @@ -103,7 +128,8 @@ import { * * Emits the APP-controlled testids the live-QA driver asserts: * authed-shell · __entity__-title-input · __entity__-create-submit · __entity__-row - * __entity__-edit · __entity__-delete + * __entity__-edit · __entity__-delete (plus __entity__-loading / __entity__-error + * for the loading + error states, kept distinct from the "-empty" empty-state id). */ export default function __Entities__Page() { const stack = useCardStack(); @@ -112,7 +138,7 @@ export default function __Entities__Page() { const [quickTitle, setQuickTitle] = useState('');__PARENT_FK_HOOK__ - const { data, isLoading, refetch } = use__Entities__Query({ + const { data, isLoading, isError, error, refetch } = use__Entities__Query({ selection: { fields: { __SELECTION_FIELDS__ @@ -197,15 +223,20 @@ export default function __Entities__Page() { } return ( -
-

__ENTITY_LABEL__

+
+
+

__ENTITY_LABEL__

+

+ Create, edit, and manage your __ENTITY_LABEL_LOWER__. +

+
- + - Add __ENTITY_LABEL__ + Add __ENTITY_LABEL__ -
+ {isLoading ? ( -

Loading…

+ // LOADING — skeleton that mirrors the real list shape (a bordered surface with + // divided rows), so the first paint has no layout jump. data-slot="skeleton" lets + // the boilerplate's prefers-reduced-motion rule still the pulse. +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ) : isError ? ( + // ERROR — a quiet, non-card panel (left-accent divider; weight+color hierarchy, not a + // shouty box) with the query message + Retry. The testid is __entity__-error (NOT a + // "-empty" suffix, which the live-QA driver reserves for the empty state). +
+

Couldn’t load __ENTITY_LABEL_LOWER__.

+

+ {error instanceof Error ? error.message : 'Something went wrong. Please try again.'} +

+ +
) : rows.length === 0 ? ( -

- Nothing yet — add your first one above. -

+ // EMPTY — a calm dashed panel inviting the first create. data-slot="content-fade-in" + // gives a subtle, reduced-motion-honored fade. +
+

No __ENTITY_LABEL_LOWER__ yet

+

+ Add your first one with the form above. +

+
) : ( -
    + // DATA — one bordered surface, rows separated by dividers (cards are reserved for the + // add surface above, where elevation earns its keep). +
      {rows.map((row) => (
    • - {row.__TITLE_FIELD__ ?? row.id} + {row.__TITLE_FIELD__ ?? row.id}