figura-cli 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figura-cli",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "Command-line client for the Figura visualization SaaS",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  name: fig
3
3
  description: "Mock up a screen as an on-brand, shareable HTML fig via your Figura team — render a UI state in seconds before writing the real code. Use when asked to visualize a screen, mock a UI, or 'make a fig'. iPhone-framed by default; browser frame for web/desktop."
4
4
  argument-hint: '<what to visualize>'
5
- allowed-tools: 'mcp__figura__figura_whoami, mcp__figura__figura_fig_scaffold, mcp__figura__figura_fig_devices, mcp__figura__figura_list_figs, mcp__figura__figura_generate_fig, mcp__figura__figura_compose_fig, mcp__figura__figura_create_fig, mcp__figura__figura_revise_fig, mcp__figura__figura_publish_fig, mcp__figura__figura_commit_fig, mcp__figura__figura_request_feedback, Bash(figura *), Bash(bunx figura-cli *)'
5
+ allowed-tools: 'mcp__figura__figura_whoami, mcp__figura__figura_fig_scaffold, mcp__figura__figura_fig_devices, mcp__figura__figura_list_figs, mcp__figura__figura_generate_fig, mcp__figura__figura_compose_fig, mcp__figura__figura_create_fig, mcp__figura__figura_revise_fig, mcp__figura__figura_upload_asset, mcp__figura__figura_publish_fig, mcp__figura__figura_commit_fig, mcp__figura__figura_merge_figs, mcp__figura__figura_request_feedback, Bash(figura *), Bash(bunx figura-cli *)'
6
6
  ---
7
7
 
8
8
  # /fig — figure it out before you build it
@@ -19,6 +19,28 @@ place (your team's Figura brand profile) and is injected server-side. That is th
19
19
  whole point: a fig is on-brand *by construction*, and this skill can never drift
20
20
  from your brand. To change the brand, use the `figura-brand` skill, not this one.
21
21
 
22
+ ## Hard rules — read before anything else
23
+
24
+ 1. **When you author the markup, a phone fig ⇒ `figura_compose_fig`. Always.
25
+ No exceptions.** The phone is the only frame the composer builds, and every
26
+ framed phone screen goes through it. **Web/desktop (browser frame) ⇒
27
+ `figura_create_fig`**, following the scaffold's `--platform web` frame
28
+ contract verbatim — markup only, the scaffold owns the CSS.
29
+ **Tablet has no authoring path** (#869): say so and stop.
30
+ (`figura_generate_fig` authors nothing locally — step 3 governs it.)
31
+ 2. **The frame is Figura's, never yours.** Bezel, notch/Dynamic Island, status
32
+ bar, home bar, safe insets, artboard geometry and scale are server-owned.
33
+ You author screen CONTENT only.
34
+ 3. **`figura_create_fig` is for web/`document`/exotic figs only.** Never for a
35
+ phone fig — this is a rule, not a preference. No `compose` on this engine? Say
36
+ so and stop; the caller upgrades. A hand-framed phone is not the fallback.
37
+ 4. **Self-check before you land a phone fig.** Search your own markup for these;
38
+ any hit means you drew the frame — discard it and rebuild via
39
+ `figura_compose_fig`:
40
+ `.fig-device` wrapper · `--fig-safe-*` · notch / Dynamic Island / bezel /
41
+ home-bar elements · a status-bar row · a phone-shaped `border-radius`
42
+ container · a hand-written device width/height.
43
+
22
44
  ## When to use
23
45
 
24
46
  - Asked to mock a screen, visualize a UI state, or "make a fig".
@@ -37,8 +59,22 @@ something in a vacuum — and end up rebuilding what already ships. So every tim
37
59
  matching route). If a real implementation exists, **Read it** and treat it as the
38
60
  baseline you must show.
39
61
  - **Check the team's figs** — `figura fig list --area <area>` (or
40
- `figura_list_figs`). If a fig of this screen already exists, **`fig revise` it**
41
- (a new version in the same chain), never a fresh duplicate.
62
+ `figura_list_figs`). If a fig of this screen already exists, **iterate that
63
+ fig** (step 5), never a fresh duplicate.
64
+
65
+ ★ **The chain is the unit — and Figura now enforces it.** A create that looks
66
+ like the next round of an open question is **refused** (409 `similar_chain`) and
67
+ the response names the chain to revise. That is not an obstacle to route around:
68
+ each round you land as its own fig is a card in the library nobody can walk, and
69
+ a reviewer who opens round 4 sees neither rounds 1–3 nor the discussion held on
70
+ them. Revise the named fig. `new: true` (CLI `--new`) exists for a genuinely
71
+ different question — not for "revising felt like more work".
72
+
73
+ **Already forked it?** `figura_merge_figs` (CLI `figura fig merge <id> --into
74
+ <id>`) folds the sibling back in: its versions append after the destination's
75
+ head, share links keep resolving, verdicts and decisions follow. Run it with
76
+ `dry_run` first to see the plan. Merging your own fork mid-session is normal
77
+ housekeeping, not an admission of anything.
42
78
 
