From c25d246b8b133eefc7acc88c76b8aa453e3ecad8 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 21:45:27 +0100 Subject: [PATCH 1/4] Add the canvas type axis and an axis-keyed ruleset table Phase 30 slice B. canvas_create takes type/device (mobile: device artboard and root safeArea insets the renderer adds to the root padding); canvas_set_type re-declares without touching the root. Genre and preset relaxations move into one ruleset table keyed by type, genre and preset that composes by union and can enable or disable rule ids. Evaluate and autofix report the surface, and generate_design_system pivots on the device body size on mobile. --- README.md | 24 ++- VISION.md | 2 +- docs/GUIDELINES.md | 10 ++ docs/specs/PHASE-30-SPEC.md | 35 ++-- src/design-language.ts | 19 ++- src/evaluate.ts | 109 +++++-------- src/index.ts | 98 ++++++++++-- src/renderer.ts | 28 ++++ src/scene-graph.ts | 36 ++++- src/surfaces.ts | 211 ++++++++++++++++++++++++ src/types.ts | 9 ++ src/viewer.ts | 7 +- test-design-language.ts | 25 ++- test-discoverability.ts | 38 +++-- test-surface.ts | 308 ++++++++++++++++++++++++++++++++++++ 15 files changed, 837 insertions(+), 122 deletions(-) create mode 100644 src/surfaces.ts create mode 100644 test-surface.ts diff --git a/README.md b/README.md index f693684..27d12e2 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,12 @@ Create a new canvas. If `projectId` is omitted, it lands in the built-in `Untitl |-------|------|-------------| | `name` | string? | Canvas name | | `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). A phone screen is `width: 390, height: 844`. | -| `height` | number? | Artboard height in px, a positive integer (default 900). Each dimension defaults on its own. | +| `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. | +| `device` | string? | Device profile for `type: "mobile"` (default `"iphone-390"`: 390×844, insets 47 top and 34 bottom). Only valid with `type: "mobile"`. | -The response also carries the artboard `width`/`height` 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. +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. ### `canvas_list` @@ -466,7 +468,7 @@ The headline call for a new project's look: **one seed color + one personality |-------|------|-------------| | `seed` | string | The brand color everything derives from (`#RRGGBB`) | | `personality` | string | **Required** — `technical`, `editorial`, `soft`, or `data-dense`. Dashboards → `data-dense`/`technical`, marketing/content → `editorial`, consumer → `soft` | -| `baseSize` / `ratio` | number? | Override the personality's type pivot / scale ratio | +| `baseSize` / `ratio` | number? | Override the type pivot / scale ratio. On a canvas declared `type: "mobile"` the pivot defaults to the device body size (17px; 16px for `data-dense`); `baseSize` still wins. The result reports the pivot as `baseSize` + `baseSizeSource` (`"explicit"`, `"personality"` or `"mobile"`). | | `canvasId` / `projectId` / `workspaceId` | string? | Exactly one — the token layer written to | | `preserveInherited` | boolean? | Canvas scope only. Default `true` — see "Inherited tokens" below. Pass `false` to write the generated language whole. | @@ -797,6 +799,18 @@ Durably declare what a canvas *is* — writes the evaluation genre to `metadata. Returns `{ canvasId, genre, canonical, previous, relaxes, presetRelaxes?, note?, versionHashUnchanged }` — `canonical` is the genre with its alias folded (`"dashboard"` → `"data"`, `"checkout"` → `"commerce"`, what evaluate reports); `relaxes` lists the cliché tells the stamp relaxes; `presetRelaxes` (`{ preset, relaxed }`) appears when the canvas's preset relaxes tells on its own (the material preset), which a genre clear doesn't remove; an unknown genre is stored but relaxes nothing, and the `note` says so immediately. Other provenance facts (structure, `importedFrom`) are preserved, and the stamp never moves the canvas's `versionHash` (the hash covers design content only) — recorded approvals stay valid. +### `canvas_set_type` + +Declare what medium an existing canvas is on. Writes `metadata.surface` only: it never resizes the artboard and never writes the root's `safeArea`, because either would reflow what is already placed. For a new phone screen, `canvas_create({ type: "mobile" })` does all of it at once. + +| Param | Type | Description | +|-------|------|-------------| +| `canvasId` | string | Canvas to declare | +| `type` | `"web"` \| `"mobile"` \| null | The medium; `null` clears the declaration (the canvas reads as undeclared web again) | +| `device` | string? | Device profile for `"mobile"` (default `"iphone-390"`). Only valid with `"mobile"`. | + +Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. On a mobile result whose root has no `safeArea` or a size other than the device's, `hint` names the op that finishes the job, e.g. `U("document", { safeArea: { top: 47, bottom: 34 } })`. The declaration is metadata, so the `versionHash` doesn't move; it does change which rules run, so re-evaluate after it. + ### `canvas_evaluate` Auto-score a design against quality heuristics. Returns an overall score (0–100), per-category scores, per-node actionable issues, and a **`directive`** — a present/keep-working verdict. Designed for generator-evaluator loops: build with `batch_design`, score with `canvas_evaluate`, fix the issues targeting the returned `nodeId`s, repeat until the directive says `READY`. @@ -809,6 +823,8 @@ A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict f Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed, byPreset? }` — the genre in effect (canonical: a `"dashboard"` stamp reports `"data"`, a `"checkout"` one `"commerce"`), whether it came from the `genre` param or the canvas's provenance stamp, the tells relaxed, `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax — and `byPreset` (`{ preset, relaxed }`), present when the applied preset relaxed tells on its own (the material preset relaxes accent-hue and pure-black-white whatever the genre). A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. +Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"` or `"mobile"`), its device profile (`null` on web), whether the type was `"declared"` (by `canvas_create` or `canvas_set_type`) or is the `"default"` web of an undeclared canvas, and the rule ids the composed type, genre and preset rulesets turned on (`rulesOn`) and off (`rulesOff`). A rule both enabled and disabled stays on and is listed in `conflicts`. `canvas_autofix` returns the same field. + Whenever the `coverage` category runs on a **base** canvas, the result also carries a **`coverage`** field: `{ dataBearing, states, missing }` — whether the screen carries data-bearing content, which state variants (`canvas_add_variant`) already exist, and which demanded states are missing. It's absent on variant canvases (a variant *is* a designed state, so it doesn't demand states of itself). | Param | Type | Description | diff --git a/VISION.md b/VISION.md index 3e6fd21..8078ecc 100644 --- a/VISION.md +++ b/VISION.md @@ -546,7 +546,7 @@ _Full spec-driven breakdown in [`docs/specs/PHASE-29-SPEC.md`](docs/specs/PHASE- The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and got 100/100, READY TO PRESENT, without a single mobile check running: the touch-target rule only looks at form controls, and the screen had none. Mobile today is a screen size, not a surface. This phase gives a canvas a declared `type` (`web` or `mobile`), separates it from genre and preset so the three declarations stop colliding, and builds the three layers a surface needs: rules that judge a phone, vocabulary to build one, and starting points. Acceptance is literal: the attempt screen rebuilt from a mobile archetype, CLEAN with every tap target a primitive. - [x] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias -- [ ] 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 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` - [ ] Slice C — the mobile ruleset: 44px targets as an error on every tappable primitive, 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 - [ ] Slice D — vocabulary: `fixed` / `sticky` positioning, `safeArea` on the root, and the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives - [ ] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index ea27924..80c1b19 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -36,6 +36,16 @@ Pick the right `width` per node — this is the single biggest lever for respons **Artboard height is a viewport, not a limit.** The root's `height` sizes the default `screenshot` capture, but content taller than it is normal — it scrolls on a real page, and a `clip` finding from `canvas_stress` for exactly that case is `info`, not a problem to fix. To actually see past the artboard (a long page, a design that grew under stress content), pass `fullPage: true` to `screenshot` — or to `export`, which takes the same option — instead of editing the root height by hand. +## Declaring the medium (web or mobile) + +A canvas declares what medium it is on, separately from its genre (what it is for) and its preset (what style it uses). The type picks which rules judge the screen and which defaults the generators use. It never changes what renders. + +- **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. The renderer adds those to the root's own top and bottom padding, at exact pixels, and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. +- **`type: "web"` is the default.** An undeclared canvas is judged as web and its evaluate result says `source: "default"`. +- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only. It never resizes the artboard and never writes `safeArea`, because either would reflow what you placed. When the result has a `hint`, follow it: `U("document", { safeArea: { top: 47, bottom: 34 } })` reserves the insets, and the hint names the device size if the root differs. +- **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports `baseSize` and `baseSizeSource`. +- **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off; a rule both enabled and disabled stays on and is listed in `conflicts`, so composing declarations can never switch a guardrail off. + ## 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 c3564c2..0ea220c 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -63,19 +63,19 @@ One profile ships. The mechanism is keyed by profile id so a second device (393 |---|---|---|---| | Profile id | `iphone-390` | — | — | | Artboard | 390×844 | iPhone 12/13/14 class | high | -| Safe top inset | 47 | iOS portrait inset for this class | medium (third-party, consistent) | -| Safe bottom inset | 34 | iOS home-indicator inset | medium | -| Touch target floor | 44 | Apple HIG 44pt; WCAG 2.2 SC 2.5.5 (AAA) 44px | high (WCAG verified) | -| Nav bar height | 44 (large title 96) | iOS | medium | -| Tab bar height | 49, plus the 34 bottom inset | iOS | medium | -| Tab count | 2–5 allowed, 3–5 recommended, every tab labeled | Material 3 nav bar 3–5 (verified); Apple (secondary) | high | -| Body size | 17 (floor 16) | iOS Dynamic Type Body 17; 16 is the Material and mobile-web floor | high | -| Smallest text | 11 | iOS Caption 2 | high | -| List row min height | 44 (56 with a subtitle) | iOS table row 44 | medium | -| Side margin | 16 | iOS and Material compact | medium | -| Home indicator | 134×5 pill, centred in the bottom inset | iOS | medium | - -Values marked medium came from secondary sources because Apple's HIG pages are JS-rendered and could not be fetched. Slice B, which lands the profile table, re-checks them against the HIG before they land in code; a corrected value changes this table in the same PR. +| Safe top inset | 47 | iOS portrait inset for this class (useyourloaf iPhone 12/14 tables) | medium (secondary sources agree; no Apple number) | +| Safe bottom inset | 34 | iOS home-indicator inset (same tables) | medium (secondary sources agree) | +| Touch target floor | 44 | Apple HIG Buttons ("hit region of at least 44x44 pt"); WCAG 2.2 SC 2.5.5 (AAA) 44px | high (both verified) | +| Nav bar height | 44 (large title 96) | iOS convention | house value (no Apple number reachable) | +| Tab bar height | 49, plus the 34 bottom inset | iOS convention | house value (the HIG gives a height for tvOS only) | +| Tab count | 2–5 allowed, 3–5 recommended, every tab labeled | Material 3 nav bar 3–5 (verified); Apple HIG Tab bars: "five or fewer", include labels (verified) | high | +| Body size | 17 (floor 16) | Apple HIG Typography: Body 17 at the Large default (verified); 16 is the Material and mobile-web floor | high | +| Smallest text | 11 | Apple HIG Typography: Caption 2 = 11 (verified) | high | +| List row min height | 44 (56 with a subtitle) | 44 follows the hit region; 56 is a house value | house value (the HIG gives no row heights) | +| Side margin | 16 | iOS and Material compact | house value (the HIG defers to system margins) | +| Home indicator | 134×5 pill, centred in the bottom inset | one secondary source | low (house value) | + +Slice B re-checked every value against the HIG through developer.apple.com's JSON and Markdown endpoints (2026-10-31). No value changed. Apple's own pages confirm the 44pt target, Body 17, Caption 2 = 11 and "five or fewer" labeled tabs. The insets come from secondary tables that agree with each other. The bar heights, row heights, side margin and home indicator have no Apple source that could be read, so they are house values; a simulator measurement can raise them later. ### Terms used below @@ -177,6 +177,15 @@ Values marked medium came from secondary sources because Apple's HIG pages are J - Surfaces: `canvas_create` / `canvas_set_type` / `canvas_evaluate` docstrings, `INSTRUCTIONS`, GOTCHAS, GUIDELINES; `test-discoverability.ts` gains a types list and a device-profile list. - Tests: new `test-surface.ts` (creation defaults, set-type, union, conflict, report, `safeArea` shell padding as an HTML-string assertion with no Chrome, and `metadata.surface` render invariance), `test-design-language.ts` mobile cases. +- **As built (spec changes recorded at slice B):** + - *No rule ids yet.* Every `enable` / `disable` list in the ruleset table is empty, the mobile row included, because a rule id lands in the same change as the check that honours it. On a mobile canvas the `surface` report's `rulesOn` is therefore `[]` in slice B; FR-B3's "lists every mobile rule id" becomes real as slice C registers each check. Semantics for slice C: an opt-in rule runs only when some active ruleset enables it; a default rule runs unless one disables it. + - *The table holds canonical genres only.* `GENRE_ALIASES` moved to `src/surfaces.ts` with the table, and every lookup folds an alias first. `relaxedByGenre`, `relaxedByPreset`, `knownGenres` and `canonicalGenre` are views over it, re-exported from `evaluate.ts` so callers did not change. `test-discoverability.ts` imports the table instead of parsing `evaluate.ts`. + - *The table has a `base` row* (empty for now) beside `type`, `genre` and `preset`, so FR-C6's base-ruleset rule has a home. + - *`surface` is always on the evaluate result*, every mode and category filter, the empty-canvas result included; `canvas_autofix` returns it too. + - *`safeArea` renders as `padding-top: calc( + px)`* (and bottom likewise) on the document root only, at exact pixels even when the authored padding is fluid. A side that is not a finite non-negative number is ignored. `safeArea` is part of the root, so it is in the `versionHash`. + - *`canvas_create`* errors on `device` without `type: "mobile"` and on an unknown device, and reports `type`, `typeSource`, `device` and `safeArea`. *`canvas_set_type`* reports `previous`, the unchanged `artboard`, and a `hint` naming the `safeArea` op and the device size when a mobile result lacks them. + - *`generate_design_system`* reports its pivot as `baseSize` + `baseSizeSource` (`explicit` / `personality` / `mobile`). Project and workspace scope have no type, so they stay web. + **Slice C — the mobile ruleset (FR-C1..C8)** - Each check is registered by rule id in the ruleset table and runs only when on, except `mobile-artboard`, which is in the base ruleset (FR-C6). - `src/evaluate.ts:1785-1900` (`checkUsability`): take the active ruleset instead of `rootWidth` alone; extend the tappable set with the slice D primitives as they land (the rule id exists from slice C, tested against controls first); add `mobile-handbuilt-target`. diff --git a/src/design-language.ts b/src/design-language.ts index e7a28a3..bf5df59 100644 --- a/src/design-language.ts +++ b/src/design-language.ts @@ -15,6 +15,7 @@ import { generateColorSystem } from './color-system.js'; import { generateTypeScale, generateSpaceScale, type RatioName } from './scales.js'; import type { DesignVariables, ShadowSpec } from './types.js'; +import { DEVICE_PROFILES, DEFAULT_DEVICE, deviceProfile, type SurfaceType } from './surfaces.js'; export type PersonalityName = 'technical' | 'editorial' | 'soft' | 'data-dense'; export const PERSONALITY_NAMES: PersonalityName[] = ['technical', 'editorial', 'soft', 'data-dense']; @@ -156,6 +157,22 @@ export interface DesignSystemOptions { baseSize?: number; /** Override the personality's scale ratio. */ ratio?: RatioName | number; + /** Phase 30 slice B — the canvas type the system is for. On `mobile` the + * pivot becomes the device body size (17; 16 for data-dense) unless + * `baseSize` is explicit. */ + surface?: SurfaceType; + /** The mobile device profile (default "iphone-390"). */ + device?: string; +} + +/** Where a generated system's type pivot came from. */ +export function typeBase(personalityName: PersonalityName, opts: DesignSystemOptions = {}): { size: number; source: 'explicit' | 'personality' | 'mobile' } { + if (opts.baseSize !== undefined) return { size: opts.baseSize, source: 'explicit' }; + if (opts.surface === 'mobile') { + const profile = deviceProfile(opts.device ?? DEFAULT_DEVICE) ?? DEVICE_PROFILES[DEFAULT_DEVICE]; + return { size: personalityName === 'data-dense' ? profile.bodyFloor : profile.bodySize, source: 'mobile' }; + } + return { size: PERSONALITIES[personalityName].type.baseSize, source: 'personality' }; } /** One seed + one personality → the full token stack. Pure — writing to a @@ -180,7 +197,7 @@ export function generateDesignSystem( Object.assign(colors, colorSystem.categorical, colorSystem.tints); // Type scale — the existing engine, pivoted by the personality. - const baseSize = opts.baseSize ?? p.type.baseSize; + const baseSize = typeBase(personalityName, opts).size; const steps = generateTypeScale({ ratio: opts.ratio ?? p.type.ratio, baseSize, diff --git a/src/evaluate.ts b/src/evaluate.ts index 2534783..e984698 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -6,6 +6,7 @@ import { computeLayout, type LayoutRect } from './screenshot.js'; import { extractInventory, expandInstances, isDataLike } from './drift.js'; import { getIconSvg, parseIconRef } from './icons.js'; import { storedGenre } from './genre.js'; +import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, type SurfaceType } from './surfaces.js'; // --- Types --- @@ -85,6 +86,13 @@ export interface EvaluationResult { * evaluation cycle. */ genre?: GenreReport; + /** + * Phase 30 slice B — always present: the canvas's declared type (or the + * `web` default) and the rule ids the composed type + genre + preset + * rulesets turned on and off. A rule both enabled and disabled stays on and + * is listed in `conflicts`. + */ + surface: SurfaceReport; /** * Phase 24 slice C — present when the coverage category ran on a BASE * canvas: whether the screen carries data-bearing content (tables / forms), @@ -122,6 +130,18 @@ export interface GenreReport { byPreset?: { preset: string; relaxed: ClicheTell[] }; } +export interface SurfaceReport { + type: SurfaceType; + /** The device profile a mobile canvas targets; null on web. */ + device: string | null; + /** 'declared' when canvas_create / canvas_set_type set the type; 'default' + * when nobody did (the canvas is judged as web). */ + source: 'declared' | 'default'; + rulesOn: string[]; + rulesOff: string[]; + conflicts: string[]; +} + interface NodeEntry { node: SceneNode; parent: SceneNode | null; @@ -1020,69 +1040,10 @@ function checkConsistencyDetailed(entries: NodeEntry[], layoutRects: LayoutRect[ // fake browser chrome, the hanging eyebrow header, fabricated data). Advisory // by design (warning/info, never a hard error); genre can relax a gate. -/** Genre (what the screen is for) → tells it relaxes because they're - * intentional in that kind of screen. Material Design is legitimately purple. */ -const RELAXED_BY_GENRE: Record = { - // Material Design legitimately uses a purple accent AND white elevated - // surfaces (cards on an off-white background), so both are intentional here. - // Also a preset (RELAXED_BY_PRESET); kept as a genre because agents are - // told to declare editors and admin forms as "material". - material: ['accent-hue', 'pure-black-white'], - // Phase 22 slice C (#135) — screens whose figures are the user's own data - // (dashboards, activity, finance, fitness, logistics): realistic figures - // ARE the design (they drive column widths, wrapping, $2.7M-vs-$2,700,000 - // legibility), so "fabricated data" flags would zero the category for the - // screen's whole reason to exist. Phase 30: `data` is canonical (it names - // what the numbers are, not a layout); `dashboard` is the alias. - data: ['honest-content'], - dashboard: ['honest-content'], - // Phase 29 slice A (#194) — transactional commerce screens: a checkout's - // line prices, subtotal, store credit and total ARE the design (they set the - // column widths, the decimal alignment, and which number reads as the one - // you're about to pay), so the fabricated-data tell zeroes the category for - // the screen's whole reason to exist. Scope is the TRANSACTION — cart, - // checkout, order confirmation, billing history. NOT a pricing or marketing - // page: invented customer counts, savings claims and testimonials are - // exactly the slop this tell exists to catch, and `commerce` doesn't touch - // them (it relaxes honest-content, which fires on figures — the marketing - // slop lands on slop-copy and on honest-content's testimonial/logo shapes). - // `checkout` is an alias. - commerce: ['honest-content'], - checkout: ['honest-content'], -}; - -/** Phase 30 — the preset half of the ruleset: applying the material STYLE - * relaxes material's tells whatever the genre is, so untangling genre from - * preset doesn't strip a material-preset canvas of its relaxation. */ -const RELAXED_BY_PRESET: Record = { - material: ['accent-hue', 'pure-black-white'], -}; - -/** Genres that are aliases of another entry — folded to their canonical - * genre in the report so an alias never reads as a second option. */ -const GENRE_ALIASES = new Map([['dashboard', 'data'], ['checkout', 'commerce']]); - -// Own-key lookups only: a stored genre is free text, and "constructor" must -// read as an unknown genre, not Object.prototype.constructor. -function lookup(table: Record, key: string | undefined): ClicheTell[] { - return key !== undefined && Object.hasOwn(table, key) ? table[key] : []; -} - -export function canonicalGenre(genre: string): string { - return GENRE_ALIASES.get(genre) ?? genre; -} - -/** Issue #162 — what a genre stamp buys, for canvas_set_genre's immediate - * feedback (the evaluate-time report stays buildGenreReport's job). */ -export function relaxedByGenre(genre: string): ClicheTell[] { - return lookup(RELAXED_BY_GENRE, genre); -} -export function relaxedByPreset(preset: string | undefined): ClicheTell[] { - return lookup(RELAXED_BY_PRESET, preset); -} -export function knownGenres(): string[] { - return Object.keys(RELAXED_BY_GENRE); -} +// Phase 30 slice B — the genre/preset relaxation tables moved into the +// axis-keyed ruleset table in surfaces.ts (RULESETS); these re-exports keep +// their callers unchanged. +export { canonicalGenre, relaxedByGenre, relaxedByPreset, knownGenres } from './surfaces.js'; /** canvas_set_genre's result fields for a stamp on a canvas with `preset`: * the canonical genre, what the genre relaxes, and what the preset relaxes on @@ -1120,9 +1081,8 @@ function buildGenreReport( preset: string | undefined, ): GenreReport { const notRelaxed = new Map(); - for (const [g, tells] of Object.entries(RELAXED_BY_GENRE)) { - if (GENRE_ALIASES.has(g)) continue; - for (const tell of tells) { + for (const [g, ruleset] of genreRulesets()) { + for (const tell of ruleset.relax ?? []) { if (relaxed.has(tell)) continue; const by = notRelaxed.get(tell) ?? []; if (!by.includes(g)) by.push(g); @@ -2084,6 +2044,13 @@ export async function evaluateCanvas( // workspace design system. const mergedTokens = getCanvasTokens(canvas); const resolvedRoot = resolveVariables(canvas.root, mergedTokens); + // Phase 30 slice B — one composition of the type, genre and preset rulesets + // serves the cliche relaxation and the surface report. + const declared = resolveSurface(canvas); + const resolvedGenre = resolveGenre(canvas, options.genre); + const preset = canvas.metadata?.provenance?.preset; + const rules = composeRulesets(activeRulesets({ type: declared.type, genre: resolvedGenre.genre, preset })); + const surface: SurfaceReport = { ...declared, rulesOn: rules.on, rulesOff: rules.off, conflicts: rules.conflicts }; const entries = buildTreeContext(resolvedRoot); // Also walk the unresolved tree for token usage stats const rawEntries = buildTreeContext(canvas.root); @@ -2107,6 +2074,7 @@ export async function evaluateCanvas( summary: 'Canvas is empty — nothing has been designed yet.', stats: { totalNodes: entries.length, textNodes: 0, frameNodes: 0, maxDepth: 0, tokenUsagePercent: 0, componentReusePercent: 0 }, mode: options.mode, + surface, }; } @@ -2173,10 +2141,8 @@ export async function evaluateCanvas( let genreReport: GenreReport | undefined; if (activeCategories.includes('cliche')) { // Genre comes from the explicit option, else the canvas's stored genre; - // the applied preset contributes its own relaxation on top. - const resolved = resolveGenre(canvas, options.genre); - const preset = canvas.metadata?.provenance?.preset; - const relaxed = new Set([...relaxedByGenre(resolved.genre ?? ''), ...relaxedByPreset(preset)]); + // the applied preset contributes its own relaxation on top (composed above). + const relaxed = new Set(rules.relaxed); // Resolved entries → $accent becomes a real hex for hue math; rawEntries → // distinguish a literal default purple (autofixable) from a $token one. // Table header cells are small uppercase labels by convention — exclude @@ -2194,7 +2160,7 @@ export async function evaluateCanvas( } } results.set('cliche', checkCliche(entries, rawEntries, mergedTokens, { relaxed, tableDescendantIds })); - genreReport = buildGenreReport(resolved, relaxed, preset); + genreReport = buildGenreReport(resolvedGenre, relaxed, preset); } const overallScore = aggregateScores(results, CATEGORY_WEIGHTS); @@ -2234,6 +2200,7 @@ export async function evaluateCanvas( issues: allIssues, ...(coverageReport ? { coverage: coverageReport } : {}), ...(genreReport ? { genre: genreReport } : {}), + surface, summary: generateSummary(overallScore, categories, allIssues), stats: { totalNodes, diff --git a/src/index.ts b/src/index.ts index 47932e7..bb1ff62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url'; 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, addVariant } from './scene-graph.js'; +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 } 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'; @@ -28,7 +29,7 @@ import { applyPerturbation, compareLayouts, lastStressRun, PERTURBATION_NAMES, r import { buildEvaluateDirective } from './directive.js'; import { generateTypeScale, generateSpaceScale, resolveRatio, type RatioName } from './scales.js'; import { generateColorSystem } from './color-system.js'; -import { generateDesignSystem, PERSONALITY_NAMES, type PersonalityName } from './design-language.js'; +import { generateDesignSystem, typeBase, PERSONALITY_NAMES, type PersonalityName } from './design-language.js'; import { evaluateProject } from './project-evaluate.js'; import { startViewer, getViewerUrl, setExternalViewerUrl } from './viewer.js'; import { evaluateCanvas, describeGenreStamp, knownGenres } from './evaluate.js'; @@ -82,6 +83,8 @@ 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, 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 rules 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 and is listed in conflicts. + 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. - Prefer STRUCTURED gradient / shadows ({ stops: [...] } and [{ x, y, blur, color }]); a raw CSS string on those fields is accepted too. The plural \`shadows\` always beats the singular \`shadow\` — writing \`shadow\` on a node that has \`shadows\` is a no-op (batch_design warns when this happens). @@ -103,7 +106,7 @@ const WORKFLOW_CHEATSHEET = [ 'Start from a taste-vetted pattern: list_structures → apply_structure, then ADAPT it — don\'t start from a blank canvas.', '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 a phone screen); reference tokens with $name (e.g. fill: "$surface").', + '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").', 'screenshot → review the render → iterate.', 'canvas_evaluate → resolve EVERY comment (canvas_autofix apply: true for the mechanical subset / batch_design for the rest) → re-evaluate → repeat until the inspector is CLEAN (zero warnings, zero errors, zero cliché tells). That\'s the readiness gate — not the score, which is always reported and worth climbing toward (aim for > 95) but no longer withholds readiness once nothing blocking is left. Only present once CLEAN.', 'Final polish when an LLM API key is present: canvas_evaluate mode "llm" (the five-axis vision rubric) → canvas_revise on failing axes → re-judge until it passes or canvas_revise stops. The heuristic gate is the floor; the critique loop is the ceiling — make it the standard last step before presenting, not an exception. It never blocks: still-failing axes after canvas_revise stops get named to the user, not looped on. Keyless sessions skip it (a note says so), nothing else changes.', @@ -137,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.', + '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 judgment 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.', '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.', ]; @@ -169,18 +173,26 @@ 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 (e.g. 390×844 for a phone screen).', + '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 (judged by mobile rules, 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, 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.', { 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.'), - width: z.number().int().positive().optional().describe('Artboard width in px (default 1440)'), - height: z.number().int().positive().optional().describe('Artboard height in px (default 900)'), + width: z.number().int().positive().optional().describe('Artboard width in px (default 1440, or the device width for type "mobile")'), + height: z.number().int().positive().optional().describe('Artboard height in px (default 900, or the device height for type "mobile")'), + type: z.enum(['web', 'mobile']).optional().describe('What medium the canvas is on: "web" (default) or "mobile" (a phone app screen: device artboard + safe-area insets on the root)'), + device: z.string().optional().describe('Device profile for type "mobile" (default "iphone-390": 390×844, insets 47/34). Only valid with type "mobile".'), }, - async ({ name, projectId, width, height }) => { + async ({ name, projectId, width, height, type, device }) => { if (projectId && !getProject(projectId)) { return { content: [{ type: 'text', text: `Error: Project "${projectId}" not found. Use project_list to see available projects.` }], isError: true }; } - const canvas = createCanvas(name, projectId ?? DEFAULT_PROJECT_ID, { width, height }); + if (device !== undefined && type === undefined) { + return { content: [{ type: 'text', text: 'Error: device only applies to type "mobile" — pass type: "mobile" with it.' }], isError: true }; + } + const declared = type === undefined ? undefined : surfaceFor(type, device); + if (declared && 'error' in declared) return { content: [{ type: 'text', text: `Error: ${declared.error}` }], isError: true }; + const canvas = createCanvas(name, projectId ?? DEFAULT_PROJECT_ID, { width, height }, declared?.surface); + const surface = resolveSurface(canvas); const viewerUrl = getViewerUrl(); return { content: [ @@ -193,6 +205,10 @@ server.tool( projectId: canvas.projectId, width: canvas.root.width, height: canvas.root.height, + type: surface.type, + typeSource: surface.source, + ...(surface.device ? { device: surface.device } : {}), + ...(canvas.root.safeArea ? { safeArea: canvas.root.safeArea } : {}), viewerUrl: viewerUrl ? `${viewerUrl}/canvas/${canvas.id}` : null, galleryUrl: viewerUrl, diversification: diversificationFor(canvas.projectId), @@ -1107,11 +1123,11 @@ Personalities (required — pick a stance, don't default into sameness): - "soft" — warm, rounded, human (Plus Jakarta Sans + Inter, 12/16/20 radii, springy motion). For consumer apps, onboarding, anything friendly. - "data-dense" — instrument-panel density (Inter + a JetBrains Mono "figures" role, 13px pivot, minimal radii, near-flat depth). For dashboards, tables, monitoring. -The typography layer ships ROLE tokens the structures speak — $display / $heading / $title (page/screen titles, one step below $display — every archetype's page title references it) / $body / $label / $caption (+ $figures when a mono face exists) — alongside the text-xs…text-3xl steps. Depth: reference elevation from any node as shadow: "$elevation.flat|raised|floating|overlay" — the dark layer re-states each depth so it reads on dark surfaces. The COLOR RANGE rides along (Phase 28): $chart-1…$chart-6 categorical series tokens for dataviz, and the tint layer ($accent-tint / $success-tint / … — tint as fill, base color as ink, AA by construction both themes) for chips, icon tiles, pills, and avatars. Writes to exactly ONE of canvasId / projectId / workspaceId. Canvas scope keeps tokens the canvas resolves through an inherited design system rather than clobbering them — but preservation is FIELD-WISE for typography, so an inherited role that carries only a size keeps that size and still picks up this personality's family/weight/tracking (before v2.1 it swallowed the whole spec and the role silently rendered in the fallback stack). What was kept is reported two ways: \`preservedFromDesignSystem\` for ordinary tokens, and \`designSystemConflicts\` for tokens in the semantic vocabulary THIS system defines (the surface/ink/border/accent semantics, the STATUS colours and their paired -tint layer, the \$chart-* range, and the type roles — an inherited success keeps pairing with the GENERATED success-tint and fails contrast) — a conflict means the screen is running two design languages at once, and each entry names the fix. \`preserveInherited: false\` writes the generated language whole. The response's typographyRoles reports what the canvas ACTUALLY resolves, not what was generated — the two differ exactly when preservation fired. The two single-purpose generators stay available for targeted regeneration (just the palette, just the scale).`, +The typography layer ships ROLE tokens the structures speak — $display / $heading / $title (page/screen titles, one step below $display — every archetype's page title references it) / $body / $label / $caption (+ $figures when a mono face exists) — alongside the text-xs…text-3xl steps. Depth: reference elevation from any node as shadow: "$elevation.flat|raised|floating|overlay" — the dark layer re-states each depth so it reads on dark surfaces. The COLOR RANGE rides along (Phase 28): $chart-1…$chart-6 categorical series tokens for dataviz, and the tint layer ($accent-tint / $success-tint / … — tint as fill, base color as ink, AA by construction both themes) for chips, icon tiles, pills, and avatars. Writes to exactly ONE of canvasId / projectId / workspaceId. Canvas scope keeps tokens the canvas resolves through an inherited design system rather than clobbering them — but preservation is FIELD-WISE for typography, so an inherited role that carries only a size keeps that size and still picks up this personality's family/weight/tracking (before v2.1 it swallowed the whole spec and the role silently rendered in the fallback stack). What was kept is reported two ways: \`preservedFromDesignSystem\` for ordinary tokens, and \`designSystemConflicts\` for tokens in the semantic vocabulary THIS system defines (the surface/ink/border/accent semantics, the STATUS colours and their paired -tint layer, the \$chart-* range, and the type roles — an inherited success keeps pairing with the GENERATED success-tint and fails contrast) — a conflict means the screen is running two design languages at once, and each entry names the fix. \`preserveInherited: false\` writes the generated language whole. The response's typographyRoles reports what the canvas ACTUALLY resolves, not what was generated — the two differ exactly when preservation fired. The two single-purpose generators stay available for targeted regeneration (just the palette, just the scale). On a canvas declared type: "mobile" (canvas_create / canvas_set_type) the type scale pivots on the device body size — 17px for every personality except data-dense, which uses 16px — so the generated system already meets the phone body floor; an explicit baseSize still wins. The result reports the pivot as baseSize + baseSizeSource ("explicit" | "personality" | "mobile").`, { seed: z.string().describe('The brand color to derive everything from (#RRGGBB)'), personality: z.enum(['technical', 'editorial', 'soft', 'data-dense']).describe('The design stance — required. Genre guide: dashboards → data-dense or technical; marketing/content → editorial; consumer → soft.'), - baseSize: z.number().optional().describe('Override the personality\'s type pivot (10–24px)'), + baseSize: z.number().optional().describe('Override the type pivot (10–24px) — the personality\'s, or the 17/16px mobile body on a type "mobile" canvas'), ratio: z.union([z.enum(['minor-second', 'major-second', 'minor-third', 'major-third', 'perfect-fourth', 'golden']), z.number()]).optional().describe('Override the personality\'s scale ratio'), canvasId: z.string().optional().describe('Write to this canvas\'s variables'), projectId: z.string().optional().describe('Write to this project\'s design system'), @@ -1125,7 +1141,14 @@ The typography layer ships ROLE tokens the structures speak — $display / $head return { content: [{ type: 'text', text: 'Error: pass exactly ONE of canvasId / projectId / workspaceId — the layer the system is written to.' }], isError: true }; } try { - const system = generateDesignSystem(seed, personality as PersonalityName, { baseSize, ratio }); + // Phase 30 slice B — a mobile canvas gets the device body size as its + // pivot (canvas scope only: a project/workspace has no type). + if (canvasId) ensureFresh(canvasId); + const target = canvasId ? getCanvas(canvasId) : undefined; + const declared = target ? resolveSurface(target) : undefined; + const typeOpts = { baseSize, ratio, surface: declared?.type, device: declared?.device ?? undefined }; + const system = generateDesignSystem(seed, personality as PersonalityName, typeOpts); + const base = typeBase(personality as PersonalityName, typeOpts); const vars = system.variables; let preserved: PresetApplyResult['preserved'] = []; @@ -1195,6 +1218,8 @@ The typography layer ships ROLE tokens the structures speak — $display / $head wroteTo, personality: system.personality, intent: system.intent, + baseSize: base.size, + baseSizeSource: base.source, fonts: system.fonts, seed: system.colorSystem.seed, semantics: system.colorSystem.light, @@ -1991,6 +2016,49 @@ Stamping a genre does NOT move the canvas's versionHash (the hash covers design } ); +// --- canvas_set_type (Phase 30 slice B) --- +server.tool( + 'canvas_set_type', + `Declare what MEDIUM an existing canvas is on: "web" (a web page) or "mobile" (a phone app screen: it selects the mobile ruleset — the evaluate result's surface report lists the rule ids that run — and generate_design_system then gives it a 17px body). Writes metadata.surface only — it NEVER resizes the artboard and NEVER writes the root's safeArea, because either would reflow what you already placed. So a canvas made mobile here usually needs one more op: when the result carries a "hint", it names it — U("document", { safeArea: { top: 47, bottom: 34 } }) to reserve the device insets, and the device artboard size (390×844 for "iphone-390") if the root differs. For a NEW phone screen, prefer canvas_create({ type: "mobile" }), which does all of that at once. Pass type: null to clear the declaration (the canvas reads as an undeclared web canvas again, typeSource "default"). device ("iphone-390", the default) is only valid with "mobile". The result echoes { type, device, source, previous ({ type, device } or null), artboard { width, height }, hint? }. Like canvas_set_genre it does NOT move the versionHash (metadata is outside the hash) — but the type changes which rules run, so re-evaluate after it.`, + { + canvasId: z.string().describe('Canvas ID'), + type: z.enum(['web', 'mobile']).nullable().describe('"web", "mobile", or null to clear the declaration'), + device: z.string().optional().describe('Device profile for "mobile" (default "iphone-390"). Only valid with "mobile".'), + }, + async ({ canvasId, type, device }) => { + if (type === null && device !== undefined) { + return { content: [{ type: 'text', text: 'Error: device only applies to type "mobile".' }], isError: true }; + } + const declared = type === null ? null : surfaceFor(type, device); + if (declared && 'error' in declared) return { content: [{ type: 'text', text: `Error: ${declared.error}` }], isError: true }; + const result = setCanvasType(canvasId, declared ? declared.surface : null); + if (!result) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; + const canvas = getCanvas(canvasId)!; + const surface = resolveSurface(canvas); + const artboard = { width: canvas.root.width, height: canvas.root.height }; + const profile = surface.device ? deviceProfile(surface.device) : undefined; + const steps: string[] = []; + if (profile && !canvas.root.safeArea) { + steps.push(`reserve the device insets with U("document", { safeArea: { top: ${profile.safeArea.top}, bottom: ${profile.safeArea.bottom} } })`); + } + if (profile && (artboard.width !== profile.width || artboard.height !== profile.height)) { + steps.push(`resize the artboard to ${profile.width}×${profile.height} (U("document", { width: ${profile.width}, height: ${profile.height} })) — this canvas is ${artboard.width}×${artboard.height}`); + } + return { + content: [{ type: 'text', text: JSON.stringify({ + canvasId, + type: surface.type, + device: surface.device, + source: surface.source, + previous: result.previous, + artboard, + ...(steps.length ? { hint: `canvas_set_type never touches the root. To finish making this a ${surface.device} screen: ${steps.join('; ')}.` } : {}), + versionHashUnchanged: true, + }, null, 2) }], + }; + } +); + // --- canvas_evaluate --- server.tool( 'canvas_evaluate', @@ -2002,7 +2070,9 @@ Designed for generator-evaluator loops: generate with batch_design, evaluate wit THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional depth (a vision-model rubric critique — composition, hierarchy, polish) on top; it requires an ANTHROPIC_API_KEY or OPENAI_API_KEY and fails gracefully without one — the FULL heuristic result still returns with an llmNote explaining what's missing, and the heuristic directive alone decides. With a key it still decides: a needsRevision verdict never blocks presenting — run canvas_revise, and if it stops with axes still failing, present and name them. Screens whose figures are the user's own data: declare genre: "data" so the design's own realistic figures aren't flagged as fabricated; transactional screens (cart/checkout/order/billing) pass genre: "commerce" for the same reason (see the genre param). -The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains.`, +The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. + +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }.`, { canvasId: z.string().describe('Canvas ID to evaluate'), mode: z.enum(['fast', 'detailed', 'llm']).default('fast').describe('"fast" = JSON-only (<100ms), "detailed" = + Puppeteer layout checks, "llm" = fast + vision-model rubric critique'), @@ -2069,7 +2139,7 @@ The result's "genre" field (present whenever cliche ran) makes the genre decisio // --- canvas_autofix --- server.tool( 'canvas_autofix', - `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Also: an unknown icon name with exactly one spelling that resolves (hyphens ↔ underscores, or a missing material: prefix — ties broken by the icon set the canvas already uses) → rewritten to that name. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate.`, + `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Also: an unknown icon name with exactly one spelling that resolves (hyphens ↔ underscores, or a missing material: prefix — ties broken by the icon set the canvas already uses) → rewritten to that name. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate. The result carries the same "genre" and "surface" reports as canvas_evaluate.`, { canvasId: z.string().describe('Canvas ID to autofix'), categories: z.array(z.enum(['spacing', 'color', 'typography', 'structure', 'consistency', 'cliche', 'coverage', 'usability'])) @@ -2103,6 +2173,7 @@ server.tool( totalIssues: result.issues.length, fixableCount: fixes.length, ...(result.genre ? { genre: result.genre } : {}), + surface: result.surface, ...(apply ? { applied: 0, note: 'apply: true had nothing to write' } : {}), fixes, }, null, 2) }], @@ -2123,6 +2194,7 @@ server.tool( totalIssues: result.issues.length, fixableCount: fixes.length, ...(result.genre ? { genre: result.genre } : {}), + surface: result.surface, appliedCount: applied.length, applied, ...(failed.length ? { failed } : {}), diff --git a/src/renderer.ts b/src/renderer.ts index 3bec96b..a3ad47c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -677,6 +677,7 @@ function buildStyles(node: SceneNode, registered?: ReadonlySet): string } } } + if (node.type === 'document') s.push(...safeAreaPadding(node)); // Position if (node.position) s.push(`position: ${node.position}`); @@ -1037,6 +1038,33 @@ function findNearestContainer(ancestors: SceneNode[]): SceneNode | undefined { return undefined; } +/** Phase 30 slice B — the root's safeArea insets ride on top of its authored + * top/bottom padding, at exact px (insets never scale fluidly). Each side is + * honoured only when it is a finite non-negative number. */ +function safeAreaPadding(node: SceneNode): string[] { + const insets = node.safeArea; + if (!insets || typeof insets !== 'object') return []; + const valid = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0; + const sides = paddingSides(node.padding); + const out: string[] = []; + if (valid(insets.top)) out.push(`padding-top: calc(${sides.top} + ${insets.top}px)`); + if (valid(insets.bottom)) out.push(`padding-bottom: calc(${sides.bottom} + ${insets.bottom}px)`); + return out; +} + +/** The authored top/bottom padding as buildStyles emits it (0px when absent or + * not numeric). */ +function paddingSides(padding: SceneNode['padding']): { top: string; bottom: string } { + const px = (v: unknown) => (typeof v === 'number' ? responsivePadding(v) : '0px'); + if (typeof padding === 'number') return { top: px(padding), bottom: px(padding) }; + // CSS shorthand order: one or two values share top/bottom; three or four + // put bottom third. + if (Array.isArray(padding) && padding.length && padding.every((p) => typeof p === 'number')) { + return { top: px(padding[0]), bottom: px(padding[padding.length >= 3 ? 2 : 0]) }; + } + return { top: '0px', bottom: '0px' }; +} + function responsivePadding(value: number): string { if (value < PADDING_SCALE_MIN) return `${value}px`; const min = Math.max(8, Math.round(value * 0.25)); diff --git a/src/scene-graph.ts b/src/scene-graph.ts index 78a98fa..1b37f04 100644 --- a/src/scene-graph.ts +++ b/src/scene-graph.ts @@ -6,6 +6,7 @@ import { DEFAULT_PROJECT_ID, type Canvas, type SceneNode } from './types.js'; import { isRepoBound, repoDir, writeCanvasToDir, removeCanvasFromDir, loadCanvasesFromDir, externallyModified, readCanvasFile } from './repo-store.js'; import { canvasVersionHash } from './version.js'; import { migrateProvenance } from './genre.js'; +import { deviceProfile, DEFAULT_DEVICE, type SurfaceType } from './surfaces.js'; const store = new Map(); @@ -132,8 +133,13 @@ export function createCanvas( name?: string, projectId: string = DEFAULT_PROJECT_ID, size: { width?: number; height?: number } = {}, + surface?: { type: SurfaceType; device?: string }, ): Canvas { const id = nanoid(10); + // Phase 30 slice B — a mobile canvas takes its device's artboard (an + // explicit width/height wins per side) and reserves the device insets on + // the root, so in-flow content can never reach them. + const profile = surface?.type === 'mobile' ? deviceProfile(surface.device ?? DEFAULT_DEVICE) : undefined; const canvas: Canvas = { id, name: name ?? `Canvas ${store.size + 1}`, @@ -142,15 +148,17 @@ export function createCanvas( type: 'document', name: 'Document', children: [], - width: size.width ?? 1440, - height: size.height ?? 900, + width: size.width ?? profile?.width ?? 1440, + height: size.height ?? profile?.height ?? 900, fill: '#FFFFFF', + ...(profile ? { safeArea: { ...profile.safeArea } } : {}), }, variables: {}, components: {}, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), projectId, + ...(surface ? { metadata: { surface: { ...surface } } } : {}), }; store.set(id, canvas); persistCanvas(canvas); @@ -191,6 +199,27 @@ export function setCanvasGenre(canvasId: string, genre: string | null): { previo return { previous }; } +/** Phase 30 slice B — declare (or clear, with null) what medium a canvas is + * on. Writes only metadata.surface: never the artboard size or the root's + * safeArea, since either would reflow what the agent placed. Returns the prior + * declaration, or undefined when the canvas doesn't exist. */ +export function setCanvasType( + canvasId: string, + surface: { type: SurfaceType; device?: string } | null, +): { previous: { type: SurfaceType; device?: string } | null } | undefined { + ensureFresh(canvasId); + const canvas = store.get(canvasId); + if (!canvas) return undefined; + const previous = canvas.metadata?.surface ? { ...canvas.metadata.surface } : null; + if (surface === null && previous === null) return { previous }; // nothing to clear — don't dirty the file + const metadata = { ...canvas.metadata }; + if (surface === null) delete metadata.surface; + else metadata.surface = { ...surface }; + canvas.metadata = metadata; + touchCanvas(canvasId); + return { previous }; +} + /** * Reload a canvas from disk if its file changed externally since this process * last touched it — so the caller mutates the current version instead of @@ -499,7 +528,7 @@ function deepCloneWithNewIds(node: SceneNode, overrides?: Partial, id /** Phase 24 slice A (FR-A1) — clone a base canvas into a linked state variant. * The variant is a full sibling canvas (re-keyed node IDs, same project, * copied tokens/components/fonts) stamped with metadata.variant. Provenance - * (structure/preset/genre — WHAT the screen is) carries over; feedback and + * (structure/preset/genre — WHAT the screen is) and the surface type carry over; feedback and * critique (per-canvas judgments) deliberately don't. Adding a variant to a * canvas that is itself a variant links to the ROOT base (spec C6: variant * trees deeper than one level have no UX meaning). */ @@ -531,6 +560,7 @@ export function addVariant(canvasId: string, state: string): { canvas: Canvas; i if (base.fonts) canvas.fonts = structuredClone(base.fonts); canvas.metadata = { ...(base.metadata?.provenance ? { provenance: structuredClone(base.metadata.provenance) } : {}), + ...(base.metadata?.surface ? { surface: structuredClone(base.metadata.surface) } : {}), variant: { of: baseId, state: normalized, at: new Date().toISOString() }, }; touchCanvas(canvas.id); diff --git a/src/surfaces.ts b/src/surfaces.ts new file mode 100644 index 0000000..982ba78 --- /dev/null +++ b/src/surfaces.ts @@ -0,0 +1,211 @@ +// Phase 30 slice B — the canvas TYPE axis (what medium a screen is on) and the +// ruleset table every axis contributes to. A canvas carries three independent +// declarations — type, genre, preset — and each value adds one ruleset; the +// active rules are their union. Adding a type or a genre is one table row. + +import type { Canvas } from './types.js'; +import type { ClicheTell } from './evaluate.js'; + +export type SurfaceType = 'web' | 'mobile'; +export const SURFACE_TYPES: readonly SurfaceType[] = ['web', 'mobile']; + +/** One device a `mobile` canvas can target. CSS px (1pt = 1px). Apple's HIG + * confirms the 44pt target and the 17/11 type sizes; the insets come from + * consistent secondary sources; the bar, row and margin values are house + * values (see the Phase 30 spec's device profile table). */ +export interface DeviceProfile { + id: string; + width: number; + height: number; + safeArea: { top: number; bottom: number }; + touchTarget: number; + navBarHeight: number; + navBarLargeHeight: number; + tabBarHeight: number; + tabCount: { min: number; max: number }; + bodySize: number; + /** The smallest body size the mobile ruleset accepts (the data-dense pivot). */ + bodyFloor: number; + smallestText: number; + listRowHeight: number; + listRowSubtitleHeight: number; + sideMargin: number; + homeIndicator: { width: number; height: number }; +} + +export const DEVICE_PROFILES: Record = { + 'iphone-390': { + id: 'iphone-390', + width: 390, + height: 844, + safeArea: { top: 47, bottom: 34 }, + touchTarget: 44, + navBarHeight: 44, + navBarLargeHeight: 96, + tabBarHeight: 49, + tabCount: { min: 2, max: 5 }, + bodySize: 17, + bodyFloor: 16, + smallestText: 11, + listRowHeight: 44, + listRowSubtitleHeight: 56, + sideMargin: 16, + homeIndicator: { width: 134, height: 5 }, + }, +}; + +export const DEFAULT_DEVICE = 'iphone-390'; + +export function isSurfaceType(value: unknown): value is SurfaceType { + return typeof value === 'string' && (SURFACE_TYPES as readonly string[]).includes(value); +} + +export function deviceProfile(id: string): DeviceProfile | undefined { + return Object.hasOwn(DEVICE_PROFILES, id) ? DEVICE_PROFILES[id] : undefined; +} + +export interface SurfaceDeclaration { + type: SurfaceType; + device: string | null; + source: 'declared' | 'default'; +} + +/** The canvas's type. Absent (or unreadable) means `web`, source `default` — + * a canvas nobody declared. A mobile canvas with no stored device targets the + * default profile. */ +export function resolveSurface(canvas: Canvas): SurfaceDeclaration { + const stored = canvas.metadata?.surface; + if (!stored || !isSurfaceType(stored.type)) return { type: 'web', device: null, source: 'default' }; + if (stored.type === 'web') return { type: 'web', device: null, source: 'declared' }; + return { type: 'mobile', device: typeof stored.device === 'string' ? stored.device : DEFAULT_DEVICE, source: 'declared' }; +} + +/** Validate a type + device pair for canvas_create / canvas_set_type. Returns + * the metadata to store, or an error message. */ +export function surfaceFor(type: SurfaceType, device?: string): { surface: { type: SurfaceType; device?: string } } | { error: string } { + if (device !== undefined && type !== 'mobile') { + return { error: `device only applies to type "mobile" (got type "${type}").` }; + } + if (type === 'web') return { surface: { type: 'web' } }; + const id = device ?? DEFAULT_DEVICE; + if (!deviceProfile(id)) { + return { error: `Unknown device "${id}". Known devices: ${Object.keys(DEVICE_PROFILES).join(', ')}.` }; + } + return { surface: { type: 'mobile', device: id } }; +} + +// --- Rulesets --- + +/** What one axis value contributes: cliché tells it relaxes, rule ids it turns + * on, rule ids it turns off. An opt-in rule runs only when some active ruleset + * enables it; a default rule runs unless one disables it. */ +export interface Ruleset { + relax?: ClicheTell[]; + enable?: string[]; + disable?: string[]; +} + +export type RulesetAxis = 'base' | 'type' | 'genre' | 'preset'; + +/** Keyed by axis, then value. Genre rows are canonical names only; aliases + * fold through GENRE_ALIASES first. The enable/disable lists start empty: a + * rule id lands here in the same change as the check that honours it. */ +export const RULESETS: Record> = { + base: { all: {} }, + type: { web: {}, mobile: {} }, + genre: { + // Material Design legitimately uses a purple accent AND white elevated + // surfaces (cards on an off-white background), so both are intentional + // here. Also a preset row; kept as a genre because agents are told to + // declare editors and admin forms as "material". + material: { relax: ['accent-hue', 'pure-black-white'] }, + // Phase 22 slice C (#135) — screens whose figures are the user's own data + // (dashboards, activity, finance, fitness, logistics): realistic figures + // ARE the design (they drive column widths, wrapping, $2.7M-vs-$2,700,000 + // legibility), so "fabricated data" flags would zero the category for the + // screen's whole reason to exist. `data` names what the numbers are, not + // a layout; `dashboard` is the alias. + data: { relax: ['honest-content'] }, + // Phase 29 slice A (#194) — transactional commerce screens: a checkout's + // line prices, subtotal, store credit and total ARE the design. Scope is + // the TRANSACTION — cart, checkout, order confirmation, billing history. + // NOT a pricing or marketing page: invented customer counts, savings + // claims and testimonials are exactly the slop this tell exists to catch. + // `checkout` is the alias. + commerce: { relax: ['honest-content'] }, + }, + // Applying the material STYLE relaxes material's tells whatever the genre. + preset: { material: { relax: ['accent-hue', 'pure-black-white'] } }, +}; + +/** Genres that are aliases of a canonical row — folded before every lookup so + * an alias never reads as a second option. */ +export const GENRE_ALIASES = new Map([['dashboard', 'data'], ['checkout', 'commerce']]); + +export function canonicalGenre(genre: string): string { + return GENRE_ALIASES.get(genre) ?? genre; +} + +// Own-key lookups only: a stored genre is free text, and "constructor" must +// read as an unknown genre, not Object.prototype.constructor. +function row(axis: RulesetAxis, key: string | undefined): Ruleset | undefined { + const table = RULESETS[axis]; + return key !== undefined && Object.hasOwn(table, key) ? table[key] : undefined; +} + +export function relaxedByGenre(genre: string): ClicheTell[] { + return row('genre', canonicalGenre(genre))?.relax ?? []; +} + +export function relaxedByPreset(preset: string | undefined): ClicheTell[] { + return row('preset', preset)?.relax ?? []; +} + +/** Every genre that relaxes something, each canonical name followed by its + * aliases (material, data, dashboard, commerce, checkout). */ +export function knownGenres(): string[] { + return Object.keys(RULESETS.genre).flatMap((g) => [ + g, + ...[...GENRE_ALIASES].filter(([, canonical]) => canonical === g).map(([alias]) => alias), + ]); +} + +/** The canonical genre rows, for reports that offer genres as options. */ +export function genreRulesets(): Array<[string, Ruleset]> { + return Object.entries(RULESETS.genre); +} + +export function activeRulesets(decl: { type: SurfaceType; genre?: string; preset?: string }): Ruleset[] { + return [ + row('base', 'all'), + row('type', decl.type), + decl.genre === undefined ? undefined : row('genre', canonicalGenre(decl.genre)), + row('preset', decl.preset), + ].filter((r): r is Ruleset => r !== undefined); +} + +export interface ComposedRules { + relaxed: ClicheTell[]; + on: string[]; + off: string[]; + conflicts: string[]; +} + +/** Union across rulesets. A rule both enabled and disabled is ON and reported + * as a conflict, so a guardrail never disappears through composition. */ +export function composeRulesets(rulesets: Ruleset[]): ComposedRules { + const relaxed = new Set(); + const enabled = new Set(); + const disabled = new Set(); + for (const r of rulesets) { + r.relax?.forEach((t) => relaxed.add(t)); + r.enable?.forEach((id) => enabled.add(id)); + r.disable?.forEach((id) => disabled.add(id)); + } + return { + relaxed: [...relaxed], // ruleset order (genre before preset), as the genre report always listed them + on: [...enabled].sort(), + off: [...disabled].filter((id) => !enabled.has(id)).sort(), + conflicts: [...disabled].filter((id) => enabled.has(id)).sort(), + }; +} diff --git a/src/types.ts b/src/types.ts index 9a727b8..f7c2d7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,10 @@ export interface SceneNode { gridColumn?: number | string; gridRow?: number | string; padding?: number | string | Array; // values or "$space-*" refs (2 or 4 entries) + /** Phase 30 slice B — document root only: the device insets the renderer + * adds to the root's own top and bottom padding (canvas_create with + * `type: "mobile"` writes the profile's). The root's fill paints them. */ + safeArea?: { top: number; bottom: number }; alignItems?: 'start' | 'center' | 'end' | 'stretch'; justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around'; wrap?: boolean; @@ -430,6 +434,11 @@ export interface Canvas { * cloned from the base; the viewer groups them and the coverage check * (slice C) counts them. */ variant?: VariantLink; + /** Phase 30 slice B — what medium the canvas is on (canvas_create `type` + * / canvas_set_type). Absent means `web`, never declared. It selects + * rules, generator defaults and presentation, never the render — the + * insets a phone needs live on the root as `safeArea`. */ + surface?: { type: 'web' | 'mobile'; device?: string }; [key: string]: unknown; }; } diff --git a/src/viewer.ts b/src/viewer.ts index 0fc38cf..90bb4d4 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -6,6 +6,7 @@ import { getProject, listProjects, listWorkspaces, getCanvasTokens, getProjectDe import { getRepoLocation, archiveRepoCanvas, deleteRepoCanvas, updateRepoCanvas } from './aggregate.js'; import { DEFAULT_PROJECT_ID, type Canvas, type SceneNode } from './types.js'; import { evaluateCanvas, resolveGenre, type EvaluationResult, type EvaluationIssue } from './evaluate.js'; +import { resolveSurface } from './surfaces.js'; import { isPresetName } from './genre.js'; import { addFeedback, listFeedback, resolveFeedback, deleteFeedback, openFeedbackCount } from './feedback.js'; @@ -847,9 +848,11 @@ function designedStatesFor(canvasId: string, listing?: CanvasSummary[]): string[ } export function evalCacheKey(canvas: Canvas, states?: string[]): string { - // Phase 30: genre and preset are separate axes, and both change the score. + // Phase 30: type, genre and preset are separate axes, and each changes the + // score (they select the rulesets that run). const prov = canvas.metadata?.provenance; - const genre = `${resolveGenre(canvas).genre ?? ''}/${prov?.preset ?? ''}`; + const surface = resolveSurface(canvas); + const genre = `${resolveGenre(canvas).genre ?? ''}/${prov?.preset ?? ''}/${surface.type}/${surface.device ?? ''}`; let tokens = ''; try { tokens = JSON.stringify(getCanvasTokens(canvas)); } catch { /* mirrored canvas with no live project — tree + genre still key it */ } // Phase 24 slice C — the coverage check reads the canvas's designed state diff --git a/test-design-language.ts b/test-design-language.ts index 1d5866c..a25e310 100644 --- a/test-design-language.ts +++ b/test-design-language.ts @@ -7,7 +7,7 @@ // Usage: npx tsx test-design-language.ts import './test-env.js'; -import { generateDesignSystem, PERSONALITY_NAMES, type PersonalityName } from './src/design-language.js'; +import { generateDesignSystem, typeBase, PERSONALITY_NAMES, type PersonalityName } from './src/design-language.js'; import { createCanvas } from './src/scene-graph.js'; import { applyStructure } from './src/structures.js'; import { evaluateCanvas } from './src/evaluate.js'; @@ -95,5 +95,28 @@ for (const personality of PERSONALITY_NAMES) { check('unknown personality throws with the roster', err.includes('technical') && err.includes('data-dense'), err.slice(0, 80)); } +// ── Phase 30 slice B: the mobile type scale (FR-B4) ─────────────────────── +{ + console.log('\n── mobile type scale ──'); + for (const personality of PERSONALITY_NAMES) { + const sys = generateDesignSystem(SEED, personality, { surface: 'mobile' }); + const typo = sys.variables.typography!; + const body = typo['body'].fontSize as number; + const want = personality === 'data-dense' ? 16 : 17; + check(`${personality}: mobile body is ${want}`, body === want, String(body)); + check(`${personality}: mobile body ≥ 16 (the mobile floor)`, body >= 16); + const small = Object.entries(typo).filter(([, spec]) => typeof spec.fontSize === 'number' && (spec.fontSize as number) < 11); + check(`${personality}: no role or step below 11 on mobile`, small.length === 0, small.map(([k, v]) => `${k}=${v.fontSize}`).join(', ')); + const base = typeBase(personality, { surface: 'mobile' }); + check(`${personality}: typeBase reports the mobile source`, base.source === 'mobile' && base.size === want); + } + const explicit = generateDesignSystem(SEED, 'soft', { surface: 'mobile', baseSize: 15 }); + check('explicit baseSize wins over the mobile pivot', explicit.variables.typography!['body'].fontSize === 15); + check('typeBase: explicit source', typeBase('soft', { surface: 'mobile', baseSize: 15 }).source === 'explicit'); + const web = generateDesignSystem(SEED, 'technical', { surface: 'web' }); + check('web keeps the personality pivot', web.variables.typography!['body'].fontSize === generateDesignSystem(SEED, 'technical').variables.typography!['body'].fontSize); + check('typeBase: personality source on web', typeBase('data-dense', { surface: 'web' }).source === 'personality' && typeBase('data-dense').size === 13); +} + console.log(allPass ? '\nAll design-language tests passed.' : '\nSOME TESTS FAILED'); process.exit(allPass ? 0 : 1); diff --git a/test-discoverability.ts b/test-discoverability.ts index 0cd260e..abc905e 100644 --- a/test-discoverability.ts +++ b/test-discoverability.ts @@ -18,6 +18,7 @@ import './test-env.js'; import { readFileSync } from 'node:fs'; import { domToSceneGraph, type RawDomNode } from './src/import.js'; import { listStructures } from './src/structures.js'; +import { RULESETS, GENRE_ALIASES, SURFACE_TYPES, DEVICE_PROFILES } from './src/surfaces.js'; interface Check { name: string; ok: boolean; detail?: string } const checks: Check[] = []; @@ -116,13 +117,11 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); } // ── 7. every relax-genre is surfaced where agents look ─────────────────────── -// Mirrors the RELAXED_BY_GENRE keys in evaluate.ts (module-private, so parsed -// from source). A new genre must be named in index.ts (genre param / GOTCHAS) +// Mirrors the genre rows of the ruleset table (src/surfaces.ts RULESETS) plus +// their aliases. A new genre must be named in index.ts (genre param / GOTCHAS) // and GUIDELINES or agents can never discover it. { - const evaluateSrc = readFileSync('src/evaluate.ts', 'utf-8'); - const table = evaluateSrc.match(/const RELAXED_BY_GENRE[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; - const genres = [...table.matchAll(/^\s{2}(\w+):/gm)].map((m) => m[1]); + const genres = [...Object.keys(RULESETS.genre), ...GENRE_ALIASES.keys()]; expect('relax-genres found', genres.length >= 3, genres.join(', ')); const missingIdx = genres.filter((g) => !indexSrc.includes(`"${g}"`)); expect('every relax-genre named in src/index.ts', missingIdx.length === 0, missingIdx.join(', ')); @@ -150,15 +149,12 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); // describes a style, not a genre value — stays exempt. // GENRE_ALIASES is a Map of [alias, canonical] pairs (Phase 30 made `data` // canonical and `dashboard` its alias). - const aliasBlock = evaluateSrc.match(/const GENRE_ALIASES[^=]*=\s*new Map[^(]*\(\[([\s\S]*?)\]\);/)?.[1] ?? ''; - const aliases = new Set([...aliasBlock.matchAll(/\['([^']+)',\s*'[^']+'\]/g)].map((m) => m[1])); - const canonical = genres.filter((g) => !aliases.has(g)); - expect('genre aliases parsed from evaluate.ts', aliases.size > 0, [...aliases].join(', ')); + const canonical = Object.keys(RULESETS.genre); + expect('genre aliases present in the ruleset table', GENRE_ALIASES.size > 0, [...GENRE_ALIASES.keys()].join(', ')); const tellToGenres = new Map(); for (const g of canonical) { - const tells = table.match(new RegExp(`^\\s{2}${g}:\\s*\\[([^\\]]*)\\]`, 'm'))?.[1] ?? ''; - for (const t of [...tells.matchAll(/'([^']+)'/g)].map((m) => m[1])) { + for (const t of RULESETS.genre[g].relax ?? []) { tellToGenres.set(t, [...(tellToGenres.get(t) ?? []), g]); } } @@ -193,11 +189,12 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); const evaluateSrc = readFileSync('src/evaluate.ts', 'utf-8'); const recommends = /genre[:=]?\s*\\?["'`]dashboard\\?["'`]|set_genre\(\s*\\?["'`]dashboard/; const offenders: string[] = []; - for (const [label, src] of [['src/index.ts', indexSrc], ['src/evaluate.ts', evaluateSrc], ['GUIDELINES', guidelines], ['README', readme]] as const) { + const surfacesSrc = readFileSync('src/surfaces.ts', 'utf-8'); + for (const [label, src] of [['src/index.ts', indexSrc], ['src/evaluate.ts', evaluateSrc], ['src/surfaces.ts', surfacesSrc], ['GUIDELINES', guidelines], ['README', readme]] as const) { src.split('\n').forEach((line, i) => { if (recommends.test(line)) offenders.push(`${label}:${i + 1}`); }); } expect('no agent surface recommends genre: "dashboard" (declare "data")', offenders.length === 0, offenders.join(', ')); - expect('aliases parsed: dashboard → data, checkout → commerce', /\['dashboard',\s*'data'\]/.test(evaluateSrc) && /\['checkout',\s*'commerce'\]/.test(evaluateSrc)); + expect('aliases: dashboard → data, checkout → commerce', GENRE_ALIASES.get('dashboard') === 'data' && GENRE_ALIASES.get('checkout') === 'commerce'); for (const field of ['byPreset', 'presetRelaxes', 'canonical']) { expect(`genre field "${field}" documented in src/index.ts`, indexSrc.includes(field)); @@ -367,6 +364,21 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); } } +// ── Phase 30 slice B: the type axis ────────────────────────────────────────── +// Every canvas type and every device profile is named where agents look, and +// the surface report's fields are documented — a type an agent can't find is +// a phone screen judged as a web page. +{ + for (const [label, src] of [['src/index.ts', indexSrc], ['GUIDELINES', guidelines]] as const) { + const types = SURFACE_TYPES.filter((t) => !src.includes(`type: "${t}"`)); + expect(`every canvas type named (type: "…") in ${label}`, types.length === 0, types.join(', ')); + const devices = Object.keys(DEVICE_PROFILES).filter((d) => !src.includes(`"${d}"`)); + expect(`every device profile named in ${label}`, devices.length === 0, devices.join(', ')); + const fields = ['rulesOn', 'rulesOff', 'conflicts', 'typeSource', 'safeArea'].filter((f) => !src.includes(f)); + expect(`surface fields documented in ${label}`, fields.length === 0, fields.join(', ')); + } +} + let allPass = true; for (const c of checks) { if (!c.ok) allPass = false; diff --git a/test-surface.ts b/test-surface.ts new file mode 100644 index 0000000..46bc0aa --- /dev/null +++ b/test-surface.ts @@ -0,0 +1,308 @@ +// Phase 30 slice B — the canvas type axis and the ruleset table. In-process: +// creation defaults, canvas_set_type's storage rules, variants, ruleset +// composition (union + conflict), the surface report, the root's safeArea +// padding (HTML-string assertions, no Chrome), render invariance under +// metadata.surface, and the viewer's score cache key. Then the real tool +// handlers over MCP stdio: canvas_create / canvas_set_type / canvas_evaluate / +// canvas_autofix / generate_design_system results and errors. +// +// Usage: npx tsx test-surface.ts + +import './test-env.js'; +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'; +import { createCanvas, setCanvasType, setCanvasGenre, addVariant, getCanvas } from './src/scene-graph.js'; +import { parseAndExecute } from './src/operations.js'; +import { canvasVersionHash } from './src/version.js'; +import { evaluateCanvas } from './src/evaluate.js'; +import { renderToHtml } from './src/renderer.js'; +import { evalCacheKey } from './src/viewer.js'; +import { + activeRulesets, composeRulesets, resolveSurface, surfaceFor, knownGenres, relaxedByGenre, + RULESETS, DEVICE_PROFILES, DEFAULT_DEVICE, type Ruleset, +} from './src/surfaces.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}` : ''}`); +} + +function addContent(canvas: Canvas) { + parseAndExecute(canvas.root, ` +f=I("document", { type: "frame", layout: "vertical", gap: 16, padding: 24 }) +I(f, { type: "text", content: "Morning run", fontSize: 20 }) +`, canvas); +} + +// The document root's own inline style (the first data-node-id div). +function rootStyle(canvas: Canvas): string { + const html = renderToHtml(canvas.root, 390, 844, canvas); + return html.match(new RegExp(`]*data-node-id="${canvas.root.id}"[^>]*>`))?.[0] ?? html.match(/style="([^"]*)"[^>]*data-node-id="[^"]*"/)?.[0] ?? ''; +} + +// ── creation defaults ───────────────────────────────────────────────────── +{ + console.log('── creation ──'); + const p = DEVICE_PROFILES[DEFAULT_DEVICE]; + const mobile = createCanvas('Phone', undefined, {}, { type: 'mobile', device: DEFAULT_DEVICE }); + check('mobile canvas reads back 390×844', mobile.root.width === 390 && mobile.root.height === 844); + check('mobile root.safeArea is the profile insets', mobile.root.safeArea?.top === 47 && mobile.root.safeArea?.bottom === 34); + check('metadata.surface stored', mobile.metadata?.surface?.type === 'mobile' && mobile.metadata?.surface?.device === 'iphone-390'); + check('resolveSurface: declared mobile', JSON.stringify(resolveSurface(mobile)) === JSON.stringify({ type: 'mobile', device: 'iphone-390', source: 'declared' })); + check('profile constants match the spec table', p.width === 390 && p.height === 844 && p.touchTarget === 44 && p.bodySize === 17 && p.bodyFloor === 16 && p.smallestText === 11); + + const sized = createCanvas('Phone wide', undefined, { width: 393 }, { type: 'mobile' }); + check('explicit width wins, height from the profile', sized.root.width === 393 && sized.root.height === 844); + check('explicit size still gets the insets', sized.root.safeArea?.top === 47); + + const web = createCanvas('Web', undefined, {}, { type: 'web' }); + check('declared web: 1440×900, no safeArea', web.root.width === 1440 && web.root.height === 900 && web.root.safeArea === undefined); + check('declared web source', resolveSurface(web).source === 'declared' && resolveSurface(web).device === null); + + const plain = createCanvas('Plain'); + check('no type: no metadata.surface', plain.metadata?.surface === undefined); + check('no type: web / default', JSON.stringify(resolveSurface(plain)) === JSON.stringify({ type: 'web', device: null, source: 'default' })); + + const odd = createCanvas('Odd'); + odd.metadata = { surface: { type: 'tablet' as 'web' } }; + check('an unreadable stored type reads as web / default', resolveSurface(odd).source === 'default'); + + check('surfaceFor rejects device on web', 'error' in surfaceFor('web', 'iphone-390')); + check('surfaceFor rejects an unknown device', 'error' in surfaceFor('mobile', 'pixel-9')); + check('surfaceFor rejects a prototype key as a device', 'error' in surfaceFor('mobile', 'constructor')); + check('surfaceFor defaults the device', JSON.stringify(surfaceFor('mobile')) === JSON.stringify({ surface: { type: 'mobile', device: 'iphone-390' } })); +} + +// ── canvas_set_type storage ─────────────────────────────────────────────── +{ + console.log('\n── set type ──'); + const c = createCanvas('Desk', undefined, { width: 1440, height: 900 }); + addContent(c); + const rootBefore = JSON.stringify(c.root); + const hash = canvasVersionHash(c); + const r1 = setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); + check('set-type returns previous null', r1?.previous === null); + check('type stored', resolveSurface(c).type === 'mobile'); + check('root untouched (no resize, no safeArea)', JSON.stringify(c.root) === rootBefore); + check('versionHash unchanged', canvasVersionHash(c) === hash); + const r2 = setCanvasType(c.id, { type: 'web' }); + check('previous echoes the prior declaration', r2?.previous?.type === 'mobile' && r2?.previous?.device === 'iphone-390'); + const r3 = setCanvasType(c.id, null); + check('clear: previous web, surface removed', r3?.previous?.type === 'web' && c.metadata?.surface === undefined); + check('clear: reads as default', resolveSurface(c).source === 'default'); + const lm = c.lastModified; + setCanvasType(c.id, null); + check('clearing a clear canvas does not dirty it', c.lastModified === lm); + check('unknown canvas → undefined', setCanvasType('nope', { type: 'web' }) === undefined); + + setCanvasGenre(c.id, 'commerce'); + setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); + check('set-type leaves the genre alone', c.metadata?.provenance?.genre === 'commerce'); +} + +// ── variants ────────────────────────────────────────────────────────────── +{ + console.log('\n── variants ──'); + const base = createCanvas('Feed', undefined, {}, { type: 'mobile', device: 'iphone-390' }); + addContent(base); + const { canvas: v } = addVariant(base.id, 'empty'); + check('variant copies metadata.surface', v.metadata?.surface?.type === 'mobile' && v.metadata?.surface?.device === 'iphone-390'); + check('variant root keeps safeArea', v.root.safeArea?.top === 47 && v.root.safeArea?.bottom === 34); + const ev = await evaluateCanvas(v, { mode: 'fast' }); + check('variant evaluates as mobile', ev.surface.type === 'mobile' && ev.surface.source === 'declared'); + v.metadata!.surface!.device = 'changed'; + check('variant surface is a copy, not shared', base.metadata?.surface?.device === 'iphone-390'); +} + +// ── ruleset composition ─────────────────────────────────────────────────── +{ + console.log('\n── rulesets ──'); + const sets = activeRulesets({ type: 'mobile', genre: 'commerce', preset: 'material' }); + check('base + type + genre + preset rows', sets.length === 4, String(sets.length)); + const composed = composeRulesets(sets); + check('relax is the union of all three axes', ['honest-content', 'accent-hue', 'pure-black-white'].every((t) => composed.relaxed.includes(t as never)), composed.relaxed.join(',')); + check('alias folds before lookup', activeRulesets({ type: 'web', genre: 'checkout' }).some((r) => r === RULESETS.genre.commerce)); + check('unknown genre and preset add no row', activeRulesets({ type: 'web', genre: 'constructor', preset: 'toString' }).length === 2); + + const synthetic: Ruleset[] = [ + { enable: ['mobile-touch-target', 'x-only-on'] }, + { disable: ['mobile-touch-target', 'x-only-off'], relax: ['accent-hue'] }, + { enable: ['x-only-on'], relax: ['accent-hue'] }, + ]; + const c = composeRulesets(synthetic); + check('enable+disable conflict resolves to ON', c.on.includes('mobile-touch-target') && !c.off.includes('mobile-touch-target')); + check('conflict is reported', JSON.stringify(c.conflicts) === JSON.stringify(['mobile-touch-target'])); + check('plain disable stays off', JSON.stringify(c.off) === JSON.stringify(['x-only-off'])); + check('ids de-duplicated + sorted', JSON.stringify(c.on) === JSON.stringify(['mobile-touch-target', 'x-only-on'])); + check('relax de-duplicated', c.relaxed.length === 1); + + check('views: knownGenres keeps canonical-then-alias order', JSON.stringify(knownGenres()) === JSON.stringify(['material', 'data', 'dashboard', 'commerce', 'checkout']), knownGenres().join(',')); + check('views: relaxedByGenre folds aliases', JSON.stringify(relaxedByGenre('dashboard')) === JSON.stringify(['honest-content'])); + check('views: unknown genre relaxes nothing', relaxedByGenre('constructor').length === 0); +} + +// ── surface report on evaluate ──────────────────────────────────────────── +{ + console.log('\n── surface report ──'); + const m = createCanvas('Report', undefined, {}, { type: 'mobile' }); + addContent(m); + const ev = await evaluateCanvas(m, { mode: 'fast' }); + check('surface report present with the documented shape', JSON.stringify(Object.keys(ev.surface).sort()) === JSON.stringify(['conflicts', 'device', 'rulesOff', 'rulesOn', 'source', 'type'])); + check('mobile report', ev.surface.type === 'mobile' && ev.surface.device === 'iphone-390' && ev.surface.source === 'declared'); + check('slice B: no rule ids registered yet', ev.surface.rulesOn.length === 0 && ev.surface.rulesOff.length === 0 && ev.surface.conflicts.length === 0); + const filtered = await evaluateCanvas(m, { mode: 'fast', categories: ['spacing'] }); + check('surface present even without the cliche category', filtered.surface?.type === 'mobile' && filtered.genre === undefined); + const empty = createCanvas('Empty', undefined, {}, { type: 'mobile' }); + const evEmpty = await evaluateCanvas(empty, { mode: 'fast' }); + check('surface present on the empty-canvas result', evEmpty.surface?.type === 'mobile'); + const plain = createCanvas('Plain report'); + addContent(plain); + const evPlain = await evaluateCanvas(plain, { mode: 'fast' }); + check('undeclared canvas reports web / default', evPlain.surface.type === 'web' && evPlain.surface.source === 'default' && evPlain.surface.device === null); + + // FR-A2 survives the table: material preset + commerce genre relax together. + m.metadata = { ...m.metadata, provenance: { preset: 'material', genre: 'commerce' } as never }; + const evMat = await evaluateCanvas(m, { mode: 'fast' }); + check('material preset + commerce genre relax all three', ['accent-hue', 'pure-black-white', 'honest-content'].every((t) => evMat.genre?.relaxed.includes(t as never)), evMat.genre?.relaxed.join(',')); + const evExplicit = await evaluateCanvas(m, { mode: 'fast', genre: 'commerce' }); + check('explicit genre keeps the preset relaxation', evExplicit.genre?.relaxed.includes('accent-hue') === true); +} + +// ── safeArea rendering + render invariance ──────────────────────────────── +{ + console.log('\n── safeArea render ──'); + const m = createCanvas('Insets', undefined, {}, { type: 'mobile' }); + addContent(m); + check('no authored padding: 0px + insets', rootStyle(m).includes('padding-top: calc(0px + 47px)') && rootStyle(m).includes('padding-bottom: calc(0px + 34px)'), rootStyle(m)); + + m.root.padding = 24; + check('scalar padding adds to the insets', rootStyle(m).includes('padding-top: calc(24px + 47px)') && rootStyle(m).includes('padding-bottom: calc(24px + 34px)')); + m.root.padding = [12, 16]; + check('[v, h] padding: v + insets', rootStyle(m).includes('padding-top: calc(12px + 47px)') && rootStyle(m).includes('padding-bottom: calc(12px + 34px)')); + m.root.padding = [8, 16, 20, 16]; + check('[t, r, b, l] padding: t / b + insets', rootStyle(m).includes('padding-top: calc(8px + 47px)') && rootStyle(m).includes('padding-bottom: calc(20px + 34px)')); + m.root.padding = [10, 16, 6]; + check('[t, h, b] padding: t / b + insets', rootStyle(m).includes('padding-top: calc(10px + 47px)') && rootStyle(m).includes('padding-bottom: calc(6px + 34px)')); + m.root.padding = 40; + check('fluid padding stays fluid, inset stays exact', /padding-top: calc\(clamp\([^)]*\) \+ 47px\)/.test(rootStyle(m)), rootStyle(m)); + + m.root.padding = undefined; + m.root.safeArea = { top: -5, bottom: Number.NaN } as never; + check('invalid insets are ignored', !rootStyle(m).includes('calc(')); + m.root.safeArea = { top: 47 } as never; + check('one valid side still applies', rootStyle(m).includes('padding-top: calc(0px + 47px)') && !rootStyle(m).includes('padding-bottom')); + + // A frame's safeArea is not the root's: only the document honours it. + const frameOnly = createCanvas('Frame insets'); + parseAndExecute(frameOnly.root, `f=I("document", { type: "frame", padding: 8, safeArea: { top: 47, bottom: 34 } })`, frameOnly); + check('safeArea on a frame is ignored', !renderToHtml(frameOnly.root, 390, 844, frameOnly).includes('+ 47px')); + + const plain = createCanvas('Invariance'); + addContent(plain); + const before = renderToHtml(plain.root, 390, 844, plain); + plain.metadata = { ...plain.metadata, surface: { type: 'mobile', device: 'iphone-390' } }; + check('metadata.surface never changes the render', renderToHtml(plain.root, 390, 844, plain) === before); + check('no safeArea: no calc padding in the shell', !before.includes('+ 47px')); + + const viaOp = createCanvas('Via op'); + addContent(viaOp); + parseAndExecute(viaOp.root, `U("document", { safeArea: { top: 47, bottom: 34 } })`, viaOp); + check('U("document", { safeArea }) writes the root', viaOp.root.safeArea?.top === 47 && viaOp.root.safeArea?.bottom === 34); + const h0 = canvasVersionHash(viaOp); + viaOp.root.safeArea = { top: 50, bottom: 34 }; + check('safeArea is part of the versionHash (it changes the render)', canvasVersionHash(viaOp) !== h0); +} + +// ── viewer score cache key ──────────────────────────────────────────────── +{ + console.log('\n── cache key ──'); + const c = createCanvas('Cache'); + addContent(c); + const k0 = evalCacheKey(c, []); + setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); + const k1 = evalCacheKey(c, []); + check('set-type changes the score cache key', k1 !== k0); + setCanvasType(c.id, { type: 'web' }); + check('web vs mobile keys differ', evalCacheKey(c, []) !== k1); + setCanvasGenre(c.id, 'data'); + check('set-genre still changes it', evalCacheKey(c, []) !== k1); + check('the canvas in the store is the one keyed', getCanvas(c.id) === c); +} + +// ── the real tool handlers over MCP stdio ───────────────────────────────── +{ + console.log('\n── MCP tools ──'); + const home = mkdtempSync(join(tmpdir(), 'framesmith-surface-mcp-')); + const cwd = mkdtempSync(join(tmpdir(), 'framesmith-surface-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-surface', version: '0' }); + await client.connect(transport); + async function tool(name: string, args: Record) { + const r = await client.callTool({ name, arguments: args }) as { content: { text: string }[]; isError?: boolean }; + const text = r.content[0]?.text ?? ''; + try { return { ...JSON.parse(text), isError: r.isError }; } catch { return { text, isError: r.isError }; } + } + + try { + const m = await tool('canvas_create', { name: 'Phone', type: 'mobile' }); + check('canvas_create mobile: size, type, device, safeArea in the result', + m.width === 390 && m.height === 844 && m.type === 'mobile' && m.typeSource === 'declared' && m.device === 'iphone-390' && m.safeArea?.top === 47 && m.safeArea?.bottom === 34, JSON.stringify(m)); + const w = await tool('canvas_create', { name: 'Page' }); + check('canvas_create default: web / default, no device or safeArea', w.type === 'web' && w.typeSource === 'default' && w.device === undefined && w.safeArea === undefined); + const bad = await tool('canvas_create', { name: 'Bad', device: 'iphone-390' }); + check('canvas_create: device without type errors', bad.isError === true && /type: "mobile"/.test(bad.text)); + const badDev = await tool('canvas_create', { name: 'Bad', type: 'mobile', device: 'pixel-9' }); + check('canvas_create: unknown device errors with the known list', badDev.isError === true && badDev.text.includes('iphone-390')); + const webDev = await tool('canvas_create', { name: 'Bad', type: 'web', device: 'iphone-390' }); + check('canvas_create: device with web errors', webDev.isError === true); + + const set = await tool('canvas_set_type', { canvasId: w.canvasId, type: 'mobile' }); + check('canvas_set_type: mobile with the default device', set.type === 'mobile' && set.device === 'iphone-390' && set.source === 'declared' && set.previous === null); + check('canvas_set_type: artboard untouched', set.artboard?.width === 1440 && set.artboard?.height === 900); + check('canvas_set_type: hint names the safeArea op and the size', typeof set.hint === 'string' && set.hint.includes('U("document", { safeArea: { top: 47, bottom: 34 } })') && set.hint.includes('390×844'), set.hint); + check('canvas_set_type: versionHashUnchanged', set.versionHashUnchanged === true); + const onPhone = await tool('canvas_set_type', { canvasId: m.canvasId, type: 'mobile' }); + check('canvas_set_type on a created phone canvas: no hint', onPhone.hint === undefined && onPhone.previous?.type === 'mobile'); + const cleared = await tool('canvas_set_type', { canvasId: w.canvasId, type: null }); + check('canvas_set_type null clears to default', cleared.type === 'web' && cleared.source === 'default' && cleared.device === null && cleared.previous?.type === 'mobile'); + const nullDev = await tool('canvas_set_type', { canvasId: w.canvasId, type: null, device: 'iphone-390' }); + check('canvas_set_type: device with null errors', nullDev.isError === true); + const missing = await tool('canvas_set_type', { canvasId: 'nope', type: 'web' }); + check('canvas_set_type: unknown canvas errors', missing.isError === true); + + await tool('batch_design', { canvasId: m.canvasId, operations: ` +f=I("document", { type: "frame", layout: "vertical", gap: 16, padding: 16 }) +I(f, { type: "text", content: "Today", fontSize: 28 }) +` }); + const ev = await tool('canvas_evaluate', { canvasId: m.canvasId }); + check('canvas_evaluate returns the surface report', ev.surface?.type === 'mobile' && Array.isArray(ev.surface?.rulesOn) && Array.isArray(ev.surface?.conflicts)); + const fix = await tool('canvas_autofix', { canvasId: m.canvasId }); + check('canvas_autofix returns the surface report', fix.surface?.type === 'mobile' && fix.surface?.source === 'declared'); + + const gen = await tool('generate_design_system', { seed: '#0E7490', personality: 'technical', canvasId: m.canvasId }); + check('generate_design_system on mobile: body 17, source mobile', gen.typographyRoles?.body?.fontSize === 17 && gen.baseSize === 17 && gen.baseSizeSource === 'mobile', JSON.stringify(gen.typographyRoles?.body)); + const dense = await tool('canvas_create', { name: 'Dense', type: 'mobile' }); + const genDense = await tool('generate_design_system', { seed: '#0E7490', personality: 'data-dense', canvasId: dense.canvasId }); + check('data-dense on mobile: body 16', genDense.baseSize === 16 && genDense.typographyRoles?.body?.fontSize === 16); + const genWeb = await tool('generate_design_system', { seed: '#0E7490', personality: 'data-dense', canvasId: w.canvasId }); + check('data-dense on web keeps its 13 pivot', genWeb.baseSize === 13 && genWeb.baseSizeSource === 'personality'); + const genExplicit = await tool('generate_design_system', { seed: '#0E7490', personality: 'soft', canvasId: m.canvasId, baseSize: 18 }); + check('explicit baseSize wins on mobile', genExplicit.baseSize === 18 && genExplicit.baseSizeSource === 'explicit'); + } finally { + await client.close(); + } +} + +console.log(allPass ? '\nAll surface tests passed.' : '\nSOME TESTS FAILED'); +process.exit(allPass ? 0 : 1); From 05829bddd5045f84334686b1996b2bf6910c9e40 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 21:57:25 +0100 Subject: [PATCH 2/4] Close slice B verifier findings on hints, freshness, docs and tests canvas_set_type's hint now uses the renderer's safeArea validity rule, canvas_autofix reads fresh in propose mode, and clearing a type drops an emptied metadata bag. Docs stop implying mobile rules run yet and answer the cold-read gaps; test-surface covers the mutation survivors. --- README.md | 8 ++--- docs/GUIDELINES.md | 15 +++++---- docs/specs/PHASE-30-SPEC.md | 4 ++- src/index.ts | 19 ++++++----- src/renderer.ts | 9 +++-- src/scene-graph.ts | 3 +- src/surfaces.ts | 10 ++++++ test-surface.ts | 67 +++++++++++++++++++++++++++++++++++-- 8 files changed, 106 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 27d12e2..eb68fb4 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Create a new canvas. If `projectId` is omitted, it lands in the built-in `Untitl | `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. | -| `device` | string? | Device profile for `type: "mobile"` (default `"iphone-390"`: 390×844, insets 47 top and 34 bottom). Only valid with `type: "mobile"`. | +| `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. @@ -308,7 +308,7 @@ R("nodeId", { type: "text", content: "Replaced" }) **Node types:** `frame`, `text`, `rectangle`, `ellipse`, `image`, `icon`, `path`, `component`, `instance`, `toggle`, `checkbox`, `radio`, `select`, `chart`, `skeleton` (loading-placeholder block — token-derived neutral fill; pulses subtly in the live viewer, always static in screenshots/exports so diffs stay deterministic; `pulse: false` opts a block out) -**Properties:** `fill`, `gradient`, `stroke`, `strokeWidth`, `strokeStyle`, `borderTop`, `borderRight`, `borderBottom`, `borderLeft`, `cornerRadius`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `layout` (`"horizontal"` | `"vertical"` | `"grid"`), `gap`, `rowGap`, `gridColumns`, `gridColumn`, `gridRow`, `padding`, `alignItems`, `justifyContent`, `fontSize`, `fontFamily`, `fontWeight`, `color`, `content`, `textAlign`, `lineHeight`, `letterSpacing` (px), `textDecoration`, `textTransform`, `textOverflow` (`"ellipsis"` — designed single-line truncation; `canvas_stress` reports clips behind it as info), `tabularNums`, `fontVariationSettings`, `src`, `objectFit`, `opacity`, `shadow` (CSS string, or `"$elevation."` referencing an elevation token), `shadows`, `blur`, `backdropBlur`, `backdropFilter`, `overflow`, `wrap`, `position`, `x`, `y`, `icon`, `iconSize`, `iconColor`, `iconStyle`, `checked`, `disabled`, `value`, `d`, `viewBox`, `strokeLinecap`, `strokeLinejoin`, `strokeDasharray`, `animation`, `transition` (object, or `"$motion."` referencing a motion token), `kind`, `series`, `segments`, `innerRatio`, `centerValue`, `centerLabel`, `sparkKind`, `xDomain`, `yDomain`, `curve`, `gridlines`, `xLabels`, `yLabels`, `componentId`, `overrides`, `name` (a readable label — `find_nodes` and component overrides match on it), `pulse` (skeleton only), `responsive` (`"stack"` | `"wrap"`) +**Properties:** `fill`, `gradient`, `stroke`, `strokeWidth`, `strokeStyle`, `borderTop`, `borderRight`, `borderBottom`, `borderLeft`, `cornerRadius`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `layout` (`"horizontal"` | `"vertical"` | `"grid"`), `gap`, `rowGap`, `gridColumns`, `gridColumn`, `gridRow`, `padding`, `alignItems`, `justifyContent`, `fontSize`, `fontFamily`, `fontWeight`, `color`, `content`, `textAlign`, `lineHeight`, `letterSpacing` (px), `textDecoration`, `textTransform`, `textOverflow` (`"ellipsis"` — designed single-line truncation; `canvas_stress` reports clips behind it as info), `tabularNums`, `fontVariationSettings`, `src`, `objectFit`, `opacity`, `shadow` (CSS string, or `"$elevation."` referencing an elevation token), `shadows`, `blur`, `backdropBlur`, `backdropFilter`, `overflow`, `wrap`, `position`, `x`, `y`, `icon`, `iconSize`, `iconColor`, `iconStyle`, `checked`, `disabled`, `value`, `d`, `viewBox`, `strokeLinecap`, `strokeLinejoin`, `strokeDasharray`, `animation`, `transition` (object, or `"$motion."` referencing a motion token), `kind`, `series`, `segments`, `innerRatio`, `centerValue`, `centerLabel`, `sparkKind`, `xDomain`, `yDomain`, `curve`, `gridlines`, `xLabels`, `yLabels`, `componentId`, `overrides`, `safeArea` (`{ top, bottom }`, document root only — insets the renderer adds to the root's top and bottom padding; `canvas_create` with `type: "mobile"` writes the device's), `name` (a readable label — `find_nodes` and component overrides match on it), `pulse` (skeleton only), `responsive` (`"stack"` | `"wrap"`) Chart `series` entries take their own keys: `data`, `stroke`, `strokeWidth`, `strokeDasharray`, `area`, `points`, `highlight`, `barGradient`. @@ -809,7 +809,7 @@ Declare what medium an existing canvas is on. Writes `metadata.surface` only: it | `type` | `"web"` \| `"mobile"` \| null | The medium; `null` clears the declaration (the canvas reads as undeclared web again) | | `device` | string? | Device profile for `"mobile"` (default `"iphone-390"`). Only valid with `"mobile"`. | -Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. On a mobile result whose root has no `safeArea` or a size other than the device's, `hint` names the op that finishes the job, e.g. `U("document", { safeArea: { top: 47, bottom: 34 } })`. The declaration is metadata, so the `versionHash` doesn't move; it does change which rules run, so re-evaluate after it. +Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. Clearing the type leaves the root, including any `safeArea`, as it is. On a mobile result whose root reserves no insets or has a size other than the device's, `hint` names the ops that finish the job: `U("document", { safeArea: { top: 47, bottom: 34 } })` and `U("document", { width: 390, height: 844 })`. Those ops change the root, so they move the `versionHash`; the declaration alone is metadata and doesn't. It changes the evaluate result's `surface` report (and, as mobile checks register, which rules run), so re-evaluate after it. ### `canvas_evaluate` @@ -823,7 +823,7 @@ A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict f Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed, byPreset? }` — the genre in effect (canonical: a `"dashboard"` stamp reports `"data"`, a `"checkout"` one `"commerce"`), whether it came from the `genre` param or the canvas's provenance stamp, the tells relaxed, `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax — and `byPreset` (`{ preset, relaxed }`), present when the applied preset relaxed tells on its own (the material preset relaxes accent-hue and pure-black-white whatever the genre). A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. -Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"` or `"mobile"`), its device profile (`null` on web), whether the type was `"declared"` (by `canvas_create` or `canvas_set_type`) or is the `"default"` web of an undeclared canvas, and the rule ids the composed type, genre and preset rulesets turned on (`rulesOn`) and off (`rulesOff`). A rule both enabled and disabled stays on and is listed in `conflicts`. `canvas_autofix` returns the same field. +Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"` or `"mobile"`), its device profile (`null` on web), whether the type was `"declared"` (by `canvas_create` or `canvas_set_type`) or is the `"default"` web of an undeclared canvas, and the rule ids the composed type, genre and preset rulesets turned on (`rulesOn`) and off (`rulesOff`). A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`. No rule ids are registered yet, so both lists are empty today. `canvas_autofix` returns the same field. Whenever the `coverage` category runs on a **base** canvas, the result also carries a **`coverage`** field: `{ dataBearing, states, missing }` — whether the screen carries data-bearing content, which state variants (`canvas_add_variant`) already exist, and which demanded states are missing. It's absent on variant canvases (a variant *is* a designed state, so it doesn't demand states of itself). diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 80c1b19..b4fb128 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -8,7 +8,7 @@ Canvases live inside a `Workspace > Project > Canvas` hierarchy. The built-in `P - **`workspace_create({ name })`** — top-level container (e.g. "Client work", "Personal"). - **`project_create({ workspaceId, name })`** — group related canvases inside a workspace. -- **`canvas_create({ name, projectId, width?, height? })`** — drops the canvas in the given project (defaults to Untitled). The artboard is 1440×900 unless you pass `width`/`height` (positive integers, each defaulting on its own) — e.g. `width: 390, height: 844` for a phone screen. +- **`canvas_create({ name, projectId, width?, height?, type?, device? })`** — drops the canvas in the given project (defaults to Untitled). The artboard is 1440×900 unless you pass `width`/`height` (positive integers, each defaulting on its own). For a phone app screen pass `type: "mobile"` instead of sizing it by hand (see [Declaring the medium](#declaring-the-medium-web-or-mobile)). - **`canvas_move({ canvasId, projectId })`** — reassign a canvas between projects. - **`canvas_archive` / `canvas_unarchive`** — soft-delete: canvas stays on disk but hides from default listings. Reach for this when iterating; reach for `canvas_delete` (permanent) only when sure. @@ -16,7 +16,7 @@ Canvases live inside a `Workspace > Project > Canvas` hierarchy. The built-in `P ## Two-line summary -1. **Author desktop-first at one design width.** Pick a width (1200 or 1440 is typical for a web page; a phone screen is its own artboard, `canvas_create({ width: 390, height: 844 })`), compose the design there. +1. **Author desktop-first at one design width.** Pick a width (1200 or 1440 is typical for a web page; a phone app screen is its own artboard, `canvas_create({ type: "mobile" })`), compose the design there. 2. **Adapt down with `responsive` hints + fluid widths.** The renderer derives the mobile/tablet layout from the same scene graph — you don't author it twice. ## Width strategies @@ -38,13 +38,14 @@ Pick the right `width` per node — this is the single biggest lever for respons ## Declaring the medium (web or mobile) -A canvas declares what medium it is on, separately from its genre (what it is for) and its preset (what style it uses). The type picks which rules judge the screen and which defaults the generators use. It never changes what renders. +A canvas declares what medium it is on, separately from its genre (what it is for) and its preset (what style it uses). The declaration selects a ruleset and the defaults the generators use. The declaration itself never changes a pixel: what renders comes only from the scene, so the artboard size and `safeArea` that `canvas_create` writes into the root are what change the render. -- **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. The renderer adds those to the root's own top and bottom padding, at exact pixels, and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. +- **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`, the only profile today) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. An explicit `width` or `height` wins for that side; the insets and the device stay the profile's. Passing `device` without `type: "mobile"`, or an unknown device, is an error. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. +- **The renderer adds the insets to the root's own top and bottom padding**, at exact pixels even when the authored padding scales fluidly (padding of 32px or more), and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. - **`type: "web"` is the default.** An undeclared canvas is judged as web and its evaluate result says `source: "default"`. -- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only. It never resizes the artboard and never writes `safeArea`, because either would reflow what you placed. When the result has a `hint`, follow it: `U("document", { safeArea: { top: 47, bottom: 34 } })` reserves the insets, and the hint names the device size if the root differs. -- **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports `baseSize` and `baseSizeSource`. -- **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off; a rule both enabled and disabled stays on and is listed in `conflicts`, so composing declarations can never switch a guardrail off. +- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root reserves no insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. +- **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"` for both mobile pivots, `"explicit"`, or `"personality"` on web). A `$body` inherited from a workspace or project design system is preserved by default and listed in `designSystemConflicts`; pass `preserveInherited: false` to take the mobile body. +- **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off. A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`, so composing declarations can never switch a guardrail off. No rule ids are registered yet, so both lists are empty today; the mobile checks register theirs as they land. ## Responsive hints diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 0ea220c..afa9588 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -184,7 +184,9 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *`surface` is always on the evaluate result*, every mode and category filter, the empty-canvas result included; `canvas_autofix` returns it too. - *`safeArea` renders as `padding-top: calc( + px)`* (and bottom likewise) on the document root only, at exact pixels even when the authored padding is fluid. A side that is not a finite non-negative number is ignored. `safeArea` is part of the root, so it is in the `versionHash`. - *`canvas_create`* errors on `device` without `type: "mobile"` and on an unknown device, and reports `type`, `typeSource`, `device` and `safeArea`. *`canvas_set_type`* reports `previous`, the unchanged `artboard`, and a `hint` naming the `safeArea` op and the device size when a mobile result lacks them. - - *`generate_design_system`* reports its pivot as `baseSize` + `baseSizeSource` (`explicit` / `personality` / `mobile`). Project and workspace scope have no type, so they stay web. + - *`generate_design_system`* reports its GENERATED pivot as `baseSize` + `baseSizeSource` (`explicit` / `personality` / `mobile`). Project and workspace scope have no type, so they stay web. A `$body` inherited from a workspace or project design system is still preserved by default (it appears in `designSystemConflicts`), so FR-B4's "body ≥ 16" holds for what the canvas renders only without one or with `preserveInherited: false`. + - *One reader for `safeArea`.* `usableSafeArea` (a side counts only as a finite non-negative number) serves both the renderer and `canvas_set_type`'s hint, so the hint never treats a value the renderer ignores as reserved. + - *`canvas_autofix` reads fresh in propose mode too* (it called `ensureFresh` only with `apply: true`), so its proposals and its `genre` / `surface` reports describe what is on disk. **Slice C — the mobile ruleset (FR-C1..C8)** - Each check is registered by rule id in the ruleset table and runs only when on, except `mobile-artboard`, which is in the base ruleset (FR-C6). diff --git a/src/index.ts b/src/index.ts index bb1ff62..5b0f6c5 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 } from './surfaces.js'; +import { deviceProfile, resolveSurface, surfaceFor, usableSafeArea } 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'; @@ -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, 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 rules 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 and is listed in conflicts. +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, 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 (no rule ids are registered yet, so the surface report's rulesOn is empty today; the mobile checks register theirs as they land): 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. 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. @@ -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.', - '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 judgment 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.', + '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; no mobile rule ids are registered yet), 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.', '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.', ]; @@ -173,7 +173,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 (judged by mobile rules, 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, 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, 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.', { 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.'), @@ -558,7 +558,7 @@ Concatenate bindings: U(header+"/childId", {...}) Returns { ok, nodeIds, results }: nodeIds maps each bound variable to the node ID it created (e.g. { "header": "n_a1b2" }) — record it and use those IDs to target nodes in later calls (bindings only live within a single call). results lists each op's outcome in order. If the call wrote a fontFamily nothing can serve yet (not cached, not registered, not system/generic), an extra "Font warnings" content item names it — cache-only check, no network, so it can't catch everything set_fonts/network resolution would. Node types: frame, text, rectangle, ellipse, image, icon, path, component, instance, toggle, checkbox, radio, select, chart, skeleton -Properties: fill, gradient, stroke, strokeWidth, strokeStyle, borderTop, borderRight, borderBottom, borderLeft, cornerRadius, width, height, minWidth, minHeight, maxWidth, layout ("horizontal"|"vertical"|"grid"), gap (0 = flush, never flagged as off-scale), rowGap, gridColumns, gridColumn, gridRow, padding, alignItems, justifyContent, fontSize, fontFamily, fontWeight, color, content, textAlign, lineHeight, letterSpacing (px), textDecoration, textTransform ("uppercase" etc. — don't bake casing into content), textOverflow ("ellipsis" — designed single-line truncation for labels that must survive hostile-length content; canvas_stress reports clips behind it as info, not warnings), tabularNums (fixed-width digits so number columns align — table scaffolds and chart ticks carry it by default), fontVariationSettings (variable-font axes, e.g. '"wght" 650'), src, objectFit, opacity, shadow, shadows (plural wins — a \`shadow\` written where \`shadows\` is set is ignored, and the op result carries a warning saying so), blur, backdropBlur, backdropFilter, overflow, wrap, position, x, y, icon, iconSize, iconColor, iconStyle, checked, disabled, value, pulse, d, viewBox, strokeLinecap, strokeLinejoin, strokeDasharray, animation, transition (object, or the string "$motion." referencing a motion token), kind, series, segments, innerRatio, centerValue, centerLabel, sparkKind, xDomain, yDomain, curve, gridlines, xLabels, yLabels, componentId, overrides, responsive +Properties: fill, gradient, stroke, strokeWidth, strokeStyle, borderTop, borderRight, borderBottom, borderLeft, cornerRadius, width, height, minWidth, minHeight, maxWidth, layout ("horizontal"|"vertical"|"grid"), gap (0 = flush, never flagged as off-scale), rowGap, gridColumns, gridColumn, gridRow, padding, alignItems, justifyContent, fontSize, fontFamily, fontWeight, color, content, textAlign, lineHeight, letterSpacing (px), textDecoration, textTransform ("uppercase" etc. — don't bake casing into content), textOverflow ("ellipsis" — designed single-line truncation for labels that must survive hostile-length content; canvas_stress reports clips behind it as info, not warnings), tabularNums (fixed-width digits so number columns align — table scaffolds and chart ticks carry it by default), fontVariationSettings (variable-font axes, e.g. '"wght" 650'), src, objectFit, opacity, shadow, shadows (plural wins — a \`shadow\` written where \`shadows\` is set is ignored, and the op result carries a warning saying so), blur, backdropBlur, backdropFilter, overflow, wrap, position, x, y, icon, iconSize, iconColor, iconStyle, checked, disabled, value, pulse, d, viewBox, strokeLinecap, strokeLinejoin, strokeDasharray, animation, transition (object, or the string "$motion." referencing a motion token), kind, series, segments, innerRatio, centerValue, centerLabel, sparkKind, xDomain, yDomain, curve, gridlines, xLabels, yLabels, componentId, overrides, safeArea ({ top, bottom }, document root only — insets the renderer adds to the root's top/bottom padding; canvas_create type "mobile" writes the device's), responsive Charts: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", series: [{ data: [210, 450, 648], stroke: "$accent", strokeDasharray?: "6 4", area?, points?, highlight?: [2] (a lone index 2 works too; null clears it; an entry that isn't an index into data renders no emphasis and the op result warns), barGradient? }], yDomain?: [0, 2700], curve?: "smooth", gridlines?: 4, xLabels?: ["Jan", ..., "Dec"], yLabels? } — the node does ALL the value→coordinate math (multi-series in one node; x = data index, a shorter series stops early against a longer one; domains auto from data, bars floor at 0). Dash the projected/forecast series, solid the actuals; highlight: [i] renders those bars solid over muted siblings (the selected-day treatment) and barGradient fades the muted bars inside the SVG. DONUT: kind "donut" + segments: [{ value, color: "$chart-1", label? }] + innerRatio? + centerValue/centerLabel — data-bound slices from 12 o\'clock clockwise, center figure rendered tabular; compose the legend yourself from frames + $chart-* dots. SPARKLINE: kind "sparkline" (+ sparkKind "bar"|"line") — axis-free mini-trend for KPI cards, latest point emphasized by default (override with series.highlight). NEVER hand-compute path d-strings, absolutely-position tick labels, fake a donut with conic gradients, or hand-place sparkline bars. @@ -1123,7 +1123,7 @@ Personalities (required — pick a stance, don't default into sameness): - "soft" — warm, rounded, human (Plus Jakarta Sans + Inter, 12/16/20 radii, springy motion). For consumer apps, onboarding, anything friendly. - "data-dense" — instrument-panel density (Inter + a JetBrains Mono "figures" role, 13px pivot, minimal radii, near-flat depth). For dashboards, tables, monitoring. -The typography layer ships ROLE tokens the structures speak — $display / $heading / $title (page/screen titles, one step below $display — every archetype's page title references it) / $body / $label / $caption (+ $figures when a mono face exists) — alongside the text-xs…text-3xl steps. Depth: reference elevation from any node as shadow: "$elevation.flat|raised|floating|overlay" — the dark layer re-states each depth so it reads on dark surfaces. The COLOR RANGE rides along (Phase 28): $chart-1…$chart-6 categorical series tokens for dataviz, and the tint layer ($accent-tint / $success-tint / … — tint as fill, base color as ink, AA by construction both themes) for chips, icon tiles, pills, and avatars. Writes to exactly ONE of canvasId / projectId / workspaceId. Canvas scope keeps tokens the canvas resolves through an inherited design system rather than clobbering them — but preservation is FIELD-WISE for typography, so an inherited role that carries only a size keeps that size and still picks up this personality's family/weight/tracking (before v2.1 it swallowed the whole spec and the role silently rendered in the fallback stack). What was kept is reported two ways: \`preservedFromDesignSystem\` for ordinary tokens, and \`designSystemConflicts\` for tokens in the semantic vocabulary THIS system defines (the surface/ink/border/accent semantics, the STATUS colours and their paired -tint layer, the \$chart-* range, and the type roles — an inherited success keeps pairing with the GENERATED success-tint and fails contrast) — a conflict means the screen is running two design languages at once, and each entry names the fix. \`preserveInherited: false\` writes the generated language whole. The response's typographyRoles reports what the canvas ACTUALLY resolves, not what was generated — the two differ exactly when preservation fired. The two single-purpose generators stay available for targeted regeneration (just the palette, just the scale). On a canvas declared type: "mobile" (canvas_create / canvas_set_type) the type scale pivots on the device body size — 17px for every personality except data-dense, which uses 16px — so the generated system already meets the phone body floor; an explicit baseSize still wins. The result reports the pivot as baseSize + baseSizeSource ("explicit" | "personality" | "mobile").`, +The typography layer ships ROLE tokens the structures speak — $display / $heading / $title (page/screen titles, one step below $display — every archetype's page title references it) / $body / $label / $caption (+ $figures when a mono face exists) — alongside the text-xs…text-3xl steps. Depth: reference elevation from any node as shadow: "$elevation.flat|raised|floating|overlay" — the dark layer re-states each depth so it reads on dark surfaces. The COLOR RANGE rides along (Phase 28): $chart-1…$chart-6 categorical series tokens for dataviz, and the tint layer ($accent-tint / $success-tint / … — tint as fill, base color as ink, AA by construction both themes) for chips, icon tiles, pills, and avatars. Writes to exactly ONE of canvasId / projectId / workspaceId. Canvas scope keeps tokens the canvas resolves through an inherited design system rather than clobbering them — but preservation is FIELD-WISE for typography, so an inherited role that carries only a size keeps that size and still picks up this personality's family/weight/tracking (before v2.1 it swallowed the whole spec and the role silently rendered in the fallback stack). What was kept is reported two ways: \`preservedFromDesignSystem\` for ordinary tokens, and \`designSystemConflicts\` for tokens in the semantic vocabulary THIS system defines (the surface/ink/border/accent semantics, the STATUS colours and their paired -tint layer, the \$chart-* range, and the type roles — an inherited success keeps pairing with the GENERATED success-tint and fails contrast) — a conflict means the screen is running two design languages at once, and each entry names the fix. \`preserveInherited: false\` writes the generated language whole. The response's typographyRoles reports what the canvas ACTUALLY resolves, not what was generated — the two differ exactly when preservation fired. The two single-purpose generators stay available for targeted regeneration (just the palette, just the scale). On a canvas declared type: "mobile" (canvas_create / canvas_set_type) the type scale pivots on the device body size — 17px for every personality except data-dense, which uses 16px — so the generated $body meets the phone body floor; an explicit baseSize still wins. The result reports the GENERATED pivot as baseSize + baseSizeSource ("explicit" | "personality" | "mobile"); if the canvas inherits a $body from a workspace/project design system, that body is preserved by default and shows up in designSystemConflicts (typographyRoles.body is what renders) — pass preserveInherited: false to take the mobile body.`, { seed: z.string().describe('The brand color to derive everything from (#RRGGBB)'), personality: z.enum(['technical', 'editorial', 'soft', 'data-dense']).describe('The design stance — required. Genre guide: dashboards → data-dense or technical; marketing/content → editorial; consumer → soft.'), @@ -2019,7 +2019,7 @@ Stamping a genre does NOT move the canvas's versionHash (the hash covers design // --- canvas_set_type (Phase 30 slice B) --- server.tool( 'canvas_set_type', - `Declare what MEDIUM an existing canvas is on: "web" (a web page) or "mobile" (a phone app screen: it selects the mobile ruleset — the evaluate result's surface report lists the rule ids that run — and generate_design_system then gives it a 17px body). Writes metadata.surface only — it NEVER resizes the artboard and NEVER writes the root's safeArea, because either would reflow what you already placed. So a canvas made mobile here usually needs one more op: when the result carries a "hint", it names it — U("document", { safeArea: { top: 47, bottom: 34 } }) to reserve the device insets, and the device artboard size (390×844 for "iphone-390") if the root differs. For a NEW phone screen, prefer canvas_create({ type: "mobile" }), which does all of that at once. Pass type: null to clear the declaration (the canvas reads as an undeclared web canvas again, typeSource "default"). device ("iphone-390", the default) is only valid with "mobile". The result echoes { type, device, source, previous ({ type, device } or null), artboard { width, height }, hint? }. Like canvas_set_genre it does NOT move the versionHash (metadata is outside the hash) — but the type changes which rules run, so re-evaluate after it.`, + `Declare what MEDIUM an existing canvas is on: "web" (a web page) or "mobile" (a phone app screen: it selects the mobile ruleset — the evaluate result's surface report lists the rule ids that run — and generate_design_system then gives it a 17px body). Writes metadata.surface only — it NEVER resizes the artboard and NEVER writes the root's safeArea, because either would reflow what you already placed. So a canvas made mobile here usually needs one more op: when the result carries a "hint", it names it — U("document", { safeArea: { top: 47, bottom: 34 } }) to reserve the device insets, and the device artboard size (390×844 for "iphone-390") if the root differs. For a NEW phone screen, prefer canvas_create({ type: "mobile" }), which does all of that at once. Pass type: null to clear the declaration (the canvas reads as an undeclared web canvas again, typeSource "default"). device ("iphone-390", the default) is only valid with "mobile". Clearing leaves the root (including any safeArea) as it is. The result echoes { canvasId, type, device, source, previous ({ type, device } or null), artboard { width, height }, hint?, versionHashUnchanged }. Like canvas_set_genre it does NOT move the versionHash (metadata is outside the hash; the hint's ops do move it, since they change the root) — but the type changes the surface report and the ruleset, so re-evaluate after it.`, { canvasId: z.string().describe('Canvas ID'), type: z.enum(['web', 'mobile']).nullable().describe('"web", "mobile", or null to clear the declaration'), @@ -2038,7 +2038,8 @@ server.tool( const artboard = { width: canvas.root.width, height: canvas.root.height }; const profile = surface.device ? deviceProfile(surface.device) : undefined; const steps: string[] = []; - if (profile && !canvas.root.safeArea) { + const insets = usableSafeArea(canvas.root.safeArea); + if (profile && (insets.top === undefined || insets.bottom === undefined)) { steps.push(`reserve the device insets with U("document", { safeArea: { top: ${profile.safeArea.top}, bottom: ${profile.safeArea.bottom} } })`); } if (profile && (artboard.width !== profile.width || artboard.height !== profile.height)) { @@ -2151,7 +2152,7 @@ server.tool( .describe('Write the fixes to the canvas in this call (default false: propose only, returning ops to run via batch_design).'), }, async ({ canvasId, categories, genre, apply }) => { - if (apply) ensureFresh(canvasId); // mutating path — pick up external edits first + ensureFresh(canvasId); // pick up external edits first — the proposals and the genre/surface reports must describe what's on disk const canvas = getCanvas(canvasId); if (!canvas) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; diff --git a/src/renderer.ts b/src/renderer.ts index a3ad47c..29bc26b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,6 +1,7 @@ import type { Canvas, FontFace, SceneNode } from './types.js'; import { getIconSvg } from './icons.js'; import { aliasFamilyStack } from './fonts.js'; +import { usableSafeArea } from './surfaces.js'; /** Build a `background: …` declaration from a node's `gradient`. * @@ -1042,13 +1043,11 @@ function findNearestContainer(ancestors: SceneNode[]): SceneNode | undefined { * top/bottom padding, at exact px (insets never scale fluidly). Each side is * honoured only when it is a finite non-negative number. */ function safeAreaPadding(node: SceneNode): string[] { - const insets = node.safeArea; - if (!insets || typeof insets !== 'object') return []; - const valid = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0; + const insets = usableSafeArea(node.safeArea); const sides = paddingSides(node.padding); const out: string[] = []; - if (valid(insets.top)) out.push(`padding-top: calc(${sides.top} + ${insets.top}px)`); - if (valid(insets.bottom)) out.push(`padding-bottom: calc(${sides.bottom} + ${insets.bottom}px)`); + if (insets.top !== undefined) out.push(`padding-top: calc(${sides.top} + ${insets.top}px)`); + if (insets.bottom !== undefined) out.push(`padding-bottom: calc(${sides.bottom} + ${insets.bottom}px)`); return out; } diff --git a/src/scene-graph.ts b/src/scene-graph.ts index 1b37f04..670c440 100644 --- a/src/scene-graph.ts +++ b/src/scene-graph.ts @@ -215,7 +215,8 @@ export function setCanvasType( const metadata = { ...canvas.metadata }; if (surface === null) delete metadata.surface; else metadata.surface = { ...surface }; - canvas.metadata = metadata; + if (Object.keys(metadata).length) canvas.metadata = metadata; + else delete canvas.metadata; touchCanvas(canvasId); return { previous }; } diff --git a/src/surfaces.ts b/src/surfaces.ts index 982ba78..3a56a7e 100644 --- a/src/surfaces.ts +++ b/src/surfaces.ts @@ -80,6 +80,16 @@ export function resolveSurface(canvas: Canvas): SurfaceDeclaration { return { type: 'mobile', device: typeof stored.device === 'string' ? stored.device : DEFAULT_DEVICE, source: 'declared' }; } +/** The root safeArea sides the renderer honours: each side only when it is a + * finite non-negative number. One reader for the renderer and for + * canvas_set_type's hint, so the hint never calls an ignored value reserved. */ +export function usableSafeArea(value: unknown): { top?: number; bottom?: number } { + if (!value || typeof value !== 'object') return {}; + const valid = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0; + const { top, bottom } = value as Record; + return { ...(valid(top) ? { top } : {}), ...(valid(bottom) ? { bottom } : {}) }; +} + /** Validate a type + device pair for canvas_create / canvas_set_type. Returns * the metadata to store, or an error message. */ export function surfaceFor(type: SurfaceType, device?: string): { surface: { type: SurfaceType; device?: string } } | { error: string } { diff --git a/test-surface.ts b/test-surface.ts index 46bc0aa..b234552 100644 --- a/test-surface.ts +++ b/test-surface.ts @@ -9,7 +9,7 @@ // Usage: npx tsx test-surface.ts import './test-env.js'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, utimesSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -59,6 +59,17 @@ function rootStyle(canvas: Canvas): string { const sized = createCanvas('Phone wide', undefined, { width: 393 }, { type: 'mobile' }); check('explicit width wins, height from the profile', sized.root.width === 393 && sized.root.height === 844); check('explicit size still gets the insets', sized.root.safeArea?.top === 47); + const tall = createCanvas('Phone tall', undefined, { height: 700 }, { type: 'mobile' }); + check('explicit height wins, width from the profile', tall.root.width === 390 && tall.root.height === 700); + mobile.root.safeArea!.top = 99; + check('root.safeArea is a copy, never the profile object', p.safeArea.top === 47 && sized.root.safeArea?.top === 47); + mobile.root.safeArea!.top = 47; + check('profile row is exactly the spec table', JSON.stringify(p) === JSON.stringify({ + id: 'iphone-390', width: 390, height: 844, safeArea: { top: 47, bottom: 34 }, touchTarget: 44, + navBarHeight: 44, navBarLargeHeight: 96, tabBarHeight: 49, tabCount: { min: 2, max: 5 }, + bodySize: 17, bodyFloor: 16, smallestText: 11, listRowHeight: 44, listRowSubtitleHeight: 56, + sideMargin: 16, homeIndicator: { width: 134, height: 5 }, + })); const web = createCanvas('Web', undefined, {}, { type: 'web' }); check('declared web: 1440×900, no safeArea', web.root.width === 1440 && web.root.height === 900 && web.root.safeArea === undefined); @@ -99,10 +110,24 @@ function rootStyle(canvas: Canvas): string { setCanvasType(c.id, null); check('clearing a clear canvas does not dirty it', c.lastModified === lm); check('unknown canvas → undefined', setCanvasType('nope', { type: 'web' }) === undefined); + check('clearing the only metadata leaves no empty bag', c.metadata === undefined, JSON.stringify(c.metadata)); + + // Persisted, and stamped on top of what's on disk (ensureFresh). + const file = join(process.env.FRAMESMITH_HOME!, 'canvases', `${c.id}.json`); + setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); + check('set-type is written to disk', JSON.parse(readFileSync(file, 'utf-8')).metadata?.surface?.type === 'mobile'); + const onDisk = JSON.parse(readFileSync(file, 'utf-8')); + onDisk.metadata.surface = { type: 'web' }; + writeFileSync(file, JSON.stringify(onDisk)); + const later = new Date(Date.now() + 5000); + utimesSync(file, later, later); + const rFresh = setCanvasType(c.id, null); + check('set-type reads a hand-edit first (previous is the on-disk web)', rFresh?.previous?.type === 'web', JSON.stringify(rFresh?.previous)); setCanvasGenre(c.id, 'commerce'); setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); - check('set-type leaves the genre alone', c.metadata?.provenance?.genre === 'commerce'); + // getCanvas: the hand-edit reload above replaced the store's object. + check('set-type leaves the genre alone', getCanvas(c.id)?.metadata?.provenance?.genre === 'commerce'); } // ── variants ────────────────────────────────────────────────────────────── @@ -169,6 +194,7 @@ function rootStyle(canvas: Canvas): string { m.metadata = { ...m.metadata, provenance: { preset: 'material', genre: 'commerce' } as never }; const evMat = await evaluateCanvas(m, { mode: 'fast' }); check('material preset + commerce genre relax all three', ['accent-hue', 'pure-black-white', 'honest-content'].every((t) => evMat.genre?.relaxed.includes(t as never)), evMat.genre?.relaxed.join(',')); + check('relaxed keeps ruleset order: genre, then preset (build decision D1)', JSON.stringify(evMat.genre?.relaxed) === JSON.stringify(['honest-content', 'accent-hue', 'pure-black-white']), JSON.stringify(evMat.genre?.relaxed)); const evExplicit = await evaluateCanvas(m, { mode: 'fast', genre: 'commerce' }); check('explicit genre keeps the preset relaxation', evExplicit.genre?.relaxed.includes('accent-hue') === true); } @@ -232,6 +258,12 @@ function rootStyle(canvas: Canvas): string { setCanvasGenre(c.id, 'data'); check('set-genre still changes it', evalCacheKey(c, []) !== k1); check('the canvas in the store is the one keyed', getCanvas(c.id) === c); + DEVICE_PROFILES['test-phone'] = { ...DEVICE_PROFILES[DEFAULT_DEVICE], id: 'test-phone' }; + setCanvasType(c.id, { type: 'mobile', device: 'iphone-390' }); + const kA = evalCacheKey(c, []); + setCanvasType(c.id, { type: 'mobile', device: 'test-phone' }); + check('a device change alone changes the score cache key', evalCacheKey(c, []) !== kA); + delete DEVICE_PROFILES['test-phone']; } // ── the real tool handlers over MCP stdio ───────────────────────────────── @@ -280,6 +312,23 @@ function rootStyle(canvas: Canvas): string { check('canvas_set_type: device with null errors', nullDev.isError === true); const missing = await tool('canvas_set_type', { canvasId: 'nope', type: 'web' }); check('canvas_set_type: unknown canvas errors', missing.isError === true); + const badSetDev = await tool('canvas_set_type', { canvasId: w.canvasId, type: 'mobile', device: 'pixel-9' }); + check('canvas_set_type: unknown device errors with the known list', badSetDev.isError === true && badSetDev.text.includes('iphone-390')); + const webSetDev = await tool('canvas_set_type', { canvasId: w.canvasId, type: 'web', device: 'iphone-390' }); + check('canvas_set_type: device with web errors', webSetDev.isError === true); + + // The hint names exactly what is missing. + const sizeOnly = await tool('canvas_create', { name: 'Odd size', type: 'mobile', width: 400 }); + const hSize = await tool('canvas_set_type', { canvasId: sizeOnly.canvasId, type: 'mobile' }); + check('hint: safeArea present, size wrong → size step only', typeof hSize.hint === 'string' && hSize.hint.includes('390×844') && !hSize.hint.includes('safeArea'), hSize.hint); + const bare = await tool('canvas_create', { name: 'Bare phone', width: 390, height: 844 }); + const hBare = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'mobile' }); + check('hint: size right, no safeArea → safeArea step only', typeof hBare.hint === 'string' && hBare.hint.includes('safeArea') && !hBare.hint.includes('resize'), hBare.hint); + await tool('batch_design', { canvasId: bare.canvasId, operations: 'U("document", { safeArea: "abc" })' }); + const hJunk = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'mobile' }); + check('hint: a safeArea the renderer ignores still gets the hint', typeof hJunk.hint === 'string' && hJunk.hint.includes('safeArea'), hJunk.hint); + const hWeb = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'web' }); + check('hint: never on a web result', hWeb.hint === undefined); await tool('batch_design', { canvasId: m.canvasId, operations: ` f=I("document", { type: "frame", layout: "vertical", gap: 16, padding: 16 }) @@ -289,6 +338,20 @@ I(f, { type: "text", content: "Today", fontSize: 28 }) check('canvas_evaluate returns the surface report', ev.surface?.type === 'mobile' && Array.isArray(ev.surface?.rulesOn) && Array.isArray(ev.surface?.conflicts)); const fix = await tool('canvas_autofix', { canvasId: m.canvasId }); check('canvas_autofix returns the surface report', fix.surface?.type === 'mobile' && fix.surface?.source === 'declared'); + await tool('batch_design', { canvasId: m.canvasId, operations: 'g=I("document", { type: "frame", layout: "vertical", gap: 13, padding: 16 })\nI(g, { type: "text", content: "Off-scale gap", fontSize: 16 })\nI(g, { type: "text", content: "Second line", fontSize: 16 })' }); + const applied = await tool('canvas_autofix', { canvasId: m.canvasId, apply: true }); + check('canvas_autofix apply: true returns the surface report', applied.appliedCount > 0 && applied.surface?.type === 'mobile', JSON.stringify({ n: applied.appliedCount, s: applied.surface })); + + // Propose mode reads a hand-edit first, like canvas_evaluate. + const mFile = join(home, 'canvases', `${m.canvasId}.json`); + const mDisk = JSON.parse(readFileSync(mFile, 'utf-8')); + mDisk.metadata.surface = { type: 'web' }; + writeFileSync(mFile, JSON.stringify(mDisk)); + const t = new Date(Date.now() + 5000); + utimesSync(mFile, t, t); + const proposeFresh = await tool('canvas_autofix', { canvasId: m.canvasId }); + check('canvas_autofix propose mode sees an on-disk edit', proposeFresh.surface?.type === 'web', JSON.stringify(proposeFresh.surface)); + await tool('canvas_set_type', { canvasId: m.canvasId, type: 'mobile' }); const gen = await tool('generate_design_system', { seed: '#0E7490', personality: 'technical', canvasId: m.canvasId }); check('generate_design_system on mobile: body 17, source mobile', gen.typographyRoles?.body?.fontSize === 17 && gen.baseSize === 17 && gen.baseSizeSource === 'mobile', JSON.stringify(gen.typographyRoles?.body)); From d15d11e1957916cd64fff1b61c0e47fe88736921 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 22:05:39 +0100 Subject: [PATCH 3/4] Test the partial-safeArea hint and say both insets in its docs --- README.md | 2 +- docs/GUIDELINES.md | 2 +- test-surface.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb68fb4..e2dc7a5 100644 --- a/README.md +++ b/README.md @@ -809,7 +809,7 @@ Declare what medium an existing canvas is on. Writes `metadata.surface` only: it | `type` | `"web"` \| `"mobile"` \| null | The medium; `null` clears the declaration (the canvas reads as undeclared web again) | | `device` | string? | Device profile for `"mobile"` (default `"iphone-390"`). Only valid with `"mobile"`. | -Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. Clearing the type leaves the root, including any `safeArea`, as it is. On a mobile result whose root reserves no insets or has a size other than the device's, `hint` names the ops that finish the job: `U("document", { safeArea: { top: 47, bottom: 34 } })` and `U("document", { width: 390, height: 844 })`. Those ops change the root, so they move the `versionHash`; the declaration alone is metadata and doesn't. It changes the evaluate result's `surface` report (and, as mobile checks register, which rules run), so re-evaluate after it. +Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. Clearing the type leaves the root, including any `safeArea`, as it is. On a mobile result whose root doesn't reserve both insets or has a size other than the device's, `hint` names the ops that finish the job: `U("document", { safeArea: { top: 47, bottom: 34 } })` and `U("document", { width: 390, height: 844 })`. Those ops change the root, so they move the `versionHash`; the declaration alone is metadata and doesn't. It changes the evaluate result's `surface` report (and, as mobile checks register, which rules run), so re-evaluate after it. ### `canvas_evaluate` diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index b4fb128..ae3e598 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -43,7 +43,7 @@ A canvas declares what medium it is on, separately from its genre (what it is fo - **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`, the only profile today) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. An explicit `width` or `height` wins for that side; the insets and the device stay the profile's. Passing `device` without `type: "mobile"`, or an unknown device, is an error. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. - **The renderer adds the insets to the root's own top and bottom padding**, at exact pixels even when the authored padding scales fluidly (padding of 32px or more), and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. - **`type: "web"` is the default.** An undeclared canvas is judged as web and its evaluate result says `source: "default"`. -- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root reserves no insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. +- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root doesn't reserve both insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. - **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"` for both mobile pivots, `"explicit"`, or `"personality"` on web). A `$body` inherited from a workspace or project design system is preserved by default and listed in `designSystemConflicts`; pass `preserveInherited: false` to take the mobile body. - **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off. A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`, so composing declarations can never switch a guardrail off. No rule ids are registered yet, so both lists are empty today; the mobile checks register theirs as they land. diff --git a/test-surface.ts b/test-surface.ts index b234552..84fdc28 100644 --- a/test-surface.ts +++ b/test-surface.ts @@ -327,6 +327,9 @@ function rootStyle(canvas: Canvas): string { await tool('batch_design', { canvasId: bare.canvasId, operations: 'U("document", { safeArea: "abc" })' }); const hJunk = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'mobile' }); check('hint: a safeArea the renderer ignores still gets the hint', typeof hJunk.hint === 'string' && hJunk.hint.includes('safeArea'), hJunk.hint); + await tool('batch_design', { canvasId: bare.canvasId, operations: 'U("document", { safeArea: { top: 47 } })' }); + const hHalf = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'mobile' }); + check('hint: one usable side still gets the safeArea step', typeof hHalf.hint === 'string' && hHalf.hint.includes('safeArea'), hHalf.hint); const hWeb = await tool('canvas_set_type', { canvasId: bare.canvasId, type: 'web' }); check('hint: never on a web result', hWeb.hint === undefined); From b63a50368d3f72bb39bab4e2516537f761df8d02 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 22:10:11 +0100 Subject: [PATCH 4/4] Rewrap the addVariant docstring --- src/scene-graph.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scene-graph.ts b/src/scene-graph.ts index 670c440..2a85cfd 100644 --- a/src/scene-graph.ts +++ b/src/scene-graph.ts @@ -529,10 +529,10 @@ function deepCloneWithNewIds(node: SceneNode, overrides?: Partial, id /** Phase 24 slice A (FR-A1) — clone a base canvas into a linked state variant. * The variant is a full sibling canvas (re-keyed node IDs, same project, * copied tokens/components/fonts) stamped with metadata.variant. Provenance - * (structure/preset/genre — WHAT the screen is) and the surface type carry over; feedback and - * critique (per-canvas judgments) deliberately don't. Adding a variant to a - * canvas that is itself a variant links to the ROOT base (spec C6: variant - * trees deeper than one level have no UX meaning). */ + * (structure/preset/genre — WHAT the screen is) and the surface type carry + * over; feedback and critique (per-canvas judgments) deliberately don't. + * Adding a variant to a canvas that is itself a variant links to the ROOT base + * (spec C6: variant trees deeper than one level have no UX meaning). */ export function addVariant(canvasId: string, state: string): { canvas: Canvas; idMap: Record } { ensureFresh(canvasId); let base = store.get(canvasId);