super-ux 0.32.0 → 0.33.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,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0 — 2026-08-10
4
+
5
+ The chain designed landing pages and had nowhere to record that a landing is a
6
+ page a machine reads. This release gives that decision a home, teaches the
7
+ router the words users actually bring, and adds the two copy checks that our
8
+ own interface failed.
9
+
10
+ ### Added
11
+
12
+ - **`Web surface:` — the second reader gets a field.** A screen that is a
13
+ public URL now carries five, and each is the design-time twin of a check an
14
+ audit runs on the live page later, so both ends speak one vocabulary:
15
+ `Route`, `Answers` (the ONE question this page answers — a second question is
16
+ a second page), `Indexable`, `Without JS`, `Entity`. `screens.md` answers
17
+ `Web surfaces: yes|no` once per project, because a declared absence is
18
+ countable and an unanswered question is not. Contract stays **v4**: the block
19
+ is optional and additive, nothing to migrate.
20
+ `ux-flows` asks at the moment it already asks about Figma and the style pack;
21
+ `ux-audit` checks a built screen against the record; **seo-aeo-audit** joins
22
+ `sheleg-design` and `task-pipeline` as the third companion, on the same
23
+ recommend-never-force contract. The reason it lives in the chain and not in
24
+ an audit: once a page is live its URL is in other people's links and its
25
+ structure is what an answer engine already quoted, so an audit then finds a
26
+ problem it can no longer fix.
27
+ - **`test/ux_lint_test.py` — the UX linter gets a fixture harness.** The brand
28
+ linter has carried one per code since 0.30.0; the older and more central
29
+ linter had none. Fourteen cases, every rule with its planted defect and its
30
+ clean twin, wired into CI. The backfill for the checks that predate it is
31
+ **B-010** on the board — named rather than implied.
32
+ - **B007 — a voice names one brand it admires and one it refuses.** The refused
33
+ half does the work: it is the only one that can be checked against a draft out
34
+ loud. Silent while the voice is `draft`, because the references are part of
35
+ calibrating it and a warning on a freshly seeded project is how a linter
36
+ teaches people to ignore it on day one.
37
+ - **B026 — a label, button, menu item or title takes no full stop.** Scoped by
38
+ key prefix rather than by guessing at the text: a message may be a sentence
39
+ and should be. It found `Nothing selected.` in this project's own installer
40
+ on its first run.
41
+ - **Four routing rows and the composite brief.** `/ux` now answers to
42
+ *воронка / funnel / pricing / checkout*, *дизайн / how should it look*,
43
+ *SEO / чтобы находилось*, and *мобильное приложение / which platform* — every
44
+ one of which had a capability behind it and no words in front of it. And a
45
+ brief that names three things is now mapped to three routes, ordered by chain
46
+ position, with the sequence stated before the first one runs.
47
+
48
+ ### Fixed
49
+
50
+ - `menu.nothing` in `bin/super-ux.js` ended in a full stop, and the registry
51
+ agreed with it. Both corrected; B026 is what found it.
52
+ - This project's own `screens.md` now answers the web-surface question (`no`,
53
+ with the reason), and its `voice.md` names its two references.
54
+
3
55
  ## 0.32.0 — 2026-08-10
4
56
 
5
57
  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.33.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]
@@ -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
+ "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"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
+ "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"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"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",
@@ -266,6 +337,7 @@ def main() -> int:
266
337
  warn(f"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>