super-ux 0.59.1 → 0.59.3
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 +34 -0
- package/README.md +1 -1
- package/bin/super-ux.js +6 -2
- package/cursor/rules/copywriting.mdc +4 -0
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +87 -12
- package/plugins/super-ux/scripts/ux_lint.py +11 -0
- package/templates/brand/README.md +1 -0
- package/templates/brand/copy-brief.md +10 -0
- package/templates/foundation.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 0.59.3 — accountable journeys and explicit copy inputs
|
|
2
|
+
|
|
3
|
+
- Journeys record an Owner, with `unknown` supported. `U086` advises migration
|
|
4
|
+
for missing/blank/duplicate owners without assigning anyone automatically.
|
|
5
|
+
- Marketing writing uses a current `copy-brief.md` with success metric,
|
|
6
|
+
constraints and a reference to the voice pair alongside the original inputs.
|
|
7
|
+
`B074` advises existing packs; `B075` checks present records. Unresolved values
|
|
8
|
+
remain `unknown`; unrelated tasks do not load a brief.
|
|
9
|
+
- Seeds and every per-skill contract copy are synchronized. Discovery descriptions
|
|
10
|
+
are unchanged. No installation or provider operation was performed.
|
|
11
|
+
- CI uses nightly runs and deliberate ready-for-review promotion, preserving jobs.
|
|
12
|
+
[FW05 source receipt](docs/evidence/runs/2026-10-13-fw05-journey-copy.md).
|
|
13
|
+
|
|
14
|
+
## 0.59.2 — brand-lint names the line, and a release refuses a lightweight tag
|
|
15
|
+
|
|
16
|
+
- **`B022` reports the line.** An unregistered literal was reported at line 0 —
|
|
17
|
+
`B022 bin/super-ux.js:0` for a string on line 303 — so the finding named a file
|
|
18
|
+
and left the search to the reader. Code literals now carry the line their first
|
|
19
|
+
character is on (template-literal paragraphs included), HTML controls the line
|
|
20
|
+
they open on. The comment scanner keeps a block comment's newlines so a stripped
|
|
21
|
+
comment cannot shift the count. Fixture `b022_line_cases` in
|
|
22
|
+
`test/brand_lint_test.py`; three plants (line 0 back, comment newlines dropped,
|
|
23
|
+
HTML line pinned) each turned it red.
|
|
24
|
+
- **The probe string that fired it is retired, not registered.** `claude --version`
|
|
25
|
+
in `bin/super-ux.js` was the `command` field of the typed result returned when the
|
|
26
|
+
claude CLI is absent; nothing prints it. B022's doctrine is "agree it or retire
|
|
27
|
+
it", and a row in `strings.md` would register as interface copy a string no
|
|
28
|
+
reader sees, so the field is now derived from the probe's own argv, the way
|
|
29
|
+
`run()` derives every other `command`. `python3 docs/brand/lint.py` is clean.
|
|
30
|
+
- **The release refuses a lightweight tag.** A step right after checkout in
|
|
31
|
+
`release.yml` fails unless `git cat-file -t <tag>` is `tag`. `git describe` and
|
|
32
|
+
`git submodule status` see annotated tags only; v0.59.1 was cut lightweight and
|
|
33
|
+
the hub read super-ux as its previous release.
|
|
34
|
+
|
|
1
35
|
## 0.59.1 — inventories and audits work without delegation
|
|
2
36
|
|
|
3
37
|
Brand inventory, scenario inventory and UX audit now use the same areas, batches
|
package/README.md
CHANGED
|
@@ -161,7 +161,7 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
161
161
|
python3 docs/brand/lint.py
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
48 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
|
|
165
165
|
with no sourced fact, a field over its limit with the locale coefficient
|
|
166
166
|
applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
|
|
167
167
|
a rhetorical dash, a title that ends in a full stop, a locale that lags
|
package/bin/super-ux.js
CHANGED
|
@@ -294,13 +294,17 @@ function printUpdateLine() {
|
|
|
294
294
|
|
|
295
295
|
function installClaudePlugin() {
|
|
296
296
|
console.log(`\n--- Claude Code plugin ---`);
|
|
297
|
-
|
|
297
|
+
// The probe's argv is the one source of its `command`, exactly as run()
|
|
298
|
+
// derives it: the typed result carries it, nothing prints it, so it is not
|
|
299
|
+
// interface copy and gets no strings.md row (brand-lint B022, retired).
|
|
300
|
+
const probeArgv = ['claude', '--version'];
|
|
301
|
+
const probe = spawnSync(probeArgv[0], probeArgv.slice(1), { stdio: 'ignore' });
|
|
298
302
|
if (probe.error && probe.error.code === 'ENOENT') {
|
|
299
303
|
console.log(`claude CLI not found. Run inside Claude Code instead:
|
|
300
304
|
/plugin marketplace add ${REPO}
|
|
301
305
|
/plugin install super-ux@super-ux`);
|
|
302
306
|
return { status: 'unsupported', code: null, signal: null,
|
|
303
|
-
command: '
|
|
307
|
+
command: probeArgv.join(' ') };
|
|
304
308
|
}
|
|
305
309
|
if (run('claude', ['plugin', 'marketplace', 'add', REPO]).status !== 'ok') {
|
|
306
310
|
console.log('(marketplace may already be added, continuing)');
|
|
@@ -21,6 +21,10 @@ locale policy. Missing terms or facts are reported, never invented. This pass
|
|
|
21
21
|
owns product text and `strings.md` rows (`Status: proposed`); claim coordinated
|
|
22
22
|
files before editing. A file living in `docs/brand/` does not forbid the edit.
|
|
23
23
|
|
|
24
|
+
For marketing writing only, use the copywriting skill’s `marketing-copy.md`
|
|
25
|
+
→ Before writing contract for the current brief; keep unknown inputs explicit.
|
|
26
|
+
Other tasks do not load this brief.
|
|
27
|
+
|
|
24
28
|
## Rules that hold in every voice
|
|
25
29
|
|
|
26
30
|
- **No decorative full stop in headings or labels**, even when inline markup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.3",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
|
|
6
6
|
"test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
|
|
@@ -81,7 +81,7 @@ STRING_KINDS = ("copy", "layout")
|
|
|
81
81
|
|
|
82
82
|
SEVERITY_ERROR = "error"
|
|
83
83
|
SEVERITY_WARN = "warn"
|
|
84
|
-
WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072".split())
|
|
84
|
+
WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072 B074".split())
|
|
85
85
|
|
|
86
86
|
Finding = namedtuple("Finding", "code severity path line message")
|
|
87
87
|
|
|
@@ -560,16 +560,38 @@ def code_literals(text: str) -> list[str]:
|
|
|
560
560
|
Comments must already be stripped by the caller -- `_strip_comments` leaves
|
|
561
561
|
literals untouched, which is what makes both patterns below safe to run.
|
|
562
562
|
"""
|
|
563
|
-
|
|
564
|
-
|
|
563
|
+
return [lit for lit, _line in code_literals_at(text)]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def code_literals_at(text: str) -> list[tuple[str, int]]:
|
|
567
|
+
"""`code_literals`, each with the 1-based line its first character is on.
|
|
568
|
+
|
|
569
|
+
B022 reported every unregistered literal at line 0 -- `bin/super-ux.js:0`
|
|
570
|
+
for a string on line 303 -- so the finding named a file and left the reader
|
|
571
|
+
to search it. The line is counted in the comment-stripped text, which
|
|
572
|
+
`_strip_comments` keeps line-for-line with the source (a block comment
|
|
573
|
+
leaves its newlines behind), so the number is the source's own.
|
|
574
|
+
"""
|
|
575
|
+
def line_at(offset: int) -> int:
|
|
576
|
+
return text.count("\n", 0, offset) + 1
|
|
577
|
+
|
|
578
|
+
out: list[tuple[str, int]] = [
|
|
579
|
+
(m.group(2), line_at(m.start(2))) for m in LITERAL_RE.finditer(text)
|
|
580
|
+
]
|
|
581
|
+
for m in TEMPLATE_RE.finditer(text):
|
|
582
|
+
block = m.group(1)
|
|
565
583
|
if "\n" not in block:
|
|
566
584
|
continue # single-line backticks are already in `out`
|
|
585
|
+
cursor = 0
|
|
567
586
|
for para in re.split(r"\n\s*\n", block):
|
|
587
|
+
at = block.find(para, cursor)
|
|
588
|
+
cursor = at + len(para)
|
|
568
589
|
joined = " ".join(
|
|
569
590
|
line.strip() for line in para.splitlines() if line.strip()
|
|
570
591
|
)
|
|
571
592
|
if len(joined) >= 3:
|
|
572
|
-
|
|
593
|
+
lead = len(para) - len(para.lstrip())
|
|
594
|
+
out.append((joined, line_at(m.start(1) + at + lead)))
|
|
573
595
|
return out
|
|
574
596
|
|
|
575
597
|
# A template literal's interpolations split it into pieces, and the pieces are
|
|
@@ -742,10 +764,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
742
764
|
body = read(target) or ""
|
|
743
765
|
html = html_copy(body) if target.suffix.lower() in HTML_SUFFIXES else None
|
|
744
766
|
source_copy = " ".join(html.blocks) if html is not None else body
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
767
|
+
located = list(zip(html.interface_texts, html.interface_lines)) \
|
|
768
|
+
if html is not None else [
|
|
769
|
+
(lit, line) for lit, line
|
|
770
|
+
in code_literals_at(_strip_comments(body, target.suffix))
|
|
771
|
+
if _looks_like_copy(lit)
|
|
772
|
+
]
|
|
773
|
+
literals = [lit for lit, _line in located]
|
|
749
774
|
if literals or html is not None:
|
|
750
775
|
# The registry records what a reader sees, so the built page is the
|
|
751
776
|
# authority whenever there is one. Source is the fallback and stays
|
|
@@ -781,10 +806,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
781
806
|
swept.add(file_part)
|
|
782
807
|
known = {r["text"] for r in rows}
|
|
783
808
|
trimmed = {t.strip() for t in known}
|
|
784
|
-
|
|
809
|
+
first_line: dict[str, int] = {}
|
|
810
|
+
for lit, line in located: # one report per literal, at its first line
|
|
811
|
+
first_line.setdefault(lit, line)
|
|
812
|
+
for lit, line in first_line.items():
|
|
785
813
|
if lit not in known and lit.strip() not in trimmed:
|
|
786
814
|
findings.append(Finding(
|
|
787
|
-
"B022", SEVERITY_WARN, f"{file_part}",
|
|
815
|
+
"B022", SEVERITY_WARN, f"{file_part}", line,
|
|
788
816
|
f"\"{lit}\" is in the code with no registry row -- "
|
|
789
817
|
f"agree it or retire it",
|
|
790
818
|
))
|
|
@@ -927,6 +955,7 @@ class HTMLCopy(HTMLParser):
|
|
|
927
955
|
self.last_anchor_end = None
|
|
928
956
|
self.controls = []
|
|
929
957
|
self.interface_texts = []
|
|
958
|
+
self.interface_lines = [] # parallel to interface_texts: the control's opening line
|
|
930
959
|
|
|
931
960
|
@property
|
|
932
961
|
def blocked(self):
|
|
@@ -939,7 +968,7 @@ class HTMLCopy(HTMLParser):
|
|
|
939
968
|
attrs.get("style", ""), re.I))
|
|
940
969
|
if not hidden:
|
|
941
970
|
if tag in self.CONTROLS or attrs.get("role", "").lower() in self.INTERFACE_ROLES:
|
|
942
|
-
self.controls.append((tag, len(self.stack), []))
|
|
971
|
+
self.controls.append((tag, len(self.stack), [], self.getpos()[0]))
|
|
943
972
|
if tag == "br" or tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag):
|
|
944
973
|
for control in self.controls:
|
|
945
974
|
control[2].append(" ")
|
|
@@ -978,6 +1007,7 @@ class HTMLCopy(HTMLParser):
|
|
|
978
1007
|
text = " ".join("".join(control[2]).split())
|
|
979
1008
|
if text:
|
|
980
1009
|
self.interface_texts.append(text)
|
|
1010
|
+
self.interface_lines.append(control[3])
|
|
981
1011
|
self.controls.remove(control)
|
|
982
1012
|
if tag == "a" and not self.blocked:
|
|
983
1013
|
self.last_anchor_end = len(self.parts)
|
|
@@ -1106,7 +1136,12 @@ def _strip_comments(text: str, suffix: str) -> str:
|
|
|
1106
1136
|
continue
|
|
1107
1137
|
if not hashed and text.startswith("/*", i):
|
|
1108
1138
|
end = text.find("*/", i + 2)
|
|
1109
|
-
|
|
1139
|
+
stop = n if end == -1 else end + 2
|
|
1140
|
+
# Keep the comment's newlines: B022 reports a literal by its line
|
|
1141
|
+
# in THIS text, so a stripped multi-line comment must not shift
|
|
1142
|
+
# every line after it.
|
|
1143
|
+
out.append("\n" * text.count("\n", i, stop))
|
|
1144
|
+
i = stop
|
|
1110
1145
|
continue
|
|
1111
1146
|
out.append(ch)
|
|
1112
1147
|
i += 1
|
|
@@ -2539,6 +2574,45 @@ def apply_fixes(brand_dir: Path, findings: list[Finding]) -> int:
|
|
|
2539
2574
|
return rewritten
|
|
2540
2575
|
|
|
2541
2576
|
|
|
2577
|
+
COPY_BRIEF_FIELDS = ("Surface", "Action", "Reader", "Customer words", "Proof",
|
|
2578
|
+
"Success metric", "Constraints", "Voice reference")
|
|
2579
|
+
|
|
2580
|
+
|
|
2581
|
+
def check_copy_brief(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
2582
|
+
"""B074/B075: one current marketing brief, with explicit unknowns permitted."""
|
|
2583
|
+
path = brand_dir / "copy-brief.md"
|
|
2584
|
+
if not path.exists():
|
|
2585
|
+
return [Finding("B074", SEVERITY_WARN, "copy-brief.md", 0,
|
|
2586
|
+
"marketing sources have no current copy brief; seed one "
|
|
2587
|
+
"before the next writing task, retaining unknown inputs")] if sources.get("marketing") else []
|
|
2588
|
+
text = read(path)
|
|
2589
|
+
if text is None:
|
|
2590
|
+
return [Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
|
|
2591
|
+
"current copy brief cannot be read")]
|
|
2592
|
+
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
|
2593
|
+
fields = {}
|
|
2594
|
+
findings = []
|
|
2595
|
+
for field in COPY_BRIEF_FIELDS:
|
|
2596
|
+
values = re.findall(rf"(?m)^{re.escape(field)}:[ \t]*(.*)$", text)
|
|
2597
|
+
if len(values) != 1 or unfilled(values[0]):
|
|
2598
|
+
findings.append(Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
|
|
2599
|
+
f"needs one nonempty {field}: value; use unknown for an unresolved input"))
|
|
2600
|
+
else:
|
|
2601
|
+
fields[field] = values[0].strip()
|
|
2602
|
+
ref = fields.get("Voice reference", "unknown")
|
|
2603
|
+
if ref.lower() != "unknown":
|
|
2604
|
+
voice = re.sub(r"<!--.*?-->", "", read(brand_dir / "voice.md") or "", flags=re.S)
|
|
2605
|
+
section = re.search(r"(?ms)^## Voice references[ \t]*\n(.*?)(?=^## |\Z)", voice)
|
|
2606
|
+
pair = [re.search(rf"(?m)^[ \t]*(?:-[ \t]*)?\*\*{field}:\*\*[ \t]*(.*)$", section[1] if section else "")
|
|
2607
|
+
for field in ("Admired", "Refused")]
|
|
2608
|
+
if (ref != "voice.md#voice-references" or not section
|
|
2609
|
+
or any(not m or unfilled(m[1]) or m[1].strip().lower() == "unknown" for m in pair)):
|
|
2610
|
+
findings.append(Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
|
|
2611
|
+
"Voice reference must be unknown or voice.md#voice-references "
|
|
2612
|
+
"with a filled Admired/Refused pair in voice.md"))
|
|
2613
|
+
return findings
|
|
2614
|
+
|
|
2615
|
+
|
|
2542
2616
|
def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
|
|
2543
2617
|
"""Every check, in order. `fix` is applied by the caller via apply_fixes."""
|
|
2544
2618
|
sources = load_sources(brand_dir)
|
|
@@ -2549,6 +2623,7 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
|
|
|
2549
2623
|
findings.extend(check_consistency(brand_dir, sources))
|
|
2550
2624
|
findings.extend(check_facts(brand_dir, sources))
|
|
2551
2625
|
findings.extend(check_channels(brand_dir, sources))
|
|
2626
|
+
findings.extend(check_copy_brief(brand_dir, sources))
|
|
2552
2627
|
findings.extend(check_short_video(brand_dir, sources))
|
|
2553
2628
|
findings.extend(check_bot_safety(brand_dir, sources))
|
|
2554
2629
|
findings.extend(check_ai_tells(brand_dir, sources))
|
|
@@ -735,6 +735,16 @@ def check_jobs(foundation: str, scenarios: str = "", flows: str = "",
|
|
|
735
735
|
f"{', '.join(JOB_FIELDS)}")
|
|
736
736
|
|
|
737
737
|
|
|
738
|
+
def check_journey_owners(foundation: str) -> None:
|
|
739
|
+
"""U086: absence is migration advice; unknown is an explicit unassigned owner."""
|
|
740
|
+
for sid, body in sorted(entry_blocks(foundation, "JRN").items()):
|
|
741
|
+
owners = re.findall(r"(?m)^[ \t]*(?:-[ \t]*)?\*\*Owner:\*\*[ \t]*(.*)$", body)
|
|
742
|
+
if len(owners) != 1 or _unfilled(owners[0]):
|
|
743
|
+
warn(f"[U086] foundation.md: {sid} needs one **Owner:** value — "
|
|
744
|
+
"name the accountable role/person or write unknown; "
|
|
745
|
+
"do not invent an assignment")
|
|
746
|
+
|
|
747
|
+
|
|
738
748
|
WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
|
|
739
749
|
|
|
740
750
|
|
|
@@ -1251,6 +1261,7 @@ def main() -> int:
|
|
|
1251
1261
|
check_field_vocabulary(scenarios, foundation)
|
|
1252
1262
|
check_status_enums(scenarios, foundation, screens, flows, vision)
|
|
1253
1263
|
check_jobs(foundation, scenarios, flows, screens)
|
|
1264
|
+
check_journey_owners(foundation)
|
|
1254
1265
|
check_vision(ux, vision)
|
|
1255
1266
|
check_web_surface(screens, flows)
|
|
1256
1267
|
check_onboarding(flows, ux, project_root)
|
|
@@ -15,6 +15,7 @@ Owned by the `brand-voice` skill. Written by `copywriting`. Checked by
|
|
|
15
15
|
| `terminology.md` | our words, banned words, entity and tier names |
|
|
16
16
|
| `facts.md` | canonical numbers and proof — the only source of a figure |
|
|
17
17
|
| `channels.md` | one record per surface: register, limits, bans |
|
|
18
|
+
| `copy-brief.md` | current marketing task inputs; unknowns stay explicit |
|
|
18
19
|
| `strings.md` | interface string registry → `file:line` → scenario |
|
|
19
20
|
| `locales/<code>.md` | per-locale delta |
|
|
20
21
|
|
package/templates/foundation.md
CHANGED
|
@@ -23,6 +23,7 @@ data/observation, recognizable by a real user. -->
|
|
|
23
23
|
## Customer journeys
|
|
24
24
|
|
|
25
25
|
<!-- ### JRN-01: <persona> — <job> (JTBD-01)
|
|
26
|
+
- **Owner:** unknown
|
|
26
27
|
| # | Stage | User action | Touchpoint | Emotion (1-5) | Pain | Opportunity |
|
|
27
28
|
|---|-------|------------|------------|---------------|------|-------------|
|
|
28
29
|
-->
|