diff --git a/.agents/skills/constructive-builder/SKILL.md b/.agents/skills/constructive-builder/SKILL.md index 2b08f1b..a4651b1 100644 --- a/.agents/skills/constructive-builder/SKILL.md +++ b/.agents/skills/constructive-builder/SKILL.md @@ -40,9 +40,10 @@ 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. +- **S7 — Scaffold the domain SKELETON** (always). `node scripts/scaffold-frontend.mjs ` stamps the runtime-generic CRUD Stack + `_meta` meta-forms and emits a page per `ui.routes[].kind:crud` — the **working functional skeleton** (the data wiring, the `-*` testids, the four states, row-scoping, RLS scoping, Blocks mounts), with a neutral, DENSITY-keyed default look. This is the SKELETON to AUTHOR FROM, **not** the final UI. → `references/phase-4-blocks.md`, plus the `constructive-frontend` skill. +- **S7.5 — AUTHOR the presentation from the `design.md`** (always — this is where the app gets its design). The `design.md` is the full design spec; **you, the agent, author the whole frontend from it** — there is no compile/theme step, you write the CSS yourself. Customize/replace the stock shadcn components, set the type (fonts/scale/weights), compose the layout (shell + each entity page's `return(...)`: list → data-table / gallery / split-pane / editorial / board), establish real hierarchy + spacing rhythm + intentional ornament, add subtle (reduced-motion) motion. Blocks compose as **ingredients** (restyle them via the token names). The ONLY hard rails are **Rail 1** (the functional contract — every `-*` testid, row-scoping, the hooks/selection/refetch/Stack-pushes, the RLS scoping const(s), the route/block/flow surfaces) and **Rail 2** (the shadcn token names + Tailwind-v4 wiring in the built `globals.css`, so Blocks render — checked by `node scripts/check-design.mjs --app `). Selectors are testid/role-only and the token contract is name-only, so the gates can't see your restyle. Record the chosen shell/composition/density in the `design.md` so re-runs stay consistent. → **[`references/design-guide.md`](./references/design-guide.md)** (the authoring guide + both preserve-contracts + the taste self-check + the example palettes). - **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`. +- **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` (which include the functional design gate `node scripts/check-design.mjs --app ` — the Rail 2 Blocks-render token contract), and the independent evaluator. → "Verification" below + `references/speedrun.md`. > **Checkpoint discipline.** After every green `verify-phase.sh` gate: update the per-app run-state (start from `fixtures/run-state.template.json`; the verifier **reads** it, never writes) and `git commit`/tag the green state so you can roll back to the last passing phase. With `APP_ID=` exported (app-id = the brief's `naming.db_name`) the gates read `build//run-state.json` + `build//app-brief.yaml`; unset = the legacy singleton `build/run-state.json`. Roll back with `git reset --hard green-phase-`, then re-run that phase. See `references/troubleshooting.md`. @@ -59,7 +60,7 @@ The happy path: a warm hub → a verified basic CRUD app + one auth flow, zero b | **1 — Backend Up** | Docker (`postgres-plus:18`) + `pgpm deploy` + GraphQL server. **Warm hub → skip.** | S0 | `verify-phase.sh 1` | [`phase-1-backend.md`](./references/phase-1-backend.md) | | **2 — Data Model Provisioned** | Workspace + provision package + blueprint (tables, RLS, grants). | S1–S2 | `verify-phase.sh 2.1`, `2.3` | [`phase-2-data-model.md`](./references/phase-2-data-model.md) | | **3 — Frontend + SDK** | Scaffold Next.js app + `pnpm codegen` (admin + auth + app SDK). | S3–S4, S8 | `verify-phase.sh 2.6` | [`phase-3-frontend-sdk.md`](./references/phase-3-frontend-sdk.md) | -| **4 — UI / Blocks** | Blocks on-ramp (conditional) + domain CRUD body. | S5–S7 | `verify-phase.sh 3` | [`phase-4-blocks.md`](./references/phase-4-blocks.md) | +| **4 — UI / Blocks** | Blocks on-ramp (conditional) + domain skeleton, then AUTHOR the presentation from the `design.md`. | S5–S7.5 | `verify-phase.sh 3` | [`phase-4-blocks.md`](./references/phase-4-blocks.md) | Side-quests — search, email, standalone SDK, seed data — are **not** mainline; add one only when the brief calls for it (each phase doc points to the matching extension). Authoring depth for the data/security surfaces is in the cross-referenced skills below. @@ -99,8 +100,9 @@ Each table declares a **policy intent**, which maps to one of three tiers: | [phase-1-backend.md](./references/phase-1-backend.md) | Cold-infra: Docker (`postgres-plus:18`), `pgpm deploy`, GraphQL server, gate `1` | Standing up a backend from cold (skip on a warm hub) | | [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 | +| [phase-4-blocks.md](./references/phase-4-blocks.md) | Blocks on-ramp branch + the domain CRUD skeleton; gate `3` | Wiring auth/account/org UI or scaffolding entity CRUD | +| [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-guide.md](./references/design-guide.md) | The design guide: author the frontend from a `design.md` — intent + signature, craft foundations, the two rails (functional + shadcn-token), light dials, structure archetypes, the taste self-check, and the example palettes | Shaping/authoring an app's look-and-feel (S7.5) | | [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/references/brief-grammar.md b/.agents/skills/constructive-builder/references/brief-grammar.md index 7e21cf0..e708272 100644 --- a/.agents/skills/constructive-builder/references/brief-grammar.md +++ b/.agents/skills/constructive-builder/references/brief-grammar.md @@ -23,6 +23,10 @@ The brief is **intent-level**: you pick WHAT (a `modules.preset`, a set of `flow > scalars (bare / "double" / 'single' / int / float / true|false|null), full-line and trailing comments, > and bare tokens with internal `+` / `/` / `:` (so `public-read+owner-write` parses). If a brief needs a > construct beyond this, add to the grammar in that one module — don't reach for a dep. +> +> **Not supported: folded/literal block scalars** (`>-`, `>`, `|`, `|-`). Write every multi-word string +> value (e.g. `app.description`, `design.brief`) as a **single-line quoted string** — never spread it +> across following indented lines with a `>`/`|` indicator (it would be mis-parsed as a nested block). --- @@ -41,6 +45,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. **ABSENT ⇒ auto-propose a RICH `design.md`** + AUTHOR the frontend from it; `{ preset: }` ⇒ start from that named exemplar (`references/examples/.md`) and adapt. The design.md is the AUTHORED spec — there is no compile step; the only machine check is the functional Rail-2 gate (`node scripts/check-design.mjs --app `). See [design (optional)](#design-optional) + [design-guide.md](./design-guide.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 +558,54 @@ 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 constrains the generated app's **look and +feel**. It is purely additive: a brief **without** a `design:` block is fully valid. There is **no +compile step** — the `design.md` is the AUTHORED design spec and **you (the agent) hand-author the whole +frontend from it**, guided by **[design-guide.md](./design-guide.md)** + the worked exemplars in +**[references/examples/](./examples/)**. Blocks (auth / account / org) compose into that frontend. The +only machine check is the FUNCTIONAL Rail-2 gate — `node scripts/check-design.mjs --app ` — which +asserts the built `globals.css` still defines the shadcn token NAMES + Tailwind-v4 wiring so Blocks +render. It never judges taste. + +> **Default = auto-propose a RICH design.md, then author from it.** When `design:` is **absent**, the +> build **proposes a full, opinionated `design.md`** (a generated app should not ship a stock theme nor a +> token-swapped generic template) and then **authors the whole frontend from it** — the shell, every +> page's composition, the type, spacing, surfaces, ornament, copy, and `globals.css`. **The quality of +> the `design.md` is the quality ceiling of the app.** + +> **`{ preset: }` = start from a named exemplar and adapt.** Each alias names a worked exemplar +> under `references/examples/.md`; the build opens it as the starting point and adapts it for this +> app. Use this block only to *constrain* the auto-proposal at intent level — you may also pin a few +> palette **roles** (semantic role names, NOT shadcn var names) or a one-line `brief:` sentence; the +> agent authors everything else richly. None of it is required to get a theme. + +### Shape (every field optional) + +```yaml +design: + brief: "warm, calm, high-end print feel" # one-line style sentence that steers the auto-proposal + preset: # start from references/examples/.md and adapt + density: compact # comfortable | cozy | compact — seeds the scaffold skeleton's spacing + colors: # a FEW role-level pins (semantic ROLES, never shadcn var names) + primary: "oklch(0.55 0.11 162)" # the one brand/action color + accent: "oklch(0.70 0.12 250)" # at most one accent + neutral: "oklch(0.55 0.01 250)" # one gray temperature + font: { sans: , mono: } # type-family hints the authored frontend honors + radius: "0.5rem" # radius scalar hint (px / em / rem) +``` + +> **`density` seeds the skeleton.** The `density` dial is the one design key the scaffold skeleton reads — +> it sets the generated entity pages' starting spacing tier (`comfortable | cozy | compact`, default +> `cozy`). Everything else here is intent the AUTHORED `design.md` + frontend honor; pin only what you +> want to constrain and author the rest. The full authoring playbook — intent + signature, palette, +> type, spacing, surfaces, ornament, copy, and the two hard rails — is in +> **[design-guide.md](./design-guide.md)**. + +> **All design strings must be single-line quoted strings.** The zero-dep brief YAML reader does **not** +> support folded/literal block scalars (`>-`, `>`, `|`, `|-`). Write `brief:` (and every other string +> value) on one line — never spread across following indented lines with a `>`/`|` indicator. diff --git a/.agents/skills/constructive-builder/references/design-guide.md b/.agents/skills/constructive-builder/references/design-guide.md new file mode 100644 index 0000000..31076e2 --- /dev/null +++ b/.agents/skills/constructive-builder/references/design-guide.md @@ -0,0 +1,232 @@ +# Design Guide — AUTHOR the frontend from the design.md + +You are the designer. The `design.md` is the **full design spec**; you **hand-author the entire +frontend from it** — the shell, every page's composition, the type, the spacing, the surfaces, the +ornament, the copy, and the `globals.css`. **There is no compiler.** Nothing styles the app for you: +you write the CSS yourself and the tokens you put there are the tokens the app ships. Blocks (the +auth / account / org ingredients) compose *into* that frontend — treat them as ingredients you +restyle through the token names, not as finished UI. + +This guide is the single source for *how* to author with craft. It is **guidelines, not templates** — +roles and archetypes only, never app/entity literals. Two hard rails (below) are the entire envelope; +inside them, every visual choice is yours. + +--- + +## 1. Intent first — and a named signature + +Before you author a surface, you must be able to say, in the design.md's own words: + +- **Who** the user is, **what** they must accomplish (the verb), and **how it should feel** — a + *specific* quality ("calm like a reading room", "dense like a control desk"), never "clean and + modern". +- The **one signature element** this product is remembered by — a thing that could only exist for + *this* app (a distinctive row, a particular masthead, a domain-shaped empty state). Spend your + boldness here; keep everything around it quiet. + +If the design.md pins these, follow them exactly. If it leaves an axis free, spend that freedom on a +choice that fits *this* product's world. **If you can't name who / what / feel / signature, ask — +do not default.** A default unasked is the moment the app goes generic. + +--- + +## 2. The two hard rails (everything else is your call) + +Only two things are enforced. Inside them, style / type / layout / composition / ornament / custom +CSS are entirely yours. + +### RAIL 1 — the FUNCTIONAL contract (the app must still work + pass testid-only QA) + +QA drives the app by **testid and role only** — never by text, CSS, class, or DOM shape — so any +restyle or restructure you do is *invisible* to it, **as long as every contract element below stays +present and interactable**. You may **move** any of these; you may not remove, rename, or hide one. + +- Every **`-*` testid** (derived kebab-singular from the route entity): the `authed-shell` + sentinel; `*-title-input`, `*-create-submit`, `*-details`, `*-row` (the row container holds the + row's **title text**), `*-edit`, `*-delete`, `*-loading`, `*-error` (`role="alert"`, suffix is + **not** `-empty`), `*-retry`; and `-empty` (the kebab **plural** + `-empty`). +- The **selector conventions**: empty via `[data-testid$="-empty"]`, FK picker via + `select[data-testid$="-select"]`, social buttons via `[data-testid^="social-btn-"]`. The + `DynamicFormCard` fixed testids (`record-create` / `record-delete` / `record-delete-confirm`). +- **Row-scoping + interactability**: each row's edit/delete live *inside* that row; every driven + control is genuinely visible + sized when mounted (no `display:none` / `opacity:0` / 0×0, no hiding + behind an un-opened tab or drawer). +- The **SDK wiring**: the typed list + create **hooks**, a non-empty **`selection.fields`**, the + create's `onSuccess` → **`refetch`**, the `data.__entities__.nodes` accessor, and the three + **`useCardStack` pushes** (edit / detailed-create / delete). +- The **RLS scoping const(s)** the policy requires — the const + its import + the spread key in BOTH + the quick-add `mutate` AND the detailed-create defaults (one per policy tier; drop it and the create + 200s with 0 rows or NOT-NULL-rejects). +- The **flows**: each flow's route **path** + mounted **block** + the `flow-surfaces.json` manifest + + the **shell sentinels** persist (account is one aggregated page; org admin under `/org/[orgId]/…`; + auth pages render outside the shell). +- The **provider order** + the **two-store auth bridge** in the layout (do not re-order the + providers, even while you re-order the visual shell). +- The **static gates** stay green. + +Selectors being testid/role-only is *why* your restyle/restructure is safe. Author the presentation; +never touch a testid, a scoping const, a hook/selection/refetch/push, or the provider order as part +of a *visual* change — if a change seems to need that, you've crossed into Rail 1; re-compose around +it instead. + +### RAIL 2 — the TOKEN contract (the built globals.css must let Blocks render) + +Blocks resolve shadcn utilities (`bg-primary`, `text-muted-foreground`, `border-border`, …) through +your `globals.css`. So the file you author **must** define the shadcn token NAMES in **both `:root` +and `.dark`**, with the Tailwind-v4 wiring intact. Their *values* are entirely your call. + +The names that must be defined: + +``` +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 +``` + +The wiring that must stay intact: `@import 'tailwindcss';` · a **non-empty** `@theme inline` mapping +`--color-*: var(--*)` (this is what makes `bg-primary` resolve to `var(--primary)`) · +`@custom-variant dark;` · **at least one `@source`**. + +This is the **only** machine check that survives, run as: + +``` +node scripts/check-design.mjs --app +``` + +It hard-fails on a dropped/renamed name or broken wiring, and nothing else. Everything beyond these +names + wiring is free — add your own custom properties, fonts, and hand-written `@layer` component +CSS around the required block. + +--- + +## 3. Craft foundations + +These are mid-altitude principles — the difference between a real product and a template. Apply them +as judgment. + +- **Subtle layering + surface elevation.** Surfaces stack in *whisper-quiet* lightness steps (a few % + each); you feel the hierarchy, you don't see it. The sidebar shares the canvas hue (don't fragment + into "sidebar world" + "content world"); inputs read slightly **inset**. **Pick ONE depth + strategy** — borders-only / subtle shadow / layered shadow / surface-tint — and commit; mixing + reads as noise. +- **Border progression.** Borders are **low-opacity** — findable, not loud. If a border is the first + thing your eye lands on, it's too strong. +- **Color lives somewhere.** Draw the palette from the **product's world**, not a stock ramp. Gray + builds the structure; **one accent**, used with intent, carries meaning (action / status / + emphasis). Keep that accent one hue across surfaces — shift *lightness*, not *hue*. No pure + black / pure white slabs. +- **Typography is the design, not a container for it.** Build distinct levels readable at a glance via + **size + weight + tracking together**, not size alone — four text roles (primary / secondary / + tertiary / muted), and use all four. **Ban the generic sans as the headline face**: never reach for + Inter, Roboto, Open Sans, Lato, or system-ui as your display type. Pick **one distinctive face + decisively** and commit. Bring a **mono** face for the data / numeric / technical layer. +- **States are part of the design.** Every entity surface ships a **loading** skeleton that *matches + the real layout* (not a spinner), an **empty** state (a clear invitation + the primary create + action), and an **error** state (what went wrong + how to fix, in the interface's voice — never + vague, never apologetic). Plus real **hover / focus / disabled** treatments. Restyle these; never + delete them. +- **Radius is a scale, and a signal.** Sharp corners read technical; round read friendly. Choose one + scale and use it consistently. +- **Dark mode leans on borders.** In dark, depth comes from borders + small lightness steps more than + shadow. Verify the look in **both** modes — they are not the same design twice. + +--- + +## 4. The dials (light hints) + +Three optional 1–10 hints bias the work. Keep them light — a feel, not a system. + +| Dial | 1 → 10 | Biases | +|---|---|---| +| **variance** | flat / predictable → bold / unconventional | accent strength, type contrast, **structural boldness** | +| **motion** | none / instant → lively | transition length + hover/enter (always reduced-motion-gated; keep subtle for apps) | +| **density** | airy → compact | padding / gap / row-height — and it **seeds the scaffold skeleton's spacing tier** | + +Words → dials, briefly: *calm, trustworthy, admin* → 3–4 / 2–3 / 4–5 · *clean, focused, minimal* → +5–6 / 3–4 / 2–3 · *refined, editorial, premium* → 7–8 / 5–7 / 3–4 · *fun, energetic, consumer* → +9–10 / 8–10 / 3–4 · *raw, utilitarian* → 4–6 / 1–2 / 2–6. These are **applications**, so bias toward +the calm rows; theatrics are *earned* by the brief. Record `density` in the design.md so the scaffold +picks the matching spacing tier. + +--- + +## 5. Structure — restructure, don't just recolor + +You may rearrange the **shell** and the page **composition**, not merely tint a fixed layout — +rearranging is safe precisely because QA is testid/role-only (Rail 1). Same sidebar + same three-equal +cards + same metric boxes on every build reads as machine-made instantly; vary it. + +A compact archetype palette to author *toward* (adapt, don't skin): + +- **Shells:** `sidebar` (rail + top bar — the multi-route default) · `top-nav` (horizontal, wide + canvas) · `minimal` (no chrome, single surface) · `editorial-wide` (centered measure, prominent + masthead) · `dense-dashboard` (persistent tight chrome). +- **Compositions** (the per-entity `return(...)`): `list` (divided rows) · `data-table` (columnar, + dense) · `gallery` (card grid) · `split-pane` (master/detail) · `editorial` (single wide column) · + `board` (status columns). + +Whatever composition you pick, the contract is identical: the **`-row`** is whatever element +*repeats per record* (a list item, a table row, a card, a board card), it carries the row's **title +text**, and its **edit/delete** affordances live **inside** it. + +The **edit seams** that carry all of this: the **app-shell frame** (the one place the nav + top bar + +main are arranged — restyle or drop the rail; a `hideSidebar` off-ramp exists; mirror any restructure +in the loading skeleton), each entity page's **`return(...)` block** (the presentation seam — author +it wholesale to your composition; *move, don't remove* the contract elements), and the page **max-w +clamp** (widen for dashboards/tables, keep centered for editorial — a pure class change). + +**Record the chosen direction** (shell / composition / density / why) in the design.md, so re-runs and +day-2 turns reproduce it instead of reverting to the default. + +--- + +## 6. Self-check before you show it + +Look at what you made and ask "if they said this lacks craft, what would they mean?" — then fix that +first. Four quick tests: + +- **Swap test** — if you swapped your typeface / layout / palette for the most common defaults, would + the app feel meaningfully different? Where swapping wouldn't matter, you defaulted — author there. +- **Squint test** — blur your eyes (in **both** light and dark): is the hierarchy still readable, with + nothing harsh jumping out? Craft whispers. +- **Signature test** — can you point to the **one** signature element in actual components, not "the + overall feel"? +- **Token test** — read your token values: do they sound like *this* product's world, or like any + project (a named, world-derived value vs a generic `--gray-700`)? + +Then run `node scripts/check-design.mjs --app ` (Rail 2) and re-run the structural gates + live +QA (Rail 1). "Green" means **verified across the stack**, light and dark — not a passing build alone. + +--- + +## 7. Examples (start here, then adapt) + +`references/examples/` holds worked exemplars — each a complete, opinionated design.md in a named +aesthetic category, so you can see the foundations above made concrete: + +- **Graphite** — precision; restrained, engineered, exacting. +- **Prism** — colorful but **structured**; energy held in a disciplined system. +- **Eclipse** — a dark-first product surface. +- **Folio** — editorial; a print-feel reading layout. +- **Solaris** — warm and soft; gentle, premium. +- **Concrete** — brutalist; raw, square, monospace structure. +- **constructive.md** — the **opt-out baseline** (today's stock look). + +How to use them: + +- **Absent design block** ⇒ **auto-propose a rich one** — read the intent, name the atmosphere + the + dials + a signature, and author a full design.md. The quality of the design.md is the quality + ceiling of the app; never ship a thin token set or stock shadcn. +- **`design: { preset: }`** ⇒ start from that exemplar and **adapt** it to this product — + never ship it as a fixed skin. +- **`design: { preset: constructive }`** ⇒ keep today's look (the opt-out). + +--- + +**One last nudge:** don't let these examples (or your last build) become a *new* default. Vary the +shell, the type, and the palette across builds — the genericity tell is sameness, and the cure is a +choice made on purpose every time. diff --git a/.agents/skills/constructive-builder/references/examples/concrete.md b/.agents/skills/constructive-builder/references/examples/concrete.md new file mode 100644 index 0000000..08f73fb --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/concrete.md @@ -0,0 +1,58 @@ +--- +name: Concrete +description: A brutalist archetype — stark near-black on raw white, heavy display type, square corners, thick visible borders, one hazard accent. +dials: { variance: 8, motion: 1, density: 6 } +font: { sans: "Space Grotesk", mono: "Space Mono" } +radius: "0rem" +# Light-mode palette as shadcn ROLE values in OKLCH. The agent derives +# card/popover/secondary/sidebar/chart-* as elevation/hue steps of these per design-guide.md. +colors: + background: "oklch(0.98 0 0)" # raw, near-paper white — true neutral, no cast + foreground: "oklch(0.18 0 0)" # near-black ink, structural and heavy (never pure #000) + primary: "oklch(0.18 0 0)" # the action IS ink — a black slab, not a hue + primary-foreground: "oklch(0.99 0 0)" + muted: "oklch(0.95 0 0)" # a flat concrete-gray fill, no tint + muted-foreground: "oklch(0.42 0 0)" # secondary ink, still firm + accent: "oklch(0.62 0.18 40)" # ONE hazard accent — a loud safety-orange/vermilion, far from the AI band + border: "oklch(0.18 0 0)" # THICK and near-black — borders are the structure, not a hairline + ring: "oklch(0.62 0.18 40)" + destructive: "oklch(0.53 0.19 27)" + success: "oklch(0.55 0.13 150)" + warning: "oklch(0.72 0.15 75)" + info: "oklch(0.52 0.10 233)" +# Dark-mode KEY overrides (only the roles that must shift; the agent derives the rest): +dark: + background: "oklch(0.18 0 0)" # near-black concrete (not pure #000) + foreground: "oklch(0.96 0 0)" # stark off-white ink + primary: "oklch(0.96 0 0)" # ink inverts to off-white; the slab stays neutral + border: "oklch(0.96 0 0)" # thick borders invert to off-white — still loud, still structural +--- + +# Concrete + +> Art direction: black ink on raw white, fenced by thick square borders and stamped with one hazard accent — unapologetic, structural, loud. + +## Atmosphere +The feel of poured concrete and stamped signage: a blueprint, a loading dock, a printed manifest with no decoration left in. Nothing is soft or apologetic — every region is a hard rectangle with a heavy edge, and the interface announces itself rather than receding. Honest, industrial, and a little defiant. + +## Palette — with intent +The world is built from one stark high-contrast pair: a raw near-white slab and a near-black ink (never pure `#000`, so it stays a deliberate ink rather than a void). Neutrals carry **zero chroma** — flat concrete grays — so the surface reads as raw material, not a tinted brand. The single hazard **vermilion** (hue ~40, chroma held under 0.20) is the only color in the room: it marks the live action and the active edge the way safety paint marks a structural hazard — used sparingly so it *shouts* when it appears. Status hues stay chromatic so red / green / amber always read as state against the monochrome field. + +## Type +Lead with a **heavy geometric grotesque with idiosyncratic detailing** — prefer Space Grotesk, or a peer like a condensed industrial grotesque — set BOLD and large for display, with hard weight contrast (700+ headings against 400 body). Pair it with a stark **monospace** (Space Mono) for ids, counts, labels, and the technical layer — mono leans into the manifest/blueprint feel. Hierarchy is brutal and obvious: big heavy headings, tight tracking, generous jumps in size; uppercase labels with wide tracking for section stamps. Never reach for Inter / Roboto / Open Sans / Lato / system-ui as the headline face — the display weight is the signature. + +## Layout & density +A **top-nav** banner over a wide working canvas, or a hard-ruled **sidebar** fenced from the content by a thick border — pick one and let the edges do the framing. Compose entities as a dense **data-table** or divided **list** where every row is boxed by a heavy rule. Depth strategy is **borders-only**, taken to the extreme: 2–3px near-black edges (not hairlines) are the entire structural system — surfaces never lift on a shadow, they are *fenced*. Radius is a hard **0** everywhere; corners are square by law. Spacing is firm and grid-locked (DENSITY ~6) — compact but never cramped, every block aligned to a visible structure. + +## Motion +Near-zero (MOTION 1): instant state changes, hard cuts, no eases or springs — the interface snaps. Always honor `prefers-reduced-motion`. + +## Banned +- Any rounded corner, pill, or soft radius — corners are square, full stop. +- Drop shadows, glows, or "lifted card" depth — separation is thick borders only. +- Thin low-opacity hairlines — the border is heavy and near-black by design. +- A second accent hue, gradients, or color spent on anything but the one hazard signal. +- Pure-black (`#000`) ink or surfaces, and humanist / soft / rounded display faces. + +## Reach for it when +Tools that want to feel raw, structural, and unmistakable — developer utilities, internal manifests, editorial-brutalist products, or any surface that should look engineered and a little defiant rather than friendly. diff --git a/.agents/skills/constructive-builder/references/examples/constructive.md b/.agents/skills/constructive-builder/references/examples/constructive.md new file mode 100644 index 0000000..debb1a9 --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/constructive.md @@ -0,0 +1,58 @@ +--- +name: constructive +preset: constructive +description: The stock platform default — calm sky-blue on near-neutral gray, dependable and trust-first, the deliberate zero-restyle opt-out. +dials: { variance: 3, motion: 2, density: 5 } +font: { sans: "Geist", mono: "Geist Mono" } +radius: "0.5rem" +# Light-mode palette as shadcn ROLE values in OKLCH — TODAY'S scaffolded look, recorded verbatim. +# The agent derives card/popover/secondary/sidebar/chart-* as elevation steps of these per design-guide.md. +colors: + background: "oklch(1 0 0)" # plain white surface — the calm, neutral canvas + foreground: "oklch(0.21 0.006 265)" # near-black ink with a faint cool cast, never #000 + primary: "oklch(0.688 0.1754 245.6)" # the signature sky-blue (hue ~246 — true sky, just BELOW the AI band) + primary-foreground: "oklch(0.985 0.002 250)" + muted: "oklch(0.967 0.003 265)" # whisper-quiet near-neutral fill + muted-foreground: "oklch(0.55 0.012 265)" # secondary ink + accent: "oklch(0.688 0.1754 245.6)" # the SAME single sky-blue — no second hue + border: "oklch(0.922 0.004 265)" # hairline, near-neutral + ring: "oklch(0.688 0.1754 245.6)" + destructive: "oklch(0.577 0.19 27)" + success: "oklch(0.60 0.13 150)" + warning: "oklch(0.72 0.14 75)" + info: "oklch(0.688 0.1754 245.6)" # info shares the signature sky-blue +# Dark-mode KEY overrides (only the roles that must shift; the agent derives the rest): +dark: + background: "oklch(0.18 0.008 265)" # deep cool charcoal, not #000 + foreground: "oklch(0.94 0.004 265)" + primary: "oklch(0.72 0.16 245.6)" # sky-blue lifts in L to hold against charcoal + border: "oklch(0.30 0.008 265)" # hairline stays a hairline +--- + +# constructive + +> Art direction: the platform's calm stock face — a sky-blue action on near-neutral gray, dependable to a fault, with nothing custom layered on top. + +## Atmosphere +The default an admin surface ships with before anyone reaches for a theme: composed, trustworthy, and unsurprising. The world is a settings panel or a back-office console that wants to feel safe and familiar — no personality forced on it, just a clean canvas and a friendly blue that says "this is the live thing." Choosing this look is choosing *not* to design: you accept the stock palette as-is. + +## Palette — with intent +The signature is a single **sky-blue** (hue ~246, chroma ~0.18) — a true sky tone deliberately one step *below* the generic AI blue-purple band, so it reads as dependable rather than templated. It is the only color in the room: it carries the primary action, the focus ring, and links. Everything structural is near-neutral gray on a white surface (deep cool charcoal in dark), with the faintest cool cast so it never feels clinical. Status hues (red / green / amber) appear only as semantic signal. + +## Type +Geist for everything in the chrome — a clean geometric grotesk that stays neutral and legible — paired with Geist Mono for ids, counts, keys, and timestamps. Hierarchy comes from weight and size on a calm scale (600 headings, 400 body), never from color or flourish. This is the stock pairing; the opt-out keeps it rather than choosing a more distinctive face. + +## Layout & density +A persistent left **sidebar** over a moderate-density working canvas — the conventional admin shell, kept as scaffolded. Compose entities as a readable **list** of divided rows; balanced spacing (DENSITY ~5) that neither sprawls nor cramps. Depth strategy is **borders-only**: surfaces separate by near-neutral hairlines and small lightness steps, never drop shadows. Medium radius (`0.5rem`) softens controls just enough to feel approachable. + +## Motion +Minimal (MOTION ~2): short opacity/position fades on state change, no springs. Always honor `prefers-reduced-motion`. + +## Banned +- Restyling for its own sake — this preset's whole point is to leave the stock look untouched. +- A second accent hue or gradients — one sky-blue and near-neutrals, full stop. +- Pure black (#000) ink or surfaces; pushing the primary up into the AI blue-purple band. +- Drop shadows or glows as the depth device — separation is hairlines only. + +## Reach for it when +You want a dependable, trust-first admin surface and **no custom design work at all** — the agent skips the authoring pass and leaves the scaffolded `globals.css` exactly as-is (it already satisfies Rail 2). Reach past it for any product that wants a remembered identity. diff --git a/.agents/skills/constructive-builder/references/examples/eclipse.md b/.agents/skills/constructive-builder/references/examples/eclipse.md new file mode 100644 index 0000000..434fb22 --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/eclipse.md @@ -0,0 +1,57 @@ +--- +name: Eclipse +description: Dark-mode-first product UI — deep cool charcoal canvas, one restrained low-chroma teal, depth from borders, dense and exact. +dials: { variance: 5, motion: 3, density: 7 } +font: { sans: "Sora", mono: "Geist Mono" } +radius: "0.25rem" +# Light-mode palette as shadcn ROLE values (the quieter, derived mode — author the dark block first, then quiet it down). +colors: + background: "oklch(0.985 0.003 230)" + foreground: "oklch(0.24 0.012 250)" + primary: "oklch(0.55 0.08 195)" + primary-foreground: "oklch(0.985 0.004 230)" + muted: "oklch(0.955 0.004 240)" + muted-foreground: "oklch(0.52 0.012 245)" + accent: "oklch(0.55 0.08 195)" + border: "oklch(0.90 0.006 240)" + ring: "oklch(0.55 0.08 195)" + destructive: "oklch(0.55 0.19 27)" + success: "oklch(0.58 0.12 158)" + warning: "oklch(0.74 0.13 80)" + info: "oklch(0.55 0.08 195)" +# Dark-mode KEY overrides (the PRIMARY intent — deep charcoal, lifted teal, a visible-but-quiet border): +dark: + background: "oklch(0.20 0.012 250)" + foreground: "oklch(0.94 0.006 240)" + primary: "oklch(0.70 0.09 195)" + border: "oklch(0.30 0.012 245)" +--- + +# Eclipse + +> Art direction: a deep cool-charcoal console where structure is drawn in **borders, not shadows**, and a single muted teal is the only light in the room. + +## Atmosphere +An operator's product UI built to be lived in after dark — a control surface for someone who keeps it open all day. Calm and exact, never moody-for-its-own-sake: the dimness is functional (low glare, long sessions), and the one cool accent reads as a signal, not decoration. + +## Palette — with intent +The canvas is a deep cool charcoal (`oklch(0.20 0.012 250)`), never pure black — `#000` reads harsh and flattens depth. The one accent is a **restrained teal** (`~hue 195`, chroma held to ~0.08): deliberately on the cool, *non-purple* side of blue so it never drifts into the generic AI blue-purple band, and quiet enough to sit in a dim room without buzzing. It lifts to `L 0.70` in dark so the single primary action still carries against charcoal. Status hues stay chromatic so red/green/amber always mean *state* in an otherwise monochrome field. + +## Type +Lead with **Sora** — a geometric grotesk with a slightly technical, even cadence that suits a console (prefer it over Inter/Roboto/system-ui as the headline face). Pair it with **Geist Mono** for ids, counts, timestamps, and table data — numerics live in mono here. Hierarchy is carried by **weight + size together** (heavy headings, `font-medium` to lead a row against `muted-foreground`), with tight tracking on display; the dense scale keeps steps small. + +## Layout & density +A **dense-dashboard** shell: a tight persistent rail, a compact top bar, a wide working canvas — chrome that stays put because the user never leaves. Entity surfaces favor a **data-table** (columnar, scannable, right-aligned numerics) over roomy cards. Depth strategy is **borders-only**: on a dark canvas shadows are invisible, so separate surfaces with quiet 1px borders (`oklch(0.30 …)`) and a faint lightness step, never a drop shadow. Tight rhythm; symmetrical padding. + +## Motion +Restrained (MOTION 3): short, fast micro-interactions on a calm ease-out; no spring, no entrance theater. Always honor `prefers-reduced-motion`. + +## Banned +- Pure-black (`#000`) canvas or text — use the near-black charcoal so depth survives. +- Drop shadows or glows as the depth device (invisible/ugly on dark — use borders + a lightness step). +- A purple/indigo primary (the AI blue-purple tell) — the accent is cool teal, with intent. +- More than one chromatic accent; color spent on decoration instead of signal/status. +- Roomy three-equal-card grids where a dense table would scan better. + +## Reach for it when +Operator consoles, monitoring/observability, trading or analytics dashboards, dev tools — anything kept open all day where a dark, dense, signal-over-decoration surface earns its place. diff --git a/.agents/skills/constructive-builder/references/examples/folio.md b/.agents/skills/constructive-builder/references/examples/folio.md new file mode 100644 index 0000000..cf8f611 --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/folio.md @@ -0,0 +1,55 @@ +--- +name: Folio +description: An editorial print archetype — warm paper, serif display over humanist sans, long-form and calm. +dials: { variance: 6, motion: 3, density: 4 } +font: { sans: "Source Sans 3", mono: "IBM Plex Mono", serif: "Lora" } +radius: "0.5rem" +colors: + background: "oklch(0.985 0.006 85)" + foreground: "oklch(0.25 0.012 80)" + primary: "oklch(0.42 0.07 155)" + primary-foreground: "oklch(0.98 0.008 85)" + muted: "oklch(0.95 0.008 85)" + muted-foreground: "oklch(0.50 0.013 80)" + accent: "oklch(0.74 0.09 85)" + border: "oklch(0.88 0.010 82)" + ring: "oklch(0.42 0.07 155)" + destructive: "oklch(0.52 0.18 27)" + success: "oklch(0.55 0.10 150)" + warning: "oklch(0.74 0.11 75)" + info: "oklch(0.50 0.07 215)" +dark: + background: "oklch(0.20 0.012 80)" + foreground: "oklch(0.94 0.008 85)" + primary: "oklch(0.62 0.08 155)" + border: "oklch(0.34 0.012 80)" +--- + +# Folio + +> Art direction: a typeset page, not a screen — ink-green on warm paper, where the serifs and the measure do the work that chrome usually does. + +## Atmosphere +A long-form reading surface that happens to hold an app: an archivist's desk, a printed annual, a well-bound journal. It feels unhurried, literate, and confident — air around every block, weight in every title, nothing shouting for attention. + +## Palette — with intent +The primary is a sober **ink-green** (hue ~155, chroma kept modest): the color of a good fountain-pen ink or a ledger rule — intentional and bookish, never an alert. It carries links and the single page action. The whole neutral ramp is pulled *warm* (hue ~80) so borders and muted text read as printed gray, not screen gray. The lone accent is a quiet **brass/ochre** reserved for a kicker or a hairline flourish — present, never a second voice. Status hues stay earthy so they harmonize with the paper instead of puncturing it. + +## Type +The soul of the look is the **serif/sans contrast**. Set the masthead and display headings in a warm humanist old-style serif (prefer a Lora / Source Serif / Spectral family) and the body + UI in a readable humanist sans (Source Sans 3, IBM Plex Sans); data and codes in a mono. A roomy base on a *large* scale ratio (~1.414, an augmented fourth) gives a book-like jump from body to display; body leading runs generous (~1.7), headings tight. Clamp body copy to a comfortable measure so lines never run too long. Never flatten the two faces into one — that contrast is the whole signal. Avoid Inter / Roboto / Open Sans / Lato / system-ui as the headline face. + +## Layout & density +Editorial-wide: a centered column with a prominent masthead, not a navigation rail fighting the content. Compose entities as article-like rows or a single readable list, generous (DENSITY ~4) with a wide rhythm. Depth strategy is **borders-only** — warm hairline rules between sections are the primary grouping device; no boxed cards, no drop shadows. Medium radius (`0.5rem`) softens controls just enough to feel humane without going round. + +## Motion +Restrained (MOTION ~3): short fades and a gentle underline/rule reveal on hover; sections settle in on first paint. Always honor `prefers-reduced-motion`. + +## Banned +- Clinical pure-white or pure-black slabs — use the warm paper and warm ink tones. +- A single flat sans for everything (the serif display is the point). +- Boxed heavy cards, drop shadows, or decorative gradients. +- A second chromatic accent competing with the ink-green. +- Cramped body line-height or center-aligned long paragraphs. + +## Reach for it when +Content-forward, document-heavy, or premium products — reading apps, knowledge bases, long-form admin, editorial tooling. diff --git a/.agents/skills/constructive-builder/references/examples/graphite.md b/.agents/skills/constructive-builder/references/examples/graphite.md new file mode 100644 index 0000000..f1cb1af --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/graphite.md @@ -0,0 +1,58 @@ +--- +name: Graphite +description: Precision developer-tool minimalism — graphite ink on near-white, monochrome with one instrument-blue accent. +dials: { variance: 4, motion: 2, density: 6 } +font: { sans: "Geist", mono: "Geist Mono" } +radius: "0.125rem" +# Light-mode palette as shadcn ROLE values in OKLCH. The agent derives +# card/popover/secondary/sidebar/chart-* as elevation/hue steps of these per design-guide.md. +colors: + background: "oklch(0.985 0.002 250)" # near-white, a faint cool cast + foreground: "oklch(0.22 0.012 250)" # graphite ink, never #000 + primary: "oklch(0.55 0.13 233)" # one instrument-blue accent (true blue, NOT the AI-purple band) + primary-foreground: "oklch(0.99 0.002 250)" + muted: "oklch(0.965 0.003 250)" # whisper-quiet surface tint + muted-foreground: "oklch(0.50 0.012 250)" # secondary ink + accent: "oklch(0.55 0.13 233)" # the SAME single accent — no second hue + border: "oklch(0.90 0.006 250)" # hairline, low chroma + ring: "oklch(0.55 0.13 233)" + destructive: "oklch(0.55 0.19 27)" + success: "oklch(0.60 0.13 150)" + warning: "oklch(0.72 0.14 75)" + info: "oklch(0.58 0.10 233)" +# Dark-mode KEY overrides (only the roles that must shift; the agent derives the rest): +dark: + background: "oklch(0.19 0.006 250)" # near-black graphite, not #000 + foreground: "oklch(0.93 0.004 250)" + primary: "oklch(0.66 0.135 233)" # accent lifts in L to hold on dark + border: "oklch(0.30 0.008 250)" # hairline stays a hairline +--- + +# Graphite + +> Art direction: graphite ink on near-white, ruled by hairlines and a single instrument-blue — a precise tool that disappears so the data can speak. + +## Atmosphere +Engineered and exacting, not friendly. The world of a focused operator surface: a CLI dressed as an app, where every pixel is accountable and nothing is decorative. Quiet, cool, and confident — the chrome recedes and the content is the only event. + +## Palette — with intent +The canvas is a near-white with the faintest cool cast (hue 250), and the type is graphite — a deep neutral ink, never pure black, so it reads as deliberate rather than harsh. Everything structural is monochrome along one cool gray ramp. The single instrument-blue (hue 233, modest chroma) is the only color in the room: it marks the live thing — focus ring, active row, primary action — and carries meaning precisely *because* nothing else competes. Status (red / green / amber) appears only as semantic signal, never as decoration. + +## Type +Reach for a geometric or grotesque sans with even, mechanical proportions — Geist, or a peer like Söhne or a clean grotesque — never Inter/Roboto/Open Sans/system-ui as the headline face. Pair it with a mono (Geist Mono) for the technical layer: ids, counts, code, keys, timestamps. Hierarchy comes from weight and size on a tight scale, not from color or flourish; headings sit at 600, body at 400, with slight negative tracking on display sizes for an engineered edge. + +## Layout & density +A persistent left sidebar over a dense data-table workspace — the operator's console, not a marketing page. Tight, regular spacing on a consistent grid; rows are compact and scannable. Depth strategy is **borders-only**: surfaces separate by hairline low-opacity rules and tiny lightness steps, never by drop shadow. Radius is near-0 (2px) so corners read as machined, not soft. Align everything to the grid; let the rules do the structural work. + +## Motion +Minimal and instant — short opacity/position fades on state change, no springs or bounce. Always honor `prefers-reduced-motion`. + +## Banned +- Drop shadows, glows, or any "lifted card" depth — separation is hairlines only. +- A second accent hue, or gradients — one instrument-blue and neutrals, full stop. +- Rounded, soft, or pill shapes; large radii read as consumer, not instrument. +- Pure black (#000) ink or surfaces; the AI blue-purple band for the accent. +- Humanist or rounded display faces, emoji ornament, decorative iconography. + +## Reach for it when +Developer tools, internal operator consoles, monitoring/admin surfaces, and any dense data app that should feel precise and trustworthy rather than playful. diff --git a/.agents/skills/constructive-builder/references/examples/prism.md b/.agents/skills/constructive-builder/references/examples/prism.md new file mode 100644 index 0000000..c36be46 --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/prism.md @@ -0,0 +1,63 @@ +--- +name: Prism +description: Colorful-but-structured workspace — a white canvas where several signature color surfaces act as full blocks, with a near-black action. +dials: { variance: 6, motion: 4, density: 5 } +font: { sans: "DM Sans", mono: "Fira Code" } +radius: "0.5rem" +# Light-mode palette as shadcn ROLE values in OKLCH. The agent derives card/popover/secondary/sidebar +# from these as elevation steps, and derives the chart-* ramp into the SIGNATURE tinted surfaces. +colors: + background: "oklch(0.995 0.001 250)" # a clean white canvas — the color comes from the surfaces, not the page + foreground: "oklch(0.24 0.012 265)" # neutral near-black ink; text stays quiet so the surfaces carry hue + primary: "oklch(0.21 0.018 265)" # near-black action — the CTA is ink, NOT a chromatic hue + primary-foreground: "oklch(0.985 0.001 250)" + muted: "oklch(0.965 0.003 250)" # the faintest cool gray for rails/fills + muted-foreground: "oklch(0.50 0.012 265)" + accent: "oklch(0.62 0.13 238)" # ONE chromatic highlight — a clean azure, safely below the AI band + border: "oklch(0.92 0.004 250)" # hairline; surfaces are separated by tinted fills, not heavy lines + ring: "oklch(0.62 0.13 238)" + destructive: "oklch(0.55 0.19 27)" + success: "oklch(0.60 0.12 152)" + warning: "oklch(0.75 0.13 78)" + info: "oklch(0.62 0.13 238)" +# The disciplined multi-hue spectrum that names this look — full-block surface tints, each modest chroma: + chart-1: "oklch(0.62 0.13 238)" # azure + chart-2: "oklch(0.64 0.12 150)" # green + chart-3: "oklch(0.76 0.13 82)" # amber + chart-4: "oklch(0.63 0.15 18)" # coral + chart-5: "oklch(0.58 0.12 300)" # a restrained violet — modest chroma keeps it OUT of the slop band +dark: + background: "oklch(0.20 0.012 265)" # cool near-black canvas (never pure #000) + foreground: "oklch(0.96 0.003 250)" + primary: "oklch(0.96 0.003 250)" # ink inverts to near-white; the action stays neutral, not chromatic + border: "oklch(0.30 0.008 265)" +--- + +# Prism + +> Art direction: a white worksheet where each section snaps into its own confident color block — organized, multi-hue, and disciplined, with the only solid action rendered in plain ink. + +## Atmosphere +The feel of a well-kept structured-data product: rows, groups, and views, each labeled by a calm color. It is friendly and organized rather than loud — the page is white and quiet, and the hues live in the surfaces (group headers, tagged cards, view chips), never in the prose. + +## Palette — with intent +Color is **architecture, not decoration**: a modest spectrum (azure / green / amber / coral / restrained violet) tags and separates regions as full tinted blocks, so structure reads at a glance. The one true `accent` is the azure — used for focus and a single highlight — while the `primary` action is deliberately **near-black ink**, so the most important button never competes with the colorful surfaces. Neutrals are a faint cool gray; every hue is held to modest chroma so five colors coexist without clashing. + +## Type +Reach for **DM Sans** — a clean, lightly geometric grotesque that stays neutral against the colored surfaces; pair with **Fira Code** for IDs, counts, and record keys (the structured-data tell). Modest weight contrast on purpose: `400` body, `500` labels, `600` headings — hierarchy comes from the colored surfaces and spacing, not from heavy display type. Avoid Inter/Roboto/system-ui as the headline face. + +## Layout & density +Top-nav over a white canvas with view chips; compose entities as a **data-table** or a tagged card **gallery**, each group introduced by its own color-surface header. Slightly dense (DENSITY 5) so a structured grid breathes without sprawling. Depth strategy = **surface-shift**: regions separate by tinted fills + hairline borders, not drop shadows. + +## Motion +Restrained (MOTION 4): quick crisp tints and view switches, no bounce; always honor `prefers-reduced-motion`. + +## Banned +- A chromatic (azure/violet) PRIMARY action — the solid CTA stays near-black ink. +- More than the five disciplined surface hues, or any one pushed to a screaming chroma. +- The generic AI blue-violet band (hue ~265–295) as `primary` or `accent`. +- Drop shadows or gradients standing in for the color-surface structure. +- A flat all-gray grid that throws away the signature multi-hue surfaces. + +## Reach for it when +Structured-data and collaborative products — anything organized into labeled groups, tags, and switchable views. diff --git a/.agents/skills/constructive-builder/references/examples/solaris.md b/.agents/skills/constructive-builder/references/examples/solaris.md new file mode 100644 index 0000000..3fb66f6 --- /dev/null +++ b/.agents/skills/constructive-builder/references/examples/solaris.md @@ -0,0 +1,58 @@ +--- +name: Solaris +description: A warm, soft, luminous archetype — sun-warmed off-white, a gentle peach-coral primary, big radii and diffuse shadows; approachable and human. +dials: { variance: 5, motion: 4, density: 4 } +font: { sans: "Nunito", mono: "Spline Sans Mono" } +radius: "1rem" +# Light-mode palette as shadcn ROLE values in OKLCH. The agent derives +# card/popover/secondary/sidebar/chart-* as elevation/hue steps of these per design-guide.md. +colors: + background: "oklch(0.985 0.012 75)" # warm off-white, sun on paper — never clinical #fff + foreground: "oklch(0.28 0.018 50)" # warm near-black ink, soft not stark + primary: "oklch(0.72 0.135 45)" # soft peach-coral — friendly warmth, chroma well under 0.20 + primary-foreground: "oklch(0.99 0.010 75)" + muted: "oklch(0.955 0.014 72)" # a touch-warmer fill, whisper-quiet + muted-foreground: "oklch(0.52 0.020 55)" # warm secondary ink + accent: "oklch(0.78 0.115 60)" # the ONE accent — a warm amber sibling of the primary, gentler + border: "oklch(0.90 0.014 70)" # warm hairline, low chroma, soft + ring: "oklch(0.72 0.135 45)" + destructive: "oklch(0.58 0.18 27)" # kept warm-leaning so it harmonizes, still unmistakably alert + success: "oklch(0.64 0.115 150)" + warning: "oklch(0.78 0.13 78)" + info: "oklch(0.62 0.085 230)" # the lone cool note, low chroma so it never fights the warmth +# Dark-mode KEY overrides (only the roles that must shift; the agent derives the rest): +dark: + background: "oklch(0.22 0.014 50)" # warm near-black, like lamplight on dark wood — never #000 + foreground: "oklch(0.94 0.012 75)" + primary: "oklch(0.76 0.13 48)" # peach lifts in L to glow against the warm dark + border: "oklch(0.32 0.014 50)" # soft warm hairline +--- + +# Solaris + +> Art direction: sun-warmed off-white and a gentle peach glow, with generous rounding and soft diffuse light — an interface that feels welcoming the moment it loads. + +## Atmosphere +The feel of morning light through a window: warm, soft, and unhurried. This is a surface for people, not operators — a product that wants to be liked, where nothing has a hard edge and every shadow is a soft fall of light rather than a drawn line. Calm and generous, with just enough warmth to feel cared-for. + +## Palette — with intent +Everything is pulled **warm** (hue ~50–75): the canvas is a sun-on-paper off-white, and the ink is a soft warm near-black, so even the neutrals feel friendly rather than clinical. The primary is a **peach-coral** (hue ~45, chroma held under 0.20) — it reads as warmth and welcome, carrying the primary action and focus without ever shouting. The lone accent is a slightly lighter **amber sibling** of that primary — same family, a half-step warmer — used for a highlight or a soft status glow, never a competing voice. Status hues lean warm so they harmonize, with a single low-chroma cool **info** as the one calm counterpoint. + +## Type +Lead with a **rounded humanist sans** — Nunito, or a peer like Quicksand or Varela Round — whose soft terminals carry the whole friendly signal; never Inter / Roboto / Open Sans / Lato / system-ui as the headline face. Pair it with a gently rounded mono (Spline Sans Mono) for ids, counts, and keys so even the numeric layer stays warm. Keep weight contrast soft and generous: a heavier rounded display for headings, a comfortable body, open leading. Hierarchy comes from size + weight + air, not from anything sharp. + +## Layout & density +A **sidebar** shell that shares the warm canvas hue (no cold "rail world"), over roomy **gallery** cards or a relaxed **list** — composed to breathe (DENSITY ~4), with a soft, even rhythm. Depth strategy is **soft-shadow**: surfaces lift on wide, low-opacity, warm-tinted shadows — diffuse light, not a hard drop — which is the signature that sets this apart from the borders-only archetypes. Big radius (`1rem`) on cards, inputs, and buttons so every corner feels rounded and kind; inputs read gently inset. + +## Motion +Lively but soft (MOTION ~4): easings settle with a gentle ease-out, hovers warm and lift slightly, surfaces fade in like light arriving — never snappy or mechanical. Always honor `prefers-reduced-motion`. + +## Banned +- Clinical pure-white canvas or pure-black ink/surfaces — everything is warm, soft, off-true. +- Sharp or near-0 corners; hairline-only "engineered" depth — this archetype lifts on soft light. +- A cold, neutral-gray primary, or any second chromatic voice competing with the peach. +- The generic AI blue-purple band (hue ~265–295) for the primary — the warmth is the point. +- Tight, dense data-table chrome or rigid grids that fight the gentle, roomy feel. + +## Reach for it when +Consumer and lifestyle products, onboarding and wellness surfaces, friendly community or personal apps — anything that should feel warm, human, and welcoming rather than technical. 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..02c402f 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,14 @@ 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. +> **Look-and-feel comes later (in the Phase-4 authoring step), not here.** This phase scaffolds the app +> and its stock `src/app/globals.css`. You **author the presentation** — including the `globals.css` +> shadcn-token values — in Phase 4, after the Blocks `@import` and skeleton are in place, working from the +> app's `design.md`. The only machine check is the FUNCTIONAL gate: the built `globals.css` must still +> define the shadcn token names + Tailwind-v4 wiring so Blocks render (`node scripts/check-design.mjs +> --app `). So **do not theme `globals.css` here** — leave that to the authoring step. Methodology: +> [design-guide.md](./design-guide.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..7564716 100644 --- a/.agents/skills/constructive-builder/references/phase-4-blocks.md +++ b/.agents/skills/constructive-builder/references/phase-4-blocks.md @@ -15,8 +15,17 @@ > `./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. +> authoring the presentation from the app's `design.md` ([design-guide.md](./design-guide.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: AUTHOR the presentation.** After the Blocks `@import` + skeleton +> are in place and **before**/alongside the CRUD body, **author the app's look-and-feel by hand** from the +> app's `design.md` — including the `globals.css` shadcn-token values — so the template **and** every +> installed Block (all read the shadcn `var(--…)` tokens) render in the app's voice. The ONE machine check +> is FUNCTIONAL: the built `globals.css` must still define the shadcn token **names** + Tailwind-v4 wiring +> so Blocks render — validate with **`node scripts/check-design.mjs --app `**. Full authoring +> methodology + the token contract: **[design-guide.md](./design-guide.md)**. --- diff --git a/.agents/skills/constructive-builder/references/speedrun.md b/.agents/skills/constructive-builder/references/speedrun.md index 6c5e7e9..a10e1be 100644 --- a/.agents/skills/constructive-builder/references/speedrun.md +++ b/.agents/skills/constructive-builder/references/speedrun.md @@ -432,6 +432,42 @@ default to `['ID_ASC']`. --- +## S7.5 — AUTHOR the presentation from the app's design.md (always — this is where the app gets its design) + +The `design.md` is the **full design spec** and **you author the whole frontend from it** — the shell, +each entity page's composition, the type (fonts/scale/weights), the spacing rhythm, the surfaces, the +intentional ornament, the copy, **and** the shadcn token values in `/src/app/globals.css` (so the +template UI and every installed Block render in the app's voice — they all read the `var(--…)` shadcn +tokens). **There is no compiler / codemod / theme step — you write the CSS and compose the components +yourself.** + +Two rails frame the work. **Rail 1 is functional:** the CRUD body's behavior contract — the `-*` +testids, row-scoping, hooks/selection/refetch, the RLS scoping consts, the route/block/flow surfaces — +must survive your restyle. **Rail 2 is the token contract:** the built `globals.css` must still define the +shadcn token **names** + the Tailwind-v4 wiring so Blocks render. Rail 2 is the **only** machine check, and +it is FUNCTIONAL (it never judges taste): + +```bash +node scripts/check-design.mjs --app # = the WORKSPACE ROOT (resolves packages/app) +``` + +- **Default = author a domain-fitting look.** With **no `design:` block** in the brief, propose and + author a `design.md` for the app, then realize it in `globals.css` + the components. A generated app + should not ship the stock boilerplate look unless asked. +- **Opt-out = keep today's look.** `design: { preset: constructive }` ⇒ leave the boilerplate + `globals.css` as-is and skip the authoring pass (it already satisfies Rail 2). +- **Token contract is the rail.** Whatever you author, `globals.css` MUST keep defining the full shadcn + token name set (background, foreground, primary, …, radius) under `:root`/`.dark` + the `@theme inline` + Tailwind-v4 wiring — that is exactly what `check-design.mjs` asserts and what keeps Blocks rendering. +- **Brief control.** The optional `design:` block (`brief`/`preset`/`density`/`colors`/`font`/`radius`) + guides the look — see [brief-grammar.md](./brief-grammar.md) "design (optional)". + +> **The full authoring methodology — the design.md format, the token contract, the words→look guidance, +> and the preserve list — is in [design-guide.md](./design-guide.md).** Read it when authoring/adapting an +> app's look or when `check-design.mjs` flags a missing token. + +--- + ## S8 — Build + the two TS gates ```bash @@ -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) +> S7→[phase-4-blocks.md](./phase-4-blocks.md) CRUD body, S7.5→[design-guide.md](./design-guide.md)) 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..e25583f --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/check-design.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * check-design.mjs — the ONE style-side hard rail (RAIL 2): the Blocks-contract + * validator. + * + * The design.md is the FULL design spec and the AGENT authors the whole frontend + * from it; there is no machine "taste" gate. The only thing a build must still + * guarantee mechanically is FUNCTIONAL: the generated app's `globals.css` keeps the + * shadcn token NAMES + the Tailwind-v4 wiring intact so Blocks RENDER. This script + * checks exactly that and nothing else. + * + * Given a generated app's `globals.css` it asserts the shadcn token names survive in + * BOTH `:root` and `.dark` and the Tailwind-v4 wiring (`@import 'tailwindcss'`, a + * non-empty `@theme inline` carrying the `--color-*: var(--*)` map, `@custom-variant + * dark`, ≥1 `@source`) is intact. A dropped/renamed shadcn name or broken wiring = + * ERROR. The Blocks on-ramp's UI-dist `@source` + UI `@import` are ADVISORY (they are + * legitimately absent before Blocks are installed). + * + * GENERIC BY CONSTRUCTION: it reasons only about token NAMES + wiring directives — no + * app/entity/flow/domain literal anywhere. + * + * Usage: + * node scripts/check-design.mjs --globals + * node scripts/check-design.mjs --app # resolves /src/app/globals.css + * + * Output: a single JSON object on stdout: + * { ok, source, mode:'blocks-contract', findings:[{rule,severity,msg}], counts:{error,warn,info} } + * + * Exit codes: + * 0 no ERROR findings (warnings/info allowed) + * 1 at least one ERROR finding (gate failed) + * 2 could not run (no/unreadable input, bad args) + * + * Zero dependencies. Pure Node (>=18). + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { OVERRIDE_SURFACE } from './lib/design/tokens.mjs'; + +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 globalsArg = ''; +let appArg = ''; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--globals') globalsArg = argv[++i] || ''; + else if (a === '--app') appArg = argv[++i] || ''; + 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, 40).join('\n')); + process.exit(0); + } else if (a.startsWith('--')) fail2(`unknown argument: ${a}`); + else fail2(`unexpected positional argument: ${a} (pass --globals or --app )`); +} + +if (!globalsArg && !appArg) { + fail2('no source given. Pass --globals or --app .'); +} + +const cssPath = globalsArg + ? resolve(process.cwd(), globalsArg) + : resolve(process.cwd(), appArg, 'src', 'app', 'globals.css'); +if (!existsSync(cssPath)) fail2(`globals.css not found: ${cssPath}`); + +let css; +try { + css = readFileSync(cssPath, 'utf8'); +} catch (e) { + fail2(`could not read ${cssPath}: ${e.message}`); +} + +const { findings } = validateBlocksContract(css); +const counts = { error: 0, warn: 0, info: 0 }; +for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; +const ok = counts.error === 0; +process.stdout.write( + JSON.stringify({ ok, source: cssPath, mode: 'blocks-contract', findings, counts }) + '\n' +); +process.exit(ok ? 0 : 1); + +/** + * validateBlocksContract(css) — the RAIL 2 hard validator. Given a generated app's + * `globals.css`, assert the shadcn-token contract + Tailwind-v4 wiring SURVIVE, so + * Blocks render. Returns { findings:[{rule,severity,msg}] }; the caller fails on any + * `error`. GENERIC — it reasons only about token NAMES + wiring directives. + * + * HARD (error): + * • every shadcn contract name DEFINED in BOTH the :root{} and .dark{} blocks + * • @import 'tailwindcss' + * • a non-empty @theme inline block carrying the --color-* mappings + * • @custom-variant dark + * • at least one @source + * ADVISORY (warn — legitimately absent pre-Blocks-on-ramp/S5): + * • @source ".../@constructive-io/ui/dist" + * • @import '@constructive-io/ui/globals.css' + */ +function validateBlocksContract(css) { + const findings = []; + const add = (rule, severity, msg) => findings.push({ rule, severity, msg }); + + // Extract ALL rule bodies whose selector matches (the contract is on the names + // being DEFINED somewhere in a :root / .dark block — there may be several, e.g. + // the boilerplate's base block + the generated override block). Brace-balanced + // scan. Returns the concatenation of every matched body (or null if none). + const ruleBodies = (selectorRe) => { + const re = new RegExp(selectorRe.source, 'g'); + const bodies = []; + let m; + while ((m = re.exec(css))) { + let i = css.indexOf('{', m.index + m[0].length - 1); + if (i < 0) continue; + let depth = 0; + const start = i; + for (; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { + depth--; + if (depth === 0) { + bodies.push(css.slice(start + 1, i)); + re.lastIndex = i + 1; + break; + } + } + } + } + return bodies.length ? bodies.join('\n') : null; + }; + // `:root` — but NOT `.dark .x:root`-style; a `:root` token whose preceding char is + // not a word/`.`/`#` (so we don't catch `:root` inside another selector segment). + const rootBody = ruleBodies(/(?:^|[\s,}])\:root\s*\{/m); + const darkBody = ruleBodies(/(?:^|[\s,}])\.dark\s*\{/m); + + if (rootBody == null) add('rail2-root-missing', 'error', 'no `:root { … }` block found — the shadcn light tokens have nowhere to live.'); + if (darkBody == null) add('rail2-dark-missing', 'error', 'no `.dark { … }` block found — the shadcn dark tokens have nowhere to live (Blocks render unstyled in dark mode).'); + + const definedIn = (body, bare) => body != null && new RegExp(`(^|[\\s;{])--${bare}\\s*:`).test(body); + + for (const bare of OVERRIDE_SURFACE) { + if (bare === 'radius') continue; // radius is a scalar, not a color token name + if (rootBody != null && !definedIn(rootBody, bare)) { + add('rail2-name-missing', 'error', `shadcn token --${bare} is not defined in :root — Blocks that read it (e.g. bg-${bare}) will render unstyled.`); + } + if (darkBody != null && !definedIn(darkBody, bare)) { + add('rail2-name-missing', 'error', `shadcn token --${bare} is not defined in .dark — Blocks render unstyled/broken in dark mode.`); + } + } + // --radius is part of the contract (drives --radius-* derivations); require it in :root. + if (rootBody != null && !definedIn(rootBody, 'radius')) { + add('rail2-name-missing', 'error', 'shadcn token --radius is not defined in :root — radius-derived utilities (rounded-*) break.'); + } + + // ── Tailwind-v4 wiring (hard) ── + if (!/@import\s+['"]tailwindcss['"]/.test(css)) { + add('rail2-wiring', 'error', "missing `@import 'tailwindcss'` — without it nothing generates."); + } + // @theme inline must be present AND non-empty AND carry the --color-* mappings. + const themeBody = ruleBodies(/@theme\s+inline\s*\{/); + if (themeBody == null || themeBody.trim() === '') { + add('rail2-wiring', 'error', 'missing or empty `@theme inline { … }` — the --color-* → var(--*) map that lets `bg-primary` etc. resolve.'); + } else if (!/--color-[a-z-]+\s*:\s*var\(--/.test(themeBody)) { + add('rail2-wiring', 'error', '`@theme inline` carries no `--color-*: var(--*)` mappings — shadcn utilities (bg-primary/text-muted-foreground) will not resolve.'); + } + if (!/@custom-variant\s+dark\b/.test(css)) { + add('rail2-wiring', 'error', 'missing `@custom-variant dark (…)` — `dark:` variants will not resolve against the .dark class.'); + } + if (!/@source\b/.test(css)) { + add('rail2-wiring', 'error', 'missing any `@source` directive — app/block source will not be scanned, so utilities will not generate.'); + } + + // ── Blocks on-ramp (advisory — absent pre-S5) ── + if (!/@source\s+['"][^'"]*@constructive-io\/ui\/dist/.test(css)) { + add('blocks-onramp', 'warn', "the Blocks on-ramp `@source \"…/@constructive-io/ui/dist\"` is absent — block utilities will not generate until wire-app (S5) adds it (OK if Blocks are not installed yet)."); + } + if (!/@import\s+['"]@constructive-io\/ui\/globals\.css['"]/.test(css)) { + add('blocks-onramp', 'warn', "the Blocks on-ramp `@import '@constructive-io/ui/globals.css'` is absent — brand tokens + base UI styles are added by wire-app (S5) (OK pre-install)."); + } + + return { findings }; +} diff --git a/.agents/skills/constructive-builder/scripts/genericity-check.sh b/.agents/skills/constructive-builder/scripts/genericity-check.sh index fe56333..4a1c42f 100755 --- a/.agents/skills/constructive-builder/scripts/genericity-check.sh +++ b/.agents/skills/constructive-builder/scripts/genericity-check.sh @@ -232,6 +232,91 @@ 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 ONE surviving design check stays GENERIC + correct as a standing canary, +# the same way the four app tiers above guard the build span. Post-pivot there is no +# compiler and no wire-design step: the design.md is the spec the AGENT authors the +# frontend from, and the only machine check is FUNCTIONAL — does a built globals.css +# satisfy the shadcn-token + Tailwind-v4 contract so Blocks render? This canary drives +# that validator directly (check-design.mjs --globals) on synthesized CSS, self-contained +# (a temp globals.css; no network, no DB, no fixtures): +# 1. a COMPLETE globals.css (all shadcn token names in :root + .dark + the Tailwind-v4 +# wiring) PASSES (exit 0) — the validator accepts a contract-satisfying app. +# 2. a BROKEN globals.css (one shadcn token name dropped) FAILS (exit 1) — the validator +# still catches the regression that would render Blocks unstyled. +# Skips gracefully (warn, never fail the canary) only if node or check-design.mjs is 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 check_design="$REPO_ROOT/scripts/check-design.mjs" + if [ ! -f "$check_design" ]; then + warn "design rot-canary: scripts/check-design.mjs missing — skipped (not failing)" + return 0 + fi + + # The canonical shadcn token contract (the 40 names; radius is a scalar handled separately). + # GENERIC: token NAMES only — no app/entity/flow/domain literal, no brand name. + local names=( + 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 + ) + + # Emit a contract-satisfying globals.css to $1, with the value $2 for every color token. + # Includes the Tailwind-v4 wiring the validator hard-requires: @import 'tailwindcss', a + # non-empty @theme inline carrying --color-*: var(--*) maps, @custom-variant dark, a @source. + _emit_good_globals() { + local out="$1" val="$2" n + { + echo "@import 'tailwindcss';" + echo '@custom-variant dark (&:is(.dark *));' + echo '@source "../";' + echo ':root {' + for n in "${names[@]}"; do echo " --${n}: ${val};"; done + echo ' --radius: 0.5rem;' + echo '}' + echo '.dark {' + for n in "${names[@]}"; do echo " --${n}: ${val};"; done + echo '}' + echo '@theme inline {' + for n in "${names[@]}"; do echo " --color-${n}: var(--${n});"; done + echo ' --radius-md: var(--radius);' + echo '}' + } > "$out" + } + + local tmp; tmp="$(mktemp -d)" + + # 1) a COMPLETE globals.css must PASS the validator (exit 0). + _emit_good_globals "$tmp/good.css" "oklch(0.5 0.05 250)" + if node "$check_design" --globals "$tmp/good.css" >/dev/null 2>&1; then + pass "design rot-canary: a complete globals.css PASSES the Blocks token-contract validator (exit 0)" + else + rm -rf "$tmp" + fail "design rot-canary: a complete globals.css was REJECTED by check-design.mjs --globals" "the Blocks-contract validator (check-design.mjs --globals) no longer accepts a contract-satisfying globals.css — it should pass when every shadcn token name is in :root + .dark and the @theme inline/@custom-variant dark/@source wiring is present. Fix scripts/check-design.mjs." + fi + + # 2) a BROKEN globals.css (one shadcn token name dropped from :root) must FAIL (exit 1). + _emit_good_globals "$tmp/broken.css" "oklch(0.5 0.05 250)" + # Drop the FIRST occurrence of --primary: (the :root declaration) so a contract name is missing. + awk 'BEGIN{done=0} /^[[:space:]]*--primary:/ && !done {done=1; next} {print}' "$tmp/broken.css" > "$tmp/broken.css.tmp" && mv "$tmp/broken.css.tmp" "$tmp/broken.css" + local bstatus=0 + node "$check_design" --globals "$tmp/broken.css" >/dev/null 2>&1 || bstatus="$?" + if [ "$bstatus" -eq 1 ]; then + pass "design rot-canary: a globals.css missing a shadcn token name is REJECTED by the validator (exit 1)" + rm -rf "$tmp" + else + rm -rf "$tmp" + fail "design rot-canary: a broken globals.css (dropped shadcn token name) was NOT rejected (got exit $bstatus, expected 1)" "the Blocks-contract validator must HARD-FAIL (exit 1, rail2-name-missing) when a shadcn token name is missing from :root/.dark — a build that drops one would otherwise ship unstyled Blocks. Fix scripts/check-design.mjs." + fi + unset -f _emit_good_globals +} +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..2000b7a 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-guide.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-guide.md / 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/blocks-contract.test.mjs b/.agents/skills/constructive-builder/scripts/lib/design/blocks-contract.test.mjs new file mode 100644 index 0000000..70f6287 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/blocks-contract.test.mjs @@ -0,0 +1,125 @@ +/** node --test scripts/lib/design/blocks-contract.test.mjs + * + * RAIL 2 — the Blocks-contract validator (the ONE style-side hard rail). Drives the + * STANDALONE CLI (`scripts/check-design.mjs --globals `) so the exact gate a + * build runs is what we assert. A clean globals.css PASSES (exit 0); a globals.css + * that drops a shadcn name OR breaks the Tailwind-v4 wiring FAILS (exit 1, error + * finding). + * + * GENERIC: the fixture css is synthesized here from the contract NAMES — no app / + * entity / domain literal, no dependency on a checked-in app. The contract names come + * from the tiny zero-dep tokens module (the only thing the validator now imports). + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { OVERRIDE_SURFACE } from './tokens.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CHECK = resolve(HERE, '..', '..', 'check-design.mjs'); + +// Build a minimal-but-COMPLETE globals.css that satisfies RAIL 2: every shadcn +// contract name in both :root and .dark + the Tailwind-v4 wiring. +function goodCss() { + const names = [...OVERRIDE_SURFACE].filter((n) => n !== 'radius'); + const decls = (val) => names.map((n) => ` --${n}: ${val};`).join('\n') + '\n --radius: 0.5rem;'; + const colorMap = names.map((n) => ` --color-${n}: var(--${n});`).join('\n'); + return [ + "@import 'tailwindcss';", + "@import 'tw-animate-css';", + '@source "../";', + '@custom-variant dark (&:is(.dark *));', + ':root {', + decls('oklch(0.5 0.05 250)'), + '}', + '.dark {', + decls('oklch(0.3 0.05 250)'), + '}', + '@theme inline {', + colorMap, + ' --radius-md: var(--radius);', + '}', + ].join('\n'); +} + +function runValidator(css) { + const dir = mkdtempSync(join(tmpdir(), 'rail2-')); + const file = join(dir, 'globals.css'); + writeFileSync(file, css, 'utf8'); + try { + const res = spawnSync(process.execPath, [CHECK, '--globals', file], { encoding: 'utf8' }); + const json = JSON.parse(res.stdout.trim()); + return { exit: res.status, json }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('a clean globals.css PASSES the Blocks-contract validator (exit 0)', () => { + const { exit, json } = runValidator(goodCss()); + assert.equal(exit, 0, `expected pass, findings=${JSON.stringify(json.findings)}`); + assert.equal(json.ok, true); + assert.equal(json.mode, 'blocks-contract'); + assert.equal(json.counts.error, 0); + // on-ramp UI-dist @source / UI @import absent → advisory only (warn), not fail. + assert.ok(json.findings.every((f) => f.severity !== 'error')); +}); + +test('dropping a shadcn name from :root HARD-FAILS (exit 1, rail2-name-missing)', () => { + const css = goodCss().replace(/^ --primary: oklch[^\n]*\n/m, ''); // remove from FIRST (:root) block + const { exit, json } = runValidator(css); + assert.equal(exit, 1); + assert.equal(json.ok, false); + assert.ok(json.findings.some((f) => f.rule === 'rail2-name-missing' && f.severity === 'error')); +}); + +test('breaking @theme inline HARD-FAILS (exit 1, rail2-wiring)', () => { + const css = goodCss().replace('@theme inline {', '@theme-OOPS inline {'); + const { exit, json } = runValidator(css); + assert.equal(exit, 1); + assert.ok(json.findings.some((f) => f.rule === 'rail2-wiring' && f.severity === 'error')); +}); + +test('removing @custom-variant dark HARD-FAILS (exit 1, rail2-wiring)', () => { + const css = goodCss().replace(/@custom-variant dark[^\n]*\n/, ''); + const { exit, json } = runValidator(css); + assert.equal(exit, 1); + assert.ok(json.findings.some((f) => f.rule === 'rail2-wiring' && f.severity === 'error')); +}); + +test('removing every @source HARD-FAILS (exit 1, rail2-wiring)', () => { + const css = goodCss().replace(/@source[^\n]*\n/g, ''); + const { exit, json } = runValidator(css); + assert.equal(exit, 1); + assert.ok(json.findings.some((f) => f.rule === 'rail2-wiring' && f.severity === 'error')); +}); + +test('a missing .dark block HARD-FAILS (Blocks break in dark mode)', () => { + // strip the .dark{...} rule entirely + const css = goodCss().replace(/\.dark \{[\s\S]*?\n\}\n/, ''); + const { exit, json } = runValidator(css); + assert.equal(exit, 1); + assert.ok(json.findings.some((f) => f.rule === 'rail2-dark-missing' || f.rule === 'rail2-name-missing')); +}); + +test('a bad/missing input exits 2 (could-not-run)', () => { + const res = spawnSync(process.execPath, [CHECK, '--globals', join(tmpdir(), 'does-not-exist-rail2.css')], { + encoding: 'utf8', + }); + assert.equal(res.status, 2); + const json = JSON.parse(res.stdout.trim()); + assert.equal(json.ok, false); +}); + +test('the on-ramp UI @source/@import, when PRESENT, clear the advisory warnings', () => { + const css = + goodCss().replace('@source "../";', '@source "../";\n@source "../../node_modules/@constructive-io/ui/dist";') + + "\n@import '@constructive-io/ui/globals.css';"; + const { exit, json } = runValidator(css); + assert.equal(exit, 0); + assert.ok(!json.findings.some((f) => f.rule === 'blocks-onramp'), JSON.stringify(json.findings)); +}); 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..7f150a4 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/design-md.mjs @@ -0,0 +1,135 @@ +/** + * scripts/lib/design/design-md.mjs — PARSE-ONLY reader for a Google-Labs-style + * `design.md`: a Markdown document with a YAML frontmatter block delimited by + * `---` fences, followed by free prose. + * + * Sole consumer is scaffold-frontend.mjs, which calls parseDesignMd to read the + * LAYOUT-DENSITY dial out of an emitted design.md's frontmatter. The frontmatter is + * parsed with the skill's EXISTING zero-dep YAML reader (`parseBrief` from + * ../brief-yaml.mjs) — NO new dependency — via the quoteUnquotedCssFunctions helper + * that tolerates hand-authored UNQUOTED `oklch()`/`rgb()`/`color-mix()` values, so + * the density read never trips over a design.md's color tokens. + * + * ZERO-DEP. Node >=18 ESM. Pure functions. There is no serializer: the design.md is + * authored by the agent, never machine-emitted, so this module only reads. + */ + +import { parseBrief } from '../brief-yaml.mjs'; + +/** + * The CSS color/value FUNCTIONS a hand-authored `design.md` is allowed to write + * UNQUOTED. Their argument lists contain spaces, parens, and (for `color-mix`) + * inner commas + an `in ` token with no `:` — all of which the zero-dep + * YAML reader would otherwise mis-tokenize (a flow `{…}`/`[…]` would split on the + * inner comma; a bare scalar after a `:` is fine but a value INSIDE a flow map is + * not). We pre-quote these verbatim so the reader sees one opaque scalar. + */ +const CSS_FN = /^(?:oklch|oklab|lch|lab|rgba?|hsla?|hwb|color-mix|color|var|calc|min|max|clamp|linear-gradient|radial-gradient|conic-gradient)$/i; + +/** + * Walk a frontmatter YAML string and wrap every UNQUOTED CSS-function value + * (`oklch(…)`, `rgb(…)`, `color-mix(…)`, …) in double quotes, preserving the + * function text verbatim. Quoting is skipped inside existing `'…'`/`"…"` strings + * and for already-quoted values, so this is idempotent and safe to run on the + * shipped (already-quoted) presets. Balanced parens are tracked so a nested call + * like `color-mix(in oklch, oklch(…) 50%, white)` is captured as ONE token. + */ +function quoteUnquotedCssFunctions(yamlText) { + let out = ''; + let i = 0; + const n = yamlText.length; + // A function name must start at a token boundary: line start, or after one of + // these scalar-context delimiters (`:` `,` `[` `{` whitespace `>` `-`). + const isBoundary = (ch) => ch === undefined || /[\s:,[{(>-]/.test(ch); + while (i < n) { + const c = yamlText[i]; + // Pass quoted strings (and their content) through untouched. + if (c === '"' || c === "'") { + const q = c; + out += c; + i++; + while (i < n) { + out += yamlText[i]; + if (yamlText[i] === q && yamlText[i - 1] !== '\\') { i++; break; } + i++; + } + continue; + } + // A `#` outside quotes starts a comment to end-of-line — copy it verbatim. + if (c === '#') { + const eol = yamlText.indexOf('\n', i); + const end = eol === -1 ? n : eol; + out += yamlText.slice(i, end); + i = end; + continue; + } + // Try to match a CSS function name token at a boundary. + if (/[A-Za-z]/.test(c) && isBoundary(yamlText[i - 1])) { + let j = i; + while (j < n && /[A-Za-z-]/.test(yamlText[j])) j++; + const name = yamlText.slice(i, j); + if (CSS_FN.test(name) && yamlText[j] === '(') { + // Capture the balanced (...) group. + let depth = 0; + let k = j; + for (; k < n; k++) { + const ch = yamlText[k]; + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) { k++; break; } } + } + if (depth === 0) { + // Extend across trailing space-separated modifiers that belong to the + // same value (e.g. `oklch(…) 50%`) up to a top-level `,` `]` `}` `\n` — + // OR a ` #` comment start (which the reader strips), so a trailing + // comment is NOT swallowed into the quoted value. + while (k < n && !/[,\]}\n]/.test(yamlText[k])) { + if (yamlText[k] === '#' && /\s/.test(yamlText[k - 1])) break; + if (yamlText[k] === '(') { let d = 1; k++; while (k < n && d > 0) { if (yamlText[k] === '(') d++; else if (yamlText[k] === ')') d--; k++; } } + else k++; + } + const value = yamlText.slice(i, k).replace(/\s+$/, ''); + const trailing = yamlText.slice(i + value.length, k); + out += `"${value.replace(/"/g, '\\"')}"${trailing}`; + i = k; + continue; + } + } + } + out += c; + i++; + } + return out; +} + +/** + * 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(); + // Tolerate unquoted CSS-function values (`primary: oklch(0.55 0.34 280)`, + // `rgb(10, 20, 30)`, `color-mix(in oklch, …)`) by pre-quoting them verbatim so + // the zero-dep reader never splits on a value's internal space/paren/comma. + const frontmatter = + yamlText.trim() === '' ? {} : parseBrief(quoteUnquotedCssFunctions(yamlText)); + return { frontmatter: frontmatter || {}, prose }; +} diff --git a/.agents/skills/constructive-builder/scripts/lib/design/tokens.mjs b/.agents/skills/constructive-builder/scripts/lib/design/tokens.mjs new file mode 100644 index 0000000..1b94276 --- /dev/null +++ b/.agents/skills/constructive-builder/scripts/lib/design/tokens.mjs @@ -0,0 +1,61 @@ +/** + * tokens.mjs — the shadcn-token CONTRACT (RAIL 2), as a tiny zero-dep module. + * + * `OVERRIDE_SURFACE` is the set of token NAMES a generated app's globals.css MUST + * carry so Blocks render: every name here is asserted present in BOTH the `:root` + * (light) and `.dark` blocks by the Blocks-contract validator (check-design.mjs). + * `radius` is a scalar (drives the `--radius-*` derivations) and is required in + * `:root` only. + * + * This is the single source of the contract: it replaces the constant that used to + * live in the (now-removed) design compiler. GENERIC BY CONSTRUCTION — these are + * color ROLE names + one scalar, never an app/entity/flow/domain literal. + * + * Zero dependencies. Pure Node (>=18). + */ + +/* The 40 canonical shadcn token names (39 color roles + the `radius` scalar). */ +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', +]); + +/* Back-compat alias: consumers that referred to the contract as CONTRACT_NAMES. */ +export const CONTRACT_NAMES = OVERRIDE_SURFACE; 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..5458522 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 @@ -1,6 +1,12 @@ /** * scripts/lib/scaffold-frontend/entity-page.mjs — the per-entity domain UI emitter. * + * Emits the FUNCTIONAL SKELETON the agent then AUTHORS the presentation from (see + * scripts/templates/frontend/entity-page.tsx PRESENTATION SEAM + references/design-guide.md): + * the data wiring, the testids, the four states, the row-scoping, and the RLS scoping are + * the working contract; the default look is a neutral, replaceable starting point — NOT the + * final UI. The frontend phase = "scaffold this skeleton, THEN author the design.md faithfully". + * * emitEntityPage (step b) stamps a thin app//page.tsx from entity-page.tsx, wiring the * codegen'd SDK hooks + DynamicFormCard and splicing in the policy scoping, * belongs-to FK, and N:M relation-manager seams. EVERY seam collapses to the @@ -40,6 +46,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 +137,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 +156,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 +213,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 +251,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 +266,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'; @@ -183,23 +286,32 @@ export function emitStubPage(srcDir, route, ctx) { /** * ${route.path || '/'} — ${label} (kind: ${kind}). * - * STUB emitted by scripts/scaffold-frontend.mjs for a non-CRUD route. The generic - * CRUD path (typed list + DynamicFormCard) only covers \`kind: crud\`; richer - * surfaces are yours to build. + * STUB emitted by scripts/scaffold-frontend.mjs for a non-CRUD route — a SKELETON to + * AUTHOR FROM, not a finished page. The generic CRUD path (typed list + DynamicFormCard) + * only covers \`kind: crud\`; this richer surface is yours to author from the design.md. + * + * // TODO: AUTHOR this surface — build with @constructive-io/ui; see constructive-frontend + * // (CRUD Stack cards, meta-forms, the 50+ Base UI components) and compose Blocks as + * // ingredients. For a read list use the typed @sdk/app hooks directly; for create/edit + * // reuse DynamicFormCard from @/components/crud/dynamic-form-card. * - * // TODO: custom UI — build with @constructive-io/ui; see constructive-frontend - * // (CRUD Stack cards, meta-forms, the 50+ Base UI components). For a read list - * // use the typed @sdk/app hooks directly; for create/edit reuse DynamicFormCard - * // from @/components/crud/dynamic-form-card. + * PRESENTATION SEAM — AUTHOR THE UI FROM HERE per the app's design.md (see + * references/design-guide.md): customize/replace stock components, set the type, compose + * the layout, add intentional hierarchy/spacing/ornament + subtle (reduced-motion) motion. + * PRESERVE (RAIL 1) the functional contract for whatever you mount: any -* testids, + * row-scoping, the hooks/selection/refetch/Stack-pushes, and the scoping const(s); and + * (RAIL 2) the shadcn token names the components read. Restyle freely — don't hide a control. */ 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..01c9bb5 100644 --- a/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh +++ b/.agents/skills/constructive-builder/scripts/lib/verify-gates.sh @@ -351,6 +351,71 @@ check_harness_drift() { fi } +# Additive DESIGN subsystem gate. Post-pivot the design COMPILER is gone: the design.md is the +# full spec the AGENT authors the frontend from, Blocks compose, and the ONLY surviving machine +# check is FUNCTIONAL — does the BUILT app's globals.css still satisfy the shadcn-token + +# Tailwind-v4 contract so Blocks render? Two independent checks, each self-disabling: +# (A) ROT-CANARY — run the surviving Blocks token-contract validator's own test +# (scripts/lib/design/blocks-contract.test.mjs) so the validator's pass/fail behaviour +# (every shadcn name in :root + .dark, the @theme inline / @custom-variant / @source +# wiring) cannot rot unnoticed. It ships in the skill, so it runs whenever this gate +# fires — NO app needed. Self-disables only if that test is absent or `node` can't run it. +# (B) FUNCTIONAL BLOCKS-CONTRACT GATE — once the frontend exists, validate the BUILT +# /src/app/globals.css with `check-design.mjs --app ` (RAIL 2): a dropped / +# renamed shadcn token name or broken Tailwind-v4 wiring = ERROR, because Blocks would then +# render unstyled. This is what the agent authors TOWARD, not a lint of any design.md. +# No app / no built globals.css yet → (B) is a clean no-op (pre-frontend phases). Wired into +# Phase 1 (the canary, app-independent) and Phase 2.4 (the functional --app check, post-frontend). +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 rot-canary — the ONE surviving design test: the Blocks token-contract validator's + # own test (the compiler + its suite are deleted; this is all that remains). + local canary_test="$REPO_ROOT/scripts/lib/design/blocks-contract.test.mjs" + if [ -f "$canary_test" ]; then + local out status=0 + out="/tmp/check-design-tests.$$" + node --test "$canary_test" >"$out" 2>&1 || status="$?" + if [ "$status" -eq 0 ]; then + pass "Design: Blocks token-contract validator green (the shadcn-name + Tailwind-v4 wiring contract holds)" + rm -f "$out" + else + tail -n 40 "$out" 2>/dev/null | sed 's/^/ /' || true + rm -f "$out" + fail "Design: the Blocks token-contract validator test FAILED (scripts/lib/design/blocks-contract.test.mjs)" "The Blocks-contract validator (check-design.mjs --globals) changed behaviour — it no longer PASSES a complete globals.css or no longer FAILS a broken one (a dropped shadcn token name, or missing @theme inline / @custom-variant dark / @source wiring). See the failing assertion above; fix scripts/check-design.mjs (or the test fixture) until 'node --test scripts/lib/design/blocks-contract.test.mjs' is green." + fi + fi + + # (B) FUNCTIONAL gate — validate the BUILT app's globals.css against the Blocks contract. + # No app / no built globals.css yet (pre-frontend phases) → clean no-op (return 0); the + # pre-check below means the checker is only invoked once there is something to validate, + # so its own exit-2 "globals.css not found" never reaches us here. + local checker="$REPO_ROOT/scripts/check-design.mjs" + [ -f "$checker" ] || return 0 + local app_root globals_css + app_root="$(workspace_path "$(app_rel)")" + [ -d "$app_root" ] || return 0 + globals_css="$app_root/src/app/globals.css" + [ -f "$globals_css" ] || return 0 + + echo " INFO: Design gate — validating the BUILT app globals.css against the Blocks token contract ($globals_css)" + local dout dstatus=0 + dout="/tmp/check-design-globals.$$" + node "$checker" --app "$app_root" >"$dout" 2>&1 || dstatus="$?" + if [ "$dstatus" -eq 0 ]; then + pass "Design: built app globals.css satisfies the shadcn-token + Tailwind-v4 contract — Blocks render (check-design.mjs --app 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 $globals_css (exit 2) — skipped (not failing)" + else + cat "$dout" 2>/dev/null || true + rm -f "$dout" + fail "Design: built app globals.css fails the Blocks token contract (check-design.mjs --app, exit $dstatus)" "A shadcn token name or the @theme inline/@custom-variant/@source wiring is missing from the built globals.css — Blocks will render unstyled; restore the token contract in src/app/globals.css. See the finding(s) above and re-run 'node scripts/check-design.mjs --app ' 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..0f9179f 100644 --- a/.agents/skills/constructive-builder/scripts/scaffold-app.mjs +++ b/.agents/skills/constructive-builder/scripts/scaffold-app.mjs @@ -14,7 +14,7 @@ * └────────────────────────────────────────────────────────────────────────┘ * ┌─ PHASE 3 (Wire + Codegen) ─ wire-app.mjs + graphql-codegen ──────────────┐ * │ NOT done here. The build wires env/providers (scripts/wire-app.mjs) and │ - * │ runs codegen so the typed @sdk/app hooks (useTodosQuery, …) exist. │ + * │ runs codegen so the typed @sdk/app hooks exist. │ * └────────────────────────────────────────────────────────────────────────┘ * ┌─ PHASE 4 (Frontend) ─ scaffold-frontend.mjs ─────────────────────────────┐ * │ brief → per-entity CRUD pages + CRUD infra + routes/nav. REQUIRES the │ @@ -22,6 +22,11 @@ * │ + wire-app), NOT this generator. │ * └────────────────────────────────────────────────────────────────────────┘ * + * The presentation (theme) is NOT a pass here: the agent hand-authors the app's + * globals.css + frontend from its design.md (guided by references/design-guide.md + + * references/examples/), and the only surviving machine check on it is the FUNCTIONAL + * Blocks-token gate in check-design.mjs — there is no theme-compiler step to sequence. + * * So: `--phase provision` runs at Phase 2; `--phase frontend` at Phase 4; * `--phase all` (default) runs BOTH back-to-back — only correct in a re-run when * the app scaffold + codegen ALREADY exist (e.g. regenerating after a brief edit). @@ -99,7 +104,7 @@ 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)) { diff --git a/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs b/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs index 6e6bf52..a064252 100644 --- a/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs +++ b/.agents/skills/constructive-builder/scripts/scaffold-frontend.mjs @@ -2,8 +2,18 @@ /** * scripts/scaffold-frontend.mjs * - * Brief → the per-entity domain UI, AFTER Phase-3 codegen has produced the typed - * SDK hooks (@sdk/app). Runs at PHASE 4 (see scaffold-app.mjs for the staging). + * Brief → the per-entity domain UI as a WORKING SKELETON TO AUTHOR FROM, AFTER Phase-3 + * codegen has produced the typed SDK hooks (@sdk/app). Runs at PHASE 4 (see scaffold-app.mjs + * for the staging). + * + * SKELETON, NOT FINAL UI. Everything below emits the FUNCTIONAL contract — the data wiring, + * the testids, the four list states, row-scoping, the RLS scoping, and the Blocks mounts — + * correct and working, so the app FUNCTIONS and composes with Blocks. The DEFAULT presentation + * is a neutral, replaceable starting point. The frontend phase is then: AUTHOR the presentation + * faithfully from the app's design.md (customize/replace stock components, set the type, compose + * the layout, intentional hierarchy/spacing/ornament, subtle + reduced-motion). The ONLY hard + * rails are (1) this FUNCTIONAL contract and (2) the shadcn-token contract (Blocks read tokens by + * name). See references/design-guide.md for the authoring playbook + the full preserve list. * * It does SIX things, each independently idempotent (re-running is a safe no-op): * (a) CRUD INFRA (once) — stamps the runtime-generic meta-form stack from @@ -96,6 +106,7 @@ import { emitEntityPage, emitStubPage, tableFor } from './lib/scaffold-frontend/ import { appendRoute, appendNavItem } from './lib/scaffold-frontend/routes-nav.mjs'; import { emitAuthPages } from './lib/scaffold-frontend/auth-pages.mjs'; import { emitFlowSurfaces } from './lib/scaffold-frontend/flow-surfaces.mjs'; +import { parseDesignMd } from './lib/design/design-md.mjs'; // ════════════════════════════════════════════════════════════════════════════ // App-dir detection — mirror verify-phase.sh app_rel(): the app may live at @@ -118,6 +129,56 @@ function resolveAppSrc(appDir) { return path.join(appDir, 'packages', 'app', 'src'); } +// ════════════════════════════════════════════════════════════════════════════ +// DENSITY RESOLUTION (generic, robust to WHERE the agent recorded the dial). +// +// The DENSITY dial drives LAYOUT density (see entity-page.mjs DENSITY_SCALES). The +// canonical home for the dials is the brief: `brief.design.dials.density`. But an +// auto-propose agent may instead record the dials in the EMITTED design.md (the +// durable, lint-gated design record) under its frontmatter `dials:` map. Either home +// must "just work". So density resolves in this order: +// 1. brief.design.dials.density (the canonical, single source of truth) +// 2. .dials.density (fallback — read straight from a design.md +// sitting next to the app, if the agent put one there) +// Anything else ⇒ undefined ⇒ entity-page.mjs defaults to the 'cozy' tier (the +// pre-wave literals) so a design-less build is byte-identical. GENERIC: density is a +// single integer/tier name — no entity/app literal is ever read here. +// ════════════════════════════════════════════════════════════════════════════ + +/** Discover a design.md the agent authored next to the app and return its parsed + * frontmatter object (so the density dial can be read from it), or null when none + * is found. This density discovery is self-standing — it is the only reason this + * scaffolder reads a design.md at all. */ +function discoverDesignMdFrontmatter(appDir) { + const candidates = [ + path.join(appDir, 'design.md'), + path.join(appDir, '..', 'design.md'), + path.join(appDir, 'app', 'design.md'), + path.join(appDir, 'packages', 'app', 'design.md'), + ]; + for (const cand of candidates) { + if (!fs.existsSync(cand)) continue; + try { + return parseDesignMd(fs.readFileSync(cand, 'utf8')).frontmatter || null; + } catch { + // A malformed design.md is not fatal for scaffolding — fall through to the + // 'cozy' default rather than abort the whole frontend emit. + return null; + } + } + return null; +} + +/** Resolve the DENSITY dial from the brief, falling back to an emitted design.md's + * `dials.density`. Returns the raw dial (number | tier string) or undefined; the + * page emitters clamp/default it (resolveDensity). GENERIC — no entity input. */ +function resolveDensityDial(brief, appDir) { + const fromBrief = brief.design?.dials?.density; + if (fromBrief != null) return fromBrief; + const fm = discoverDesignMdFrontmatter(appDir); + return fm?.dials?.density; +} + // ════════════════════════════════════════════════════════════════════════════ // (a) CRUD infra — the runtime-generic meta-form stack // ════════════════════════════════════════════════════════════════════════════ @@ -224,6 +285,16 @@ function main() { const srcDir = resolveAppSrc(appDir); const ctx = { dryRun, written: [], skipped: [], warnings: [] }; + // DESIGN DENSITY (generic, dial-driven). The DENSITY dial (1–10) sets the spacing/padding/ + // rhythm scale the generated CRUD + stub pages bake into their Tailwind classes at emit time. + // It is resolved from the canonical brief.design.dials.density, OR (fallback) from an emitted + // design.md's `dials.density` next to the app — so density "just works" regardless of which home + // an auto-propose agent recorded the dials in. 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. See resolveDensityDial() for the resolution order. + const density = resolveDensityDial(brief, appDir); + const routes = brief.ui?.routes ?? []; const crudRoutes = routes.filter((r) => (r.kind || 'crud') === 'crud'); @@ -248,11 +319,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/auth-page.tsx b/.agents/skills/constructive-builder/scripts/templates/frontend/auth-page.tsx index 59542cc..9ee3169 100644 --- a/.agents/skills/constructive-builder/scripts/templates/frontend/auth-page.tsx +++ b/.agents/skills/constructive-builder/scripts/templates/frontend/auth-page.tsx @@ -17,6 +17,14 @@ * bounces the protected app shell back to `/` (the live-QA `authed-shell` timeout). * These wrappers ARE that block→route + host-token-persist bridge. * + * AUTHOR THE PRESENTATION. The block (SignInCard / SignUpCard) is an INGREDIENT; the + *
wrapper below is a neutral default — author the sign-in/up surface from the app's + * design.md (the masthead, the layout, type, any split/brand panel), per + * references/design-guide.md. PRESERVE the contract: the route paths (/sign-in, /sign-up, + * outside the shell), the block mount, the token-persist bridge (justAuthenticated latch → + * TokenManager.setToken('admin') + setAuthenticated → router.push), and any social-btn-* / + * cross-origin testids the flow add-ons seam in. Restyle freely; don't drop the bridge. + * * HOST-SIDE TOKEN PERSISTENCE (the two-auth-store fix): the blocks delegate token * persistence to the host's onSuccess (they only return the result). We mirror the * template's native useLogin: 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..9a155f1 100644 --- a/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx +++ b/.agents/skills/constructive-builder/scripts/templates/frontend/entity-page.tsx @@ -1,10 +1,22 @@ /** - * entity-page.tsx — parameterized per-entity CRUD page. + * entity-page.tsx — parameterized per-entity CRUD page (a WORKING SKELETON to AUTHOR FROM). * * ────────────────────────────────────────────────────────────────────────── * TEMPLATE: stamped per CRUD route by scripts/scaffold-frontend.mjs to * /src/app//page.tsx (one file per `ui.routes[].kind: crud` entry). * + * WHAT THIS IS — and IS NOT. This is the FUNCTIONAL SKELETON: the data wiring, the + * testids, the four list states, the row-scoping, and the RLS scoping the app needs to + * WORK and to compose with Blocks. It is NOT the finished UI. The default presentation + * below (a card + a divided list, neutral shadcn surfaces) is a sane, replaceable + * starting point — NOT a prescription. The frontend phase is "scaffold this skeleton, + * THEN AUTHOR the presentation faithfully from the app's design.md" (customize/replace + * the stock components, set the type/scale/weights, compose the layout, add intentional + * hierarchy/spacing/ornament). AUTHOR everything below the PRESENTATION SEAM; the ONLY + * hard rails are (1) the FUNCTIONAL contract (the testids/hooks/states/scoping/mounts + * called out at the seam) and (2) the shadcn-token contract (Blocks read tokens by + * name). See references/design-guide.md for the authoring playbook + the preserve list. + * * Two complementary CRUD paths: * • QUICK-ADD + list — the typed, codegen'd SDK hooks (use__Entities__Query / * use__Create_Entity__Mutation). A single-submit create + a typed list. This is @@ -21,6 +33,30 @@ * `entity=todo` makes the canary's `todo-*` testids + single-submit create fall out * with zero special-casing. * + * THE FOUR LIST STATES (a FUNCTIONAL invariant — emit all four for EVERY entity; their + * LOOK is the design.md's call — author each one, don't ship the stock shape as final): + * • LOADING — a skeleton wrapped in data-testid="__entity__-loading". Author it to + * MIRROR your authored list shape so the first paint has no layout jump. Each skeleton + * carries data-slot="skeleton" so the boilerplate's prefers-reduced-motion rule stills + * its pulse (keep that when you restyle). + * • ERROR — data-testid="__entity__-error", role="alert", with the query message + a + * Retry (data-testid="__entity__-retry") that re-runs the query. The testid MUST NOT + * end in "-empty" (the live-QA driver reserves [data-testid$="-empty"] for the empty + * state) — that constraint is functional; the panel's design is yours. + * • EMPTY — data-testid="__ENTITIES_EMPTY_TESTID__" (the kebab PLURAL + "-empty"), + * inviting the first create. Author the invitation; keep the testid. + * • DATA — the rows. Each repeating record carries data-testid="__entity__-row", CONTAINS + * the row's title text, and scopes its own edit/delete inside it. Compose the list as + * the design.md dictates (rows / table / cards / board / split-pane — see + * design-guide.md). data-slot="content-fade-in" gives a reduced-motion-honored fade. + * + * 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 +68,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 +126,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 +140,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 +150,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__ @@ -196,16 +234,44 @@ export default function __Entities__Page() { }); } + // ╔══════════════════════════════════════════════════════════════════════════════════╗ + // ║ PRESENTATION SEAM — AUTHOR THE UI FROM HERE. ║ + // ╚══════════════════════════════════════════════════════════════════════════════════╝ + // Everything ABOVE this line is the FUNCTIONAL skeleton (data hooks + handlers) — keep it. + // Everything in the return(...) BELOW is a neutral DEFAULT, not the final design: AUTHOR it + // faithfully from the app's design.md — customize/replace the stock shadcn components, set the + // type (the design.md's fonts/scale/weights), compose the layout (rows → data-table / gallery / + // split-pane / editorial / board), establish real hierarchy + spacing rhythm + intentional + // ornament, add subtle motion (honor prefers-reduced-motion). Blocks compose as INGREDIENTS. + // See references/design-guide.md for the authoring playbook. + // + // The ONLY things you may NOT remove/rename/hide while authoring (RAIL 1 — the functional + // contract; the gates + live-QA assert these by testid/role only, never by look): + // • the -* testids: title-input · create-submit · details · row (MUST contain the + // row's title text) · edit · delete · loading · error (role="alert", NOT "-empty") · retry; + // and -empty; + // • row-scoping: each row's edit/delete live INSIDE its -row; + // • the data wiring: the hooks + selection.fields + refetch + the three useCardStack pushes + // (openEdit / openDetailedCreate / openDelete); + // • the policy scoping const(s) (ownerId / activeOrgId / validFrom) spread into the create. + // And RAIL 2 — the shadcn-token contract: components read tokens by NAME (bg-primary, + // text-muted-foreground, border-border, …). Restyle them, add your own; don't break the names. + // Restyle and re-compose freely — just never HIDE a contract control (display:none / 0×0 fails). return ( -
-

__ENTITY_LABEL__

+
+
+

__ENTITY_LABEL__

+

+ Create, edit, and manage your __ENTITY_LABEL_LOWER__. +

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

Loading…

+ // LOADING (default to author) — a skeleton that mirrors the list shape so the first + // paint has no layout jump. Re-author it to match your authored list. KEEP the + // data-testid="__entity__-loading" wrapper + data-slot="skeleton" (reduced-motion). +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ) : isError ? ( + // ERROR (default to author) — the query message + a Retry. KEEP data-testid="__entity__-error" + // (role="alert", and NOT a "-empty" suffix, which the live-QA driver reserves for the empty + // state) + the __entity__-retry control. Design the panel however the design.md dictates. +
+

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 (default to author) — invite the first create. KEEP the kebab-PLURAL "-empty" + // testid (the driver's empty-state sentinel). data-slot="content-fade-in" = reduced-motion fade. +
+

No __ENTITY_LABEL_LOWER__ yet

+

+ Add your first one with the form above. +

+
) : ( -
    + // DATA (default to author) — the rows. Compose this however the design.md dictates + // (list / data-table / cards / board / split-pane). KEEP each repeating record as an + // -row that CONTAINS its title text and scopes its own edit/delete inside it. +
      {rows.map((row) => (
    • - {row.__TITLE_FIELD__ ?? row.id} + {row.__TITLE_FIELD__ ?? row.id}