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 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
- 33 deterministic checks (`B001`..`B073`) — banned words, one action under two names, a figure
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
@@ -322,7 +322,7 @@ async function menu() {
322
322
 
323
323
  if (picked.length === 0) {
324
324
  if (prompter) prompter.close();
325
- console.log('Nothing selected.');
325
+ console.log('Nothing selected');
326
326
  return;
327
327
  }
328
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.32.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 screen_blocks(text: str) -> dict[str, str]:
93
- """Map SCR-id -> its section body (from its header to the next ### / ##)."""
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(r"^###\s+(SCR-\d+):", text, flags=re.MULTILINE)
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 ---
@@ -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>
@@ -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>