super-ux 0.32.0 → 0.34.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 +86 -0
- package/README.md +1 -1
- package/bin/super-ux.js +1 -1
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +52 -0
- package/plugins/super-ux/scripts/ux_lint.py +91 -19
- package/templates/brand/voice.md +8 -0
- package/templates/screens.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.0 — 2026-08-10
|
|
4
|
+
|
|
5
|
+
B-010 and B-002, and the gate that stops both coming back. The UX linter is
|
|
6
|
+
older and more central than the brand one and had neither codes nor fixtures;
|
|
7
|
+
this release gives it both, and then gates the fixtures so the harness cannot
|
|
8
|
+
fall behind the linter the way the linter fell behind the contract.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Every UX linter rule has a code, `U001`..`U054`.** Twenty-one of them, in
|
|
13
|
+
the message itself, so a rule can be searched, cited in review and gated on.
|
|
14
|
+
The full table with severities is in `references/scenario-format.md` — the
|
|
15
|
+
contract, not the source, is where a rule's meaning lives.
|
|
16
|
+
- **`test/ux_lint_test.py` covers all twenty-one.** 43 checks: every code with
|
|
17
|
+
its planted defect, and a clean twin wherever silence is the interesting
|
|
18
|
+
half. Three defects were planted in the linter itself to confirm the harness
|
|
19
|
+
bites — a duplicate-id check that never fires, a Figma-frame check short-
|
|
20
|
+
circuited, a coverage check keyed to a status that does not exist — and each
|
|
21
|
+
turned exactly one case red.
|
|
22
|
+
- **`validate_ux_lint_coverage`** — every emitted code needs a fixture **and** a
|
|
23
|
+
contract row. It went red on all twenty-one on its first run, which is what a
|
|
24
|
+
coverage gate is supposed to do the day it is added.
|
|
25
|
+
- **`validate_run_instructions`** — closes B-002 from the other side. The
|
|
26
|
+
existing gate asked *for each known destination, does a command seed it?*;
|
|
27
|
+
this one asks *for each path an instruction tells the reader to run, is it a
|
|
28
|
+
destination anything seeds?* That is the direction a rename breaks: an
|
|
29
|
+
instruction naming `docs/ux/linter.py` while commands seed `docs/ux/lint.py`
|
|
30
|
+
passed the old gate and failed the reader.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Linter output now carries the code before the message. Exit codes, severities
|
|
35
|
+
and behaviour are unchanged; anything keying off exit status is unaffected.
|
|
36
|
+
|
|
37
|
+
## 0.33.0 — 2026-08-10
|
|
38
|
+
|
|
39
|
+
The chain designed landing pages and had nowhere to record that a landing is a
|
|
40
|
+
page a machine reads. This release gives that decision a home, teaches the
|
|
41
|
+
router the words users actually bring, and adds the two copy checks that our
|
|
42
|
+
own interface failed.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **`Web surface:` — the second reader gets a field.** A screen that is a
|
|
47
|
+
public URL now carries five, and each is the design-time twin of a check an
|
|
48
|
+
audit runs on the live page later, so both ends speak one vocabulary:
|
|
49
|
+
`Route`, `Answers` (the ONE question this page answers — a second question is
|
|
50
|
+
a second page), `Indexable`, `Without JS`, `Entity`. `screens.md` answers
|
|
51
|
+
`Web surfaces: yes|no` once per project, because a declared absence is
|
|
52
|
+
countable and an unanswered question is not. Contract stays **v4**: the block
|
|
53
|
+
is optional and additive, nothing to migrate.
|
|
54
|
+
`ux-flows` asks at the moment it already asks about Figma and the style pack;
|
|
55
|
+
`ux-audit` checks a built screen against the record; **seo-aeo-audit** joins
|
|
56
|
+
`sheleg-design` and `task-pipeline` as the third companion, on the same
|
|
57
|
+
recommend-never-force contract. The reason it lives in the chain and not in
|
|
58
|
+
an audit: once a page is live its URL is in other people's links and its
|
|
59
|
+
structure is what an answer engine already quoted, so an audit then finds a
|
|
60
|
+
problem it can no longer fix.
|
|
61
|
+
- **`test/ux_lint_test.py` — the UX linter gets a fixture harness.** The brand
|
|
62
|
+
linter has carried one per code since 0.30.0; the older and more central
|
|
63
|
+
linter had none. Fourteen cases, every rule with its planted defect and its
|
|
64
|
+
clean twin, wired into CI. The backfill for the checks that predate it is
|
|
65
|
+
**B-010** on the board — named rather than implied.
|
|
66
|
+
- **B007 — a voice names one brand it admires and one it refuses.** The refused
|
|
67
|
+
half does the work: it is the only one that can be checked against a draft out
|
|
68
|
+
loud. Silent while the voice is `draft`, because the references are part of
|
|
69
|
+
calibrating it and a warning on a freshly seeded project is how a linter
|
|
70
|
+
teaches people to ignore it on day one.
|
|
71
|
+
- **B026 — a label, button, menu item or title takes no full stop.** Scoped by
|
|
72
|
+
key prefix rather than by guessing at the text: a message may be a sentence
|
|
73
|
+
and should be. It found `Nothing selected.` in this project's own installer
|
|
74
|
+
on its first run.
|
|
75
|
+
- **Four routing rows and the composite brief.** `/ux` now answers to
|
|
76
|
+
*воронка / funnel / pricing / checkout*, *дизайн / how should it look*,
|
|
77
|
+
*SEO / чтобы находилось*, and *мобильное приложение / which platform* — every
|
|
78
|
+
one of which had a capability behind it and no words in front of it. And a
|
|
79
|
+
brief that names three things is now mapped to three routes, ordered by chain
|
|
80
|
+
position, with the sequence stated before the first one runs.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- `menu.nothing` in `bin/super-ux.js` ended in a full stop, and the registry
|
|
85
|
+
agreed with it. Both corrected; B026 is what found it.
|
|
86
|
+
- This project's own `screens.md` now answers the web-surface question (`no`,
|
|
87
|
+
with the reason), and its `voice.md` names its two references.
|
|
88
|
+
|
|
3
89
|
## 0.32.0 — 2026-08-10
|
|
4
90
|
|
|
5
91
|
A structural audit of 0.31.0 found twenty-two defects that 3427 green checks
|
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
129
129
|
python3 docs/brand/lint.py
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
35 deterministic checks (`B001`..`B073`) — banned words, one action under two names, a figure
|
|
133
133
|
with no sourced fact, a field over its limit with the locale coefficient
|
|
134
134
|
applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
|
|
135
135
|
a locale that lags without saying so. Exit 0 clean, 1 warnings, 2 errors.
|
package/bin/super-ux.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Scenario-driven UI development for AI agents (Claude Code, Cursor, 70+ agents): a versioned design chain in docs/ux/, a scenario-first hard rule, a deterministic drift linter, and evidence-backed UX audits. This package is the installer CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"super-ux": "bin/super-ux.js"
|
|
@@ -201,6 +201,34 @@ def check_contract(brand_dir: Path) -> list[Finding]:
|
|
|
201
201
|
"report",
|
|
202
202
|
))
|
|
203
203
|
|
|
204
|
+
# B007 -- a voice defined only by what it is drifts toward the average of
|
|
205
|
+
# everything. Naming one brand you admire and one you refuse gives the
|
|
206
|
+
# writer two fixed points, and the refused one does most of the work: it
|
|
207
|
+
# is the only field that can be checked against a draft out loud.
|
|
208
|
+
# A `draft` voice has not been calibrated yet and the references are part
|
|
209
|
+
# of calibrating it, so firing here would put a warning on every freshly
|
|
210
|
+
# seeded project -- which is how a linter teaches people to ignore it on
|
|
211
|
+
# day one. The check begins the moment someone claims the voice is done.
|
|
212
|
+
if voice and status and status != "draft":
|
|
213
|
+
section = re.search(
|
|
214
|
+
r"^##\s+Voice references\s*$(.*?)(?=^##\s|\Z)",
|
|
215
|
+
voice, re.MULTILINE | re.DOTALL,
|
|
216
|
+
)
|
|
217
|
+
body = section.group(1) if section else ""
|
|
218
|
+
admired = re.search(r"\*\*Admired:\*\*\s*(\S.*)", body)
|
|
219
|
+
refused = re.search(r"\*\*Refused:\*\*\s*(\S.*)", body)
|
|
220
|
+
missing = [
|
|
221
|
+
name for name, m in (("Admired", admired), ("Refused", refused))
|
|
222
|
+
if not m or unfilled(m.group(1))
|
|
223
|
+
]
|
|
224
|
+
if missing:
|
|
225
|
+
findings.append(Finding(
|
|
226
|
+
"B007", SEVERITY_WARN, "voice.md", 1,
|
|
227
|
+
f"`## Voice references` is missing {', '.join(missing)} -- "
|
|
228
|
+
f"a voice with no brand it refuses to sound like has no edge "
|
|
229
|
+
f"to be checked against",
|
|
230
|
+
))
|
|
231
|
+
|
|
204
232
|
return findings
|
|
205
233
|
|
|
206
234
|
|
|
@@ -348,6 +376,12 @@ def _looks_like_copy(literal: str) -> bool:
|
|
|
348
376
|
DIRECTIVES = {"use strict", "use client", "use server"}
|
|
349
377
|
|
|
350
378
|
|
|
379
|
+
LABEL_KEY_RE = re.compile(
|
|
380
|
+
r"^(button|cta|label|title|heading|header|menu|tab|nav|placeholder"
|
|
381
|
+
r"|action|link|toggle|chip|badge)\b", re.IGNORECASE
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
351
385
|
def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
352
386
|
"""B020-B025 -- the registry, the code and the casing agree."""
|
|
353
387
|
findings: list[Finding] = []
|
|
@@ -369,6 +403,24 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
369
403
|
f"keeps one name across the whole flow",
|
|
370
404
|
))
|
|
371
405
|
|
|
406
|
+
# B026 -- a label is a name, not a statement, so it ends with nothing.
|
|
407
|
+
# Scoped by key prefix rather than by guessing at the text: a message may
|
|
408
|
+
# be a sentence and should be, while a button that ends in a full stop is
|
|
409
|
+
# the single most common tell that prose leaked into a control.
|
|
410
|
+
for row in rows:
|
|
411
|
+
if not LABEL_KEY_RE.match(row["key"]):
|
|
412
|
+
continue
|
|
413
|
+
text = row["text"].rstrip()
|
|
414
|
+
if not text.endswith(".") or text.endswith("..") or text.endswith("…"):
|
|
415
|
+
continue
|
|
416
|
+
if ". " in text: # genuinely several sentences -- a different defect
|
|
417
|
+
continue
|
|
418
|
+
findings.append(Finding(
|
|
419
|
+
"B026", SEVERITY_WARN, "strings.md", 0,
|
|
420
|
+
f"`{row['key']}` ends in a full stop: \"{text}\". A label, button, "
|
|
421
|
+
f"menu item or title is a name and takes no terminal punctuation",
|
|
422
|
+
))
|
|
423
|
+
|
|
372
424
|
for row in rows:
|
|
373
425
|
location = row["location"]
|
|
374
426
|
file_part = location.split(":")[0]
|
|
@@ -73,12 +73,12 @@ def check_unique_and_gaps(entry_ids: list[str], label: str) -> None:
|
|
|
73
73
|
seen[i] = seen.get(i, 0) + 1
|
|
74
74
|
for i, n in seen.items():
|
|
75
75
|
if n > 1:
|
|
76
|
-
err(f"{label}: duplicate id {i} ({n} entries)")
|
|
76
|
+
err(f"[U001] {label}: duplicate id {i} ({n} entries)")
|
|
77
77
|
nums = sorted(int(i.split("-")[1]) for i in seen)
|
|
78
78
|
if nums:
|
|
79
79
|
missing = [n for n in range(1, max(nums) + 1) if n not in nums]
|
|
80
80
|
if missing:
|
|
81
|
-
warn(f"{label}: id gaps (retired entries should stay): {missing}")
|
|
81
|
+
warn(f"[U002] {label}: id gaps (retired entries should stay): {missing}")
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def figma_enabled(foundation: str) -> bool | None:
|
|
@@ -89,10 +89,10 @@ def figma_enabled(foundation: str) -> bool | None:
|
|
|
89
89
|
return m.group(1).lower() == "enabled"
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
93
|
-
"""Map
|
|
92
|
+
def entry_blocks(text: str, prefix: str) -> dict[str, str]:
|
|
93
|
+
"""Map PREFIX-id -> its section body (from its header to the next ### / ##)."""
|
|
94
94
|
out: dict[str, str] = {}
|
|
95
|
-
parts = re.split(
|
|
95
|
+
parts = re.split(rf"^###\s+({prefix}-\d+):", text, flags=re.MULTILINE)
|
|
96
96
|
# parts = [pre, id1, body1, id2, body2, ...]
|
|
97
97
|
for i in range(1, len(parts), 2):
|
|
98
98
|
sid = parts[i]
|
|
@@ -102,6 +102,77 @@ def screen_blocks(text: str) -> dict[str, str]:
|
|
|
102
102
|
return out
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def screen_blocks(text: str) -> dict[str, str]:
|
|
106
|
+
"""Map SCR-id -> its section body."""
|
|
107
|
+
return entry_blocks(text, "SCR")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def web_surfaces_declared(screens: str) -> bool | None:
|
|
114
|
+
"""True/False from the project-level declaration; None if unstated."""
|
|
115
|
+
m = re.search(r"\*\*Web surfaces:\*\*\s*(yes|no)\b", screens, re.IGNORECASE)
|
|
116
|
+
if not m:
|
|
117
|
+
return None
|
|
118
|
+
return m.group(1).lower() == "yes"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_web_surface(screens: str, flows: str) -> None:
|
|
122
|
+
"""A screen a crawler will read is designed as one, or declared not to be.
|
|
123
|
+
|
|
124
|
+
The rule this enforces is decided at design time and cannot be recovered
|
|
125
|
+
by an audit: by the time a landing is live, its URL is in other people's
|
|
126
|
+
links and its structure is what an answer engine already quoted. So the
|
|
127
|
+
chain records the five things a later audit checks -- and a project with
|
|
128
|
+
no public page says so once, because a declared absence is countable and
|
|
129
|
+
an unanswered question is not.
|
|
130
|
+
"""
|
|
131
|
+
declared = web_surfaces_declared(screens)
|
|
132
|
+
blocks = {
|
|
133
|
+
sid: body for sid, body in screen_blocks(screens).items()
|
|
134
|
+
if "**Web surface:**" in body
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if declared is None:
|
|
138
|
+
warn(
|
|
139
|
+
"[U050] screens.md: no **Web surfaces:** declaration — answer yes or no once "
|
|
140
|
+
"(a public page a search or answer engine reads?). An unanswered "
|
|
141
|
+
"question reads as no, and this one cannot be fixed after launch"
|
|
142
|
+
)
|
|
143
|
+
elif declared is False and blocks:
|
|
144
|
+
for sid in sorted(blocks):
|
|
145
|
+
err(
|
|
146
|
+
f"[U051] screens.md: declares no web surfaces but {sid} carries a "
|
|
147
|
+
f"**Web surface:** block — one of the two is wrong"
|
|
148
|
+
)
|
|
149
|
+
elif declared is True and not blocks:
|
|
150
|
+
warn(
|
|
151
|
+
"[U052] screens.md: declares web surfaces but no screen carries a "
|
|
152
|
+
"**Web surface:** block"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
for sid, body in sorted(blocks.items()):
|
|
156
|
+
for field in WEB_SURFACE_FIELDS:
|
|
157
|
+
if f"**{field}:**" not in body:
|
|
158
|
+
err(f"[U053] screens.md: {sid} web surface block is missing **{field}:**")
|
|
159
|
+
|
|
160
|
+
# A declaration of "no" is silence, so it must not be able to hide a flow
|
|
161
|
+
# that plainly starts on the web. This is the one contradiction the
|
|
162
|
+
# declaration cannot absorb.
|
|
163
|
+
if declared is False:
|
|
164
|
+
for fid, body in sorted(entry_blocks(flows, "FLW").items()):
|
|
165
|
+
m = re.search(r"\*\*Entry point:\*\*\s*(.+)", body)
|
|
166
|
+
if not m:
|
|
167
|
+
continue
|
|
168
|
+
entry = m.group(1).strip()
|
|
169
|
+
if re.match(r"https?://|/\S", entry):
|
|
170
|
+
warn(
|
|
171
|
+
f"[U054] flows.md: {fid} starts at a URL ({entry.split()[0]}) while "
|
|
172
|
+
f"screens.md declares no web surfaces — one of the two is wrong"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
105
176
|
VISION_SECTIONS = [
|
|
106
177
|
"1. Essence",
|
|
107
178
|
"2. Core idea",
|
|
@@ -129,7 +200,7 @@ def check_vision(ux: Path, vision: str) -> None:
|
|
|
129
200
|
return
|
|
130
201
|
for section in VISION_SECTIONS:
|
|
131
202
|
if not re.search(rf"^##\s+{re.escape(section)}\s*$", vision, re.MULTILINE):
|
|
132
|
-
err(f"vision.md: missing section '## {section}'")
|
|
203
|
+
err(f"[U030] vision.md: missing section '## {section}'")
|
|
133
204
|
# Emptiness is a defect only once the document claims to be finished.
|
|
134
205
|
# A freshly seeded template is all headings and no content by design, and
|
|
135
206
|
# a linter that fails on its own seed teaches people to skip the linter.
|
|
@@ -141,17 +212,17 @@ def check_vision(ux: Path, vision: str) -> None:
|
|
|
141
212
|
if len(body) == 2:
|
|
142
213
|
tail = re.split(r"^##\s", body[1], maxsplit=1, flags=re.MULTILINE)[0]
|
|
143
214
|
if not tail.strip():
|
|
144
|
-
err(f"vision.md: approved but '## {section}' is empty — "
|
|
215
|
+
err(f"[U031] vision.md: approved but '## {section}' is empty — "
|
|
145
216
|
f"the section that settles arguments cannot be blank")
|
|
146
217
|
|
|
147
218
|
root = ux.parent.parent if ux.name == "ux" else ux.parent
|
|
148
219
|
present = [root / n for n in INSTRUCTION_FILES if (root / n).is_file()]
|
|
149
220
|
if not present:
|
|
150
|
-
warn("vision.md exists but the project has no CLAUDE.md / AGENTS.md / "
|
|
221
|
+
warn("[U032] vision.md exists but the project has no CLAUDE.md / AGENTS.md / "
|
|
151
222
|
"GEMINI.md — the alignment rule has nowhere to live")
|
|
152
223
|
return
|
|
153
224
|
if not any(VISION_RULE_HEADING in read(p) for p in present):
|
|
154
|
-
warn(f"vision.md exists but no '{VISION_RULE_HEADING}' block in "
|
|
225
|
+
warn(f"[U033] vision.md exists but no '{VISION_RULE_HEADING}' block in "
|
|
155
226
|
f"{', '.join(p.name for p in present)} — nothing ever reads the vision "
|
|
156
227
|
f"(run the `vision` skill's step 4)")
|
|
157
228
|
|
|
@@ -165,7 +236,7 @@ def check_links(ux: Path) -> None:
|
|
|
165
236
|
continue
|
|
166
237
|
resolved = (md.parent / target.split("#", 1)[0]).resolve()
|
|
167
238
|
if not resolved.exists():
|
|
168
|
-
warn(f"{md.name}: broken link -> {target}")
|
|
239
|
+
warn(f"[U040] {md.name}: broken link -> {target}")
|
|
169
240
|
|
|
170
241
|
|
|
171
242
|
def main() -> int:
|
|
@@ -205,18 +276,18 @@ def main() -> int:
|
|
|
205
276
|
continue
|
|
206
277
|
idx = index_ids(text, pref)
|
|
207
278
|
for missing in sorted(entries - idx):
|
|
208
|
-
warn(f"{name}: {missing} has no index row")
|
|
279
|
+
warn(f"[U003] {name}: {missing} has no index row")
|
|
209
280
|
for ghost in sorted(idx - entries):
|
|
210
|
-
err(f"{name}: index lists {ghost} but no entry exists")
|
|
281
|
+
err(f"[U004] {name}: index lists {ghost} but no entry exists")
|
|
211
282
|
|
|
212
283
|
# --- Flows reference existing screens ---
|
|
213
284
|
if has_flows and has_screens:
|
|
214
285
|
screen_ids = set(ids(screens, "SCR"))
|
|
215
286
|
used = refs(flows, "SCR")
|
|
216
287
|
for miss in sorted(used - screen_ids):
|
|
217
|
-
err(f"flows.md references {miss} but screens.md has no such screen")
|
|
288
|
+
err(f"[U010] flows.md references {miss} but screens.md has no such screen")
|
|
218
289
|
for orphan in sorted(screen_ids - used):
|
|
219
|
-
warn(f"screens.md: {orphan} is used by no flow (orphan)")
|
|
290
|
+
warn(f"[U011] screens.md: {orphan} is used by no flow (orphan)")
|
|
220
291
|
|
|
221
292
|
# --- Scenario traces resolve ---
|
|
222
293
|
if ids(scenarios, "SCN"):
|
|
@@ -226,10 +297,10 @@ def main() -> int:
|
|
|
226
297
|
traced_flw = refs(scenarios, "FLW")
|
|
227
298
|
if has_stories:
|
|
228
299
|
for miss in sorted(traced_st - story_ids):
|
|
229
|
-
warn(f"scenarios.md: traces to {miss} which is not in foundation.md")
|
|
300
|
+
warn(f"[U012] scenarios.md: traces to {miss} which is not in foundation.md")
|
|
230
301
|
if has_flows:
|
|
231
302
|
for miss in sorted(traced_flw - flow_ids):
|
|
232
|
-
warn(f"scenarios.md: traces to {miss} which is not in flows.md")
|
|
303
|
+
warn(f"[U013] scenarios.md: traces to {miss} which is not in flows.md")
|
|
233
304
|
|
|
234
305
|
# --- must/should stories have a scenario ---
|
|
235
306
|
if has_stories and ids(scenarios, "SCN"):
|
|
@@ -241,7 +312,7 @@ def main() -> int:
|
|
|
241
312
|
tail = re.split(r"^#{2,3}\s", foundation[m.end():], maxsplit=1, flags=re.MULTILINE)[0]
|
|
242
313
|
if re.search(r"\*\*Priority:\*\*\s*(must|should)", tail, re.IGNORECASE):
|
|
243
314
|
if sid not in traced:
|
|
244
|
-
warn(f"foundation.md: {sid} (must/should) has no scenario tracing to it")
|
|
315
|
+
warn(f"[U014] foundation.md: {sid} (must/should) has no scenario tracing to it")
|
|
245
316
|
|
|
246
317
|
# --- Screen-level: Figma frames, coverage, drift status ---
|
|
247
318
|
if has_screens:
|
|
@@ -259,13 +330,14 @@ def main() -> int:
|
|
|
259
330
|
cells = [c.strip() for c in rest.split("|")]
|
|
260
331
|
frame = cells[1] if len(cells) >= 2 else ""
|
|
261
332
|
if not frame or frame in ("-", "—", "<frame deep-link>", "<frame link>"):
|
|
262
|
-
err(f"screens.md: {sid} state '{state}' has no Figma frame link")
|
|
333
|
+
err(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
|
|
263
334
|
cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
|
|
264
335
|
cov = cov_m.group(1).strip() if cov_m else ""
|
|
265
336
|
if status == "built" and (not cov or cov.lower().startswith("none")):
|
|
266
|
-
warn(f"screens.md: {sid} is 'built' but has no Coverage")
|
|
337
|
+
warn(f"[U021] screens.md: {sid} is 'built' but has no Coverage")
|
|
267
338
|
|
|
268
339
|
check_vision(ux, vision)
|
|
340
|
+
check_web_surface(screens, flows)
|
|
269
341
|
check_links(ux)
|
|
270
342
|
|
|
271
343
|
# --- Report ---
|
package/templates/brand/voice.md
CHANGED
|
@@ -56,3 +56,11 @@ Conventions rather than character. Each is decided again in
|
|
|
56
56
|
Copied from the pack, kept here so the audit can look for it by name.
|
|
57
57
|
|
|
58
58
|
<the degenerate form this voice collapses into when overdone>
|
|
59
|
+
|
|
60
|
+
## Voice references
|
|
61
|
+
|
|
62
|
+
<!-- Two fixed points. The refused one does most of the work: it is the only
|
|
63
|
+
half that can be checked against a draft out loud ("this is the thing we said
|
|
64
|
+
we would never sound like"). Required once Status leaves `draft`. -->
|
|
65
|
+
- **Admired:** <brand or product, and the ONE thing it does that you want>
|
|
66
|
+
- **Refused:** <brand or product, and the ONE thing it does that you refuse>
|
package/templates/screens.md
CHANGED
|
@@ -23,6 +23,17 @@ contract) before drawing anything; record its token file below. -->
|
|
|
23
23
|
- **Component source:** <shared UI components dir, e.g. src/components/>
|
|
24
24
|
- **Assets:** <icons/illustrations location>
|
|
25
25
|
|
|
26
|
+
## Web surfaces
|
|
27
|
+
|
|
28
|
+
<!-- Does this product have pages a search engine or an AI answer engine will
|
|
29
|
+
read — a landing, pricing, docs, blog? Answer once, here. "no" silences the
|
|
30
|
+
check; flip it to "yes" the moment a public page is designed, and give every
|
|
31
|
+
public screen the **Web surface:** block shown below. This is decided at design
|
|
32
|
+
time on purpose: once a page is live its URL is in other people's links and its
|
|
33
|
+
structure is what an answer engine already quoted, so an audit afterwards finds
|
|
34
|
+
the problem it can no longer fix. -->
|
|
35
|
+
- **Web surfaces:** no
|
|
36
|
+
|
|
26
37
|
## Screens
|
|
27
38
|
|
|
28
39
|
<!-- One entry per screen (see ux-contract v4 for field rules):
|
|
@@ -38,6 +49,12 @@ contract) before drawing anything; record its token file below. -->
|
|
|
38
49
|
| empty | <trigger> | <frame deep-link> | <prompt to act> |
|
|
39
50
|
| error | <trigger> | <frame deep-link> | <message + recovery> |
|
|
40
51
|
| loading | <trigger> | <frame deep-link> | <skeleton/progress> |
|
|
52
|
+
- **Web surface:** (only when this screen is a public URL; all five required)
|
|
53
|
+
- **Route:** </pricing — the path, readable and stable>
|
|
54
|
+
- **Answers:** <the ONE question this page answers; a second question is a second page>
|
|
55
|
+
- **Indexable:** <yes | no + why | canonical → /other-path>
|
|
56
|
+
- **Without JS:** <what a reader gets with no JS executed — the answer, or nothing>
|
|
57
|
+
- **Entity:** <schema.org type + the thing it describes, matched to visible content>
|
|
41
58
|
- **Wireframe:** wireframes/SCR-01.md (optional)
|
|
42
59
|
- **Coverage:** <file:line, or "none yet">
|
|
43
60
|
- **Scenarios:** <SCN-… touching this screen>
|