super-ux 0.28.0 → 0.29.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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.0 — 2026-08-05
4
+
5
+ A pass aimed at the installed base rather than the catalog. Run across the
6
+ twelve projects using super-ux, the tooling turned out to be blind to the
7
+ most common failure: only two are cleanly on contract v4, five carry no
8
+ marker at all — including the two largest bases, at 119 and 120 scenarios —
9
+ one holds v2, v3 and v4 across four artifacts simultaneously, and one keeps
10
+ its base under a name the contract does not own, so four audit reports were
11
+ produced against scenarios the tooling could not find.
12
+
13
+ ### Added
14
+ - **`/ux-doctor` and `docs/ux/doctor.py`.** `ux_lint` checks a chain against
15
+ itself, so a base written entirely to an old contract passes it — from the
16
+ inside such a chain is consistent. The doctor reports the effective
17
+ contract version, what each version since it introduced, mixed versions
18
+ across artifacts, files the tooling cannot find under their contract
19
+ names, audits produced against a base that is not there, and which
20
+ additive sections a project has not adopted. Read-only unless `--fix`,
21
+ which does only what cannot be wrong: renames, and moving loose audit
22
+ reports into `audits/`. Contract upgrades stay content decisions for
23
+ `/ux-update`. Wired into `/ux` status and the Cursor rule.
24
+ - **Information architecture — BP-180, BP-181.** BP-052 requires navigation
25
+ to be visible and BP-049 puts it in reach; neither judges whether the
26
+ *groups* make sense. Card sorting for grouping, tree testing for labels —
27
+ the one navigation decision that cannot be judged from the inside, because
28
+ the team already knows where everything is.
29
+ - **Moderated test tasks generated from `scenarios.md`.** A scenario is
30
+ already the shape a test task wants — situation, goal, observable success
31
+ — so the tasks are a rewrite of the base rather than a new artifact, and
32
+ what comes back grades against the same base.
33
+ - **`benchmark:<competitor>` audit scope.** Every other scope measures the
34
+ product against its own chain and cannot report that a flow is two steps
35
+ longer than everyone else's. Same axes both sides, observable from outside
36
+ only, and gaps recorded as opportunities for the foundation rather than as
37
+ defects in the report.
38
+ - **Reviews and support tickets as WHY-layer evidence** in `ux-foundation` —
39
+ the cheapest input the layer has, and the one most often skipped because
40
+ it does not feel like research.
41
+
3
42
  ## 0.28.0 — 2026-08-05
4
43
 
5
44
  Closes the carry-over ledger the 0.27 audit opened. Twenty-three practices,
package/README.md CHANGED
@@ -185,9 +185,10 @@ The contracts every skill reads:
185
185
  |---|---|
186
186
  | [scenario-format.md](plugins/super-ux/skills/references/scenario-format.md) | **The contract (ux-contract v4).** File layout, every field name, stable IDs (`P` `JTBD` `JRN` `ST` `FLW` `SCR` `SCN`), completeness checklists, the `draft → validated → implemented` lifecycle, audit verdicts and severities, the UX-plan format |
187
187
  | [system-map.md](plugins/super-ux/skills/references/system-map.md) | The whole system on one page — pipeline, files, skills, companions, and the four sync rules; every skill points here |
188
+ | [ux_doctor.py](plugins/super-ux/scripts/ux_doctor.py) | Contract doctor — reports mixed or stale contract versions across a project's artifacts, files the tooling cannot find under their contract names, and audits produced against a base that is not there. `/ux-lint` checks a chain against itself; this checks it against the contract. Installed as `docs/ux/doctor.py`, read-only unless `--fix` |
188
189
  | [best-practices-index.md](plugins/super-ux/skills/references/best-practices-index.md) | Generated tag index over the catalog — tag → ids, id → title. Read it to decide which entries to open; regenerated by `plugins/super-ux/scripts/bp_index.py` and checked for drift by the validator |
189
190
  | [ux-design-principles.md](plugins/super-ux/skills/references/ux-design-principles.md) | How the agent thinks: the design pipeline (forward and backwards), task analysis, flow rules, heuristics PRN-01..21, the improvement procedure, anti-patterns |
