diff --git a/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts new file mode 100644 index 0000000000..566c57b194 --- /dev/null +++ b/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts @@ -0,0 +1,199 @@ +/** + * Transcript markdown rhythm contract. + * + * The defect this pins was not a wrong number — it was a wrong ORDER. Under + * `density="compact"` the transcript spaced list items ~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 8px to 10px + * is a design decision and should stay green here; making list gaps meet or + * exceed block gaps is the regression, and must fail. A screenshot cannot lock + * that — it fixes one rendering of one sample rather than the relation — which + * is why AGENTS.md asks for a computed-style or text contract on cascade and + * layout invariants. The visual half lives in the `TranscriptTurn` story + * (packages/ui/stories/markdown.stories.tsx). + * + * Scoped to the compact surface only. Document mode is Astryx's own rhythm and + * the Daily Review renders through it, so nothing here should constrain it. + */ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { readAllRendererCss, stripCssComments } from './css-test-helpers.js'; + +/** 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; +} + +describe('transcript markdown rhythm', () => { + it('declares the compact ladder in strictly increasing order', async () => { + const css = stripCssComments(await readAllRendererCss()); + 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.', + ); + } + }); + + it('expresses every block gap as an adjacent-sibling relation', async () => { + const css = stripCssComments(await readAllRendererCss()); + const compactBlockRules = [ + ...css.matchAll( + /(\[data-maka-contract="markdown"\]\s*\.astryx-markdown\[data-density="compact"\]\s*>[^{]*)\{([^}]*)\}/g, + ), + ].filter(([, , body]) => /margin-block-start\s*:/.test(body)); + + assert.ok(compactBlockRules.length > 0, 'no compact block-gap rules found'); + + for (const [, selector, body] of compactBlockRules) { + assert.match( + selector, + />[^{]*\+/, + 'every block gap must be an adjacent-sibling rule (`> … + …`). A gap is a relation ' + + 'between two blocks, so the first block should match no gap rule at all. The ' + + '`> *` + `> :first-child` reset form looks equivalent but loses on specificity: ' + + `:first-child scores (0,4,0) against the heading rules' (0,5,0), so a turn opening ` + + 'with a heading keeps a chapter gap above its first line and pushes off the top of ' + + `the bubble. Offending rule: \`${selector.trim()}\` { ${body.trim()} }`, + ); + } + + assert.doesNotMatch( + css, + /\.astryx-markdown\[data-density="compact"\]\s*>\s*:first-child/, + 'the `:first-child` gap reset is back. It cannot beat the heading rules on ' + + 'specificity — use adjacent-sibling gap rules so the first block is never matched.', + ); + + // The other half of "the distance between two blocks is the value declared + // here": without it Astryx's own per-element end margins survive in the + // earlier layer and collapse against these gaps. An `hr` carries 12px in + // compact, so dropping the reset silently widens the block step after one. + assert.match( + css, + /\.astryx-markdown\[data-density="compact"\]\s*>\s*\*\s*\{[^}]*margin-block\s*:\s*0/, + 'the blanket `> * { margin-block: 0 }` reset is gone. The gap rules only set ' + + 'margin-block-START, so without it Astryx\'s end margins survive and collapse ' + + 'against them — the declared ladder stops being the spacing you get.', + ); + + // Declaration and usage are separate failures: the ladder can stay ordered + // while the rules that spend it are hardcoded, which turns the table into + // decoration and the first test into a tautology. + for (const [, selector, body] of compactBlockRules) { + assert.match( + body, + /margin-block-start\s*:\s*var\(\s*--md-gap-[\w-]+\s*\)/, + 'a compact gap rule sets a literal instead of a `--md-gap-*` variable. The ladder ' + + 'test above only checks that the variables are declared in order; a hardcoded ' + + `value leaves it green while the rendered spacing ignores it. Rule: \`${selector.trim()}\` ` + + `{ ${body.trim()} }`, + ); + } + }); + + it('keeps a typed `hr` wider than the block step', async () => { + const css = stripCssComments(await readAllRendererCss()); + const rule = new RegExp( + String.raw`\.astryx-markdown\[data-density="compact"\]\s*>\s*\*\s*\+\s*hr\s*,[^{]*\{([^}]*)\}`, + ).exec(css); + assert.ok( + rule, + 'the compact surface no longer gives `hr` its own step. An `hr` is the only break ' + + 'the author typed by hand; left on the generic block gap it reads no wider than ' + + 'the paragraph boundary above it, so writing one changes nothing.', + ); + + const used = /margin-block-start\s*:\s*var\(\s*(--md-gap-[\w-]+)\s*\)/.exec(rule[1])?.[1]; + assert.ok(used, `the \`hr\` rule must spend a --md-gap-* variable. Found: { ${rule[1].trim()} }`); + + const block = LADDER.indexOf('--md-gap-block'); + const rung = LADDER.indexOf(used as (typeof LADDER)[number]); + assert.ok( + rung > block, + `\`hr\` spends ${used}, which is not above --md-gap-block on the ladder. Which rung ` + + 'it takes is a design call — section today, chapter would be fine — but it has to ' + + 'outrank the ordinary block step or the separator carries no meaning.', + ); + }); + + it('spends the list-item row padding it re-spaces as prose', async () => { + const css = stripCssComments(await readAllRendererCss()); + // Astryx's ListItem carries the control-row padding that inverted the + // ladder. Zeroing it is what makes --md-gap-list the whole distance between + // two items; leave it in and the gap variable understates the real spacing. + const rule = new RegExp( + String.raw`\.astryx-markdown\[data-density="compact"\][^{]*\.astryx-list-item\s*\{([^}]*)\}`, + ).exec(css); + assert.ok(rule, 'the compact surface no longer neutralizes `.astryx-list-item` padding'); + assert.match( + rule[1], + /padding-block\s*:\s*0/, + 'ListItem block padding must be zeroed on the compact surface, otherwise the real ' + + 'list-item gap is padding + gap and the ladder above is not the spacing you get', + ); + + const listGap = new RegExp( + String.raw`\.astryx-markdown\[data-density="compact"\][^{]*\.astryx-list\s*\{([^}]*)\}`, + ).exec(css); + assert.ok(listGap, 'the compact surface no longer sets a `.astryx-list` row gap'); + assert.match( + listGap[1], + /gap\s*:\s*var\(\s*--md-gap-list\s*\)/, + 'the list gap must spend `--md-gap-list`. A literal here detaches the tightest rung ' + + 'from the ladder the first test orders, so an inversion could be reintroduced ' + + 'without failing anything', + ); + }); + + it('keeps two heading size steps on the compact surface', async () => { + const css = stripCssComments(await readAllRendererCss()); + const fonts = [ + ...css.matchAll( + /\.astryx-markdown\[data-density="compact"\][^{]*\.astryx-markdown-heading[^{]*\{([^}]*)\}/g, + ), + ] + .map((m) => /font\s*:\s*var\(\s*(--maka-text-heading-\d)\s*\)/.exec(m[1])?.[1]) + .filter((tier): tier is string => tier !== undefined); + + assert.ok( + new Set(fonts).size >= 2, + 'the transcript heading scale collapsed to one size tier. Flattening Astryx\'s ' + + 'document ladder is deliberate, but flattening it to ZERO steps is the bug this ' + + 'replaced: h2 and h3 then differ in nothing — not size, weight, or colour. ' + + `Found tiers: ${JSON.stringify(fonts)}`, + ); + }); +}); diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 5cf59b4858..3d51761713 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -212,39 +212,16 @@ independent instead of keyed on a `[data-role="assistant"]` descendant. */ /* --- Conversation heading scale ------------------------------------------- */ -/* Astryx's heading ladder (20/18/16/14…) is a DOCUMENT scale, and the - transcript is not a document. An agent turn emits `##` freely, so a ladder - that steps 1.4× per level shreds one reply into visually unrelated slabs. - Flatten it to two steps — h1 for a genuine section break, everything below - at body size — and let weight 600 plus colour carry the hierarchy, which is - what the rest of the conversation already does. - Cursor (1.43/1.32/1.21), Claude Code (1.14/1.07/1.0) and Codex - (1.71/1.43/1.21) all flatten here relative to their own document styles; - Claude Code goes furthest and merges h4–h6 into `strong`. Two steps sits - inside that range. - Every level shares the transcript baseline so headings do not add rhythm of - their own. - Scoped to `.maka-turn`, not to the Markdown contract alone. The contract is - emitted by the shared MarkdownBody, whose other caller is the Daily Review - panel — and a review report IS a document, so the argument above does not - apply to it. It keeps Astryx's full ladder. - Bare tags are the whole story: Astryx's Markdown renderer emits plain - h1–h6 with stylex props (Markdown.js `const Tag = \`h${level}\``). The - .astryx-heading.level-N classes come from the standalone Heading - component, which Markdown never mounts. */ -/* The one heading in a turn that is not at the body size, and so the one that - cannot take the body baseline with it: at 16px, --maka-line-body's 20px is - on the 4px grid but is the 14px tier's line box, one step tighter than the - tier this heading actually sits at. It takes its own tier's leading (24px); - the turn still reads as one rhythm because 24 is the next step of the same - grid, not a different one. */ -.maka-turn [data-maka-contract="markdown"] h1 { - font: var(--maka-text-heading-3); -} - -.maka-turn [data-maka-contract="markdown"] :is(h2, h3, h4, h5, h6) { - font: var(--maka-text-heading-4); -} +/* Moved to the transcript rhythm table in packages/ui/src/styles.css. The + argument for flattening the document ladder is unchanged; what changed is + where it is expressed. These rules keyed on a `.maka-turn` ancestor, which + made the heading scale a function of DOM position: the same MarkdownBody + rendered document-sized headings in the Daily Review and flat ones in a + turn, and no Storybook story could reproduce the transcript without + remembering to wrap one. The rhythm table keys on Astryx's own + `data-density` instead, so the scale follows the `density` prop that already + distinguishes the two surfaces. It also keeps two size steps rather than + one — h2 and h3 used to be identical in size, weight and colour. */ .maka-chat-message pre:not([data-maka-contract="markdown"] pre) { margin: 0; diff --git a/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx b/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx new file mode 100644 index 0000000000..185440f155 --- /dev/null +++ b/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx @@ -0,0 +1,141 @@ +/** + * The DOM half of the transcript rhythm contract. + * + * The rhythm table in styles.css keys entirely on DOM that Astryx generates at + * runtime — `data-density` on the document root, `astryx-markdown-heading` plus + * `data-level` on headings, `astryx-list`/`astryx-list-item` inside a list. + * None of those names appear in Maka source as literals; they come from + * Astryx's `themeProps()`, whose prefix has a single upstream owner + * (`naming.ts`). So an Astryx bump that renames one of them makes every + * selector in the table silently stop matching, and the spacing regresses to + * the inverted ladder with no failing check anywhere: the CSS contract test + * (apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts) reads the + * stylesheet as text and stays green against DOM it never sees, and the + * `check-dead-css` entries are an allowlist that marks these classes live + * rather than asserting they exist. + * + * This pins the other half: the hooks the stylesheet selects on are really + * emitted. Together the two tests fail on either side of the join. + */ +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'); + +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 — DOM hooks', () => { + it('emits the density attribute the rhythm table scopes 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 the CSS contract still passes on text it never renders. + 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 — and the stylesheet-side contract cannot see it.', + ); + 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', + ); + }); + + it('emits the heading hooks the two-step scale selects on', () => { + const markup = compactMarkup(); + 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 this branch replaced.', + ); + }); + + /** + * 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 own 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" are the same set. They are + * today — the transcript is the only caller that asks 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 test 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())}`, + ); + }); + + it('emits the list hooks whose control padding the table neutralizes', () => { + const markup = compactMarkup(); + assert.match( + markup, + /class="[^"]*\bastryx-list\b/, + 'the markdown list no longer renders through Astryx `List`. If it stopped being a ' + + 'control list that is good news, but the padding-zeroing rule is now dead and the ' + + 'list rhythm needs re-measuring rather than silently inheriting whatever replaced it.', + ); + 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', + ); + }); +}); diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index b00f1adead..f6e736b836 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -112,11 +112,149 @@ @keyframes maka-spin { to { transform: rotate(360deg); } } .maka-spin { animation: maka-spin 1s linear infinite; } +/* --------------------------------------------------------------------------- + Transcript markdown rhythm — the single authority for compact prose spacing. + + Astryx's `density` only reaches the blocks Markdown renders itself. Lists are + delegated to the List control, and Markdown hands it a hardcoded + `density="compact"` in BOTH modes — so a markdown list is spaced as a + clickable row (4px block padding, the RFC #839 control value), not as prose. + The result under `density="compact"` was a broken order: list items sat 10px + apart while paragraphs sat 4px apart, so same-level items read as further + apart than different paragraphs. + + This table restores one rule — visual distance rises with semantic distance — + by owning every compact prose gap in one place: + + 4px list items (same level) + 8px blocks (paragraph, list, quote, table, code) + 16px section (h3-h6, and either side of an `hr`) + 24px chapter (h1, h2) + + Three deliberate choices: + + - Only `[data-density="compact"]`. The document mode is not broken (its + lists sit 10px apart against 12px paragraphs, which is already in order), + and the Daily Review renders through it. Fixing only the broken half keeps + that surface out of the blast radius. + - Scoped to Astryx's own `data-density`, not to a `.maka-turn` ancestor. + `themeProps()` reflects every visual prop as a data attribute for exactly + this ("consumers target stable data-attribute selectors"), so the rhythm + follows the `density` prop instead of where the markdown happens to sit. + Storybook then reproduces the transcript by passing the prop, which the + old ancestor-scoped rules could not. + - Top level only. `>` keeps every gap on the document's own children, so + blocks nested inside a list item or a blockquote keep Astryx's own compact + 4px instead of the 8px block gap. That tier difference is the point, not + an oversight: a list item should read as one unit, so two paragraphs + inside it belong closer together than two paragraphs in the transcript. + The ladder extends downward (4px nested < 8px block) rather than + inverting, which is the invariant that matters. + + A gap is a relation BETWEEN two blocks, so every gap rule is an adjacent + sibling rule. Two consequences, both load-bearing: + + - The first block matches no gap rule at all, so it needs no reset. The + reset form (`> *` for the gap, `> :first-child` to take it back) looks + equivalent and is not: `:first-child` scores (0,4,0) against the heading + rules' (0,5,0), so a turn that OPENS with a heading — which is most of + them — kept the 24px chapter gap above its first line and pushed itself + off the top of the bubble. Expressing the relation directly is what makes + that unrepresentable rather than merely fixed. + - Gaps are margin-top only, on top of a blanket `margin-block: 0` that + clears Astryx's own margins. Nothing collapses, so the distance between + two blocks is the value declared here. */ +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] { + --md-gap-list: var(--space-1); + --md-gap-block: var(--space-2); + --md-gap-section: var(--space-4); + --md-gap-chapter: var(--space-6); +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * { + margin-block: 0; +} +/* `:not(dialog)` because a gap is a relation between two blocks in the document + flow, and a dialog is not one. Astryx's `Dialog` does not portal, so the + Mermaid fullscreen overlay renders as a direct child of the markdown document + while being `position: fixed; inset: 0` — an adjacent-sibling margin there + pushes the whole viewport-filling overlay off the top of the screen rather + than spacing anything (caught by the Mermaid E2E journey, 8px off). The + exclusion belongs on the side that RECEIVES the margin; an overlay sitting + between two blocks is fine as the `+` antecedent. */ +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + *:not(dialog) { + margin-block-start: var(--md-gap-block); +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + .astryx-markdown-heading:is([data-level="1"], [data-level="2"]) { + margin-block-start: var(--md-gap-chapter); +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + .astryx-markdown-heading:not([data-level="1"], [data-level="2"]) { + margin-block-start: var(--md-gap-section); +} +/* An `hr` is the only break the author typed by hand. Left on the generic block + step it reads no wider than the paragraph boundary above it, so writing one + changes nothing — which is the same failure as the flattened heading scale, + in a different place. It sits at the section step because that is what it + separates. A following h1/h2 still wins on specificity ((0,5,0) beats + (0,3,1)) and keeps the chapter gap, so `hr` before a major heading is + unaffected. */ +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + hr, +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > hr + *:not(dialog) { + margin-block-start: var(--md-gap-section); +} +/* Prose list, not a control list: drop the ListItem row padding and express the + row rhythm as a gap, so it stays below the block gap above. */ +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] .astryx-list-item { + padding-block: 0; +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] .astryx-list { + gap: var(--md-gap-list); +} +/* Heading scale: size says "this is a heading", weight and colour say which + level. Astryx's document ladder (20/18/16/14) is a page scale and an agent + turn emits `##` every few lines, so the transcript keeps two size steps — + but it keeps TWO, not one. The previous rule flattened h2-h6 onto a single + size, which left h2 and h3 identical in size, weight AND colour: three + levels of structure rendered as three identical bold lines. + Cursor (1.43/1.32/1.21), Claude Code (1.14/1.07/1.0) and Codex + (1.71/1.43/1.21) all flatten here relative to their own document styles, and + opencode goes further — 17/15/13 with h4+ dropped to a muted colour. Nobody + flattens to zero. Two steps plus colour sits inside that range. + + RISK, not just a choice: these are the only rules here that are typography + rather than spacing, and Astryx's density RFC (facebook/astryx#839) draws + the line at "density shifts heights and spacing, not typography". Keying + them on `data-density` is honest only while `compact` and "transcript" name + the same set — true today, since the transcript is the only caller asking + for compact and the Daily Review renders a document at the default. A + 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. */ +[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); +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] .astryx-markdown-heading:not([data-level="1"], [data-level="2"]) { + font: var(--maka-text-heading-4); +} +/* Below h3 the size ladder is spent — h4-h6 already sit at body size, and + growing them back would re-create the slabs the flattening exists to avoid. + Colour is the one axis left that costs no vertical space, so the deepest + levels step down in ink instead. opencode makes the same call at the same + point (13px h4-h6 dropped to muted); it is why a turn can carry four levels + of structure inside two type sizes. */ +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] .astryx-markdown-heading:not([data-level="1"], [data-level="2"], [data-level="3"]) { + color: var(--foreground-secondary); +} + /* Markdown code renderers are custom components so Mermaid can be loaded only - for settled Mermaid fences. Preserve Astryx's document/compact rhythm here. */ + for settled Mermaid fences. Astryx skips its own spacing wrapper when + `components.code` is set, so the custom block sits directly in the document + flow — the compact rhythm above already carries it, and only the document + mode needs its margins declared here. */ .maka-markdown-code { min-width: 0; } .maka-markdown-code-default { margin-block: var(--space-3) var(--space-4); } -.maka-markdown-code-compact { margin-block: var(--space-1) var(--space-2); } [role="document"] > .maka-markdown-code:first-child { margin-block-start: 0; } [role="document"] > .maka-markdown-code:last-child { margin-block-end: 0; } .maka-mermaid-diagram { diff --git a/packages/ui/stories/markdown.stories.tsx b/packages/ui/stories/markdown.stories.tsx index 94cf5469e2..b192429f29 100644 --- a/packages/ui/stories/markdown.stories.tsx +++ b/packages/ui/stories/markdown.stories.tsx @@ -32,8 +32,60 @@ function ProseFrame(props: { children: React.ReactNode; width?: number }) { ); } -// Real path: chat → an assistant answer that mixes headings, emphasis, a list, a quote -// and a table — the ordinary shape of a long reply. +// The two stories above render markdown at document density, which is the Daily +// Review's mode — not the transcript's. The transcript passes +// `density="compact"` (chat-turn.tsx), a different block rhythm AND a different +// heading scale, so neither story showed what a chat turn actually looks like. +// This one does. +// +// The frame is written out rather than imported: the real host is `ChatTurn`, +// which needs a full TurnViewModel and a runtime to build one. What is +// reproduced here is the part that reaches markdown — the `.maka-turn` wrapper +// and the assistant bubble. Nothing in the markdown rhythm keys on `.maka-turn` +// any more (it keys on Astryx's `data-density`), so the wrapper is here for +// frame fidelity, not to make the styles apply. +// +// Real path: chat → any assistant turn whose reply uses nested headings and a +// list — i.e. the ordinary long answer, rendered as the transcript renders it. +export const TranscriptTurn: Story = { + render: () => ( + +
+ + 注意:新增状态只需补一个行图标。', + '', + '如果后续要加新状态,只需补充对应的行图标,不再扩张侧栏信息架构。', + ].join('\n')} + /> + +
+
+ ), +}; + +// Real path: 每日回顾 → a generated report that mixes headings, emphasis, a list, +// a quote and a table. This is the document density — the Daily Review panel is +// the caller that leaves `density` unset. (It was annotated as a chat answer, +// but chat passes `density="compact"`; see TranscriptTurn above.) export const RichAssistantAnswer: Story = { render: () => ( @@ -67,8 +119,8 @@ export const RichAssistantAnswer: Story = { ), }; -// Real path: chat → a long assistant answer, pinned at the 680px prose measure to check -// vertical rhythm across many blocks. +// Real path: 每日回顾 → a long generated report, pinned at the 680px prose measure +// to check vertical rhythm across many blocks at document density. export const LongFormArticle: Story = { render: () => ( diff --git a/scripts/check-dead-css.mjs b/scripts/check-dead-css.mjs index 5b85016db9..1fbac548c3 100755 --- a/scripts/check-dead-css.mjs +++ b/scripts/check-dead-css.mjs @@ -156,6 +156,16 @@ const DYNAMIC_STYLE_HOOKS = new Set([ // literals. Keep in sync with the density prop's values. 'maka-markdown-code-default', 'maka-markdown-code-compact', + // Astryx's Markdown renders its document root and every block through + // themeProps, so these classes exist only at runtime. The transcript rhythm + // table in packages/ui/src/styles.css targets them (with `data-density`) to + // own compact prose spacing, which Astryx's density cannot reach on its own. + 'astryx-markdown', + 'astryx-markdown-heading', + // Markdown delegates lists to the List control; the rhythm table re-spaces + // its rows as prose. + 'astryx-list', + 'astryx-list-item', ]); /**