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.
@@ -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())
@@ -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`.
@@ -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, and — when
18
- Figma is enabled (default) — the UI is mocked up in Figma with every
19
- screen linked to its frame. Building UI before this is the exact mistake
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).
@@ -27,4 +27,6 @@ flowchart TD
27
27
  |--------|------------------|
28
28
  | SCR-01 <name> | success |
29
29
  | SCR-02 <name> | error, success |
30
+ - **Wireframe:** wireframes/FLW-01.md (optional; per-screen wireframes live
31
+ under the screen's SCR-ID in screens.md)
30
32
  -->
@@ -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
- - **Money moments:** <paywall placement, upgrade triggers, rating prompts, winback>
37
- - **Acquisition coherence:** <the one story ad -> listing -> onboarding tells>
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
@@ -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/>