super-ux 0.56.3 → 0.58.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/CHANGELOG.md CHANGED
@@ -1,3 +1,126 @@
1
+ ## 0.58.0 — the look is decided before the frames, and approved on a critique
2
+
3
+ `ux-flows` picked the style pack itself, from three or four names copied out of
4
+ a catalogue that had grown to dozens, then drew a frame for every state of
5
+ every flow in that one guess and asked for approval of the lot. The approval
6
+ covered the flow and the look at once, on frames nobody had critiqued. This
7
+ release hands the visual decision to its owner and splits the approvals, from
8
+ the family's 2026-10-07 design-pipeline plan, tasks U1–U5 plus the screen
9
+ matrix and the onboarding budget.
10
+
11
+ - **U1. `/sheleg-design` decides the look; super-ux keeps no pack list.**
12
+ `ux-flows` step 4, `visual-identity.md`, `/ux`, the Cursor rule, the
13
+ `screens.md` seed and the README now delegate to the companion's own index,
14
+ defaults and director record. An underdetermined brief gets **directions
15
+ before frames**: one key screen in one state, two directions only when the
16
+ fork is real (rubric first), the human picks on a 2-up mini sheet, then the
17
+ rest of the frames. `validate_direction_before_frames` refuses a pack name in
18
+ any of those seven files.
19
+ - **U2. `Style pack: none` keeps the floor.** With sheleg-design installed its
20
+ `SLOP_MARKERS.md` catalogue applies pack or no pack; where code exists,
21
+ `npx sheleg-design-skill --lint <dir>` runs and its result is recorded;
22
+ companion absent or a version without `--lint` → `Markers: NOT_RUN`, never a
23
+ pass. `visual-identity.md`, `figma-integration.md`, `audit-depth.md`.
24
+ - **U3. Flow approval and art-direction approval are two decisions.** Frames
25
+ are critiqued with `get_screenshot` against the director record's rubric
26
+ before approval, findings as region → defect → fix triples, one re-render
27
+ round (two at most), then `unresolved` to the human. The flow records
28
+ `Art direction: approved <date> — critique: <where>`; **`U085`** refuses an
29
+ approval that cites no critique or a path that does not resolve.
30
+ - **U4. `visual-drift` in `ux-audit`.** The live pass compares the build with
31
+ its Figma frame in the same state, viewport and theme, under the capture
32
+ record of sheleg-design's `VISUAL_REVIEW.md`; an unfit or missing capture is
33
+ BLOCKED, never PASS. The live pass is offered by default for
34
+ `surface_class: flagship` or `ad`, and for landing, onboarding, paywall and
35
+ store screens when no class is recorded.
36
+ - **U5. Figma is a file per surface.** `foundation.md` → Design tooling
37
+ records the folder and a `Figma files` table with a row per surface the
38
+ product has (App / Web / ASO); the "single project file" wording is gone
39
+ from the template, the contract, `figma-integration.md` and `ux-foundation`.
40
+ - **Every screen declares its states and axes.** States: default, loading,
41
+ empty, error, offline, long-content, keyboard-up, first-run (those that
42
+ apply). Axes: `viewport; theme; text; locale`, per screen or as a Design
43
+ system default. **`U079`** (no states, Figma or not), **`U080`** (no axes or
44
+ one missing), **`U081`** (a built screen's state with an empty `Story`
45
+ cell). `U020` now finds the frame column by its header and covers every
46
+ declared state, not only loading / empty / error / success: a `keyboard-up`
47
+ row with no frame used to pass.
48
+ - **The onboarding budget is one screen** (R2 of the Apple Design
49
+ Awards-derived rubric), in `onboarding.md` ON-06 and the flow contract. Flows record `Onboarding:` and `First value:`;
50
+ **`U082`** refuses a second screen without `Onboarding budget:` citing a
51
+ director record that exists and discusses onboarding; **`U083`** warns on a
52
+ flow named for onboarding that declares nothing; **`U084`** on a count with
53
+ no first value.
54
+ - **Warn first, gate on opt-in.** Existing `screens.md` files are format 1:
55
+ `U079`, `U080`, `U081`, and `U020` on states beyond loading / empty / error /
56
+ success, report as **warnings** there, and the lint still exits 0 — what
57
+ `U020` failed before 0.58.0 still fails. A registry carrying
58
+ `<!-- screens-format: 2 -->` makes them errors; the seeded `screens.md`
59
+ writes the marker, so a new project is strict from its first line.
60
+ `docs/ux/doctor.py` names the migration on a format-1 file and `/ux-update`
61
+ performs it: one `- **Axes:** …` line in the Design system block, a state
62
+ list per screen, the lint clean of those warnings, then the marker. This
63
+ repository's own registry is on format 2. `--strict` still fails on every
64
+ warning. Measured over all 118 registries on the maintainer's machine: the first
65
+ cut of this release newly failed 71 of the 80 that passed; this one newly
66
+ fails none and adds no error anywhere.
67
+ - Evals `EV-06` (visual drift is found) and `EV-07` (one key screen, 2-up,
68
+ no pack list) join `test/evals/cases.json`; not run in CI, as before.
69
+
70
+ ## 0.57.0 — short video gets a playbook, a hook filter and a caption linter
71
+
72
+ `copywriting` covered posts for X, Reddit and LinkedIn and nothing that moves:
73
+ no Reels, Shorts or TikTok physics, no hook, no script, no caption rules, and
74
+ an X playbook asserting two ranking rules the open ranker does not contain.
75
+ This release fills that from the family's 2026-10-07 research, every rule with
76
+ its source and the date it was read.
77
+
78
+ - **Three new references, linked from `copywriting`.** `video-script.md`: the
79
+ angle in one sentence, the modular hook, body and ask, six organic and ten
80
+ ad structures, words per second for English, the shot list, gates G2–G4,
81
+ reading the retention chart. **Russian timing is measured by reading aloud,
82
+ and the file carries no coefficient**: the research proposed one as its
83
+ author's assumption, and a table would have made it a rule nobody re-measures.
84
+ `hooks.md`: the mechanics (frame 0, text by about 0.5 s, works muted, the body
85
+ pays), the triple (frame 0, on-screen text of ten words or fewer, first line),
86
+ a taxonomy, 26 formulas and 40 templates, each template with its source.
87
+ `research-outliers.md`: views ÷ the account's median, 3× a signal, under 1.5×
88
+ noise, a sample within 10× of your size, with a worked example.
89
+ - **`channel-playbooks.md`.** New `Instagram` (the 125-character feed window,
90
+ five hashtags since 2025-12-18, links that do not click, one ask, sends as
91
+ the signal for non-followers) and `Short video` (platform signals, originality,
92
+ AI labelling, safe zones, length, pace, the loop, trial reels). **X is re-read
93
+ against `xai-org/x-algorithm@e62790c`**: the published weights (share via
94
+ copy link 20, reply, quote and share via DM 5, a reply from a mutual follow
95
+ +15, follow 4, like 0.5, report −234, mute −58.8) and the 48-hour window are
96
+ in; "bookmarks weigh heavily" and "more than two hashtags is penalised" are
97
+ out, because neither is in the ranker; link suppression stays as practice,
98
+ marked unverified. The seeded `channels.md` X record no longer carries them.
99
+ - **Four new codes, 42 → 46.** `B044` filters short-video hooks: five checks,
100
+ 0.6 × mean + 0.4 × minimum, −15 per dealbreaker, and on-screen text over ten
101
+ words. It is a filter, not a predictor, and its row says so in numbers (AUC
102
+ 0.83 at catching a bad hook, 0.56 at picking a winner); a hook in Cyrillic is
103
+ not scored against English vocabulary. `B045` and `B046` read two new
104
+ physics tokens, `fold N` and `one ask`. `B066` is the new marker AT-16:
105
+ invisible and look-alike-space characters, eighteen named classes plus any
106
+ other format character, seven numbered exemptions with a fixture each, and
107
+ it never examines a dash or a quotation mark. `B030` now reads the `hook` and
108
+ `on-screen` fields as public copy. `B042`'s message no longer assumes every
109
+ surface's reason is reach.
110
+ - **New surfaces:** `Instagram`, `TikTok`, `YouTube Shorts` and `short video`.
111
+ - **A gate for the shelf itself.** `validate_short_video_shelf` counts HF-01..26
112
+ and HT-01..40, refuses a template without a source URL, a short-video or
113
+ Instagram rule without one, the two removed X claims coming back, and
114
+ recomputes every multiple and reading in the outlier example. Each was
115
+ watched failing on a planted defect.
116
+ - Because `channel-playbooks.md` links the three new files, the closure ships
117
+ them inside `brand-voice` (13) and `ux-audit` (24) as well; their reference
118
+ sentences were recounted from the tree.
119
+ - Provenance: the 26 formula names, the scoring arithmetic, the dealbreakers,
120
+ the caption mechanics, the outlier method and the eighteen character classes
121
+ come from `Jakeschincariol/instagram-agent-skill` at `d03c56b` (MIT); shapes,
122
+ examples, word lists and code are rewritten and no text is reproduced.
123
+
1
124
  ## 0.56.3 — check the heading the reader sees
2
125
 
3
126
  The no-title-period rule already existed, but its linter read Markdown headings
