super-ux 0.60.0 → 0.62.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 +62 -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 +154 -15
- package/templates/brand/voice.md +2 -0
- package/templates/claude-rule.md +10 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
+
## 0.62.0 (2026-10-19): retrieval crawlers, the audit's brief, landing promises, Copylot sweep
|
|
2
|
+
|
|
3
|
+
- **`B050` judges retrieval agents only.** The AI-search contradiction is a block
|
|
4
|
+
of `OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`,
|
|
5
|
+
`PerplexityBot` or `Perplexity-User` while `channels.md` declares AI search a
|
|
6
|
+
target. Training and dataset tokens (`GPTBot`, `ClaudeBot`, `CCBot`,
|
|
7
|
+
`Google-Extended`) are a separate decision and are never reported; before
|
|
8
|
+
this release a `GPTBot` block was an error and an `OAI-SearchBot` block was
|
|
9
|
+
invisible. robots.txt is read in RFC 9309 groups: shared `User-agent` lines,
|
|
10
|
+
the `*` fallback for an agent with no group of its own, case-insensitive
|
|
11
|
+
tokens, and an equal-length `Allow: /` beating `Disallow: /`. Each purpose is
|
|
12
|
+
cited from the vendor's own crawler page in the code and the contract.
|
|
13
|
+
Fifteen new fixtures, each defect planted in the linter and watched red.
|
|
14
|
+
- **`seo-aeo-safety.md`:** a "Crawler policy: two decisions" section with the
|
|
15
|
+
vendor table; the technical floor replaced by a pointer to seo-aeo-audit's
|
|
16
|
+
brief mode (`references/build-time.md`, ships with seo-aeo-audit 0.28); no
|
|
17
|
+
`FAQPage`/`HowTo` markup for rich results (Google's changelog: HowTo removed
|
|
18
|
+
in 2023, FAQ rich results gone from 7 May 2026); the keyword-stuffing text now
|
|
19
|
+
states what `B051` enforces (pages of 40 or more words, a word repeated 5 or
|
|
20
|
+
more times above a 4% share, registered terms exempt, advisory) instead of
|
|
21
|
+
the retired 1% rule; Claude's row in the engine table reflects web search.
|
|
22
|
+
- **`landing-pages.md` LP-21..LP-24:** an offer condition next to its claim and
|
|
23
|
+
the same in the ad; lead with the problem and name AI only where it changes
|
|
24
|
+
the outcome; a "works with X" claim waits for a measured acceptance test in
|
|
25
|
+
`facts.md`; one job per page and creatives judged by the qualified-step rate,
|
|
26
|
+
not CTR. Readiness-check commands for LP-21, LP-23 and LP-24; LP-22 stays a
|
|
27
|
+
reading. Each rule cites the regulator or platform policy it rests on.
|
|
28
|
+
- **`copywriting`:** runs `copylot.check` beside the brand linter when the tool
|
|
29
|
+
is in the session (target `register: web`, `format: web-page`; until
|
|
30
|
+
Copylot's schema lists them, `marketing` and no format, said out loud) and
|
|
31
|
+
prints a fallback line when it is absent.
|
|
32
|
+
- **Synthetic respondents are hypotheses:** `research-evidence.md`,
|
|
33
|
+
`ux-foundation` and `ux-scenarios` say a synthetic persona or LLM "digital
|
|
34
|
+
twin" is never evidence for a persona or scenario decision (cited: Nielsen
|
|
35
|
+
Norman Group, 2024). `audit-depth.md` adds the quarterly fresh-user walk:
|
|
36
|
+
clean install or new account, from the ad or landing page to paid use with a
|
|
37
|
+
real payment method, on the smallest supported screen, findings recorded
|
|
38
|
+
against scenarios.
|
|
39
|
+
- Contract copies synchronized with `python3 test/sync_references.py`; seeded
|
|
40
|
+
`docs/brand/lint.py` re-copied. Discovery descriptions unchanged.
|
|
41
|
+
|
|
42
|
+
## 0.61.0 (2026-10-18): no em dash in new text
|
|
43
|
+
|
|
44
|
+
Owner decision, 2026-10-18: text an agent writes carries no em dash (U+2014), in
|
|
45
|
+
any language, and no substitute for one. The family hub carries the same rule as
|
|
46
|
+
an always-on line for every host; this release makes product text agree with it.
|
|
47
|
+
|
|
48
|
+
- `ai-tells.md` AT-06 is now a hard ban, not a frequency target. The dash rule has
|
|
49
|
+
two layers: the glyph (no em dash in newly authored text) and the role (the
|
|
50
|
+
rhetorical dash is banned in every spelling; the grammatical Russian dash is
|
|
51
|
+
allowed only where no rewrite reads naturally). Ranges take an en dash.
|
|
52
|
+
- New lint code `B067` (`brand_lint.py`, seeded `docs/brand/lint.py`): an em dash
|
|
53
|
+
joining a range errors; an em dash opening a line where the locale has no
|
|
54
|
+
dialogue dash errors; a lone dash in a locale with a grammatical one warns, so
|
|
55
|
+
the writer decides whether the grammar needs it. Never a second finding beside
|
|
56
|
+
`B062`. Five fixtures, each watched failing with `B067` stubbed out.
|
|
57
|
+
- `copywriting`: the em dash is a Non-negotiable and the one exception to the
|
|
58
|
+
advisory humanization pass; the status-line examples no longer use a dash.
|
|
59
|
+
`brand-voice` seeds the invariant into `voice.md`; the brand hard rule
|
|
60
|
+
(`templates/claude-rule.md` and its embedded copies) and the Cursor rule say so.
|
|
61
|
+
- Contract copies synchronized with `python3 test/sync_references.py`.
|
|
62
|
+
|
|
1
63
|
## 0.60.0 — a rendered screen is checked in WebKit (Safari) first
|
|
2
64
|
|
|
3
65
|
- 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.62.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"
|
|
@@ -1794,7 +1794,79 @@ def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
|
|
|
1794
1794
|
return findings
|
|
1795
1795
|
|
|
1796
1796
|
|
|
1797
|
-
|
|
1797
|
+
# B050 judges RETRIEVAL agents only: the ones that fetch a page to show, cite
|
|
1798
|
+
# or answer from it. Blocking one while AI search is a declared target is the
|
|
1799
|
+
# contradiction. Each purpose is the vendor's own wording, read 2026-10-19:
|
|
1800
|
+
# OAI-SearchBot "surface websites in search results in ChatGPT's search
|
|
1801
|
+
# features"; ChatGPT-User user-initiated fetches
|
|
1802
|
+
# (https://developers.openai.com/api/docs/bots)
|
|
1803
|
+
# Claude-SearchBot indexes content for search; Claude-User retrieves a
|
|
1804
|
+
# page in response to a user query
|
|
1805
|
+
# (https://support.claude.com/en/articles/8896518)
|
|
1806
|
+
# PerplexityBot "surface and link websites in search results on
|
|
1807
|
+
# Perplexity"; Perplexity-User fetches for a user's question
|
|
1808
|
+
# (https://docs.perplexity.ai/guides/bots)
|
|
1809
|
+
RETRIEVAL_AGENTS = (
|
|
1810
|
+
"OAI-SearchBot", "ChatGPT-User", "Claude-SearchBot", "Claude-User",
|
|
1811
|
+
"PerplexityBot", "Perplexity-User",
|
|
1812
|
+
)
|
|
1813
|
+
# TRAINING and dataset tokens: blocking them is a separate decision with no
|
|
1814
|
+
# effect on AI search, so B050 never reports them. GPTBot and ClaudeBot are
|
|
1815
|
+
# the vendors' training crawlers (same pages); Google-Extended controls Gemini
|
|
1816
|
+
# training and grounding and "does not impact a site's inclusion in Google
|
|
1817
|
+
# Search" (https://developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers);
|
|
1818
|
+
# CCBot builds Common Crawl's open archive (https://commoncrawl.org/ccbot).
|
|
1819
|
+
TRAINING_AGENTS = ("GPTBot", "ClaudeBot", "CCBot", "Google-Extended")
|
|
1820
|
+
# Whole-site paths and their rule length. Google's matcher ignores a trailing
|
|
1821
|
+
# `*` (so `/*` and a bare `*` weigh what `/` weighs) and reads `$` as the end
|
|
1822
|
+
# of the path; the longer rule wins and Allow wins a tie (RFC 9309 2.2.2).
|
|
1823
|
+
# So `Disallow: /*` against `Allow: /` is a tie Allow wins, while
|
|
1824
|
+
# `Disallow: /*$` (3 octets) outranks `Allow: /`.
|
|
1825
|
+
FULL_PATHS = {"/": 1, "/*": 1, "*": 1, "/*$": 3}
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def robots_groups(text: str) -> list[tuple[list[str], list[tuple[str, str]]]]:
|
|
1829
|
+
"""RFC 9309 groups: consecutive `User-agent` lines share the rules after
|
|
1830
|
+
them, and a `User-agent` line after a rule opens a new group. Tokens are
|
|
1831
|
+
lower-cased, because product tokens match case-insensitively, and cut to
|
|
1832
|
+
their leading [a-z_-] run, as Google's matcher does, so `OAI-SearchBot/1.0`
|
|
1833
|
+
still names OAI-SearchBot. A UTF-8 BOM before the first line is dropped."""
|
|
1834
|
+
text = text.lstrip("\ufeff")
|
|
1835
|
+
groups: list[tuple[list[str], list[tuple[str, str]]]] = []
|
|
1836
|
+
agents: list[str] = []
|
|
1837
|
+
rules: list[tuple[str, str]] = []
|
|
1838
|
+
for raw in text.splitlines():
|
|
1839
|
+
line = raw.split("#", 1)[0].strip()
|
|
1840
|
+
m = re.match(r"^(user-agent|allow|disallow)\s*:\s*(.*?)\s*$", line, re.I)
|
|
1841
|
+
if not m:
|
|
1842
|
+
continue
|
|
1843
|
+
key, value = m.group(1).lower(), m.group(2)
|
|
1844
|
+
if key == "user-agent":
|
|
1845
|
+
if rules:
|
|
1846
|
+
groups.append((agents, rules))
|
|
1847
|
+
agents, rules = [], []
|
|
1848
|
+
token = re.match(r"\*(?=\s|$)|[a-z_-]*", value.lower()).group(0)
|
|
1849
|
+
agents.append(token or value.lower())
|
|
1850
|
+
elif agents:
|
|
1851
|
+
rules.append((key, value))
|
|
1852
|
+
if agents:
|
|
1853
|
+
groups.append((agents, rules))
|
|
1854
|
+
return groups
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def robots_blocks(text: str, token: str) -> bool:
|
|
1858
|
+
"""True when robots.txt shuts `token` out of the whole site. The agent
|
|
1859
|
+
obeys the groups naming it, or the `*` groups when none does; an
|
|
1860
|
+
equal-length `Allow` beats `Disallow` (RFC 9309 section 2.2.2)."""
|
|
1861
|
+
groups = robots_groups(text)
|
|
1862
|
+
own = [r for a, r in groups if token.lower() in a]
|
|
1863
|
+
rules = [rule for rs in (own or [r for a, r in groups if "*" in a])
|
|
1864
|
+
for rule in rs]
|
|
1865
|
+
def longest(kind: str) -> int:
|
|
1866
|
+
return max((FULL_PATHS[v] for k, v in rules
|
|
1867
|
+
if k == kind and v in FULL_PATHS), default=0)
|
|
1868
|
+
disallow, allow = longest("disallow"), longest("allow")
|
|
1869
|
+
return disallow > 0 and disallow > allow
|
|
1798
1870
|
|
|
1799
1871
|
FILLER_OPENERS = (
|
|
1800
1872
|
"in today's digital landscape", "in the ever-evolving world",
|
|
@@ -2037,6 +2109,75 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
2037
2109
|
return findings
|
|
2038
2110
|
|
|
2039
2111
|
|
|
2112
|
+
# AT-06's hard layer, owner-decided 2026-10-18: newly authored text carries no
|
|
2113
|
+
# em dash (U+2014) in any language. B062 above judges the dash's ROLE; this
|
|
2114
|
+
# judges the glyph B062 leaves alone. It never reports a sentence B062
|
|
2115
|
+
# already reports, so one dash is one finding.
|
|
2116
|
+
EM_DASH = "—"
|
|
2117
|
+
EM_DASH_RANGE_RE = re.compile(rf"\d\s*{EM_DASH}\s*\d")
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def em_dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding]:
|
|
2121
|
+
"""The em dash the rhetorical check cannot see, and the Russian dash it allows.
|
|
2122
|
+
|
|
2123
|
+
Three cases, each one the B062 branches pass over in silence:
|
|
2124
|
+
|
|
2125
|
+
- a range written with an em dash, in any language: an error, because a
|
|
2126
|
+
range takes an en dash (U+2013) and a rewrite never costs anything;
|
|
2127
|
+
- an em dash opening a line where the locale has no dialogue dash: an
|
|
2128
|
+
error, because there it is decoration, not the speech convention;
|
|
2129
|
+
- a lone dash, in any spelling, where the locale has a grammatical dash:
|
|
2130
|
+
a warning. The copula (AT-06-E1) is allowed only where no rewrite reads
|
|
2131
|
+
naturally, and only the writer can judge that, so the linter asks and
|
|
2132
|
+
never blocks on grammar it cannot parse.
|
|
2133
|
+
"""
|
|
2134
|
+
findings: list[Finding] = []
|
|
2135
|
+
raw = prose_only(text)
|
|
2136
|
+
# Length-preserving like the B062 normaliser, so quotes come from `raw`.
|
|
2137
|
+
cells = TABLE_CELL_DASH_RE.sub(r"\1 \2", raw) # AT-06-E4
|
|
2138
|
+
body = normalise_dash_spelling(raw)
|
|
2139
|
+
if DASH not in body:
|
|
2140
|
+
return findings
|
|
2141
|
+
|
|
2142
|
+
def line_of(sentence: str) -> int:
|
|
2143
|
+
at = body.find(sentence[:40])
|
|
2144
|
+
return body.count("\n", 0, at) + 1 if at >= 0 else 0
|
|
2145
|
+
|
|
2146
|
+
for sentence, cell, original in zip(sentences(body), sentences(cells), sentences(raw)):
|
|
2147
|
+
if DASH not in sentence:
|
|
2148
|
+
continue
|
|
2149
|
+
line = line_of(sentence)
|
|
2150
|
+
if EM_DASH_RANGE_RE.search(cell):
|
|
2151
|
+
findings.append(Finding(
|
|
2152
|
+
code, SEVERITY_ERROR, path, line,
|
|
2153
|
+
f"an em dash joins a range; write the range with an en dash "
|
|
2154
|
+
f'(U+2013) or the word "to": "{_around(sentence, original)}"',
|
|
2155
|
+
))
|
|
2156
|
+
continue
|
|
2157
|
+
speech = DASH_SPEECH_RE.match(sentence)
|
|
2158
|
+
if strict:
|
|
2159
|
+
# B062 reports every other bare dash in this locale.
|
|
2160
|
+
if speech and cell.lstrip().startswith(EM_DASH):
|
|
2161
|
+
findings.append(Finding(
|
|
2162
|
+
code, SEVERITY_ERROR, path, line,
|
|
2163
|
+
f"an em dash opens the line, and this locale has no dialogue "
|
|
2164
|
+
f'dash; drop it or use a list: "{_around(sentence, original)}"',
|
|
2165
|
+
))
|
|
2166
|
+
continue
|
|
2167
|
+
if speech: # AT-06-E3
|
|
2168
|
+
continue
|
|
2169
|
+
bare = sentence.count(DASH) - len(DASH_RANGE_RE.findall(sentence))
|
|
2170
|
+
if bare <= 0 or bare >= 2 or DASH_CONJ_RE.search(sentence):
|
|
2171
|
+
continue # a range, or B062's own error
|
|
2172
|
+
findings.append(Finding(
|
|
2173
|
+
code, SEVERITY_WARN, path, line,
|
|
2174
|
+
f"a dash in new text is allowed only where the grammar needs it "
|
|
2175
|
+
f"(AT-06-E1) and no rewrite reads naturally; otherwise use a full "
|
|
2176
|
+
f'stop, a comma, a colon or brackets: "{_around(sentence, original)}"',
|
|
2177
|
+
))
|
|
2178
|
+
return findings
|
|
2179
|
+
|
|
2180
|
+
|
|
2040
2181
|
def markdown_title(text: str) -> str:
|
|
2041
2182
|
"""Read common inline heading markup as the displayed label."""
|
|
2042
2183
|
text = re.sub(r"!?\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
@@ -2090,21 +2231,15 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
2090
2231
|
(p, {}, "") for p in sources["robots"]
|
|
2091
2232
|
]:
|
|
2092
2233
|
robots = read(brand_dir.parent.parent / path) or ""
|
|
2093
|
-
blocks = []
|
|
2094
|
-
agent = None
|
|
2095
|
-
for line in robots.splitlines():
|
|
2096
|
-
head = re.match(r"^User-agent:\s*(.+?)\s*$", line, re.I)
|
|
2097
|
-
if head:
|
|
2098
|
-
agent = head.group(1).strip()
|
|
2099
|
-
elif re.match(r"^Disallow:\s*/\s*$", line, re.I) and agent:
|
|
2100
|
-
if agent in AI_AGENTS:
|
|
2101
|
-
blocks.append(agent)
|
|
2234
|
+
blocks = [t for t in RETRIEVAL_AGENTS if robots_blocks(robots, t)]
|
|
2102
2235
|
if blocks:
|
|
2103
2236
|
findings.append(Finding(
|
|
2104
2237
|
"B050", SEVERITY_ERROR, path, 0,
|
|
2105
|
-
f"channels.md declares AI search a target while "
|
|
2106
|
-
f"{', '.join(blocks)}
|
|
2107
|
-
f"
|
|
2238
|
+
f"channels.md declares AI search a target while the "
|
|
2239
|
+
f"retrieval agent(s) {', '.join(blocks)} are blocked here. "
|
|
2240
|
+
f"An answer engine cannot cite a page it may not fetch. "
|
|
2241
|
+
f"Training crawlers ({', '.join(TRAINING_AGENTS)}) are a "
|
|
2242
|
+
f"separate decision and are not judged here",
|
|
2108
2243
|
))
|
|
2109
2244
|
|
|
2110
2245
|
records = surfaces(brand_dir)
|
|
@@ -2197,10 +2332,11 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
2197
2332
|
|
|
2198
2333
|
|
|
2199
2334
|
def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
2200
|
-
"""B060-B063 -- machine-drafting markers, and the
|
|
2335
|
+
"""B060-B063 and B067 -- machine-drafting markers, and the absolute bans.
|
|
2201
2336
|
|
|
2202
2337
|
B060 counts vocabulary markers, B061 bans levity where the user is
|
|
2203
|
-
losing something, B062 is AT-06 (the rhetorical dash)
|
|
2338
|
+
losing something, B062 is AT-06 (the rhetorical dash), B067 is AT-06's
|
|
2339
|
+
hard layer (the em dash glyph in new text) and B063 is AT-07
|
|
2204
2340
|
(a title that ends in a full stop) outside the string registry, which
|
|
2205
2341
|
B026 already covers. The grades and the reasoning are in
|
|
2206
2342
|
`references/ai-tells.md`; this file carries only the provable subset.
|
|
@@ -2231,12 +2367,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
2231
2367
|
text = f"{fields.get('title', '')}\n{body}"
|
|
2232
2368
|
strict = not grammatical_dash_language(text, primary)
|
|
2233
2369
|
findings.extend(dash_findings("B062", path, body, strict))
|
|
2370
|
+
# B067 -- the em dash glyph B062 does not judge (AT-06, hard layer).
|
|
2371
|
+
findings.extend(em_dash_findings("B067", path, body, strict))
|
|
2234
2372
|
|
|
2235
2373
|
for row in registry(brand_dir):
|
|
2236
2374
|
if row["kind"] == "layout":
|
|
2237
2375
|
continue
|
|
2238
2376
|
strict = not grammatical_dash_language(row["text"], primary)
|
|
2239
2377
|
findings.extend(dash_findings("B062", row["location"], row["text"], strict))
|
|
2378
|
+
findings.extend(em_dash_findings("B067", row["location"], row["text"], strict))
|
|
2240
2379
|
|
|
2241
2380
|
# B063 -- AT-07 outside the registry: document titles and the headings
|
|
2242
2381
|
# 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.
|