refactor(i18n): shorten the five descriptions the gate could not see

The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.

Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.

`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.

The five, all six locales:

- full backup — the twelve-item enumeration was the description. It is now
  the card's opening body paragraph, where a list of what is in the file
  belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
  review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
  paragraph rather than riding the page explainer.

Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 23:20:12 +02:00
1 parent ff61aec51f
commit 23eae2823e
12 files changed
+172 -73

No files matched your search

+5
View File
@@ -76,6 +76,11 @@ export default function InsightsEcgPage() {
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
{/* What the app does and does not claim about a trace. Its own
paragraph, so the page explainer above stays one sentence. */}
<p className="text-muted-foreground text-sm">
{t("insights.subPage.ecgDisclaimer")}
</p>
<EcgSection enabled={isAuthenticated} hideHeading />
</SubPageShell>
);
+3
View File
@@ -144,6 +144,9 @@ export function LabsSettings() {
/>
}
/>
{/* "Less accurate — review every value" is safety copy, so it reads
foreground in the body rather than muted beside the switch. */}
<p className="text-sm">{t("labs.localOcr.detail")}</p>
</SettingsCard>
{/* Biomarker catalog — define / edit / delete. */}
@@ -7,10 +7,10 @@ import { join } from "node:path";
* (design standards §3).
*
* Scope is deliberately the strings that actually reach a card header's
* description slot — `<SettingsCardHeader description={t("…")}>` — because
* that is the slot the rule is about. A hint in a card body is prose and is
* allowed to be prose; `scripts/measure-description-lengths.mjs` sweeps those
* for review without gating them.
* description slot, whether the prop sits on the header or on a shell that
* forwards it. That is the slot the rule is about. A hint in a card BODY is
* prose and is allowed to be prose; `scripts/measure-description-lengths.mjs`
* sweeps those for review without gating them.
*
* Both EN and DE are checked. DE runs 15-25% longer than EN, and a rule that
* only holds in English produces a slot that wraps to two lines in half the
@@ -46,19 +46,57 @@ function walk(dir: string, out: string[] = []): string[] {
return out;
}
/** Every `description={t("…")}` inside a `<SettingsCardHeader …>` element. */
/**
* Elements whose `description` prop reaches `SettingsCardHeader`'s slot.
*
* The first cut of this guard matched `<SettingsCardHeader` only, and only
* under three component folders. That left five real card descriptions
* invisible to it — one of them the 351-character, three-sentence full-backup
* text that the audit's own shortlist had named. Two ways in were missing:
* a card header rendered from a folder the walk never entered, and a
* description FORWARDED to the header by a shell (`ExportCardShell`,
* `ImportCardShell`), where the prop is on the shell's tag rather than the
* header's.
*
* `SubPageShell` is deliberately NOT here. Its description renders as
* `<p className="text-foreground text-sm">` — the body tier, where the
* standards allow prose. Gating it would enforce a rule §3 does not make for
* that tier.
*/
const DESCRIPTION_HOSTS = [
"SettingsCardHeader",
"ExportCardShell",
"ImportCardShell",
];
/** Component roots that can host a card header, directly or via a shell. */
const ROOTS = [
join("src", "components", "settings"),
join("src", "components", "admin"),
join("src", "components", "measurement-reminders"),
join("src", "components", "labs"),
join("src", "components", "insights"),
join("src", "components", "medications"),
join("src", "components", "records"),
];
/** Every `description={t("…")}` on an element that owns the header's slot. */
function describedKeys(): Map<string, string[]> {
const keys = new Map<string, string[]>();
for (const dir of ["settings", "admin", "measurement-reminders"]) {
for (const file of walk(join(ROOT, "src", "components", dir))) {
for (const root of ROOTS) {
for (const file of walk(join(ROOT, root))) {
const source = readFileSync(file, "utf8");
const header = /<SettingsCardHeader\b/g;
const host = new RegExp(`<(?:${DESCRIPTION_HOSTS.join("|")})\\b`, "g");
let match: RegExpExecArray | null;
while ((match = header.exec(source))) {
while ((match = host.exec(source))) {
// The element ends at the first `/>` or `>` that closes the opening
// tag; a `description={t("x")}` after that belongs to something else.
const end = source.indexOf("/>", match.index);
const slice = source.slice(match.index, end === -1 ? undefined : end);
const selfClose = source.indexOf("/>", match.index);
const childrenOpen = source.indexOf("\n >", match.index);
const end = [selfClose, childrenOpen]
.filter((i) => i !== -1)
.sort((a, b) => a - b)[0];
const slice = source.slice(match.index, end ?? undefined);
const desc = /description=\{t\(\s*"([^"]+)"/.exec(slice);
if (!desc) continue;
const list = keys.get(desc[1]) ?? [];
@@ -96,7 +134,7 @@ describe("card header descriptions — one sentence, ≤120 characters", () => {
it("finds the description slots it is meant to check", () => {
// A matcher that quietly matches nothing passes every assertion below it.
expect(keys.size).toBeGreaterThan(25);
expect(keys.size).toBeGreaterThan(35);
});
for (const locale of ["en", "de"] as const) {
+10 -1
View File
@@ -484,7 +484,16 @@ function FullBackupCard() {
</Button>
}
>
{/* v1.28 backup-completeness — the description above lists what's
{/* What is in the file, and the shape it has. This used to be the
card's description — twelve enumerated record types in a muted
`text-xs` meta slot, which is neither the tier nor the length that
slot is for. */}
<p className="text-sm">
{t("settings.sections.export.cards.fullBackup.contents")}{" "}
{t("settings.sections.export.cards.fullBackup.detail")}
</p>
{/* v1.28 backup-completeness — the paragraph above lists what's
included; this line honestly discloses what isn't (document
binaries, workout GPS/sample series) rather than implying
"everything" is in the file. Always visible, not gated on the
@@ -113,6 +113,10 @@ export function CsvImportCard() {
title={t("settings.sections.export.import.csv.title")}
description={t("settings.sections.export.import.csv.description")}
>
<p className="text-sm">
{t("settings.sections.export.import.csv.detail")}
</p>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.csv.pasteLabel")}
@@ -216,6 +216,10 @@ export function DoseHistoryImportCard() {
title={t("settings.sections.export.import.doseHistory.title")}
description={t("settings.sections.export.import.doseHistory.description")}
>
<p className="text-sm">
{t("settings.sections.export.import.doseHistory.detail")}
</p>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.doseHistory.pasteLabel")}