super-ux 0.60.0 → 0.61.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 +21 -0
- package/README.md +2 -2
- package/cursor/rules/copywriting.mdc +8 -3
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +75 -2
- package/templates/brand/voice.md +2 -0
- package/templates/claude-rule.md +10 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## 0.61.0 (2026-10-18): no em dash in new text
|
|
2
|
+
|
|
3
|
+
Owner decision, 2026-10-18: text an agent writes carries no em dash (U+2014), in
|
|
4
|
+
any language, and no substitute for one. The family hub carries the same rule as
|
|
5
|
+
an always-on line for every host; this release makes product text agree with it.
|
|
6
|
+
|
|
7
|
+
- `ai-tells.md` AT-06 is now a hard ban, not a frequency target. The dash rule has
|
|
8
|
+
two layers: the glyph (no em dash in newly authored text) and the role (the
|
|
9
|
+
rhetorical dash is banned in every spelling; the grammatical Russian dash is
|
|
10
|
+
allowed only where no rewrite reads naturally). Ranges take an en dash.
|
|
11
|
+
- New lint code `B067` (`brand_lint.py`, seeded `docs/brand/lint.py`): an em dash
|
|
12
|
+
joining a range errors; an em dash opening a line where the locale has no
|
|
13
|
+
dialogue dash errors; a lone dash in a locale with a grammatical one warns, so
|
|
14
|
+
the writer decides whether the grammar needs it. Never a second finding beside
|
|
15
|
+
`B062`. Five fixtures, each watched failing with `B067` stubbed out.
|
|
16
|
+
- `copywriting`: the em dash is a Non-negotiable and the one exception to the
|
|
17
|
+
advisory humanization pass; the status-line examples no longer use a dash.
|
|
18
|
+
`brand-voice` seeds the invariant into `voice.md`; the brand hard rule
|
|
19
|
+
(`templates/claude-rule.md` and its embedded copies) and the Cursor rule say so.
|
|
20
|
+
- Contract copies synchronized with `python3 test/sync_references.py`.
|
|
21
|
+
|
|
1
22
|
## 0.60.0 — a rendered screen is checked in WebKit (Safari) first
|
|
2
23
|
|
|
3
24
|
- New shared contract `references/browser-engine.md`: Safari through
|
package/README.md
CHANGED
|
@@ -161,10 +161,10 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
161
161
|
python3 docs/brand/lint.py
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
49 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
|
-
a rhetorical dash, a title that ends in a full stop, a locale that lags
|
|
167
|
+
a rhetorical dash or an em dash in new text, a title that ends in a full stop, a locale that lags
|
|
168
168
|
without saying so, a short-video hook the filter calls weak, a caption that
|
|
169
169
|
spends the feed window or asks twice, an invisible character pasted in. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
|
|
170
170
|
|
|
@@ -54,9 +54,14 @@ Could this sentence sit unchanged on a competitor's page? Then it says
|
|
|
54
54
|
nothing. Rewrite it until it could not.
|
|
55
55
|
|
|
56
56
|
Machine-made tells to strip: tricolons that add nothing, "seamless /
|
|
57
|
-
effortless / unlock / elevate / robust",
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
effortless / unlock / elevate / robust", "it's not just X, it's Y", hedges
|
|
58
|
+
stacked two deep, and a closing sentence that summarizes what was just said.
|
|
59
|
+
|
|
60
|
+
**No em dash (U+2014) in text you write, in any language** (owner decision,
|
|
61
|
+
2026-10-18). A hard ban, not a count: no substitute either (`--`, an en dash
|
|
62
|
+
or a spaced hyphen doing a dash's job). Use a full stop, a comma, a colon or
|
|
63
|
+
brackets, or rewrite. The rare exception is a dash Russian grammar needs where
|
|
64
|
+
no rewrite reads naturally; quotations, code, commands and paths keep theirs.
|
|
60
65
|
|
|
61
66
|
## Short video
|
|
62
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
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"
|
|
@@ -2037,6 +2037,75 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
2037
2037
|
return findings
|
|
2038
2038
|
|
|
2039
2039
|
|
|
2040
|
+
# AT-06's hard layer, owner-decided 2026-10-18: newly authored text carries no
|
|
2041
|
+
# em dash (U+2014) in any language. B062 above judges the dash's ROLE; this
|
|
2042
|
+
# judges the glyph B062 leaves alone. It never reports a sentence B062
|
|
2043
|
+
# already reports, so one dash is one finding.
|
|
2044
|
+
EM_DASH = "—"
|
|
2045
|
+
EM_DASH_RANGE_RE = re.compile(rf"\d\s*{EM_DASH}\s*\d")
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
def em_dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding]:
|
|
2049
|
+
"""The em dash the rhetorical check cannot see, and the Russian dash it allows.
|
|
2050
|
+
|
|
2051
|
+
Three cases, each one the B062 branches pass over in silence:
|
|
2052
|
+
|
|
2053
|
+
- a range written with an em dash, in any language: an error, because a
|
|
2054
|
+
range takes an en dash (U+2013) and a rewrite never costs anything;
|
|
2055
|
+
- an em dash opening a line where the locale has no dialogue dash: an
|
|
2056
|
+
error, because there it is decoration, not the speech convention;
|
|
2057
|
+
- a lone dash, in any spelling, where the locale has a grammatical dash:
|
|
2058
|
+
a warning. The copula (AT-06-E1) is allowed only where no rewrite reads
|
|
2059
|
+
naturally, and only the writer can judge that, so the linter asks and
|
|
2060
|
+
never blocks on grammar it cannot parse.
|
|
2061
|
+
"""
|
|
2062
|
+
findings: list[Finding] = []
|
|
2063
|
+
raw = prose_only(text)
|
|
2064
|
+
# Length-preserving like the B062 normaliser, so quotes come from `raw`.
|
|
2065
|
+
cells = TABLE_CELL_DASH_RE.sub(r"\1 \2", raw) # AT-06-E4
|
|
2066
|
+
body = normalise_dash_spelling(raw)
|
|
2067
|
+
if DASH not in body:
|
|
2068
|
+
return findings
|
|
2069
|
+
|
|
2070
|
+
def line_of(sentence: str) -> int:
|
|
2071
|
+
at = body.find(sentence[:40])
|
|
2072
|
+
return body.count("\n", 0, at) + 1 if at >= 0 else 0
|
|
2073
|
+
|
|
2074
|
+
for sentence, cell, original in zip(sentences(body), sentences(cells), sentences(raw)):
|
|
2075
|
+
if DASH not in sentence:
|
|
2076
|
+
continue
|
|
2077
|
+
line = line_of(sentence)
|
|
2078
|
+
if EM_DASH_RANGE_RE.search(cell):
|
|
2079
|
+
findings.append(Finding(
|
|
2080
|
+
code, SEVERITY_ERROR, path, line,
|
|
2081
|
+
f"an em dash joins a range; write the range with an en dash "
|
|
2082
|
+
f'(U+2013) or the word "to": "{_around(sentence, original)}"',
|
|
2083
|
+
))
|
|
2084
|
+
continue
|
|
2085
|
+
speech = DASH_SPEECH_RE.match(sentence)
|
|
2086
|
+
if strict:
|
|
2087
|
+
# B062 reports every other bare dash in this locale.
|
|
2088
|
+
if speech and cell.lstrip().startswith(EM_DASH):
|
|
2089
|
+
findings.append(Finding(
|
|
2090
|
+
code, SEVERITY_ERROR, path, line,
|
|
2091
|
+
f"an em dash opens the line, and this locale has no dialogue "
|
|
2092
|
+
f'dash; drop it or use a list: "{_around(sentence, original)}"',
|
|
2093
|
+
))
|
|
2094
|
+
continue
|
|
2095
|
+
if speech: # AT-06-E3
|
|
2096
|
+
continue
|
|
2097
|
+
bare = sentence.count(DASH) - len(DASH_RANGE_RE.findall(sentence))
|
|
2098
|
+
if bare <= 0 or bare >= 2 or DASH_CONJ_RE.search(sentence):
|
|
2099
|
+
continue # a range, or B062's own error
|
|
2100
|
+
findings.append(Finding(
|
|
2101
|
+
code, SEVERITY_WARN, path, line,
|
|
2102
|
+
f"a dash in new text is allowed only where the grammar needs it "
|
|
2103
|
+
f"(AT-06-E1) and no rewrite reads naturally; otherwise use a full "
|
|
2104
|
+
f'stop, a comma, a colon or brackets: "{_around(sentence, original)}"',
|
|
2105
|
+
))
|
|
2106
|
+
return findings
|
|
2107
|
+
|
|
2108
|
+
|
|
2040
2109
|
def markdown_title(text: str) -> str:
|
|
2041
2110
|
"""Read common inline heading markup as the displayed label."""
|
|
2042
2111
|
text = re.sub(r"!?\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
@@ -2197,10 +2266,11 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
2197
2266
|
|
|
2198
2267
|
|
|
2199
2268
|
def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
2200
|
-
"""B060-B063 -- machine-drafting markers, and the
|
|
2269
|
+
"""B060-B063 and B067 -- machine-drafting markers, and the absolute bans.
|
|
2201
2270
|
|
|
2202
2271
|
B060 counts vocabulary markers, B061 bans levity where the user is
|
|
2203
|
-
losing something, B062 is AT-06 (the rhetorical dash)
|
|
2272
|
+
losing something, B062 is AT-06 (the rhetorical dash), B067 is AT-06's
|
|
2273
|
+
hard layer (the em dash glyph in new text) and B063 is AT-07
|
|
2204
2274
|
(a title that ends in a full stop) outside the string registry, which
|
|
2205
2275
|
B026 already covers. The grades and the reasoning are in
|
|
2206
2276
|
`references/ai-tells.md`; this file carries only the provable subset.
|
|
@@ -2231,12 +2301,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
2231
2301
|
text = f"{fields.get('title', '')}\n{body}"
|
|
2232
2302
|
strict = not grammatical_dash_language(text, primary)
|
|
2233
2303
|
findings.extend(dash_findings("B062", path, body, strict))
|
|
2304
|
+
# B067 -- the em dash glyph B062 does not judge (AT-06, hard layer).
|
|
2305
|
+
findings.extend(em_dash_findings("B067", path, body, strict))
|
|
2234
2306
|
|
|
2235
2307
|
for row in registry(brand_dir):
|
|
2236
2308
|
if row["kind"] == "layout":
|
|
2237
2309
|
continue
|
|
2238
2310
|
strict = not grammatical_dash_language(row["text"], primary)
|
|
2239
2311
|
findings.extend(dash_findings("B062", row["location"], row["text"], strict))
|
|
2312
|
+
findings.extend(em_dash_findings("B067", row["location"], row["text"], strict))
|
|
2240
2313
|
|
|
2241
2314
|
# B063 -- AT-07 outside the registry: document titles and the headings
|
|
2242
2315
|
# inside them. B026 owns the same rule for `strings.md`, and the two are
|
package/templates/brand/voice.md
CHANGED
|
@@ -40,6 +40,8 @@ Promise: <one line, checkable against a row in facts.md>
|
|
|
40
40
|
What survives translation. Breaking one of these is a finding on any surface,
|
|
41
41
|
in any locale.
|
|
42
42
|
|
|
43
|
+
- No em dash (U+2014) in new text, in any language; the grammatical Russian
|
|
44
|
+
dash only where no rewrite reads naturally (owner decision, 2026-10-18)
|
|
43
45
|
- <e.g. never hedges>
|
|
44
46
|
- <e.g. never claims a number that is not in facts.md>
|
|
45
47
|
|
package/templates/claude-rule.md
CHANGED
|
@@ -48,14 +48,16 @@
|
|
|
48
48
|
notification and accessible name. Search `strings.md` before naming one.
|
|
49
49
|
- **No humor, exclamation marks or emoji** on error, destructive confirm,
|
|
50
50
|
billing or paywall surfaces, in any voice.
|
|
51
|
-
- **No rhetorical dash, and no full stop after a
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
- **No em dash in new text, no rhetorical dash, and no full stop after a
|
|
52
|
+
title.** Text you write carries no em dash (U+2014) in any language (owner
|
|
53
|
+
decision, 2026-10-18), and no dash in any spelling stands in for a full
|
|
54
|
+
stop, a comma or a colon. A heading, button, menu item or page title is a
|
|
55
|
+
name rather than a statement. The dash Russian grammar needs (the copula,
|
|
56
|
+
direct speech) stays only where no rewrite reads naturally; a range takes
|
|
57
|
+
an en dash. Choose the replacement from the meaning, because a comma, a
|
|
58
|
+
colon and a full stop state three different relationships and
|
|
59
|
+
find-and-replace picks the wrong one. `B062`, `B063` and `B067` catch what
|
|
60
|
+
a machine can prove; the rest is in the skill's `ai-tells.md`.
|
|
59
61
|
- Run `python3 docs/brand/lint.py` after any text change and before calling
|
|
60
62
|
work done. It must exit 0 — an error blocks and a warning is advice, which
|
|
61
63
|
is the same policy the UX linter has; `--strict` makes warnings block too.
|