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 +90 -0
- package/README.md +4 -4
- package/cursor/rules/ux-flows.mdc +23 -16
- package/package.json +1 -1
- package/plugins/super-ux/scripts/ux_doctor.py +24 -0
- package/plugins/super-ux/scripts/ux_lint.py +240 -8
- package/templates/flows.md +4 -0
- package/templates/foundation.md +11 -4
- package/templates/screens.md +21 -11
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
|
|
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
|
|
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):
|
|
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/
|
|
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 →
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
#
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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 ---
|
package/templates/flows.md
CHANGED
|
@@ -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:**
|
package/templates/foundation.md
CHANGED
|
@@ -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).
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
package/templates/screens.md
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
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>
|