super-ux 0.57.0 → 0.58.1

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,93 @@
1
+ ## 0.58.1 — a store title is 30 characters on Google Play too, and two more stores have their limits
2
+
3
+ `store-copy.md` said a Google Play title takes 50 characters. Google's metadata
4
+ policy says 30 ("Your app title must be 30 characters or less", read
5
+ 2026-10-07), so a title written to the old figure would be cut or refused. The
6
+ error was reported by a neighbouring writing agent's research and checked here
7
+ against the policy page before anything changed.
8
+
9
+ - **Google Play title: 30, not 50.** The limits table now cites each store's own
10
+ page with the date it was read.
11
+ - **RuStore and Meta Horizon columns.** RuStore: name 30, short description 80,
12
+ full 4000, up to 5 search tags, no What's new limit stated (publication help
13
+ page); a name claiming superiority may not pass moderation (RuStore team
14
+ article, 2023-11-09, cited as such). Meta Horizon: name 40, short description
15
+ 500, long description 1500 (metadata page, updated 2025-08-20). Two figures
16
+ passed along with the report — a RuStore name of 50 and a Horizon rule on text
17
+ in screenshots — were not found on the stores' pages and are not stated.
18
+ - **"No plurals" is an English rule.** Matching of inflected forms in other
19
+ languages is not documented by the stores, so the rule now says to check a
20
+ form in the store's own search before dropping it.
21
+
22
+ ## 0.58.0 — the look is decided before the frames, and approved on a critique
23
+
24
+ `ux-flows` picked the style pack itself, from three or four names copied out of
25
+ a catalogue that had grown to dozens, then drew a frame for every state of
26
+ every flow in that one guess and asked for approval of the lot. The approval
27
+ covered the flow and the look at once, on frames nobody had critiqued. This
28
+ release hands the visual decision to its owner and splits the approvals, from
29
+ the family's 2026-10-07 design-pipeline plan, tasks U1–U5 plus the screen
30
+ matrix and the onboarding budget.
31
+
32
+ - **U1. `/sheleg-design` decides the look; super-ux keeps no pack list.**
33
+ `ux-flows` step 4, `visual-identity.md`, `/ux`, the Cursor rule, the
34
+ `screens.md` seed and the README now delegate to the companion's own index,
35
+ defaults and director record. An underdetermined brief gets **directions
36
+ before frames**: one key screen in one state, two directions only when the
37
+ fork is real (rubric first), the human picks on a 2-up mini sheet, then the
38
+ rest of the frames. `validate_direction_before_frames` refuses a pack name in
39
+ any of those seven files.
40
+ - **U2. `Style pack: none` keeps the floor.** With sheleg-design installed its
41
+ `SLOP_MARKERS.md` catalogue applies pack or no pack; where code exists,
42
+ `npx sheleg-design-skill --lint <dir>` runs and its result is recorded;
43
+ companion absent or a version without `--lint` → `Markers: NOT_RUN`, never a
44
+ pass. `visual-identity.md`, `figma-integration.md`, `audit-depth.md`.
45
+ - **U3. Flow approval and art-direction approval are two decisions.** Frames
46
+ are critiqued with `get_screenshot` against the director record's rubric
47
+ before approval, findings as region → defect → fix triples, one re-render
48
+ round (two at most), then `unresolved` to the human. The flow records
49
+ `Art direction: approved <date> — critique: <where>`; **`U085`** refuses an
50
+ approval that cites no critique or a path that does not resolve.
51
+ - **U4. `visual-drift` in `ux-audit`.** The live pass compares the build with
52
+ its Figma frame in the same state, viewport and theme, under the capture
53
+ record of sheleg-design's `VISUAL_REVIEW.md`; an unfit or missing capture is
54
+ BLOCKED, never PASS. The live pass is offered by default for
55
+ `surface_class: flagship` or `ad`, and for landing, onboarding, paywall and
56
+ store screens when no class is recorded.
57
+ - **U5. Figma is a file per surface.** `foundation.md` → Design tooling
58
+ records the folder and a `Figma files` table with a row per surface the
59
+ product has (App / Web / ASO); the "single project file" wording is gone
60
+ from the template, the contract, `figma-integration.md` and `ux-foundation`.
61
+ - **Every screen declares its states and axes.** States: default, loading,
62
+ empty, error, offline, long-content, keyboard-up, first-run (those that
63
+ apply). Axes: `viewport; theme; text; locale`, per screen or as a Design
64
+ system default. **`U079`** (no states, Figma or not), **`U080`** (no axes or
65
+ one missing), **`U081`** (a built screen's state with an empty `Story`
66
+ cell). `U020` now finds the frame column by its header and covers every
67
+ declared state, not only loading / empty / error / success: a `keyboard-up`
68
+ row with no frame used to pass.
69
+ - **The onboarding budget is one screen** (R2 of the Apple Design
70
+ Awards-derived rubric), in `onboarding.md` ON-06 and the flow contract. Flows record `Onboarding:` and `First value:`;
71
+ **`U082`** refuses a second screen without `Onboarding budget:` citing a
72
+ director record that exists and discusses onboarding; **`U083`** warns on a
73
+ flow named for onboarding that declares nothing; **`U084`** on a count with
74
+ no first value.
75
+ - **Warn first, gate on opt-in.** Existing `screens.md` files are format 1:
76
+ `U079`, `U080`, `U081`, and `U020` on states beyond loading / empty / error /
77
+ success, report as **warnings** there, and the lint still exits 0 — what
78
+ `U020` failed before 0.58.0 still fails. A registry carrying
79
+ `<!-- screens-format: 2 -->` makes them errors; the seeded `screens.md`
80
+ writes the marker, so a new project is strict from its first line.
81
+ `docs/ux/doctor.py` names the migration on a format-1 file and `/ux-update`
82
+ performs it: one `- **Axes:** …` line in the Design system block, a state
83
+ list per screen, the lint clean of those warnings, then the marker. This
84
+ repository's own registry is on format 2. `--strict` still fails on every
85
+ warning. Measured over all 118 registries on the maintainer's machine: the first
86
+ cut of this release newly failed 71 of the 80 that passed; this one newly
87
+ fails none and adds no error anywhere.
88
+ - Evals `EV-06` (visual drift is found) and `EV-07` (one key screen, 2-up,
89
+ no pack list) join `test/evals/cases.json`; not run in CI, as before.
90
+
1
91
  ## 0.57.0 — short video gets a playbook, a hook filter and a caption linter
2
92
 
3
93
  `copywriting` covered posts for X, Reddit and LinkedIn and nothing that moves:
package/README.md CHANGED
@@ -219,7 +219,7 @@ fine without either.
219
219
 
220
220
  | When | Companion | What it adds |
221
221
  |---|---|---|
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: 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` |
223
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>` |
224
224
 
225
225
  The boundary that keeps them from fighting: BP-079..090 and BP-130..135 are
@@ -247,7 +247,7 @@ to is a skill nobody runs.
247
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 |
248
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 |
249
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 |
250
- | `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 |
251
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) |
252
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 |
253
253
 
@@ -264,8 +264,8 @@ The contracts every skill reads:
264
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 |
265
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` |
266
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 |
267
- | [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 |
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): 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 |
269
269
 
270
270
  ## Keeping installs current
271
271
 
@@ -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.57.0",
3
+ "version": "0.58.1",
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"
@@ -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 ---
@@ -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>