super-ux 0.19.0 → 0.26.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 +400 -0
- package/README.md +185 -140
- package/bin/super-ux.js +15 -3
- package/cursor/rules/super-ux.mdc +12 -6
- package/cursor/rules/ux-audit.mdc +9 -2
- package/cursor/rules/ux-flows.mdc +20 -3
- package/cursor/rules/ux-foundation.mdc +9 -0
- package/cursor/rules/ux-scenarios.mdc +10 -3
- package/package.json +6 -2
- package/plugins/super-ux/scripts/ux_lint.py +231 -0
- package/templates/README.md +4 -2
- package/templates/claude-rule.md +9 -4
- package/templates/flows.md +2 -0
- package/templates/foundation.md +12 -2
- package/templates/screens.md +5 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""super-ux linter — checks a target project's docs/ux/ for integrity and drift.
|
|
3
|
+
|
|
4
|
+
Deterministic enforcement of the ux-contract: run it after any UX change and
|
|
5
|
+
before calling the work done, and wire it into the project's CI/pre-commit.
|
|
6
|
+
It turns the prose rules (same-change, no lost Figma, no orphans, no drift)
|
|
7
|
+
into a check that fails.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 docs/ux/lint.py # lint ./docs/ux
|
|
11
|
+
python3 docs/ux/lint.py <dir> # lint <dir> (a docs/ux directory or its parent)
|
|
12
|
+
python3 docs/ux/lint.py --strict # warnings also fail (exit 1)
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 clean (warnings allowed unless --strict), 1 problems found,
|
|
15
|
+
2 no UX docs at all (run /ux first). Stdlib only; tolerant parsing — reports
|
|
16
|
+
what it can and never crashes on malformed markdown.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
ERRORS: list[str] = []
|
|
26
|
+
WARNS: list[str] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def err(msg: str) -> None:
|
|
30
|
+
ERRORS.append(msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def warn(msg: str) -> None:
|
|
34
|
+
WARNS.append(msg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read(path: Path) -> str:
|
|
38
|
+
try:
|
|
39
|
+
text = path.read_text(encoding="utf-8")
|
|
40
|
+
except OSError:
|
|
41
|
+
return ""
|
|
42
|
+
# Strip HTML comments so template examples (shipped commented-out) and
|
|
43
|
+
# notes are never parsed as real entries.
|
|
44
|
+
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_ux_dir(arg: str | None) -> Path | None:
|
|
48
|
+
base = Path(arg) if arg else Path.cwd()
|
|
49
|
+
for cand in (base, base / "docs" / "ux", base.parent if base.name else base):
|
|
50
|
+
if (cand / "scenarios.md").exists() or (cand / "foundation.md").exists():
|
|
51
|
+
return cand
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ids(text: str, prefix: str) -> list[str]:
|
|
56
|
+
"""All '### PREFIX-NN:' entry ids, in order."""
|
|
57
|
+
return re.findall(rf"^###\s+({prefix}-\d+):", text, re.MULTILINE)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def index_ids(text: str, prefix: str) -> set[str]:
|
|
61
|
+
"""Ids appearing in a leading '| PREFIX-NN |' index-table cell."""
|
|
62
|
+
return set(re.findall(rf"^\|\s*({prefix}-\d+)\s*\|", text, re.MULTILINE))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def refs(text: str, prefix: str) -> set[str]:
|
|
66
|
+
"""Every PREFIX-NN token mentioned anywhere."""
|
|
67
|
+
return set(re.findall(rf"\b({prefix}-\d+)\b", text))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_unique_and_gaps(entry_ids: list[str], label: str) -> None:
|
|
71
|
+
seen: dict[str, int] = {}
|
|
72
|
+
for i in entry_ids:
|
|
73
|
+
seen[i] = seen.get(i, 0) + 1
|
|
74
|
+
for i, n in seen.items():
|
|
75
|
+
if n > 1:
|
|
76
|
+
err(f"{label}: duplicate id {i} ({n} entries)")
|
|
77
|
+
nums = sorted(int(i.split("-")[1]) for i in seen)
|
|
78
|
+
if nums:
|
|
79
|
+
missing = [n for n in range(1, max(nums) + 1) if n not in nums]
|
|
80
|
+
if missing:
|
|
81
|
+
warn(f"{label}: id gaps (retired entries should stay): {missing}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def figma_enabled(foundation: str) -> bool | None:
|
|
85
|
+
"""True/False from foundation Design tooling; None if unstated (default-on)."""
|
|
86
|
+
m = re.search(r"\*\*Figma:\*\*\s*(enabled|disabled)", foundation, re.IGNORECASE)
|
|
87
|
+
if not m:
|
|
88
|
+
return None
|
|
89
|
+
return m.group(1).lower() == "enabled"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def screen_blocks(text: str) -> dict[str, str]:
|
|
93
|
+
"""Map SCR-id -> its section body (from its header to the next ### / ##)."""
|
|
94
|
+
out: dict[str, str] = {}
|
|
95
|
+
parts = re.split(r"^###\s+(SCR-\d+):", text, flags=re.MULTILINE)
|
|
96
|
+
# parts = [pre, id1, body1, id2, body2, ...]
|
|
97
|
+
for i in range(1, len(parts), 2):
|
|
98
|
+
sid = parts[i]
|
|
99
|
+
body = parts[i + 1] if i + 1 < len(parts) else ""
|
|
100
|
+
body = re.split(r"^##\s", body, maxsplit=1, flags=re.MULTILINE)[0]
|
|
101
|
+
out[sid] = body
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_links(ux: Path) -> None:
|
|
106
|
+
link_re = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
107
|
+
for md in sorted(ux.rglob("*.md")):
|
|
108
|
+
text = read(md)
|
|
109
|
+
for target in link_re.findall(text):
|
|
110
|
+
if target.startswith(("http://", "https://", "#", "mailto:")):
|
|
111
|
+
continue
|
|
112
|
+
resolved = (md.parent / target.split("#", 1)[0]).resolve()
|
|
113
|
+
if not resolved.exists():
|
|
114
|
+
warn(f"{md.name}: broken link -> {target}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> int:
|
|
118
|
+
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
119
|
+
strict = "--strict" in sys.argv[1:]
|
|
120
|
+
ux = find_ux_dir(args[0] if args else None)
|
|
121
|
+
if ux is None:
|
|
122
|
+
print("no UX docs found (docs/ux/scenarios.md). Run /ux to set up.")
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
foundation = read(ux / "foundation.md")
|
|
126
|
+
flows = read(ux / "flows.md")
|
|
127
|
+
screens = read(ux / "screens.md")
|
|
128
|
+
scenarios = read(ux / "scenarios.md")
|
|
129
|
+
|
|
130
|
+
has_flows = bool(ids(flows, "FLW"))
|
|
131
|
+
has_screens = bool(ids(screens, "SCR"))
|
|
132
|
+
has_stories = bool(ids(foundation, "ST"))
|
|
133
|
+
|
|
134
|
+
# --- ID integrity ---
|
|
135
|
+
for text, pref, label in [
|
|
136
|
+
(scenarios, "SCN", "scenarios.md"),
|
|
137
|
+
(flows, "FLW", "flows.md"),
|
|
138
|
+
(screens, "SCR", "screens.md"),
|
|
139
|
+
(foundation, "ST", "foundation.md/stories"),
|
|
140
|
+
(foundation, "JTBD", "foundation.md/jobs"),
|
|
141
|
+
]:
|
|
142
|
+
entry_ids = ids(text, pref)
|
|
143
|
+
if entry_ids:
|
|
144
|
+
check_unique_and_gaps(entry_ids, label)
|
|
145
|
+
|
|
146
|
+
# --- Index <-> entries sync (scenarios, screens) ---
|
|
147
|
+
for text, pref, name in [(scenarios, "SCN", "scenarios.md"), (screens, "SCR", "screens.md")]:
|
|
148
|
+
entries = set(ids(text, pref))
|
|
149
|
+
if not entries:
|
|
150
|
+
continue
|
|
151
|
+
idx = index_ids(text, pref)
|
|
152
|
+
for missing in sorted(entries - idx):
|
|
153
|
+
warn(f"{name}: {missing} has no index row")
|
|
154
|
+
for ghost in sorted(idx - entries):
|
|
155
|
+
err(f"{name}: index lists {ghost} but no entry exists")
|
|
156
|
+
|
|
157
|
+
# --- Flows reference existing screens ---
|
|
158
|
+
if has_flows and has_screens:
|
|
159
|
+
screen_ids = set(ids(screens, "SCR"))
|
|
160
|
+
used = refs(flows, "SCR")
|
|
161
|
+
for miss in sorted(used - screen_ids):
|
|
162
|
+
err(f"flows.md references {miss} but screens.md has no such screen")
|
|
163
|
+
for orphan in sorted(screen_ids - used):
|
|
164
|
+
warn(f"screens.md: {orphan} is used by no flow (orphan)")
|
|
165
|
+
|
|
166
|
+
# --- Scenario traces resolve ---
|
|
167
|
+
if ids(scenarios, "SCN"):
|
|
168
|
+
story_ids = set(ids(foundation, "ST"))
|
|
169
|
+
flow_ids = set(ids(flows, "FLW"))
|
|
170
|
+
traced_st = refs(scenarios, "ST")
|
|
171
|
+
traced_flw = refs(scenarios, "FLW")
|
|
172
|
+
if has_stories:
|
|
173
|
+
for miss in sorted(traced_st - story_ids):
|
|
174
|
+
warn(f"scenarios.md: traces to {miss} which is not in foundation.md")
|
|
175
|
+
if has_flows:
|
|
176
|
+
for miss in sorted(traced_flw - flow_ids):
|
|
177
|
+
warn(f"scenarios.md: traces to {miss} which is not in flows.md")
|
|
178
|
+
|
|
179
|
+
# --- must/should stories have a scenario ---
|
|
180
|
+
if has_stories and ids(scenarios, "SCN"):
|
|
181
|
+
traced = refs(scenarios, "ST")
|
|
182
|
+
for m in re.finditer(r"^###\s+(ST-\d+):", foundation, re.MULTILINE):
|
|
183
|
+
sid = m.group(1)
|
|
184
|
+
# Only this story's own body: stop at the next heading, so a
|
|
185
|
+
# neighbor's Priority line is never read as this story's.
|
|
186
|
+
tail = re.split(r"^#{2,3}\s", foundation[m.end():], maxsplit=1, flags=re.MULTILINE)[0]
|
|
187
|
+
if re.search(r"\*\*Priority:\*\*\s*(must|should)", tail, re.IGNORECASE):
|
|
188
|
+
if sid not in traced:
|
|
189
|
+
warn(f"foundation.md: {sid} (must/should) has no scenario tracing to it")
|
|
190
|
+
|
|
191
|
+
# --- Screen-level: Figma frames, coverage, drift status ---
|
|
192
|
+
if has_screens:
|
|
193
|
+
fig = figma_enabled(foundation)
|
|
194
|
+
for sid, body in screen_blocks(screens).items():
|
|
195
|
+
status_m = re.search(r"\*\*Status:\*\*\s*(designed|built|drifted|retired)", body)
|
|
196
|
+
status = status_m.group(1) if status_m else None
|
|
197
|
+
if status == "retired":
|
|
198
|
+
continue
|
|
199
|
+
# every state row present in the States table
|
|
200
|
+
state_rows = re.findall(r"^\s*\|\s*(loading|empty|error|success)\s*\|(.*)\|\s*$",
|
|
201
|
+
body, re.MULTILINE | re.IGNORECASE)
|
|
202
|
+
if fig is not False: # enabled or default-on
|
|
203
|
+
for state, rest in state_rows:
|
|
204
|
+
cells = [c.strip() for c in rest.split("|")]
|
|
205
|
+
frame = cells[1] if len(cells) >= 2 else ""
|
|
206
|
+
if not frame or frame in ("-", "—", "<frame deep-link>", "<frame link>"):
|
|
207
|
+
err(f"screens.md: {sid} state '{state}' has no Figma frame link")
|
|
208
|
+
cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
|
|
209
|
+
cov = cov_m.group(1).strip() if cov_m else ""
|
|
210
|
+
if status == "built" and (not cov or cov.lower().startswith("none")):
|
|
211
|
+
warn(f"screens.md: {sid} is 'built' but has no Coverage")
|
|
212
|
+
|
|
213
|
+
check_links(ux)
|
|
214
|
+
|
|
215
|
+
# --- Report ---
|
|
216
|
+
for e in ERRORS:
|
|
217
|
+
print(f"ERROR: {e}")
|
|
218
|
+
for w in WARNS:
|
|
219
|
+
print(f"warn: {w}")
|
|
220
|
+
total = len(ERRORS) + len(WARNS)
|
|
221
|
+
if not total:
|
|
222
|
+
print(f"OK — docs/ux is consistent ({ux})")
|
|
223
|
+
return 0
|
|
224
|
+
print(f"\n{len(ERRORS)} error(s), {len(WARNS)} warning(s)")
|
|
225
|
+
if ERRORS or (strict and WARNS):
|
|
226
|
+
return 1
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(main())
|
package/templates/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Personas · JTBD · Journeys · Stories → Flows → Screens → Scenario
|
|
|
12
12
|
|
|
13
13
|
| File | Holds |
|
|
14
14
|
|------|-------|
|
|
15
|
-
| `foundation.md` | WHO & WHY: personas, jobs-to-be-done, journeys, user stories, monetization, Figma on/off |
|
|
15
|
+
| `foundation.md` | WHO & WHY: personas, jobs-to-be-done, journeys, user stories, monetization, Figma on/off + file URL |
|
|
16
16
|
| `flows.md` | HOW: user-flow diagrams (screens, branches, error paths), referencing screens by `SCR-ID` |
|
|
17
17
|
| `screens.md` | THE UI MAP: every screen and state with its Figma frame link, wireframe, code coverage, scenarios, resources |
|
|
18
18
|
| `scenarios.md` | WHAT EXACTLY: use-case scenarios — the source of truth for behavior |
|
|
@@ -29,7 +29,9 @@ Personas · JTBD · Journeys · Stories → Flows → Screens → Scenario
|
|
|
29
29
|
`scenarios.md`, affected flows, the affected screens in `screens.md`,
|
|
30
30
|
and (Figma on) the Figma frame plus its link — together, not later.
|
|
31
31
|
3. **No drift.** Code that diverges from a screen's record, or a stale Figma
|
|
32
|
-
link, is a bug to fix.
|
|
32
|
+
link, is a bug to fix. The same goes for the look: one style pack is
|
|
33
|
+
recorded in `screens.md` → Design system (pick it with the
|
|
34
|
+
**sheleg-design** skill) and every screen obeys it.
|
|
33
35
|
4. **Lint it.** Run `python3 docs/ux/lint.py` after changes and in CI.
|
|
34
36
|
|
|
35
37
|
Maintained with the super-ux plugin. In Claude Code, run `/ux`.
|
package/templates/claude-rule.md
CHANGED
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
serve, which journey stage, which story — then flows and scenarios,
|
|
15
15
|
validated against the existing base, approved.
|
|
16
16
|
- **Do NOT write interface code until the UX workflow is done first:** the
|
|
17
|
-
foundation → flows → scenarios chain is designed and approved,
|
|
18
|
-
Figma is enabled (default) — the UI is mocked up in Figma with
|
|
19
|
-
screen linked to its frame. Building UI before this is the exact
|
|
20
|
-
super-ux exists to prevent.
|
|
17
|
+
foundation → flows → screens → scenarios chain is designed and approved,
|
|
18
|
+
and — when Figma is enabled (default) — the UI is mocked up in Figma with
|
|
19
|
+
every screen linked to its frame. Building UI before this is the exact
|
|
20
|
+
mistake super-ux exists to prevent.
|
|
21
|
+
- Visual identity is ONE locked style pack, recorded in `docs/ux/screens.md`
|
|
22
|
+
→ Design system and obeyed by every Figma frame and every built screen —
|
|
23
|
+
picked with the **sheleg-design** companion skill when the project has no
|
|
24
|
+
design system of its own (recommended, not required). Inventing a palette,
|
|
25
|
+
type pairing, or motion per screen is visual drift.
|
|
21
26
|
- After any UX change and before calling the work done, run the linter
|
|
22
27
|
`python3 docs/ux/lint.py` — it must pass (errors are drift/broken
|
|
23
28
|
structure; wire it into CI/pre-commit).
|
package/templates/flows.md
CHANGED
package/templates/foundation.md
CHANGED
|
@@ -33,8 +33,18 @@ data/observation, recognizable by a real user. -->
|
|
|
33
33
|
- **Model:** hard paywall | freemium | hybrid | trial (type, length) — and why
|
|
34
34
|
- **Value metric:** <what the paid tier meters>
|
|
35
35
|
- **Free boundary:** <what stays free, where the visible limit sits>
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
36
|
+
- **Purchase surface:** in-app (IAP) | web checkout | web2app (web funnel -> app) — BP-030/BP-078/BP-127, and which storefronts
|
|
37
|
+
- **Money moments:** <paywall placement, upgrade triggers, checkout, failed payment, cancel, rating prompts, winback>
|
|
38
|
+
- **Acquisition coherence:** <the one story ad -> landing/listing -> onboarding tells>
|
|
39
|
+
-->
|
|
40
|
+
|
|
41
|
+
## Product mechanics
|
|
42
|
+
|
|
43
|
+
<!-- Two dimensions the practice-selection profile reads; record them once,
|
|
44
|
+
even when the answer is "none":
|
|
45
|
+
- **Personalization:** none | rule-based | inferred/model-driven — BP-143/BP-144 (what gets asked when, and how a wrong inference is corrected)
|
|
46
|
+
- **Engagement mechanics:** none | streaks/tiers | points/badges/leaderboards — BP-141/BP-142 (which traced job each mechanic reinforces, and the recovery path when progress is lost)
|
|
47
|
+
- **Accessibility regime:** none stated | EAA (EU) | ADA (US) | both — BP-138, with the owner
|
|
38
48
|
-->
|
|
39
49
|
|
|
40
50
|
## Design tooling
|
package/templates/screens.md
CHANGED
|
@@ -13,6 +13,11 @@ whose code diverges from its record here is a "drifted" finding. -->
|
|
|
13
13
|
|
|
14
14
|
## Design system
|
|
15
15
|
|
|
16
|
+
<!-- Style pack = the locked visual identity every frame and built screen obeys.
|
|
17
|
+
Pick it with the sheleg-design companion skill (workbench for product UI /
|
|
18
|
+
dashboards / tools, instrument-console, editorial-luxury, or a new pack on its
|
|
19
|
+
contract) before drawing anything; record its token file below. -->
|
|
20
|
+
- **Style pack:** <pack name, or "none — platform defaults">
|
|
16
21
|
- **Figma library:** <url/name, or "none — platform defaults">
|
|
17
22
|
- **Tokens in code:** <where color/type/spacing tokens live, e.g. src/theme/tokens.ts>
|
|
18
23
|
- **Component source:** <shared UI components dir, e.g. src/components/>
|