How to design with framesmith so the result holds up across breakpoints.
Canvases live inside a Workspace > Project > Canvas hierarchy. The built-in Personal workspace + Untitled project always exist as a default home; create more once you're running multiple projects.
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_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 forcanvas_delete(permanent) only when sure.
workspace_delete and project_delete refuse to remove non-empty containers. Clear the contents first or canvas_move them out.
- Author desktop-first at one design width. Pick a width (1200 or 1440 is typical), compose the design there.
- Adapt down with
responsivehints + fluid widths. The renderer derives the mobile/tablet layout from the same scene graph — you don't author it twice.
Pick the right width per node — this is the single biggest lever for responsive quality.
| Use | When | Example |
|---|---|---|
Fixed pixels (width: 360) |
Icons, badges, small chips, fixed UI elements that should not reflow | Avatar width: 40, badge width: 80 |
Percentage string (width: "50%") |
Column splits inside a parent — child should be a fraction of available row | Two-column hero, sidebar + main |
Fluid + cap (width: "100%", maxWidth: 600) |
Content that should fill the row on narrow viewports but cap on wide screens | Article body, dashboard cards |
Floor (width: "50%", minWidth: 240) |
Column splits where the child has a minimum readable size | Card grid where 50% would otherwise become unreadably narrow |
width: "fit-content" |
Hugs its content; lets the parent's gap and alignItems do the spacing |
Buttons, pills, link-style text |
Height floor (minHeight instead of height) |
A card or row whose content can grow under hostile-length text | A stat card that must hold its rest-state rhythm without clipping a long value |
Default to fluid. Reach for fixed pixel widths only when the content genuinely shouldn't scale.
Artboard height is a viewport, not a limit. The root's height sizes the default screenshot capture, but content taller than it is normal — it scrolls on a real page, and a clip finding from canvas_stress for exactly that case is info, not a problem to fix. To actually see past the artboard (a long page, a design that grew under stress content), pass fullPage: true to screenshot — or to export, which takes the same option — instead of editing the root height by hand.
Set responsive on container nodes (frames with layout: "horizontal" or layout: "grid" and children). The renderer emits the right media-query rules — flex-wrap for horizontal containers, a single-column collapse with spans reset for grid.
| Hint | Effect | Use when |
|---|---|---|
responsive: "stack" |
Horizontal container flips to vertical below 768px; a grid container collapses to one column (spans reset) |
Multi-column rows that should become a single column on mobile. This is the most common case — almost every card row, hero with side-by-side panels, footer link group |
responsive: "wrap" |
Children wrap to the next line instead of overflowing | Tag clouds, badge groups, card grids that can have an irregular last row |
Pricing tiers (3 cards side-by-side → single column on mobile):
row=I("document", { type: "frame", layout: "horizontal", gap: 24, responsive: "stack" })
c1=I(row, { type: "frame", width: "100%", maxWidth: 360, padding: 32, fill: "#0F172A", cornerRadius: 16 })
// ...c2, c3 the same shapeTwo-column hero (text + image, stacks below 768px):
hero=I("document", { type: "frame", layout: "horizontal", gap: 48, responsive: "stack", alignItems: "center" })
text=I(hero, { type: "frame", width: "50%", layout: "vertical", gap: 16 })
img=I(hero, { type: "image", src: "...", width: "50%" })Tag list (wraps to next line as the row narrows):
tags=I("document", { type: "frame", layout: "horizontal", gap: 8, responsive: "wrap" })
// each tag: width: "fit-content", padding: [4, 12], cornerRadius: 999Bento grid (real CSS grid — spans make the asymmetric rhythm; stacks on mobile):
grid=I("document", { type: "frame", layout: "grid", gridColumns: 4, gap: 24, responsive: "stack" })
hero=I(grid, { type: "frame", gridColumn: 3, height: 280, width: "100%", padding: 24, fill: "$bg-surface", cornerRadius: 16 })
side=I(grid, { type: "frame", height: 280, width: "100%", padding: 24, fill: "$bg-surface", cornerRadius: 16 })
// ...three more tiles; a gridColumn: 2 tile closes the second rowgridColumns takes a count, an array of fr weights / lengths ([2, 1, "240px"]), or a template string; a numeric gridColumn/gridRow means "span N". Prefer grid over nested-flex approximation whenever tiles span columns — the bento-grid structure stamps this shape ready-made.
Toolbar (never reflows):
bar=I("document", { type: "frame", layout: "horizontal", gap: 16, padding: 16, alignItems: "center" })The chart node owns the value→coordinate math — give it data and domains, never hand-computed path d strings or absolutely-positioned tick labels:
- One node per chart, multi-series inside.
series: [{ data, stroke, strokeDasharray?, area?, points? }]— the 4-series pace-to-goal chart is one node with a shared scale. - Dash is the convention for projected vs actual. Solid accent line +
strokeDasharray: "6 4"reference/forecast lines — don't differentiate similar lines by colour alone. - 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/yDomaindefault 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%); addbarGradient: trueand 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.highlightoverrides). Never hand-place 4px bars.
- Three fixed-pixel cards in a horizontal row.
width: 360× 3 in a row withoutresponsive: "stack"clips on a 390px mobile viewport instead of reflowing. Either setresponsive: "stack"or usewidth: "100%", maxWidth: 360. - Setting
marginon every node. Usegapon the parent flex container andpaddingon the child. The renderer doesn't surfacemargin. - Setting
fontFamilyon every text node. The renderer defaults to a system sans-serif stack at the body level. Only setfontFamilywhen you want a different face — and prefersystem-ui, -apple-system, sans-serif-style stacks; if you need quoted multi-word names ('Segoe UI'), they're supported, but keep them inside the double-quoted value. - Hardcoding pixel font sizes everywhere. Large sizes get a
clamp()treatment by the renderer to scale down on small viewports — only setfontSizeto the desktop value and let the renderer handle the rest. - Unicode glyphs as icon stand-ins.
✓ ● ▾ ○read as unfinished. Use theiconnode type — two sets render by name as inline SVG: 1,900+ Lucide (icon: "check") and 3,800+ Material Symbols (icon: "material:check",iconStyle: "outlined" | "rounded" | "sharp",-fillsuffix for filled variants).I("parent", { type: "icon", icon: "material:check", iconSize: 16, iconColor: "$primary" }). For Material-style design systems, prefer the Material set. - Baking uppercase into
content. UsetextTransform: "uppercase"(withletterSpacingfor tracking) so the underlying copy stays editable and case-styling lives in the design, not the data. - Faking form controls from frames + ellipses.
toggle,checkbox,radio, andselectare real node types:I("parent", { type: "toggle", checked: true }). They style themselves from$accent/$border/$bg-surfacetokens (neutral fallbacks when unthemed) and stay pixel-consistent;fill/stroke/coloroverride. - Faking loading states from plain gray rectangles. Use
{ type: "skeleton" }— token-derived fill, correct default radius, and it pulses in the live viewer (always static in screenshots/exports so diffs stay deterministic). Stampskeleton-table/skeleton-card/skeleton-stat-cardinstead of hand-placing bars — and skeleton EVERY data surface: the coverage check flags a loading variant that leaves live metric values beside skeletoned regions (a half-designed loading screen flashes real-looking data next to placeholders). - Hand-building app furniture node by node. A data table, form field, toolbar, stat card, or settings row is one
apply_structurestamp: component-kind structures insert under anytargetIdwith re-keyed IDs and return anidMapfor populating. Stampdata-table, fill the placeholders, copy rows withC()— don't place ~90 nodes by hand.
Design tokens (colors, spacing, radius, typography) can live at three levels — workspace, project, and canvas. At render time the renderer merges them with the rightmost layer winning:
workspace.designSystem ──┐
├─→ merged tokens used to resolve $name references
project.designSystem ──┤
│
canvas.variables ──┘ (override layer)
Authoring rules:
- Reach for workspace tokens before hex codes. If you're working inside the
Coideworkspace, set the brand palette once viaworkspace_set_design_system({ workspaceId, variables: { colors: { primary: "..." } } }). Every canvas under that workspace can then referencefill: "$primary"and resolve to the brand value — no per-canvas redefinition. - Project layer is for sub-brand overrides. A
Coide → Marketingproject might overrideprimarywith the marketing accent while inheriting everything else from the workspace. - Canvas-level variables are escape hatches, not the primary surface. Use them when one canvas legitimately diverges from the design system; otherwise leave them empty and let the workspace tokens flow through.
- Presets work at every layer.
workspace_apply_preset({ workspaceId, preset: "dark" })copies the dark-preset tokens into the workspace;project_apply_presetand the existingapply_preset(canvas-level) do the same at their respective layers. - Start the look with one call —
generate_design_system.generate_design_system({ seed: "#0E7490", personality: "technical", workspaceId })writes the complete design language: the color system, a curated font pairing (real faces load from the first screenshot), typography roles ($display/$heading/$title/$body/$label/$caption, plus$figureswith a mono face), a radius and density stance,$elevation.flat|raised|floating|overlaydepth tokens (the dark layer re-states each depth so it reads on dark surfaces — reference asshadow: "$elevation.raised"), and$motiondefaults. The personality is required and is a real design stance:technical(crisp product tool),editorial(serif voice, generous air),soft(rounded, warm),data-dense(instrument-panel density). Same seed, different personality = visibly different product. The two generators below regenerate just one part. - Don't hand-pick a type scale — generate one.
generate_scale({ ratio: "major-third", workspaceId })derives a fulltext-xs…text-3xlscale plus a pairedspace-3xs…space-3xlscale from a named ratio (minor-second1.125,major-second1.2,minor-third1.25,major-third1.333,perfect-fourth1.5,golden1.618 — or any number), with the craft defaults (line-height bands, display tracking) baked in and sizes pinned against the ratio check. Passfluid: {}for Utopia-styleclamp()sizes that interpolate between viewports instead of static px. - Don't eyeball a palette from ten hexes — generate one.
generate_color_system({ seed: "#2563EB", workspaceId })derives OKLCHprimary-50…900+ a matched neutral ramp + status colors + thebg-surface/text-primary/accent/… semantic tokens the structures already speak, with every text/surface pair AA-clean by construction. Status colors (success/warning/danger) are tuned as TEXT — each is darkened from its hue's band until it clears AA (4.5:1) against white, so a$dangervalidation message is legible, not just legible-adjacent. It also derives the color range — a$chart-1…$chart-6categorical series palette and the-tintlayer for chips/tiles/badges — see "Designing with character" below. - It ships a dark theme too. The dark mapping lands in a sparse
dark.colorsoverride layer alongside the light tokens (or set one by hand viaset_variables, which also acceptsdark.elevationfor depth-token overrides) — status colors get a second, dark-specific pass here so they clear AA against the dark surface as well. Render it withtheme: "dark"onscreenshot/screenshot_responsive/export;canvas_evaluate'scolorcategory then contrast-checks both themes automatically and scores the worse one — dark-run failures point at the dark token layer (a node literal would break light). WCAG 2.2 is the gate; APCA Lc values appear as info-only advisories on perceptually weak pairs (APCA is a candidate method, not a standard — it never blocks). - Give motion the same treatment as color and type. Declare
motiontokens viaset_variables({ fast: { duration: 150, easing: "ease-out" } }) and reference them astransition: "$motion.<name>"— ad-hoc duration/easing pairs sprawling across a canvas with no declared motion tokens get an advisory.
Merge semantics are per-category: a project that only sets colors doesn't reset the workspace's spacing/radius/typography. A canvas that only overrides colors.primary keeps every other workspace color.
A design language is a set of COMMITTED choices, and generate_design_system makes them for you — your job is to spend them well.
Pick the personality like a genre, not a mood. technical for developer tools, admin panels, and B2B products (crisp geometry, tight tracking, quick motion). editorial for marketing and content (a serif display voice, generous air, near-sharp corners). soft for consumer products and onboarding (rounded, warm, springy). data-dense for dashboards, tables, and monitoring (a 13px pivot, a mono $figures role for numbers, near-flat depth). Same seed, different personality = a visibly different product — so choose deliberately and stay with it across the project.
Spend the accent in one confident moment per screen. The generated system keeps the accent AA-safe everywhere, but discipline is what makes it read as designed: one primary action, one active nav item, one focal chart series. If the accent appears in more than a few places, everything reads as equally important, which is the same as nothing being important. (The accent-consistency tell catches competing hues; restraint within ONE hue is on you.) Status colours are exempt when referenced as tokens: $success / $warning / $danger are the system's status vocabulary, not competing accents, so a screen can show savings in green, a destructive action in red and a low-stock note in amber without flagging (the same literals still count — nothing declares a raw #22c55e to be a status colour).
Use elevation as meaning, not decoration. $elevation.flat for resting cards, raised for the one surface that should come forward (the active card, a dropdown), floating for menus and popovers, overlay for modals. A screen where everything is raised is flat again. The dark layer re-states each depth automatically — never hand-tune shadows per theme.
Let the roles do the typography. $title for the screen title, $heading for section heads, $body for prose, $label for control and form labels, $caption for metadata, $figures (when the personality ships one) for numbers that deserve tabular alignment. The roles carry the personality's face, weight, and tracking — a screen that references roles re-voices itself when the personality changes; a screen full of literal font sizes doesn't.
Stamp the micro-patterns, don't hand-build them. kpi-card, status-chip, segmented-control, breadcrumb, and initials-avatar are component structures — one stamp each, already on the tint pair and the chart vocabulary, customized via the returned idMap. The dashboard structure's stat cards ARE kpi-cards.
Chips, tiles, and pills use the tint pair. Every soft-colored surface — a status chip, a KPI card's icon tile, a pill badge, an initials avatar — is fill: "$success-tint" (or accent-tint / warning-tint / danger-tint / neutral-tint) with its BASE color as the ink: color: "$success". The pair is AA by construction in both themes; hand-mixed pastel + hex ink combinations are exactly the thing this layer exists to end. Chart series and legend dots take $chart-1…$chart-6 — hue-walked from the seed, distinct, and safe on both surfaces. The cliché tells recognize this vocabulary: a violet referenced through $chart-* or $*-tint never flags (a literal violet still does), and uppercase KPI labels beside big tabular figures don't count as eyebrows — the evaluator knows a metric label from a section eyebrow.
Motion comes from tokens. transition: "$motion.fast" for hover/focus feedback, "$motion.base" for state changes, "$motion.slow" for surfaces entering/leaving. The personality sets the temperament; scattering literal millisecond values breaks it.
Shared chrome is a component, not a copy-paste. When the same chunk exists (or is about to exist) twice — an app shell, a stat card, a table row — promote it and instance it instead of re-authoring:
- Build it once, name the parts you'll vary (
name: "Title",name: "ActiveNav"). create_component({ canvasId, nodeId })— the subtree becomes a component and aninstancereplaces it, render-identical. The result'soverridableChildrenlists what you can override.- Stamp more copies:
I("parent", { type: "instance", componentId, overrides: { "Title": { content: "Settings" } } })— overrides match def children by name; instance-level props (width, opacity) override the def root. - Cross-canvas:
copy_nodes({ fromCanvasId, nodeIds: [instanceId], toCanvasId })— the component def travels with the copy, so 15 sibling screens share one shell definition.
canvas_evaluate's "no component instances found" advisory is the nudge; these two tools are the action. Current sharp edge: batch_design ops address the tree, so they can't edit a def after promotion — a def child's id no longer resolves (those nodes left the tree), and U(instanceId, ...) sets instance-level props, not the def. To revise a component: build the new version as a plain subtree, create_component it, point instances at the new componentId with U(), and delete the scaffold.
Name the family and it loads. Set fontFamily in a typography token (or on a node) and the renderer resolves it from Google Fonts automatically — at token-write time and again as a render-time backstop. Binaries are cached under ~/.framesmith/fonts/, so after the first resolve, rendering is offline and deterministic. A family that can't be resolved degrades to the system fallback stack with a warning in the tool result — if a screenshot reports a font warning, act on it; the render is not showing the face you asked for.
// This is the whole happy path — no set_fonts call needed:
workspace_set_design_system({
workspaceId,
variables: { typography: { body: { fontSize: 16, fontFamily: 'Inter' }, code: { fontSize: 13, fontFamily: 'JetBrains Mono' } } },
});typography.body.fontFamilyis the document default (alias:base). Text nodes without an explicitfontFamilyrender in it — set it once at the workspace and the whole canvas follows."mono"and"sans"are generic shorthands. They render as CSSmonospace/sans-serif— no registration, no network, never warned. Upgrade one to a real face anytime:set_fonts({ fonts: [{ family: "mono", url: "<JetBrains Mono css2 URL>" }] })— the label you pass is what gets registered, so existingfontFamily: "mono"nodes pick up the real face with no edits.set_fontsis for everything else: non-Google sources (direct.woff2/.ttfbinary URLs,data:URIs), explicit registration by name (families: ["Inter"]), or pasting a Google Fonts stylesheet URL (fonts.googleapis.com/css2?...— faces are extracted automatically and registered under yourfamilylabel; the result'saliasedfield shows the mapping).- Typos surface at write time.
batch_designwarns when a call writes afontFamilythat is neither cached, registered, nor a system/generic family — don't wait three renders to notice"JetBrans Mono". font-display: swapis automatic. Paint isn't blocked on slow fonts.- System families never resolve (
system-ui,Roboto,Arial, …) — they're already on every render's fallback stack. Only the first non-system family of a stack is loaded.
A screen that already ships doesn't need redrawing — canvas_import_html (snippet + optional CSS) and canvas_import_url (live page) turn it into an editable, token-mapped canvas, and canvas_sync_from_url pixel-diffs the canvas against the live page later to catch drift.
The report is the contract. Imports are lossy by design; what makes them trustworthy is that they say exactly what happened. After every import, read:
report.snapped— values rewritten to$tokenrefs (Tailwind class intent likebg-surface, plus nearest-color matches against your design system).literalslists colors that found no token; near-ties are reported and left literal, never guessed.scaleMatchesnotes numbers that equal a scale token (gap 16 ≙$md) — informational, since number-typed props can't hold refs.report.layout— how each container's structure was reconstructed:table(rows of proportional columns),grid(rows from the computed track template),centered(auto-margin/max-width content kept centered at its real width),geometry(multi-column CSS clustered from bounding boxes). Astack-fallbackentry is your to-do list: that one container looked multi-column but couldn't be reconstructed confidently — fix it by hand; everything else arrived structurally correct, so don't rebuild imported tables or grids node-by-node.report.warnings/unmatchedFonts/unmatchedIcons— dropped background images, truncations, fonts and SVGs that didn't resolve.
Practical notes:
- A bare Tailwind snippet has no Tailwind runtime. The intent mapper covers the common utilities + the bundled v4 palette; pass the compiled stylesheet via
cssfor everything else. Live URLs always have real CSS, so this only matters for pasted snippets. - Token snapping defaults to the target project's merged design system — import into the right project and
tokenMatchneeds no configuration. Passtailwind: { theme }to map custom utility names. - Auth for gated pages (
auth.headers/cookies) lives in a throwaway browser context and is never persisted. - After importing:
screenshotto review fidelity, fix what the report flagged, then the canvas is the design-of-record — the next section is how it stays one.
A canvas that describes a shipped screen is a contract, and contracts rot silently: the code evolves, nobody re-compares, and one day the canvas shows a column that no longer exists or a radio group that shipped as a select. If approved canvases can silently stop describing reality, "approved" stops meaning anything. Three tools keep the contract falsifiable — split by the question they answer:
| Question | Tool |
|---|---|
| What structurally diverged? | canvas_check_drift — findings in words: missing-in-page, missing-in-canvas, control-mismatch, table-mismatch |
| How much does it look different? | canvas_sync_from_url — pixel diff + changePercent |
| Is a recorded approval still true? | canvas_version — content versionHash + expectedHash check |
Before designing on an existing canvas that describes a shipped view, run canvas_check_drift against the live route. Designing on a drifted canvas means faithfully restyling a fiction. On findings, reconcile deliberately — one of three moves, never a fourth:
- The page is right → update the canvas (
batch_design, or re-import the changed region withcanvas_import_url). - The canvas is right → flag the implementation gap to the user; don't "fix" the canvas to match a bug.
- Unclear which → surface the difference and ask.
Never silently annotate the difference away — that's how a select shipped against an approved radio group and the approval stayed green.
Approvals bind to a hash, not a name. Record { canvasId, versionHash } (from canvas_version or any canvas_list row) wherever your approval records live — a YAML file, a PR comment; the workflow is yours. The hash covers design content only (tree + tokens + components + fonts), so feedback arriving, critique stamps, and provenance changes never invalidate an approval — only an actual design change does. Check later with expectedHash, or headlessly from a hook:
npx framesmith verify kpi-revenue-driver-control --hash sha256:8014d416f924378b --project-dir .Make CI demand the comparison — drift that waits for a human to notice is drift that ships. A post-deploy job per route ↔ canvas pair:
npx framesmith check-drift admin-stream-types --url "$STAGING_URL/admin/stream_types" --project-dir . --jsonExit codes are gate-friendly for both commands: 0 pass, 1 drift/mismatch, 2 error. verify is Chrome-free (pre-commit fast); check-drift needs Chrome and takes --viewport WxH / --selector / --wait-for for JS-rendered pages. Gated pages (auth) stay on the MCP tool — credentials don't belong in shell history.
canvas_evaluatescores the design on 8 categories (spacing, color, typography, structure, consistency, cliche, coverage, usability) and surfaces actionable issues withnodeIdreferences — see "Design every state" below for whatcoveragedemands. Theusabilitycategory holds the mechanical UX floors beyond contrast: a control below the 24px hit-target floor is an error (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass), an unlabeled control is a warning, and vague action copy ("Click here") is an info. Controls also render with accent-derived:focus-visiblerings in the live viewer — screenshots stay byte-identical because nothing is focused in a static capture. Use it in a generator-evaluator loop:batch_design→canvas_evaluate→ fix the returned nodeIds.canvas_autofixrunscanvas_evaluateinternally and returns just the subset of issues that have a mechanically derived fix — off-scale spacing snaps to scale (gap, scalar padding, and array padding via a whole-array snap), missing layout becomesvertical, recoverable WCAG contrast failures get#000or#FFFbased on background luminance. Passapply: trueto write every fix in the same call (the result reports applied/failed per op), or run the returned ops viabatch_designyourself — either way, re-evaluate after. Closes the loop without judgment calls on your part.canvas_evaluatewithmode: "llm"runs fast-mode heuristics plus a vision-model critique against a fixed rubric (Claude or GPT-4.1, picked from env). Returns the heuristic result with an extrallmCritiquefield: five axes — hierarchy, execution, specificity, restraint, variety — each scored 1–5 with a rationale, plus a derived overall,summary,suggestions, andneedsRevision/failingAxes(any axis below thefloor, default 3). The verdict is stamped on the canvas + build log so quality is auditable over time. Use this for the "is this visually well-designed?" question heuristics can't answer — composition, hierarchy, polish. Costs one API call per run; reach for it after the heuristic score plateaus.canvas_revisecloses the loop: it judges, and for any failing axis asks the model for targetedbatch_designops, applies them, and re-judges — up tomaxIterationspasses (1–3). It mutates the canvas, reverts any pass that doesn't improve the overall, and stops on pass / cap / no-improvement. Opt-in and costly (≥2 API calls per pass); reach for it when you want the model to act on its own critique instead of you hand-translating it.screenshot_responsiverenders the same scene at mobile / tablet / desktop. Inspect all three; ifresponsivehints are set correctly the mobile layout will look right with no extra work.snapshot_layoutreturns computed bounding boxes — useful for asserting alignment or detecting overflow programmatically.- The human can point. The user leaves comments by toggling Comment mode in the viewer and clicking any element — no prose archaeology about which card they meant.
get_feedbackreturns those comments (each with a node snapshot — type / name / text — so you can act without extra lookups;orphaned: truemeans the node is gone but the concern likely still applies to its replacement). Check it whenever you pick up an existing canvas — feedback may have arrived while you were away, and the running server picks up viewer-written comments automatically. Open feedback blocks presenting, exactly like open inspector comments: address every item, then close each withresolve_feedbackand a one-line note saying what changed — the note is your reply to the user, shown in the viewer's Feedback tab. You won't miss waiting comments:canvas_listrows andcanvas_evaluateresults carry anopenFeedbackcount (the evaluate directive stays blocking while any are open), andinitreports the workspace total at session start.
The happy path of one static frame with ideal data is half a design. Real UX lives in the empty table, the loading skeleton, the failed form — and in the name that's too long for its cell. framesmith makes both halves enforceable.
States are sibling canvases, one call each. canvas_add_variant({ canvasId, state }) clones the screen into a linked variant (<base> · empty), returning an idMap so you can immediately target the cloned nodes: delete the data rows, stamp the matching scaffold, adjust copy. The scaffolds make each state a stamp, not a rebuild:
| State | Scaffold | Notes |
|---|---|---|
empty |
empty-state |
Icon + title + one-line hint + CTA — an empty screen is a first-run experience, never a bare void |
loading |
skeleton-table / skeleton-card / skeleton-stat-card |
Real header + skeleton blocks matching the loaded geometry, so content lands without layout shift. Skeletons pulse only in the live viewer — screenshots stay static |
error |
— | The form's validation-failure treatment: field messages + a summary. Design it; there's no generic scaffold because error copy is the design |
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 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.
A product is a set of screens, and two whole classes of quality only exist at that level. When a multi-screen module feels done, review it as one:
project_evaluate({ projectId }) rolls up per-screen scores (with designed/missing states and token adoption) plus the cross-screen findings no single canvas can see:
| Finding | What it means | The fix path |
|---|---|---|
radius-drift |
One screen runs a different corner-radius system than the rest | Align to the shared scale, or declare radius tokens |
accent-drift |
One screen's accent hue sits far from the project's dominant one | Point it at the shared $accent (or generate_color_system once at the workspace) |
token-adoption |
One screen styles by hand while the rest reference tokens | The consistency lint lists the exact literals; canvas_autofix re-attaches unique matches |
copied-chrome |
The same shell hand-copied across screens instead of component instances | create_component on one, stamp instances, copy_nodes to siblings |
state-coverage |
The aggregated missing empty/loading/error table | canvas_add_variant + the state scaffolds, one screen at a time |
This roll-up is advisory — it never gates. Only the per-canvas directives (score, coverage, feedback) block presenting; the roll-up reviews coherence and names its evidence (which canvases, which values) so a human can overrule any finding at a glance.
mode: "llm" adds the flow critique: up to 8 screens rendered and judged together against four flow axes — navigation-consistency, terminology-consistency, state-visibility, hierarchy-consistency — with per-screen notes naming the canvas. Screens past the cap land in flowSkipped (pass canvasIds to pick the flow); without an API key the full heuristic roll-up still returns with a flowNote. One paid multi-image call per invocation, same cost posture as canvas_evaluate's llm mode.
The rhythm for a module: design each screen to its own READY directive → design the states → stress the data screens → then project_evaluate before calling the set done.
The bar is "designers say wow," not "competent." The cliché tells below are the don'ts; these are the do's. Apply them up front — the evaluator is the safety net, not the plan.
- Start from a pattern, don't start blank.
list_structures→apply_structurestamps a taste-vetted page scaffold (every one is regression-tested to > 95 with zero cliché tells across themes, built from type-role tokens rather than literal pixel sizes, and stress-hardened to survive hostile-length content). Adapt it — swap copy, set$tokens, vary the structure — rather than inventing layout from nothing. A blank canvas is where slop comes from. - Use the whole toolkit — a real UI uses these, so your design must too. Icons (
{ type: "icon", icon: "search" }— Lucide ormaterial:), fonts by name, real controls (toggle/checkbox/radio/select), components, and$tokens. Never fake them (no Unicode-glyph icons, no ellipse "toggles") and never omit them where a real UI has them: nav rows get a leading icon, metrics get an icon, feature lists get check icons, empty states get a glyph, forms use real controls. Starting from a pattern gives you all of this — don't strip it out. - One focal point per screen. Decide what the eye hits first (usually the headline or the primary action) and make everything else quieter. Two competing focal points = no focal point.
- Build real hierarchy. Size, weight, and color should encode importance in steps you can see — a display heading, a clearly smaller subhead, body, then muted captions. Avoid near-equal sizes (a 16→15→14 ramp reads as one blurry tier); aim for ~1.2–1.6× jumps.
- Keep one type scale and one spacing scale. Pick a small set of sizes and a spacing rhythm (e.g. 8 / 16 / 24 / 32 / 48) and reuse them. Off-scale one-offs are the most common craft tell.
- Let it breathe. Generous, consistent padding and whitespace read as designed; cramped, uneven spacing reads as machine-filled. Restraint beats density.
- One accent, flat color, no effects. A single accent hue plus neutrals. Flat
$surfacefills over gradients; a subtle near-black shadow over any glow. Off-black/off-white over pure#000/#fff. - Honest content. Labeled placeholders (
"Metric — to confirm") over invented numbers, names, or logos.
Polishing the design to the bar is your job, not the user's — they should never have to point out a missing icon, an off-scale gap, or a low score. So the loop is part of designing, not an afterthought:
canvas_evaluatethe canvas.- Resolve every warning and every cliché tell —
canvas_autofixfor the mechanical subset (spacing/contrast/known-default accent),batch_designfor the rest. (Cliché tells are info/warning but they're the slop signal — always fix them.) Pure advisories like "consider extracting components" are optional refinements that don't block. - Re-run
canvas_evaluate. - 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.
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.)
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. 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).
canvas_evaluate scores craft (contrast, scale, structure) and a cliche category — the visual tells that read as machine-made. The bar is "designers say wow," not "competent": flat color and restraint beat effects. Steer away from these before you draw; the evaluator is the safety net, not the plan.
| Tell | What flags | Do this instead |
|---|---|---|
| Default purple / indigo accent | An accent (button, stroke, icon, accent text) in the indigo→violet band — especially the Tailwind defaults #6366f1 / #8b5cf6 / #7c3aed |
Pick an accent that fits the brand — a considered blue, green, or warm hue. Set it once as a $accent token. |
| Gradient / glow overuse | 3+ gradient nodes, or a colored glow/bloom shadow (large blur + a saturated or translucent-white color) | Flat $surface fills. Reserve a gradient for at most one deliberate focal moment; use a subtle near-black low-alpha shadow, not a halo. |
| Fake browser / OS chrome | A row of ≥3 small circular dots (mac traffic lights) wrapping content | Frame the content directly. Skip the fake window — it adds nothing and dates the mockup. |
| Hanging eyebrow header | A small eyebrow/tag beside a large heading in a horizontal row | Stack the eyebrow above the heading (layout: "vertical", left-aligned). |
| Fabricated content | Invented metrics / testimonials / brand logos in placeholder copy ("99.9% uptime", "— Jane Doe, CEO", "TechCrunch") |
Use a labeled placeholder until real data exists: "Uptime — to confirm" + a neutral block. Don't ship invented numbers. Exception: on a dashboard/analytics mockup the realistic figures ARE the design — pass genre: "dashboard" (alias "data") so they aren't flagged; on a transactional screen (cart, checkout, order confirmation, billing history) the money is the design — pass genre: "commerce" (alias "checkout"). A pricing page gets neither: its numbers are claims. |
| Eyebrow rhythm | More eyebrow labels than ~1 per 3 sections — an eyebrow above nearly every heading. An eyebrow is small text that is uppercase (via textTransform or typed in capitals) or tracked at letterSpacing ≥ 1 |
Keep eyebrows rare (≤ ceil(sections / 3)). Let most headings stand alone; reserve the eyebrow for sections that genuinely need a kicker. A form label with the light tracking a type role sets (0.25–0.6) is not an eyebrow and doesn't count. |
| Slop copy | Stock AI phrasing in short copy — filler verbs ("Elevate", "Seamless", "Unleash"), scroll cues ("Scroll to explore"), placeholder names ("Jane Doe"), hype labels ("BETA", "Early access"), section-number eyebrows ("01 / Index") |
Write specific, branded copy that names the concrete benefit. A numeral bound to a unit noun by a tight hyphen is a compound modifier, not an eyebrow — "30-day returns", "2-year warranty" and "24/7 support" are ordinary product copy and pass. |
| Radius consistency | 4+ distinct corner radii across the page — no single radius system (full pills, cornerRadius: 999+, are a shape choice and never count toward the census) |
Consolidate to one small radius scale (e.g. 8 / 12, plus 999 for pills). Define it as a $token and reuse. |
| Pure black / white | #000000 ink (text / icon / stroke / fill) or a #ffffff page background |
Use a designed off-black (#0A0A0A) for ink and an off-white (#FAFAFA) for the page surface. |
| Accent consistency | 3+ competing saturated accent hues (excludes neutrals + the page background) | Pick one accent hue — plus neutrals and at most one status color. Set it once as $accent. |
clicheis advisory — tells arewarning/info, never a hard error; they dent the score, they don't block.canvas_autofixfixes the mechanical ones — it swaps a known-default purple accent, deletes a fake-chrome strip, and softens pure black (#000000) ink to off-black. Gradient/glow, the hanging header, fabricated copy, eyebrow rhythm, slop copy, mixed radius systems, and competing accents carry a suggestion but no op (taste/judgment calls).- Genre relaxes intentional tells — stamp the canvas once with
canvas_set_genre(no token churn;nullclears; the stamp never moves theversionHash, so approvals stay valid), or passgenreper evaluate/autofix call, so a style that legitimately uses a tell isn't nagged.genre: "material"allows the purple accent and white elevated surfaces (both are intentional in Material Design);genre: "dashboard"(alias"data") allows realistic figures on data-dense product screens, andgenre: "commerce"(alias"checkout") allows the money on a transactional screen — cart, checkout, order confirmation, billing history (both relaxhonest-content). Declare the genre the screen actually is — don't use it to dodge flags on a marketing page. - Genre follows what the screen is FOR, not what it contains. "It shows figures →
dashboard" is the intuitive heuristic and it's wrong: a targets editor stampeddashboardclears one figure tell while leaving the white-surface tells thatmaterialwould have relaxed. Read screens presenting published figures →dashboard/data; a screen you complete a purchase on →commerce/checkout; editors and admin forms →material. A pricing page is marketing, not commerce: its numbers are claims about a product, not the arithmetic of a transaction, so it keeps the fabricated-content gate. The evaluate result'sgenrefield makes this checkable: it reports the active genre, its source (explicitparam vsprovenancestamp), the tells itrelaxed, andnotRelaxed([{ tell, relaxedBy }]) — tells still flagging that a different genre would relax. If your score is pinned by tells listed innotRelaxed, you likely declared the wrong genre. (honest-contentis effectively the ceiling for any financial UI — declaredashboardon read screens,commerceon transactional ones, or the design's own figures cap the score.)
A few operational details that aren't obvious from the tool schemas:
- Scope to a repo with
init(orcanvas_bind) — binding re-keys IDs. Binding rewrites every project/canvas ID torepo-*form, so IDs captured before the bind stop resolving.initbinds and returns the fresh IDs in one call (prefer it); after a barecanvas_bind, re-list withproject_list/canvas_list. - Same change across many nodes? Use
replace_matching_properties, not NU()ops. It applies onesetto every node matching a property/value predicate (AND across keys; token refs like"$surface"match literally), withscope(subtree) andtypefilters. Preview wide matches withdryRun: truefirst — a common value likewidth: 150can match more nodes than intended (making a 17-row table fill its container is one call, not 68U()ops). - Record
batch_design'snodeIdsmap.batch_designreturns{ ok, nodeIds, results }wherenodeIdsmaps each bound variable (header=I(...)) to the node ID it created. Bindings only live within a single call, so keep that map and target the real IDs in later calls rather than re-deriving them. Lost track anyway?find_nodesrecovers ids by property/text/name with a readable path per match — use it instead of eyeballingread_nodestrees; editing a guessed id is how the wrong node gets restyled. - Matching an existing app's type scale? Pin it with typography tokens. The type-scale check flags adjacent sizes at a ratio below 1.1, which a deliberately dense scale (14/13/12/11) trips by design. Declare those sizes as typography tokens (
set_variables) — a pair where both sizes are token-declared is pinned and skips the ratio check; undeclared one-offs still flag. Declaring the scale is the intentionality signal. - Spacing variety is measured against your declared scale, not a flat count. The
spacingcategory 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/headingroles — asoft-personality section head at 25px or adata-denseone at 19px still reads as a heading. Canvases without declared roles fall back to the fixed 14px / 28px constants. - Typography
$tokensresolve the FULL spec. AfontSize: "$heading"reference applies the token's size and itsfontWeight/fontFamily/lineHeight/letterSpacing— explicit node props always win, so overrides behave as expected. (Before v1.12 only.fontSizeresolved; 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 withgap: 0;borderLeft: { width: 3, color: "$primary" }marks the active row.style: "dashed" | "dotted"(andstrokeStylefor the all-sidesstroke) is the convention for forecast/placeholder/draft;strokeDasharray: "6 4"dashes SVG paths — the projected-vs-actual convention in charts. Never simulate hairlines withgap: 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: [...] }andshadows: [{ 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 beatsshadow(singular). Writingshadowon a node that already hasshadowsdoes nothing at render time — the plural form wins.batch_designandreplace_matching_propertiesflag this with awarningin the op result; when you see it, put the value inshadows(array or CSS string) or clearshadowsfirst.import_design_mdis best-effort. It reads tokens per heading section in list / table /name: valueform (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 withset_variables. It honors explicit named spacing values and only synthesizes a scale from a statedBase unit:— it won't fabricate one otherwise.- Approvals should record a
versionHash, not just a canvas name. A name says which design was approved; the hash says which version of it — see "Keeping the design honest (gate integrity)" above for the full workflow,canvas_versionshape, and CLI recipes. apply_preset,generate_design_system, andgenerate_color_systemall respect an inherited design system, canvas scope only. A token the canvas already resolves through the workspace/project layers is kept rather than silently overwritten. Typography preservation is field-wise: a partially-specified inherited token (say{ fontSize: 13 }) is merged with the generated role rather than shadowing it whole, so the personality's font/weight/tracking still land —preservedFromDesignSystementries carry afilledFromPresetlist naming what the preset contributed. If the kept token lands on a name the generator itself owns (the surface/ink/border/accent semantics, the status colours and their paired-tintlayer, the$chart-*range, and the type roles), it's split out intodesignSystemConflictsinstead, withwhy/fix— that's two design languages on one screen, not a routine preservation, and it's worth reading even when the score is fine. Take the generated value explicitly withset_variables, or passpreserveInherited: falseon the two generators to write the new language whole and skip preservation entirely.- A literal that happens to match a token is flagged, not silently trusted.
canvas_evaluate'sconsistencycategory catches a rawfill/stroke/color/cornerRadiusvalue that exactly equals a token's — it's drift-in-waiting since the literal won't move when the token does.canvas_autofixre-points it to$tokenwhen the match is unique; a value shared by multiple tokens is reported with candidates and left for you to pick — it never guesses.