43
79
  If any prior design exists → build in **`mode: compare`** and render it as
44
80
  **existing (as-is) → proposed**, grounded in the real current state. Even when the
@@ -105,7 +141,7 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
105
141
  to the user verbatim (a one-line "figura-mcp is behind / below min — run …"),
106
142
  then proceed. It's a passive nudge, shown once per session — never a blocker.
107
143
 
108
- 2. **Default — write the CONTENT, let Figura own the STRUCTURE.** Build device
144
+ 2. **Default — write the CONTENT, let Figura own the STRUCTURE.** Build phone
109
145
  figs with **`figura_compose_fig`**: you author only each **screen's CONTENT**
110
146
  (the markup *inside* the phone); Figura owns the parts that used to drift — the
111
147
  iPhone **frame** (bezel + Dynamic Island/notch + home bar), the **safe zone**,
@@ -130,18 +166,35 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
130
166
  detail in; never for a redesign argument), or `document` (NO device
131
167
  framing — brand/overview figs).
132
168
  - **`screens`** — an ARRAY. Each screen's **`html` is CONTENT ONLY**: the
133
- markup that goes *inside* the phone. Do **NOT** draw a frame, bezel, status
134
- bar, or safe padding; do **NOT** set `--fig-safe-*`; do **NOT** wrap it in
135
- `.fig-device`. For every device layout each screen also carries the typed
136
- intent Figura needs — **`device`** (a real catalog id from
169
+ markup that goes *inside* the phone (hard rule 2). For every device layout
170
+ each screen also carries the typed intent Figura needs **`device`** (a
171
+ real catalog id from
137
172
  `figura_fig_devices`, e.g. `iphone-16`), **`orientation`**
138
173
  (`portrait`/`landscape`), and **`safeZone`** (`respect` = Figura pads your
139
174
  content to the device's real safe insets; `ignore` = edge-to-edge for a
140
175
  splash/hero). (`document` screens need none of these.)
176
+ - **`childFig`** (instead of that screen's `html`) — compose the frame BY
177
+ REFERENCE: the id of an existing fig composed with `kind: 'child'`, which
178
+ Figura embeds in its own iframe. `single` and `compare` only. Reach for it
179
+ when two screens fight over the same CSS (each child's styles are sealed in
180
+ its own document and cannot reach the frame or the other screen), or when a
181
+ screen deserves its own versions, reactions and comments. Compose each child
182
+ first, then pass their ids, and give the screen the SAME `device` and
183
+ `orientation` the child was composed with — the frame draws that phone's
184
+ chrome around the child's own safe-area guides, so a mismatch is refused.
185
+ The reference is PINNED: a later revision of a child does not change a
186
+ composition that already exists. A composed-by-reference parent is revised
187
+ by **typed screens, never by HTML** — see step 5. Publishing the parent is
188
+ what makes its children reachable at the public link; a child fig has no
189
+ share link of its own. A referenced screen still takes `notes` on `compare`
190
+ — the rail and its pins are the parent's markup, so they never reach into
191
+ the child.
141
192
  - **`sharedCss`** — ONE stylesheet every screen shares: put your brand tokens
142
193
  (from the scaffold) and shared content classes here. Per-screen one-offs go
143
194
  in that screen's `css`.
144
- - Plus `area`, `title`, `orientation`, and a real `description`.
195
+ - Plus `area`, `title`, `orientation`, and a real `description` — the fig's
196
+ BRIEF, and the reviewer's only view of the thread. Contract: "The brief"
197
+ below.
145
198
 
146
199
  **Small screens are the SERVER's job — with one exception that is yours.**
147
200
  Below a catalog-derived breakpoint every multi-frame layout (`compare`,
@@ -162,11 +215,11 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
162
215
  brand with `bunx figura-cli brand --json` and author your content styles
163
216
  against that.
164
217
 
165
- **`figura_create_fig` is the escape hatch** reach for it only for a
166
- `document`/exotic fig where you must hand-author the entire HTML (or when
167
- `compose` is unavailable on an older engine). It takes a full `html` document
168
- and you own the whole contract yourself. For any device fig, prefer
169
- `compose` — it can't drift.
218
+ **The other path `figura_create_fig`** (hard rule 3): a full `html` document
219
+ you hand-author, for a web/`document`/exotic fig. Fetch the scaffold for the
220
+ platform (`--platform web` for a browser frame) and follow its frame contract
221
+ verbatim; the chrome CSS is stamped at save, so author the markup and none of
222
+ its styles.
170
223
 
171
224
  **Landing IS hosting — leave `publish` and `commit` unset.** Composing a fig
172
225
  always persists it to Figura (there is no separate "save it for real" step);
@@ -220,19 +273,31 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
220
273
  only the served bytes can tell a working fig from one that renders as empty
221
274
  phones. It exits non-zero if a frame class shipped unsized — fix and re-land
222
275
  before a human sees it. Then run
223
- `figura fig open <id>` (the id `figura_create_fig`/`figura_generate_fig` just
224
- returned). It opens **`viewUrl`** in the reviewer's browser — the actual
276
+ `figura fig open <id>` (the id the landing call just returned). It opens
277
+ **`viewUrl`** in the reviewer's browser — the actual
225
278
  Figura render (brand chrome, CSP, favicon, responsive frame), team-scoped so
226
279
  any signed-in teammate can open it even though nothing was published. That's
227
280
  what gets approved, and it's the exact page that ships once published — not a
228
281
  `file://` reconstruction that can drift from it. No display reachable (a
229
282
  headless/CI session)? Paste the printed `viewUrl` into the conversation
230
- instead, or fall back to `figura fig open <id> --local` for a local sandboxed
231
- HTML preview.
232
-
233
- 5. **Iterate against the SAME fig, not a fresh one.** Changes from review go
234
- through **`figura_revise_fig`** (`id`, the updated `html`) — it joins the
235
- existing chain as a new version and returns a fresh `viewUrl`. Publishing is
283
+ instead there is no local-preview fallback; a fig is only viewed on
284
+ figura.so.
285
+
286
+ 5. **Iterate against the SAME fig, not a fresh one.** On the create path, changes
287
+ from review go through **`figura_revise_fig`** (`id`, the updated `html`) — it
288
+ joins the existing chain as a new version and returns a fresh `viewUrl`.
289
+ **A COMPOSED fig revises by typed screens**: call `figura_revise_fig` with
290
+ `layout` + `screens` (the same shape compose takes) instead of `html`, and it
291
+ re-composes into the same chain. Every child reference is carried forward with
292
+ its pin, label, notes and position; an empty screen `{}` keeps a REFERENCED
293
+ slot exactly as it was, so changing one side of a by-reference diptych is
294
+ `screens: [{}, {…}]`. A slot that held INLINE `html` must have its html sent
295
+ again — Figura never reads content back out of a composed document. Passing
296
+ `html` for a composed fig is refused (422) — it would leave the references
297
+ behind. `figura_get_fig` returns the fig's `children` when you need to see the
298
+ current composition. Never hand-author the framed document to feed `revise`
299
+ (hard rule 1). A revision takes its own `description` — restate the arc
300
+ there; whoever reads v4 did not read v1. Publishing is
236
301
  NOT inherited automatically: a revision of an already-published fig still
237
302
  lands unpublished by default (`viewUrl` is a team-scoped view, and the
238
303
  existing public link keeps serving the OLD version) — pass `publish: true`
@@ -255,9 +320,9 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
255
320
  the team (digest-batched) — so at the end of the session, commit exactly
256
321
  the fig the user asked for / the final agreed version, and nothing else.
257
322
  Explorations, rejected variants, and intermediate iterations STAY drafts.
258
- If the deliverable is the fig you're about to create/revise anyway, prefer
259
- `commit: true` on that final `figura_create_fig` / `figura_revise_fig`
260
- call (CLI: `--commit`) over a separate call; otherwise commit it afterwards
323
+ If the deliverable is the fig you're about to land anyway, prefer
324
+ `commit: true` on that final landing call (CLI: `--commit`) over a separate
325
+ call; otherwise commit it afterwards
261
326
  with **`figura_commit_fig`** (CLI: `figura fig commit <id>`). Committed the
262
327
  wrong one? `figura_commit_fig` with `uncommit: true` (CLI:
263
328
  `figura fig uncommit <id>`) returns it to a draft — the share link is
@@ -269,6 +334,8 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
269
334
  **`figura_request_feedback`** (CLI: `figura fig request-feedback <id> --from
270
335
  <name|email> --note <text>`). See "Assign a reviewer" below — this step is
271
336
  what stops a fig from being landed, committed, and never actually read.
337
+ The note names the decision; the fig's `description` carries the context —
338
+ check it against "The brief" below *before* you send the ask.
272
339
 
273
340
  ## Presenting a redesign — FOCUS first, overview second
274
341
 
@@ -280,8 +347,8 @@ per-screen content. You supply each state's CONTENT + a `label`; the composition
280
347
  the focus pills, the scaled overview grid, and the no-horizontal-scroll guarantee
281
348
  are server-owned. The design intent below still guides WHAT each state's content
282
349
  says and which is recommended — you just no longer hand-write the toggle markup
283
- or the frame scaling. (If you're on the `create` escape hatch instead, the same
284
- pattern must be hand-authored plain CSS-only radio tabs, binary, no library:)
350
+ or the frame scaling. (On the `create` path a web fig — the same pattern is
351
+ hand-authored: plain CSS-only radio tabs, binary, no library.)
285
352
 
286
353
  **★ FOCUS IS THE DEFAULT VIEW.** The fig opens on ONE full-size frame with state
287
354
  pills — on desktop AND mobile. Overview is the *alternative*, behind the second
@@ -291,41 +358,102 @@ using it for one shipped a 4-state fig as 134px phones with ~3px annotation
291
358
  chips. A Focus-only fig is **complete on its own**; add Overview only when there
292
359
  are enough states that scanning them at once genuinely helps.
293
360
 
361
+ **★ THE LAST PILL IS YOUR READ — a fig that shows options must pick one.**
362
+ Whenever a fig carries more than one *direction* (A / B / …, or any "which way
363
+ do we go" set), the FINAL state is a **verdict state**, not one more variation:
364
+ the options weighed against each other, and one of them named as the pick.
365
+ Without it a reviewer gets a gallery and no argument, and the decision lands back
366
+ on whoever opened the link — which is the exact thing a fig exists to prevent.
367
+
368
+ The verdict state is one framed screen like any other, labelled `My read`, and
369
+ it carries, tersely:
370
+
371
+ - **the pick, named in the first line** — "Go with A" — never "both have merit";
372
+ - **one line per option** of what it wins and what it costs (`+` / `–`) — the
373
+ same pairs the Overview chips carry, so the two views can't disagree;
374
+ - **the deciding reason**: the one constraint that broke the tie (a component
375
+ that already ships, a cap, a rule the others violate);
376
+ - **what would change your mind**, when the pick is close.
377
+
378
+ Two states of the SAME direction (a before/after, one layout at two
379
+ orientations) need no verdict — there is nothing to weigh. Three directions and
380
+ no verdict pill is an unfinished fig.
381
+
294
382
  - **Focus — full frame (default).** One state at a time behind state pills
295
383
  (Today / A / B / …), each state framed identically so flipping states in place
296
- reads as spot-the-difference. This is where the context lives, pinned on the
297
- frame as annotation chips: what's broken today, what each variation improves,
298
- what it trades away. Open on the first pill = the honest current state.
299
- - **Never put annotation chips inside a clipping box.** `position:absolute`
300
- cannot escape an ancestor's `overflow:hidden`, so a chip that straddles a
301
- rounded-corner frame ships sliced. Hang chips off a positioned wrapper as
302
- siblings of the frame, anchored to grow away from its edge.
384
+ reads as spot-the-difference. This is where the context lives: what's broken
385
+ today, what each variation improves, what it trades away. Open on the first
386
+ pill = the honest current state.
387
+ - **★ Annotation NEVER goes on the design pass `notes`, don't draw chips.**
388
+ A note written into a screen's `html` lands inside the phone screen, on top of
389
+ the layout the fig exists to let someone judge. Measured on a shipped fig
390
+ (`fig_wjgcksh38pix`): 3–4 absolutely-positioned chips per state, every one 100%
391
+ inside the screen box, together covering **12–19% of the artboard** — sitting
392
+ on the masonry grid and hero row that were the whole question — while the page
393
+ around the frame was empty black. So annotation is **data, not markup**: give
394
+ each screen a `notes` array on `figura_compose_fig` and Figura renders a rail
395
+ **beside** the frame, with only a ~20px numbered pin on the artboard (~0.3%
396
+ coverage). Nothing to toggle: the layout and the argument are both fully
397
+ visible at every viewport, and below the restructure breakpoint the rail
398
+ stacks under the frame automatically.
399
+
400
+ ```jsonc
401
+ "notes": [
402
+ // `at` is percent on the SCREEN → a numbered pin there, note N in the rail.
403
+ { "kind": "broken", "text": "Hero is score-picked, so it lands mid-row and manufactures a second full-width row (#2755).", "at": { "x": 34, "y": 52 } },
404
+ { "kind": "improved", "text": "A run IS a collection — a name is always attached.", "at": { "x": 91, "y": 14 } },
405
+ // No `at` → a point about the state as a whole. Numbered, no pin.
406
+ { "kind": "tradeoff", "text": "Costs the shipped vertical-advance gesture." }
407
+ ]
408
+ ```
409
+
410
+ `kind` is `broken` | `improved` | `tradeoff` | `detail` (default `detail`), max 8
411
+ notes a screen, and `compose` **rejects** notes on `single`/`variations`/
412
+ `document` — those layouts have no rail, and silently dropping a note drops
413
+ the argument. If you're hand-authoring via `figura_create_fig` instead, the
414
+ same rule holds by hand: the rail is a **sibling of the frame**, never a
415
+ descendant of the clipped `.fig-screen`, and nothing but a pin sits on the
416
+ design.
417
+ - **Every direction gets the SAME treatment.** If one direction is shown in a
418
+ second orientation, a second state, or a second breakpoint, they ALL are.
419
+ A subset argues from unequal evidence and quietly favours whichever direction
420
+ got the extra look: one fig shipped `Today ↻` and `A ↻` pills while B and
421
+ Fresh had no landscape frame at all, so two directions were judged turned and
422
+ two were judged only upright. **Cover the orientations the product actually
423
+ ships** — on an app that is landscape-native, that means portrait AND
424
+ landscape for every direction, by default, not as an extra. Narrow to one
425
+ orientation only when the work itself is scoped to one: a portrait-only bug,
426
+ or a conversation already about the turn, doesn't need its opposite.
427
+ - **A pill label is plain TEXT.** `label` is a text field, not markup — write
428
+ the character (`·`, `↻`, `—`), never an HTML entity. The same call carries
429
+ each screen's `html`, where `&middot;` is exactly right, so the habit crosses
430
+ over easily; the composer decodes an entity it finds in a label rather than
431
+ shipping it literally, but the label still means text.
303
432
  - **Overview — side by side (alternative).** Every state at once: the honest
304
433
  current state first, then each variation, as scaled-down frames in a grid.
305
434
  Under each: a status chip and ONE condensed context line (`+ / –`). This view
306
435
  is for scanning and picking a direction, not for reading detail. The grid must
307
436
  wrap to one column before it can overflow — **a fig never scrolls
308
- horizontally**, at any viewport. To shrink the framed phones, wrap each in the
309
- kit's **`.fig-mini`** wrapper (see "Scaling a framed device" below), never a
310
- rule on `.fig-device` and never CSS `zoom` (zoom leaks an unzoomed
311
- `scrollWidth` into horizontal overflow — the exact thing this view forbids).
437
+ horizontally**, at any viewport. Frame scaling is the server's (hard rule 2).
312
438
  - **In both views:** render the current state honestly from the shipped
313
439
  implementation — **reconstructed from source** per "Reconstruct the baseline
314
440
  from SOURCE" (no screenshot ⇒ study the component file(s) and rebuild the real
315
- layout, don't approximate); color-code chips by function (broken / improved /
316
- trade-off / detail) using the team's status colors from the fetched design
317
- system; mark the recommended variation and close with a short "my read"
318
- arguing the pick against the others. Keep the chrome around the frames
319
- minimal the frames are the content; no legends (chips label themselves),
320
- no repeated context, fluid `max-width` layout throughout.
441
+ layout, don't approximate); classify every note by function via its `kind`
442
+ (`broken` / `improved` / `tradeoff` / `detail`) rather
443
+ than hand-picking colors — the rail and its pins take the team's status colors
444
+ from the design system, and a `sharedCss` override of `--fig-note-improved` and
445
+ friends re-points all of them at once; and mark the recommended direction in
446
+ both the pick the verdict state argues for wears its chip here too. Keep the
447
+ chrome around the frames minimal — the frames are the content; no legends
448
+ (notes label themselves), no repeated context, fluid `max-width` throughout.
321
449
  - **Verify the SERVED fig, not your local draft.** The component kit (device
322
450
  frame, status bar, control bar) is stamped **server-side at save** — it does
323
451
  **not** exist in the HTML you authored locally, so a headless render of your
324
452
  own file shows *unstyled, empty* frames whether or not the fig is actually
325
453
  correct. A local screenshot is structurally blind to the most common way a
326
454
  framed fig breaks. So verify the fig that actually ships:
327
- 1. **Land it first** (`figura_create_fig` / `figura_revise_fig`), then dump
328
- the stamped bytes: `figura fig open <id> --local --print`. This is the
455
+ 1. **Land it first**, then dump
456
+ the stamped bytes: `figura fig open <id> --print`. This is the
329
457
  served document, kit and all.
330
458
  2. **Assert the kit stamped — `figura fig verify <id>`.** One command, and it
331
459
  **exits non-zero** if any frame class your markup uses shipped with no CSS
@@ -341,15 +469,26 @@ are enough states that scanning them at once genuinely helps.
341
469
  repo's `.figura/figs/` (gitignored), or `~/.figura/figs/` outside a bound
342
470
  repo, never `/tmp`/`mktemp`), and render it at a few viewport widths.
343
471
  `document.scrollWidth` must equal the viewport in every view and state
344
- (sideways scroll = broken), no annotation chip may cover the content it
345
- annotates, and no chip may be cut by a clipping ancestor: walk each chip's
346
- ancestors up to `<html>` and, wherever computed `overflow` is
347
- `hidden`/`clip`, assert the chip's rect stays inside that ancestor's box
348
- the viewport box for `<body>`/`<html>`, whose overflow propagates there.
349
- Comparing a chip to the frame's rect alone misses the vertical escape, and
350
- no rect test sees a rounded clip, which is why the authoring rule above is
351
- the real guarantee. `figura fig open <id> --local` writes the same served
352
- bytes to `<repo>/.figura/figs/figura-fig-<id>.html`.
472
+ (sideways scroll = broken), and **measure how much of the artboard the
473
+ annotation covers**: sum the intersection of every annotation element with
474
+ the `.fig-screen` box and divide by its area. With `notes` that number is
475
+ the pins alone (~0.3%); anything in the double digits means chips got drawn
476
+ into the design again. Also assert nothing is cut by a clipping ancestor:
477
+ walk each element's ancestors up to `<html>` and, wherever computed
478
+ `overflow` is `hidden`/`clip`, assert its rect stays inside that ancestor's
479
+ box the viewport box for `<body>`/`<html>`, whose overflow propagates
480
+ there. Comparing to the frame's rect alone misses the vertical escape, and
481
+ no rect test sees a rounded clip, which is why keeping the rail a sibling
482
+ of the frame is the real guarantee. `figura fig open <id> --print` is where
483
+ those served bytes come from.
484
+
485
+ **A fig composed BY REFERENCE has no printable bytes.** Its screens are served
486
+ per request, so `--print` refuses (non-zero) and names the hosted URL: the
487
+ stored document is references, not a render. Verify it by opening that URL —
488
+ `figura fig open <id>` — and screenshotting the hosted page. Steps 1 and 3
489
+ above assume printable bytes and do not apply; `figura fig verify <id>` still
490
+ reports on the parent's own markup. Its `notes` rail and pins are the
491
+ PARENT's markup, so they render and measure the same way.
353
492
 
354
493
  A **wipe-slider** stays the narrow tool for exactly two states of the same
355
494
  layout with pixel-level tweaks. Never ship a squeezed side-by-side as the
@@ -395,6 +534,54 @@ Example — a GSAP-driven entrance for a mobile screen, pinned + allowlisted:
395
534
  </script>
396
535
  ```
397
536
 
537
+ ## The brief — what the reviewer reads before your fig
538
+
539
+ **`description` is the fig's BRIEF, not a caption.** Figura renders it as the
540
+ **Brief** panel beside the fig, above the discussion: it is the first thing the
541
+ person you assign in step 8 reads, and across a multi-round exercise it is the
542
+ only place the *thread* survives — each round lands as its own fig, and nothing
543
+ in the product links them. The fig argues THIS round. The brief carries what
544
+ happened before it.
545
+
546
+ ★ **Four beats, in this order.**
547
+
548
+ 1. **The objective, in the user's own words.** Quote the sentence that started
549
+ the exercise, verbatim — and re-quote it in every later round. It is the
550
+ standard the fig gets judged against, and the first thing to evaporate when a
551
+ round is written from memory.
552
+ 2. **The arc — one line per prior round: what it tried, and what it settled or
553
+ killed.** Name each round so the reviewer can go and find it.
554
+ 3. **What is still open** — the question this round does *not* close.
555
+ 4. **The pick, and the decision you want back.**
556
+
557
+ ★ **An abandoned direction stays in the brief, with the reason it was
558
+ abandoned.** "Withdrawn", "superseded", "we tried that" are deletions, not
559
+ summaries. A dead route is the most useful thing a reviewer can be handed: it
560
+ stops them re-proposing it, and it is usually where the unresolved tension is
561
+ still sitting. And when a round was killed by *evidence* — a real user rejected
562
+ it, a constraint bit, a measurement came back — that evidence outranks every
563
+ design argument in the brief, and has to survive into the next round's.
564
+
565
+ ★ **Never write a later round's brief from session memory.** Before composing a
566
+ fig that continues, supersedes, or replaces an earlier one, read the earlier
567
+ briefs — `figura_list_revisions` for the chain you're revising, or
568
+ `figura_list_figs` (by `area`/`tag`) then `figura_get_fig` — and carry the
569
+ objective and the arc forward. A long session compresses; the brief must
570
+ not. The prior fig is on the server, so this costs one call and never depends on
571
+ what is still in your context.
572
+
573
+ **Which field is which.** `description` is the brief, on every write path
574
+ (`figura_compose_fig` / `figura_create_fig` / `figura_revise_fig` /
575
+ `figura_update_fig`), and it is carried per version — so **a revision restates
576
+ the arc** instead of assuming the reader saw v1. Cap: 2000 characters. `prompt`
577
+ is a different field: the raw brief/instruction you were handed, kept for
578
+ provenance. `intent` (the `figura:context` meta) is a one-line design intent for
579
+ search and is **truncated at 400 characters** — never park the objective there.
580
+
581
+ **The brief is not the fig.** Don't narrate what the frames already show. If the
582
+ brief and the fig disagree about the recommendation, one of them is stale — fix
583
+ it before you send the ask.
584
+
398
585
  ## Assign a reviewer — a proposal fig isn't done until someone's on it
399
586
 
400
587
  **A link nobody was pointed at is a fig nobody saw.** Landing, publishing, and
@@ -422,7 +609,7 @@ private.
422
609
 
423
610
  ## Metadata & geometry — stamped for you
424
611
 
425
- Every landed fig is **auto-stamped server-side** (on `create` and `generate`)
612
+ Every landed fig is **auto-stamped server-side** (`compose`, `create`, `generate`)
426
613
  with two things you do **not** hand-write:
427
614
 
428
615
  - A **device kit** `<style>` — the target's hard geometry as CSS custom
@@ -435,53 +622,26 @@ with two things you do **not** hand-write:
435
622
  search and the self-improving loop. Artifact-descriptive only — no team/brand
436
623
  ids (the block is served on the public link).
437
624
 
438
- With **`figura_compose_fig`** you do NOT hand-write any of the frame/safe/device
439
- contract below Figura stamps it from your typed params. The rest of this
440
- section applies ONLY to the **`figura_create_fig` escape hatch**, where you
441
- hand-author the whole document: the exact rules are **fetched, not restated
442
- here** the
443
- presentation model (frameless single vs canvas composition), when to emit or
444
- omit the whole-fig `<meta name="figura:artboard">`, the `.fig-device` /
445
- `data-device` frame markup (Figura stamps the bezel, Dynamic Island, and
446
- safe-area insets **before** your `<style>`, so you normally leave those classes
447
- alone — if you do style one, your rule wins by cascade and the frame still
448
- works; to shrink a frame use the `.fig-mini` wrapper, never a hand-written
449
- `.fig-device` width/height/transform), the `figura:context` / `figura:feature`
450
- metas, and the valid devices all live in the canonical scaffold.
451
-
452
- **Scaling a framed device — use `.fig-mini`, never a rule on `.fig-device`.**
453
- An overview grid needs shrunken frames, and the obvious way to write that is a
454
- CSS rule on the injected class. Don't: your rule wins over the kit, so the frame
455
- stops matching real device geometry — and `zoom` is worse, it leaks the unzoomed
456
- width into scrollable overflow. The kit ships the wrapper instead:
457
-
458
- ```html
459
- <div class="fig-mini" style="--fig-scale:.34">
460
- <div class="fig-device portrait" data-device="iphone-16">
461
- <div class="fig-screen">…</div><div class="fig-di"></div><div class="fig-homebar"></div>
462
- </div>
463
- </div>
464
- ```
465
-
466
- `.fig-mini` scales its device with `transform` and takes the **scaled** layout
467
- box, so a row of minis lays out at its visual size instead of reserving
468
- full-size phones. Add `landscape` to the device (or the wrapper) and the box
469
- flips with it. `--fig-scale` defaults to `.34`; set it per element.
625
+ The rest of this section applies ONLY to the **`figura_create_fig`** path (hard
626
+ rule 3), where you hand-author the whole document: the exact rules are
627
+ **fetched, not restated here** the presentation model, the browser frame
628
+ contract, the `figura:artboard` / `figura:context` / `figura:feature` metas all
629
+ live in the canonical scaffold.
470
630
 
471
631
  - **`figura_fig_scaffold`** (CLI: `figura fig scaffold`) is authoritative for the
472
632
  presentation model, the frame markup, and the output contract. Fetch it and
473
633
  follow it verbatim (step 2 above) — do NOT restate design/format rules from
474
634
  memory. A bundled skill can pin stale; the scaffold is always current.
475
- - **`figura_fig_devices`** (CLI: `figura fig devices`) is the full valid-
476
- `data-device` catalogevery framed-device id + its geometry (logical dims,
477
- safe-area insets, cutout, corner radius). Pick a real id from it for a canvas
478
- composition's `<div class="fig-device" data-device="…">`; an unknown id
479
- silently gets no frame.
635
+ - **`figura_fig_devices`** (CLI: `figura fig devices`) is the catalog `compose`
636
+ validates againstpick each screen's **`device`** id from it (an unknown id
637
+ is a 400).
480
638
 
481
639
  ## Notes
482
640
 
483
- - **MCP unavailable** (headless / cron, or no interactive auth): fall back to the
484
- CLI `bunx figura-cli fig create` / `fig publish`, with `fig revise` /
641
+ - **MCP unavailable** (headless / cron, or no interactive auth): the CLI has no
642
+ `compose`, so a phone fig has no CLI path say so and stop (hard rule 3). For
643
+ a web/`document` fig fall back to
644
+ `bunx figura-cli fig create` / `fig publish`, with `fig revise` /
485
645
  `fig revisions` for versions, `fig commit` / `fig uncommit` (or `--commit` on
486
646
  the final create/revise) for the team library, `bunx figura-cli fig scaffold`
487
647
  for the authoring scaffold, and `bunx figura-cli brand --json` for the raw
@@ -71,10 +71,47 @@ Hunt across whatever stack this repo uses — do not assume one framework:
71
71
  `src/routes/*`, `src/screens/*`). These become each platform's `surfaces[]` —
72
72
  THIS product's real areas ("Feed", "Checkout"), never a generic placeholder
73
73
  list and never another product's.
74
+ - **Locales** — which languages the product ships, their script and direction.
75
+ Run `figura brand detect-locales [path]`: an offline scan returning
76
+ `locales` (paste-ready `identity.locales`), `evidence` (why each locale got its
77
+ script/direction), `needsConfirmation`, and the `signals` it fired on. Put the
78
+ DEFAULT language first.
79
+
80
+ Three rules, each from a real miss on a real repo (#998):
81
+
82
+ - **NEVER list a vendor directory's languages.** `node_modules`, `Pods`,
83
+ `dist`, `build`, `.next`, `out`, `vendor`, `DerivedData`, `.git`, `.claude`
84
+ are not this product. A React Native monorepo's `node_modules` alone carries
85
+ 40+ `*.lproj` languages; listing them makes the brand claim languages the
86
+ product does not ship. `detect-locales` drops them itself — if you scan by
87
+ hand instead, exclude every one of those path segments.
88
+ - **Script and direction come from the message TEXT, not the tag.** A repo can
89
+ tag Sorani Kurdish `ku`, which any tag table calls Latin/LTR — SHA7N's
90
+ `locale/ku/general.json` is 4848 Arabic codepoints. Read the locale's message
91
+ files and judge from the characters. Arabic script plus any of ڕ ڵ ێ ۆ is
92
+ Sorani → emit the tag **`ckb`** (that is the tag the per-script font
93
+ resolver's Sorani coverage check keys on; `ku` would let a font missing those
94
+ letters through), not `ar` and not `ku`.
95
+ - **Ambiguous tags are a question, not a guess.** `ku ks pa sd az uz tk ha ber
96
+ mn` are each written in more than one script depending on region or
97
+ orthography. With message content, classify from it. Without, they arrive in
98
+ `needsConfirmation` — ASK the user which script that language uses in this
99
+ product, and do not paste the locale until they answer.
100
+
101
+ Show the user the `evidence` line for each locale alongside the JSON, so a
102
+ wrong call is visible rather than silent.
103
+
104
+ Manual signals if the CLI is unavailable: i18n deps in `package.json`
105
+ (`next-intl`, `react-i18next`, `i18next`, `@lingui/*`, `vue-i18n`), an i18n
106
+ config's `locales: [...]` array, `locale/*` / `locales/*.json` /
107
+ `messages/*.json` dirs, `dir="rtl"` in markup, iOS `*.lproj/` dirs +
108
+ `Localizable.strings`, Android `values-ar/strings.xml`.
74
109
  - **Which platform(s)** — detect the target platform(s) so you only emit the
75
110
  surfaces that apply:
76
111
  - `mobile` — an iOS/Android/React-Native/Expo app (`*.xcodeproj`, `Info.plist`,
77
112
  `android/`, `app.json`/Expo, SF Symbols usage, safe-area/tab-bar tokens).
113
+ Note WHICH OS it targets — an `android/` + Gradle + Material repo is
114
+ `os: "android"`, an `*.xcodeproj`/SwiftUI repo is `os: "ios"`.
78
115
  - `web` — a web app / site (`index.html`, a web router, CSS media queries,
79
116
  hover states, a `tailwind.config`).
80
117
  - `desktop` — an Electron/Tauri/native-desktop shell.
@@ -93,14 +130,22 @@ EXACTLY (strict keys, hex 3/4/6/8 digits, no blank array entries, only
93
130
 
94
131
  - **name** — the product/brand name (still required on the write).
95
132
  - **identity** — the SHARED brand: `colors` (real palette, each
96
- `{ name, hex, role?, meaning? }`), `gradient` (if any), `type`
133
+ `{ name, hex, role?, meaning? }`), `gradient` (if any), `signature`
134
+ (`{ kind: solid|gradient|iridescent, stops?: hex[] }` — solid unless the repo's
135
+ tokens define a gradient identity; see `reference/brand-format.md`), `type`
97
136
  (`{ families, ramp, rules }`), `spacing`, `radius`, `voice`
98
- (`{ nouns, promise, lines, tone }`), `forbidden` (hard guardrails as
99
- `[{ id?, rule }]`).
137
+ (`{ nouns, promise, lines, tone }`), `locales` (each
138
+ `{ tag, script, direction, fonts? }` — BCP-47 tag, ISO 15924 script,
139
+ `ltr`/`rtl`; FIRST entry is the default; per-locale `fonts` override the
140
+ shared families for that script, e.g. an Arabic display face), `forbidden`
141
+ (hard guardrails as `[{ id?, rule }]`).
100
142
  - **platforms** — a map with a key ONLY for each platform you detected in step 1.
101
143
  Each surface: `components` (signature components as `[{ name, spec }]`), `icons`
102
144
  (the platform's icon language), `layout` (its frame/shell law), `surfaces` (this
103
- product's real areas), `bans` (platform-specific don'ts). Use the per-platform
145
+ product's real areas), `bans` (platform-specific don'ts). `mobile` ALSO takes
146
+ `os` (`"ios"` | `"android"`) — set it when the repo names its OS; it drives the
147
+ fig's device frame, system-bar heights, back affordance, and corner radii.
148
+ Omit it for an iOS/cross-platform repo (iOS is the default). Use the per-platform
104
149
  **`tokensBlock`** escape-hatch ONLY for guidance that genuinely doesn't fit a
105
150
  structured field — prefer a structured field.
106
151