package/README.md CHANGED
@@ -157,11 +157,12 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
157
157
  python3 docs/brand/lint.py
158
158
  ```
159
159
 
160
- 42 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
160
+ 46 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
161
161
  with no sourced fact, a field over its limit with the locale coefficient
162
162
  applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
163
163
  a rhetorical dash, a title that ends in a full stop, a locale that lags
164
- without saying so. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
164
+ without saying so, a short-video hook the filter calls weak, a caption that
165
+ spends the feed window or asks twice, an invisible character pasted in. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
165
166
 
166
167
  Clean means *checkable*, not *good*: tone drift, unproven claims and a voice
167
168
  that has overshot its own failure mode are judged by `/ux-audit copy`.
@@ -218,7 +219,7 @@ fine without either.
218
219
 
219
220
  | When | Companion | What it adds |
220
221
  |---|---|---|
221
- | At VISUALIZE / BUILD, a frame or a screen is about to be drawn | **[sheleg-design](https://github.com/ssheleg/sheleg-design-skill)** | The look: one locked style pack (palette, type, texture, motion tokens, bans) with ready token CSS: `workbench` for product UI, dashboards and tools; `instrument-console`; `editorial-luxury`; or a new pack on its contract. Plus the motion methodology for cinematic scroll-driven landings. The pack is recorded in `screens.md`; its tokens become the Figma variables *and* the code tokens. `npx sheleg-design-skill` |
222
+ | At VISUALIZE / BUILD, a frame or a screen is about to be drawn | **[sheleg-design](https://github.com/ssheleg/sheleg-design-skill)** | The look. `/sheleg-design` picks one locked style pack (palette, type, texture, motion tokens, bans) from its own index and defaults, keeps a director record of the decision, and holds a visual floor of slop markers that applies even when the answer is no pack. When the brief leaves the look open, one key screen is drawn in two directions and a person picks on a 2-up sheet before any other frame. The pack is recorded in `screens.md`; its tokens become the Figma variables *and* the code tokens. `npx sheleg-design-skill` |
222
223
  | After an audit or an Improve pass produced a UX plan | **[task-pipeline](https://github.com/ssheleg/task-pipeline)** | Executes the plan end-to-end through gated stages: spec → plan → subagent build → tests → deploy → docs. `/task-pipeline docs/ux/plans/<file>` |
223
224
 
224
225
  The boundary that keeps them from fighting: BP-079..090 and BP-130..135 are
@@ -243,10 +244,10 @@ to is a skill nobody runs.
243
244
  | skill `ux-scenarios` | `docs/ux/scenarios.md`: use-case scenarios (action → observable response, alt and error paths) covering every flow node and edge, `Traces:` to stories and flows, validated for conflicts, coverage and traceability |
244
245
  | skill `ux-audit` | Batched audit with full context: code vs every scenario plus its story's acceptance criteria; verdicts PASS / PARTIAL / FAIL / BLOCKED with `file:line` evidence; depths `quick` / `standard` / `deep`; a `coverage` scope that audits the chain itself |
245
246
  | skill `brand-voice` | `docs/brand/`: the pack and its five axes, the words the product owns and bans, canonical facts, the per-surface register, locales, plus six shipped voice packs, each declaring the degeneration it collapses into when overdone |
246
- | skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
247
+ | skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
247
248
  | `/ux` | **The one command**: sets up whatever is missing, reports status across every layer, then offers only the applicable actions with one marked recommended. Idempotent |
248
249
  | `/vision` `/ux-init` `/ux-foundation` `/ux-flows` `/ux-update` `/ux-audit` `/ux-rule` `/ux-lint` `/ux-doctor` · `/brand` `/brand-init` `/brand-update` `/brand-lint` `/copy` | Direct controls for when you know exactly what you want; `/ux-rule` installs both hard rules and seeds `lint.py` + `doctor.py`; `/brand-init` seeds `docs/brand/` and its linter |
249
- | `docs/ux/lint.py` + `/ux-lint` | The deterministic half: missing Figma frames, unresolved SCR/story traces, orphans, built screens without coverage, index desync, ID gaps, broken links. Stdlib-only, exit 1 on problems, so wire it into CI and drift can't merge |
250
+ | `docs/ux/lint.py` + `/ux-lint` | The deterministic half: missing Figma frames, a screen with no states or no render axes, a built state with no story, more than one onboarding screen before the first value with no reason on file, art direction approved with no critique cited, unresolved SCR/story traces, orphans, built screens without coverage, index desync, ID gaps, broken links. Stdlib-only, exit 1 on problems, so wire it into CI and drift can't merge |
250
251
  | `cursor/rules/*.mdc` | The same methodology for Cursor: one always-on hard rule + seven agent-requested rules (vision, foundation, flows, scenarios, audit, brand voice, copywriting) |
251
252
  | `templates/` | Seeds for `docs/ux/`: the vision skeleton, foundation, flows, screens, scenario base, the folder README, and the audit-report skeleton. Both hard-rule snippets live here as their single source, `claude-rule.md` (scenario-first) and `vision-rule.md` (vision alignment), and the validator fails if a command's embedded copy drifts from them. Seeds for `docs/brand/`: voice, terminology, facts, channels, the string registry, a locale delta, and its folder README. The tree is mirrored into `plugins/super-ux/templates/` (and the seeds each skill names into that skill's own directory) by `test/sync_references.py`, so installed channels carry the seeds their texts point at; the validator refuses drift between the copies |
252
253
 
@@ -263,8 +264,8 @@ The contracts every skill reads:
263
264
  | [practice-selection.md](plugins/super-ux/skills/references/practice-selection.md) | The deterministic bridge: product profile → mandatory consideration sets → per-artifact checklists → a compliance table where every pulled practice gets a verdict. No silent skips, no cargo cult |
264
265
  | [funnel-research.md](plugins/super-ux/skills/references/funnel-research.md) | Reading a funnel market before designing one, `FR-01..FR-07`: where competitor funnels are visible, the four signals that survive when revenue is invisible, the fields that make a corpus comparable, which adjacent categories transfer, the stop before copying, and where each finding lands in the chain. Carried by `ux-foundation` and `ux-flows` |
265
266
  | [component-guidelines.md](plugins/super-ux/skills/references/component-guidelines.md) | Which control for which job (radios/select/switch, sheet/alert, modal/disclosure, combobox, nav bar/rail, FAB, dates, toasts) and the platform rules of Apple HIG, Material 3, W3C ARIA APG and GOV.UK |
266
- | [visual-identity.md](plugins/super-ux/skills/references/visual-identity.md) | The visual layer and its owner: one style pack for the whole product, where it's recorded, how it meets Figma and code, and the division of labor with the craft floors |
267
- | [figma-integration.md](plugins/super-ux/skills/references/figma-integration.md) · [figma-structure.md](plugins/super-ux/skills/references/figma-structure.md) | The optional Figma surface (on by default): when and how to mock up, and how to structure the file so frames named `SCR-NN/<Screen>/<state>` map 1:1 to `screens.md`, giving deterministic lookup and checkable drift |
267
+ | [visual-identity.md](plugins/super-ux/skills/references/visual-identity.md) | The visual layer and its owner: `/sheleg-design` decides the one style pack, directions come before frames when the brief leaves the look open, the slop-marker floor holds even with no pack, and how the pack meets Figma and code |
268
+ | [figma-integration.md](plugins/super-ux/skills/references/figma-integration.md) · [figma-structure.md](plugins/super-ux/skills/references/figma-structure.md) | The optional Figma surface (on by default): one file per surface (App, Web, ASO), frames critiqued against the director record before anyone approves the look, flow and art direction approved separately, and frames named `SCR-NN/<Screen>/<state>` that map 1:1 to `screens.md`, giving deterministic lookup and checkable drift |
268
269
 
269
270
  ## Keeping installs current
270
271
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
2
+ description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
3
3
  alwaysApply: false
4
4
  ---
5
5
 
@@ -54,6 +54,17 @@ effortless / unlock / elevate / robust", em-dash cadence in every paragraph,
54
54
  "it's not just X, it's Y", hedges stacked two deep, and a closing sentence
55
55
  that summarizes what was just said.
56
56
 
57
+ ## Short video
58
+
59
+ A hook is three things at once, about one thing: frame 0, on-screen text of
60
+ ten words or fewer, and the first spoken line. The body pays the promise
61
+ before the middle. Write the angle first ("for [persona], who wants
62
+ [desire], we show [proof]"), time the script by reading it aloud (never
63
+ convert a word count into another language), and give every figure in the
64
+ hook its `facts.md` row. `B044` filters weak hooks; it is a filter, never a
65
+ predictor of which hook wins. A caption fits the feed window and makes one
66
+ ask (`B045`, `B046`).
67
+
57
68
  ## Localization
58
69
 
59
70
  A locale is not a translation of the English: the length coefficient, the
@@ -25,9 +25,13 @@ recovery edges), `Screens traversed` table (`| Screen | States used here |`
25
25
 
26
26
  Each screen entry in `screens.md`: `Used by`, `Purpose`, `Elements` (mark
27
27
  the ONE primary action), `States` table (`| State | Trigger | Figma frame |
28
- Behavior |` — a row per loading/empty/error/success that applies),
28
+ Story | Behavior |` — a row per default/loading/empty/error/offline/
29
+ long-content/keyboard-up/first-run state that applies), `Axes`
30
+ (viewport; theme; text; locale — or the Design system default),
29
31
  `Wireframe`, `Coverage` (file:line), `Scenarios`, `Resources`, `Status`
30
- (designed|built|drifted|retired).
32
+ (designed|blocked|built|drifted|retired). A flow that onboards records
33
+ `Onboarding:` and `First value:`; the budget is one screen before the first
34
+ value unless the director record says why.
31
35
 
32
36
  ## Design rules
33
37
 
@@ -40,20 +44,23 @@ Behavior |` — a row per loading/empty/error/success that applies),
40
44
  primary action, not pixels); storyboard only when usage context drives
41
45
  design.
42
46
  - Visual identity BEFORE any frame: `screens.md` → Design system →
43
- `Style pack`. Empty and no design system in the project → pick a pack with
44
- the **sheleg-design** companion skill (`workbench` for product UI /
45
- dashboards / tools, `instrument-console`, `editorial-luxury`, or a new
46
- pack on its contract; cinematic scroll pages also take its motion
47
- methodology) and record the pack + token file. Missing → offer the
48
- one-time install once (`npx sheleg-design-skill`) and continue on platform
49
- defaults; recommend, don't force. The pack owns palette/type/motion and
50
- its bans; BP-079..090 stay the floors it must clear. Never invent a look
51
- per screen.
52
- - Figma mockups optional (default on): if the foundation's Design tooling
53
- enables Figma and a Figma MCP is available, mirror each screen into a
54
- frame built on the pack's tokens (they become the Figma variable
55
- collections) applying visual-craft practices (BP-079..090), and link every
56
- screen row to its frame; ask the user once at the start of design.
47
+ `Style pack`. Empty and no design system in the project →
48
+ `/sheleg-design` decides from its own pack index and defaults and keeps
49
+ the director record; record the pack + token file. A brief that leaves
50
+ the look open gets directions first: one key screen in one state, two
51
+ directions only when the fork is real, the human picks on a 2-up sheet,
52
+ then the rest. Missing → offer the one-time install once
53
+ (`npx sheleg-design-skill`) and continue on platform defaults; `none`
54
+ still keeps the sheleg-design slop-marker floor where it is installed.
55
+ The pack owns palette/type/motion and its bans; BP-079..090 stay the
56
+ floors it must clear. Never invent a look per screen.
57
+ - Figma mockups optional (default on): one Figma file per surface (App /
58
+ Web / ASO). If the foundation's Design tooling enables Figma and a Figma
59
+ MCP is available, mirror each screen-state into a frame built on the
60
+ pack's tokens applying BP-079..090, and link every state to its frame.
61
+ Critique the frames with `get_screenshot` (region → defect → fix) before
62
+ approval; the art direction is approved separately from the flow and the
63
+ approval cites the critique.
57
64
  - Backwards mode (existing product): reconstruct flows as they ARE from
58
65
  code with file:line evidence, tag `inferred` until confirmed; gaps
59
66
  between is/should become the improvement backlog.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.56.3",
3
+ "version": "0.58.0",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
6
6
  "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
@@ -81,7 +81,7 @@ STRING_KINDS = ("copy", "layout")
81
81
 
82
82
  SEVERITY_ERROR = "error"
83
83
  SEVERITY_WARN = "warn"
84
- WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B051 B053 B054 B060 B063 B064 B071 B072".split())
84
+ WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072".split())
85
85
 
86
86
  Finding = namedtuple("Finding", "code severity path line message")
87
87
 
@@ -1369,7 +1369,13 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
1369
1369
  f"`{row['fact']}` was due for review on {row['review']}",
1370
1370
  ))
1371
1371
 
1372
- for path, _fields, body in documents(brand_dir, sources, "marketing"):
1372
+ for path, fields, body in documents(brand_dir, sources, "marketing"):
1373
+ # A short-video hook is the first public sentence anyone hears, so its
1374
+ # figure needs a row exactly as a body figure does (video-script.md).
1375
+ spoken = [v for k, v in fields.items()
1376
+ if HOOK_FIELD_RE.match(k) or re.match(r"^on-screen(?:-\d+)?$", k)]
1377
+ if spoken:
1378
+ body = body + "\n\n" + "\n\n".join(spoken)
1373
1379
  for m in NUMBER_RE.finditer(body):
1374
1380
  number = m.group(0)
1375
1381
  digits = re.sub(r"\s+", "", number)
@@ -1461,9 +1467,23 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
1461
1467
  if "link in body" in physics and re.search(r"https?://", body):
1462
1468
  findings.append(Finding(
1463
1469
  "B042", SEVERITY_ERROR, path, 0,
1464
- "link in the post body -- this surface suppresses reach "
1465
- "for it; the convention is the first reply",
1470
+ "link in the post body, which this surface's physics "
1471
+ "forbid -- it suppresses reach or does not click there; "
1472
+ "put it where channels.md says (first reply, bio, DM)",
1466
1473
  ))
1474
+ fold = re.search(r"fold (\d+)", physics)
1475
+ if fold:
1476
+ findings.extend(_feed_window(path, body, int(fold.group(1))))
1477
+ if "one ask" in physics:
1478
+ asks = caption_asks(body)
1479
+ if len(asks) != 1:
1480
+ findings.append(Finding(
1481
+ "B046", SEVERITY_WARN, path, 0,
1482
+ (f"{len(asks)} asks ({', '.join(asks)}) -- two asks "
1483
+ f"split one reader's single action" if asks else
1484
+ "no ask -- decide what this post is for and ask for "
1485
+ "that one thing"),
1486
+ ))
1467
1487
  cap = re.search(r"max (\d+) hashtags", physics)
1468
1488
  if cap:
1469
1489
  used = re.findall(r"(?<!\w)#\w+", body)
@@ -1480,6 +1500,236 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
1480
1500
  return findings
1481
1501
 
1482
1502
 
1503
+ # B045 -- the feed window. Instagram shows roughly the first 125 characters of
1504
+ # a caption before "... more", so the first line either fits the window or is
1505
+ # cut mid-thought, and a tag or a mention spent there is a hook not written.
1506
+ # The window size is declared per surface (`fold N`) because it is platform
1507
+ # physics that moves, and the number belongs in channels.md, not in this file.
1508
+ # Method: `skills/ig-caption/caption.py` in Jakeschincariol/instagram-agent-skill
1509
+ # @d03c56b (MIT), re-implemented here; see channel-playbooks.md#instagram.
1510
+ HASHTAG_TOKEN_RE = re.compile(r"(?<![\w&])#\w+")
1511
+
1512
+
1513
+ def _feed_window(path: str, body: str, fold: int) -> list[Finding]:
1514
+ text = body.strip()
1515
+ if not text:
1516
+ return []
1517
+ first = text.splitlines()[0].strip()
1518
+ window = text[:fold]
1519
+ reasons = []
1520
+ if first[:1] in ("#", "@"):
1521
+ reasons.append(f"it opens with `{first.split()[0]}`")
1522
+ if HASHTAG_TOKEN_RE.search(window) and first[:1] != "#":
1523
+ reasons.append("a hashtag sits inside the window")
1524
+ if len(first) > fold:
1525
+ reasons.append(f"the first line is {len(first)} characters and the "
1526
+ f"feed cuts it at {fold}")
1527
+ if not reasons:
1528
+ return []
1529
+ shown = window.split("\n")[0] if len(first) > fold else first
1530
+ return [Finding(
1531
+ "B045", SEVERITY_WARN, path, 0,
1532
+ f"the feed window ({fold} characters before \"... more\"): "
1533
+ f"{'; '.join(reasons)}. The feed shows: \"{shown[:fold]}\"",
1534
+ )]
1535
+
1536
+
1537
+ # B046 -- one ask per post. Patterns are the asks a caption actually makes;
1538
+ # a question mark alone is not an ask, because most captions ask nothing by
1539
+ # asking a rhetorical question. English after caption.py's eight (re-written),
1540
+ # Russian as their direct equivalents.
1541
+ CAPTION_ASKS = (
1542
+ (re.compile(r"(?i)\bcomment (?:the word )?[\"']?[A-Z0-9]{2,}\b"), "comment a keyword"),
1543
+ (re.compile(r"(?i)\b(?:dm|message) (?:me|us)\b"), "DM"),
1544
+ (re.compile(r"(?i)\bsave (?:this|it)\b"), "save this"),
1545
+ (re.compile(r"(?i)\b(?:share|send) (?:this|it)\b"), "share this"),
1546
+ (re.compile(r"(?i)\bfollow (?:me|us|for)\b"), "follow"),
1547
+ (re.compile(r"(?i)\blink in (?:my |our |the )?bio\b"), "link in bio"),
1548
+ (re.compile(r"(?i)\b(?:swipe|tap) (?:through|left|right|for|to)\b"), "swipe or tap"),
1549
+ (re.compile(r"(?i)\btell me\b|\bwhat would you\b|\bwhich one\b"), "answer a question"),
1550
+ (re.compile(r"(?i)напиши(?:те)? в комментари|коммент(?:арий)? [А-ЯЁA-Z0-9]{2,}"), "comment a keyword"),
1551
+ (re.compile(r"(?i)напиши(?:те)? (?:мне|нам) в (?:директ|личку|лс)"), "DM"),
1552
+ (re.compile(r"(?i)\bсохрани(?:те)?\b"), "save this"),
1553
+ (re.compile(r"(?i)\b(?:отправь|отправьте|перешли|перешлите)\b"), "share this"),
1554
+ (re.compile(r"(?i)\bподпиши(?:сь|тесь)\b"), "follow"),
1555
+ (re.compile(r"(?i)ссылк[аиу] в (?:профиле|шапке|био)"), "link in bio"),
1556
+ )
1557
+
1558
+
1559
+ def caption_asks(body: str) -> list[str]:
1560
+ """The asks a caption makes, one entry per distinct kind."""
1561
+ return [name for pattern, name in CAPTION_ASKS if pattern.search(body)]
1562
+
1563
+
1564
+ # B044 -- the hook filter. A FILTER, never a predictor, and the reference says
1565
+ # so in numbers: on its author's corpus of 74 real short-form hooks the method
1566
+ # separates written-to-be-bad hooks from real ones at AUC 0.83 and a creator's
1567
+ # hits from the same creator's misses at AUC 0.56, where 0.50 is a coin. So
1568
+ # B044 warns on what it catches well (greetings, preambles, a line with
1569
+ # nothing concrete or nothing at stake) and never ranks two decent hooks.
1570
+ #
1571
+ # Method and arithmetic: `skills/ig-reel/hookscore.py` in
1572
+ # Jakeschincariol/instagram-agent-skill @d03c56b (MIT) -- five checks 0..100,
1573
+ # overall = 0.6 * mean + 0.4 * min - 15 per dealbreaker, STRONG at >= 70 with
1574
+ # no check under 55 and no dealbreaker, WEAK under 50. The vocabularies below
1575
+ # are this file's own and shorter, so a score can differ by a few points from
1576
+ # the original's. They are English, so a hook in another script is not scored
1577
+ # at all; only the two script-independent dealbreakers apply to it.
1578
+ HOOK_WORD_RE = re.compile(r"[A-Za-z0-9$%'’-]+")
1579
+ HOOK_FIGURE_RE = re.compile(
1580
+ r"[$€£]\s?\d[\d,]*(?:\.\d+)?"
1581
+ r"|\b\d[\d,]*(?:\.\d+)?\s?(?:%|k\b|x\b|hours?\b|hrs?\b|minutes?\b|mins?\b"
1582
+ r"|days?\b|weeks?\b|months?\b|years?\b)?",
1583
+ re.I,
1584
+ )
1585
+ HOOK_PROPER_RE = re.compile(r"(?<!^)\b[A-Z][a-z]{2,}\b")
1586
+ HOOK_SPOKEN_FIGURES = {
1587
+ "zero", "two", "three", "four", "five", "six", "seven", "eight", "nine",
1588
+ "ten", "twelve", "twenty", "thirty", "fifty", "hundred", "thousand",
1589
+ "million", "billion", "dozen", "half", "twice", "triple",
1590
+ "dollars", "dollar", "bucks", "grand", "percent", "revenue", "salary",
1591
+ "profit", "rent",
1592
+ }
1593
+ HOOK_STAKES = {
1594
+ "stop", "never", "wrong", "mistake", "mistakes", "lose", "lost", "losing",
1595
+ "cost", "costs", "broke", "failed", "fail", "nobody", "no", "not", "don't",
1596
+ "can't", "won't", "didn't", "quit", "fired", "deleted", "killed",
1597
+ "replaced", "cut", "free", "paid", "saved", "banned", "worst", "hate",
1598
+ "wasted", "waste", "scam", "lied", "truth", "secret", "hidden", "before",
1599
+ "until", "instead", "but", "unless", "problem", "risk", "warning",
1600
+ "regret", "only", "without", "versus", "vs", "actually", "forgot",
1601
+ }
1602
+ HOOK_WEAK_OPENERS = (
1603
+ "so", "ok", "okay", "hey", "hi", "hello", "guys", "welcome", "today",
1604
+ "basically", "honestly", "just", "let's", "lets", "um", "i wanted",
1605
+ "i want", "have you", "do you", "did you", "are you", "in this",
1606
+ "in today", "this is", "there is", "there are", "it is", "you know",
1607
+ )
1608
+ HOOK_IMPERATIVES = {
1609
+ "stop", "steal", "copy", "delete", "try", "watch", "read", "save", "use",
1610
+ "build", "make", "write", "send", "start", "quit", "never", "always",
1611
+ "don't", "check", "cancel",
1612
+ }
1613
+ HOOK_EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF☀-➿]")
1614
+ HOOK_DEALBREAKERS = (
1615
+ ("stop-scrolling", re.compile(r"(?i)^\s*(?:stop scrolling|don'?t scroll)")),
1616
+ ("preamble", re.compile(r"(?i)\b(?:in (?:this|today'?s) (?:video|reel)"
1617
+ r"|i'?m going to show you|i'?ll show you how)\b")),
1618
+ ("greeting", re.compile(r"(?i)^\s*(?:hey|hi|hello|what'?s up|welcome)\b")),
1619
+ ("hashtag", re.compile(r"(?:^|\s)#\w+")),
1620
+ ("emoji", HOOK_EMOJI_RE),
1621
+ )
1622
+ # Applied to a hook in any script: neither depends on the vocabulary.
1623
+ SCRIPT_INDEPENDENT_DEALBREAKERS = ("hashtag", "emoji")
1624
+
1625
+
1626
+ def _hook_words(text: str) -> list[str]:
1627
+ return HOOK_WORD_RE.findall(re.sub(r"(?<=\d),(?=\d)", "", text))
1628
+
1629
+
1630
+ def hook_score(text: str) -> dict:
1631
+ """Score one spoken hook. `verdict` is None for a hook not in Latin script.
1632
+
1633
+ Returns {score, verdict, checks: {LENGTH, SPECIFICITY, STAKES, FRONTLOAD,
1634
+ ADDRESS}, flags: [dealbreaker ids]}.
1635
+ """
1636
+ flags = [name for name, pattern in HOOK_DEALBREAKERS if pattern.search(text)]
1637
+ if CYRILLIC_RE.search(text) or not re.search(r"[A-Za-z]", text):
1638
+ return {"score": None, "verdict": None, "checks": {},
1639
+ "flags": [f for f in flags if f in SCRIPT_INDEPENDENT_DEALBREAKERS]}
1640
+ words = _hook_words(text)
1641
+ low = [w.lower().strip("'’") for w in words]
1642
+
1643
+ n = len(words)
1644
+ length = 100.0 if 5 <= n <= 12 else (100 - (5 - n) * 20 if n < 5
1645
+ else 100 - (n - 12) * 11)
1646
+ if len(text.strip()) > 60: # two lines of large on-screen text
1647
+ length -= 12
1648
+
1649
+ figures = [m for m in HOOK_FIGURE_RE.findall(text) if m.strip()]
1650
+ hits = len(figures) + len(set(HOOK_PROPER_RE.findall(text))) \
1651
+ + sum(1 for w in low if w in HOOK_SPOKEN_FIGURES)
1652
+ specificity = 15.0 if hits == 0 else 45 + 30 * hits
1653
+
1654
+ markers = set(w for w in low if w in HOOK_STAKES)
1655
+ if re.search(r"[$€£]\s?\d", text):
1656
+ markers.add("a price")
1657
+ stakes = {0: 20.0, 1: 70.0}.get(len(markers), 100.0)
1658
+
1659
+ opener = " ".join(low[:2])
1660
+ penalty = 30 if any(opener.startswith(w) or (low and low[0] == w)
1661
+ for w in HOOK_WEAK_OPENERS) else 0
1662
+ payload = next((i for i, w in enumerate(low)
1663
+ if w in HOOK_STAKES or w in HOOK_SPOKEN_FIGURES
1664
+ or HOOK_FIGURE_RE.match(words[i])
1665
+ or (i and HOOK_PROPER_RE.match(words[i]))), None)
1666
+ frontload = (30.0 if payload is None else 100.0 if payload <= 3
1667
+ else 70.0 if payload <= 6 else 40.0) - penalty
1668
+
1669
+ lowered = text.lower()
1670
+ if re.search(r"\b(?:you|your|you're|yourself)\b", lowered):
1671
+ address = 100.0
1672
+ elif low and low[0] in HOOK_IMPERATIVES:
1673
+ address = 90.0
1674
+ elif re.search(r"\b(?:i|my|me|we|our)\b", lowered):
1675
+ address = 70.0
1676
+ else:
1677
+ address = 35.0
1678
+
1679
+ clamp = lambda v: max(0.0, min(100.0, float(v))) # noqa: E731
1680
+ checks = {"LENGTH": clamp(length), "SPECIFICITY": clamp(specificity),
1681
+ "STAKES": clamp(stakes), "FRONTLOAD": clamp(frontload),
1682
+ "ADDRESS": clamp(address)}
1683
+ parts = list(checks.values())
1684
+ score = clamp(0.6 * sum(parts) / len(parts) + 0.4 * min(parts)
1685
+ - 15 * len(flags))
1686
+ verdict = ("STRONG" if score >= 70 and min(parts) >= 55 and not flags
1687
+ else "OK" if score >= 50 else "WEAK")
1688
+ return {"score": score, "verdict": verdict, "checks": checks, "flags": flags}
1689
+
1690
+
1691
+ HOOK_FIELD_RE = re.compile(r"^hook(?:-\d+)?$")
1692
+ ON_SCREEN_LIMIT = 10
1693
+
1694
+
1695
+ def check_short_video(brand_dir: Path, sources: dict) -> list[Finding]:
1696
+ """B044 -- every `hook` / `hook-N` field, and its on-screen companion."""
1697
+ findings: list[Finding] = []
1698
+ for key in ("marketing", "store"):
1699
+ for path, fields, _body in documents(brand_dir, sources, key):
1700
+ for name in sorted(f for f in fields if HOOK_FIELD_RE.match(f)):
1701
+ hook = fields[name]
1702
+ if not hook:
1703
+ continue
1704
+ got = hook_score(hook)
1705
+ reasons = []
1706
+ if got["verdict"] == "WEAK":
1707
+ weakest = min(got["checks"], key=got["checks"].get)
1708
+ reasons.append(f"scores {got['score']:.0f} (WEAK); weakest "
1709
+ f"check {weakest} at "
1710
+ f"{got['checks'][weakest]:.0f}")
1711
+ if got["flags"]:
1712
+ reasons.append(f"dealbreaker(s): {', '.join(got['flags'])}")
1713
+ if reasons:
1714
+ findings.append(Finding(
1715
+ "B044", SEVERITY_WARN, path, 0,
1716
+ f"`{name}`: \"{hook}\" {'; '.join(reasons)}. A filter, "
1717
+ f"not a predictor: AUC 0.83 at catching a bad hook, "
1718
+ f"0.56 at picking a winner (hooks.md)",
1719
+ ))
1720
+ for name in sorted(f for f in fields
1721
+ if re.match(r"^on-screen(?:-\d+)?$", f)):
1722
+ words = fields[name].split()
1723
+ if len(words) > ON_SCREEN_LIMIT:
1724
+ findings.append(Finding(
1725
+ "B044", SEVERITY_WARN, path, 0,
1726
+ f"`{name}` is {len(words)} words; on-screen hook text "
1727
+ f"is read in about half a second, {ON_SCREEN_LIMIT} "
1728
+ f"words at most (hooks.md)",
1729
+ ))
1730
+ return findings
1731
+
1732
+
1483
1733
  def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
1484
1734
  """B041 -- the four rules that recover a third of the 100-character field."""
1485
1735
  findings = []
@@ -1998,6 +2248,141 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
1998
2248
  return findings
1999
2249
 
2000
2250
 
2251
+ # AT-16 / B066 -- invisible and look-alike-space characters. They render as
2252
+ # nothing or as an ordinary space, survive every paste, and break search,
2253
+ # hyphenation and exact-match checks downstream. The eighteen named classes
2254
+ # are the ones the humanizer in Jakeschincariol/instagram-agent-skill @d03c56b
2255
+ # strips (`skills/ig-human/slop.json`); any other Unicode format character
2256
+ # (category Cf -- bidi embeddings, overrides and isolates among them) is
2257
+ # reported by the catch-all. This check REPORTS; it does not rewrite, because
2258
+ # every class below has a context where it is correct, and those contexts are
2259
+ # the numbered exemptions in ai-tells.md (AT-16-E1..E7).
2260
+ #
2261
+ # What it deliberately does NOT port from that humanizer: the typography pass
2262
+ # that turns an em dash into ", ", an en dash into "-" and curly quotes into
2263
+ # straight ones. In Russian the dash is normative and «ёлочки» are the
2264
+ # quotation marks; a rule that rewrote them would make correct text wrong.
2265
+ # The rhetorical dash is AT-06 / B062, which knows the difference.
2266
+ import unicodedata # noqa: E402
2267
+
2268
+ INVISIBLE_NAMED = {
2269
+ 0x200B: "zero-width", 0x200C: "zero-width", 0x200D: "zero-width",
2270
+ 0x2060: "zero-width", 0xFEFF: "zero-width", 0x00AD: "zero-width",
2271
+ 0x180E: "zero-width", 0x2063: "zero-width",
2272
+ 0x061C: "bidi", 0x200E: "bidi", 0x200F: "bidi",
2273
+ 0x00A0: "space", 0x202F: "space", 0x2009: "space", 0x2007: "space",
2274
+ 0x2003: "space", 0x2002: "space",
2275
+ }
2276
+ TAG_RANGE = range(0xE0000, 0xE0080)
2277
+ BIDI_ISOLATES = range(0x2066, 0x206A)
2278
+ RTL_RE = re.compile("[֐-ࣿיִ-﷿ﹰ-ﻼ]")
2279
+ # Locales whose own typography puts a no-break or narrow space where a writer
2280
+ # means one: «в пятницу», «1 000», « Prix : ». Short on purpose, like
2281
+ # COPULA_LOCALES: a wrong entry silences the space class for a whole language.
2282
+ TYPOGRAPHIC_SPACE_LOCALES = ("ru", "uk", "be", "fr")
2283
+
2284
+
2285
+ def _is_pictograph(char: str) -> bool:
2286
+ cp = ord(char)
2287
+ return (0x1F000 <= cp <= 0x1FAFF or 0x2600 <= cp <= 0x27BF
2288
+ or 0x2300 <= cp <= 0x23FF or cp == 0xFE0F)
2289
+
2290
+
2291
+ def _needs_joiner(char: str) -> bool:
2292
+ """A letter of a script that spells with ZWJ/ZWNJ (Arabic, Persian, Indic…)."""
2293
+ return 0x0590 <= ord(char) < 0x2000 and char.isalpha() \
2294
+ and not CYRILLIC_RE.match(char) and not ("Ͱ" <= char <= "Ͽ")
2295
+
2296
+
2297
+ def invisible_chars(text: str, locale: str | None = None) -> list[tuple]:
2298
+ """(index, code point, name, class) for every reportable character.
2299
+
2300
+ Exemptions, numbered in ai-tells.md: E1 a joiner inside an emoji sequence,
2301
+ E2 a joiner beside a letter of a script that needs it, E3 a direction mark
2302
+ or isolate in text that carries right-to-left letters, E4 tag characters
2303
+ inside a flag sequence, E5 typographic spaces in a locale whose typography
2304
+ uses them, E6 dashes and quotation marks (never examined), E7 a byte-order
2305
+ mark opening the file.
2306
+ """
2307
+ rtl = any(c.isalpha() for c in RTL_RE.findall(text)) # letters, not marks
2308
+ spaces_ok = bool(CYRILLIC_RE.search(text)) or (
2309
+ bool(locale) and locale.split("-")[0].lower() in TYPOGRAPHIC_SPACE_LOCALES)
2310
+ hits = []
2311
+ for i, char in enumerate(text):
2312
+ cp = ord(char)
2313
+ if cp in INVISIBLE_NAMED:
2314
+ cls = INVISIBLE_NAMED[cp]
2315
+ elif cp in TAG_RANGE:
2316
+ cls = "tag"
2317
+ elif unicodedata.category(char) == "Cf":
2318
+ cls = "format"
2319
+ else:
2320
+ continue
2321
+ prev = text[i - 1] if i else ""
2322
+ nxt = text[i + 1] if i + 1 < len(text) else ""
2323
+ if cp == 0xFEFF and i == 0: # E7
2324
+ continue
2325
+ if cp == 0x200D and prev and nxt and _is_pictograph(prev) \
2326
+ and _is_pictograph(nxt): # E1
2327
+ continue
2328
+ if cp in (0x200C, 0x200D) and ((prev and _needs_joiner(prev))
2329
+ or (nxt and _needs_joiner(nxt))): # E2
2330
+ continue
2331
+ if rtl and (cls == "bidi" or cp in BIDI_ISOLATES): # E3
2332
+ continue
2333
+ if cls == "tag": # E4
2334
+ start = i
2335
+ while start and ord(text[start - 1]) in TAG_RANGE:
2336
+ start -= 1
2337
+ end = i
2338
+ while end + 1 < len(text) and ord(text[end + 1]) in TAG_RANGE:
2339
+ end += 1
2340
+ if start and text[start - 1] == "\U0001F3F4" and ord(text[end]) == 0xE007F:
2341
+ continue
2342
+ if cls == "space" and spaces_ok: # E5
2343
+ continue
2344
+ name = unicodedata.name(char, f"U+{cp:04X}")
2345
+ hits.append((i, cp, name, cls))
2346
+ return hits
2347
+
2348
+
2349
+ def check_invisible(brand_dir: Path, sources: dict) -> list[Finding]:
2350
+ """B066 -- AT-16 over every declared copy source, read as raw text.
2351
+
2352
+ Raw on purpose: a literal character is what travels into a paste, while
2353
+ an HTML entity such as `&nbsp;` is markup someone typed deliberately and
2354
+ is not reported. One finding per file and class, at the first line.
2355
+ """
2356
+ findings: list[Finding] = []
2357
+ primary, _ = declared_locales(brand_dir)
2358
+ root = brand_dir.parent.parent
2359
+ seen: set = set()
2360
+ for key in ("ui", "marketing", "store", "locales"):
2361
+ for pattern in sources.get(key, []):
2362
+ for path in source_paths(root, pattern):
2363
+ if path in seen or not path.is_file():
2364
+ continue
2365
+ seen.add(path)
2366
+ text = read(path) or ""
2367
+ fields, _ = _front_matter(text)
2368
+ hits = invisible_chars(text, fields.get("locale") or primary)
2369
+ grouped: dict = {}
2370
+ for index, cp, name, cls in hits:
2371
+ grouped.setdefault((cls, cp, name), []).append(index)
2372
+ rel = path.relative_to(root).as_posix()
2373
+ for (cls, cp, name), where in sorted(grouped.items(),
2374
+ key=lambda kv: kv[1][0]):
2375
+ line = text.count("\n", 0, where[0]) + 1
2376
+ findings.append(Finding(
2377
+ "B066", SEVERITY_WARN, rel, line,
2378
+ f"U+{cp:04X} {name} ({cls} class) x{len(where)} -- "
2379
+ f"invisible in the editor and carried into every "
2380
+ f"paste. Remove it, or keep it where ai-tells.md "
2381
+ f"AT-16 names the context that needs it",
2382
+ ))
2383
+ return findings
2384
+
2385
+
2001
2386
  def declared_locales(brand_dir: Path) -> tuple[str | None, list[str]]:
2002
2387
  """(primary, others) from voice.md's `Locales:` line."""
2003
2388
  text = read(brand_dir / "voice.md") or ""
@@ -2164,8 +2549,10 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
2164
2549
  findings.extend(check_consistency(brand_dir, sources))
2165
2550
  findings.extend(check_facts(brand_dir, sources))
2166
2551
  findings.extend(check_channels(brand_dir, sources))
2552
+ findings.extend(check_short_video(brand_dir, sources))
2167
2553
  findings.extend(check_bot_safety(brand_dir, sources))
2168
2554
  findings.extend(check_ai_tells(brand_dir, sources))
2555
+ findings.extend(check_invisible(brand_dir, sources))
2169
2556
  findings.extend(check_locales(brand_dir, sources))
2170
2557
  return findings
2171
2558
 
@@ -40,6 +40,19 @@ ADDITIVE = [
40
40
 
41
41
  ARTIFACTS = ["vision.md", "foundation.md", "flows.md", "screens.md", "scenarios.md"]
42
42
 
43
+ # `screens.md` format 2 (0.58.0): the screen matrix — a state list and render
44
+ # axes per screen, a frame for every declared state, a story per state once
45
+ # built — is a gate. Format 1 gets the same findings as lint warnings. The
46
+ # upgrade is content, so the doctor names it and never stamps the marker.
47
+ SCREENS_FORMAT = re.compile(r"<!--\s*screens-format:\s*(\d+)\s*-->")
48
+ SCREENS_MIGRATION = (
49
+ "screens.md is on format 1: U079/U080/U081 (and U020 for states beyond "
50
+ "loading/empty/error/success) are lint warnings, not a gate. To migrate: "
51
+ "add `- **Axes:** viewport: …; theme: …; text: …; locale: …` to the Design "
52
+ "system block, give every screen a state list, run the linter until those "
53
+ "warnings are gone, then add `<!-- screens-format: 2 -->` under the title."
54
+ )
55
+
43
56
  # Names the contract owns, and the near-misses seen in the wild.
44
57
  RENAMES = {
45
58
  "ux-scenarios.md": "scenarios.md",
@@ -89,7 +102,13 @@ def diagnose(ux: Path) -> dict:
89
102
  if f.exists() and needle not in f.read_text(encoding="utf-8", errors="ignore"):
90
103
  missing_additive.append(label)
91
104
 
105
+ screens_format = None
106
+ if present.get("screens.md"):
107
+ m = SCREENS_FORMAT.search((ux / "screens.md").read_text(encoding="utf-8", errors="ignore"))
108
+ screens_format = int(m.group(1)) if m else 1
109
+
92
110
  return {
111
+ "screens_format": screens_format,
93
112
  "present": present,
94
113
  "markers": markers,
95
114
  "effective": min(known) if known else None,
@@ -148,6 +167,9 @@ def report(ux: Path, d: dict) -> int:
148
167
  for label in d["missing_additive"]:
149
168
  print(f" optional, absent: {label}")
150
169
 
170
+ if d.get("screens_format") == 1:
171
+ print(f" migration offered: {SCREENS_MIGRATION}")
172
+
151
173
  print(" OK — on the current contract" if problems == 0 else f" {problems} problem(s)")
152
174
  return problems
153
175
 
@@ -222,6 +244,8 @@ def main() -> int:
222
244
  flags.append("loose-audits")
223
245
  if d["audits"] and not d["present"].get("scenarios.md"):
224
246
  flags.append("audits-without-base")
247
+ if d.get("screens_format") == 1:
248
+ flags.append("screens-format-1")
225
249
  print(f"{ux} {state}{' ' + ','.join(flags) if flags else ''}")
226
250
  return 0
227
251
 
@@ -136,6 +136,121 @@ CITED_PATH = re.compile(
136
136
  )
137
137
 
138
138
 
139
+ # The four axes a screen is rendered across, by the names the contact sheet
140
+ # uses for its columns. A fifth axis is allowed; these four are required.
141
+ AXES = ("viewport", "theme", "text", "locale")
142
+ AXES_FIELD = re.compile(r"\*\*Axes:\*\*[ \t]*(.*)")
143
+
144
+ # A cell or value that is still the template's placeholder, or a dash, says
145
+ # nothing — the same as an empty cell.
146
+ def _unfilled(value: str) -> bool:
147
+ v = value.strip()
148
+ return (not v or v in ("-", "—", "–")
149
+ or bool(re.fullmatch(r"<[^>]*>", v)))
150
+
151
+
152
+ def state_table(body: str) -> tuple[list[str], list[list[str]]]:
153
+ """(lower-cased headers, rows) of a screen's States table, found by header.
154
+
155
+ The table is the one whose first header cell is `State`. Columns are read
156
+ by their header name, never by their position after the state name — the
157
+ position is what let U020 read a `Behavior` cell as a frame link.
158
+ """
159
+ lines = body.splitlines()
160
+ for i, line in enumerate(lines):
161
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
162
+ if not line.strip().startswith("|") or not cells or cells[0].lower() != "state":
163
+ continue
164
+ headers = [c.lower() for c in cells]
165
+ rows: list[list[str]] = []
166
+ for row in lines[i + 1:]:
167
+ s = row.strip()
168
+ if not s.startswith("|"):
169
+ break
170
+ if re.fullmatch(r"\|[\s:|-]+\|?", s):
171
+ continue # the separator row
172
+ rows.append([c.strip() for c in s.strip("|").split("|")])
173
+ return headers, rows
174
+ return [], []
175
+
176
+
177
+ def declared_states(body: str) -> list[str]:
178
+ """State names a screen declares — its table rows, or an inline list."""
179
+ _, rows = state_table(body)
180
+ names = [r[0] for r in rows if r and r[0]]
181
+ if names:
182
+ return names
183
+ m = re.search(r"\*\*States:\*\*[ \t]*([^\n|]+)$", body, re.MULTILINE)
184
+ if m and not _unfilled(m.group(1)):
185
+ return [s.strip() for s in re.split(r"[,;]", m.group(1)) if s.strip()]
186
+ return []
187
+
188
+
189
+ def axes_value(text: str) -> str | None:
190
+ """The `**Axes:**` value in a block, wrapped lines joined; None when absent."""
191
+ value = field_body(text, AXES_FIELD)
192
+ return None if value is None else " ".join(value.split())
193
+
194
+
195
+ def missing_axes(value: str) -> list[str]:
196
+ """Which of the four required axes a declaration leaves out or leaves blank."""
197
+ found: dict[str, str] = {}
198
+ for part in value.split(";"):
199
+ if ":" not in part:
200
+ continue
201
+ name, _, val = part.partition(":")
202
+ found[name.strip().lower()] = val
203
+ return [a for a in AXES if _unfilled(found.get(a, ""))]
204
+
205
+
206
+ # The registry format a `screens.md` opted into. Format 2 (0.58.0) makes the
207
+ # screen matrix -- states, axes, stories, a frame for every declared state --
208
+ # a gate; format 1 reports the same findings as warnings, because turning them
209
+ # on as errors newly failed 71 of 80 passing real registries on upgrade. The marker is an
210
+ # HTML comment, so it is read from the RAW file: `read()` strips comments.
211
+ SCREENS_FORMAT = re.compile(r"<!--\s*screens-format:\s*(\d+)\s*-->")
212
+ FORMAT_HINT = (" (a warning while screens.md is format 1 — add the missing "
213
+ "fields, then `<!-- screens-format: 2 -->` to make it a gate)")
214
+
215
+
216
+ def screens_format(path: Path) -> int:
217
+ """1 unless the file carries `<!-- screens-format: N -->`."""
218
+ try:
219
+ m = SCREENS_FORMAT.search(path.read_text(encoding="utf-8"))
220
+ except OSError:
221
+ return 1
222
+ return int(m.group(1)) if m else 1
223
+
224
+
225
+ def legacy_frameless(body: str) -> set[str]:
226
+ """The states the pre-0.58 U020 flagged: one of four names, cell read by
227
+ position. Kept so a format-1 registry fails exactly where it always did."""
228
+ out = set()
229
+ for state, rest in re.findall(r"^\s*\|\s*(loading|empty|error|success)\s*\|(.*)\|\s*$",
230
+ body, re.MULTILINE | re.IGNORECASE):
231
+ cells = [c.strip() for c in rest.split("|")]
232
+ frame = cells[1] if len(cells) >= 2 else ""
233
+ if not frame or frame in ("-", "—", "<frame deep-link>", "<frame link>"):
234
+ out.add(state.lower())
235
+ return out
236
+
237
+
238
+ def section(text: str, heading: str) -> str:
239
+ """The body of `## <heading>` up to the next `## `, or ''."""
240
+ m = re.search(rf"^##[ \t]+{re.escape(heading)}[ \t]*$", text, re.MULTILINE)
241
+ if not m:
242
+ return ""
243
+ return re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.MULTILINE)[0]
244
+
245
+
246
+ def resolves(ref: str, ux: Path, root: Path) -> bool:
247
+ """A cited local path exists — relative to the project root or to docs/ux."""
248
+ path = ref.split("#", 1)[0].strip()
249
+ if not path:
250
+ return False
251
+ return (root / path).exists() or (ux / path).exists()
252
+
253
+
139
254
  def screen_blocks(text: str) -> dict[str, str]:
140
255
  """Map SCR-id -> its section body."""
141
256
  return entry_blocks(text, "SCR")
@@ -737,6 +852,70 @@ HOST_TARGET = {"claude": "CLAUDE.md", "codex": "AGENTS.md", "gemini": "GEMINI.md
737
852
  HOST_MARKER = {"claude": ".claude", "codex": ".codex", "gemini": ".gemini"}
738
853
 
739
854
 
855
+ # The onboarding budget (R2 of the design-award rubric): at most one onboarding screen before
856
+ # the first value. A flow states the count in `Onboarding:` and the destination
857
+ # in `First value:`; more than one screen needs `Onboarding budget:` pointing at
858
+ # the director record that says why. The linter checks the record exists and
859
+ # talks about onboarding — whether the reason is good is a person's call.
860
+ ONBOARDING_NAME = re.compile(r"onboarding|first[- ]run|онбординг", re.IGNORECASE)
861
+ LOCAL_REF = re.compile(r"(?<![\w/])([\w.-]+(?:/[\w.-]+)+\.[A-Za-z]{1,5}(?:#[\w-]+)?)")
862
+
863
+
864
+ def check_onboarding(flows: str, ux: Path, root: Path) -> None:
865
+ names = {fid: name for fid, _, name in entry_names(flows, "FLW")}
866
+ for fid, body in entry_blocks(flows, "FLW").items():
867
+ declared = field_body(body, re.compile(r"\*\*Onboarding:\*\*[ \t]*(.*)"))
868
+ if declared is None:
869
+ if ONBOARDING_NAME.search(names.get(fid, "")):
870
+ warn(f"[U083] flows.md: {fid} reads as onboarding and declares no "
871
+ f"**Onboarding:** — list the screens before the first value "
872
+ f"(or `none`) so the budget can be counted")
873
+ continue
874
+ count = len(set(re.findall(r"\bSCR-\d+\b", declared)))
875
+ first = field_body(body, re.compile(r"\*\*First value:\*\*[ \t]*(.*)"))
876
+ if not stated(first):
877
+ warn(f"[U084] flows.md: {fid} counts onboarding screens and names no "
878
+ f"**First value:** — the budget is counted against a destination "
879
+ f"nobody wrote down")
880
+ if count <= 1:
881
+ continue
882
+ budget = field_body(body, re.compile(r"\*\*Onboarding budget:\*\*[ \t]*(.*)")) or ""
883
+ ref = LOCAL_REF.search(budget)
884
+ if not ref:
885
+ err(f"[U082] flows.md: {fid} puts {count} onboarding screens before the "
886
+ f"first value — the budget is one; more needs **Onboarding budget:** "
887
+ f"citing the director record that justifies it")
888
+ elif not resolves(ref.group(1), ux, root):
889
+ err(f"[U082] flows.md: {fid} cites '{ref.group(1)}' for its onboarding "
890
+ f"budget, which does not exist")
891
+ else:
892
+ record = (root / ref.group(1).split("#")[0])
893
+ if not record.exists():
894
+ record = ux / ref.group(1).split("#")[0]
895
+ if "onboarding" not in read(record).lower():
896
+ err(f"[U082] flows.md: {fid} cites '{ref.group(1)}' for its onboarding "
897
+ f"budget and the record never mentions onboarding")
898
+
899
+
900
+ # Flow approval and art-direction approval are two decisions. The second is made
901
+ # on frames that were critiqued first, so an approval names the critique it
902
+ # read — a director record section, a file, or a link — and a local one resolves.
903
+ def check_art_direction(flows: str, ux: Path, root: Path) -> None:
904
+ for fid, body in entry_blocks(flows, "FLW").items():
905
+ value = field_body(body, re.compile(r"\*\*Art direction:\*\*[ \t]*(.*)"))
906
+ if not value or not value.strip().lower().startswith("approved"):
907
+ continue
908
+ if re.search(r"https?://\S+", value):
909
+ continue
910
+ ref = LOCAL_REF.search(value)
911
+ if not ref:
912
+ err(f"[U085] flows.md: {fid} records art direction approved and cites no "
913
+ f"critique — the approval names the frame critique it was made on")
914
+ elif not resolves(ref.group(1), ux, root):
915
+ err(f"[U085] flows.md: {fid} cites '{ref.group(1)}' as its critique, "
916
+ f"which does not exist")
917
+
918
+
740
919
  def active_hosts(root: Path) -> list[str]:
741
920
  """Hosts present in this project, by their marker directory. When none is
742
921
  detectable the target defaults to Claude, matching the seed default."""
@@ -971,6 +1150,15 @@ def main() -> int:
971
1150
  if has_screens:
972
1151
  fig = figma_enabled(foundation)
973
1152
  screens_root = project_root
1153
+ default_axes = axes_value(section(screens, "Design system"))
1154
+ gated = screens_format(ux / "screens.md") >= 2
1155
+
1156
+ def matrix(msg: str) -> None:
1157
+ """U079-U081 and the widened U020: errors on format 2, warnings before."""
1158
+ if gated:
1159
+ err(msg)
1160
+ else:
1161
+ warn(msg + FORMAT_HINT)
974
1162
  for sid, body in screen_blocks(screens).items():
975
1163
  # Read by value, not matched against a copy of the enum: the copy
976
1164
  # was one value short of the contract for as long as `blocked`
@@ -979,15 +1167,57 @@ def main() -> int:
979
1167
  status = declared_status(body)
980
1168
  if status == "retired":
981
1169
  continue
982
- # every state row present in the States table
983
- state_rows = re.findall(r"^\s*\|\s*(loading|empty|error|success)\s*\|(.*)\|\s*$",
984
- body, re.MULTILINE | re.IGNORECASE)
1170
+ # The state list is required whatever the tooling: it is the rows of
1171
+ # the contact sheet, and a screen with none is reviewed in the one
1172
+ # state somebody happened to render.
1173
+ states = declared_states(body)
1174
+ if not states:
1175
+ matrix(f"[U079] screens.md: {sid} declares no states — list the ones "
1176
+ f"that apply (default, loading, empty, error, offline, "
1177
+ f"long-content, keyboard-up, first-run)")
1178
+ # Axes are the sheet's columns. A screen may inherit the project's
1179
+ # default from the Design system block, and override it.
1180
+ axes = axes_value(body)
1181
+ if axes is None:
1182
+ axes = default_axes
1183
+ if axes is None:
1184
+ matrix(f"[U080] screens.md: {sid} declares no **Axes:** and the Design "
1185
+ f"system sets no default — name viewport, theme, text and locale")
1186
+ else:
1187
+ gaps = missing_axes(axes)
1188
+ if gaps:
1189
+ matrix(f"[U080] screens.md: {sid} axes leave out {', '.join(gaps)} — "
1190
+ f"write `name: values` for each of {', '.join(AXES)}")
1191
+ headers, rows = state_table(body)
1192
+ frame_col = next((i for i, h in enumerate(headers)
1193
+ if "figma" in h or "frame" in h), None)
1194
+ story_col = next((i for i, h in enumerate(headers)
1195
+ if re.search(r"\b(?:stor(?:y|ies)|preview)\b", h)), None)
985
1196
  if fig is not False: # enabled or default-on
986
- for state, rest in state_rows:
987
- cells = [c.strip() for c in rest.split("|")]
988
- frame = cells[1] if len(cells) >= 2 else ""
989
- if not frame or frame in ("-", "—", "<frame deep-link>", "<frame link>"):
990
- err(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
1197
+ frameless: list[str] = []
1198
+ if rows:
1199
+ for row in rows:
1200
+ frame = row[frame_col] if frame_col is not None and frame_col < len(row) else ""
1201
+ if _unfilled(frame):
1202
+ frameless.append(row[0])
1203
+ else:
1204
+ frameless = list(states)
1205
+ # What the old rule caught stays an error on every format;
1206
+ # only the newly covered states wait for the opt-in.
1207
+ legacy = legacy_frameless(body)
1208
+ for state in legacy:
1209
+ err(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
1210
+ for state in frameless:
1211
+ if state.lower() not in legacy:
1212
+ matrix(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
1213
+ # A Story column is the build-side twin of the frame: once a screen
1214
+ # is built, each state it declares can be shown on its own.
1215
+ if story_col is not None and status == "built":
1216
+ for row in rows:
1217
+ story = row[story_col] if story_col < len(row) else ""
1218
+ if _unfilled(story):
1219
+ matrix(f"[U081] screens.md: {sid} is built and state '{row[0]}' "
1220
+ f"names no story — the state cannot be rendered on its own")
991
1221
  cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
992
1222
  cov = cov_m.group(1).strip() if cov_m else ""
993
1223
  if status == "built" and (not cov or cov.lower().startswith("none")):
@@ -1023,6 +1253,8 @@ def main() -> int:
1023
1253
  check_jobs(foundation, scenarios, flows, screens)
1024
1254
  check_vision(ux, vision)
1025
1255
  check_web_surface(screens, flows)
1256
+ check_onboarding(flows, ux, project_root)
1257
+ check_art_direction(flows, ux, project_root)
1026
1258
  check_links(ux)
1027
1259
 
1028
1260
  # --- Report ---
@@ -103,14 +103,36 @@ Locales: <coefficient applies to both limits>
103
103
  Register: density +1, distance -1
104
104
  Format: one idea; threads 5-12 posts, each standing alone
105
105
  Limits: 280
106
- Forbidden: physics: link in body suppresses reach; >2 hashtags penalised;
107
- editing within 30 minutes resets distribution
108
- | brand: <engagement bait, fake urgency>
106
+ Forbidden: physics: link in body (practice; unverified in the open ranker) | brand: <engagement bait, fake urgency>
109
107
  CTA: <in the first reply, not the post>
110
108
  Proof: <one figure, sourced>
111
109
  Locales: <…>
112
110
  ```
113
111
 
112
+ ### Instagram
113
+
114
+ ```
115
+ Register: distance -1
116
+ Format: a caption; the first line is the window the feed shows
117
+ Limits: body 2200
118
+ Forbidden: physics: link in body, max 5 hashtags, fold 125, one ask | brand: <engagement bait>
119
+ CTA: one, spoken in the video as well
120
+ Proof: <one figure, sourced>
121
+ Locales: <…>
122
+ ```
123
+
124
+ ### short video
125
+
126
+ ```
127
+ Register: distance -1, density +1
128
+ Format: hook triple (frame 0, on-screen text, first line), body, one ask
129
+ Limits: <running time; Russian and other locales timed by reading aloud>
130
+ Forbidden: physics: none | brand: <fabricated testimonials, unlabelled synthetic people>
131
+ CTA: one, or a loop back to the first line
132
+ Proof: <one figure, sourced>
133
+ Locales: <…>
134
+ ```
135
+
114
136
  ### Reddit
115
137
 
116
138
  ```
@@ -125,5 +147,5 @@ Locales: <…>
125
147
  ```
126
148
 
127
149
  <add the remaining marketing surfaces this product uses: landing body,
128
- pricing, blog, changelog, LinkedIn, HN and Product Hunt, App Store,
129
- Google Play, ads, lifecycle email>
150
+ pricing, blog, changelog, TikTok, YouTube Shorts, LinkedIn, HN and Product
151
+ Hunt, App Store, Google Play, ads, lifecycle email>
@@ -10,6 +10,10 @@ node and edge. -->
10
10
  - **Goal:** <observable end state for the user>
11
11
  - **Entry points:** <all of them: screen, deep link, push, empty-state CTA>
12
12
  - **Success exit:** <where the user lands on success>
13
+ - **First value:** <SCR-ID / step where the user first gets what they came for>
14
+ - **Onboarding:** <SCR-IDs shown before the first value, or none; budget is one>
15
+ - **Onboarding budget:** <only past one screen: the director record that justifies it>
16
+ - **Art direction:** <pending | approved YYYY-MM-DD — critique: <record#critique or link>>
13
17
  - **Task analysis:**
14
18
  1. <user-visible micro-step; cut everything that doesn't serve the job>
15
19
  - **Flow:**
@@ -50,11 +50,18 @@ even when the answer is "none":
50
50
  ## Design tooling
51
51
 
52
52
  <!-- Visual mockups in Figma are optional (default on). Ask the user at the
53
- start of design; record the choice here (see figma-integration). The Figma
54
- file and design-system details live in docs/ux/screens.md (the UI map);
55
- this section just records the on/off choice and the file location.
53
+ start of design; record the choice here (see figma-integration). Figma is a
54
+ set of files in the product's folder, one per surface the product has; the
55
+ design-system details and frame links live in docs/ux/screens.md (the UI
56
+ map). This section records the on/off choice and where the files are.
56
57
  - **Figma:** enabled | disabled
57
- - **Figma file:** <url — one project file, one page per feature/flow group>
58
+ - **Figma folder:** <team / project the product's files live in>
59
+ - **Figma files:**
60
+ | Surface | File | Holds |
61
+ |---------|------|-------|
62
+ | App | <url> | every SCR-NN frame of the product |
63
+ | Web | <url> | landing, pricing, docs pages |
64
+ | ASO | <url> | store screenshots, icon, preview frames |
58
65
  -->
59
66
 
60
67
  ## User stories
@@ -1,5 +1,9 @@
1
1
  # UI Screen Registry
2
2
 
3
+ <!-- screens-format: 2 -->
4
+ <!-- The line above puts this registry on format 2: the screen matrix
5
+ (states, axes, a frame per state, a story per built state) is a lint gate.
6
+ Keep it. -->
3
7
  <!-- Managed with super-ux (ux-contract v4). The design map: every screen and
4
8
  state with its Figma frame, wireframe, code coverage, and related UX/UI
5
9
  resources. Update in the same change as any interface change; when Figma is
@@ -14,10 +18,14 @@ whose code diverges from its record here is a "drifted" finding. -->
14
18
  ## Design system
15
19
 
16
20
  <!-- Style pack = the locked visual identity every frame and built screen obeys.
17
- Pick it with the sheleg-design companion skill (workbench for product UI /
18
- dashboards / tools, instrument-console, editorial-luxury, or a new pack on its
19
- contract) before drawing anything; record its token file below. -->
20
- - **Style pack:** <pack name, or "none — platform defaults">
21
+ /sheleg-design chooses it from its own pack index and defaults, before any
22
+ frame is drawn (directions first when the brief leaves the look open), and
23
+ writes the director record. "none" declines a pack, not the slop-marker
24
+ floor. Axes = the columns every screen is reviewed across; a screen's own
25
+ **Axes:** line overrides this default. -->
26
+ - **Style pack:** <the pack /sheleg-design chose, or "none — platform defaults">
27
+ - **Director record:** <docs/design/<surface>/director-record.md, or "none">
28
+ - **Axes:** viewport: <widths>; theme: <light, dark, …>; text: <default, 200%, …>; locale: <locales>
21
29
  - **Figma library:** <url/name, or "none — platform defaults">
22
30
  - **Tokens in code:** <where color/type/spacing tokens live, e.g. src/theme/tokens.ts>
23
31
  - **Component source:** <shared UI components dir, e.g. src/components/>
@@ -42,13 +50,15 @@ the problem it can no longer fix. -->
42
50
  - **Used by:** <FLW-… and the step(s)>
43
51
  - **Purpose:** <the job step this screen serves>
44
52
  - **Elements:** <each element; mark the ONE primary action>
45
- - **States:**
46
- | State | Trigger | Figma frame | Behavior |
47
- |-------|---------|-------------|----------|
48
- | success | default | <frame deep-link> | <what shows> |
49
- | empty | <trigger> | <frame deep-link> | <prompt to act> |
50
- | error | <trigger> | <frame deep-link> | <message + recovery> |
51
- | loading | <trigger> | <frame deep-link> | <skeleton/progress> |
53
+ - **States:** (every one that applies: default, loading, empty, error,
54
+ offline, long-content, keyboard-up, first-run, product-specific ones)
55
+ | State | Trigger | Figma frame | Story | Behavior |
56
+ |-------|---------|-------------|-------|----------|
57
+ | default | <trigger> | <frame deep-link> | <story/preview, once built> | <what shows> |
58
+ | empty | <trigger> | <frame deep-link> | <story> | <prompt to act> |
59
+ | error | <trigger> | <frame deep-link> | <story> | <message + recovery> |
60
+ | loading | <trigger> | <frame deep-link> | <story> | <skeleton/progress> |
61
+ - **Axes:** (only when this screen differs from the Design system default)
52
62
  - **Web surface:** (only when this screen is a public URL; all five required)
53
63
  - **Route:** </pricing — the path, readable and stable>
54
64
  - **Answers:** <the ONE question this page answers; a second question is a second page>