Secondary-Text Audit
Extract every piece of subordinate copy in a surface and put each one on trial.
The ruleset's premise is that secondary text defaults to absent: it exists
only to carry information the user needs before acting that the label, the
control's shape, and the surrounding context do not already give them. Most
shipped secondary text describes what something is, which the label already
did. That text goes.
The output is a verdict table, not prose commentary. Every row resolves to
keep, rewrite, or delete, and every rewrite carries its replacement
string.
Design philosophy (read this — it shapes every judgment call)
Delete is the expected majority outcome. This is the inversion that makes
the skill useful. An audit that keeps most of what it finds has not applied
the ruleset; it has admired the copy. If your table is mostly keep, re-run
the fact test on every keep row and mean it.
Uncertainty resolves to delete, with one exception. When a string sits
between delete and rewrite, choose delete — the primary label almost
always suffices. This is the opposite of the precision-over-recall bias in
ux-semantics-audit, and deliberately so: a wrong keep is permanent noise
on the screen.
The exception is a string carrying a cost, a scope, or a cause. Deleting one
of those does not cost a line of copy, it costs the reader the fact that made
the screen actionable. When the string tells the user what an action breaks,
where a setting stops applying, or why a surface is in a state they did not
choose, uncertainty resolves to rewrite. Trim it to the fact and keep it.
Judge the rendered pair, never the key name. A verdict needs the label
the user actually sees next to the text. A key called *.description may
render as a card subtitle, a tooltip body, a dialog description, or nothing
at all.
Fact before verdict. Name which kind of fact the text carries before
deciding anything. A string carrying none is delete — that is the rule,
not a shortcut.
A rewrite must be a real string. "Tighten this" is not a replacement.
Write the sentence that ships, in the register
docs/ui/VOICE.md fixes. If you cannot write
one that passes the rules, the verdict was delete.
A shorter string is not automatically a better one. Trimming is the means,
not the goal. A replacement that reads as a system log has failed even when
every word left is load-bearing — check it against VOICE.md before it ships.
Workflow
Step 1 — Scope the audit
The unit of analysis is the surface: a page, tab, dialog, drawer, or
self-contained section. Secondary text is judged against what renders beside it,
so the scope must be something the user sees at once.
- Whole package → audit surface by surface; do not emit one 120-row table.
- A named page ("the settings page", "the app detail page") → that page and the
dialogs it opens.
- New copy the user just wrote → that string plus its label and siblings.
Step 2 — Extract the candidates
node .claude/skills/secondary-text-audit/scripts/extract-secondary-text.mjs --scope settings
The script collects both carriers Rome uses — i18n locale keys under
packages/web/src/i18n/locales/<locale>/ and literal strings in *Description
components and description= / subtitle= / hint= / helperText= props —
pairs each with its label, resolves it back to the component that renders it,
and prescreens it. --scope <substr> filters by key or location; --json emits
the rows for programmatic use; --root points at another package.
Its flags are advisory triage, not verdicts:
forbidden:* and generic-verb are near-certain delete, but confirm the
match is real before writing it down.
no-signal fires on most rows. It means "re-examine", nothing more — real
cost text often has no number.
sr-only rows are out of scope entirely. Never delete them.
UNRENDERED means no call site was found. Confirm with a grep for the bare
key before calling copy dead, then verdict it delete.
dynamic-key and key-ref are the weakest resolutions and can over-match: a
computed key like connections.headings.${role} matches every sibling under
that prefix, including ones the union type never produces. Open the cited line
and check the key can really reach this row before trusting it — a row that
only resolves this way is a delete candidate hiding behind a wildcard.
label-inferred means the label shown is a guess from the key path. Read the
component before judging that row.
Step 3 — Read the ruleset and the voice
Read docs/ui/VOICE.md first — it fixes the
register, the person, and what a description must carry, and every rewrite
row is written in it. Then read
docs/ui/secondary-text.md in full. It
carries the scope, the five kinds of fact, the four-question test, and the
repo rules. Do not
audit from memory of the kind names.
Step 4 — Establish the rendered context
For each surface, read the component and write down, before judging anything:
- the label rendered above/beside each string, verbatim;
- the siblings rendered on the same screen (this decides every
difference verdict);
- whether the string is conditional — empty-state only, error-only,
first-run only (this decides first-step text and pulls error copy out
of scope);
- what the control's shape already says — a toggle, a file input, and a
destructive-variant button each carry information the copy need not repeat.
This step is what separates a verdict from a guess. Skipping it produces
plausible-sounding tables that are wrong about half the rows.
Step 5 — Run the test per string
Ask the four questions of every string, in order, stopping at the first
failure. Then, for rewrite rows, check that the replacement passes the four questions on its own and
obeys VOICE.md.
Step 6 — Verdict-hardening pass
Before emitting, re-examine the table:
- Every
keep — name its kind of fact out loud. If naming it takes a paragraph of
justification, it is rewrite or delete.
- Every
rewrite — read the replacement alone, without the original. Does it
pass the four questions as a new string? Does it lead with the fact?
- Every
delete — confirm the information is not lost: either it was
restatement, or the surviving payload moved into another row's replacement.
Say which.
- Every row — is the label quoted the one that actually renders?
Step 7 — Emit the report
Use exactly this structure:
# Secondary-Text Audit: <surface>
## Summary
<2-4 sentences: how many strings, the keep/rewrite/delete split, and the single
pattern driving most deletions on this surface.>
## Verdicts
| Location | Label | Secondary text | Fact | Verdict | Replacement |
|---|---|---|---|---|---|
## Notes
<Only rows needing a reason a reader would otherwise dispute: a `keep` that
looks like description, a `delete` whose payload moved elsewhere, a `rewrite`
that changes placement. One line each, referencing the location.>
## Out of scope
<Strings the extractor surfaced that the ruleset excludes — sr-only
descriptions, error messages, placeholders, dev-gallery copy — with the
exclusion that applies. Keeps the next reader from re-litigating them.>
Column rules:
- Location —
file:line of the render site, plus the i18n key when there is
one. The applying agent needs both: the key to edit, the line to check.
- Fact — one of the five kinds, or
none.
- Verdict —
keep, rewrite, or delete.
- Replacement — required for
rewrite, empty for delete and keep.
Order rows by render order on the surface, not by verdict. The applying agent
walks the file top to bottom, and a reader checking your work walks the screen.
Step 8 — Applying the verdicts
When the user asks for the changes and not just the table:
- A
delete on an i18n key removes it from every locale under
packages/web/src/i18n/locales/ — en and zh-CN today. A key deleted from
en alone leaves the other locale still rendering the string.
- A
rewrite updates en and marks the other locales for retranslation; do not
hand-translate unless the user asks.
- Deleting the last child of a key object removes the empty parent too.
- Removing a rendered string usually leaves a dead wrapper element — delete the
<p> or the description={...} prop, not just the key.
- Verify with
pnpm typecheck, then look at the surface in the running
dashboard. Copy changes are exactly the class of change a type check cannot
catch: a deleted key that is still rendered fails at runtime as a raw key
string, not at build time.
Division of labor
| Question |
Skill |
| Should this description exist, and how should it read? |
secondary-text-audit |
| Does this view have the right labels, states, and emphasis? |
ux-semantics-audit |
| Is the palette itself sound? |
color-audit |
The seam with ux-semantics-audit is its consistent-terminology and
label-outcome-clarity rules: those judge the label, this skill judges what
hangs beneath it. When a description only exists because the label is wrong, say
so in Notes and name the ux-semantics-audit follow-up rather than writing a
replacement that props up a bad label.
What this skill does NOT do
- Judge labels, button text, or headings — that is the primary layer.
- Own tone or brand voice —
docs/ui/VOICE.md does.
This skill decides whether a string exists and what it must carry; every
replacement it writes must obey that file.
- Rewrite error messages or validation text — different rules, out of scope.
- Translate. It flags locales that need retranslation; it does not write them.
- Apply its own verdicts unless asked. The table is the deliverable.
1---2name: secondary-text-audit3description: Audit the subordinate copy in a UI — section descriptions, field helper text, hints, card subtitles, empty-state body copy, tooltip bodies — against the secondary-text ruleset, and emit a per-string keep/rewrite/delete verdict table a coding agent can apply. Use this skill whenever the user asks to review, tighten, trim, or clean up UI copy, microcopy, descriptions, helper text, subtitles, hints, or empty states; asks "is this copy necessary?", "does this description earn its place?", "why is our UI so wordy?"; wants a settings page or dialog decluttered; or is writing new secondary text and wants it checked before it ships — even if they don't say "audit". For view-level UX behavior (labels, missing states, redundancy, one-primary-action), use ux-semantics-audit; for palette and tokens, use color-audit. This skill owns everything about whether a piece of subordinate copy should exist at all and how it should read.4---56# Secondary-Text Audit78Extract every piece of subordinate copy in a surface and put each one on trial.9The ruleset's premise is that **secondary text defaults to absent**: it exists10only to carry information the user needs before acting that the label, the11control's shape, and the surrounding context do not already give them. Most12shipped secondary text describes what something *is*, which the label already13did. That text goes.1415The output is a verdict table, not prose commentary. Every row resolves to16`keep`, `rewrite`, or `delete`, and every `rewrite` carries its replacement17string.1819## Design philosophy (read this — it shapes every judgment call)20211. **Delete is the expected majority outcome.** This is the inversion that makes22 the skill useful. An audit that keeps most of what it finds has not applied23 the ruleset; it has admired the copy. If your table is mostly `keep`, re-run24 the fact test on every `keep` row and mean it.252. **Uncertainty resolves to `delete`, with one exception.** When a string sits26 between `delete` and `rewrite`, choose `delete` — the primary label almost27 always suffices. This is the opposite of the precision-over-recall bias in28 `ux-semantics-audit`, and deliberately so: a wrong `keep` is permanent noise29 on the screen.3031 The exception is a string carrying a cost, a scope, or a cause. Deleting one32 of those does not cost a line of copy, it costs the reader the fact that made33 the screen actionable. When the string tells the user what an action breaks,34 where a setting stops applying, or why a surface is in a state they did not35 choose, uncertainty resolves to `rewrite`. Trim it to the fact and keep it.363. **Judge the rendered pair, never the key name.** A verdict needs the label37 the user actually sees next to the text. A key called `*.description` may38 render as a card subtitle, a tooltip body, a dialog description, or nothing39 at all.404. **Fact before verdict.** Name which kind of fact the text carries before41 deciding anything. A string carrying none is `delete` — that is the rule,42 not a shortcut.435. **A rewrite must be a real string.** "Tighten this" is not a replacement.44 Write the sentence that ships, in the register45 [`docs/ui/VOICE.md`](../../../docs/ui/VOICE.md) fixes. If you cannot write46 one that passes the rules, the verdict was `delete`.47486. **A shorter string is not automatically a better one.** Trimming is the means,49 not the goal. A replacement that reads as a system log has failed even when50 every word left is load-bearing — check it against `VOICE.md` before it ships.5152## Workflow5354### Step 1 — Scope the audit5556The unit of analysis is the **surface**: a page, tab, dialog, drawer, or57self-contained section. Secondary text is judged against what renders beside it,58so the scope must be something the user sees at once.5960- Whole package → audit surface by surface; do not emit one 120-row table.61- A named page ("the settings page", "the app detail page") → that page and the62 dialogs it opens.63- New copy the user just wrote → that string plus its label and siblings.6465### Step 2 — Extract the candidates6667```bash68node .claude/skills/secondary-text-audit/scripts/extract-secondary-text.mjs --scope settings69```7071The script collects both carriers Rome uses — i18n locale keys under72`packages/web/src/i18n/locales/<locale>/` and literal strings in `*Description`73components and `description=` / `subtitle=` / `hint=` / `helperText=` props —74pairs each with its label, resolves it back to the component that renders it,75and prescreens it. `--scope <substr>` filters by key or location; `--json` emits76the rows for programmatic use; `--root` points at another package.7778Its flags are **advisory triage**, not verdicts:7980- `forbidden:*` and `generic-verb` are near-certain `delete`, but confirm the81 match is real before writing it down.82- `no-signal` fires on most rows. It means "re-examine", nothing more — real83 cost text often has no number.84- `sr-only` rows are out of scope entirely. Never delete them.85- `UNRENDERED` means no call site was found. Confirm with a grep for the bare86 key before calling copy dead, then verdict it `delete`.87- `dynamic-key` and `key-ref` are the weakest resolutions and can over-match: a88 computed key like `connections.headings.${role}` matches every sibling under89 that prefix, including ones the union type never produces. Open the cited line90 and check the key can really reach this row before trusting it — a row that91 only resolves this way is a `delete` candidate hiding behind a wildcard.92- `label-inferred` means the label shown is a guess from the key path. Read the93 component before judging that row.9495### Step 3 — Read the ruleset and the voice9697Read [`docs/ui/VOICE.md`](../../../docs/ui/VOICE.md) first — it fixes the98register, the person, and what a description must carry, and every `rewrite`99row is written in it. Then read100[`docs/ui/secondary-text.md`](../../../docs/ui/secondary-text.md) in full. It101carries the scope, the five kinds of fact, the four-question test, and the102repo rules. Do not103audit from memory of the kind names.104105### Step 4 — Establish the rendered context106107For each surface, read the component and write down, before judging anything:108109- **the label** rendered above/beside each string, verbatim;110- **the siblings** rendered on the same screen (this decides every111 difference verdict);112- **whether the string is conditional** — empty-state only, error-only,113 first-run only (this decides first-step text and pulls error copy out114 of scope);115- **what the control's shape already says** — a toggle, a file input, and a116 destructive-variant button each carry information the copy need not repeat.117118This step is what separates a verdict from a guess. Skipping it produces119plausible-sounding tables that are wrong about half the rows.120121### Step 5 — Run the test per string122123Ask the four questions of every string, in order, stopping at the first124failure. Then, for `rewrite` rows, check that the replacement passes the four questions on its own and125obeys `VOICE.md`.126127### Step 6 — Verdict-hardening pass128129Before emitting, re-examine the table:130131- Every `keep` — name its kind of fact out loud. If naming it takes a paragraph of132 justification, it is `rewrite` or `delete`.133- Every `rewrite` — read the replacement alone, without the original. Does it134 pass the four questions as a new string? Does it lead with the fact?135- Every `delete` — confirm the information is not lost: either it was136 restatement, or the surviving payload moved into another row's replacement.137 Say which.138- Every row — is the label quoted the one that actually renders?139140### Step 7 — Emit the report141142Use exactly this structure:143144```markdown145# Secondary-Text Audit: <surface>146147## Summary148<2-4 sentences: how many strings, the keep/rewrite/delete split, and the single149pattern driving most deletions on this surface.>150151## Verdicts152153| Location | Label | Secondary text | Fact | Verdict | Replacement |154|---|---|---|---|---|---|155156## Notes157<Only rows needing a reason a reader would otherwise dispute: a `keep` that158looks like description, a `delete` whose payload moved elsewhere, a `rewrite`159that changes placement. One line each, referencing the location.>160161## Out of scope162<Strings the extractor surfaced that the ruleset excludes — sr-only163descriptions, error messages, placeholders, dev-gallery copy — with the164exclusion that applies. Keeps the next reader from re-litigating them.>165```166167Column rules:168169- **Location** — `file:line` of the render site, plus the i18n key when there is170 one. The applying agent needs both: the key to edit, the line to check.171- **Fact** — one of the five kinds, or `none`.172- **Verdict** — `keep`, `rewrite`, or `delete`.173- **Replacement** — required for `rewrite`, empty for `delete` and `keep`.174175Order rows by render order on the surface, not by verdict. The applying agent176walks the file top to bottom, and a reader checking your work walks the screen.177178### Step 8 — Applying the verdicts179180When the user asks for the changes and not just the table:181182- A `delete` on an i18n key removes it from **every** locale under183 `packages/web/src/i18n/locales/` — `en` and `zh-CN` today. A key deleted from184 `en` alone leaves the other locale still rendering the string.185- A `rewrite` updates `en` and marks the other locales for retranslation; do not186 hand-translate unless the user asks.187- Deleting the last child of a key object removes the empty parent too.188- Removing a rendered string usually leaves a dead wrapper element — delete the189 `<p>` or the `description={...}` prop, not just the key.190- Verify with `pnpm typecheck`, then look at the surface in the running191 dashboard. Copy changes are exactly the class of change a type check cannot192 catch: a deleted key that is still rendered fails at runtime as a raw key193 string, not at build time.194195## Division of labor196197| Question | Skill |198|---|---|199| Should this description exist, and how should it read? | **secondary-text-audit** |200| Does this view have the right labels, states, and emphasis? | **ux-semantics-audit** |201| Is the palette itself sound? | **color-audit** |202203The seam with `ux-semantics-audit` is its `consistent-terminology` and204`label-outcome-clarity` rules: those judge the **label**, this skill judges what205hangs beneath it. When a description only exists because the label is wrong, say206so in Notes and name the `ux-semantics-audit` follow-up rather than writing a207replacement that props up a bad label.208209## What this skill does NOT do210211- Judge labels, button text, or headings — that is the primary layer.212- Own tone or brand voice — [`docs/ui/VOICE.md`](../../../docs/ui/VOICE.md) does.213 This skill decides whether a string exists and what it must carry; every214 replacement it writes must obey that file.215- Rewrite error messages or validation text — different rules, out of scope.216- Translate. It flags locales that need retranslation; it does not write them.217- Apply its own verdicts unless asked. The table is the deliverable.