From 82262294340e2b31b92dff72745e250d0d02cef1 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 19:50:53 +0100 Subject: [PATCH 1/4] Freeze the slice E build contract --- docs/specs/PHASE-30-SPEC.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index b3250eb..d2f3dc8 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -314,6 +314,18 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - Six mobile realisations in `src/structures.ts`, referencing type roles and $tokens only. - `test-patterns.ts` (`BAR = 95` at :14, five themes at :18; it runs fast mode only today) extended per FR-E4 with a detailed-mode and stress pass in the full tier; `test-discoverability.ts:75-83` extended to anatomies and surfaces. +- **Build contract (slice E, frozen before build).** Pins what the FR rows leave open. Where it changes an FR, it says so. No pre-freeze verifier: no rule in this slice has a verdict that depends on prior runs or stored state (resolution is a pure function of the name, the registry and the canvas type). + - *Fields.* `Structure.anatomy?: string` (default `name`) and `Structure.surface?: 'web' | 'mobile' | 'any'` (default `web`). `structureAnatomy(s)` and `structureSurface(s)` in `src/structures.ts` are the only readers of the defaults. A surface *covers* a type when it equals it or is `any`. + - *Registry invariant (FR-E1).* One check, run on every built-in at module load and by `registerStructure`, throws on: two structures with the same anatomy whose surfaces cover a common type (so an `any` collides with a `web` or `mobile` of the same anatomy); and a structure whose name is another structure's anatomy without being its own (it could never be reached by name, because anatomy resolution runs first). Re-registering an existing name replaces that structure and is checked against the rest, as today. + - *Resolution (FR-E2).* `resolveStructure(name, type)`, exported and pure. (1) When some structure's anatomy is `name`, it returns the one whose surface covers `type`; if none does, it throws naming the anatomies that have a `type` realisation and, when the anatomy has a realisation for the other type, the `realisation` argument that stamps it. (2) Otherwise the structure named `name`, which must cover `type`; if it does not, it throws naming its anatomy and the `realisation` argument that forces it. (3) Otherwise the existing not-found error. `applyStructure` resolves with `type = opts.realisation ?? resolveSurface(canvas).type`, so `realisation` forces either type (the FR names `web`; `mobile` is the symmetric case) and bypasses the canvas-type check. Components resolve the same way: an unmarked (`web`) component refuses on a mobile canvas unless forced. The result gains `anatomy` and `surface` (the stamped structure's). Provenance and the build log record the realisation's name (`dashboard-mobile`), as for any structure. + - *Listing (FR-E2).* `listStructures()` with no argument returns every structure (internal callers and tests); `listStructures({ type })` returns those whose surface covers `type`. Each entry gains `anatomy`, `surface`, and `surfaces`: the types the entry's anatomy is realised on, in the order `web`, `mobile` (an `any` contributes both). The `list_structures` tool takes `canvasId` (lists for that canvas's resolved type) or `type`; neither lists `web`; both is an error, as is an unknown canvas. Its result keeps its shape (an array, or `{ structures, diversification }` with `projectId`). `apply_structure` takes `realisation?: 'web' | 'mobile'`. + - *Realisations (FR-E3).* `dashboard-mobile`, `settings-mobile`, `auth-mobile`, `onboarding-mobile`, `feed-mobile`, `detail-mobile`; kind `page`, tagged on all four axes. The first root-level node is a `status-bar`; the last is a `home-indicator` or a `tab-bar` (pinned by default). "Built only from primitives" means every tap target and every piece of device chrome is a primitive (`button`, `list-row`, `nav-bar` actions, `tab-bar`, the chrome); frames, text and icons carry layout and copy, and no frame is tap-shaped (zero `mobile-handbuilt-target`). Same theming split, type roles and placeholder-copy rule (C8) as every scaffold. The realisations never write `safeArea`: pinned chrome makes the renderer reserve the insets on a root without one (FR-D1), so a stamp on a `canvas_set_type`-made canvas is still correct. `detail-mobile` uses the documented pinned-CTA idiom (fixed `bottom: 0` bar, padding under 32, a 34px spacer last, and an end spacer in the content frame). + - *Components.* Every component stays `web` unless it passes FR-E4 stamped on a mobile canvas; the build tries the state scaffolds and the small micro-patterns and records which were marked `any`. + - *Pattern gate (FR-E4).* `test-patterns.ts` (fast): every page structure is stamped on a canvas of its own surface (a `mobile` one via `createCanvas(..., { type: 'mobile' })`), same five themes, categories and bar; a mobile realisation additionally fails on any issue whose `rule` starts with `mobile-`, at any severity. Every `any` component is stamped under the root of a bare mobile canvas and fails on any `mobile-*` issue. A new full-tier `test-patterns-mobile.ts`: per mobile realisation, detailed mode with zero `mobile-*` issues, and a full five-perturbation stress run built the way `canvas_stress` builds it (instances expanded, `type: 'mobile'`) with zero warnings; per `any` component, detailed mode with zero `mobile-*` issues. `test-scaffold-system.ts` stamps each structure on a canvas of its surface (`any` on web, as today) and passes the type to `applyPerturbation`. + - *Tests.* `test-structures.ts`: the invariant (both rejection cases), the listing filter and `surfaces`, every resolution branch. A stdio MCP test drives the real `list_structures` (`canvasId`, `type`, both, neither) and `apply_structure` (`dashboard` on a mobile canvas, `pricing` refused, `realisation: "web"`). + - *Discoverability.* `test-discoverability.ts` adds: every anatomy with a mobile realisation is named in the INSTRUCTIONS mobile paragraph and in GUIDELINES; `apply_structure`'s docstring names `realisation`; `list_structures`'s names `canvasId`. The README check already covers every structure name. + - *Docs.* `list_structures` docstring (mobile listing, and the four state scaffolds it misses today), `apply_structure` docstring (resolution, `realisation`, the refusal), INSTRUCTIONS (the structures paragraph and the mobile paragraph), GOTCHAS (the structures and declare-the-medium lines), README structures list, GUIDELINES mobile section, VISION checklist. + **Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). - `src/viewer.ts:603-604` and `:756` (thumb sizing), `:515` / `:679` (card iframes), `:1107-1109` and `:1350-1385` (detail sizing, breakpoint buttons, compare grid), `:1474-1476` (active-state detection). Profile table imported from `src/surfaces.ts`. - Tests: `test-viewer-perf.ts` must stay green (thumbnail change cannot cost render time); a new viewer route test for card sizing and the framed detail markup. From cffc66c8137961f046b4dd080d3eede5dd633441 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 20:07:32 +0100 Subject: [PATCH 2/4] Add mobile realisations and resolve structures by canvas type --- README.md | 12 +- VISION.md | 2 +- docs/GUIDELINES.md | 20 ++ docs/specs/PHASE-30-SPEC.md | 11 + src/index.ts | 38 ++- src/renderer.ts | 5 +- src/structures.ts | 462 +++++++++++++++++++++++++++++++++++- src/types.ts | 6 + test-discoverability.ts | 16 ++ test-mobile-primitives.ts | 3 + test-patterns-mobile.ts | 77 ++++++ test-patterns.ts | 70 +++++- test-scaffold-system.ts | 21 +- test-structures-mcp.ts | 76 ++++++ test-structures.ts | 75 +++++- 15 files changed, 851 insertions(+), 43 deletions(-) create mode 100644 test-patterns-mobile.ts create mode 100644 test-structures-mcp.ts diff --git a/README.md b/README.md index f1278d0..b68f373 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ MCP Client → stdio → framesmith server Left to itself, an AI agent tends to produce UI that *looks* AI-generated — generic spacing, no icons, a purple gradient by default, placeholder text that reads like placeholder text. framesmith closes that gap by moving design **before** code and holding it to a bar: -1. **Start from taste, not a blank canvas.** The agent stamps one of **11 vetted page patterns** (dashboard, auth, pricing, settings, onboarding, and more) — each regression-tested to score **> 95 with zero cliché tells** under the neutral default theme and all four presets, and every structure (page and component alike) built entirely from type-role tokens so it re-voices under a generated design system instead of injecting stray pixel sizes, and hardened to survive hostile-length content (`canvas_stress` CLEAN) — then adapts it. A blank canvas is where slop comes from; a pattern is a non-slop starting point. *(`list_structures` / `apply_structure`)* +1. **Start from taste, not a blank canvas.** The agent stamps one of **11 vetted page patterns** (dashboard, auth, pricing, settings, onboarding, and more), or on a phone canvas one of **6 mobile realisations** built from the mobile primitives — each regression-tested to score **> 95 with zero cliché tells** under the neutral default theme and all four presets (a mobile realisation takes the presets' colours over a phone type scale, is also held to every personality's generated phone system, and raises no mobile-ruleset finding), and every structure (page and component alike) built entirely from type-role tokens so it re-voices under a generated design system instead of injecting stray pixel sizes, and hardened to survive hostile-length content (`canvas_stress` CLEAN) — then adapts it. A blank canvas is where slop comes from; a pattern is a non-slop starting point. *(`list_structures` / `apply_structure`)* 2. **Use the whole toolkit — like a real UI does.** Real icons (Lucide + Material Symbols), fonts resolved by name, real input controls (`toggle` / `checkbox` / `radio` / `select`), data-driven charts (line/bar/donut/sparkline, multi-series, bar emphasis), reusable components, and layered design tokens — never faked with Unicode glyphs, stray ellipses, or hand-drawn SVG paths standing in for a chart. 3. **Evaluate, then self-correct.** `canvas_evaluate` scores the design across five craft categories plus a **cliché-tell** detector, a **state-coverage** check (a data screen without designed empty/loading states isn't done), and a **usability** layer (hit-target floor, label association, honest action copy), and returns a **`READY` / `NOT READY` directive**. The agent resolves every warning and tell and only presents once it's `READY` — polishing to the bar is the agent's job, not yours. 4. **Review in the browser, own the output.** You browse canvases like design files, with a live **quality inspector** and **design-system panel**. Designs persist as **open JSON checked into your repo** — no proprietary format, no lock-in — and can be re-imported from shipped HTML to keep the design honest. @@ -48,7 +48,7 @@ Left to itself, an AI agent tends to produce UI that *looks* AI-generated — ge | Area | What you get | |------|--------------| | **Rendering** | Scene graph → HTML/CSS → Puppeteer PNG · responsive breakpoints · real CSS grid (`layout: "grid"` + spans) for bento/editorial compositions · gradients, shadows, blur, glassmorphism · SVG paths · animations · data-driven charts (line/bar/donut/sparkline, multi-series, bar emphasis + in-SVG gradients) | -| **Pattern library** | 11 vetted page archetypes + 5 component scaffolds, all scoring > 95 with zero cliché tells, typography built from type-role tokens (not literal pixel sizes), and stress-hardened to survive hostile-length content; taxonomy axes + a diversification signal so successive screens vary | +| **Pattern library** | 11 vetted page archetypes + 6 mobile realisations + 14 component scaffolds, all scoring > 95 with zero cliché tells, typography built from type-role tokens (not literal pixel sizes), and stress-hardened to survive hostile-length content; taxonomy axes + a diversification signal so successive screens vary | | **Quality & taste** | `canvas_evaluate` (8 categories incl. cliché tells, state coverage + usability floors) with a `READY`/`NOT READY` directive · `canvas_autofix` (mechanical fixes) · optional vision-model rubric critique + `canvas_revise` · `canvas_add_variant` clones a screen into a linked empty/loading/error state · `canvas_stress` content-perturbation testing (long text, i18n, big numbers, empty/many rows) · `project_evaluate` rolls up a project's screens for cross-screen consistency (radius/accent drift, token adoption, hand-copied chrome, state coverage) plus an optional multi-screen flow critique — advisory, never a gate | | **Design systems** | `generate_design_system`: one seed + one personality (`technical` / `editorial` / `soft` / `data-dense`) → the complete design language — colors, curated font pairing, typography roles, radius/density stances, `$elevation` depth tokens (dark-aware), `$motion` defaults · layered `$token`s (workspace ▸ project ▸ canvas) · style presets · `DESIGN.md` import · `generate_scale` derives a modular type + spacing scale from a named ratio (optional fluid `clamp()` sizes) · `generate_color_system` turns one seed color into OKLCH ramps + AA-checked semantic tokens, a categorical chart palette, and a tint layer for chips/tiles/badges, dark theme included · dual-theme rendering + evaluation (`theme: "dark"` on screenshot/export, both-theme contrast in `canvas_evaluate` with APCA advisory) · token-detachment lint re-attaches literals that drift from a token | | **Primitives** | Lucide + Material Symbols icons · Google Fonts by name · real form controls · components with instance overrides — `create_component` promotes existing work, `copy_nodes` carries subtrees (and their component defs) across canvases | @@ -623,10 +623,12 @@ Apply a style guide preset to a canvas. Merges preset design tokens into the can ### `list_structures` -List available layout structures — named scaffolds you stamp onto a canvas and then populate. Returns each structure's name, `kind`, description, and (for pages) taxonomy axes. Distinct from presets: structures define **layout skeleton**, presets define **color/token theme** — they compose. +List available layout structures — named scaffolds you stamp onto a canvas and then populate — for one canvas type. Returns each structure's name, `kind`, description, (for pages) taxonomy axes, its `anatomy` (the screen kind it realises), its `surface` (`web`, `mobile`, or `any` for a component that stamps on every type), and `surfaces` (the types its anatomy is realised on). Distinct from presets: structures define **layout skeleton**, presets define **color/token theme** — they compose. | Param | Type | Description | |-------|------|-------------| +| `canvasId` | string? | List what stamps on this canvas: its declared type picks the realisations. Not with `type`. | +| `type` | `"web"` \| `"mobile"`? | List for this canvas type instead. With neither, the web listing. | | `projectId` | string? | If given, also return a `diversification` signal for the project (recently-built structures + a hint to differ on ≥ 1 axis), so you pick a shape that contrasts with recent work. Omit it to get just the structure list. | Two kinds: @@ -634,12 +636,15 @@ Two kinds: - **`page`** — whole-page scaffolds stamped once at the canvas root: `marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `dashboard`, `auth`, `pricing`, `settings`, `onboarding`. Each is tagged on four independent axes — `heroTreatment`, `density`, `rhythm`, `alignment` — so you can deliberately vary page shape instead of defaulting to the same layout. Every page scaffold is regression-tested to score **> 95 with zero cliché tells** under the neutral default theme and all four presets (the pattern library's taste bar) and to pass `canvas_stress` CLEAN under long-text and i18n content, so it's a non-slop starting point you adapt, not boilerplate. - **`component`** — reusable fragments stamped under **any** node via `targetId`, repeatably: `data-table` (header + 3 rows with avatar/name/email, role chip, status dot, right-aligned numeric cell, actions, and a pagination footer), `form-field`, `toolbar`, `stat-card` (the original name for the KPI-card anatomy below, kept as an alias), `toggle-row`, plus the state scaffolds `empty-state` (icon + title + hint + CTA — an empty screen is a first-run experience, not a void), `skeleton-table` (real header + skeleton rows matching `data-table` geometry, so the loaded table lands without layout shift), `skeleton-card`, and `skeleton-stat-card` (so a loading dashboard skeletons every data surface — the coverage check warns when a loading variant leaves a live region beside skeletoned ones). The dashboard micro-patterns: `kpi-card` (tinted icon tile + uppercase metric label + big tabular value + live sparkline + tinted delta pill — the dashboard structure stamps these), `status-chip` (the tint pair: `$success-tint` fill + `$success` ink), `segmented-control`, `breadcrumb`, and `initials-avatar`. A high-fidelity table costs one stamp instead of ~90 hand-placed nodes; a reference-grade KPI card costs one. +**Mobile realisations.** Each structure realises an anatomy for one canvas type. Six page anatomies have a mobile realisation for a `type: "mobile"` canvas, built from the mobile primitives (status bar, nav bar, list rows, buttons, a pinned tab bar or home indicator) and gated like the web set — > 95 with zero cliché tells and zero mobile-ruleset findings, in fast and detailed mode, and `canvas_stress` CLEAN: `dashboard-mobile`, `settings-mobile`, `auth-mobile`, `onboarding-mobile`, and two mobile-first anatomies, `feed-mobile` (a list of rows) and `detail-mobile` (an item with a pinned primary action). Of the components, `form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip` and `initials-avatar` stamp on every type; the rest are web-only. + ### `apply_structure` Stamp a layout structure onto a canvas and return the placeholder node IDs to populate. Scaffolds are token-driven, not hard-coded: every scaffold's spacing, radius, elevation, and typography — all of it, every text node's `fontSize`, not just titles — reference `$space-*` / `$radius-*` / `$elevation.*` / the type-role tokens (`$display`/`$title`/`$heading`/`$text-lg`/`$text-sm`/`$body`/`$label`/`$caption`, plus the usual `$color` tokens), so once a project has a generated design system, stamping a pattern already reads in its density, depth, and voice — no re-theming pass needed, and no scaffold can inject a size the generated scale doesn't have. `apply_structure` seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's `seededColors` / `seededTokens` list exactly which names got seeded. Populate the placeholders with `batch_design` `U` ops, then `screenshot` to verify. - **Page scaffolds** insert at the canvas root (refusing on a non-empty canvas unless `replace`), record provenance (`metadata.provenance`), and append to the **per-project build log** that feeds the `diversification` signal. - **Component scaffolds** insert under `targetId` (default root), repeatably — every stamp re-keys its node IDs (`form-field-1-…`, `form-field-2-…`) and returns an `idMap` (template ID → live ID) for follow-up ops. Component stamps don't touch provenance or the build log: they don't shape the page. +- **The name resolves by the canvas's declared type.** An anatomy name stamps that anatomy's realisation for the type: `dashboard` on a mobile canvas stamps `dashboard-mobile`. A realisation name is used as is. A name with no realisation for the type is refused with the anatomies that have one (`pricing` on a phone; a web-only component too); `realisation` stamps another type's realisation anyway. The result names the `anatomy` and `surface` it stamped, and its `placeholders` include each primitive's copy with `prop` naming the field to set (`title` or `label`). | Param | Type | Description | |-------|------|-------------| @@ -647,6 +652,7 @@ Stamp a layout structure onto a canvas and return the placeholder node IDs to po | `structure` | string | Structure name (use `list_structures`, e.g. `"marquee-hero"`, `"data-table"`) | | `replace` | boolean? | Page scaffolds: if the root already has children, clear them before stamping. Default `false` (refuses on a non-empty canvas) | | `targetId` | string? | Component scaffolds: node to stamp under (default `"document"`) | +| `realisation` | `"web"` \| `"mobile"`? | Stamp the realisation for this type instead of the canvas's own (e.g. `"web"` for the web dashboard on a phone canvas) | ### `canvas_import_html` diff --git a/VISION.md b/VISION.md index 1c2b299..a2196dc 100644 --- a/VISION.md +++ b/VISION.md @@ -549,7 +549,7 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [x] Slice B — the type axis: `metadata.surface` with one device profile (`iphone-390`), `canvas_create({ type })` and `canvas_set_type`, a ruleset table keyed by type, genre and preset that composes by union and can turn rules on, a `surface` report on every evaluation, and a mobile type scale from `generate_design_system` - [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives joined it), safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type - [x] Slice D — vocabulary: `fixed` / `sticky` positioning with the renderer reserving room for pinned chrome, a root that grows with long content (closing slice C's long-screen gap), the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives, the primitives joining the 44px target rule, `mobile-tab-bar`, and pinned bars in the safe-area rule (`safeArea` on the root landed in slice B) -- [ ] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them +- [x] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them - [ ] Slice F — the viewer: thumbnails at the artboard's real proportions and a device frame with a safe-area overlay - [ ] Slice G — the acceptance re-run diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index d55314b..202e9d2 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -100,6 +100,26 @@ I(bar, { type: "frame", height: 34 }) The bar is 12 + 44 + 34 + 12 = 102px tall; the root's `safeArea` reserves 34, so the content frame's spacer adds the other 68. +## Starting a phone screen (mobile realisations) + +A structure realises an **anatomy** (a screen kind) for one canvas type. On a mobile canvas, `apply_structure` resolves an anatomy name to its mobile realisation, so `apply_structure({ canvasId, structure: "dashboard" })` stamps `dashboard-mobile`; the result's `applied`, `anatomy` and `surface` say what was stamped. Six anatomies have a mobile realisation: + +| Anatomy | Mobile realisation | Shape | +|---|---|---| +| `dashboard` | `dashboard-mobile` | Large-title nav bar, a headline metric card, a two-up stat row, a recent list of rows, a pinned four-tab bar | +| `settings` | `settings-mobile` | Grouped list rows (toggles, values, chevrons) and a destructive sign-out button | +| `auth` | `auth-mobile` | Title, two fields, and the primary and secondary buttons held at the bottom of the screen | +| `onboarding` | `onboarding-mobile` | One page of a paged flow: skip, glyph, heading, body, page dots, a full-width continue | +| `feed` | `feed-mobile` | A full-bleed list of rows (monogram, title, preview, time) and a pinned four-tab bar | +| `detail` | `detail-mobile` | Back nav bar, media slot, title, body, an attribute list, and a primary action pinned over the home indicator | + +Every one starts with a `status-bar`, ends with a `home-indicator` or a pinned `tab-bar`, and builds every tap target from a primitive, so it passes the mobile ruleset in fast and detailed mode and survives `canvas_stress`. Adapt it like any scaffold: the result's `placeholders` include each primitive's copy, with `prop` naming the field (`U("dm-row-1", { title: "Morning run" })`, `U("amb-submit", { label: "Sign in" })`). + +- **Use `generate_design_system` for a phone's type**, then stamp. The built-in presets set a 14–15px `$body`, which `mobile-type-floor` flags on a phone whatever the layout. +- **`list_structures({ canvasId })`** lists what stamps on that canvas: on a phone, the six realisations and the components marked for every type (`form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`). Each entry names its `anatomy`, its `surface` (`web`, `mobile` or `any`) and `surfaces`, the types its anatomy is realised on. +- **A name with no realisation for the canvas's type is refused**, with the anatomies that have one: `pricing` on a phone, or a web-only component such as `data-table` (its 28px pager buttons are hand-built). `realisation: "web"` stamps the web one anyway, and `realisation: "mobile"` stamps a mobile one on a web canvas. +- **The realisations never write `safeArea`.** Their pinned chrome makes the renderer reserve the insets, so they are also correct on a canvas made mobile with `canvas_set_type`. + ## Responsive hints Set `responsive` on **container nodes** (frames with `layout: "horizontal"` or `layout: "grid"` and children). The renderer emits the right media-query rules — flex-wrap for horizontal containers, a single-column collapse with spans reset for grid. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index d2f3dc8..e6dce47 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -326,6 +326,17 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *Discoverability.* `test-discoverability.ts` adds: every anatomy with a mobile realisation is named in the INSTRUCTIONS mobile paragraph and in GUIDELINES; `apply_structure`'s docstring names `realisation`; `list_structures`'s names `canvasId`. The README check already covers every structure name. - *Docs.* `list_structures` docstring (mobile listing, and the four state scaffolds it misses today), `apply_structure` docstring (resolution, `realisation`, the refusal), INSTRUCTIONS (the structures paragraph and the mobile paragraph), GOTCHAS (the structures and declare-the-medium lines), README structures list, GUIDELINES mobile section, VISION checklist. +- **As built (slice E).** Where the build departs from the contract above, or settles what it left open: + - *The presets lend a phone everything but their type scale (spec change to FR-E4's themes).* Every built-in preset sets a 14 or 15px `$body`, which `mobile-type-floor` flags on any phone screen that uses the body role, whatever its structure. So on a mobile realisation the gate applies each preset's colours, spacing, radius and depth over the canvas's own type scale. It also holds every realisation to each personality's generated phone system (`generate_design_system` with `surface: "mobile"`, stamped after it the way `apply_structure` passes the inherited tokens): > 95, zero tells, zero mobile findings. That is the path the docs tell agents to take. + - *Primitives seed the tokens they read.* `applyStructure` seeds only the colour tokens a stamp references, and the primitives read `$text-primary`, `$text-secondary`, `$accent`, `$border`, `$bg-surface` and `$danger` through their defaults without naming them. An unthemed `settings-mobile` drew near-black row titles on the dark default background, and nothing in the evaluator saw it (primitive labels are not contrast-checked, a slice D known limit). A stamp holding a primitive now seeds all six. + - *Placeholders include primitive copy.* A `list-row` or `nav-bar` title and a `button` label are listed as placeholders with `prop: "title" | "label"`, so the agent knows which field to set. + - *A `button` honours `maxWidth`* (a slice D renderer gap: an authored `maxWidth` emitted no `max-width` at all). Stress found it: a long "See all" in `dashboard-mobile`'s section header pushed the header out of its column; capped at 50%, the label truncates by design. + - *`empty-state`'s hint moved from `$caption` to `$text-sm`.* Nine words at 12px fail `mobile-type-floor`'s prose floor, and the empty state is the scaffold a phone's empty variant needs. This changes the web render of the hint (12 → 14px by default). + - *Components marked `any`:* `form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`. Held back: `data-table` (its 28px pager and row-action frames raise `mobile-handbuilt-target`), `toolbar` (a 36px hand-built search field), `toggle-row` (a 40px row fails `mobile-touch-target`), and three that pass but are not phone idioms or are hand-built tap controls: `skeleton-table`, `segmented-control`, `breadcrumb`. + - *Refusals list the same kind.* The refusal names page anatomies for a page name and component anatomies for a component name (a page refusal listing fourteen components was noise). When an anatomy has no realisation for the type, every realisation it has covers the other type, so the `realisation` hint is always given. + - *`detail-mobile`'s pinned bar keeps its vertical rhythm in spacer frames* (12 above the button, 46 below it), not padding, so the bar is 110px on every spacing scale and the 76px end spacer stays exact; a padding token would change the bar's height with the personality. + - *Known limit, not fixed here.* The renderer scales any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it is a render change outside slice E. Slice G's Ridgeline rebuild will show it. + **Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). - `src/viewer.ts:603-604` and `:756` (thumb sizing), `:515` / `:679` (card iframes), `:1107-1109` and `:1350-1385` (detail sizing, breakpoint buttons, compare grid), `:1474-1476` (active-state detection). Profile table imported from `src/surfaces.ts`. - Tests: `test-viewer-perf.ts` must stay green (thumbnail change cannot cost render time); a new viewer route test for card sizing and the framed detail markup. diff --git a/src/index.ts b/src/index.ts index 9711802..0da5ede 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { createCanvas, getCanvas, listCanvases, findNode, touchCanvas, loadPersistedCanvases, archiveCanvas, unarchiveCanvas, moveCanvas, deleteCanvas, countCanvasesInProject, ensureFresh, collectMatchingNodes, replaceMatchingProperties, findNodesDetailed, setCanvasGenre, setCanvasType, addVariant } from './scene-graph.js'; -import { deviceProfile, resolveSurface, surfaceFor, usableSafeArea } from './surfaces.js'; +import { deviceProfile, resolveSurface, surfaceFor, usableSafeArea, type SurfaceType } from './surfaces.js'; import { canvasVersionHash, checkVersionHash } from './version.js'; import { loadPersistedWorkspaces, ensureDefaultWorkspaceAndProject, createWorkspace, listWorkspaces, renameWorkspace, deleteWorkspace, createProject, getProject, getWorkspace, listProjects, renameProject, deleteProject, setWorkspaceDesignSystem, getWorkspaceDesignSystem, setProjectDesignSystem, getProjectDesignSystem, getCanvasTokens, getInheritedTokens, loadRepoWorkspace } from './workspaces.js'; import { DEFAULT_PROJECT_ID, DEFAULT_WORKSPACE_ID } from './types.js'; @@ -73,7 +73,7 @@ Charts are data-driven: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", Reuse instead of rebuild: create_component promotes any existing subtree into a reusable component (an instance takes its place, render-identical); stamp more instances via batch_design ({ type: "instance", componentId, overrides: { "": {...} } } — overrides match children by NAME). copy_nodes copies subtrees across canvases with fresh ids + an idMap, carrying referenced component defs along — the app shell is built once and copied everywhere, never re-authored node-by-node. -Structures come in two kinds (list_structures): page scaffolds (marquee-hero, bento-grid, stat-led, editorial-longform, split-workbench, catalogue, dashboard, auth, pricing, settings, onboarding) stamp once at the root — each is taste-vetted (> 95, zero cliché tells, canvas_stress CLEAN, typography built from type-role tokens not literal pixel sizes) so it's a non-slop starting point to ADAPT, not boilerplate; component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, plus the dashboard micro-patterns kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamp under any targetId, repeatably, returning an idMap — a data table is one apply_structure call, not 90 nodes, a reference-grade KPI card is one more. +Structures come in two kinds (list_structures): page scaffolds (marquee-hero, bento-grid, stat-led, editorial-longform, split-workbench, catalogue, dashboard, auth, pricing, settings, onboarding) stamp once at the root — each is taste-vetted (> 95, zero cliché tells, canvas_stress CLEAN, typography built from type-role tokens not literal pixel sizes) so it's a non-slop starting point to ADAPT, not boilerplate; component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, plus the dashboard micro-patterns kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamp under any targetId, repeatably, returning an idMap — a data table is one apply_structure call, not 90 nodes, a reference-grade KPI card is one more. Each structure realises an anatomy (a screen kind) for one canvas type, and apply_structure resolves the name by the canvas's declared type (see the phone paragraph below); list_structures({ canvasId }) lists what stamps on that canvas. Import from implementation: canvas_import_html (snippet + optional CSS) and canvas_import_url (live page — viewport/selector/waitFor/auth) turn shipped UI into an editable, TOKEN-MAPPED canvas — flex→frames, text runs, imgs, recognized SVGs→icons, checkboxes/switches/selects→input primitives; Tailwind classes map to intent (bg-surface → fill "$surface") and literal colors snap to the design system. STRUCTURE reconstructs too: → rows of proportional columns, CSS grid → rows from the computed template, centered/max-width content stays centered, other multi-column CSS clusters by geometry — report.layout records how each container was handled (table|grid|centered|geometry|stack-fallback; a stack-fallback entry = hand-fix that one container, everything else arrived structurally correct). Lossy by design: READ the returned report (snapped/literals/layout/warnings) instead of assuming fidelity. @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN; stamp it after generate_design_system (the built-in presets carry a 14–15px body that mobile-type-floor flags on a phone). Its placeholders include each primitive's copy, with prop naming the field (title or label). An anatomy with no mobile realisation (pricing; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -103,7 +103,7 @@ const server = new McpServer({ * it reads the connect-time instructions or calls init. */ const WORKFLOW_CHEATSHEET = [ 'The bar: craft beautiful UI/UX a designer would sign off on. Polishing to it is YOUR job — never show the user an unpolished design.', - 'Start from a taste-vetted pattern: list_structures → apply_structure, then ADAPT it — don\'t start from a blank canvas.', + 'Start from a taste-vetted pattern: list_structures → apply_structure, then ADAPT it — don\'t start from a blank canvas. On a phone canvas the same names stamp the mobile realisations (dashboard, settings, auth, onboarding, feed, detail).', 'Use the whole toolkit by default — icons, fonts, real controls (toggle/checkbox/radio/select), components, $tokens. Never fake them; never omit them where a real UI has them.', 'Read the framesmith://guidelines resource before drawing (esp. "Designing with taste": one focal point, real hierarchy, one type + spacing scale, restraint).', 'Author at one target width (the canvas artboard — pass width/height to canvas_create for another size; for a phone app screen pass type: "mobile", which sizes the artboard to the device and reserves its safe-area insets on the root); reference tokens with $name (e.g. fill: "$surface").', @@ -140,6 +140,7 @@ const GOTCHAS = [ 'Every state, every string: a data screen is not done at one static frame with ideal data. Tables demand designed "empty" + "loading" state variants, forms demand "error" (the coverage category warns until they exist — each is one canvas_add_variant + one scaffold stamp: empty-state / skeleton-table / skeleton-card / skeleton-stat-card); skeleton blocks pulse only in the live viewer, never in screenshots. Skeleton EVERY data surface on a loading variant, not just the table — coverage flags a "half-designed" loading state when a region still shows live values beside skeletoned ones. Then canvas_stress before presenting: it renders hostile-but-realistic content (long-text / i18n / big-numbers / empty / many) and reports clips and overflows by node id — fix with fluid widths / minWidth or minHeight floors / wrapping, or textOverflow: "ellipsis" on a label that must stay single-line (that downgrades its clip to info instead of warning), and re-run until CLEAN. canvas_evaluate remembers the last stress verdict: after a FRAGILE run the directive stays NOT READY until a full CLEAN run (all five perturbations) replaces it — a subset run can record FRAGILE but never clear it.', 'Derive, don\'t hand-pick: generate_design_system (seed + REQUIRED personality: technical/editorial/soft/data-dense) writes the whole design language in one call — including $display/$heading/$title/$body/$label typography roles, $elevation.* shadow tokens (reference as shadow: "$elevation.raised"; the dark layer re-states depth), and $motion defaults. For targeted regeneration only: generate_scale gives a modular type + space scale from a ratio (craft defaults baked in; fluid clamp() optional); generate_color_system gives OKLCH ramps + a matched neutral + status colors + the semantic tokens the structures use, AA by construction, WITH a dark theme (sparse dark.colors layer) AND the color range — chart-1…chart-6 for series/segments/legend dots, plus the accent/success/warning/danger/neutral -tint layer for chips/tiles/badges (pair tint as fill with its base color as ink; AA by construction, dark included). Render dark via theme: "dark" on screenshot/screenshot_responsive/export; canvas_evaluate contrast-checks both themes automatically (dark failures point at the dark layer — never write a literal fix; APCA Lc is info-only, WCAG 2.2 gates). Literals that equal a token get re-attached by autofix; timing belongs in $motion tokens.', 'Beyond one canvas: bento/editorial compositions are REAL grid — layout: "grid" + gridColumns (count / fr-weight array / template string) + gridColumn/gridRow spans (a number means span N); responsive: "stack" collapses to one column on mobile. Never approximate tiles with nested flex. When a multi-screen module feels done, project_evaluate reviews the SET — radius-drift / accent-drift / token-adoption / copied-chrome / state-coverage findings, each naming its canvases — and mode "llm" judges up to 8 screens together for flow consistency (flowSkipped lists anything past the cap; keyless → full roll-up + a note). ADVISORY: only the per-canvas directives gate presenting.', + 'Start a phone screen from its mobile realisation: generate_design_system, then apply_structure with the anatomy name on the mobile canvas — dashboard, settings, auth, onboarding, feed or detail stamp dashboard-mobile etc. (status bar, nav bar, list rows, buttons, pinned tab bar or home indicator; every tap target a primitive). pricing and the web-only components (data-table, toolbar, …) are refused on a phone with the list of what has a mobile realisation; realisation: "web" forces the web one. list_structures({ canvasId }) lists what stamps on that canvas.', 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. Build a phone screen from the mobile primitives — status-bar, nav-bar, list-row, button, tab-bar, home-indicator, sheet — not hand-built frames: they carry 44px targets and pin themselves. On mobile a tap target below 44px is an ERROR (mobile-touch-target), content in the status-bar / home-indicator insets or left under a pinned bar is a WARNING (mobile-safe-area), and a tab bar outside 2–5 labeled tabs or not pinned to the bottom WARNS (mobile-tab-bar). Keep root safeArea; the root grows with long content and keeps its end padding. A pinned CTA bar: position "fixed", bottom: 0, padding under 32 (larger padding scales down on a phone) and a 34px spacer frame as its last child, plus a spacer at the end of your content frame tall enough that it and the root\'s 34px bottom inset reach the bar\'s height (the rule names it: 68 for a 102px bar; re-evaluate after changing the bar). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', 'Gate integrity: a canvas describing a SHIPPED view is a contract — run canvas_check_drift against the live route BEFORE designing on it (findings: missing-in-page / missing-in-canvas / control-mismatch / table-mismatch), and reconcile deliberately: update the canvas, flag the implementation, or ask — never silently annotate a difference. canvas_sync_from_url answers "how much does it LOOK different" (pixel %); canvas_version makes approvals falsifiable — record { canvasId, versionHash } at approval time and check with expectedHash later (metadata/feedback never moves the hash; ANY inherited workspace/project token edit DOES, even to a token the canvas does not use). A legacy sha256: hash still checks but carries a warning — re-record it as the current sha256r: versionHash. CI can demand both: npx framesmith check-drift / verify exit 1 on failure.', ]; @@ -1484,15 +1485,28 @@ server.tool( // --- list_structures --- server.tool( 'list_structures', - `List available layout structures, two kinds: + `List available layout structures for one canvas type, two kinds: - kind "page" — whole-page scaffolds (marquee-hero, bento-grid, …) stamped once at the canvas root, tagged on four taxonomy axes (heroTreatment, density, rhythm, alignment) so you deliberately vary page shape. - - kind "component" — reusable fragments (data-table, form-field, toolbar, stat-card, toggle-row, kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamped under ANY node via apply_structure targetId, repeatably — a high-fidelity table costs one stamp instead of ~90 hand-placed nodes. + - kind "component" — reusable fragments (data-table, form-field, toolbar, stat-card, toggle-row, the state scaffolds empty-state, skeleton-table, skeleton-card, skeleton-stat-card, and the micro-patterns kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamped under ANY node via apply_structure targetId, repeatably — a high-fidelity table costs one stamp instead of ~90 hand-placed nodes. +Each structure realises an ANATOMY (a screen kind such as dashboard or settings) for one canvas type: pass canvasId (or type) to list what stamps on that canvas — a phone canvas lists the mobile realisations (dashboard-mobile, settings-mobile, auth-mobile, onboarding-mobile, feed-mobile, detail-mobile) and the components marked for every type. With neither, the web listing. Every entry carries its anatomy, its surface ("web" | "mobile" | "any") and surfaces (the canvas types its anatomy is realised on). Distinct from presets: structures define layout skeleton, presets define color/token theme. Pass projectId to also get a diversification signal (recently-built page structures + a hint to differ). Apply one with apply_structure, then screenshot and verify before populating.`, { + canvasId: z.string().optional().describe('List what stamps on this canvas (its declared type picks the realisations). Not with type.'), + type: z.enum(['web', 'mobile']).optional().describe('List for this canvas type instead of a canvas. Default "web". Not with canvasId.'), projectId: z.string().optional().describe('If given, also return a diversification signal for this project: the recently-built structures and a hint to differ on >= 1 taxonomy axis. Use project_list to see projects.'), }, - async ({ projectId }) => { - const structures = listStructures(); + async ({ canvasId, type, projectId }) => { + if (canvasId !== undefined && type !== undefined) { + return { content: [{ type: 'text', text: 'Error: pass canvasId or type, not both.' }], isError: true }; + } + let listType: SurfaceType = type ?? 'web'; + if (canvasId !== undefined) { + ensureFresh(canvasId); + const canvas = getCanvas(canvasId); + if (!canvas) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; + listType = resolveSurface(canvas).type; + } + const structures = listStructures({ type: listType }); if (!projectId) { return { content: [{ type: 'text', text: JSON.stringify(structures, null, 2) }] }; } @@ -1508,15 +1522,17 @@ server.tool( 'apply_structure', `Stamp a layout structure (see list_structures) onto a canvas. Two kinds: - page scaffolds insert at the canvas root and record provenance; refuses if the root already has content unless replace is true. - - component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row) insert under targetId (default root), repeatably — every stamp re-keys its node IDs and returns an idMap (templateId → live id) for follow-up batch_design ops. + - component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, …) insert under targetId (default root), repeatably — every stamp re-keys its node IDs and returns an idMap (templateId → live id) for follow-up batch_design ops. +The name resolves by the canvas's declared type: an anatomy name stamps that anatomy's realisation for the type — "dashboard" on a mobile canvas stamps dashboard-mobile (status bar, nav bar, list rows, pinned tab bar, all primitives) — and the result names the anatomy and surface it stamped. A realisation name (dashboard-mobile) is used as is. A name with no realisation for the canvas's type is refused with the anatomies that have one (pricing on a phone canvas; components not marked for every type too); realisation: "web" | "mobile" stamps that type's realisation anyway. Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$title/$heading/$text-lg/$text-sm/$body/$label/$caption) for spacing, corner radius, depth, and EVERY text node's size (not just $color, and not just titles) — stamp AFTER generate_design_system on a project and the pattern already reads in that system's density, personality, and voice; no scaffold can inject a size the generated scale doesn't have. Also hardened to survive hostile-length content (canvas_stress CLEAN). Seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's seededColors/seededTokens list exactly what got seeded. Returns the placeholder node ids to populate — fill them with batch_design U ops, then call screenshot to verify the layout.`, { canvasId: z.string().describe('Canvas ID'), structure: z.string().describe('Structure name (use list_structures, e.g. marquee-hero, data-table)'), replace: z.boolean().optional().describe('Page scaffolds only: if the root already has children, clear them before stamping. Default false (refuses on a non-empty canvas).'), targetId: z.string().optional().describe('Component scaffolds only: node to stamp under (default "document"). Page scaffolds always stamp at the root.'), + realisation: z.enum(['web', 'mobile']).optional().describe('Stamp the realisation for this canvas type instead of the canvas\'s own type (e.g. "web" to put the web dashboard on a mobile canvas).'), }, - async ({ canvasId, structure, replace, targetId }) => { + async ({ canvasId, structure, replace, targetId, realisation }) => { ensureFresh(canvasId); const canvas = getCanvas(canvasId); if (!canvas) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; @@ -1532,7 +1548,7 @@ Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$ ...Object.keys(merged.typography ?? {}), ...Object.keys(merged.elevation ?? {}).map((k) => `elevation.${k}`), ]); - const result = applyStructure(canvas, structure, { replace, existingColors, existingTokens, targetId }); + const result = applyStructure(canvas, structure, { replace, existingColors, existingTokens, targetId, realisation }); // Record provenance in the per-project build log (feeds the diversification // signal) — page stamps only: component stamps don't shape the page, and // logging them would pollute the diversification signal (spec C9). diff --git a/src/renderer.ts b/src/renderer.ts index 3c24560..37779c3 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1120,8 +1120,9 @@ function renderButton(node: SceneNode, rc: RenderCtx, attrs: string): string { ...positionStyles(node, rc), 'display: inline-flex', 'flex-shrink: 0', 'align-items: center', 'justify-content: center', 'gap: 8px', `height: ${h}px`, `min-height: ${h}px`, `min-width: ${h}px`, words ? (node.width !== undefined ? `width: ${cssLength(node.width)}` : 'width: fit-content') : `width: ${h}px`, - // A long label truncates inside the column instead of running past it. - words && node.maxWidth === undefined ? 'max-width: 100%' : undefined, + // A long label truncates inside the column, or inside an authored cap, + // instead of running past it. + words ? `max-width: ${node.maxWidth !== undefined ? cssLength(node.maxWidth) : '100%'}` : undefined, words ? `padding: 0 ${BUTTON_PADDING[size]}px` : 'padding: 0', `border-radius: ${radius}px`, `background-color: ${fill}`, node.stroke ? `border: 1px solid ${node.stroke}` : undefined, diff --git a/src/structures.ts b/src/structures.ts index 25db2b8..43f614f 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -1,4 +1,5 @@ import type { BuildLogEntry, Canvas, SceneNode, Structure, StructureAxes } from './types.js'; +import { resolveSurface, SURFACE_TYPES, type SurfaceType } from './surfaces.js'; // Phase 11 — layout scaffold library. A Structure is a named page shape: a // partial scene tree of *labeled placeholder* children (C8 — never fabricated @@ -1056,6 +1057,7 @@ const onboarding: Structure = { const formField: Structure = { name: 'form-field', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'A labeled form field: label, input box, and help text. Stamp once per field; set the label/help via the returned id map.', nodes: [{ id: 'ff', type: 'frame', name: 'Form field', width: '100%', layout: 'vertical', gap: SPACE.xs, @@ -1095,6 +1097,7 @@ const toggleRow: Structure = { const statCard: Structure = { name: 'stat-card', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'The original name for the KPI-card anatomy (tinted icon tile, uppercase metric label, big tabular value, sparkline, tinted delta pill) — kept as an alias for existing callers. Prefer kpi-card for new work; the two stamp the same structure. Stamp several in a horizontal frame for a stat band.', nodes: [stat('sc')], }; @@ -1241,6 +1244,7 @@ const dataTable: Structure = { const emptyState: Structure = { name: 'empty-state', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'A designed empty state: icon, title, one-line hint, primary action. Stamp it where the data would be — an empty screen is a first-run experience, never a bare void.', nodes: [{ id: 'es', type: 'frame', name: 'Empty state', width: '100%', layout: 'vertical', alignItems: 'center', @@ -1248,7 +1252,9 @@ const emptyState: Structure = { children: [ { id: 'es-icon', type: 'icon', icon: 'inbox', iconSize: 28, iconColor: COLOR.textSecondary }, { id: 'es-title', type: 'text', content: 'Nothing here yet', fontSize: TYPE.body, fontWeight: 600, color: COLOR.textPrimary }, - { id: 'es-hint', type: 'text', content: 'Items you create will show up here — to confirm', fontSize: TYPE.caption, color: COLOR.textSecondary }, + // $text-sm, not $caption: nine words of prose at 12px are below the iOS + // Footnote floor, so the hint failed mobile-type-floor on a phone. + { id: 'es-hint', type: 'text', content: 'Items you create will show up here — to confirm', fontSize: TYPE.textSm, color: COLOR.textSecondary }, { id: 'es-cta', type: 'frame', name: 'CTA', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs, padding: [SPACE.xs, SPACE.md], cornerRadius: RADIUS.sm, fill: COLOR.accent, width: 'fit-content', @@ -1317,6 +1323,7 @@ const skeletonTable: Structure = { const skeletonCard: Structure = { name: 'skeleton-card', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'Loading state for a card: media block, title bar, and two text lines as skeletons — same silhouette as the loaded card.', nodes: [{ id: 'skc', type: 'frame', name: 'Skeleton card', width: '100%', layout: 'vertical', gap: SPACE.sm, @@ -1333,6 +1340,7 @@ const skeletonCard: Structure = { const skeletonStatCard: Structure = { name: 'skeleton-stat-card', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'Loading state for a stat card: label bar, value bar, and delta bar as skeletons — same silhouette as the loaded stat. Stamp one per stat in the row so a loading dashboard skeletons EVERY data surface, not just the table.', nodes: [{ id: 'sks', type: 'frame', name: 'Skeleton stat', width: 300, layout: 'vertical', gap: SPACE.sm, @@ -1364,6 +1372,7 @@ const skeletonStatCard: Structure = { const kpiCard: Structure = { name: 'kpi-card', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'A reference-grade KPI card: tinted icon tile + uppercase metric label, big tabular value with inline context, a live sparkline, and a tinted delta pill. The dashboard structure stamps these; customize copy/data via the idMap.', nodes: [stat('kpi')], }; @@ -1371,6 +1380,7 @@ const kpiCard: Structure = { const statusChip: Structure = { name: 'status-chip', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'A status chip on the tint pair: soft tinted pill + icon + label in the matching ink (fill "$success-tint" + color "$success" — AA by construction both themes). Swap the tint/ink pair and icon per status via the idMap.', nodes: [{ id: 'chip', type: 'frame', name: 'Status chip', layout: 'horizontal', alignItems: 'center', @@ -1424,6 +1434,7 @@ const breadcrumb: Structure = { const initialsAvatar: Structure = { name: 'initials-avatar', kind: 'component', + surface: 'any', // passes the mobile gate (FR-E4) on a phone canvas description: 'An initials avatar on the tint pair: tinted circle + two-letter monogram in the matching ink. Size/tint/initials via the idMap.', nodes: [{ id: 'av', type: 'frame', name: 'Avatar', width: 32, height: 32, cornerRadius: 999, overflow: 'hidden', @@ -1432,6 +1443,314 @@ const initialsAvatar: Structure = { }], }; +// ── mobile realisations (Phase 30 slice E) ───────────────────────────────── +// One per anatomy, for a phone canvas. Every tap target and every piece of +// device chrome is a primitive (status-bar, nav-bar, list-row, button, +// tab-bar, home-indicator), so the 44px target, safe-area and tab-bar rules +// hold by construction; frames, text and icons only lay out and label. None +// writes `safeArea`: pinned chrome makes the renderer reserve the insets on +// any root, so a stamp on a canvas re-declared mobile is still correct. + +const statusBar = (id: string): SceneNode => ({ id, type: 'status-bar', name: 'Status bar' }); +const homeIndicator = (id: string): SceneNode => ({ id, type: 'home-indicator', name: 'Home indicator' }); + +/** The phone content column: full width, the 16px side margin. */ +function mobileContent(id: string, children: SceneNode[], extra: Partial = {}): SceneNode { + return { + id, type: 'frame', name: 'Content', width: '100%', layout: 'vertical', gap: SPACE.lg, + padding: [SPACE.xs, SPACE.md, SPACE.lg, SPACE.md], children, ...extra, + }; +} + +/** A grouped list: an optional caption over a rounded card of list rows. */ +function rowGroup(id: string, title: string | undefined, rows: SceneNode[]): SceneNode { + return { + id, type: 'frame', name: title ?? 'Group', width: '100%', layout: 'vertical', gap: SPACE.xs, + children: [ + ...(title ? [{ id: `${id}-title`, type: 'text' as const, content: title, fontSize: TYPE.label, fontWeight: 600, color: COLOR.textSecondary }] : []), + { + id: `${id}-card`, type: 'frame', name: 'Card', width: '100%', layout: 'vertical', + cornerRadius: RADIUS.lg, fill: COLOR.bgSurface, stroke: COLOR.border, strokeWidth: 1, overflow: 'hidden', + children: rows.map((r, i) => (i < rows.length - 1 ? { ...r, divider: true } : r)), + }, + ], + }; +} + +/** A section header row: a title and a trailing ghost action. */ +function sectionHead(id: string, title: string, action: string): SceneNode { + return { + id, type: 'frame', name: 'Section header', width: '100%', layout: 'horizontal', + alignItems: 'center', justifyContent: 'space-between', gap: SPACE.sm, + children: [ + { id: `${id}-title`, type: 'text', content: title, fontSize: TYPE.textLg, fontWeight: 600, color: COLOR.textPrimary, textOverflow: 'ellipsis', minWidth: 0 }, + { id: `${id}-action`, type: 'button', label: action, variant: 'ghost', maxWidth: '50%' }, + ], + }; +} + +/** A compact metric tile for the phone summary row. */ +function mobileStat(id: string, icon: string): SceneNode { + return { + id, type: 'frame', name: 'Stat', width: '100%', minWidth: 0, layout: 'vertical', gap: SPACE.xs, + padding: SPACE.md, cornerRadius: RADIUS.lg, fill: COLOR.bgSurface, stroke: COLOR.border, strokeWidth: 1, + children: [ + { + id: `${id}-head`, type: 'frame', name: 'Label row', width: '100%', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs, + children: [ + { id: `${id}-icon`, type: 'icon', icon, iconSize: 16, iconColor: COLOR.accent }, + { id: `${id}-label`, type: 'text', content: 'Stat label', fontSize: TYPE.caption, color: COLOR.textSecondary, textOverflow: 'ellipsis', minWidth: 0 }, + ], + }, + { id: `${id}-value`, type: 'text', content: 'Value — TBD', fontSize: TYPE.textLg, fontWeight: 700, color: COLOR.textPrimary, tabularNums: true, textOverflow: 'ellipsis', minWidth: 0 }, + ], + }; +} + +const dashboardMobile: Structure = { + name: 'dashboard-mobile', + anatomy: 'dashboard', + surface: 'mobile', + description: + 'Phone summary home — large-title nav bar, a headline metric card, a two-up stat row, a recent-items list of rows, and a pinned four-tab bar. The mobile realisation of dashboard. No fabricated numbers.', + axes: { heroTreatment: 'stat-led', density: 'balanced', rhythm: 'uniform', alignment: 'left' }, + nodes: [ + statusBar('dm-status'), + { id: 'dm-nav', type: 'nav-bar', name: 'Nav bar', title: 'Today', largeTitle: true, trailing: [{ icon: 'bell' }] }, + mobileContent('dm-content', [ + { + id: 'dm-hero', type: 'frame', name: 'Headline metric', width: '100%', layout: 'vertical', gap: SPACE.xs, + padding: SPACE.lg, cornerRadius: RADIUS.lg, fill: COLOR.bgSurface, stroke: COLOR.border, strokeWidth: 1, shadow: ELEV.flat, + children: [ + { id: 'dm-hero-label', type: 'text', content: 'Metric label', fontSize: TYPE.label, fontWeight: 600, color: COLOR.textSecondary, textOverflow: 'ellipsis' }, + { id: 'dm-hero-value', type: 'text', content: 'Metric — TBD', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary, tabularNums: true, textOverflow: 'ellipsis' }, + { + id: 'dm-hero-delta', type: 'frame', name: 'Delta', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs2, + padding: [2, SPACE.xs], cornerRadius: 999, fill: TINT.success.bg, overflow: 'hidden', + children: [ + { id: 'dm-hero-delta-icon', type: 'icon', icon: 'trending-up', iconSize: 14, iconColor: TINT.success.ink }, + { id: 'dm-hero-delta-text', type: 'text', content: 'Change — TBD', fontSize: TYPE.caption, fontWeight: 600, color: COLOR.textPrimary, textOverflow: 'ellipsis' }, + ], + }, + ], + }, + { + id: 'dm-stats', type: 'frame', name: 'Stat row', width: '100%', layout: 'horizontal', gap: SPACE.sm, + children: [mobileStat('dm-stat-1', 'activity'), mobileStat('dm-stat-2', 'calendar')], + }, + { + id: 'dm-recent', type: 'frame', name: 'Recent', width: '100%', layout: 'vertical', gap: SPACE.xs, + children: [ + sectionHead('dm-recent-head', 'Recent', 'See all'), + rowGroup('dm-recent-list', undefined, [1, 2, 3].map((i) => ({ + id: `dm-row-${i}`, type: 'list-row' as const, title: 'Item title', subtitle: 'Supporting detail', + leading: { icon: ['activity', 'heart', 'compass'][i - 1] }, trailing: { text: 'Meta' }, + }))), + ], + }, + ]), + { + id: 'dm-tabs', type: 'tab-bar', name: 'Tab bar', + items: [ + { icon: 'house', label: 'Home', active: true }, + { icon: 'activity', label: 'Activity' }, + { icon: 'chart-column', label: 'Insights' }, + { icon: 'user', label: 'Profile' }, + ], + }, + ], +}; + +const settingsMobile: Structure = { + name: 'settings-mobile', + anatomy: 'settings', + surface: 'mobile', + description: + 'Phone settings — large-title nav bar over grouped list rows (toggles, values, disclosure chevrons), an account group, and a destructive sign-out button. The mobile realisation of settings.', + axes: { heroTreatment: 'none', density: 'balanced', rhythm: 'uniform', alignment: 'left' }, + nodes: [ + statusBar('sm-status'), + { id: 'sm-nav', type: 'nav-bar', name: 'Nav bar', title: 'Settings', largeTitle: true }, + mobileContent('sm-content', [ + rowGroup('sm-general', 'General', [ + { id: 'sm-row-1', type: 'list-row', title: 'Preference label', leading: { icon: 'bell' }, trailing: { toggle: true } }, + { id: 'sm-row-2', type: 'list-row', title: 'Preference label', leading: { icon: 'moon' }, trailing: { toggle: false } }, + { id: 'sm-row-3', type: 'list-row', title: 'Preference label', leading: { icon: 'globe' }, trailing: { text: 'Value' } }, + ]), + rowGroup('sm-account', 'Account', [ + { id: 'sm-row-4', type: 'list-row', title: 'Account detail', subtitle: 'Current value — to confirm', leading: { icon: 'user' }, trailing: { chevron: true } }, + { id: 'sm-row-5', type: 'list-row', title: 'Account detail', leading: { icon: 'lock' }, trailing: { chevron: true } }, + { id: 'sm-row-6', type: 'list-row', title: 'Account detail', leading: { icon: 'credit-card' }, trailing: { chevron: true } }, + ]), + { id: 'sm-signout', type: 'button', name: 'Sign out', label: 'Sign out', variant: 'destructive', width: '100%' }, + { id: 'sm-version', type: 'text', content: 'Version — TBD', fontSize: TYPE.caption, color: COLOR.textSecondary, textAlign: 'center', width: '100%' }, + ]), + homeIndicator('sm-home'), + ], +}; + +const authMobile: Structure = { + name: 'auth-mobile', + anatomy: 'auth', + surface: 'mobile', + description: + 'Phone sign-in — a title and supporting line, two form fields, and a full-width primary button with a secondary action held at the bottom of the screen. The mobile realisation of auth.', + axes: { heroTreatment: 'none', density: 'airy', rhythm: 'uniform', alignment: 'left' }, + nodes: [ + statusBar('amb-status'), + { + id: 'amb-content', type: 'frame', name: 'Content', width: '100%', height: '100%', layout: 'vertical', + justifyContent: 'space-between', gap: SPACE.xl, padding: [SPACE.lg, SPACE.md], + children: [ + { + id: 'amb-form', type: 'frame', name: 'Form', width: '100%', layout: 'vertical', gap: SPACE.lg, + children: [ + { + id: 'amb-head', type: 'frame', width: '100%', layout: 'vertical', gap: SPACE.xs, + children: [ + { id: 'amb-title', type: 'text', content: 'Sign in', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary }, + { id: 'amb-sub', type: 'text', content: 'Body copy — one supporting line.', fontSize: TYPE.body, color: COLOR.textSecondary, lineHeight: 1.5 }, + ], + }, + { + id: 'amb-fields', type: 'frame', width: '100%', layout: 'vertical', gap: SPACE.md, + children: [field('amb-email', 'Email'), field('amb-password', 'Password')], + }, + ], + }, + { + id: 'amb-actions', type: 'frame', name: 'Actions', width: '100%', layout: 'vertical', gap: SPACE.xs, + children: [ + { id: 'amb-submit', type: 'button', name: 'Submit', label: 'Continue', size: 'lg', width: '100%' }, + { id: 'amb-alt', type: 'button', name: 'Secondary action', label: 'Secondary action', variant: 'ghost', width: '100%' }, + ], + }, + ], + }, + homeIndicator('amb-home'), + ], +}; + +const onboardingMobile: Structure = { + name: 'onboarding-mobile', + anatomy: 'onboarding', + surface: 'mobile', + description: + 'Phone onboarding page — a skip action, a centred glyph, heading and body line, page dots, and a full-width continue button at the bottom. One page of a paged first-run flow. The mobile realisation of onboarding.', + axes: { heroTreatment: 'none', density: 'airy', rhythm: 'uniform', alignment: 'centered' }, + nodes: [ + statusBar('om-status'), + { + id: 'om-content', type: 'frame', name: 'Content', width: '100%', height: '100%', layout: 'vertical', + gap: SPACE.lg, padding: [SPACE.xs, SPACE.md, SPACE.lg, SPACE.md], + children: [ + { + id: 'om-top', type: 'frame', name: 'Top bar', width: '100%', layout: 'horizontal', justifyContent: 'end', + children: [{ id: 'om-skip', type: 'button', name: 'Skip', label: 'Skip', variant: 'ghost' }], + }, + { + id: 'om-page', type: 'frame', name: 'Page', width: '100%', height: '100%', layout: 'vertical', + alignItems: 'center', justifyContent: 'center', gap: SPACE.md, + children: [ + { + id: 'om-glyph', type: 'frame', name: 'Glyph', width: 96, height: 96, cornerRadius: RADIUS.lg, + fill: TINT.accent.bg, layout: 'vertical', alignItems: 'center', justifyContent: 'center', + children: [{ id: 'om-glyph-icon', type: 'icon', icon: 'sparkles', iconSize: 40, iconColor: TINT.accent.ink }], + }, + { id: 'om-title', type: 'text', content: 'Page heading', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary, textAlign: 'center' }, + { id: 'om-body', type: 'text', content: 'Body copy — what this step offers, in a sentence.', fontSize: TYPE.body, color: COLOR.textSecondary, textAlign: 'center', lineHeight: 1.5, maxWidth: 320 }, + { + id: 'om-dots', type: 'frame', name: 'Page dots', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs, + children: [ + { id: 'om-dot-1', type: 'frame', width: 24, height: 8, cornerRadius: 999, fill: COLOR.accent }, + { id: 'om-dot-2', type: 'frame', width: 8, height: 8, cornerRadius: 999, fill: COLOR.border }, + { id: 'om-dot-3', type: 'frame', width: 8, height: 8, cornerRadius: 999, fill: COLOR.border }, + ], + }, + ], + }, + { id: 'om-next', type: 'button', name: 'Continue', label: 'Continue', size: 'lg', width: '100%' }, + ], + }, + homeIndicator('om-home'), + ], +}; + +const feedMobile: Structure = { + name: 'feed-mobile', + anatomy: 'feed', + surface: 'mobile', + description: + 'Phone feed — large-title nav bar with search and compose actions over a full-bleed list of rows (author monogram, title, one-line preview, time), with a pinned four-tab bar. A mobile-first anatomy.', + axes: { heroTreatment: 'none', density: 'dense', rhythm: 'uniform', alignment: 'left' }, + nodes: [ + statusBar('fm-status'), + { id: 'fm-nav', type: 'nav-bar', name: 'Nav bar', title: 'Feed', largeTitle: true, trailing: [{ icon: 'search' }, { icon: 'square-pen' }] }, + { + id: 'fm-list', type: 'frame', name: 'List', width: '100%', layout: 'vertical', + children: [1, 2, 3, 4, 5, 6].map((i) => ({ + id: `fm-row-${i}`, type: 'list-row' as const, title: 'Author name', subtitle: 'Preview line — to confirm', + leading: { initials: 'AB' }, trailing: { text: 'Time' }, divider: i < 6, + })), + }, + { + id: 'fm-tabs', type: 'tab-bar', name: 'Tab bar', + items: [ + { icon: 'house', label: 'Feed', active: true }, + { icon: 'search', label: 'Search' }, + { icon: 'inbox', label: 'Inbox' }, + { icon: 'user', label: 'Profile' }, + ], + }, + ], +}; + +const detailMobile: Structure = { + name: 'detail-mobile', + anatomy: 'detail', + surface: 'mobile', + description: + 'Phone item detail — a back nav bar with a share action, a media slot, title and meta, a body paragraph, an attribute list of rows, and a primary action pinned over the home indicator. A mobile-first anatomy.', + axes: { heroTreatment: 'split', density: 'balanced', rhythm: 'asymmetric', alignment: 'left' }, + nodes: [ + statusBar('dtm-status'), + { id: 'dtm-nav', type: 'nav-bar', name: 'Nav bar', title: 'Item', leading: { label: 'Back' }, trailing: [{ icon: 'share' }] }, + mobileContent('dtm-content', [ + { + id: 'dtm-media', type: 'frame', name: 'Media slot', width: '100%', height: 220, cornerRadius: RADIUS.lg, + fill: TINT.neutral.bg, layout: 'vertical', alignItems: 'center', justifyContent: 'center', + children: [{ id: 'dtm-media-icon', type: 'icon', icon: 'image', iconSize: 32, iconColor: TINT.neutral.ink }], + }, + { + id: 'dtm-head', type: 'frame', width: '100%', layout: 'vertical', gap: SPACE.xs, + children: [ + { id: 'dtm-title', type: 'text', content: 'Item title', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary }, + { id: 'dtm-meta', type: 'text', content: 'Meta — TBD', fontSize: TYPE.label, color: COLOR.textSecondary }, + ], + }, + { id: 'dtm-body', type: 'text', content: 'Body copy — describe the item in two or three sentences so the reader knows what acting on it means.', fontSize: TYPE.body, color: COLOR.textSecondary, lineHeight: 1.5 }, + rowGroup('dtm-attrs', 'Details', [1, 2, 3].map((i) => ({ + id: `dtm-attr-${i}`, type: 'list-row' as const, title: 'Attribute', trailing: { text: 'Value' }, + }))), + // Clears the pinned bar at the end of the scroll: with the root's 34px + // inset it reaches the bar's 110px (12 + 52 + 46). + { id: 'dtm-end', type: 'frame', name: 'End spacer', width: '100%', height: 76 }, + ]), + { + id: 'dtm-bar', type: 'frame', name: 'Action bar', position: 'fixed', bottom: 0, width: '100%', + // Vertical rhythm in spacer frames, not padding, so the bar is 110px + // tall on every spacing scale and the end spacer above stays exact. + layout: 'vertical', padding: [0, SPACE.md], fill: COLOR.bgSurface, borderTop: { width: 1, color: COLOR.border }, + children: [ + { id: 'dtm-bar-top', type: 'frame', name: 'Top spacer', width: '100%', height: 12 }, + { id: 'dtm-cta', type: 'button', name: 'Primary action', label: 'Primary action', size: 'lg', width: '100%' }, + { id: 'dtm-bar-inset', type: 'frame', name: 'Home indicator inset', width: '100%', height: 46 }, + ], + }, + homeIndicator('dtm-home'), + ], +}; + const structureMap = new Map([ ['marquee-hero', marqueeHero], ['bento-grid', bentoGrid], @@ -1461,10 +1780,75 @@ const structureMap = new Map([ ['segmented-control', segmentedControl], ['breadcrumb', breadcrumb], ['initials-avatar', initialsAvatar], + // Phase 30 slice E — mobile realisations + ['dashboard-mobile', dashboardMobile], + ['settings-mobile', settingsMobile], + ['auth-mobile', authMobile], + ['onboarding-mobile', onboardingMobile], + ['feed-mobile', feedMobile], + ['detail-mobile', detailMobile], ]); -export function listStructures(): { name: string; kind: 'page' | 'component'; description: string; axes?: StructureAxes }[] { - return Array.from(structureMap.values()).map(({ name, kind, description, axes }) => ({ name, kind: kind ?? 'page', description, ...(axes ? { axes } : {}) })); +// ── anatomy and realisation (Phase 30 slice E) ────────────────────────────── +// A structure realises an anatomy (a screen kind) for one surface. Names stay +// unique; `apply_structure` resolves an anatomy name to the realisation for +// the canvas's type, so `dashboard` on a phone canvas stamps `dashboard-mobile`. + +type StructureSurface = NonNullable; + +export const structureAnatomy = (s: Structure): string => s.anatomy ?? s.name; +export const structureSurface = (s: Structure): StructureSurface => s.surface ?? 'web'; + +/** A realisation's surface covers a canvas type when it is that type or `any`. */ +const covers = (surface: StructureSurface, type: SurfaceType): boolean => surface === 'any' || surface === type; + +/** The registry invariant: (anatomy, surface) is unique, `any` overlapping + * both types, and no name shadows another structure's anatomy (anatomy + * resolution runs first, so such a structure could never be reached by name). */ +function checkRegistry(structures: Structure[]): void { + for (const [i, a] of structures.entries()) { + for (const b of structures.slice(i + 1)) { + const sameAnatomy = structureAnatomy(a) === structureAnatomy(b); + if (sameAnatomy && SURFACE_TYPES.some((t) => covers(structureSurface(a), t) && covers(structureSurface(b), t))) { + throw new Error(`Structures "${a.name}" and "${b.name}" both realise anatomy "${structureAnatomy(a)}" on the same surface.`); + } + for (const [x, y] of [[a, b], [b, a]]) { + if (x.name === structureAnatomy(y) && structureAnatomy(x) !== x.name) { + throw new Error(`Structure "${x.name}" is named after the anatomy of "${y.name}" but realises "${structureAnatomy(x)}".`); + } + } + } + } +} +checkRegistry([...structureMap.values()]); + +export interface StructureListing { + name: string; + kind: 'page' | 'component'; + description: string; + axes?: StructureAxes; + anatomy: string; + surface: StructureSurface; + /** The canvas types this entry's anatomy has a realisation for. */ + surfaces: SurfaceType[]; +} + +/** Every structure, or with `type` only those that stamp on that canvas type. */ +export function listStructures(opts: { type?: SurfaceType } = {}): StructureListing[] { + const all = Array.from(structureMap.values()); + const realisedOn = (anatomy: string): SurfaceType[] => + SURFACE_TYPES.filter((t) => all.some((s) => structureAnatomy(s) === anatomy && covers(structureSurface(s), t))); + return all + .filter((s) => opts.type === undefined || covers(structureSurface(s), opts.type)) + .map((s) => ({ + name: s.name, + kind: s.kind ?? 'page', + description: s.description, + ...(s.axes ? { axes: s.axes } : {}), + anatomy: structureAnatomy(s), + surface: structureSurface(s), + surfaces: realisedOn(structureAnatomy(s)), + })); } export function getStructure(name: string): Structure | undefined { @@ -1472,9 +1856,46 @@ export function getStructure(name: string): Structure | undefined { } export function registerStructure(structure: Structure): void { + checkRegistry([...[...structureMap.values()].filter((s) => s.name !== structure.name), structure]); structureMap.set(structure.name, structure); } +/** The structure a name stamps on a canvas of `type`: an anatomy name picks + * its realisation for the type; any other name must cover the type itself. + * @throws naming what is available when nothing fits. */ +export function resolveStructure(name: string, type: SurfaceType): Structure { + const all = Array.from(structureMap.values()); + const other: SurfaceType = type === 'web' ? 'mobile' : 'web'; + const available = (kind: Structure['kind']): string => { + const k = kind ?? 'page'; + const names = [...new Set(all.filter((s) => (s.kind ?? 'page') === k && covers(structureSurface(s), type)).map(structureAnatomy))]; + return names.length > 0 + ? `${k === 'page' ? 'Page' : 'Component'} anatomies with a ${type} realisation: ${names.join(', ')}.` + : `No ${k} structure has a ${type} realisation.`; + }; + + const realisations = all.filter((s) => structureAnatomy(s) === name); + if (realisations.length > 0) { + const fit = realisations.find((s) => covers(structureSurface(s), type)); + if (fit) return fit; + // Every realisation of it covers the other type, or one would fit. + throw new Error(`"${name}" has no ${type} realisation. ${available(realisations[0].kind)} Pass realisation: "${other}" to stamp its ${other} realisation anyway.`); + } + + const structure = structureMap.get(name); + if (!structure) { + throw new Error(`Structure "${name}" not found. Use list_structures to see available structures.`); + } + const surface = structureSurface(structure); + if (!covers(surface, type)) { + throw new Error( + `"${name}" is the ${surface} realisation of "${structureAnatomy(structure)}" and this canvas is ${type}. ` + + `${available(structure.kind)} Pass realisation: "${surface}" to stamp it anyway.`, + ); + } + return structure; +} + // ── apply ──────────────────────────────────────────────────────────────── /** Neutral defaults for the color tokens these scaffolds reference, so an @@ -1547,6 +1968,14 @@ const DEFAULT_SCAFFOLD_TYPOGRAPHY: Record) instead of leaving browser * white below/around the content. */ @@ -1560,13 +1989,18 @@ function walkNodes(node: SceneNode, visit: (n: SceneNode) => void): void { export interface ApplyStructureResult { applied: string; kind: 'page' | 'component'; + /** Phase 30 — the anatomy stamped and the surface of its realisation. */ + anatomy: string; + surface: 'web' | 'mobile' | 'any'; axes?: StructureAxes; /** Top-level node ids inserted under the canvas root (page) or target (component). */ insertedNodeIds: string[]; /** Component stamps only: template id → live (re-keyed) id, for targeting follow-up ops. */ idMap?: Record; - /** Fillable placeholders (text/image) with their role label, for populating. */ - placeholders: { id: string; role: string }[]; + /** Fillable placeholders (text/image) with their role label, for populating. + * A primitive's copy is a placeholder too; `prop` names the field to set + * (`title` on a list-row or nav-bar, `label` on a button). */ + placeholders: { id: string; role: string; prop?: 'title' | 'label' }[]; /** Color tokens seeded with neutral defaults because they were unresolved. */ seededColors: string[]; /** Phase 27 — spacing/radius/elevation/typography tokens seeded the same way. */ @@ -1618,12 +2052,9 @@ function rekeyComponentNodes(canvas: Canvas, structureName: string, nodes: Scene export function applyStructure( canvas: Canvas, structureName: string, - opts: { replace?: boolean; existingColors?: Set; existingTokens?: Set; targetId?: string } = {}, + opts: { replace?: boolean; existingColors?: Set; existingTokens?: Set; targetId?: string; realisation?: SurfaceType } = {}, ): ApplyStructureResult { - const structure = getStructure(structureName); - if (!structure) { - throw new Error(`Structure "${structureName}" not found. Use list_structures to see available structures.`); - } + const structure = resolveStructure(structureName, opts.realisation ?? resolveSurface(canvas).type); const kind = structure.kind ?? 'page'; let inserted: SceneNode[]; @@ -1644,12 +2075,12 @@ export function applyStructure( // provenance.structure names the PAGE shape; component stamps don't shape it). } else { if (opts.targetId !== undefined && opts.targetId !== 'document' && opts.targetId !== canvas.root.id) { - throw new Error(`Structure "${structureName}" is a page scaffold — it stamps at the canvas root and does not take a targetId. Component structures (see list_structures kind) do.`); + throw new Error(`Structure "${structure.name}" is a page scaffold — it stamps at the canvas root and does not take a targetId. Component structures (see list_structures kind) do.`); } const existing = canvas.root.children ?? []; if (existing.length > 0 && !opts.replace) { throw new Error( - `Canvas root already has ${existing.length} child node(s). Pass replace: true to clear them and stamp "${structureName}", or use a fresh canvas.`, + `Canvas root already has ${existing.length} child node(s). Pass replace: true to clear them and stamp "${structure.name}", or use a fresh canvas.`, ); } @@ -1686,13 +2117,16 @@ export function applyStructure( } // One pass: collect fillable placeholders + referenced color tokens. - const placeholders: { id: string; role: string }[] = []; + const placeholders: ApplyStructureResult['placeholders'] = []; const referenced = new Set(); const referencedOther = new Set(); // spacing / radius / elevation / typography refs for (const root of inserted) { walkNodes(root, (n) => { + if (PRIMITIVE_TYPES.has(n.type)) PRIMITIVE_COLOR_TOKENS.forEach((t) => referenced.add(t)); if (n.type === 'text' && typeof n.content === 'string') placeholders.push({ id: n.id, role: n.content }); else if (n.type === 'image') placeholders.push({ id: n.id, role: 'image' }); + else if ((n.type === 'list-row' || n.type === 'nav-bar') && typeof n.title === 'string') placeholders.push({ id: n.id, role: n.title, prop: 'title' }); + else if (n.type === 'button' && typeof n.label === 'string') placeholders.push({ id: n.id, role: n.label, prop: 'label' }); for (const field of COLOR_FIELDS) { const v = (n as unknown as Record)[field]; if (typeof v === 'string' && v.startsWith('$')) referenced.add(v.slice(1)); @@ -1762,6 +2196,8 @@ export function applyStructure( return { applied: structure.name, kind, + anatomy: structureAnatomy(structure), + surface: structureSurface(structure), ...(structure.axes ? { axes: structure.axes } : {}), insertedNodeIds: inserted.map((n) => n.id), ...(idMap ? { idMap } : {}), diff --git a/src/types.ts b/src/types.ts index ec48898..55f5f5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -383,6 +383,12 @@ export interface Structure { /** Taxonomy tags — page structures only (the diversification signal and the * "variety" rubric axis read them; component stamps don't shape the page). */ axes?: StructureAxes; + /** Phase 30 — the screen kind this structure realises (default `name`). + * Structures sharing an anatomy are realisations of it for different surfaces. */ + anatomy?: string; + /** Phase 30 — the canvas type this realisation is for (default `web`); + * `any` stamps on every type. (anatomy, surface) is unique in the registry. */ + surface?: 'web' | 'mobile' | 'any'; /** Placeholder children inserted under `canvas.root` by `apply_structure`. */ nodes: SceneNode[]; } diff --git a/test-discoverability.ts b/test-discoverability.ts index 189ae8c..2d3f4b0 100644 --- a/test-discoverability.ts +++ b/test-discoverability.ts @@ -86,6 +86,22 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); expect('every structure named (backticked) in the README', missing.length === 0, missing.join(', ')); } +// ── 5b. Phase 30 slice E: anatomies and surfaces ───────────────────────────── +// An agent asking for a phone screen must learn which anatomies resolve on a +// mobile canvas, and the two parameters that steer resolution. +{ + const instructions = /const INSTRUCTIONS = `([\s\S]*?)`;/.exec(indexSrc)?.[1] ?? ''; + const tool = (name: string) => new RegExp(`'${name}',\\s*\`([\\s\\S]*?)\`,`).exec(indexSrc)?.[1] ?? ''; + const anatomies = [...new Set(listStructures({ type: 'mobile' }).filter((s) => s.kind === 'page').map((s) => s.anatomy))]; + expect('there are mobile page anatomies to document', anatomies.length > 0); + const missingInstr = anatomies.filter((a) => !instructions.includes(`${a}-mobile`)); + expect('every mobile realisation named in INSTRUCTIONS', instructions !== '' && missingInstr.length === 0, missingInstr.join(', ')); + const missingGl = anatomies.filter((a) => !guidelines.includes(`\`${a}\``)); + expect('every mobile anatomy named (backticked) in GUIDELINES', missingGl.length === 0, missingGl.join(', ')); + expect('apply_structure docstring names realisation', /realisation/.test(tool('apply_structure'))); + expect('list_structures docstring names canvasId', /canvasId/.test(tool('list_structures'))); +} + // ── 6. every cliche tell is surfaced where agents look ─────────────────────── // Tell slugs are kebab ('accent-hue') but the agent surfaces describe them in // prose, so each slug maps to a phrase that MUST appear in both the index.ts diff --git a/test-mobile-primitives.ts b/test-mobile-primitives.ts index 1360bf9..58d2445 100644 --- a/test-mobile-primitives.ts +++ b/test-mobile-primitives.ts @@ -148,6 +148,9 @@ function styleOf(html: string, id: string): string { check('icon-only button is square', iconOnly.includes('width: 44px') && iconOnly.includes('height: 44px') && iconOnly.includes('padding: 0'), iconOnly); const floor = styleOf(render(doc([{ id: 'b', type: 'button', label: 'Go', size: 'sm', minHeight: 44 }])), 'b'); check('minHeight raises a button (height = min-height)', floor.includes('height: 44px; min-height: 44px'), floor); + const capped = styleOf(render(doc([{ id: 'b', type: 'button', label: 'See all', maxWidth: '50%' }])), 'b'); + check('maxWidth caps a labelled button (the label truncates inside it)', capped.includes('max-width: 50%'), capped); + check('a labelled button without maxWidth stays inside its column', sizes[1].includes('max-width: 100%'), sizes[1]); check('buttonBox matches: sm 32×32, icon-only square, never narrower than tall', buttonBox({ id: 'x', type: 'button', label: 'Go', size: 'sm' }).h === 32 && buttonBox({ id: 'x', type: 'button', icon: 'plus', width: 200 }).w === 44 diff --git a/test-patterns-mobile.ts b/test-patterns-mobile.ts new file mode 100644 index 0000000..67fa21d --- /dev/null +++ b/test-patterns-mobile.ts @@ -0,0 +1,77 @@ +import './test-env.js'; +/** + * Phase 30 slice E (FR-E4) — the pattern gate's layout half for the mobile + * realisations. test-patterns.ts holds them to > 95, zero tells and zero + * mobile findings in fast mode across the themes; this runs what fast mode + * cannot see, once per realisation (both checks are layout, not colour): + * + * 1. detailed mode (a Chrome layout pass) raises no `mobile-*` finding; + * 2. a full five-perturbation stress run, built the way canvas_stress builds + * it (instances expanded, the mobile perturbation rules), is CLEAN. + * + * Components marked for every type get the detailed-mode check too. + * Needs Chrome. Run with: npx tsx test-patterns-mobile.ts + */ +import { listStructures, applyStructure } from './src/structures.js'; +import { createCanvas } from './src/scene-graph.js'; +import { evaluateCanvas } from './src/evaluate.js'; +import { getVariables, resolveVariables } from './src/variables.js'; +import { renderToHtml } from './src/renderer.js'; +import { expandInstances } from './src/drift.js'; +import { computeLayout, shutdown } from './src/screenshot.js'; +import { applyPerturbation, compareLayouts, PERTURBATION_NAMES } from './src/stress.js'; +import type { Canvas } from './src/types.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +async function mobileFindings(canvas: Canvas): Promise { + const ev = await evaluateCanvas(canvas, { mode: 'detailed' }); + return ev.issues.flatMap((i) => (i.rule?.startsWith('mobile-') ? [`${i.rule}@${i.nodeId}`] : [])); +} + +/** Warnings from a full stress run, as canvas_stress computes them. */ +async function stressWarnings(canvas: Canvas): Promise { + const w = canvas.root.width as number; + const h = canvas.root.height as number; + const merged = getVariables(canvas); + const base = expandInstances(canvas.root, canvas); + const layoutOf = (root: typeof base) => + computeLayout(renderToHtml(resolveVariables(structuredClone(root), merged), w, h, canvas), undefined, 25, { width: w, height: h }); + const baseline = await layoutOf(base); + const warnings: string[] = []; + for (const p of PERTURBATION_NAMES) { + const { root, touched } = applyPerturbation(p, base, { type: 'mobile' }); + if (touched.length === 0) continue; + for (const f of compareLayouts(baseline, await layoutOf(root), touched, w)) { + if (f.severity === 'warning') warnings.push(`${p}:${f.kind}@${f.nodeId}`); + } + } + return warnings; +} + +const realisations = listStructures({ type: 'mobile' }).filter((s) => s.kind === 'page'); +check('six mobile realisations', realisations.length === 6, realisations.map((s) => s.name).join(', ')); + +for (const s of realisations) { + const canvas = createCanvas(`gate-${s.name}`, undefined, {}, { type: 'mobile' }); + applyStructure(canvas, s.name); + const findings = await mobileFindings(canvas); + check(`${s.name}: detailed mode raises no mobile finding`, findings.length === 0, findings.slice(0, 4).join(' | ')); + const warnings = await stressWarnings(canvas); + check(`${s.name}: full stress run CLEAN`, warnings.length === 0, warnings.slice(0, 4).join(' | ')); +} + +for (const s of listStructures().filter((x) => x.kind === 'component' && x.surface === 'any')) { + const canvas = createCanvas(`gate-${s.name}`, undefined, {}, { type: 'mobile' }); + applyStructure(canvas, s.name); + const findings = await mobileFindings(canvas); + check(`${s.name} (any): detailed mode raises no mobile finding on a phone`, findings.length === 0, findings.slice(0, 4).join(' | ')); +} + +console.log(allPass ? '\nAll mobile pattern-gate tests passed.' : '\nSOME TESTS FAILED'); +await shutdown(); +process.exit(allPass ? 0 : 1); diff --git a/test-patterns.ts b/test-patterns.ts index 37612b0..fa977a6 100644 --- a/test-patterns.ts +++ b/test-patterns.ts @@ -10,12 +10,29 @@ import { createCanvas } from './src/scene-graph.js'; import { listStructures, applyStructure } from './src/structures.js'; import { getPreset } from './src/presets.js'; import { evaluateCanvas } from './src/evaluate.js'; +import { generateDesignSystem, PERSONALITY_NAMES } from './src/design-language.js'; +import { setVariables } from './src/variables.js'; +import { getCanvasTokens } from './src/workspaces.js'; +import type { Canvas } from './src/types.js'; const BAR = 95; // every pattern must score STRICTLY above this (> 95) // Neutral default (applyStructure's seeded defaults) + every bundled preset. // All presets now use designed off-white/off-black (no pure #ffffff/#000000), // so a pattern must hold up under each without tripping a tell. const THEMES = ['default', 'dark', 'light', 'material', 'minimal'] as const; +const CATEGORIES = ['spacing', 'color', 'typography', 'structure', 'consistency', 'cliche', 'usability']; + +/** The token names a canvas already resolves, as apply_structure passes them. */ +function existing(canvas: Canvas): { existingColors: Set; existingTokens: Set } { + const m = getCanvasTokens(canvas); + return { + existingColors: new Set(Object.keys(m.colors ?? {})), + existingTokens: new Set([ + ...Object.keys(m.spacing ?? {}), ...Object.keys(m.radius ?? {}), ...Object.keys(m.typography ?? {}), + ...Object.keys(m.elevation ?? {}).map((k) => `elevation.${k}`), + ]), + }; +} let passed = 0; let failed = 0; @@ -24,17 +41,31 @@ function assert(condition: boolean, label: string) { else { console.log(` ✗ ${label}`); failed++; } } +/** Phase 30 slice E (FR-E4) — a mobile realisation is also held to zero + * findings from the mobile ruleset, at any severity. */ +const mobileFindings = (issues: { rule?: string }[]): string[] => + issues.flatMap((i) => (i.rule?.startsWith('mobile-') ? [i.rule] : [])); + async function main() { const pages = listStructures().filter((s) => s.kind === 'page'); console.log(`\nPattern quality gate — ${pages.length} page structures × ${THEMES.length} themes (bar: > ${BAR}, zero tells)\n`); for (const s of pages) { + const mobile = s.surface === 'mobile'; for (const theme of THEMES) { - const canvas = createCanvas(`gate-${s.name}-${theme}`); + // A realisation stamps on a canvas of its own surface. + const canvas = createCanvas(`gate-${s.name}-${theme}`, undefined, {}, mobile ? { type: 'mobile' } : undefined); applyStructure(canvas, s.name, { replace: true }); if (theme !== 'default') { const preset = getPreset(theme); - if (preset) canvas.variables = structuredClone(preset.variables); + // On a phone the presets lend colour, spacing, radius and depth but + // not their type scale: every built-in preset sets a 14–15px body, + // which mobile-type-floor rightly flags on every phone screen whatever + // its structure. generate_design_system is the phone type path, and + // the personalities below hold the realisations to it. + if (preset) canvas.variables = mobile + ? { ...structuredClone(preset.variables), typography: canvas.variables.typography } + : structuredClone(preset.variables); } // The gate measures the PATTERN's taste/craft — coverage (Phase 24) // demands sibling state variants, which is a property of a designed @@ -42,15 +73,44 @@ async function main() { const ev = await evaluateCanvas(canvas, { mode: 'fast', genre: theme === 'default' ? undefined : theme, - categories: ['spacing', 'color', 'typography', 'structure', 'consistency', 'cliche', 'usability'], + categories: CATEGORIES, }); const tells = ev.issues.filter((i) => i.category === 'cliche'); - const ok = ev.overallScore > BAR && tells.length === 0; - const detail = ok ? `${ev.overallScore}` : `${ev.overallScore}${tells.length ? ' · tells: ' + tells.map((t) => t.tell).join(', ') : ''}`; + const rules = mobile ? mobileFindings(ev.issues) : []; + const ok = ev.overallScore > BAR && tells.length === 0 && rules.length === 0; + const detail = ok ? `${ev.overallScore}` : `${ev.overallScore}${tells.length ? ' · tells: ' + tells.map((t) => t.tell).join(', ') : ''}${rules.length ? ' · mobile: ' + rules.join(', ') : ''}`; assert(ok, `${s.name} @ ${theme} — ${detail}`); } } + // The phone type path: each personality's generated system on a mobile + // canvas, stamped after it as an agent would (tokens it already has stay). + const realisations = pages.filter((s) => s.surface === 'mobile'); + console.log(`\nMobile realisations on generated phone systems — ${realisations.length} × ${PERSONALITY_NAMES.length} personalities\n`); + for (const s of realisations) { + for (const personality of PERSONALITY_NAMES) { + const canvas = createCanvas(`gate-${s.name}-${personality}`, undefined, {}, { type: 'mobile' }); + setVariables(canvas, generateDesignSystem('#166534', personality, { surface: 'mobile' }).variables); + applyStructure(canvas, s.name, { replace: true, ...existing(canvas) }); + const ev = await evaluateCanvas(canvas, { mode: 'fast', categories: CATEGORIES }); + const tells = ev.issues.filter((i) => i.category === 'cliche'); + const rules = mobileFindings(ev.issues); + const ok = ev.overallScore > BAR && tells.length === 0 && rules.length === 0; + assert(ok, `${s.name} @ ${personality} — ${ev.overallScore}${tells.length ? ' · tells: ' + tells.map((t) => t.tell).join(', ') : ''}${rules.length ? ' · mobile: ' + rules.join(', ') : ''}`); + } + } + + // Components marked for every type stamp onto a bare phone canvas without a + // single mobile finding (their score is the web gate's business). + const anyComponents = listStructures().filter((s) => s.kind === 'component' && s.surface === 'any'); + console.log(`\nComponents marked for every type, on a phone canvas — ${anyComponents.length}\n`); + for (const s of anyComponents) { + const canvas = createCanvas(`gate-${s.name}-mobile`, undefined, {}, { type: 'mobile' }); + applyStructure(canvas, s.name); + const rules = mobileFindings((await evaluateCanvas(canvas, { mode: 'fast' })).issues); + assert(rules.length === 0, `${s.name} on mobile${rules.length ? ' — ' + rules.join(', ') : ''}`); + } + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); } diff --git a/test-scaffold-system.ts b/test-scaffold-system.ts index 1eabc47..f20476c 100644 --- a/test-scaffold-system.ts +++ b/test-scaffold-system.ts @@ -68,13 +68,19 @@ function usedSizes(canvas: Canvas): Map { } const structures = listStructures(); + +/** A structure stamps on a canvas of its own surface (Phase 30 slice E): + * mobile realisations on a phone canvas, everything else on web as before. */ +const typeOf = (surface: string): 'web' | 'mobile' => (surface === 'mobile' ? 'mobile' : 'web'); +const canvasFor = (name: string, surface: string) => + createCanvas(name, undefined, {}, surface === 'mobile' ? { type: 'mobile' } : undefined); check('structure library is non-empty', structures.length > 0, `${structures.length} structures`); // ── 1. on the generated scale ──────────────────────────────────────────────── console.log('\n── every scaffold lands on the generated type scale ──'); -for (const { name, kind } of structures) { - const canvas = createCanvas(`scale-${name}`); - setVariables(canvas, generateDesignSystem(SEED, PERSONALITY).variables); +for (const { name, kind, surface } of structures) { + const canvas = canvasFor(`scale-${name}`, surface); + setVariables(canvas, generateDesignSystem(SEED, PERSONALITY, { surface: typeOf(surface) }).variables); applyStructure(canvas, name, { targetId: 'document' }); const cv = getCanvas(canvas.id)!; @@ -86,9 +92,10 @@ for (const { name, kind } of structures) { // ── 2. stress-clean ────────────────────────────────────────────────────────── console.log('\n── every scaffold survives hostile content ──'); -for (const { name, kind } of structures) { - const canvas = createCanvas(`stress-${name}`); - setVariables(canvas, generateDesignSystem(SEED, PERSONALITY).variables); +for (const { name, kind, surface } of structures) { + const canvas = canvasFor(`stress-${name}`, surface); + const type = typeOf(surface); + setVariables(canvas, generateDesignSystem(SEED, PERSONALITY, { surface: type }).variables); applyStructure(canvas, name, { targetId: 'document' }); const cv = getCanvas(canvas.id)!; @@ -101,7 +108,7 @@ for (const { name, kind } of structures) { const offenders: string[] = []; for (const p of PERTURBATION_NAMES as PerturbationName[]) { - const { root, touched } = applyPerturbation(p, base); + const { root, touched } = applyPerturbation(p, base, { type }); if (touched.length === 0) continue; const layout = await computeLayout( renderToHtml(resolveVariables(structuredClone(root), merged), w, h, cv), undefined, 25, { width: w, height: h }); diff --git a/test-structures-mcp.ts b/test-structures-mcp.ts new file mode 100644 index 0000000..26c5e2f --- /dev/null +++ b/test-structures-mcp.ts @@ -0,0 +1,76 @@ +// Phase 30 slice E — anatomy resolution through the REAL tool handlers. +// test-structures covers resolveStructure / listStructures / applyStructure; +// this drives list_structures and apply_structure over MCP stdio, so a handler +// that stops reading the canvas type or drops `realisation` fails here. +// +// Usage: npx tsx test-structures-mcp.ts + +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +const home = mkdtempSync(join(tmpdir(), 'framesmith-structures-mcp-')); +const cwd = mkdtempSync(join(tmpdir(), 'framesmith-structures-mcp-cwd-')); +const transport = new StdioClientTransport({ + command: resolve('node_modules/.bin/tsx'), + args: [resolve('src/index.ts')], + cwd, + env: { ...process.env, FRAMESMITH_HOME: home, CANVAS_MCP_PROJECT_DIR: cwd, FRAMESMITH_VIEWER_URL: 'http://localhost:1' } as Record, + stderr: 'ignore', +}); +const client = new Client({ name: 'test-structures-mcp', version: '0' }); +await client.connect(transport); + +async function tool(name: string, args: Record): Promise<{ data: any; text: string; isError: boolean }> { + const r = await client.callTool({ name, arguments: args }) as { content: { text: string }[]; isError?: boolean }; + const text = r.content[0]?.text ?? ''; + let data: any = text; + try { data = JSON.parse(text); } catch { /* an error string */ } + return { data, text, isError: r.isError === true }; +} + +const names = (list: { name: string }[]): string[] => list.map((s) => s.name); + +try { + const phone = (await tool('canvas_create', { name: 'phone', type: 'mobile' })).data.canvasId; + const web = (await tool('canvas_create', { name: 'web' })).data.canvasId; + + // list_structures: neither → web; canvasId → that canvas's type; type → that type. + const plain = (await tool('list_structures', {})).data; + check('no canvasId or type lists web', names(plain).includes('dashboard') && !names(plain).includes('dashboard-mobile')); + check('entries carry anatomy, surface and surfaces', plain.find((s: any) => s.name === 'dashboard')?.anatomy === 'dashboard' + && JSON.stringify(plain.find((s: any) => s.name === 'dashboard')?.surfaces) === '["web","mobile"]'); + const byCanvas = (await tool('list_structures', { canvasId: phone })).data; + check('canvasId of a mobile canvas lists the mobile realisations', names(byCanvas).includes('feed-mobile') && !names(byCanvas).includes('pricing')); + check('canvasId of a web canvas lists web', names((await tool('list_structures', { canvasId: web })).data).includes('pricing')); + check('type "mobile" lists the mobile realisations', names((await tool('list_structures', { type: 'mobile' })).data).includes('detail-mobile')); + const both = await tool('list_structures', { canvasId: phone, type: 'web' }); + check('canvasId and type together is an error', both.isError && /not both/.test(both.text), both.text); + const missing = await tool('list_structures', { canvasId: 'nope' }); + check('an unknown canvasId is an error', missing.isError && /Canvas not found/.test(missing.text), missing.text); + + // apply_structure resolves by the canvas type. + const stamped = (await tool('apply_structure', { canvasId: phone, structure: 'dashboard' })).data; + check('dashboard on a mobile canvas stamps dashboard-mobile', stamped.applied === 'dashboard-mobile' && stamped.anatomy === 'dashboard' && stamped.surface === 'mobile', JSON.stringify({ applied: stamped.applied, anatomy: stamped.anatomy })); + const refused = await tool('apply_structure', { canvasId: phone, structure: 'pricing', replace: true }); + check('pricing on a mobile canvas refuses, listing the mobile anatomies', refused.isError && /no mobile realisation.*dashboard, settings, auth, onboarding, feed, detail/.test(refused.text), refused.text); + const forced = (await tool('apply_structure', { canvasId: phone, structure: 'dashboard', replace: true, realisation: 'web' })).data; + check('realisation "web" stamps the web dashboard on a mobile canvas', forced.applied === 'dashboard' && forced.surface === 'web', forced.applied); + + // A canvas re-declared mobile resolves the same way. + await tool('canvas_set_type', { canvasId: web, type: 'mobile' }); + check('canvas_set_type to mobile changes what settings resolves to', (await tool('apply_structure', { canvasId: web, structure: 'settings' })).data.applied === 'settings-mobile'); +} finally { + await client.close(); +} + +console.log(allPass ? '\nAll structures MCP tests passed.' : '\nSOME TESTS FAILED'); +process.exit(allPass ? 0 : 1); diff --git a/test-structures.ts b/test-structures.ts index c61a37d..6fb75bb 100644 --- a/test-structures.ts +++ b/test-structures.ts @@ -1,6 +1,8 @@ // T2a runtime smoke test: registry behavior + structure invariants. // Run: npx tsx test-structures.ts -import { listStructures, getStructure, registerStructure } from './src/structures.js'; +import './test-env.js'; +import { listStructures, getStructure, registerStructure, resolveStructure, applyStructure } from './src/structures.js'; +import { createCanvas, setCanvasType } from './src/scene-graph.js'; import type { SceneNode, Structure } from './src/types.js'; let failures = 0; @@ -89,5 +91,76 @@ registerStructure(stub); check(getStructure('test-stub')?.description === 'temp', 'registered stub is retrievable'); check(listStructures().length === list.length + 1, `list grows by one after registration`); +// ── Phase 30 slice E — anatomy and realisation ───────────────────────────── +const throws = (fn: () => unknown, pattern: RegExp): boolean => { + try { fn(); return false; } catch (e) { return pattern.test((e as Error).message); } +}; +const page = (name: string, extra: Partial = {}): Structure => ({ + name, description: 'temp', axes: stub.axes, nodes: [{ id: `${name}-n`, type: 'frame' }], ...extra, +}); + +console.log('anatomy and surface — defaults and the six mobile realisations'); +const all = listStructures(); +const byName = new Map(all.map((s) => [s.name, s])); +check(byName.get('pricing')?.anatomy === 'pricing' && byName.get('pricing')?.surface === 'web', 'an unmarked structure defaults to anatomy = name, surface web'); +for (const anatomy of ['dashboard', 'settings', 'auth', 'onboarding', 'feed', 'detail']) { + const s = getStructure(`${anatomy}-mobile`)!; + const nodes = s?.nodes ?? []; + check(s?.anatomy === anatomy && s?.surface === 'mobile' && s?.kind === undefined, `${anatomy}-mobile realises ${anatomy} on mobile as a page`); + const last = nodes[nodes.length - 1]?.type; + check(nodes[0]?.type === 'status-bar' && (last === 'home-indicator' || last === 'tab-bar'), `${anatomy}-mobile starts with status-bar, ends with home-indicator or tab-bar (${last})`); +} +check(JSON.stringify(byName.get('dashboard')?.surfaces) === '["web","mobile"]', 'dashboard is realised on web and mobile'); +check(JSON.stringify(byName.get('feed-mobile')?.surfaces) === '["mobile"]', 'feed is realised on mobile only'); +check(JSON.stringify(byName.get('empty-state')?.surfaces) === '["web","mobile"]' && byName.get('empty-state')?.surface === 'any', 'an any component counts for both types'); + +console.log('listStructures({ type }) filters by the surface that covers the type'); +const mobileList = listStructures({ type: 'mobile' }).map((s) => s.name); +const webList = listStructures({ type: 'web' }).map((s) => s.name); +check(mobileList.includes('dashboard-mobile') && !mobileList.includes('dashboard') && !mobileList.includes('data-table'), 'mobile listing: realisations, no web-only structures'); +check(mobileList.includes('empty-state') && webList.includes('empty-state'), 'an any component is listed for both'); +check(webList.includes('dashboard') && !webList.includes('dashboard-mobile'), 'web listing: no mobile realisations'); +check(all.length === new Set([...mobileList, ...webList]).size, 'every structure is listed for some type'); + +console.log('resolveStructure — every branch'); +check(resolveStructure('dashboard', 'mobile').name === 'dashboard-mobile', 'anatomy name → the mobile realisation on mobile'); +check(resolveStructure('dashboard', 'web').name === 'dashboard', 'anatomy name → the web realisation on web'); +check(resolveStructure('empty-state', 'mobile').name === 'empty-state', 'an any component resolves on mobile'); +check(throws(() => resolveStructure('pricing', 'mobile'), /^"pricing" has no mobile realisation\. Page anatomies with a mobile realisation: dashboard, settings, auth, onboarding, feed, detail\. Pass realisation: "web"/), 'pricing on mobile refuses, naming the mobile page anatomies and realisation: "web"'); +check(throws(() => resolveStructure('feed', 'web'), /"feed" has no web realisation\..*Pass realisation: "mobile"/), 'feed on web refuses and names realisation: "mobile"'); +check(throws(() => resolveStructure('data-table', 'mobile'), /Component anatomies with a mobile realisation: .*empty-state/), 'a web-only component on mobile refuses, listing component anatomies'); +check(throws(() => resolveStructure('dashboard-mobile', 'web'), /mobile realisation of "dashboard" and this canvas is web.*Pass realisation: "mobile"/), 'a realisation name on the wrong type refuses with its force hint'); +check(resolveStructure('dashboard-mobile', 'mobile').name === 'dashboard-mobile', 'a realisation name on its own type is used as is'); +check(throws(() => resolveStructure('nope', 'web'), /not found/), 'unknown name → not found'); + +console.log('applyStructure resolves by the canvas type and realisation forces'); +const phone = createCanvas('e-phone', undefined, {}, { type: 'mobile' }); +const r1 = applyStructure(phone, 'dashboard'); +check(r1.applied === 'dashboard-mobile' && r1.anatomy === 'dashboard' && r1.surface === 'mobile', 'dashboard on a mobile canvas stamps dashboard-mobile'); +check(phone.metadata?.provenance?.structure === 'dashboard-mobile', 'provenance records the realisation name'); +check(r1.placeholders.some((p) => p.prop === 'title' && p.id === 'dm-row-1') && r1.placeholders.some((p) => p.prop === 'label'), 'primitive copy is listed as placeholders with its prop'); +check(['text-primary', 'text-secondary', 'border', 'bg-surface', 'danger', 'accent'].every((t) => r1.seededColors.includes(t)), 'the colour tokens primitives read are seeded'); +const plain = applyStructure(createCanvas('e-plain', undefined, {}, { type: 'mobile' }), 'feed'); +check(plain.seededColors.includes('text-primary'), 'a stamp that references no $text-primary still seeds it for its primitives'); +const forced = applyStructure(createCanvas('e-forced', undefined, {}, { type: 'mobile' }), 'dashboard', { realisation: 'web' }); +check(forced.applied === 'dashboard' && forced.surface === 'web', 'realisation: "web" stamps the web dashboard on a mobile canvas'); +const toWeb = applyStructure(createCanvas('e-web'), 'feed', { realisation: 'mobile' }); +check(toWeb.applied === 'feed-mobile', 'realisation: "mobile" stamps a mobile realisation on a web canvas'); +const retyped = createCanvas('e-retyped'); +setCanvasType(retyped.id, { type: 'mobile' }); +check(applyStructure(retyped, 'settings').applied === 'settings-mobile', 'a canvas re-declared mobile resolves to the mobile realisation'); +check(throws(() => applyStructure(createCanvas('e-refuse', undefined, {}, { type: 'mobile' }), 'pricing'), /no mobile realisation/), 'pricing on a mobile canvas refuses'); + +console.log('registry invariant — (anatomy, surface) unique, no shadowing names'); +const before = listStructures().length; +check(throws(() => registerStructure(page('dash-2', { anatomy: 'dashboard' })), /both realise anatomy "dashboard"/), 'a second web realisation of dashboard is rejected'); +check(throws(() => registerStructure(page('dash-any', { anatomy: 'dashboard', surface: 'any' })), /both realise anatomy "dashboard"/), 'an any realisation collides with the web and mobile ones'); +check(throws(() => registerStructure(page('feed', { anatomy: 'stream', surface: 'web' })), /named after the anatomy of "feed-mobile"/), 'a name that is another structure\'s anatomy is rejected'); +check(listStructures().length === before, 'rejected registrations leave the registry unchanged'); +registerStructure(page('feed-web', { anatomy: 'feed', surface: 'web' })); +check(resolveStructure('feed', 'web').name === 'feed-web', 'a web realisation of a new anatomy registers and resolves'); +registerStructure(page('feed-web', { anatomy: 'feed', surface: 'web', description: 'replaced' })); +check(getStructure('feed-web')?.description === 'replaced', 're-registering a name replaces it without colliding with itself'); + console.log(failures === 0 ? '\nT2a SMOKE TEST PASSED ✅' : `\nT2a SMOKE TEST FAILED ✗ (${failures})`); process.exit(failures === 0 ? 0 : 1); From 93ce10c039d79e38b7b62e92ba2fa703ae34f529 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 20:27:02 +0100 Subject: [PATCH 3/4] Close slice E verifier findings on stress, seeding and placeholders --- README.md | 10 +++--- docs/GUIDELINES.md | 14 ++++---- docs/specs/PHASE-30-SPEC.md | 13 +++++-- src/index.ts | 14 ++++---- src/structures.ts | 70 +++++++++++++++++++++++++------------ test-discoverability.ts | 3 ++ test-patterns-mobile.ts | 48 +++++++++++++++++++------ test-structures-mcp.ts | 20 ++++++++++- test-structures.ts | 30 ++++++++++++++-- 9 files changed, 165 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index b68f373..a8d8f04 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Create a new canvas. If `projectId` is omitted, it lands in the built-in `Untitl | `projectId` | string? | Target project. Defaults to the built-in Untitled project. See `project_list`. | | `width` | number? | Artboard width in px, a positive integer (default 1440, or the device width for `type: "mobile"`). | | `height` | number? | Artboard height in px, a positive integer (default 900, or the device height for `type: "mobile"`). Each dimension defaults on its own. | -| `type` | `"web"` \| `"mobile"`? | What medium the canvas is on. `"mobile"` declares a phone app screen: the artboard defaults to the device size and the root gets the device's safe-area insets as `safeArea: { top, bottom }`, which the renderer adds to the root's top and bottom padding. Omitted, the canvas is undeclared and judged as web. | +| `type` | `"web"` \| `"mobile"`? | What medium the canvas is on. `"mobile"` declares a phone app screen: the artboard defaults to the device size and the root gets the device's safe-area insets as `safeArea: { top, bottom }`, which the renderer adds to the root's top and bottom padding. Omitted, the canvas is undeclared and judged as web. On a mobile canvas `apply_structure` stamps the mobile realisations (`dashboard`, `settings`, `auth`, `onboarding`, `feed`, `detail`). | | `device` | string? | Device profile for `type: "mobile"` (default `"iphone-390"`, the only profile today: 390×844, insets 47 top and 34 bottom). Passing it without `type: "mobile"`, or naming an unknown device, is an error. An explicit `width`/`height` overrides the profile's size for that side; the insets stay the profile's. | The response also carries the artboard `width`/`height`, `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea` for a mobile canvas, and a `diversification` signal for the target project: the recently-built structures (newest first) and a hint to differ on at least one taxonomy axis, so successive canvases don't converge on the same layout. It's advisory — never blocking. @@ -627,7 +627,7 @@ List available layout structures — named scaffolds you stamp onto a canvas and | Param | Type | Description | |-------|------|-------------| -| `canvasId` | string? | List what stamps on this canvas: its declared type picks the realisations. Not with `type`. | +| `canvasId` | string? | List what stamps on this canvas: its declared type picks the realisations (an undeclared canvas lists web). Passing it with `type` is an error. | | `type` | `"web"` \| `"mobile"`? | List for this canvas type instead. With neither, the web listing. | | `projectId` | string? | If given, also return a `diversification` signal for the project (recently-built structures + a hint to differ on ≥ 1 axis), so you pick a shape that contrasts with recent work. Omit it to get just the structure list. | @@ -636,7 +636,7 @@ Two kinds: - **`page`** — whole-page scaffolds stamped once at the canvas root: `marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `dashboard`, `auth`, `pricing`, `settings`, `onboarding`. Each is tagged on four independent axes — `heroTreatment`, `density`, `rhythm`, `alignment` — so you can deliberately vary page shape instead of defaulting to the same layout. Every page scaffold is regression-tested to score **> 95 with zero cliché tells** under the neutral default theme and all four presets (the pattern library's taste bar) and to pass `canvas_stress` CLEAN under long-text and i18n content, so it's a non-slop starting point you adapt, not boilerplate. - **`component`** — reusable fragments stamped under **any** node via `targetId`, repeatably: `data-table` (header + 3 rows with avatar/name/email, role chip, status dot, right-aligned numeric cell, actions, and a pagination footer), `form-field`, `toolbar`, `stat-card` (the original name for the KPI-card anatomy below, kept as an alias), `toggle-row`, plus the state scaffolds `empty-state` (icon + title + hint + CTA — an empty screen is a first-run experience, not a void), `skeleton-table` (real header + skeleton rows matching `data-table` geometry, so the loaded table lands without layout shift), `skeleton-card`, and `skeleton-stat-card` (so a loading dashboard skeletons every data surface — the coverage check warns when a loading variant leaves a live region beside skeletoned ones). The dashboard micro-patterns: `kpi-card` (tinted icon tile + uppercase metric label + big tabular value + live sparkline + tinted delta pill — the dashboard structure stamps these), `status-chip` (the tint pair: `$success-tint` fill + `$success` ink), `segmented-control`, `breadcrumb`, and `initials-avatar`. A high-fidelity table costs one stamp instead of ~90 hand-placed nodes; a reference-grade KPI card costs one. -**Mobile realisations.** Each structure realises an anatomy for one canvas type. Six page anatomies have a mobile realisation for a `type: "mobile"` canvas, built from the mobile primitives (status bar, nav bar, list rows, buttons, a pinned tab bar or home indicator) and gated like the web set — > 95 with zero cliché tells and zero mobile-ruleset findings, in fast and detailed mode, and `canvas_stress` CLEAN: `dashboard-mobile`, `settings-mobile`, `auth-mobile`, `onboarding-mobile`, and two mobile-first anatomies, `feed-mobile` (a list of rows) and `detail-mobile` (an item with a pinned primary action). Of the components, `form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip` and `initials-avatar` stamp on every type; the rest are web-only. +**Mobile realisations.** Each structure realises an anatomy for one canvas type. Six page anatomies have a mobile realisation for a `type: "mobile"` canvas, built from the mobile primitives (status bar, nav bar, list rows, buttons, a pinned tab bar or home indicator) and gated like the web set: > 95 with zero cliché tells and zero mobile-ruleset findings in fast mode (the default theme, the four presets' colours over a phone type scale, and every personality's generated phone system), zero mobile-ruleset findings in detailed mode (as stamped and with every list tripled), and a CLEAN full `canvas_stress` run unthemed and under every personality: `dashboard-mobile`, `settings-mobile`, `auth-mobile`, `onboarding-mobile`, and two mobile-first anatomies, `feed-mobile` (a list of rows) and `detail-mobile` (an item with a pinned primary action). Of the components, `form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip` and `initials-avatar` stamp on every type; the rest are web-only. The other web pages (`marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `pricing`) have no mobile realisation. ### `apply_structure` @@ -644,7 +644,7 @@ Stamp a layout structure onto a canvas and return the placeholder node IDs to po - **Page scaffolds** insert at the canvas root (refusing on a non-empty canvas unless `replace`), record provenance (`metadata.provenance`), and append to the **per-project build log** that feeds the `diversification` signal. - **Component scaffolds** insert under `targetId` (default root), repeatably — every stamp re-keys its node IDs (`form-field-1-…`, `form-field-2-…`) and returns an `idMap` (template ID → live ID) for follow-up ops. Component stamps don't touch provenance or the build log: they don't shape the page. -- **The name resolves by the canvas's declared type.** An anatomy name stamps that anatomy's realisation for the type: `dashboard` on a mobile canvas stamps `dashboard-mobile`. A realisation name is used as is. A name with no realisation for the type is refused with the anatomies that have one (`pricing` on a phone; a web-only component too); `realisation` stamps another type's realisation anyway. The result names the `anatomy` and `surface` it stamped, and its `placeholders` include each primitive's copy with `prop` naming the field to set (`title` or `label`). +- **The name resolves by the canvas's declared type.** An anatomy name stamps that anatomy's realisation for the type: `dashboard` on a mobile canvas stamps `dashboard-mobile`. A realisation name is used as is. A name with no realisation for the type is refused with the anatomies of the same kind that have one (`pricing` on a phone; a web-only component too, whose anatomy is its own name); `realisation` stamps another type's realisation anyway. Resolution reads the *declared* type (`canvas_create({ type: "mobile" })` or `canvas_set_type`): an undeclared canvas is web, whatever its width. The result names the `anatomy` and `surface` it stamped, and its `placeholders` include every copy field of its primitives, with `prop` the field's path (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`); set a nested one by writing its whole object (`U(id, { trailing: { text: "2h" } })`). A stamp holding a primitive also seeds the colours the primitives read by default (`$text-primary`, `$text-secondary`, `$border`, and `$accent` / `$bg-surface` / `$danger` unless the canvas resolves `$primary` / `$surface` / `$error`), so `seededColors` can list names no node mentions. | Param | Type | Description | |-------|------|-------------| @@ -1315,7 +1315,7 @@ The loop framesmith is built around — start from taste, adapt, and polish to t 1. Start the standalone viewer in a terminal tab: `npx -p framesmith framesmith-viewer`, and open its URL for live preview. 2. `canvas_create` → get a canvas ID (share the viewer URL). 3. Set the design system once — `generate_design_system` (seed + personality → the whole language: colors, fonts, type roles, radius/density, elevation, motion) is the fastest start; `workspace_set_design_system` / `apply_preset` / `set_variables` hand-author or adjust individual `$token`s. -4. **`apply_structure`** → stamp a vetted page pattern, then adapt it with `batch_design` (real icons, controls, and components — not faked from frames). This is the starting point; a blank canvas is the slow, sloppy path. +4. **`apply_structure`** → stamp a vetted page pattern (on a phone canvas, the mobile realisation of the same name), then adapt it with `batch_design` (real icons, controls, and components — not faked from frames). This is the starting point; a blank canvas is the slow, sloppy path. 5. Watch the viewer auto-refresh as you design; `screenshot` to check the render. 6. **`canvas_evaluate`** → read the `directive`. Resolve every warning and cliché tell (`canvas_autofix` for the mechanical subset, `batch_design` for the rest), then re-evaluate. Repeat until it says `READY` — only then present the design. 7. **Final polish, when an LLM API key is configured:** `canvas_evaluate({ mode: "llm" })` scores the render on the fixed five-axis rubric, `canvas_revise` addresses the failing axes, re-judge until it passes or `canvas_revise` stops. The heuristic gate is the floor; the critique loop is the ceiling. Treat it as the standard last step, not an optional extra. It never blocks presenting: if axes still fail when `canvas_revise` stops, present the design and name the failing axes. No key on hand? The heuristic directive decides readiness either way, and the result says the critique was skipped. diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 202e9d2..6a046c0 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -102,7 +102,7 @@ The bar is 12 + 44 + 34 + 12 = 102px tall; the root's `safeArea` reserves 34, so ## Starting a phone screen (mobile realisations) -A structure realises an **anatomy** (a screen kind) for one canvas type. On a mobile canvas, `apply_structure` resolves an anatomy name to its mobile realisation, so `apply_structure({ canvasId, structure: "dashboard" })` stamps `dashboard-mobile`; the result's `applied`, `anatomy` and `surface` say what was stamped. Six anatomies have a mobile realisation: +A structure realises an **anatomy** (a screen kind) for one canvas type. A canvas has the type it was declared with (`canvas_create({ type: "mobile" })` or `canvas_set_type`, see [Declaring the medium](#declaring-the-medium-web-or-mobile)); an undeclared canvas is web whatever its width, so declare first. On a mobile canvas, `apply_structure` resolves an anatomy name to its mobile realisation, so `apply_structure({ canvasId, structure: "dashboard" })` stamps `dashboard-mobile`; the result's `applied`, `anatomy` and `surface` say what was stamped. Six anatomies have a mobile realisation; the other web pages (`marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `pricing`) have none: | Anatomy | Mobile realisation | Shape | |---|---|---| @@ -113,11 +113,11 @@ A structure realises an **anatomy** (a screen kind) for one canvas type. On a mo | `feed` | `feed-mobile` | A full-bleed list of rows (monogram, title, preview, time) and a pinned four-tab bar | | `detail` | `detail-mobile` | Back nav bar, media slot, title, body, an attribute list, and a primary action pinned over the home indicator | -Every one starts with a `status-bar`, ends with a `home-indicator` or a pinned `tab-bar`, and builds every tap target from a primitive, so it passes the mobile ruleset in fast and detailed mode and survives `canvas_stress`. Adapt it like any scaffold: the result's `placeholders` include each primitive's copy, with `prop` naming the field (`U("dm-row-1", { title: "Morning run" })`, `U("amb-submit", { label: "Sign in" })`). +Every one starts with a `status-bar`, ends with a `home-indicator` or a pinned `tab-bar`, and builds every tap target from a primitive, so it passes the mobile ruleset in fast and detailed mode and survives `canvas_stress`, unthemed and under every personality. Adapt it like any scaffold, taking node ids and fields from the result's `placeholders` rather than guessing them: each entry is `{ id, role, prop? }`, where `role` is the placeholder copy and `prop` is the path of a primitive's copy field (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`). Set a top-level one directly, a nested one by writing its whole object: `U("dm-row-1", { title: "Morning run", subtitle: "5.2 km", trailing: { text: "7:12" } })`, `U("amb-submit", { label: "Sign in" })`. -- **Use `generate_design_system` for a phone's type**, then stamp. The built-in presets set a 14–15px `$body`, which `mobile-type-floor` flags on a phone whatever the layout. -- **`list_structures({ canvasId })`** lists what stamps on that canvas: on a phone, the six realisations and the components marked for every type (`form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`). Each entry names its `anatomy`, its `surface` (`web`, `mobile` or `any`) and `surfaces`, the types its anatomy is realised on. -- **A name with no realisation for the canvas's type is refused**, with the anatomies that have one: `pricing` on a phone, or a web-only component such as `data-table` (its 28px pager buttons are hand-built). `realisation: "web"` stamps the web one anyway, and `realisation: "mobile"` stamps a mobile one on a web canvas. +- **Use `generate_design_system` for a phone's type**, then stamp. The `dark`, `light` and `material` presets set a 14–15px `$body` (`minimal` sets 16), which `mobile-type-floor` flags on a phone whatever the layout; the stamp's own neutral defaults (a 16px body) pass. +- **`list_structures({ canvasId })`** lists what stamps on that canvas: on a phone, the six realisations and the components marked for every type (`form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`). Each entry names its `anatomy` (for a component, its own name), its `surface` (`web`, `mobile` or `any`) and `surfaces`, the types its anatomy is realised on (`["web", "mobile"]` for `dashboard`, `["mobile"]` for `feed-mobile`). Passing `canvasId` and `type` together is an error. +- **A name with no realisation for the canvas's type is refused**, with the anatomies of the same kind that have one: `pricing` on a phone, or a web-only component such as `data-table` (its 28px pager buttons are hand-built), `toolbar` or `toggle-row`. `realisation: "web"` stamps the web one anyway, and `realisation: "mobile"` stamps a mobile one on a web canvas. - **The realisations never write `safeArea`.** Their pinned chrome makes the renderer reserve the insets, so they are also correct on a canvas made mobile with `canvas_set_type`. ## Responsive hints @@ -195,7 +195,7 @@ The `chart` node owns the value→coordinate math — give it data and domains, - **Baking uppercase into `content`.** Use `textTransform: "uppercase"` (with `letterSpacing` for tracking) so the underlying copy stays editable and case-styling lives in the design, not the data. - **Faking form controls from frames + ellipses.** `toggle`, `checkbox`, `radio`, and `select` are real node types: `I("parent", { type: "toggle", checked: true })`. They style themselves from `$accent`/`$border`/`$bg-surface` tokens (neutral fallbacks when unthemed) and stay pixel-consistent; `fill`/`stroke`/`color` override. - **Faking loading states from plain gray rectangles.** Use `{ type: "skeleton" }` — token-derived fill, correct default radius, and it pulses in the live viewer (always static in screenshots/exports so diffs stay deterministic). Stamp `skeleton-table` / `skeleton-card` / `skeleton-stat-card` instead of hand-placing bars — and skeleton EVERY data surface: the coverage check flags a loading variant that leaves live metric values beside skeletoned regions (a half-designed loading screen flashes real-looking data next to placeholders). -- **Hand-building app furniture node by node.** A data table, form field, toolbar, stat card, or settings row is one `apply_structure` stamp: component-kind structures insert under any `targetId` with re-keyed IDs and return an `idMap` for populating. Stamp `data-table`, fill the placeholders, copy rows with `C()` — don't place ~90 nodes by hand. +- **Hand-building app furniture node by node.** A data table, form field, toolbar, stat card, or settings row is one `apply_structure` stamp: component-kind structures insert under any `targetId` with re-keyed IDs and return an `idMap` for populating. Stamp `data-table`, fill the placeholders, copy rows with `C()` — don't place ~90 nodes by hand. On a phone canvas only the components marked for every type stamp (not `data-table`, `toolbar` or `toggle-row`), and a settings row is a `list-row`. ## Design systems (workspace + project inheritance) @@ -372,7 +372,7 @@ The rhythm for a module: design each screen to its own READY directive → desig The bar is "designers say *wow*," not "competent." The cliché tells below are the *don'ts*; these are the *do's*. Apply them up front — the evaluator is the safety net, not the plan. -- **Start from a pattern, don't start blank.** `list_structures` → `apply_structure` stamps a taste-vetted page scaffold (every one is regression-tested to > 95 with zero cliché tells across themes, built from type-role tokens rather than literal pixel sizes, and stress-hardened to survive hostile-length content). Adapt it — swap copy, set `$tokens`, vary the structure — rather than inventing layout from nothing. A blank canvas is where slop comes from. +- **Start from a pattern, don't start blank.** `list_structures` → `apply_structure` stamps a taste-vetted page scaffold (on a phone canvas, the mobile realisation of the same name; every one is regression-tested to > 95 with zero cliché tells across themes, built from type-role tokens rather than literal pixel sizes, and stress-hardened to survive hostile-length content). Adapt it — swap copy, set `$tokens`, vary the structure — rather than inventing layout from nothing. A blank canvas is where slop comes from. - **Use the whole toolkit — a real UI uses these, so your design must too.** Icons (`{ type: "icon", icon: "search" }` — Lucide or `material:`), fonts by name, real controls (`toggle`/`checkbox`/`radio`/`select`), components, and `$tokens`. Never fake them (no Unicode-glyph icons, no ellipse "toggles") and never omit them where a real UI has them: nav rows get a leading icon, metrics get an icon, feature lists get check icons, empty states get a glyph, forms use real controls. Starting from a pattern gives you all of this — don't strip it out. - **One focal point per screen.** Decide what the eye hits first (usually the headline or the primary action) and make everything else quieter. Two competing focal points = no focal point. - **Build real hierarchy.** Size, weight, and color should encode importance in steps you can see — a display heading, a clearly smaller subhead, body, then muted captions. Avoid near-equal sizes (a 16→15→14 ramp reads as one blurry tier); aim for ~1.2–1.6× jumps. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index e6dce47..9917ea4 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -327,14 +327,23 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *Docs.* `list_structures` docstring (mobile listing, and the four state scaffolds it misses today), `apply_structure` docstring (resolution, `realisation`, the refusal), INSTRUCTIONS (the structures paragraph and the mobile paragraph), GOTCHAS (the structures and declare-the-medium lines), README structures list, GUIDELINES mobile section, VISION checklist. - **As built (slice E).** Where the build departs from the contract above, or settles what it left open: - - *The presets lend a phone everything but their type scale (spec change to FR-E4's themes).* Every built-in preset sets a 14 or 15px `$body`, which `mobile-type-floor` flags on any phone screen that uses the body role, whatever its structure. So on a mobile realisation the gate applies each preset's colours, spacing, radius and depth over the canvas's own type scale. It also holds every realisation to each personality's generated phone system (`generate_design_system` with `surface: "mobile"`, stamped after it the way `apply_structure` passes the inherited tokens): > 95, zero tells, zero mobile findings. That is the path the docs tell agents to take. + - *The presets lend a phone everything but their type scale (spec change to FR-E4's themes).* The `dark`, `light` and `material` presets set a 14 or 15px `$body` (`minimal` sets 16), which `mobile-type-floor` flags on any phone screen that uses the body role, whatever its structure. So on a mobile realisation the gate applies each preset's colours, spacing, radius and depth over the canvas's own type scale. It also holds every realisation to each personality's generated phone system (`generate_design_system` with `surface: "mobile"`, stamped after it the way `apply_structure` passes the inherited tokens): > 95, zero tells, zero mobile findings. That is the path the docs tell agents to take. - *Primitives seed the tokens they read.* `applyStructure` seeds only the colour tokens a stamp references, and the primitives read `$text-primary`, `$text-secondary`, `$accent`, `$border`, `$bg-surface` and `$danger` through their defaults without naming them. An unthemed `settings-mobile` drew near-black row titles on the dark default background, and nothing in the evaluator saw it (primitive labels are not contrast-checked, a slice D known limit). A stamp holding a primitive now seeds all six. - - *Placeholders include primitive copy.* A `list-row` or `nav-bar` title and a `button` label are listed as placeholders with `prop: "title" | "label"`, so the agent knows which field to set. + - *Placeholders include primitive copy.* Every copy field of a primitive is listed as a placeholder with `prop`, the field's path (`title`, `subtitle`, `label`, `trailing.text` or `trailing[i].label`, `leading.initials`, `leading.label`, `items[i].label`), so the agent knows which field to set and no placeholder copy ships by omission. - *A `button` honours `maxWidth`* (a slice D renderer gap: an authored `maxWidth` emitted no `max-width` at all). Stress found it: a long "See all" in `dashboard-mobile`'s section header pushed the header out of its column; capped at 50%, the label truncates by design. - *`empty-state`'s hint moved from `$caption` to `$text-sm`.* Nine words at 12px fail `mobile-type-floor`'s prose floor, and the empty state is the scaffold a phone's empty variant needs. This changes the web render of the hint (12 → 14px by default). - *Components marked `any`:* `form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`. Held back: `data-table` (its 28px pager and row-action frames raise `mobile-handbuilt-target`), `toolbar` (a 36px hand-built search field), `toggle-row` (a 40px row fails `mobile-touch-target`), and three that pass but are not phone idioms or are hand-built tap controls: `skeleton-table`, `segmented-control`, `breadcrumb`. - *Refusals list the same kind.* The refusal names page anatomies for a page name and component anatomies for a component name (a page refusal listing fourteen components was noise). When an anatomy has no realisation for the type, every realisation it has covers the other type, so the `realisation` hint is always given. - *`detail-mobile`'s pinned bar keeps its vertical rhythm in spacer frames* (12 above the button, 46 below it), not padding, so the bar is 110px on every spacing scale and the 76px end spacer stays exact; a padding token would change the bar's height with the personality. + - *Verify round.* Four verifiers and a cold reader checked cffc66c; these changes came out of it: + - *The stress gate runs under every personality.* `onboarding-mobile`'s heading was stress-FRAGILE under the editorial personality (an auto-width text in a centred column grew past the artboard under the larger display face); the gate stressed only the unthemed stamp. The heading now fills its column, and `test-patterns-mobile.ts` stresses each realisation unthemed and under all four personalities. + - *Detailed mode also runs with every list tripled*, so the screen scrolls past its pinned bar and the end-of-scroll checks judge something: on the stamp as shipped no realisation reaches its bar, so a missing end spacer passed detailed mode (only fast mode caught it). + - *`any` components are stressed on a phone too.* `form-field` was FRAGILE there (a placeholder that wrapped in the 44px box at phone width); it now truncates by design. `empty-state`'s action was a hand-built 37px frame the rules could not measure (an unknown width is never judged); it is now a `button` primitive, on web too. + - *Seeding respects the primitives' fallbacks.* A canvas that resolves `$primary`, `$surface` or `$error` (every built-in preset defines `error`, not `danger`) no longer gets `$accent`, `$bg-surface` or `$danger` seeded over them by a primitive's implicit reference. A scaffold node that names `$accent` or `$bg-surface` explicitly still seeds it, as every web scaffold always has. + - *`dashboard-mobile`'s delta line* carries the success colour on its icon and secondary ink on its text, with no tint fill: an unthemed stamp followed by a light preset had left the seeded dark tint under dark text (1.2:1). + - *The refusal for a realisation name* says "not a one" (it said "this canvas is …", wrong when `realisation` forced the type). + - *Tests added from the mutation round:* the eight `any` markers, each primitive type seeding all six colours alone, the pinned bar and the bottom-held auth actions, the handler passing inherited tokens (a stamp after `generate_design_system` seeds none of its colours), `list_structures` reading a type changed on disk, and the GOTCHAS naming every anatomy. + - *Known limit, recorded.* The diversification hint, fed a phone project's realisations, says they repeat `heroTreatment: none` and `rhythm: uniform`: true (five of the six have no hero), and advisory only, but on a phone it can only be satisfied by forcing a web structure. - *Known limit, not fixed here.* The renderer scales any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it is a render change outside slice E. Slice G's Ridgeline rebuild will show it. **Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). diff --git a/src/index.ts b/src/index.ts index 0da5ede..9be37dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,7 +73,7 @@ Charts are data-driven: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", Reuse instead of rebuild: create_component promotes any existing subtree into a reusable component (an instance takes its place, render-identical); stamp more instances via batch_design ({ type: "instance", componentId, overrides: { "": {...} } } — overrides match children by NAME). copy_nodes copies subtrees across canvases with fresh ids + an idMap, carrying referenced component defs along — the app shell is built once and copied everywhere, never re-authored node-by-node. -Structures come in two kinds (list_structures): page scaffolds (marquee-hero, bento-grid, stat-led, editorial-longform, split-workbench, catalogue, dashboard, auth, pricing, settings, onboarding) stamp once at the root — each is taste-vetted (> 95, zero cliché tells, canvas_stress CLEAN, typography built from type-role tokens not literal pixel sizes) so it's a non-slop starting point to ADAPT, not boilerplate; component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, plus the dashboard micro-patterns kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamp under any targetId, repeatably, returning an idMap — a data table is one apply_structure call, not 90 nodes, a reference-grade KPI card is one more. Each structure realises an anatomy (a screen kind) for one canvas type, and apply_structure resolves the name by the canvas's declared type (see the phone paragraph below); list_structures({ canvasId }) lists what stamps on that canvas. +Structures come in two kinds (list_structures): page scaffolds (marquee-hero, bento-grid, stat-led, editorial-longform, split-workbench, catalogue, dashboard, auth, pricing, settings, onboarding) stamp once at the root — each is taste-vetted (> 95, zero cliché tells, canvas_stress CLEAN, typography built from type-role tokens not literal pixel sizes) so it's a non-slop starting point to ADAPT, not boilerplate; component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, the state scaffolds empty-state, skeleton-table, skeleton-card, skeleton-stat-card, plus the dashboard micro-patterns kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar) stamp under any targetId, repeatably, returning an idMap — a data table is one apply_structure call, not 90 nodes, a reference-grade KPI card is one more. Each structure realises an anatomy (a screen kind) for one canvas type, and apply_structure resolves the name by the canvas's declared type (see the phone paragraph below); list_structures({ canvasId }) lists what stamps on that canvas. Import from implementation: canvas_import_html (snippet + optional CSS) and canvas_import_url (live page — viewport/selector/waitFor/auth) turn shipped UI into an editable, TOKEN-MAPPED canvas — flex→frames, text runs, imgs, recognized SVGs→icons, checkboxes/switches/selects→input primitives; Tailwind classes map to intent (bg-surface → fill "$surface") and literal colors snap to the design system. STRUCTURE reconstructs too:
→ rows of proportional columns, CSS grid → rows from the computed template, centered/max-width content stays centered, other multi-column CSS clusters by geometry — report.layout records how each container was handled (table|grid|centered|geometry|stack-fallback; a stack-fallback entry = hand-fix that one container, everything else arrived structurally correct). Lossy by design: READ the returned report (snapped/literals/layout/warnings) instead of assuming fidelity. @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN; stamp it after generate_design_system (the built-in presets carry a 14–15px body that mobile-type-floor flags on a phone). Its placeholders include each primitive's copy, with prop naming the field (title or label). An anatomy with no mobile realisation (pricing; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN (unthemed and under every personality); stamp it after generate_design_system (the dark, light and material presets carry a 14–15px body that mobile-type-floor flags on a phone). Resolution reads the DECLARED type: an undeclared 390px canvas stamps the web structure, so declare type "mobile" first. Its placeholders include every copy field of its primitives, with prop the field's path (title, subtitle, label, trailing.text, leading.initials, leading.label, items[0].label — set a nested one by writing its whole object prop). An anatomy with no mobile realisation (the other web pages such as pricing, catalogue or bento-grid; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -124,7 +124,7 @@ const GOTCHAS = [ 'Icons: Lucide ({ type: "icon", icon: "search" }) and Material Symbols (icon: "material:check", iconStyle outlined/rounded/sharp, "-fill" suffix for filled) render by name — never fake them with Unicode glyphs. Spelling differs by set: Lucide hyphenates ("chevron-right"), Material keeps underscores AND needs the prefix ("material:chevron_right") — a bare "chevron_right" is looked up in Lucide and renders nothing; canvas_evaluate warns on any icon that resolves in neither set. Casing: use textTransform: "uppercase", not uppercased content.', 'Controls: toggle / checkbox / radio / select are real node types with checked / disabled / value, token-styled — never assemble them from frames + ellipses.', 'Bulk edits & queries: replace_matching_properties changes a property across every node matching a value predicate in one call (scope/type filters; dryRun previews the match set); find_nodes is the read-only twin — locate nodes by property/text/name and get ids + paths instead of guessing from read_nodes trees. canvas_autofix apply: true writes the whole mechanical fix set in one call.', - 'Component scaffolds: apply_structure with kind "component" structures (data-table, form-field, toolbar, stat-card, toggle-row — plus the Phase 28 dashboard micro-patterns: kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar, all on the tint pair) + targetId stamps reusable fragments with re-keyed IDs — build tables/forms from these, not node-by-node.', + 'Component scaffolds: apply_structure with kind "component" structures (data-table, form-field, toolbar, stat-card, toggle-row, the state scaffolds empty-state, skeleton-table, skeleton-card, skeleton-stat-card — plus the Phase 28 dashboard micro-patterns: kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar, all on the tint pair) + targetId stamps reusable fragments with re-keyed IDs — build tables/forms from these, not node-by-node.', 'Charts: the chart node type is data-driven ({ kind: "line"|"bar"|"donut"|"sparkline", series: [{ data, stroke, highlight?, barGradient?, … }], segments? for donuts, gridlines?, xLabels? }) — it does the value→coordinate math, dashes mark projected vs actual, highlight (an index or an array of indices) renders the selected bar solid over muted siblings, donuts take segments + centerValue/centerLabel, sparklines are axis-free with the latest point emphasized. Never hand-compute path d-strings, conic-hack a donut, or hand-place sparkline bars.', 'Shared chrome is a component, not a copy-paste: create_component promotes a built subtree (render-identical; overrides target named children), batch_design I() stamps more instances, copy_nodes carries subtrees + their component defs to sibling canvases. Rebuilding an app shell node-by-node on every canvas is the anti-pattern.', 'canvas_import_html: Tailwind classes map to intent directly (bg-surface → fill "$surface", gap-4 → 16, bg-red-500 → the bundled v4 palette hex) and literal colors snap to the design system — a bare snippet styles via the common utilities + palette; pass the compiled CSS via the css param for everything else. Always read the returned report (snapped/literals/layout/warnings); the import is honest about what it dropped.', @@ -140,7 +140,7 @@ const GOTCHAS = [ 'Every state, every string: a data screen is not done at one static frame with ideal data. Tables demand designed "empty" + "loading" state variants, forms demand "error" (the coverage category warns until they exist — each is one canvas_add_variant + one scaffold stamp: empty-state / skeleton-table / skeleton-card / skeleton-stat-card); skeleton blocks pulse only in the live viewer, never in screenshots. Skeleton EVERY data surface on a loading variant, not just the table — coverage flags a "half-designed" loading state when a region still shows live values beside skeletoned ones. Then canvas_stress before presenting: it renders hostile-but-realistic content (long-text / i18n / big-numbers / empty / many) and reports clips and overflows by node id — fix with fluid widths / minWidth or minHeight floors / wrapping, or textOverflow: "ellipsis" on a label that must stay single-line (that downgrades its clip to info instead of warning), and re-run until CLEAN. canvas_evaluate remembers the last stress verdict: after a FRAGILE run the directive stays NOT READY until a full CLEAN run (all five perturbations) replaces it — a subset run can record FRAGILE but never clear it.', 'Derive, don\'t hand-pick: generate_design_system (seed + REQUIRED personality: technical/editorial/soft/data-dense) writes the whole design language in one call — including $display/$heading/$title/$body/$label typography roles, $elevation.* shadow tokens (reference as shadow: "$elevation.raised"; the dark layer re-states depth), and $motion defaults. For targeted regeneration only: generate_scale gives a modular type + space scale from a ratio (craft defaults baked in; fluid clamp() optional); generate_color_system gives OKLCH ramps + a matched neutral + status colors + the semantic tokens the structures use, AA by construction, WITH a dark theme (sparse dark.colors layer) AND the color range — chart-1…chart-6 for series/segments/legend dots, plus the accent/success/warning/danger/neutral -tint layer for chips/tiles/badges (pair tint as fill with its base color as ink; AA by construction, dark included). Render dark via theme: "dark" on screenshot/screenshot_responsive/export; canvas_evaluate contrast-checks both themes automatically (dark failures point at the dark layer — never write a literal fix; APCA Lc is info-only, WCAG 2.2 gates). Literals that equal a token get re-attached by autofix; timing belongs in $motion tokens.', 'Beyond one canvas: bento/editorial compositions are REAL grid — layout: "grid" + gridColumns (count / fr-weight array / template string) + gridColumn/gridRow spans (a number means span N); responsive: "stack" collapses to one column on mobile. Never approximate tiles with nested flex. When a multi-screen module feels done, project_evaluate reviews the SET — radius-drift / accent-drift / token-adoption / copied-chrome / state-coverage findings, each naming its canvases — and mode "llm" judges up to 8 screens together for flow consistency (flowSkipped lists anything past the cap; keyless → full roll-up + a note). ADVISORY: only the per-canvas directives gate presenting.', - 'Start a phone screen from its mobile realisation: generate_design_system, then apply_structure with the anatomy name on the mobile canvas — dashboard, settings, auth, onboarding, feed or detail stamp dashboard-mobile etc. (status bar, nav bar, list rows, buttons, pinned tab bar or home indicator; every tap target a primitive). pricing and the web-only components (data-table, toolbar, …) are refused on a phone with the list of what has a mobile realisation; realisation: "web" forces the web one. list_structures({ canvasId }) lists what stamps on that canvas.', + 'Start a phone screen from its mobile realisation: generate_design_system, then apply_structure with the anatomy name on the mobile canvas — dashboard, settings, auth, onboarding, feed or detail stamp dashboard-mobile etc. (status bar, nav bar, list rows, buttons, pinned tab bar or home indicator; every tap target a primitive). Resolution reads the DECLARED type: canvas_create({ width: 390 }) with no type stamps the web dashboard. pricing and the web-only components (data-table, toolbar, …) are refused on a phone with the list of what has a mobile realisation; realisation: "web" forces the web one. list_structures({ canvasId }) lists what stamps on that canvas. Read the result\'s placeholders for node ids and fields (prop) instead of guessing them.', 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. Build a phone screen from the mobile primitives — status-bar, nav-bar, list-row, button, tab-bar, home-indicator, sheet — not hand-built frames: they carry 44px targets and pin themselves. On mobile a tap target below 44px is an ERROR (mobile-touch-target), content in the status-bar / home-indicator insets or left under a pinned bar is a WARNING (mobile-safe-area), and a tab bar outside 2–5 labeled tabs or not pinned to the bottom WARNS (mobile-tab-bar). Keep root safeArea; the root grows with long content and keeps its end padding. A pinned CTA bar: position "fixed", bottom: 0, padding under 32 (larger padding scales down on a phone) and a 34px spacer frame as its last child, plus a spacer at the end of your content frame tall enough that it and the root\'s 34px bottom inset reach the bar\'s height (the rule names it: 68 for a 102px bar; re-evaluate after changing the bar). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', 'Gate integrity: a canvas describing a SHIPPED view is a contract — run canvas_check_drift against the live route BEFORE designing on it (findings: missing-in-page / missing-in-canvas / control-mismatch / table-mismatch), and reconcile deliberately: update the canvas, flag the implementation, or ask — never silently annotate a difference. canvas_sync_from_url answers "how much does it LOOK different" (pixel %); canvas_version makes approvals falsifiable — record { canvasId, versionHash } at approval time and check with expectedHash later (metadata/feedback never moves the hash; ANY inherited workspace/project token edit DOES, even to a token the canvas does not use). A legacy sha256: hash still checks but carries a warning — re-record it as the current sha256r: versionHash. CI can demand both: npx framesmith check-drift / verify exit 1 on failure.', ]; @@ -174,7 +174,7 @@ server.resource( // --- canvas_create --- server.tool( 'canvas_create', - 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, the artboard `width`/`height`, viewer URL, and a `diversification` signal — the recently-built structures in this project plus a hint to differ on at least one taxonomy axis, so successive canvases don\'t converge on the same layout. Always share the viewer URL with the user so they can see the design live in their browser. If `projectId` is omitted, the canvas lands in the default Untitled project. The artboard defaults to 1440×900; pass `width`/`height` for another size. Building a PHONE APP screen? Pass `type: "mobile"` — it declares the canvas a phone screen (it selects the mobile ruleset, and generate_design_system gives it a 17px body) and gives you the device artboard (390×844 for the default `device: "iphone-390"`) with the root reserving the device\'s safe-area insets as `safeArea: { top: 47, bottom: 34 }` (the renderer adds them to the root\'s top/bottom padding, and the root grows with long content keeping that padding, so in-flow content never sits under the status bar or home indicator). `type: "web"` (the default) declares a web page. The result reports `type`, `typeSource` ("declared" | "default"), and for mobile `device` + `safeArea`. Change it later with canvas_set_type.', + 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, the artboard `width`/`height`, viewer URL, and a `diversification` signal — the recently-built structures in this project plus a hint to differ on at least one taxonomy axis, so successive canvases don\'t converge on the same layout. Always share the viewer URL with the user so they can see the design live in their browser. If `projectId` is omitted, the canvas lands in the default Untitled project. The artboard defaults to 1440×900; pass `width`/`height` for another size. Building a PHONE APP screen? Pass `type: "mobile"` — it declares the canvas a phone screen (it selects the mobile ruleset, and generate_design_system gives it a 17px body) and gives you the device artboard (390×844 for the default `device: "iphone-390"`) with the root reserving the device\'s safe-area insets as `safeArea: { top: 47, bottom: 34 }` (the renderer adds them to the root\'s top/bottom padding, and the root grows with long content keeping that padding, so in-flow content never sits under the status bar or home indicator). `type: "web"` (the default) declares a web page. The result reports `type`, `typeSource` ("declared" | "default"), and for mobile `device` + `safeArea`. Change it later with canvas_set_type. Then start it from a pattern: on a mobile canvas apply_structure stamps the mobile realisations (dashboard, settings, auth, onboarding, feed, detail).', { name: z.string().optional().describe('Name for the canvas'), projectId: z.string().optional().describe('Project to create the canvas in. Defaults to the built-in Untitled project. Use project_list to see available projects.'), @@ -1523,8 +1523,8 @@ server.tool( `Stamp a layout structure (see list_structures) onto a canvas. Two kinds: - page scaffolds insert at the canvas root and record provenance; refuses if the root already has content unless replace is true. - component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, …) insert under targetId (default root), repeatably — every stamp re-keys its node IDs and returns an idMap (templateId → live id) for follow-up batch_design ops. -The name resolves by the canvas's declared type: an anatomy name stamps that anatomy's realisation for the type — "dashboard" on a mobile canvas stamps dashboard-mobile (status bar, nav bar, list rows, pinned tab bar, all primitives) — and the result names the anatomy and surface it stamped. A realisation name (dashboard-mobile) is used as is. A name with no realisation for the canvas's type is refused with the anatomies that have one (pricing on a phone canvas; components not marked for every type too); realisation: "web" | "mobile" stamps that type's realisation anyway. -Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$title/$heading/$text-lg/$text-sm/$body/$label/$caption) for spacing, corner radius, depth, and EVERY text node's size (not just $color, and not just titles) — stamp AFTER generate_design_system on a project and the pattern already reads in that system's density, personality, and voice; no scaffold can inject a size the generated scale doesn't have. Also hardened to survive hostile-length content (canvas_stress CLEAN). Seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's seededColors/seededTokens list exactly what got seeded. Returns the placeholder node ids to populate — fill them with batch_design U ops, then call screenshot to verify the layout.`, +The name resolves by the canvas's declared type: an anatomy name stamps that anatomy's realisation for the type — "dashboard" on a mobile canvas stamps dashboard-mobile (status bar, nav bar, list rows, pinned tab bar, all primitives) — and the result names the anatomy and surface it stamped; its placeholders include every copy field of its primitives, with prop the field's path. A realisation name (dashboard-mobile) is used as is. A name with no realisation for the canvas's type is refused with the anatomies that have one (pricing on a phone canvas; components not marked for every type too); realisation: "web" | "mobile" stamps that type's realisation anyway. +Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$title/$heading/$text-lg/$text-sm/$body/$label/$caption) for spacing, corner radius, depth, and EVERY text node's size (not just $color, and not just titles) — stamp AFTER generate_design_system on a project and the pattern already reads in that system's density, personality, and voice; no scaffold can inject a size the generated scale doesn't have. Also hardened to survive hostile-length content (canvas_stress CLEAN). Seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's seededColors/seededTokens list exactly what got seeded (a stamp holding a mobile primitive also seeds the colours primitives read by default: $text-primary, $text-secondary, $border, and $accent / $bg-surface / $danger unless the canvas resolves $primary / $surface / $error). Returns the placeholder node ids to populate — fill them with batch_design U ops, then call screenshot to verify the layout.`, { canvasId: z.string().describe('Canvas ID'), structure: z.string().describe('Structure name (use list_structures, e.g. marquee-hero, data-table)'), diff --git a/src/structures.ts b/src/structures.ts index 43f614f..78fdafe 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -1066,7 +1066,9 @@ const formField: Structure = { { id: 'ff-input', type: 'frame', name: 'Input', width: '100%', height: 44, layout: 'horizontal', alignItems: 'center', padding: [SPACE.xs, SPACE.md], cornerRadius: RADIUS.sm, fill: COLOR.bgElevated, stroke: COLOR.border, strokeWidth: 1, - children: [{ id: 'ff-placeholder', type: 'text', content: 'Placeholder — to confirm', fontSize: TYPE.body, color: COLOR.textSecondary }], + // One line, truncating: a 44px box can't hold a wrapped second line + // (a long placeholder wrapped and clipped at phone width). + children: [{ id: 'ff-placeholder', type: 'text', content: 'Placeholder — to confirm', fontSize: TYPE.body, color: COLOR.textSecondary, textOverflow: 'ellipsis', minWidth: 0 }], }, { id: 'ff-help', type: 'text', content: 'Help text — to confirm', fontSize: TYPE.caption, color: COLOR.textSecondary }, ], @@ -1255,17 +1257,9 @@ const emptyState: Structure = { // $text-sm, not $caption: nine words of prose at 12px are below the iOS // Footnote floor, so the hint failed mobile-type-floor on a phone. { id: 'es-hint', type: 'text', content: 'Items you create will show up here — to confirm', fontSize: TYPE.textSm, color: COLOR.textSecondary }, - { - id: 'es-cta', type: 'frame', name: 'CTA', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs, - padding: [SPACE.xs, SPACE.md], cornerRadius: RADIUS.sm, fill: COLOR.accent, width: 'fit-content', - children: [ - // bg-primary as on-accent text is theme-adaptive: near-white on the - // dark accent in light mode, near-black on the light accent in dark - // mode (the tier() CTA convention — a literal white breaks in dark). - { id: 'es-cta-icon', type: 'icon', icon: 'plus', iconSize: 14, iconColor: COLOR.bgPrimary }, - { id: 'es-cta-label', type: 'text', content: 'Create item', fontSize: TYPE.textSm, fontWeight: 600, color: COLOR.bgPrimary }, - ], - }, + // A button primitive, so the action is a real 44px target on every type + // (a hand-built CTA frame rendered 37px tall, below a phone's floor). + { id: 'es-cta', type: 'button', name: 'CTA', label: 'Create item', icon: 'plus' }, ], }], }; @@ -1526,10 +1520,11 @@ const dashboardMobile: Structure = { { id: 'dm-hero-value', type: 'text', content: 'Metric — TBD', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary, tabularNums: true, textOverflow: 'ellipsis' }, { id: 'dm-hero-delta', type: 'frame', name: 'Delta', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs2, - padding: [2, SPACE.xs], cornerRadius: 999, fill: TINT.success.bg, overflow: 'hidden', + // The status colour rides on the icon; the text stays secondary ink + // on the card, so no preset or tint pairing can drop its contrast. children: [ { id: 'dm-hero-delta-icon', type: 'icon', icon: 'trending-up', iconSize: 14, iconColor: TINT.success.ink }, - { id: 'dm-hero-delta-text', type: 'text', content: 'Change — TBD', fontSize: TYPE.caption, fontWeight: 600, color: COLOR.textPrimary, textOverflow: 'ellipsis' }, + { id: 'dm-hero-delta-text', type: 'text', content: 'Change — TBD', fontSize: TYPE.caption, fontWeight: 600, color: COLOR.textSecondary, textOverflow: 'ellipsis' }, ], }, ], @@ -1657,7 +1652,9 @@ const onboardingMobile: Structure = { fill: TINT.accent.bg, layout: 'vertical', alignItems: 'center', justifyContent: 'center', children: [{ id: 'om-glyph-icon', type: 'icon', icon: 'sparkles', iconSize: 40, iconColor: TINT.accent.ink }], }, - { id: 'om-title', type: 'text', content: 'Page heading', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary, textAlign: 'center' }, + // Full width so a long heading wraps in the column (auto width in a + // centred column grew past the artboard under a large display face). + { id: 'om-title', type: 'text', content: 'Page heading', fontSize: TYPE.title, fontWeight: 700, color: COLOR.textPrimary, textAlign: 'center', width: '100%' }, { id: 'om-body', type: 'text', content: 'Body copy — what this step offers, in a sentence.', fontSize: TYPE.body, color: COLOR.textSecondary, textAlign: 'center', lineHeight: 1.5, maxWidth: 320 }, { id: 'om-dots', type: 'frame', name: 'Page dots', layout: 'horizontal', alignItems: 'center', gap: SPACE.xs, @@ -1889,7 +1886,7 @@ export function resolveStructure(name: string, type: SurfaceType): Structure { const surface = structureSurface(structure); if (!covers(surface, type)) { throw new Error( - `"${name}" is the ${surface} realisation of "${structureAnatomy(structure)}" and this canvas is ${type}. ` + + `"${name}" is the ${surface} realisation of "${structureAnatomy(structure)}", not a ${type} one. ` + `${available(structure.kind)} Pass realisation: "${surface}" to stamp it anyway.`, ); } @@ -1974,7 +1971,28 @@ const COLOR_FIELDS = ['fill', 'color', 'stroke', 'iconColor'] as const; * this, an unthemed settings-mobile drew near-black row titles on the dark * default background. */ const PRIMITIVE_TYPES = new Set(['status-bar', 'home-indicator', 'nav-bar', 'tab-bar', 'button', 'list-row', 'sheet']); -const PRIMITIVE_COLOR_TOKENS = ['text-primary', 'text-secondary', 'accent', 'border', 'bg-surface', 'danger']; +const PRIMITIVE_COLOR_TOKENS: Record = { + 'text-primary': [], 'text-secondary': [], border: [], + // The token, then the names applyControlDefaults falls back to: a canvas + // that resolves the fallback keeps it rather than getting a seeded default. + accent: ['primary'], 'bg-surface': ['surface'], danger: ['error'], +}; + +/** A primitive's copy fields as [path, text]: what an agent replaces. */ +function primitiveCopy(n: SceneNode): [string, string][] { + const out: [string, string][] = []; + const add = (path: string, v: unknown) => { if (typeof v === 'string' && v.trim() !== '') out.push([path, v]); }; + add('title', n.title); + add('subtitle', n.subtitle); + add('label', n.label); + const leading = n.leading as Record | undefined; + add('leading.initials', leading?.initials); + add('leading.label', leading?.label); + if (Array.isArray(n.trailing)) n.trailing.forEach((a, i) => add(`trailing[${i}].label`, (a as Record | null)?.label)); + else add('trailing.text', (n.trailing as Record | undefined)?.text); + (n.items ?? []).forEach((item, i) => add(`items[${i}].label`, item?.label)); + return out; +} /** Page background token applied to the document root so a scaffold fills the * viewport (renderer hoists root fill to ) instead of leaving browser @@ -1998,9 +2016,10 @@ export interface ApplyStructureResult { /** Component stamps only: template id → live (re-keyed) id, for targeting follow-up ops. */ idMap?: Record; /** Fillable placeholders (text/image) with their role label, for populating. - * A primitive's copy is a placeholder too; `prop` names the field to set - * (`title` on a list-row or nav-bar, `label` on a button). */ - placeholders: { id: string; role: string; prop?: 'title' | 'label' }[]; + * A primitive's copy is a placeholder too; `prop` is the path of its field + * (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, + * `leading.label`, `items[2].label`). */ + placeholders: { id: string; role: string; prop?: string }[]; /** Color tokens seeded with neutral defaults because they were unresolved. */ seededColors: string[]; /** Phase 27 — spacing/radius/elevation/typography tokens seeded the same way. */ @@ -2122,11 +2141,16 @@ export function applyStructure( const referencedOther = new Set(); // spacing / radius / elevation / typography refs for (const root of inserted) { walkNodes(root, (n) => { - if (PRIMITIVE_TYPES.has(n.type)) PRIMITIVE_COLOR_TOKENS.forEach((t) => referenced.add(t)); + if (PRIMITIVE_TYPES.has(n.type)) { + for (const [token, fallbacks] of Object.entries(PRIMITIVE_COLOR_TOKENS)) { + if (!fallbacks.some((f) => opts.existingColors?.has(f))) referenced.add(token); + } + } if (n.type === 'text' && typeof n.content === 'string') placeholders.push({ id: n.id, role: n.content }); else if (n.type === 'image') placeholders.push({ id: n.id, role: 'image' }); - else if ((n.type === 'list-row' || n.type === 'nav-bar') && typeof n.title === 'string') placeholders.push({ id: n.id, role: n.title, prop: 'title' }); - else if (n.type === 'button' && typeof n.label === 'string') placeholders.push({ id: n.id, role: n.label, prop: 'label' }); + else if (PRIMITIVE_TYPES.has(n.type)) { + for (const [prop, role] of primitiveCopy(n)) placeholders.push({ id: n.id, role, prop }); + } for (const field of COLOR_FIELDS) { const v = (n as unknown as Record)[field]; if (typeof v === 'string' && v.startsWith('$')) referenced.add(v.slice(1)); diff --git a/test-discoverability.ts b/test-discoverability.ts index 2d3f4b0..ef833df 100644 --- a/test-discoverability.ts +++ b/test-discoverability.ts @@ -100,6 +100,9 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); expect('every mobile anatomy named (backticked) in GUIDELINES', missingGl.length === 0, missingGl.join(', ')); expect('apply_structure docstring names realisation', /realisation/.test(tool('apply_structure'))); expect('list_structures docstring names canvasId', /canvasId/.test(tool('list_structures'))); + const gotchas = /const GOTCHAS = \[([\s\S]*?)\n\];/.exec(indexSrc)?.[1] ?? ''; + expect('GOTCHAS tell a phone agent to start from a realisation and list by canvas', + gotchas.includes('list_structures({ canvasId })') && anatomies.every((a) => gotchas.includes(a))); } // ── 6. every cliche tell is surfaced where agents look ─────────────────────── diff --git a/test-patterns-mobile.ts b/test-patterns-mobile.ts index 67fa21d..9d8d62a 100644 --- a/test-patterns-mobile.ts +++ b/test-patterns-mobile.ts @@ -3,13 +3,17 @@ import './test-env.js'; * Phase 30 slice E (FR-E4) — the pattern gate's layout half for the mobile * realisations. test-patterns.ts holds them to > 95, zero tells and zero * mobile findings in fast mode across the themes; this runs what fast mode - * cannot see, once per realisation (both checks are layout, not colour): + * cannot see: * - * 1. detailed mode (a Chrome layout pass) raises no `mobile-*` finding; + * 1. detailed mode (a Chrome layout pass) raises no `mobile-*` finding, as + * stamped and with every list run tripled, so the screen scrolls past + * its pinned bar and the end-of-scroll checks have something to judge; * 2. a full five-perturbation stress run, built the way canvas_stress builds - * it (instances expanded, the mobile perturbation rules), is CLEAN. + * it (instances expanded, the mobile perturbation rules), is CLEAN — + * unthemed and under each personality's generated phone system, since a + * larger display face is what pushes a heading past the artboard. * - * Components marked for every type get the detailed-mode check too. + * Components marked for every type get both checks on a bare phone canvas. * Needs Chrome. Run with: npx tsx test-patterns-mobile.ts */ import { listStructures, applyStructure } from './src/structures.js'; @@ -20,6 +24,9 @@ import { renderToHtml } from './src/renderer.js'; import { expandInstances } from './src/drift.js'; import { computeLayout, shutdown } from './src/screenshot.js'; import { applyPerturbation, compareLayouts, PERTURBATION_NAMES } from './src/stress.js'; +import { generateDesignSystem, PERSONALITY_NAMES, type PersonalityName } from './src/design-language.js'; +import { setVariables } from './src/variables.js'; +import { getCanvasTokens } from './src/workspaces.js'; import type { Canvas } from './src/types.js'; let allPass = true; @@ -53,23 +60,44 @@ async function stressWarnings(canvas: Canvas): Promise { return warnings; } +/** A phone canvas with the structure stamped as apply_structure stamps it, + * optionally after a generated phone design system. */ +function stamped(name: string, personality?: PersonalityName): Canvas { + const canvas = createCanvas(`gate-${name}-${personality ?? 'plain'}`, undefined, {}, { type: 'mobile' }); + if (personality) setVariables(canvas, generateDesignSystem('#166534', personality, { surface: 'mobile' }).variables); + const m = getCanvasTokens(canvas); + applyStructure(canvas, name, { + existingColors: new Set(Object.keys(m.colors ?? {})), + existingTokens: new Set([...Object.keys(m.spacing ?? {}), ...Object.keys(m.radius ?? {}), ...Object.keys(m.typography ?? {}), ...Object.keys(m.elevation ?? {}).map((k) => `elevation.${k}`)]), + }); + return canvas; +} + const realisations = listStructures({ type: 'mobile' }).filter((s) => s.kind === 'page'); check('six mobile realisations', realisations.length === 6, realisations.map((s) => s.name).join(', ')); for (const s of realisations) { - const canvas = createCanvas(`gate-${s.name}`, undefined, {}, { type: 'mobile' }); - applyStructure(canvas, s.name); + const canvas = stamped(s.name); const findings = await mobileFindings(canvas); check(`${s.name}: detailed mode raises no mobile finding`, findings.length === 0, findings.slice(0, 4).join(' | ')); - const warnings = await stressWarnings(canvas); - check(`${s.name}: full stress run CLEAN`, warnings.length === 0, warnings.slice(0, 4).join(' | ')); + + const long = stamped(s.name); + long.root = applyPerturbation('many', long.root, { type: 'mobile' }).root; + const longFindings = await mobileFindings(long); + check(`${s.name}: detailed mode raises no mobile finding with every list tripled`, longFindings.length === 0, longFindings.slice(0, 4).join(' | ')); + + for (const personality of [undefined, ...PERSONALITY_NAMES]) { + const warnings = await stressWarnings(stamped(s.name, personality)); + check(`${s.name}: full stress run CLEAN (${personality ?? 'unthemed'})`, warnings.length === 0, warnings.slice(0, 4).join(' | ')); + } } for (const s of listStructures().filter((x) => x.kind === 'component' && x.surface === 'any')) { - const canvas = createCanvas(`gate-${s.name}`, undefined, {}, { type: 'mobile' }); - applyStructure(canvas, s.name); + const canvas = stamped(s.name); const findings = await mobileFindings(canvas); check(`${s.name} (any): detailed mode raises no mobile finding on a phone`, findings.length === 0, findings.slice(0, 4).join(' | ')); + const warnings = await stressWarnings(canvas); + check(`${s.name} (any): full stress run CLEAN on a phone`, warnings.length === 0, warnings.slice(0, 4).join(' | ')); } console.log(allPass ? '\nAll mobile pattern-gate tests passed.' : '\nSOME TESTS FAILED'); diff --git a/test-structures-mcp.ts b/test-structures-mcp.ts index 26c5e2f..dc500d1 100644 --- a/test-structures-mcp.ts +++ b/test-structures-mcp.ts @@ -5,7 +5,7 @@ // // Usage: npx tsx test-structures-mcp.ts -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -68,6 +68,24 @@ try { // A canvas re-declared mobile resolves the same way. await tool('canvas_set_type', { canvasId: web, type: 'mobile' }); check('canvas_set_type to mobile changes what settings resolves to', (await tool('apply_structure', { canvasId: web, structure: 'settings' })).data.applied === 'settings-mobile'); + + // The handler passes the canvas's resolved tokens: a generated phone system's + // colours are never overwritten by the seeded neutral defaults. + const themed = (await tool('canvas_create', { name: 'themed', type: 'mobile' })).data.canvasId; + await tool('generate_design_system', { canvasId: themed, seed: '#166534', personality: 'soft' }); + const seeded = (await tool('apply_structure', { canvasId: themed, structure: 'settings' })).data.seededColors as string[]; + check('a stamp after generate_design_system seeds none of the system\'s colours', !['accent', 'text-primary', 'bg-surface', 'border'].some((t) => seeded.includes(t)), seeded.join(', ')); + + // list_structures reads the canvas fresh: a type written to disk under the + // server (a git pull) is what it lists for. + const pulled = (await tool('canvas_create', { name: 'pulled' })).data.canvasId; + const file = join(home, 'canvases', `${pulled}.json`); + const stored = JSON.parse(readFileSync(file, 'utf-8')); + stored.metadata = { ...stored.metadata, surface: { type: 'mobile', device: 'iphone-390' } }; + writeFileSync(file, JSON.stringify(stored)); + const later = new Date(Date.now() + 5000); + utimesSync(file, later, later); + check('list_structures sees a type changed on disk', names((await tool('list_structures', { canvasId: pulled })).data).includes('feed-mobile')); } finally { await client.close(); } diff --git a/test-structures.ts b/test-structures.ts index 6fb75bb..0cd301b 100644 --- a/test-structures.ts +++ b/test-structures.ts @@ -114,6 +114,17 @@ check(JSON.stringify(byName.get('dashboard')?.surfaces) === '["web","mobile"]', check(JSON.stringify(byName.get('feed-mobile')?.surfaces) === '["mobile"]', 'feed is realised on mobile only'); check(JSON.stringify(byName.get('empty-state')?.surfaces) === '["web","mobile"]' && byName.get('empty-state')?.surface === 'any', 'an any component counts for both types'); +const ANY = ['form-field', 'stat-card', 'empty-state', 'skeleton-card', 'skeleton-stat-card', 'kpi-card', 'status-chip', 'initials-avatar']; +check(JSON.stringify(all.filter((s) => s.surface === 'any').map((s) => s.name).sort()) === JSON.stringify([...ANY].sort()), 'exactly the eight documented components stamp on every type'); +const nodeOf = (structure: string, id: string): SceneNode | undefined => { + let hit: SceneNode | undefined; + getStructure(structure)!.nodes.forEach((root) => walk(root, (n) => { if (n.id === id) hit = n; })); + return hit; +}; +check(nodeOf('detail-mobile', 'dtm-bar')?.position === 'fixed' && nodeOf('detail-mobile', 'dtm-bar')?.bottom === 0, 'detail-mobile pins its action bar to the bottom'); +check(nodeOf('auth-mobile', 'amb-content')?.justifyContent === 'space-between' && nodeOf('auth-mobile', 'amb-content')?.height === '100%', 'auth-mobile holds its actions at the bottom of the screen'); +check(nodeOf('empty-state', 'es-cta')?.type === 'button', 'empty-state\'s action is a button primitive'); + console.log('listStructures({ type }) filters by the surface that covers the type'); const mobileList = listStructures({ type: 'mobile' }).map((s) => s.name); const webList = listStructures({ type: 'web' }).map((s) => s.name); @@ -129,7 +140,7 @@ check(resolveStructure('empty-state', 'mobile').name === 'empty-state', 'an any check(throws(() => resolveStructure('pricing', 'mobile'), /^"pricing" has no mobile realisation\. Page anatomies with a mobile realisation: dashboard, settings, auth, onboarding, feed, detail\. Pass realisation: "web"/), 'pricing on mobile refuses, naming the mobile page anatomies and realisation: "web"'); check(throws(() => resolveStructure('feed', 'web'), /"feed" has no web realisation\..*Pass realisation: "mobile"/), 'feed on web refuses and names realisation: "mobile"'); check(throws(() => resolveStructure('data-table', 'mobile'), /Component anatomies with a mobile realisation: .*empty-state/), 'a web-only component on mobile refuses, listing component anatomies'); -check(throws(() => resolveStructure('dashboard-mobile', 'web'), /mobile realisation of "dashboard" and this canvas is web.*Pass realisation: "mobile"/), 'a realisation name on the wrong type refuses with its force hint'); +check(throws(() => resolveStructure('dashboard-mobile', 'web'), /mobile realisation of "dashboard", not a web one.*Pass realisation: "mobile"/), 'a realisation name on the wrong type refuses with its force hint'); check(resolveStructure('dashboard-mobile', 'mobile').name === 'dashboard-mobile', 'a realisation name on its own type is used as is'); check(throws(() => resolveStructure('nope', 'web'), /not found/), 'unknown name → not found'); @@ -138,10 +149,25 @@ const phone = createCanvas('e-phone', undefined, {}, { type: 'mobile' }); const r1 = applyStructure(phone, 'dashboard'); check(r1.applied === 'dashboard-mobile' && r1.anatomy === 'dashboard' && r1.surface === 'mobile', 'dashboard on a mobile canvas stamps dashboard-mobile'); check(phone.metadata?.provenance?.structure === 'dashboard-mobile', 'provenance records the realisation name'); -check(r1.placeholders.some((p) => p.prop === 'title' && p.id === 'dm-row-1') && r1.placeholders.some((p) => p.prop === 'label'), 'primitive copy is listed as placeholders with its prop'); +const props = new Set(r1.placeholders.filter((p) => p.prop).map((p) => `${p.id}:${p.prop}`)); +check(['dm-row-1:title', 'dm-row-1:subtitle', 'dm-row-1:trailing.text', 'dm-nav:title', 'dm-recent-head-action:label', 'dm-tabs:items[0].label', 'dm-tabs:items[3].label'].every((k) => props.has(k)), + 'every primitive copy field is a placeholder with its path', [...props].join(', ')); +const feedPh = applyStructure(createCanvas('e-feed-ph', undefined, {}, { type: 'mobile' }), 'feed').placeholders; +check(feedPh.some((p) => p.id === 'fm-row-1' && p.prop === 'leading.initials' && p.role === 'AB'), 'list-row initials are a placeholder'); +const detailPh = applyStructure(createCanvas('e-detail-ph', undefined, {}, { type: 'mobile' }), 'detail').placeholders; +check(detailPh.some((p) => p.id === 'dtm-nav' && p.prop === 'leading.label' && p.role === 'Back'), 'a nav-bar back label is a placeholder'); +const fallbacks = applyStructure(createCanvas('e-fallback', undefined, {}, { type: 'mobile' }), 'feed', { existingColors: new Set(['primary', 'surface', 'error']) }); +check(!['accent', 'bg-surface', 'danger'].some((t) => fallbacks.seededColors.includes(t)) && fallbacks.seededColors.includes('text-primary'), + 'a canvas resolving primary / surface / error keeps them: the primitives\' accent / bg-surface / danger are not seeded over them', fallbacks.seededColors.join(', ')); check(['text-primary', 'text-secondary', 'border', 'bg-surface', 'danger', 'accent'].every((t) => r1.seededColors.includes(t)), 'the colour tokens primitives read are seeded'); const plain = applyStructure(createCanvas('e-plain', undefined, {}, { type: 'mobile' }), 'feed'); check(plain.seededColors.includes('text-primary'), 'a stamp that references no $text-primary still seeds it for its primitives'); +for (const type of ['status-bar', 'home-indicator', 'nav-bar', 'tab-bar', 'button', 'list-row', 'sheet'] as const) { + registerStructure(page(`seed-${type}`, { surface: 'any', nodes: [{ id: `seed-${type}-n`, type }] })); + const seeded = applyStructure(createCanvas(`e-seed-${type}`), `seed-${type}`).seededColors; + const want = ['accent', 'bg-surface', 'border', 'danger', 'text-primary', 'text-secondary']; + check(want.every((t) => seeded.includes(t)), `a lone ${type} seeds all six colours the primitives read`, seeded.join(', ')); +} const forced = applyStructure(createCanvas('e-forced', undefined, {}, { type: 'mobile' }), 'dashboard', { realisation: 'web' }); check(forced.applied === 'dashboard' && forced.surface === 'web', 'realisation: "web" stamps the web dashboard on a mobile canvas'); const toWeb = applyStructure(createCanvas('e-web'), 'feed', { realisation: 'mobile' }); From 30c9ced28ac99ebcef03db8211793c3135dbf8a3 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 20:40:41 +0100 Subject: [PATCH 4/4] Document nested placeholder paths and the explicit-token seeding caveat --- README.md | 2 +- docs/GUIDELINES.md | 4 ++-- docs/specs/PHASE-30-SPEC.md | 5 +++++ src/index.ts | 6 +++--- test-structures-mcp.ts | 1 + 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a8d8f04..939a86b 100644 --- a/README.md +++ b/README.md @@ -644,7 +644,7 @@ Stamp a layout structure onto a canvas and return the placeholder node IDs to po - **Page scaffolds** insert at the canvas root (refusing on a non-empty canvas unless `replace`), record provenance (`metadata.provenance`), and append to the **per-project build log** that feeds the `diversification` signal. - **Component scaffolds** insert under `targetId` (default root), repeatably — every stamp re-keys its node IDs (`form-field-1-…`, `form-field-2-…`) and returns an `idMap` (template ID → live ID) for follow-up ops. Component stamps don't touch provenance or the build log: they don't shape the page. -- **The name resolves by the canvas's declared type.** An anatomy name stamps that anatomy's realisation for the type: `dashboard` on a mobile canvas stamps `dashboard-mobile`. A realisation name is used as is. A name with no realisation for the type is refused with the anatomies of the same kind that have one (`pricing` on a phone; a web-only component too, whose anatomy is its own name); `realisation` stamps another type's realisation anyway. Resolution reads the *declared* type (`canvas_create({ type: "mobile" })` or `canvas_set_type`): an undeclared canvas is web, whatever its width. The result names the `anatomy` and `surface` it stamped, and its `placeholders` include every copy field of its primitives, with `prop` the field's path (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`); set a nested one by writing its whole object (`U(id, { trailing: { text: "2h" } })`). A stamp holding a primitive also seeds the colours the primitives read by default (`$text-primary`, `$text-secondary`, `$border`, and `$accent` / `$bg-surface` / `$danger` unless the canvas resolves `$primary` / `$surface` / `$error`), so `seededColors` can list names no node mentions. +- **The name resolves by the canvas's declared type.** An anatomy name stamps that anatomy's realisation for the type: `dashboard` on a mobile canvas stamps `dashboard-mobile`. A realisation name is used as is. A name with no realisation for the type is refused with the anatomies of the same kind that have one (`pricing` on a phone; a web-only component too, whose anatomy is its own name); `realisation` stamps another type's realisation anyway. Resolution reads the *declared* type (`canvas_create({ type: "mobile" })` or `canvas_set_type`): an undeclared canvas is web, whatever its width. The result names the `anatomy` and `surface` it stamped, and its `placeholders` include every copy field of its primitives, with `prop` the field's path (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`); a `prop` is a path, not an op key: set a nested one by writing its whole object or array, siblings included (`U(id, { trailing: { text: "2h" } })` replaces `trailing`, so carry a `chevron` along; `items[0].label` means rewriting `items`). A stamp holding a primitive also seeds the colours the primitives read by default (`$text-primary`, `$text-secondary`, `$border`, and `$accent` / `$bg-surface` / `$danger` unless the canvas resolves `$primary` / `$surface` / `$error`), so `seededColors` can list names no node mentions. A scaffold node that names `$accent` or `$bg-surface` itself still seeds it, as every scaffold always has, so a hand-themed canvas should define both before stamping (the presets and `generate_design_system` do). | Param | Type | Description | |-------|------|-------------| diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 6a046c0..196c0ba 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -113,9 +113,9 @@ A structure realises an **anatomy** (a screen kind) for one canvas type. A canva | `feed` | `feed-mobile` | A full-bleed list of rows (monogram, title, preview, time) and a pinned four-tab bar | | `detail` | `detail-mobile` | Back nav bar, media slot, title, body, an attribute list, and a primary action pinned over the home indicator | -Every one starts with a `status-bar`, ends with a `home-indicator` or a pinned `tab-bar`, and builds every tap target from a primitive, so it passes the mobile ruleset in fast and detailed mode and survives `canvas_stress`, unthemed and under every personality. Adapt it like any scaffold, taking node ids and fields from the result's `placeholders` rather than guessing them: each entry is `{ id, role, prop? }`, where `role` is the placeholder copy and `prop` is the path of a primitive's copy field (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`). Set a top-level one directly, a nested one by writing its whole object: `U("dm-row-1", { title: "Morning run", subtitle: "5.2 km", trailing: { text: "7:12" } })`, `U("amb-submit", { label: "Sign in" })`. +Every one starts with a `status-bar`, ends with a `home-indicator` or a pinned `tab-bar`, and builds every tap target from a primitive, so it passes the mobile ruleset in fast and detailed mode and survives `canvas_stress`, unthemed and under every personality. Adapt it like any scaffold, taking node ids and fields from the result's `placeholders` rather than guessing them: each entry is `{ id, role, prop? }`, where `role` is the placeholder copy and `prop` is the path of a primitive's copy field (`title`, `subtitle`, `label`, `trailing.text`, `leading.initials`, `leading.label`, `items[0].label`). A `prop` is a path, not an op key: set a top-level one directly, a nested one by writing its whole object or array with its siblings (a `trailing` you write replaces the old one, chevron included; a tab label means rewriting `items`): `U("dm-row-1", { title: "Morning run", subtitle: "5.2 km", trailing: { text: "7:12" } })`, `U("amb-submit", { label: "Sign in" })`. -- **Use `generate_design_system` for a phone's type**, then stamp. The `dark`, `light` and `material` presets set a 14–15px `$body` (`minimal` sets 16), which `mobile-type-floor` flags on a phone whatever the layout; the stamp's own neutral defaults (a 16px body) pass. +- **Use `generate_design_system` for a phone's type**, then stamp. The `dark`, `light` and `material` presets set a 14–15px `$body` (`minimal` sets 16), which `mobile-type-floor` flags on every phone screen that sets text in `$body` (`auth`, `onboarding` and `detail` do); the stamp's own neutral defaults (a 16px body) pass. - **`list_structures({ canvasId })`** lists what stamps on that canvas: on a phone, the six realisations and the components marked for every type (`form-field`, `stat-card`, `empty-state`, `skeleton-card`, `skeleton-stat-card`, `kpi-card`, `status-chip`, `initials-avatar`). Each entry names its `anatomy` (for a component, its own name), its `surface` (`web`, `mobile` or `any`) and `surfaces`, the types its anatomy is realised on (`["web", "mobile"]` for `dashboard`, `["mobile"]` for `feed-mobile`). Passing `canvasId` and `type` together is an error. - **A name with no realisation for the canvas's type is refused**, with the anatomies of the same kind that have one: `pricing` on a phone, or a web-only component such as `data-table` (its 28px pager buttons are hand-built), `toolbar` or `toggle-row`. `realisation: "web"` stamps the web one anyway, and `realisation: "mobile"` stamps a mobile one on a web canvas. - **The realisations never write `safeArea`.** Their pinned chrome makes the renderer reserve the insets, so they are also correct on a canvas made mobile with `canvas_set_type`. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 9917ea4..5b8707c 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -343,6 +343,11 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *`dashboard-mobile`'s delta line* carries the success colour on its icon and secondary ink on its text, with no tint fill: an unthemed stamp followed by a light preset had left the seeded dark tint under dark text (1.2:1). - *The refusal for a realisation name* says "not a one" (it said "this canvas is …", wrong when `realisation` forced the type). - *Tests added from the mutation round:* the eight `any` markers, each primitive type seeding all six colours alone, the pinned bar and the bottom-held auth actions, the handler passing inherited tokens (a stamp after `generate_design_system` seeds none of its colours), `list_structures` reading a type changed on disk, and the GOTCHAS naming every anatomy. + - *After the 3b round* (one fix round, docs and the result text only; the new checks proven red on 93ce10c and green after, not re-verified by an agent): + - *The seeding caveat is on the agent surfaces.* A realisation node that names `$accent` or `$bg-surface` explicitly (`dashboard-mobile`, `onboarding-mobile`, and every web scaffold) still seeds it over a canvas that only defines `$primary` / `$surface`, and the tab bar then draws with the neutral default (the 3b verifier measured 2.48:1 on such a canvas; primitive labels are not contrast-checked). The presets and `generate_design_system` define both tokens, so the docs tell a hand-themed canvas to do the same before stamping; seeding an alias's value instead is a change to every scaffold, left for later. + - *A `prop` is a path, not an op key.* `U(id, { "trailing.text": … })` stores a literal key; the docs and the stamp's `instruction` now say to write the whole object or array, siblings included. + - *The preset sentence is exact:* `mobile-type-floor` flags a preset's body only on screens that set text in `$body` (`auth`, `onboarding`, `detail`). + - *Recorded, not changed:* the gate's check that `empty-state`'s action is a `button` is structural; a different hand-built CTA of unknown width would pass the rules, as FR-C2 accepts. - *Known limit, recorded.* The diversification hint, fed a phone project's realisations, says they repeat `heroTreatment: none` and `rhythm: uniform`: true (five of the six have no hero), and advisory only, but on a phone it can only be satisfied by forcing a web structure. - *Known limit, not fixed here.* The renderer scales any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it is a render change outside slice E. Slice G's Ridgeline rebuild will show it. diff --git a/src/index.ts b/src/index.ts index 9be37dd..d1c6a30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN (unthemed and under every personality); stamp it after generate_design_system (the dark, light and material presets carry a 14–15px body that mobile-type-floor flags on a phone). Resolution reads the DECLARED type: an undeclared 390px canvas stamps the web structure, so declare type "mobile" first. Its placeholders include every copy field of its primitives, with prop the field's path (title, subtitle, label, trailing.text, leading.initials, leading.label, items[0].label — set a nested one by writing its whole object prop). An anatomy with no mobile realisation (the other web pages such as pricing, catalogue or bento-grid; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, and the root grows with long content keeping that padding past it, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN (unthemed and under every personality); stamp it after generate_design_system (the dark, light and material presets carry a 14–15px body that mobile-type-floor flags on a phone). Resolution reads the DECLARED type: an undeclared 390px canvas stamps the web structure, so declare type "mobile" first. Its placeholders include every copy field of its primitives, with prop the field's path (title, subtitle, label, trailing.text, leading.initials, leading.label, items[0].label — a path, not an op key: set a nested one by writing its whole object or array, siblings included). An anatomy with no mobile realisation (the other web pages such as pricing, catalogue or bento-grid; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px — rebuild it as a button), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -1524,7 +1524,7 @@ server.tool( - page scaffolds insert at the canvas root and record provenance; refuses if the root already has content unless replace is true. - component scaffolds (data-table, form-field, toolbar, stat-card, toggle-row, …) insert under targetId (default root), repeatably — every stamp re-keys its node IDs and returns an idMap (templateId → live id) for follow-up batch_design ops. The name resolves by the canvas's declared type: an anatomy name stamps that anatomy's realisation for the type — "dashboard" on a mobile canvas stamps dashboard-mobile (status bar, nav bar, list rows, pinned tab bar, all primitives) — and the result names the anatomy and surface it stamped; its placeholders include every copy field of its primitives, with prop the field's path. A realisation name (dashboard-mobile) is used as is. A name with no realisation for the canvas's type is refused with the anatomies that have one (pricing on a phone canvas; components not marked for every type too); realisation: "web" | "mobile" stamps that type's realisation anyway. -Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$title/$heading/$text-lg/$text-sm/$body/$label/$caption) for spacing, corner radius, depth, and EVERY text node's size (not just $color, and not just titles) — stamp AFTER generate_design_system on a project and the pattern already reads in that system's density, personality, and voice; no scaffold can inject a size the generated scale doesn't have. Also hardened to survive hostile-length content (canvas_stress CLEAN). Seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's seededColors/seededTokens list exactly what got seeded (a stamp holding a mobile primitive also seeds the colours primitives read by default: $text-primary, $text-secondary, $border, and $accent / $bg-surface / $danger unless the canvas resolves $primary / $surface / $error). Returns the placeholder node ids to populate — fill them with batch_design U ops, then call screenshot to verify the layout.`, +Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$title/$heading/$text-lg/$text-sm/$body/$label/$caption) for spacing, corner radius, depth, and EVERY text node's size (not just $color, and not just titles) — stamp AFTER generate_design_system on a project and the pattern already reads in that system's density, personality, and voice; no scaffold can inject a size the generated scale doesn't have. Also hardened to survive hostile-length content (canvas_stress CLEAN). Seeds neutral defaults (the pre-generator literal values) for any of those tokens the canvas can't already resolve, so an unthemed canvas still renders correctly; the result's seededColors/seededTokens list exactly what got seeded (a stamp holding a mobile primitive also seeds the colours primitives read by default: $text-primary, $text-secondary, $border, and $accent / $bg-surface / $danger unless the canvas resolves $primary / $surface / $error — a scaffold node that names $accent or $bg-surface itself still seeds it, so define $accent and $bg-surface on a hand-themed canvas before stamping). Returns the placeholder node ids to populate — fill them with batch_design U ops, then call screenshot to verify the layout.`, { canvasId: z.string().describe('Canvas ID'), structure: z.string().describe('Structure name (use list_structures, e.g. marquee-hero, data-table)'), @@ -1561,7 +1561,7 @@ Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$ ...result, instruction: result.kind === 'component' ? 'Populate the placeholders via the idMap with batch_design U ops (e.g. U(idMap["dt-row1-name"], { content: "..." })); copy repeated fragments with C ops; then screenshot to verify.' - : 'Populate each placeholder by id with batch_design U ops (replace the role-labeled content), then call screenshot to verify the layout before refining.', + : 'Populate each placeholder by id with batch_design U ops (replace the role-labeled content; for a primitive, prop is the field — a nested one such as trailing.text or items[0].label is set by writing its whole object or array, siblings included: U(id, { trailing: { text: "2h" } })), then call screenshot to verify the layout before refining.', }, null, 2) }] }; } catch (err) { return { content: [{ type: 'text', text: `Error: ${(err as Error).message}` }], isError: true }; diff --git a/test-structures-mcp.ts b/test-structures-mcp.ts index dc500d1..081a507 100644 --- a/test-structures-mcp.ts +++ b/test-structures-mcp.ts @@ -59,6 +59,7 @@ try { // apply_structure resolves by the canvas type. const stamped = (await tool('apply_structure', { canvasId: phone, structure: 'dashboard' })).data; + check('the stamp\'s instruction says how to set a nested primitive field', /whole object or array/.test(stamped.instruction ?? ''), stamped.instruction); check('dashboard on a mobile canvas stamps dashboard-mobile', stamped.applied === 'dashboard-mobile' && stamped.anatomy === 'dashboard' && stamped.surface === 'mobile', JSON.stringify({ applied: stamped.applied, anatomy: stamped.anatomy })); const refused = await tool('apply_structure', { canvasId: phone, structure: 'pricing', replace: true }); check('pricing on a mobile canvas refuses, listing the mobile anatomies', refused.isError && /no mobile realisation.*dashboard, settings, auth, onboarding, feed, detail/.test(refused.text), refused.text);