190
- | [best-practices.md](plugins/super-ux/skills/references/best-practices.md) | Living, tag-indexed catalog of 179 proven practices — subscription-app laws, mobile/web/voice guidance (Apple HIG 2025, M3 Expressive, NN/g, Baymard, WCAG 2.2), monetization economics (RevenueCat/PLG 2025 benchmarks, ASO, freemium boundaries), web funnels end to end (landing, pricing, checkout, dunning, cancel) and web2app (paid handoff, deferred deep links, storefront rules), motion and page weight (HTTP Archive field data, W3C sustainability), accessibility as it actually fails (WebAIM Million, EAA/ADA exposure), frustration telemetry, gamification and trend governance, growth loops and referral mechanics, empty states, authentication (NIST SP 800-63B rev 4) and form recovery, motion craft and perceived quality, the defaults that make an interface read as generated, interface state, locale and platform surfaces (Web Interface Guidelines), visual craft, Figma structure |
191
+ | [best-practices.md](plugins/super-ux/skills/references/best-practices.md) | Living, tag-indexed catalog of 181 proven practices — subscription-app laws, mobile/web/voice guidance (Apple HIG 2025, M3 Expressive, NN/g, Baymard, WCAG 2.2), monetization economics (RevenueCat/PLG 2025 benchmarks, ASO, freemium boundaries), web funnels end to end (landing, pricing, checkout, dunning, cancel) and web2app (paid handoff, deferred deep links, storefront rules), motion and page weight (HTTP Archive field data, W3C sustainability), accessibility as it actually fails (WebAIM Million, EAA/ADA exposure), frustration telemetry, gamification and trend governance, growth loops and referral mechanics, empty states, authentication (NIST SP 800-63B rev 4) and form recovery, motion craft and perceived quality, the defaults that make an interface read as generated, interface state, locale and platform surfaces (Web Interface Guidelines), visual craft, Figma structure |
191
192
  | [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 |
192
193
  | [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 — Apple HIG, Material 3, W3C ARIA APG, GOV.UK |
193
194
  | [visual-identity.md](plugins/super-ux/skills/references/visual-identity.md) | The visual layer and its owner: one style pack for the whole product, where it's recorded, how it meets Figma and code, and the division of labor with the craft floors |
package/bin/super-ux.js CHANGED
@@ -89,16 +89,23 @@ function installCursor(target, force) {
89
89
  // The linter is code, not a template — refresh it to the shipped version.
90
90
  // Shipped via package.json files[]; if that ever regresses, warn instead of
91
91
  // dying on an ENOENT stack trace after the rules are already installed.
92
- const lintSrc = path.join(ROOT, 'plugins', 'super-ux', 'scripts', 'ux_lint.py');
93
- const lintDst = path.join(target, 'docs', 'ux', 'lint.py');
94
- if (fs.existsSync(lintSrc)) {
95
- fs.copyFileSync(lintSrc, lintDst);
96
- console.log(`sync: ${lintDst}`);
97
- } else {
98
- console.error(
99
- `warning: linter not found in this package (${lintSrc}); docs/ux/lint.py was not installed.\n` +
100
- ` Get it from https://github.com/${REPO}/blob/main/plugins/super-ux/scripts/ux_lint.py`
101
- );
92
+ // Paths stay literal so test/validate.py can read them out of this source and
93
+ // check them against package.json files[] a variable segment here silently
94
+ // turns that check into a directory prefix nobody ships.
95
+ for (const [src, from, dst] of [
96
+ ['ux_lint.py', path.join(ROOT, 'plugins', 'super-ux', 'scripts', 'ux_lint.py'), 'lint.py'],
97
+ ['ux_doctor.py', path.join(ROOT, 'plugins', 'super-ux', 'scripts', 'ux_doctor.py'), 'doctor.py'],
98
+ ]) {
99
+ const to = path.join(target, 'docs', 'ux', dst);
100
+ if (fs.existsSync(from)) {
101
+ fs.copyFileSync(from, to);
102
+ console.log(`sync: ${to}`);
103
+ } else {
104
+ console.error(
105
+ `warning: ${src} not found in this package (${from}); docs/ux/${dst} was not installed.\n` +
106
+ ` Get it from https://github.com/${REPO}/blob/main/plugins/super-ux/scripts/${src}`
107
+ );
108
+ }
102
109
  }
103
110
 
104
111
  console.log(`done: ${installed} installed, ${skipped} skipped`);
@@ -14,6 +14,10 @@ alwaysApply: true
14
14
  - Run the linter after any UX change and before calling work done:
15
15
  `python3 docs/ux/lint.py`. It must pass — drift must not merge; wire it
16
16
  into CI/pre-commit.
17
+ - Run `python3 docs/ux/doctor.py` when picking this base up after a while.
18
+ The linter checks the chain against itself and stays silent when the whole
19
+ base is written to an old contract; the doctor is what notices. Fix a
20
+ mixed or stale contract before designing on top of it.
17
21
  - Any change that touches user-facing behavior MUST update
18
22
  `docs/ux/scenarios.md` in the same change (add/adjust scenarios, statuses,
19
23
  coverage). New user-facing behavior with no scenario is a blocker, not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.28.0",
3
+ "version": "0.29.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"
@@ -10,6 +10,7 @@
10
10
  "cursor",
11
11
  "templates",
12
12
  "plugins/super-ux/scripts/ux_lint.py",
13
+ "plugins/super-ux/scripts/ux_doctor.py",
13
14
  "README.md",
14
15
  "LICENSE",
15
16
  "CHANGELOG.md"
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnose a project's UX chain against the current contract (stdlib only).
3
+
4
+ `ux_lint.py` checks a chain against itself -- ids, links, orphans. It cannot
5
+ tell you that the whole base is written to a contract three versions old,
6
+ because from the inside everything is consistent. That is the drift this
7
+ finds: between the project and the contract it was written to.
8
+
9
+ Read-only by default. `--fix` applies only the changes that cannot be wrong:
10
+ renaming a file the contract owns and stamping a marker onto an artifact whose
11
+ shape already matches. Everything else is reported for a human to decide.
12
+
13
+ python3 ux_doctor.py [path] # report
14
+ python3 ux_doctor.py [path] --fix # apply the safe subset
15
+ python3 ux_doctor.py [path] --brief # one line, for sweeping many projects
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ CURRENT = 4
25
+
26
+ # What each contract version introduced, so a report can say what is missing
27
+ # rather than only that something is old.
28
+ HISTORY = {
29
+ 2: "`Traces:` on every scenario + traceability rules; `foundation.md` (personas, JTBD, journeys, stories)",
30
+ 3: "scenarios become use cases (`user action -> system response`), `Alt paths`, `Traces` includes `FLW-NN`; `flows.md`; `plans/`",
31
+ 4: "`screens.md` — one `SCR-NN` entry per screen with per-state Figma frames; flows reference screens by id instead of respecifying them",
32
+ }
33
+
34
+ # Additive since v4 — absence is not a version problem, but it is worth naming.
35
+ ADDITIVE = [
36
+ ("foundation.md", "## Product mechanics", "Product mechanics section (personalization, engagement, a11y regime) — 0.26.1"),
37
+ ("scenarios.md", "**Telemetry:**", "`Telemetry` on scenarios — the bridge to analytics practices, 0.28.0"),
38
+ ("foundation.md", "**Kill criteria:**", "`Kill criteria` on stories — gives `dropped` a definition, 0.28.0"),
39
+ ]
40
+
41
+ ARTIFACTS = ["foundation.md", "flows.md", "screens.md", "scenarios.md"]
42
+
43
+ # Names the contract owns, and the near-misses seen in the wild.
44
+ RENAMES = {
45
+ "ux-scenarios.md": "scenarios.md",
46
+ "ux-flows.md": "flows.md",
47
+ "ux-foundation.md": "foundation.md",
48
+ "ux-screens.md": "screens.md",
49
+ }
50
+
51
+
52
+ def find_ux_dir(arg: str | None) -> Path | None:
53
+ base = Path(arg) if arg else Path.cwd()
54
+ for cand in (base, base / "docs" / "ux", base.parent if base.name else base):
55
+ if any((cand / a).exists() for a in ARTIFACTS) or (cand / "ux-scenarios.md").exists():
56
+ return cand
57
+ return None
58
+
59
+
60
+ def marker(path: Path) -> int | None:
61
+ try:
62
+ head = path.read_text(encoding="utf-8", errors="ignore")[:2000]
63
+ except OSError:
64
+ return None
65
+ m = re.search(r"ux-contract v(\d+)", head)
66
+ return int(m.group(1)) if m else None
67
+
68
+
69
+ def diagnose(ux: Path) -> dict:
70
+ present = {a: (ux / a).exists() for a in ARTIFACTS}
71
+ markers = {a: marker(ux / a) for a in ARTIFACTS if present[a]}
72
+ known = [v for v in markers.values() if v is not None]
73
+
74
+ misnamed = {
75
+ wrong: right
76
+ for wrong, right in RENAMES.items()
77
+ if (ux / wrong).exists() and not (ux / right).exists()
78
+ }
79
+ # Audit reports loose in docs/ux instead of docs/ux/audits/
80
+ loose = sorted(
81
+ p.name for p in ux.glob("*.md")
82
+ if re.search(r"audit", p.name, re.I) and p.name not in ARTIFACTS
83
+ )
84
+ audits = sorted(p.name for p in (ux / "audits").glob("*.md")) if (ux / "audits").is_dir() else []
85
+
86
+ missing_additive = []
87
+ for fname, needle, label in ADDITIVE:
88
+ f = ux / fname
89
+ if f.exists() and needle not in f.read_text(encoding="utf-8", errors="ignore"):
90
+ missing_additive.append(label)
91
+
92
+ return {
93
+ "present": present,
94
+ "markers": markers,
95
+ "effective": min(known) if known else None,
96
+ "mixed": len(set(known)) > 1,
97
+ "misnamed": misnamed,
98
+ "loose_audits": loose,
99
+ "audits": audits,
100
+ "missing_additive": missing_additive,
101
+ }
102
+
103
+
104
+ def report(ux: Path, d: dict) -> int:
105
+ problems = 0
106
+ print(f"docs/ux: {ux}")
107
+
108
+ eff, markers = d["effective"], d["markers"]
109
+ unmarked = [a for a, v in markers.items() if v is None]
110
+
111
+ if d["mixed"]:
112
+ problems += 1
113
+ detail = ", ".join(f"{a} v{v}" for a, v in sorted(markers.items()) if v)
114
+ print(f" MIXED CONTRACT: {detail}")
115
+ print(" Each artifact was last touched by a different version and nothing reconciled them.")
116
+ elif eff is None:
117
+ problems += 1
118
+ print(" NO CONTRACT MARKER on any artifact — the chain is unmanaged.")
119
+ elif eff < CURRENT:
120
+ problems += 1
121
+ print(f" BEHIND: contract v{eff}, current v{CURRENT}")
122
+
123
+ if unmarked and not d["mixed"]:
124
+ problems += 1
125
+ print(f" UNMARKED: {', '.join(sorted(unmarked))}")
126
+
127
+ lowest = eff if eff is not None else 1
128
+ for v in range(lowest + 1, CURRENT + 1):
129
+ if v in HISTORY:
130
+ print(f" v{v} added — {HISTORY[v]}")
131
+
132
+ for wrong, right in d["misnamed"].items():
133
+ problems += 1
134
+ print(f" MISNAMED: {wrong} -> {right} (the tooling looks for the contract name and finds nothing)")
135
+
136
+ missing_core = [a for a, ok in d["present"].items() if not ok]
137
+ if missing_core:
138
+ print(f" ABSENT: {', '.join(missing_core)}")
139
+
140
+ if d["loose_audits"]:
141
+ problems += 1
142
+ print(f" AUDITS OUTSIDE audits/: {', '.join(d['loose_audits'])}")
143
+
144
+ if d["audits"] and not d["present"].get("scenarios.md"):
145
+ problems += 1
146
+ print(f" {len(d['audits'])} audit report(s) but no scenarios.md — audited against a base that is not there")
147
+
148
+ for label in d["missing_additive"]:
149
+ print(f" optional, absent: {label}")
150
+
151
+ print(" OK — on the current contract" if problems == 0 else f" {problems} problem(s)")
152
+ return problems
153
+
154
+
155
+ def fix(ux: Path, d: dict) -> list[str]:
156
+ """Only the changes that cannot be wrong."""
157
+ done = []
158
+ for wrong, right in d["misnamed"].items():
159
+ (ux / wrong).rename(ux / right)
160
+ done.append(f"renamed {wrong} -> {right}")
161
+ if d["loose_audits"]:
162
+ (ux / "audits").mkdir(exist_ok=True)
163
+ for name in d["loose_audits"]:
164
+ (ux / name).rename(ux / "audits" / name)
165
+ done.append(f"moved {name} -> audits/")
166
+ return done
167
+
168
+
169
+ def main() -> int:
170
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
171
+ ux = find_ux_dir(args[0] if args else None)
172
+ if ux is None:
173
+ print("no UX docs found (docs/ux/). Run /ux to set one up.")
174
+ return 0
175
+
176
+ d = diagnose(ux)
177
+ if "--brief" in sys.argv:
178
+ eff = d["effective"]
179
+ state = "mixed" if d["mixed"] else (f"v{eff}" if eff else "unmarked")
180
+ flags = []
181
+ if d["misnamed"]:
182
+ flags.append("misnamed")
183
+ if d["loose_audits"]:
184
+ flags.append("loose-audits")
185
+ if d["audits"] and not d["present"].get("scenarios.md"):
186
+ flags.append("audits-without-base")
187
+ print(f"{ux} {state}{' ' + ','.join(flags) if flags else ''}")
188
+ return 0
189
+
190
+ problems = report(ux, d)
191
+ if "--fix" in sys.argv:
192
+ applied = fix(ux, d)
193
+ print("\nApplied:" if applied else "\nNothing to apply automatically.")
194
+ for line in applied:
195
+ print(f" {line}")
196
+ if applied:
197
+ print(" Re-run without --fix to see what is left for a human.")
198
+ elif problems:
199
+ print("\n --fix applies the mechanical subset (renames, moving audit reports).")
200
+ print(" Contract upgrades are content decisions: run /ux-update for those.")
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main())