From 8f36511a5febab586e941f6f72c3fd9c39a03bcf Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 23:34:28 +0800 Subject: [PATCH] test(ui): re-pin the transcript rhythm contract in one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit styles.css told readers the "compact == transcript" assumption was held by `__tests__/markdown-rhythm-dom-contract.test.tsx`. That file was deleted in #2462 and its stylesheet-side sibling in #2425, so the comment has been claiming a guard that does not exist — the worst state to leave an assumption in, since the next person reads the citation and stops looking. Restore the two assertions the comment is actually about, plus the ladder order #2348 exists to protect, in ONE file. Split across two they each stayed green against the half they could not see, which is how the stylesheet half got deleted without anything noticing: - the ladder is declared on the --space-* scale in strictly increasing order (retuning a rung is a design call and stays green; list gaps meeting block gaps fails) - every rung declared is actually spent by a rule, so the ladder cannot become decoration while literals do the spacing - the runtime hooks the selectors need are really emitted by MarkdownBody — data-maka-contract, data-density, astryx-markdown-heading, data-level, astryx-list-item. These names have a single upstream owner in Astryx and appear in Maka only inside selectors, so a rename kills every rule at once with nothing failing - compact markdown still has exactly one caller, which is the bet the comment names: the table carries heading TYPOGRAPHY on a density key, and Astryx's own RFC says density must not do that Why these are not what #2425/#2462 retired. Those PRs removed markup/copy pins, duplicate presentation assertions and CSS-structure suites that asserted how a rule was written. Nothing here asserts an appearance or a rule's shape: three of the four are relations that must survive any retune, and the fourth renders the real component and checks the join between Astryx's output and Maka's selectors. Deliberately left unpinned, because they ARE how the table is written: the adjacent-sibling gap form, the `hr` rung, the ListItem padding reset, and the two heading size tiers. The stylesheet-reading half no longer revives the deleted desktop css-test-helpers module or the renderer-wide CSS aggregate it read; it reads packages/ui/src/styles.css directly, in the package that owns it. Seven mutations, each verified to fail the assertion that describes it: inverting the ladder; taking a rung off the --space-* grid; hardcoding a rung's usage; hardcoding it while naming the rung in a comment (this is why comments are stripped first); renaming the contract wrapper; cutting density off before it reaches Astryx; and adding a second compact caller. Also state in the comment what the contract does and does not hold, so the next reader does not have to open the test to find out. Generated-by: Claude Code --- .../markdown-rhythm-contract.test.tsx | 207 ++++++++++++++++++ packages/ui/src/styles.css | 14 +- 2 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx diff --git a/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx b/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx new file mode 100644 index 0000000000..d288562b07 --- /dev/null +++ b/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx @@ -0,0 +1,207 @@ +/** + * Transcript markdown rhythm contract. + * + * The defect #2348 fixed was not a wrong number — it was a wrong ORDER. Under + * `density="compact"` list items sat ~10px apart (Astryx's List control row + * padding, which `density` cannot reach from outside) while paragraphs sat 4px + * apart, so items at the same level read as further apart than separate + * paragraphs. Visual distance stopped tracking semantic distance. + * + * So the invariant is the ladder's ORDER, not its values: retuning a rung is a + * design decision and stays green here, while list gaps meeting block gaps must + * fail. And the table keys entirely on DOM Astryx generates at runtime — + * `data-density`, `astryx-markdown-heading` + `data-level`, `astryx-list-item`. + * None of those names exist in Maka source outside these selectors, so an + * Astryx bump that renames one makes every rule stop matching with nothing + * failing anywhere. + * + * Both halves live here on purpose. Split across two files they each stayed + * green against the half they could not see, and the stylesheet half was the + * one that got deleted (#2425), leaving styles.css citing a guard that no + * longer existed. + * + * Deliberately NOT pinned, because they are how the table works rather than + * what it promises: the adjacent-sibling gap form, the `hr` rung, the + * ListItem padding reset, and the two heading size tiers. + */ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MarkdownBody } from '../markdown-body.js'; + +const UI_SRC = resolve(import.meta.dirname, '..', '..', 'src'); + +/** The compact ladder, ordered from tightest (same level) to widest (chapter). */ +const LADDER = ['--md-gap-list', '--md-gap-block', '--md-gap-section', '--md-gap-chapter'] as const; + +/** `var(--space-N)` → N, the 4px-grid step count. Non-scale values return null. */ +function spaceSteps(value: string): number | null { + const match = /^var\(\s*--space-(\d+)\s*\)$/.exec(value.trim()); + return match ? Number(match[1]) : null; +} + +/** + * Comments carry the rung names in prose — the table's own header lists the + * ladder and tells the next reader to "retune `--md-gap-block`" — so the usage + * check below would pass on documentation alone without this. + */ +function stripCssComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +async function readUiStylesheet(): Promise { + return stripCssComments(await readFile(join(UI_SRC, 'styles.css'), 'utf8')); +} + +async function tsxFiles(dir: string): Promise { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await tsxFiles(path))); + else if (entry.name.endsWith('.tsx') && !entry.name.includes('.test.')) out.push(path); + } + return out; +} + +const SAMPLE = ['## Heading two', '', 'A paragraph.', '', '1. First item', '2. Second item'].join('\n'); + +function compactMarkup(): string { + return renderToStaticMarkup(); +} + +describe('transcript markdown rhythm', () => { + it('declares the compact ladder in strictly increasing order', async () => { + const css = await readUiStylesheet(); + const block = /\.astryx-markdown\[data-density="compact"\]\s*\{([^}]*)\}/.exec(css); + assert.ok(block, 'no `.astryx-markdown[data-density="compact"]` custom-property block found'); + + const declared = new Map(); + for (const m of block[1].matchAll(/(--md-gap-[\w-]+)\s*:\s*([^;]+);/g)) { + declared.set(m[1], m[2].trim()); + } + + const steps = LADDER.map((name) => { + const value = declared.get(name); + assert.ok(value, `${name} is not declared on the compact surface`); + const n = spaceSteps(value); + assert.ok( + n !== null, + `${name} is \`${value}\`; the ladder must stay on the --space-* scale so the ` + + 'order is comparable and a bare px value cannot drift off the 4px grid', + ); + return { name, steps: n }; + }); + + for (let i = 1; i < steps.length; i += 1) { + const prev = steps[i - 1]; + const cur = steps[i]; + assert.ok( + cur.steps > prev.steps, + `${cur.name} (--space-${cur.steps}) must be strictly wider than ${prev.name} ` + + `(--space-${prev.steps}). Visual distance has to rise with semantic distance: ` + + 'same-level list items closer than blocks, blocks closer than section breaks.', + ); + } + }); + + /** + * Declaration and usage are separate failures. Hardcode a gap and the ladder + * above stays declared but unspent — the first test then asserts nothing + * about the spacing anyone sees, and an inversion can be reintroduced + * without failing anything. + */ + it('spends every rung it declares', async () => { + const css = await readUiStylesheet(); + for (const name of LADDER) { + assert.match( + css, + new RegExp(String.raw`var\(\s*${name}\s*\)`), + `${name} is declared but never read. A rule spending a literal instead leaves the ` + + 'ladder test green while the rendered spacing ignores it, which turns the whole ' + + 'table into decoration.', + ); + } + }); + + it('emits the DOM hooks the rhythm table selects on', () => { + const markup = compactMarkup(); + // Every rule in the table is prefixed with this, so it is the one hook + // whose two halves — selector prefix and runtime attribute — nothing else + // joins. Rename it and all compact spacing dies silently. + assert.match( + markup, + /data-maka-contract="markdown"/, + 'the `data-maka-contract="markdown"` wrapper is gone. Every rule in the rhythm table ' + + 'is scoped on it, so all compact prose spacing and the heading scale are now dead.', + ); + assert.match( + markup, + /]*role="document"[^>]*data-density="compact"|]*data-density="compact"[^>]*role="document"/, + 'the document root no longer carries `data-density="compact"`. Every rule in the ' + + 'rhythm table is scoped on it, so all compact prose spacing is now dead.', + ); + assert.match( + markup, + /class="[^"]*\bastryx-markdown\b/, + 'the document root no longer carries the `astryx-markdown` class the table selects on', + ); + assert.match( + markup, + /]*class="[^"]*\bastryx-markdown-heading\b/, + 'headings no longer carry `astryx-markdown-heading`; the transcript heading scale is dead', + ); + assert.match( + markup, + /]*data-level="2"/, + 'headings no longer carry `data-level`. The table splits h1/h2 from h3-h6 on it, so ' + + 'without it every heading collapses to one tier — the exact defect #2348 replaced.', + ); + assert.match( + markup, + /class="[^"]*\bastryx-list-item\b/, + 'list rows no longer carry `astryx-list-item`; the rule that spends their control-row ' + + 'padding no longer applies and list items revert to sitting wider apart than paragraphs', + ); + }); + + /** + * The rhythm table carries the transcript's heading TYPOGRAPHY, not just its + * spacing, and keys both on `density`. That is a deliberate bet, and it is a + * bet: Astryx's density RFC (facebook/astryx#839) draws the line at "density + * shifts heights and spacing, not typography", so the key is honest only + * while `compact` and "transcript" name the same set. They do today — the + * transcript is the only caller asking for compact, and the Daily Review + * renders a document at the default. + * + * A separate surface attribute would decouple them, but nothing needs it yet + * and it would add a prop to a public component for a caller that does not + * exist. Guard the assumption instead: the moment a second surface asks for + * compact markdown it silently inherits transcript heading sizes and dimmed + * deep headings, and this is what tells whoever adds it that they have to + * choose — inherit deliberately, or split the key then. + */ + it('keeps compact markdown a transcript-only surface', async () => { + const callers: string[] = []; + for (const file of await tsxFiles(UI_SRC)) { + const source = await readFile(file, 'utf8'); + // `` opening tags only — not MarkdownBody's internal plumbing + // and not the density Maka hands its own code-block renderers. + for (const tag of source.matchAll(/]*?\/?>/gs)) { + if (/density=(["'])compact\1/.test(tag[0])) callers.push(relative(UI_SRC, file)); + } + } + + assert.deepEqual( + [...new Set(callers)].sort(), + ['chat-turn.tsx'], + 'a new caller renders markdown at compact density. The rhythm table treats ' + + '`density="compact"` as "this is a transcript" and gives it flattened heading sizes ' + + 'plus secondary-colour h4-h6 — typography, which Astryx\'s density is explicitly not ' + + 'supposed to carry (facebook/astryx#839). Either that is what the new surface wants, ' + + 'and this list grows, or the typography rules need their own key. ' + + `Found: ${JSON.stringify([...new Set(callers)].sort())}`, + ); + }); +}); diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 63bb5dcca7..74acfd7b92 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -197,6 +197,14 @@ 16px section (h3-h6, and either side of an `hr`) 24px chapter (h1, h2) + What holds this: __tests__/markdown-rhythm-contract.test.tsx pins the + ladder's ORDER (retuning a rung is a design call and stays green), that + every rung declared is actually spent by a rule, and that the runtime hooks + these selectors need are really emitted. The adjacent-sibling gap form, the + `hr` rung, the ListItem padding reset and the two heading size tiers are + reasoned about below but NOT pinned — they are how the table works rather + than what it promises, and each is one rule away from being re-read here. + Three deliberate choices: - Only `[data-density="compact"]`. The document mode is not broken (its @@ -295,9 +303,9 @@ second compact surface would silently inherit transcript heading sizes and dimmed deep headings. Giving typography its own surface attribute would decouple them, but nothing needs it yet; the assumption is held by a test - instead (packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx, - "keeps compact markdown a transcript-only surface"), which fails the moment - the sets diverge and says what the choice is. */ + instead (__tests__/markdown-rhythm-contract.test.tsx, "keeps compact + markdown a transcript-only surface"), which fails the moment the sets + diverge and says what the choice is. */ [data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] .astryx-markdown-heading:is([data-level="1"], [data-level="2"]) { font: var(--maka-text-heading-3); }