diff --git a/README.md b/README.md index 34789bd..aff320e 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,10 @@ 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. | -The response also carries 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` 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` @@ -300,7 +302,7 @@ M("nodeId", "newParentId", 0) R("nodeId", { type: "text", content: "Replaced" }) ``` -**Returns** `{ ok, nodeIds, results }`. `nodeIds` maps each bound variable to the node ID it created — e.g. `{ "header": "n_a1b2" }` — so you can target those 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, registered, or system/generic), a `Font warnings` content item names it — a cache-only check with no network call, so it's a heads-up, not exhaustive. +**Returns** `{ ok, nodeIds, results }`. `nodeIds` maps each bound variable to the node ID it created — e.g. `{ "header": "n_a1b2" }` — so you can target those nodes in later calls (bindings only live within a single call). `results` lists each op's outcome in order; an op that succeeded but will not fully take effect carries a `warning` (a `shadow` beside an active `shadows`, a chart `highlight` entry outside the series data). If the call wrote a `fontFamily` nothing can serve yet (not cached, registered, or system/generic), a `Font warnings` content item names it — a cache-only check with no network call, so it's a heads-up, not exhaustive. **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) @@ -334,7 +336,7 @@ I(panel, { type: "chart", kind: "donut", width: 176, height: 176, centerValue: " ] }) ``` -A **bar chart with a selected day** is `highlight: [11]` on the series (selected bar solid, the rest muted; `barGradient: true` fades the muted bars inside the SVG, invisible to the gradient-overuse tell). A **sparkline** is `kind: "sparkline"` — axis-free, latest point emphasized by default, sized for a KPI card corner. `segments[].color` and series `stroke` both take `$chart-1`…`$chart-6` refs. +A **bar chart with a selected day** is `highlight: [11]` (or `highlight: 11`; `null` clears it) on the series (selected bar solid, the rest muted; `barGradient: true` fades the muted bars inside the SVG, invisible to the gradient-overuse tell). An entry that isn't an index within the series `data` emphasizes nothing, and the `batch_design` op result warns about it. A **sparkline** is `kind: "sparkline"` — axis-free, latest point emphasized by default, sized for a KPI card corner. `segments[].color` and series `stroke` both take `$chart-1`…`$chart-6` refs. Multi-series in one node; x positions are data indexes (a shorter series stops early against a longer one — booked months vs a full-year target); `xDomain`/`yDomain` default from the data (bars floor at 0); `kind: "bar"` renders grouped bars from the same series model. Dash the projected series, solid the actuals. Editing one value is a one-prop edit — never hand-compute path `d` strings for a chart. @@ -406,8 +408,8 @@ Render canvas to PNG (returned as base64 image). |-------|------|-------------| | `canvasId` | string | Canvas ID | | `nodeId` | string? | Specific node to capture | -| `width` | number? | Viewport width (default 1440) | -| `height` | number? | Viewport height (default 900) | +| `width` | number? | Viewport width (default: the canvas's artboard width) | +| `height` | number? | Viewport height (default: the canvas's artboard height) | | `fullPage` | boolean? | Capture the whole design instead of one viewport — default false, so existing captures (diffs, responsive sets, the pattern gate) are byte-identical. A canvas taller than its artboard otherwise cuts off at the artboard height. `export` takes the same option (ignored for PDF, which paginates) | | `scale` | number? | Device scale (default 2) | | `theme` | string? | `"dark"` renders the design system's dark token layer (`dark.colors`/`dark.elevation` overrides); default light — a no-op without a dark layer | @@ -567,8 +569,8 @@ Export a canvas or specific nodes to files on disk. | `format` | string | `"png"`, `"jpeg"`, `"webp"`, or `"pdf"` | | `outputPath` | string | Directory to save files | | `nodeIds` | string[]? | Specific nodes to export (default: full canvas) | -| `width` | number? | Viewport width (default 1440) | -| `height` | number? | Viewport height (default 900) | +| `width` | number? | Viewport width (default: the canvas's artboard width) | +| `height` | number? | Viewport height (default: the canvas's artboard height) | | `scale` | number? | Device scale (default 2) | | `theme` | string? | `"dark"` renders the design system's dark token layer (`dark.colors`/`dark.elevation` overrides); default light — a no-op without a dark layer | @@ -765,8 +767,8 @@ Compare two canvases visually. Returns a diff image with changed regions highlig |-------|------|-------------| | `canvasId1` | string | First canvas ID | | `canvasId2` | string | Second canvas ID | -| `width` | number? | Viewport width (default 1440) | -| `height` | number? | Viewport height (default 900) | +| `width` | number? | Viewport width (default: the first canvas's artboard width) | +| `height` | number? | Viewport height (default: the first canvas's artboard height) | | `scale` | number? | Device scale (default 1) | ### `get_feedback` / `resolve_feedback` @@ -799,10 +801,12 @@ Returns `{ canvasId, genre, previous, relaxes, note?, versionHashUnchanged }` 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`. -The **`directive`** is the operating contract that keeps unfinished work off the user's screen. Readiness turns on **blocking findings, not the score**: it reads `READY TO PRESENT` when there are zero errors, zero warnings, **and** zero cliché tells; otherwise `NOT READY` with what to resolve. (Cliché tells block even at info severity — they're the slop signal; pure advisories like "consider extracting components" are optional.) The score is always reported alongside the directive and remains the quality bar to aim for (**> 95**) — but it no longer withholds readiness on its own: once nothing blocking is left, a design at or below the bar still reads `READY`, and the directive says so explicitly, naming the advisory findings as the remaining lever. Resolve everything blocking and re-evaluate until it says `READY` before showing the design to the user. +The **`directive`** is the operating contract that keeps unfinished work off the user's screen. Readiness turns on **blocking findings, not the score**: it reads `READY TO PRESENT` when there are zero errors, zero warnings, **and** zero cliché tells (and no `FRAGILE` stress verdict, see below); otherwise `NOT READY` with what to resolve. (Cliché tells block even at info severity — they're the slop signal; pure advisories like "consider extracting components" are optional.) The score is always reported alongside the directive and remains the quality bar to aim for (**> 95**) — but it no longer withholds readiness on its own: once nothing blocking is left, a design at or below the bar still reads `READY`, and the directive says so explicitly, naming the advisory findings as the remaining lever. Resolve everything blocking and re-evaluate until it says `READY` before showing the design to the user. Open point-and-tell comments block too: when the canvas has any, the result carries `openFeedback: n` and the directive stays blocking even at a READY score — the human's note outranks the heuristics. Read them with `get_feedback`, address each, close them with `resolve_feedback`. +A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, current }`, where `current` says whether that run was on the canvas's present `versionHash`. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When `current` is false the directive tells you to re-run stress to confirm your fix. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. + Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed }` — the genre in effect, whether it came from the `genre` param or the canvas's provenance stamp, the tells it relaxed, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. 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). @@ -810,7 +814,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | Param | Type | Description | |-------|------|-------------| | `canvasId` | string | Canvas ID to evaluate | -| `mode` | `"fast"` \| `"detailed"` \| `"llm"` | `"fast"` = JSON-tree analysis only (<100ms). `"detailed"` adds Puppeteer-based pixel-level overlap checks. `"llm"` runs fast-mode heuristics plus a vision-model critique (provider picked from `FRAMESMITH_LLM_PROVIDER` or whichever of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` is set — costs one paid API call per invocation). Default `"fast"`. | +| `mode` | `"fast"` \| `"detailed"` \| `"llm"` | `"fast"` = JSON-tree analysis only (<100ms). `"detailed"` adds Puppeteer-based pixel-level overlap checks, laid out at the canvas's artboard size. `"llm"` runs fast-mode heuristics plus a vision-model critique (provider picked from `FRAMESMITH_LLM_PROVIDER` or whichever of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` is set — costs one paid API call per invocation). Default `"fast"`. | | `categories` | string[]? | Subset of `spacing`, `color`, `typography`, `structure`, `consistency`, `cliche`, `coverage`, `usability`. Defaults to all. | | `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"dashboard"` (alias `"data"`) allows realistic figures on data-dense screens; `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's provenance preset if stamped (`canvas_set_genre` stamps it durably, no token churn). Pick by what the screen is *for* (read screens with published figures → `dashboard`; a screen you complete a purchase on → `commerce`; editors/admin forms → `material`; a pricing page is marketing and gets none of them), not what it contains — the result's `genre` field audits the choice. | @@ -818,7 +822,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | Category | Weight | Checks | |----------|--------|--------| -| `spacing` | 20 | Off-scale padding/gap values, plus spacing variety measured against the canvas's **declared** scale (`set_variables`/`generate_scale` spacing tokens) — only values *not* on that scale count toward sprawl, so a design built entirely from its own multi-step scale (framesmith's generated scale ships nine: `space-3xs`…`space-3xl`) isn't penalized for using it. No declared scale falls back to the old total-unique-values count, with a suggestion to declare one | +| `spacing` | 20 | Off-scale padding/gap values (`gap: 0`, a flush stack, is never flagged), plus spacing variety measured against the canvas's **declared** scale (`set_variables`/`generate_scale` spacing tokens) — only values *not* on that scale count toward sprawl, so a design built entirely from its own multi-step scale (framesmith's generated scale ships nine: `space-3xs`…`space-3xl`) isn't penalized for using it. No declared scale falls back to the old total-unique-values count, with a suggestion to declare one | | `color` | 25 | WCAG AA contrast ratios for text against nearest background — checked in BOTH themes when a `dark` token layer exists (dark-run issues carry `theme: "dark"`, score takes the worse theme, and dark failures point at the dark layer instead of carrying a literal fix). WCAG-passing pairs that are perceptually weak by **APCA** get an info advisory (Lc vs ~75 body / ~60 large) — APCA is a candidate method, not a standard; it never blocks | | `typography` | 20 | Type-scale ratios (1.1–2.0; adjacent sizes both declared as typography tokens are pinned and skip the check), font-family count, weight variation — plus score-neutral craft advisories: measure (prose past ~75 characters per line; warning past 90), tracking (display text ≥28px without negative letter-spacing, autofixable), and tabular numerals (numeric table columns without `tabularNums`, autofixable) | | `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable when one spelling resolves) | @@ -936,7 +940,7 @@ Apply the ops by joining them with newlines and passing to `batch_design`, then ### `canvas_stress` -Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. +Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. The verdict is remembered for `canvas_evaluate`: any run with warnings records `FRAGILE` (its directive then stays `NOT READY`), and only a run of the full default set records `CLEAN`, so a passing subset never clears an earlier `FRAGILE`. | Perturbation | What it does | |---|---| diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 9d0fa30..30b0f62 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 })`** — drops the canvas in the given project (defaults to Untitled). +- **`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_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), 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 screen is its own artboard, `canvas_create({ width: 390, height: 844 })`), 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 @@ -96,7 +96,7 @@ The `chart` node owns the value→coordinate math — give it data and domains, - **X positions are data indexes.** A 7-point booked series against a 12-point target series stops at 7/12 of the width automatically; `xDomain`/`yDomain` default from the data (bars floor at 0). - **Furniture is opt-in props**: `gridlines: 4`, `xLabels`/`yLabels` (spread evenly; empty strings skip intermediate ticks), `curve: "smooth"`, `kind: "bar"` for grouped bars. - **Editing a data point is a one-prop edit** — update the series array, done. -- **Bar emphasis is a prop, not a rebuild.** `highlight: [11]` on a bar series renders the selected bar solid while the rest go muted (~30%); add `barGradient: true` and the muted bars fade vertically inside the SVG — invisible to the gradient-overuse tell by construction. The floating tooltip card stays your composition (`position: "absolute"` frame with `$elevation.floating`). +- **Bar emphasis is a prop, not a rebuild.** `highlight: [11]` (or just `highlight: 11`) on a bar series renders the selected bar solid while the rest go muted (~30%) — an entry that isn't an index into `data` emphasizes nothing, and the `batch_design` op result warns about it; add `barGradient: true` and the muted bars fade vertically inside the SVG — invisible to the gradient-overuse tell by construction. The floating tooltip card stays your composition (`position: "absolute"` frame with `$elevation.floating`). - **Donuts are data-bound.** `kind: "donut"` + `segments: [{ value: 385, color: "$chart-1", label: "North" }, …]` + `innerRatio?` + `centerValue`/`centerLabel` — slices run clockwise from 12 o'clock, the center figure renders tabular and bold. Compose the legend yourself: rows of `$chart-*` dots + labels + values (legend layout is design, not charting). Never fake a ring with conic gradients. - **Sparklines are the KPI-card mini-trend.** `kind: "sparkline"` (+ `sparkKind: "bar" | "line"`) — axis-free, tick-free, latest point emphasized by default (`series.highlight` overrides). Never hand-place 4px bars. @@ -262,7 +262,7 @@ The happy path of one static frame with ideal data is half a design. Real UX liv **The evaluator demands them.** The `coverage` category warns — directive-blocking — when a base canvas carries data-bearing content with no designed states: a detected table demands `empty` + `loading`; a form (3+ input controls) demands `error`. (Table detection requires three matching horizontal rows — two stacked rows are a layout, not data — so the rare header-plus-one-row table goes undetected; name the frame `Table` to claim the shape explicitly.) Variant canvases and non-data screens are silent. The result's `coverage: { dataBearing, states, missing }` tells you where you stand, and `canvas_list` shows the designed states per screen. -**Then stress it.** `canvas_stress` re-renders the canvas under hostile-but-realistic content — `long-text` (×2.2 + an unbroken German compound), `i18n` (×1.4), `big-numbers` (`9` → `999+`, `$1.5M` → `$1,520,847.33`), `empty`, `many` (rows ×3) — and reports what broke, by node id: `clip` (info when a designed ellipsis is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; warning otherwise), `overflow-x`, `layout-shift` (an untouched node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent, like a full-height sidebar next to a growing table, are all exempt). Only *new* breakage counts. Fix findings with the [width strategies](#width-strategies) above — fluid widths, `minWidth` or `minHeight` floors, wrapping — or give a must-stay-single-line label `textOverflow: "ellipsis"` (the designed-truncation property: the clip downgrades to info because truncating is the design) — and re-run until `CLEAN`. A data screen goes to the user with its states designed and a `CLEAN` stress verdict, the same way it goes with a >95 score. +**Then stress it.** `canvas_stress` re-renders the canvas under hostile-but-realistic content — `long-text` (×2.2 + an unbroken German compound), `i18n` (×1.4), `big-numbers` (`9` → `999+`, `$1.5M` → `$1,520,847.33`), `empty`, `many` (rows ×3) — and reports what broke, by node id: `clip` (info when a designed ellipsis is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; warning otherwise), `overflow-x`, `layout-shift` (an untouched node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent, like a full-height sidebar next to a growing table, are all exempt). Only *new* breakage counts. Fix findings with the [width strategies](#width-strategies) above — fluid widths, `minWidth` or `minHeight` floors, wrapping — or give a must-stay-single-line label `textOverflow: "ellipsis"` (the designed-truncation property: the clip downgrades to info because truncating is the design) — and re-run until `CLEAN`. A data screen goes to the user with its states designed and a `CLEAN` stress verdict, the same way it goes with a >95 score. `canvas_evaluate` holds you to it: once stress has recorded a verdict, the evaluate result carries a `stress` field (`{ verdict, warnings, current }`), and a `FRAGILE` verdict keeps the directive `NOT READY` until a full `CLEAN` run (all five perturbations) replaces it — a subset run can record `FRAGILE` but never clear it. The verdict lives in server memory, so a restart forgets it. ## Reviewing the set @@ -306,9 +306,9 @@ Polishing the design to the bar is **your** job, not the user's — they should 3. Re-run `canvas_evaluate`. 4. Repeat until there are **zero errors, zero warnings, and zero cliché tells**. -`canvas_evaluate`'s result includes a `directive` field — it says `READY TO PRESENT` or `NOT READY` with what's left. **Only present a design once it says READY.** Don't ship the first attempt; ship the one that passes the bar. +`canvas_evaluate`'s result includes a `directive` field — it says `READY TO PRESENT` or `NOT READY` with what's left. **Only present a design once it says READY.** On a data screen that includes stress: after a `FRAGILE` `canvas_stress` run the directive stays NOT READY until a full CLEAN run replaces it. Don't ship the first attempt; ship the one that passes the bar. -**Readiness turns on blocking findings, not the score.** `READY` means step 4 is done — zero errors, zero warnings, zero cliché tells — nothing more. The score (aim for **> 95**) is always reported alongside the directive and is still the quality bar you're climbing toward, but it no longer withholds readiness by itself: a design with nothing left to resolve is `READY` even sitting at or below the bar, and the directive says so explicitly, pointing at the remaining advisory findings as the lever. (Before Phase 29 slice D, readiness also required score > 95, which could strand a design with every real problem fixed and no honest move left to raise the number — a gate that can't be satisfied by improving the design isn't a gate.) +**Readiness turns on blocking findings, not the score.** `READY` means step 4 is done — zero errors, zero warnings, zero cliché tells — and no unresolved `FRAGILE` stress verdict; nothing more. The score (aim for **> 95**) is always reported alongside the directive and is still the quality bar you're climbing toward, but it no longer withholds readiness by itself: a design with nothing left to resolve is `READY` even sitting at or below the bar, and the directive says so explicitly, pointing at the remaining advisory findings as the lever. (Before Phase 29 slice D, readiness also required score > 95, which could strand a design with every real problem fixed and no honest move left to raise the number — a gate that can't be satisfied by improving the design isn't a gate.) **Which number is the gate?** The heuristic directive (fast mode) is the presentation gate — always available, no API key. `mode: "llm"` adds a vision-model rubric critique (composition, hierarchy, polish) on top; it needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` and degrades gracefully without one — when it's unavailable, the heuristic directive alone decides. With a key the heuristic directive still decides, and a `needsRevision: true` verdict never blocks presenting. Run the critique loop as the standard last step (judge, `canvas_revise`, re-judge). If `canvas_revise` stops (`max-iterations` or `no-improvement`) with axes still failing, present the design and tell the user which axes fail rather than looping. And calibrate the evaluator to what the screen *is*: a data-dense product screen evaluated without `genre: "dashboard"` — or a checkout without `genre: "commerce"` — will flag its own figures as fabricated and pin the score below the bar with no path up — that's a miscalibrated gate, not a bad design (see Cliché & craft below). @@ -346,7 +346,7 @@ A few operational details that aren't obvious from the tool schemas: - **Spacing variety is measured against your *declared* scale, not a flat count.** The `spacing` category only counts values that AREN'T on the canvas's declared spacing scale (`set_variables`/`generate_scale`) toward sprawl — a design drawing exclusively from a nine-step generated scale (`space-3xs`…`space-3xl`) isn't penalized for using all nine. No declared scale falls back to the old total-unique-values count, and the message tells you to declare one. Declaring the scale is the intentionality signal here too. - **The eyebrow-rhythm cliché check reads type-role thresholds off your declared scale.** "Small text" (an eyebrow candidate) and "large text" (a heading) are relative to your typography tokens when you declare `label`/`caption`/`heading` roles — a `soft`-personality section head at 25px or a `data-dense` one at 19px still reads as a heading. Canvases without declared roles fall back to the fixed 14px / 28px constants. - **Typography `$tokens` resolve the FULL spec.** A `fontSize: "$heading"` reference applies the token's size **and** its `fontWeight` / `fontFamily` / `lineHeight` / `letterSpacing` — explicit node props always win, so overrides behave as expected. (Before v1.12 only `.fontSize` resolved; that quirk is dead — declare the type system on tokens and it actually applies.) -- **Row rules and accent bars are per-side borders, not layout hacks.** `borderTop: { width: 1, color: "$border" }` on each table row gives hairline separators with `gap: 0`; `borderLeft: { width: 3, color: "$primary" }` marks the active row. `style: "dashed" | "dotted"` (and `strokeStyle` for the all-sides `stroke`) is the convention for forecast/placeholder/draft; `strokeDasharray: "6 4"` dashes SVG paths — the projected-vs-actual convention in charts. Never simulate hairlines with `gap: 1` + background bleed-through — it couples separation to spacing and fights the spacing linter. +- **Row rules and accent bars are per-side borders, not layout hacks.** `borderTop: { width: 1, color: "$border" }` on each table row gives hairline separators with `gap: 0` (always on-scale — flush stacks never trip the spacing check); `borderLeft: { width: 3, color: "$primary" }` marks the active row. `style: "dashed" | "dotted"` (and `strokeStyle` for the all-sides `stroke`) is the convention for forecast/placeholder/draft; `strokeDasharray: "6 4"` dashes SVG paths — the projected-vs-actual convention in charts. Never simulate hairlines with `gap: 1` + background bleed-through — it couples separation to spacing and fights the spacing linter. - **Prefer the structured form for gradients & shadows.** `gradient: { type, angle?, stops: [...] }` and `shadows: [{ x, y, blur, spread?, color, inset? }]`. A raw CSS string is accepted too, but the structured form is canonical and diffs cleanly. - **`shadows` (plural) always beats `shadow` (singular).** Writing `shadow` on a node that already has `shadows` does nothing at render time — the plural form wins. `batch_design` and `replace_matching_properties` flag this with a `warning` in the op result; when you see it, put the value in `shadows` (array or CSS string) or clear `shadows` first. - **`import_design_md` is best-effort.** It reads tokens per heading section in list / table / `name: value` form (see the tool description for the exact accepted schema) and silently skips what it can't parse — colors deliberately reject shadow/gradient strings. Set anything it misses with `set_variables`. It honors explicit named spacing values and only synthesizes a scale from a stated `Base unit:` — it won't fabricate one otherwise. diff --git a/src/directive.ts b/src/directive.ts index 84b8651..5b19f72 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -13,6 +13,13 @@ import { appendFeedbackDirective } from './feedback.js'; * below — but still reported in every directive so it can't be forgotten. */ export const SCORE_BAR = 95; +/** The last canvas_stress verdict as the directive sees it. `current` = it ran + * on the canvas's present versionHash. */ +export interface StressState { + warnings: number; + current: boolean; +} + export interface DirectiveParts { directive: string; ready: boolean; @@ -43,19 +50,32 @@ export function countBlocking(result: EvaluationResult): number { * so explicitly and names what is left, so "ready at 93" can never be mistaken * for "ready at 100". */ -export function buildEvaluateDirective(result: EvaluationResult, openFeedback = 0): DirectiveParts { +export function buildEvaluateDirective(result: EvaluationResult, openFeedback = 0, stress?: StressState): DirectiveParts { const blocking = countBlocking(result); const optional = result.issues.length - blocking; - const ready = blocking === 0; + // A FRAGILE stress run blocks until a CLEAN run replaces it — an edit since + // the run only changes what the agent is told to do, never the verdict. + const fragile = stress && stress.warnings > 0 ? stress : undefined; + const ready = blocking === 0 && !fragile; const optTail = optional ? ` ${optional} optional refinement(s) noted (info) — address if easy, not required.` : ''; const belowBar = result.overallScore <= SCORE_BAR ? ` Score is ${result.overallScore}, at or below the > ${SCORE_BAR} bar — nothing blocking is left, so the remaining lift is in the ${optional} advisory finding(s) and in craft the heuristics don't measure. Judge the render before presenting.` : ''; + const stressNote = !fragile + ? '' + : fragile.current + ? ` The last canvas_stress run on this exact version was FRAGILE (${fragile.warnings} layout break(s)) — fix them with fluid widths / minWidth or minHeight floors / wrapping / textOverflow: "ellipsis", then re-run canvas_stress until CLEAN.` + : ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) on an earlier version — re-run canvas_stress to confirm your changes fixed them; only a CLEAN run clears this.`; - const base = ready - ? `READY TO PRESENT — ${result.overallScore}/100, no blocking issues.${optTail}${belowBar}` - : `NOT READY — ${result.overallScore}/100 with ${blocking} issue(s) to resolve${optional ? ` (+${optional} optional)` : ''}. Fix them now: canvas_autofix for the mechanical subset, batch_design for the rest (cliché tells included), then re-run canvas_evaluate. Repeat until there are zero warnings and zero cliché tells. Do NOT show this design to the user yet.`; + let base: string; + if (ready) { + base = `READY TO PRESENT — ${result.overallScore}/100, no blocking issues.${optTail}${belowBar}`; + } else if (blocking > 0) { + base = `NOT READY — ${result.overallScore}/100 with ${blocking} issue(s) to resolve${optional ? ` (+${optional} optional)` : ''}. Fix them now: canvas_autofix for the mechanical subset, batch_design for the rest (cliché tells included), then re-run canvas_evaluate. Repeat until there are zero warnings and zero cliché tells.${stressNote} Do NOT show this design to the user yet.`; + } else { + base = `NOT READY — ${result.overallScore}/100 with no evaluate issues left, but stress is not cleared.${stressNote} Then re-run canvas_evaluate. Do NOT show this design to the user yet.`; + } return { directive: appendFeedbackDirective(base, openFeedback), ready, blocking, optional }; } diff --git a/src/evaluate.ts b/src/evaluate.ts index 37973ac..117c034 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -270,7 +270,9 @@ function checkSpacing(entries: NodeEntry[], variables: DesignVariables): CheckRe const arrayPaddings: { nodeId: string; nodeName?: string; padding: number[] }[] = []; for (const { node } of entries) { - if (node.gap !== undefined && typeof node.gap === 'number') { + // gap: 0 is a deliberate flush stack (divider-separated rows, tab bars), + // never an off-scale value — the same reason padding ≤ 0 is skipped below. + if (typeof node.gap === 'number' && node.gap !== 0) { allValues.push({ value: node.gap, nodeId: node.id, nodeName: node.name, prop: 'gap', fixable: true }); } if (node.padding !== undefined) { @@ -2071,8 +2073,10 @@ export async function evaluateCanvas( // the consistency and typography (measure) checks. let detailedLayout: LayoutRect[] | undefined; if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography'))) { - const html = renderToHtml(resolvedRoot, 1440, 900, canvas); - detailedLayout = await computeLayout(html); + const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; + const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; + const html = renderToHtml(resolvedRoot, w, h, canvas); + detailedLayout = await computeLayout(html, undefined, 10, { width: w, height: h }); } if (activeCategories.includes('typography')) { results.set('typography', checkTypography(entries, mergedTokens, { diff --git a/src/index.ts b/src/index.ts index 9658a0e..4b20ee6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import { parseDesignMd } from './design-md-parser.js'; import { listFeedback, resolveFeedback, openFeedbackCount, appendFeedbackDirective } from './feedback.js'; import { importHtml, importUrl, renderImportedTree, snapToTokens } from './import.js'; import { computeStructuralDrift, expandInstances } from './drift.js'; -import { applyPerturbation, compareLayouts, PERTURBATION_NAMES, type PerturbationName } from './stress.js'; +import { applyPerturbation, compareLayouts, lastStressRun, PERTURBATION_NAMES, recordStressRun, type PerturbationName } from './stress.js'; import { buildEvaluateDirective } from './directive.js'; import { generateTypeScale, generateSpaceScale, resolveRatio, type RatioName } from './scales.js'; import { generateColorSystem } from './color-system.js'; @@ -55,7 +55,7 @@ Your job is to craft beautiful UI with real UX — designs a designer would sign Core loop: start from a taste-vetted pattern (list_structures → apply_structure) — never a blank canvas → adapt at one target width (referencing $tokens), using framesmith's real capabilities below → screenshot → canvas_evaluate → resolve EVERY comment it returns (canvas_autofix 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, not the score, is what gates presenting; the score (aim for > 95) is always reported and is the quality bar to climb toward, but once nothing is left to resolve, a sub-bar score alone doesn't withhold readiness — the directive says so explicitly and names the advisories as the remaining lever. ONLY once CLEAN present to the user — and when an LLM API key is configured, make the vision critique the STANDARD final step, not an exception: canvas_evaluate mode "llm" scores the render on the fixed five-axis rubric, canvas_revise addresses the failing axes, re-judge until it passes or canvas_revise stops. The heuristic gate is the floor; the critique loop is the ceiling — and it never gates presenting: if axes still fail when canvas_revise stops (max-iterations / no-improvement), present and name the failing axes. Never show a design with open comments — the evaluate result tells you when it's safe to present. The "Designing with taste" guidelines cover the do's (one focal point, real hierarchy, one type + spacing scale, restraint); the cliche category catches the don'ts. -A data screen isn't done at one static frame with ideal data. DESIGN EVERY STATE: tables need designed "empty" + "loading" variants, forms need "error" — the coverage category warns until they exist, and canvas_add_variant + the empty-state / skeleton-table / skeleton-card / skeleton-stat-card scaffolds make each one a clone plus a stamp. SURVIVE EVERY STRING: run canvas_stress before presenting a data screen — it renders the too-long name, the German label, the "999+" badge, and empty/tripled tables, and reports what clipped or overflowed by node id (fix with fluid widths / minWidth or minHeight floors / wrapping, then re-run until CLEAN). +A data screen isn't done at one static frame with ideal data. DESIGN EVERY STATE: tables need designed "empty" + "loading" variants, forms need "error" — the coverage category warns until they exist, and canvas_add_variant + the empty-state / skeleton-table / skeleton-card / skeleton-stat-card scaffolds make each one a clone plus a stamp. SURVIVE EVERY STRING: run canvas_stress before presenting a data screen — it renders the too-long name, the German label, the "999+" badge, and empty/tripled tables, and reports what clipped or overflowed by node id (fix with fluid widths / minWidth or minHeight floors / wrapping, then re-run until CLEAN). After a FRAGILE canvas_stress run, canvas_evaluate stays NOT READY until a full CLEAN run replaces it. DERIVE, DON'T HAND-PICK. Start every new project's look with generate_design_system: one seed color + one PERSONALITY (technical / editorial / soft / data-dense) → the complete design language — color system, curated font pairing, typography ROLES ($display/$heading/$title/$body/$label), radius + density stances, $elevation.* depth tokens (dark-aware), $motion defaults. Deterministic, no API key; a different personality on the same seed is a visibly different product — pick the stance deliberately. The single-purpose generators stay for targeted regeneration: generate_scale (a named ratio → the full text-xs…text-3xl type scale + paired space-3xs…space-3xl spacing, craft defaults baked in, fluid clamp() optional) and generate_color_system (one seed → OKLCH primary/neutral ramps, status colors, and the bg-surface/text-primary/accent semantic vocabulary the structures speak — every text/surface pair AA by construction, dark theme included as a sparse dark.colors layer). Then USE the system: theme: "dark" renders the dark theme and canvas_evaluate contrast-checks both automatically (APCA appears as info-only — WCAG 2.2 is the gate); the consistency category re-attaches literals that drifted from tokens; motion timing lives in $motion tokens, not scattered ms values. Generate first, adjust individual tokens after — never eyeball ten hexes. @@ -67,7 +67,7 @@ Icons & typography: two bundled icon sets render by name via the icon node type Fonts load by name: set fontFamily in a typography token (or on a node) and the renderer resolves it from Google Fonts automatically (cached in ~/.framesmith/fonts/ — offline after first use). typography.body.fontFamily becomes the document default. Generic shorthands "mono" / "sans" render as CSS monospace / sans-serif — no registration, no network. batch_design warns immediately when a call writes a fontFamily nothing can serve yet (not cached / not registered / not generic) — heed it, and heed "Font warnings" in screenshot results: a warned family is rendering in the fallback stack, not the face you named. set_fonts is only needed for non-Google sources; with a css2 stylesheet URL, the family label you pass is the family that gets registered. -Charts are data-driven: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", series: [{ data, stroke, strokeDasharray?, area?, points?, highlight?, barGradient? }], gridlines, xLabels } does all the coordinate math — multi-series in one node, domains auto from data, dashed = projected / solid = actual, highlight: [i] renders the selected bar solid over muted siblings. Donuts are data-bound too: kind "donut" + segments: [{ value, color: "$chart-1", label }] + centerValue/centerLabel render the ring and its center figure (legend rows stay YOUR composition). kind "sparkline" is the KPI-card mini-trend: axis-free, latest point emphasized by default. Never hand-compute SVG path coordinates, fake a donut with conic gradients, or hand-place sparkline bars; editing a value is a one-prop edit. +Charts are data-driven: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", series: [{ data, stroke, strokeDasharray?, area?, points?, highlight?, barGradient? }], gridlines, xLabels } does all the coordinate math — multi-series in one node, domains auto from data, dashed = projected / solid = actual, highlight: [i] (or a lone index i) renders the selected bar solid over muted siblings. Donuts are data-bound too: kind "donut" + segments: [{ value, color: "$chart-1", label }] + centerValue/centerLabel render the ring and its center figure (legend rows stay YOUR composition). kind "sparkline" is the KPI-card mini-trend: axis-free, latest point emphasized by default. Never hand-compute SVG path coordinates, fake a donut with conic gradients, or hand-place sparkline bars; editing a value is a one-prop edit. Reuse instead of rebuild: create_component promotes any existing subtree into a reusable component (an instance takes its place, render-identical); stamp more instances via batch_design ({ type: "instance", componentId, overrides: { "": {...} } } — overrides match children by NAME). copy_nodes copies subtrees across canvases with fresh ids + an idMap, carrying referenced component defs along — the app shell is built once and copied everywhere, never re-authored node-by-node. @@ -102,12 +102,12 @@ 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; reference tokens with $name (e.g. fill: "$surface").', + '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").', '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.', 'One canvas per screen / state; let the per-project build log nudge you to vary structure.', - 'Data screens: design every state — canvas_add_variant for "empty" / "loading" ("error" for forms) + the empty-state / skeleton-table / skeleton-stat-card scaffolds (coverage warns until they exist) — and run canvas_stress before presenting (fix clips/overflows with fluid widths, then re-run until CLEAN).', + 'Data screens: design every state — canvas_add_variant for "empty" / "loading" ("error" for forms) + the empty-state / skeleton-table / skeleton-stat-card scaffolds (coverage warns until they exist) — and run canvas_stress before presenting (fix clips/overflows with fluid widths, then re-run until CLEAN — a FRAGILE run keeps canvas_evaluate NOT READY until a full CLEAN run).', 'Usability gates like contrast does: a control under the 24px hit-target floor is an ERROR (WCAG 2.5.8; a lone control inherits its padded row\'s box), an unlabeled control is a warning, "Click here"-style copy is an info. The stock scaffolds already pass — hand-built controls are where these bite.', 'Derive the system before drawing: generate_design_system (seed + personality → the full design language: colors, font pairing, type roles, radii, $elevation depth, $motion) is the headline call; generate_scale / generate_color_system regenerate just the scale or just the palette — then reference $tokens everywhere and let the lint re-attach any literal that drifts.', 'Multi-screen module? Finish as a SET: project_evaluate rolls up per-screen scores + cross-screen drift (radius / accent / token adoption / copied chrome / states) — advisory, the per-canvas directives still gate; mode "llm" adds the flow critique. Compose bento/editorial layouts with real grid (layout "grid" + spans), never nested flex.', @@ -121,19 +121,19 @@ const GOTCHAS = [ 'Controls: toggle / checkbox / radio / select are real node types with checked / disabled / value, token-styled — never assemble them from frames + ellipses.', 'Bulk edits & queries: replace_matching_properties changes a property across every node matching a value predicate in one call (scope/type filters; dryRun previews the match set); find_nodes is the read-only twin — locate nodes by property/text/name and get ids + paths instead of guessing from read_nodes trees. canvas_autofix apply: true writes the whole mechanical fix set in one call.', 'Component scaffolds: apply_structure with kind "component" structures (data-table, form-field, toolbar, stat-card, toggle-row — plus the Phase 28 dashboard micro-patterns: kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar, all on the tint pair) + targetId stamps reusable fragments with re-keyed IDs — build tables/forms from these, not node-by-node.', - 'Charts: the chart node type is data-driven ({ kind: "line"|"bar"|"donut"|"sparkline", series: [{ data, stroke, highlight?, barGradient?, … }], segments? for donuts, gridlines?, xLabels? }) — it does the value→coordinate math, dashes mark projected vs actual, highlight renders the selected bar solid over muted siblings, donuts take segments + centerValue/centerLabel, sparklines are axis-free with the latest point emphasized. Never hand-compute path d-strings, conic-hack a donut, or hand-place sparkline bars.', + 'Charts: the chart node type is data-driven ({ kind: "line"|"bar"|"donut"|"sparkline", series: [{ data, stroke, highlight?, barGradient?, … }], segments? for donuts, gridlines?, xLabels? }) — it does the value→coordinate math, dashes mark projected vs actual, highlight (an index or an array of indices) renders the selected bar solid over muted siblings, donuts take segments + centerValue/centerLabel, sparklines are axis-free with the latest point emphasized. Never hand-compute path d-strings, conic-hack a donut, or hand-place sparkline bars.', 'Shared chrome is a component, not a copy-paste: create_component promotes a built subtree (render-identical; overrides target named children), batch_design I() stamps more instances, copy_nodes carries subtrees + their component defs to sibling canvases. Rebuilding an app shell node-by-node on every canvas is the anti-pattern.', 'canvas_import_html: Tailwind classes map to intent directly (bg-surface → fill "$surface", gap-4 → 16, bg-red-500 → the bundled v4 palette hex) and literal colors snap to the design system — a bare snippet styles via the common utilities + palette; pass the compiled CSS via the css param for everything else. Always read the returned report (snapped/literals/layout/warnings); the import is honest about what it dropped.', 'Imports reconstruct STRUCTURE: tables → proportional columns, grids → rows from the computed template, centered/max-width content stays centered, other multi-column CSS clusters by geometry. Check report.layout — a "stack-fallback" entry names a container that needs hand-fixing; everything else arrived structurally correct, so do not rebuild it.', 'Fonts: a fontFamily named in a typography token loads automatically (Google Fonts, cached locally); typography.body.fontFamily sets the document default. "mono" / "sans" are generic shorthands (render as monospace / sans-serif, no registration). batch_design warns at write time when a family is not yet servable; a "Font warnings" item in a screenshot result means the named face is NOT rendering — fix the name or register it via set_fonts (with a css2 URL, YOUR family label is what gets registered).', - 'Row rules / accent bars: per-side borders — borderTop: { width: 1, color: "$border" } per table row, borderLeft: { width: 3, color: "$primary" } for accent edges; style "dashed"|"dotted" marks forecast/draft; strokeDasharray ("6 4") dashes SVG paths. Never fake hairlines with gap: 1 + fill bleed-through.', + 'Row rules / accent bars: per-side borders — borderTop: { width: 1, color: "$border" } per table row, borderLeft: { width: 3, color: "$primary" } for accent edges; style "dashed"|"dotted" marks forecast/draft; strokeDasharray ("6 4") dashes SVG paths. Never fake hairlines with gap: 1 + fill bleed-through. gap: 0 (flush rows, tab bars) is always on-scale.', '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).', 'import_design_md reliably imports spacing + component skeletons; set colors / typography / radius explicitly via set_variables.', 'Binding (canvas_bind, or init on first run) re-keys the workspace + every project ID to repo-* form (canvas IDs are kept) — retarget from canvas_bind\'s idMap or the IDs init returns, never cache pre-bind workspace/project IDs.', 'Point-and-tell feedback: the user clicks elements in the viewer (Comment mode) to leave node-anchored or whole-page comments, stored on the canvas at metadata.feedback. get_feedback returns them (with a node snapshot; orphaned: true = the node is gone but the concern likely still applies); open feedback blocks presenting, same as open inspector comments — address each item, then resolve_feedback with a one-line note of what changed (shown as your reply in the viewer\'s Feedback tab). canvas_list rows and canvas_evaluate results carry an openFeedback count (and the evaluate directive stays blocking) while comments are open.', 'Cliché tells (canvas_evaluate "cliche" category): avoid default purple/indigo accents, gradient/glow overuse, fake window chrome, fabricated metrics, slop copy (filler verbs / scroll cues / "Jane Doe" / hype labels), an eyebrow above every section (keep to ~1 per 3 sections), mixed radius systems (one radius scale), pure black/white (use off-black/off-white), and competing accents (one accent hue + neutrals; the $success/$warning/$danger status vocabulary is exempt when referenced as tokens). The tells KNOW dashboard vocabulary (Phase 28): violets referenced via \$chart-*/\$*-tint tokens, token-referenced gradients, thin rounded sparkline bars, and uppercase KPI labels beside big tabular figures are all sanctioned — the literal versions still flag.', 'Genre calibration: declare the genre the screen actually IS — stamp it durably with canvas_set_genre (no token churn; null clears), or pass genre: "dashboard" (alias "data") per call on canvas_evaluate/canvas_autofix. "dashboard" stops a data-dense screen\'s own realistic figures from flagging as fabricated; "commerce" (alias "checkout") does the same for the money on a TRANSACTIONAL screen — cart, checkout, order confirmation, billing history, where the line prices and the total are the design; "material" allows purple + white surfaces. Genre follows what the screen is FOR, not what it contains: read screens with published figures → "dashboard"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material". The evaluate result\'s genre field audits the choice ({ active, source, relaxed, notRelaxed }) — a score pinned by tells in notRelaxed means the genre is probably wrong. Matching an existing app\'s type scale? Declare the sizes as typography tokens — pinned sizes skip the adjacent-ratio check. Never use genre to dodge flags on a marketing page — a PRICING page is marketing, not "commerce".', - '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.', + '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.', '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.', @@ -168,16 +168,18 @@ server.resource( // --- canvas_create --- server.tool( 'canvas_create', - 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, 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.', + '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).', { 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)'), }, - async ({ name, projectId }) => { + async ({ name, projectId, width, height }) => { 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); + const canvas = createCanvas(name, projectId ?? DEFAULT_PROJECT_ID, { width, height }); const viewerUrl = getViewerUrl(); return { content: [ @@ -188,6 +190,8 @@ server.tool( rootId: canvas.root.id, name: canvas.name, projectId: canvas.projectId, + width: canvas.root.width, + height: canvas.root.height, viewerUrl: viewerUrl ? `${viewerUrl}/canvas/${canvas.id}` : null, galleryUrl: viewerUrl, diversification: diversificationFor(canvas.projectId), @@ -537,9 +541,9 @@ 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, 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, responsive -Charts: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", series: [{ data: [210, 450, 648], stroke: "$accent", strokeDasharray?: "6 4", area?, points?, highlight?: [2], 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. +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. Borders: stroke + strokeWidth draw all four sides (strokeStyle "solid"|"dashed"|"dotted", default solid — a dashed outline is the forecast/placeholder convention). Per-side borders take an object: borderTop: { width: 1, color: "$border", style?: "solid"|"dashed"|"dotted" } — use these for table row rules (borderTop on each row) and accent edges (borderLeft: { width: 3, color: "$primary" }), NEVER a gap-1-with-fill-bleed hack. Paths dash via strokeDasharray: "6 4" (or [6, 4]). @@ -784,8 +788,8 @@ server.tool( { canvasId: z.string().describe('Canvas ID'), nodeId: z.string().optional().describe('Specific node ID to screenshot (defaults to full canvas)'), - width: z.number().optional().describe('Viewport width in pixels (default 1440)'), - height: z.number().optional().describe('Viewport height in pixels (default 900)'), + width: z.number().optional().describe('Viewport width in pixels (default: the canvas artboard width)'), + height: z.number().optional().describe('Viewport height in pixels (default: the canvas artboard height)'), fullPage: z.boolean().optional().describe('Capture the whole design rather than one viewport. A canvas taller than its artboard is otherwise cut off at the artboard height, and the only way to see the rest was to edit the root height by hand. Default false, so existing captures are unchanged.'), scale: z.number().optional().describe('Device scale factor (default 2 for retina)'), theme: z.enum(['light', 'dark']).optional().describe('Render theme — "dark" applies the design system\'s dark token layer (dark.colors/dark.elevation overrides); default light. No-op when no dark layer exists.'), @@ -860,7 +864,7 @@ server.tool( const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; const html = renderToHtml(resolved, w, h, canvas, renderOpts); - const layout = await computeLayout(html, nodeId, maxDepth); + const layout = await computeLayout(html, nodeId, maxDepth, { width: w, height: h }); return { content: [{ type: 'text', text: JSON.stringify(layout, null, 2) }, ...fontWarningContent(fontWarnings)] }; } @@ -1300,8 +1304,8 @@ server.tool( format: z.enum(['png', 'jpeg', 'webp', 'pdf']).describe('Export format'), outputPath: z.string().describe('Directory path to save exported files'), nodeIds: z.array(z.string()).optional().describe('Specific node IDs to export (exports each separately). Defaults to full canvas.'), - width: z.number().optional().describe('Viewport width in pixels (default 1440)'), - height: z.number().optional().describe('Viewport height in pixels (default 900)'), + width: z.number().optional().describe('Viewport width in pixels (default: the canvas artboard width)'), + height: z.number().optional().describe('Viewport height in pixels (default: the canvas artboard height)'), scale: z.number().optional().describe('Device scale factor (default 2 for retina)'), theme: z.enum(['light', 'dark']).optional().describe('Render theme — "dark" applies the design system\'s dark token layer (dark.colors/dark.elevation overrides); default light. No-op when no dark layer exists.'), fullPage: z.boolean().optional().describe('Capture the whole design rather than one viewport — the same option screenshot takes. A canvas taller than its artboard is otherwise cut off at the artboard height. Default false, so existing exports are unchanged. Ignored for PDF (which paginates) and when nodeIds are given.'), @@ -1388,8 +1392,8 @@ server.tool( { canvasId1: z.string().describe('First canvas ID'), canvasId2: z.string().describe('Second canvas ID'), - width: z.number().optional().describe('Viewport width (default 1440)'), - height: z.number().optional().describe('Viewport height (default 900)'), + width: z.number().optional().describe('Viewport width (default: the first canvas\'s artboard width)'), + height: z.number().optional().describe('Viewport height (default: the first canvas\'s artboard height)'), scale: z.number().optional().describe('Device scale factor (default 1 for diff accuracy)'), }, async ({ canvasId1, canvasId2, width, height, scale }) => { @@ -1398,8 +1402,8 @@ server.tool( const canvas2 = getCanvas(canvasId2); if (!canvas2) return { content: [{ type: 'text', text: `Error: Canvas "${canvasId2}" not found` }], isError: true }; - const w = width ?? 1440; - const h = height ?? 900; + const w = width ?? (typeof canvas1.root.width === 'number' ? canvas1.root.width : 1440); + const h = height ?? (typeof canvas1.root.height === 'number' ? canvas1.root.height : 900); const s = scale ?? 1; const prep1 = await prepareRender(canvas1); @@ -1989,9 +1993,9 @@ server.tool( 'canvas_evaluate', `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are warning/info severity — but EVERY tell blocks the directive regardless of severity. The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width artboards additionally get a 44px touch-first info), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node whose name resolves in neither bundled set — it would render as an empty slot, invisible in a screenshot: the suggestion names the spellings that do resolve (hyphen/underscore swap, or the missing material: prefix), and when exactly one does — or several do but only one matches the icon set the rest of the canvas uses — the issue carries a fix canvas_autofix applies. Icons inside component instances are checked too; they point at the component definition and carry no fix op. Modes: - "fast": JSON-only, <100ms, deterministic heuristics only. - - "detailed": adds Puppeteer-based pixel overlap detection in the consistency category. + - "detailed": adds Puppeteer-based pixel overlap detection in the consistency category, laid out at the canvas's artboard size. - "llm": fast-mode heuristics plus a vision-model critique against a FIXED rubric (provider picked from FRAMESMITH_LLM_PROVIDER env var, or whichever of ANTHROPIC_API_KEY / OPENAI_API_KEY is set). Adds an "llmCritique" field: { rubric: { hierarchy, execution, specificity, restraint, variety } each {score 1-5, rationale}, score (0-100 derived), summary, suggestions, needsRevision, failingAxes }. The verdict is stamped on the canvas (metadata.critique) + the per-project build log for auditability. Cost: one paid API call per invocation. To CLOSE the loop and auto-fix failing axes, use canvas_revise. -Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. +Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, current }) appears once canvas_stress has recorded a verdict for this canvas — current says whether that run was on the canvas's present versionHash. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when current is false the directive says to re-run stress to confirm your fix. The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. 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. Data-dense screens: pass genre: "dashboard" 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). @@ -2045,10 +2049,16 @@ The result's "genre" field (present whenever cliche ran) makes the genre decisio // the most consequential sentence framesmith says to an agent, so it is // testable rather than inline here. const openFeedback = openFeedbackCount(canvas); - const { directive } = buildEvaluateDirective(result, openFeedback); + const stressRun = lastStressRun(canvasId); + const stress = stressRun && { + verdict: stressRun.warnings === 0 ? 'CLEAN' : 'FRAGILE', + warnings: stressRun.warnings, + current: stressRun.versionHash === canvasVersionHash(canvas), + }; + const { directive } = buildEvaluateDirective(result, openFeedback, stress); return { - content: [{ type: 'text', text: JSON.stringify({ ...result, ...(llmNote ? { llmNote } : {}), ...(openFeedback > 0 ? { openFeedback } : {}), directive }, null, 2) }], + content: [{ type: 'text', text: JSON.stringify({ ...result, ...(llmNote ? { llmNote } : {}), ...(openFeedback > 0 ? { openFeedback } : {}), ...(stress ? { stress } : {}), directive }, null, 2) }], }; } ); @@ -2126,7 +2136,7 @@ server.tool( Perturbations (default: all): long-text (non-data text ×2.2 plus one long unbroken token — the wrap-breaker), i18n (×1.4 — the German/Finnish expansion rule), big-numbers (data-like text to its widest realistic form: "9" → "999+", "$1.5M" → "$1,520,847.33"), empty (detected tables lose their data rows), many (data rows ×3). Tables are found with the same inventory drift and coverage use. -Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job, or when it is just the page growing taller than its fixed artboard, which scrolls on a real page; WARNING otherwise), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. Chrome required; ~1 render per perturbation.`, +Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job, or when it is just the page growing taller than its fixed artboard, which scrolls on a real page; WARNING otherwise), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. The verdict is remembered for canvas_evaluate: any run with warnings records FRAGILE (the directive stays NOT READY), and only a run of the full default set records CLEAN — a passing subset never clears an earlier FRAGILE. Chrome required; ~1 render per perturbation.`, { canvasId: z.string().describe('Canvas to stress'), perturbations: z.array(z.enum(['long-text', 'i18n', 'big-numbers', 'empty', 'many'])).optional() @@ -2172,6 +2182,8 @@ Finding kinds: clip (content cut off — INFO when a designed text-overflow elli } } + const versionHash = canvasVersionHash(canvas); + recordStressRun(canvasId, { versionHash, warnings }, PERTURBATION_NAMES.every((n) => names.includes(n))); const verdict = warnings === 0 ? `CLEAN — the design held its layout under ${names.length} content perturbation(s).${infos ? ` ${infos} designed truncation(s) engaged (info).` : ''}` : `FRAGILE — ${warnings} layout break(s) under hostile content. Fix with fluid widths / minWidth or minHeight floors / wrapping (see width strategies), then re-run.`; @@ -2180,7 +2192,7 @@ Finding kinds: clip (content cut off — INFO when a designed text-overflow elli content: [ { type: 'text', text: JSON.stringify({ canvasId, - versionHash: canvasVersionHash(canvas), + versionHash, perturbations: perturbationResults, counts: { warnings, infos }, verdict, diff --git a/src/operations.ts b/src/operations.ts index 5bc34fa..6e40c4b 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -29,6 +29,35 @@ export function ignoredShadowWarning(node: SceneNode, props: Record): string | undefined { + const bad: string[] = []; + const visit = (node: Record) => { + if (Array.isArray(node.series)) { + node.series.forEach((s, i) => { + if (!s || typeof s !== 'object' || !('highlight' in s)) return; + const { highlight, data } = s as { highlight: unknown; data?: unknown }; + if (highlight === undefined || highlight === null) return; + const length = Array.isArray(data) ? data.length : Infinity; + const isIndex = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) < length; + const entries = Array.isArray(highlight) ? highlight : [highlight]; + const dropped = entries.filter((v) => !isIndex(v)); + if (dropped.length > 0 || !(Array.isArray(highlight) || typeof highlight === 'number')) { + bad.push(`series[${i}].highlight ${JSON.stringify(dropped.length ? dropped : highlight)}`); + } + }); + } + if (Array.isArray(node.children)) { + for (const child of node.children) if (child && typeof child === 'object') visit(child as Record); + } + }; + visit(props); + if (bad.length === 0) return undefined; + return `No emphasis for ${bad.join(', ')}: highlight takes a data index (2) or an array of indices ([2, 5]) within the series' data — those entries were dropped.`; +} + /** Split an operations string into logical ops (issue #228). A newline ends an * op only at bracket depth 0, so one op may span lines (its inner newlines * become spaces, keeping executeLine's single-line regexes valid). At depth 0 @@ -344,8 +373,9 @@ function executeLine(root: SceneNode, line: string, bindings: Map): OperationResult { - const warning = ignoredShadowWarning(node, props); - return warning ? { ...result, warning } : result; + const warnings = [ignoredShadowWarning(node, props), invalidHighlightWarning(props)].filter(Boolean); + return warnings.length ? { ...result, warning: warnings.join(' ') } : result; } diff --git a/src/renderer.ts b/src/renderer.ts index 454c768..3bec96b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -238,6 +238,14 @@ export function chartGeometry(node: SceneNode, boxW: number, boxH: number): Char }; } +/** Highlight indices: a single finite number is shorthand for `[n]`; anything + * else non-array means no emphasis (batch_design warns — see operations.ts). */ +function chartHighlight(value: unknown): number[] | null { + if (typeof value === 'number' && isFinite(value)) return [value]; + if (!Array.isArray(value)) return null; + return value.filter((n): n is number => typeof n === 'number' && isFinite(n)); +} + /** Sanitized series: finite numbers only, empty series dropped, colors defaulted. */ function chartSeriesData(node: SceneNode): Array<{ data: number[]; stroke: string; strokeWidth: number; dash: string | null; area: boolean; points: boolean; highlight: number[] | null; barGradient: boolean }> { return (node.series ?? []) @@ -249,7 +257,7 @@ function chartSeriesData(node: SceneNode): Array<{ data: number[]; stroke: strin dash: dasharrayValue(s.strokeDasharray), area: s.area === true, points: s.points === true, - highlight: Array.isArray(s.highlight) ? s.highlight.filter((n) => typeof n === 'number') : null, + highlight: chartHighlight(s.highlight), barGradient: s.barGradient === true, })) .filter((s) => s.data.length > 0); diff --git a/src/scene-graph.ts b/src/scene-graph.ts index 955ce20..2259133 100644 --- a/src/scene-graph.ts +++ b/src/scene-graph.ts @@ -127,7 +127,11 @@ export function ingestCanvases(canvases: Canvas[]): void { } } -export function createCanvas(name?: string, projectId: string = DEFAULT_PROJECT_ID): Canvas { +export function createCanvas( + name?: string, + projectId: string = DEFAULT_PROJECT_ID, + size: { width?: number; height?: number } = {}, +): Canvas { const id = nanoid(10); const canvas: Canvas = { id, @@ -137,8 +141,8 @@ export function createCanvas(name?: string, projectId: string = DEFAULT_PROJECT_ type: 'document', name: 'Document', children: [], - width: 1440, - height: 900, + width: size.width ?? 1440, + height: size.height ?? 900, fill: '#FFFFFF', }, variables: {}, diff --git a/src/stress.ts b/src/stress.ts index 9a5848e..e8074f1 100644 --- a/src/stress.ts +++ b/src/stress.ts @@ -249,3 +249,24 @@ export function compareLayouts( return findings; } + +/** The last canvas_stress verdict per canvas, kept for canvas_evaluate's + * directive so "READY TO PRESENT" can't contradict a FRAGILE stress run. In + * memory only: one server process. */ +export interface StressRecord { + versionHash: string; + warnings: number; +} + +const lastStressRuns = new Map(); + +/** A FRAGILE run always counts; only a full run can record CLEAN, so a + * narrower re-run that happens to pass never hides a known break. */ +export function recordStressRun(canvasId: string, record: StressRecord, fullRun: boolean): void { + if (record.warnings === 0 && !fullRun) return; + lastStressRuns.set(canvasId, record); +} + +export function lastStressRun(canvasId: string): StressRecord | undefined { + return lastStressRuns.get(canvasId); +} diff --git a/src/types.ts b/src/types.ts index 6f22491..0d3be7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,8 +25,9 @@ export interface ChartSeries { /** Dot markers on each data point. Lines only. */ points?: boolean; /** Phase 28 — bar emphasis: these data indices render solid; the rest render - * muted (~30% opacity). The reference-dashboard selected-bar treatment. */ - highlight?: number[]; + * muted (~30% opacity). The reference-dashboard selected-bar treatment. + * A single index `i` is shorthand for `[i]`. */ + highlight?: number | number[]; /** Phase 28 — bars fill with a vertical fade of the series color, rendered * inside the SVG (invisible to the gradient-overuse tell by construction). * Highlighted bars stay solid. Bars only. */ diff --git a/test-silent-drops.ts b/test-silent-drops.ts new file mode 100644 index 0000000..70af789 --- /dev/null +++ b/test-silent-drops.ts @@ -0,0 +1,123 @@ +import './test-env.js'; +/** + * Four defects from the 2026-09-07 mobile surface attempt. Each one silently + * dropped or contradicted something the agent wrote: + * + * 1. canvas_create threw away width/height, so no phone artboard was possible. + * 2. gap: 0 warned as off the spacing scale, though gap: null renders the same. + * 3. A scalar chart `highlight: 5` rendered no emphasis and said nothing. + * 4. canvas_evaluate said READY TO PRESENT while canvas_stress was FRAGILE. + * + * Run with: npx tsx test-silent-drops.ts + */ +import { createCanvas, getCanvas } from './src/scene-graph.js'; +import { parseAndExecute } from './src/operations.js'; +import { evaluateCanvas } from './src/evaluate.js'; +import { renderToHtml } from './src/renderer.js'; +import { buildEvaluateDirective } from './src/directive.js'; +import { lastStressRun, recordStressRun } from './src/stress.js'; +import type { EvaluationResult } from './src/evaluate.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +// ── 1. canvas_create honours width/height ──────────────────────────────────── +{ + const phone = createCanvas('phone', undefined, { width: 390, height: 844 }); + const readBack = getCanvas(phone.id)!; + check('a 390×844 canvas reads back 390×844', readBack.root.width === 390 && readBack.root.height === 844, + `${readBack.root.width}×${readBack.root.height}`); + + const wide = createCanvas('default'); + check('omitted size keeps the 1440×900 default', wide.root.width === 1440 && wide.root.height === 900); + + const tall = createCanvas('width only', undefined, { width: 390 }); + check('an omitted height keeps its default', tall.root.width === 390 && tall.root.height === 900); +} + +// ── 2. gap: 0 is on-scale ──────────────────────────────────────────────────── +{ + const canvas = createCanvas('flush'); + parseAndExecute(canvas.root, ` +list=I("document", {type:"frame", width:390, layout:"vertical", gap:0}) +odd=I("document", {type:"frame", width:390, layout:"vertical", gap:5})`, canvas); + canvas.variables = { spacing: { sm: 4, md: 8, lg: 16 } } as never; + const r = await evaluateCanvas(canvas, { mode: 'fast', categories: ['spacing'] }); + const gapIssues = r.issues.filter((i) => /^gap:/.test(i.message)); + check('gap: 0 raises no spacing issue', !gapIssues.some((i) => /gap: 0px/.test(i.message)), + gapIssues.map((i) => i.message).join('; ')); + check('gap: 5 still warns', gapIssues.some((i) => /gap: 5px/.test(i.message))); +} + +// ── 3. scalar chart highlight ──────────────────────────────────────────────── +{ + const chart = (highlight: unknown) => { + const canvas = createCanvas('chart'); + parseAndExecute(canvas.root, `c=I("document", {type:"chart", kind:"bar", width:400, height:200, series:[{data:[1,2,3,4,5,6,7], highlight:${JSON.stringify(highlight)}}]})`, canvas); + return { canvas, html: renderToHtml(canvas.root, 400, 200, canvas) }; + }; + const scalar = chart(5); + const array = chart([5]); + const none = chart([]); + check('highlight: 5 renders the same chart as highlight: [5]', strip(scalar.html) === strip(array.html)); + check('...and differs from no emphasis', strip(scalar.html) !== strip(none.html)); + + const canvas = createCanvas('bad highlight'); + const [bad] = parseAndExecute(canvas.root, `c=I("document", {type:"chart", kind:"bar", series:[{data:[1,2,3], highlight:"5"}]})`, canvas); + check('highlight: "5" succeeds with a warning', bad.ok && /series\[0\]\.highlight/.test(bad.warning ?? ''), bad.warning); + const [badEntry] = parseAndExecute(canvas.root, `U("${bad.nodeId}", {series:[{data:[1,2,3], highlight:[1, "2"]}]})`, canvas); + check('an array with a non-number entry warns too', /series\[0\]\.highlight/.test(badEntry.warning ?? ''), badEntry.warning); + const [good] = parseAndExecute(canvas.root, `U("${bad.nodeId}", {series:[{data:[1,2,3], highlight:2}]})`, canvas); + check('a valid scalar highlight carries no warning', good.ok && !good.warning, good.warning); + const [cleared] = parseAndExecute(canvas.root, `U("${bad.nodeId}", {series:[{data:[1,2,3], highlight:null}]})`, canvas); + check('highlight: null (clearing it) carries no warning', cleared.ok && !cleared.warning, cleared.warning); + const [outOfRange] = parseAndExecute(canvas.root, `U("${bad.nodeId}", {series:[{data:[1,2,3], highlight:[1, 7]}]})`, canvas); + check('an index past the data warns', /\[7\]/.test(outOfRange.warning ?? ''), outOfRange.warning); + const [fraction] = parseAndExecute(canvas.root, `U("${bad.nodeId}", {series:[{data:[1,2,3], highlight:0.7}]})`, canvas); + check('a fractional index warns', /0\.7/.test(fraction.warning ?? ''), fraction.warning); + const [nested] = parseAndExecute(canvas.root, `I("document", {type:"frame", children:[{type:"chart", kind:"bar", series:[{data:[1,2], highlight:"x"}]}]})`, canvas); + check('a chart nested in inserted children is checked too', /series\[0\]\.highlight/.test(nested.warning ?? ''), nested.warning); +} + +// ── 4. evaluate knows the last stress verdict ──────────────────────────────── +{ + const fake = (issues: Array<{ category: string; severity: string }> = []): EvaluationResult => + ({ overallScore: 99, categories: [], issues, stats: {}, mode: 'fast' } as unknown as EvaluationResult); + + const fragileNow = buildEvaluateDirective(fake(), 0, { warnings: 19, current: true }); + check('FRAGILE stress on this version → NOT READY', !fragileNow.ready && /^NOT READY/.test(fragileNow.directive), + fragileNow.directive.slice(0, 120)); + check('...and the directive names the stress breaks', /19 layout break/.test(fragileNow.directive)); + + const withIssues = buildEvaluateDirective(fake([{ category: 'spacing', severity: 'warning' }]), 0, { warnings: 3, current: true }); + check('blocking issues + FRAGILE stress → both named', !withIssues.ready && /1 issue/.test(withIssues.directive) && /3 layout break/.test(withIssues.directive)); + + const stale = buildEvaluateDirective(fake(), 0, { warnings: 4, current: false }); + check('FRAGILE on an earlier version → still NOT READY (an edit is not a fix)', !stale.ready && /^NOT READY/.test(stale.directive)); + check('...and the directive says to re-run canvas_stress to confirm', /re-run canvas_stress to confirm/.test(stale.directive)); + check('READY never sits next to a stress caveat', !/READY TO PRESENT[^]*canvas_stress/.test(stale.directive)); + + const clean = buildEvaluateDirective(fake(), 0, { warnings: 0, current: true }); + const noRun = buildEvaluateDirective(fake()); + check('CLEAN stress → same directive as no stress run', clean.ready && clean.directive === noRun.directive); + + recordStressRun('c1', { versionHash: 'sha256r:a', warnings: 2 }, true); + recordStressRun('c1', { versionHash: 'sha256r:a', warnings: 0 }, false); + check('a passing SUBSET run never clears a FRAGILE record', lastStressRun('c1')?.warnings === 2); + recordStressRun('c1', { versionHash: 'sha256r:b', warnings: 1 }, false); + check('a subset run that breaks records FRAGILE', lastStressRun('c1')?.versionHash === 'sha256r:b' && lastStressRun('c1')?.warnings === 1); + recordStressRun('c1', { versionHash: 'sha256r:b', warnings: 0 }, true); + check('a full CLEAN run clears it', lastStressRun('c1')?.warnings === 0); + check('no run recorded → undefined', lastStressRun('never') === undefined); +} + +/** Node ids are random per canvas; compare the markup without them. */ +function strip(html: string): string { + return html.replace(/data-node-id="[^"]*"/g, '').replace(/id="[^"]*"/g, ''); +} + +console.log(allPass ? '\nALL PASS' : '\nSOME FAILED'); +process.exit(allPass ? 0 : 1);