From 6c41c9adfc4839658a13b9a58fd384e78902f16d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 00:15:00 +0800 Subject: [PATCH 1/8] fix(ui): restore the transcript's markdown rhythm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `density="compact"` the transcript's markdown spacing was out of order: list items sat 10px apart while paragraphs sat 4px apart, so items at the same level read as further apart than separate paragraphs. 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) rather than as prose, and no density value can change that from the outside. Own the compact prose rhythm in one table instead: 4px between list items, 8px between blocks, 16px before a section heading, 24px before a chapter heading. Gaps are margin-top only, so there is no collapsing to reason about and the gap between two blocks is the declared value. Two scoping choices: - Only `[data-density="compact"]`. Document mode is not broken (10px list items against 12px paragraphs is already in order) and the Daily Review renders through it, so it stays out of the blast radius. - Keyed on Astryx's own `data-density` rather than a `.maka-turn` ancestor. `themeProps()` reflects visual props as data attributes for exactly this, so the rhythm follows the `density` prop instead of where the markdown happens to sit in the DOM. That second point retires the position coupling in chat-message.css: the heading scale was a function of DOM ancestry, which is why no Storybook story could reproduce the transcript. The scale keeps its two size steps (the flattening argument is unchanged) and moves into the same table. Add the TranscriptTurn story that the old scoping made impossible, and correct the two existing stories' `Real path` annotations — both named chat, but both render document density, which is the Daily Review's mode. --- .../src/renderer/styles/chat-message.css | 43 +++------- packages/ui/src/styles.css | 83 ++++++++++++++++++- packages/ui/stories/markdown.stories.tsx | 60 +++++++++++++- scripts/check-dead-css.mjs | 10 +++ 4 files changed, 157 insertions(+), 39 deletions(-) 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/styles.css b/packages/ui/src/styles.css index b00f1adead..6501e7ccd2 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -112,11 +112,90 @@ @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) + 24px chapter (h1, h2) + + Two 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. + + Block gaps are margin-top only: with no bottom margins there is no collapsing + to reason about, and the gap between any two blocks is the declared value. */ +[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: var(--md-gap-block) 0; +} +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > :first-child { + margin-block-start: 0; +} +[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); +} +/* 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. */ +[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); +} + /* 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', ]); /** From cb42e2f42e26604f2245ac118394381f4501c679 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 00:15:21 +0800 Subject: [PATCH 2/8] feat(ui): step deep markdown headings down in colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit h4-h6 already render at body size in a transcript, so the size ladder has nothing left to give them — and growing them back would rebuild the visual slabs the flattening exists to prevent. Use the one axis that costs no vertical space: the deepest levels step down to `--foreground-secondary`. A turn can then carry four levels of structure inside two type sizes, where before h3 and h4 were indistinguishable. opencode makes the same call at the same point, dropping h4-h6 to a muted colour once its 17/15/13 ladder runs out. --- packages/ui/src/styles.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 6501e7ccd2..5105c4ce16 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -188,6 +188,15 @@ [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. Astryx skips its own spacing wrapper when From 96e252627c2d9a41fadbdef6d83ac86a5a17cfbc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 00:32:13 +0800 Subject: [PATCH 3/8] fix(ui): keep a leading heading flush with the bubble top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rhythm table declared block gaps as `> *` plus a `> :first-child` reset. Those look equivalent to an adjacent-sibling rule and are not: `:first-child` scores (0,4,0) against the heading rules' (0,5,0), so the reset lost. A turn that OPENS with a heading — which is most of them, since models reach for `##` on the first line — kept the 24px chapter gap above its first line and pushed itself off the top of the bubble. Measured 26px from the bubble edge to the first heading where 2px was intended. Express the gaps as what they are instead: a gap is a relation BETWEEN two blocks, so every gap rule is an adjacent-sibling rule over a blanket `margin-block: 0`. The first block then matches no gap rule at all and needs no reset, which makes the defect unrepresentable rather than merely fixed — there is no longer a rule whose job is to lose gracefully. Pin the rhythm invariants while the reasoning is fresh, following the CSS contract tests already in this directory. The ladder assertion checks the ORDER of the four gaps rather than their values, because the order is the invariant — retuning 8px to 10px is a design call and stays green, while list gaps meeting block gaps is the regression this branch exists to fix. Every assertion was mutation-tested against the defect it describes. Found by adversarial review of the branch. --- .../markdown-rhythm-contract.test.ts | 136 ++++++++++++++++++ packages/ui/src/styles.css | 25 +++- 2 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts 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..a7861d4c78 --- /dev/null +++ b/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts @@ -0,0 +1,136 @@ +/** + * 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, + />\s*\*\s*\+/, + '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.', + ); + }); + + 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', + ); + }); + + 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/packages/ui/src/styles.css b/packages/ui/src/styles.css index 5105c4ce16..290cbab29e 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -144,8 +144,19 @@ Storybook then reproduces the transcript by passing the prop, which the old ancestor-scoped rules could not. - Block gaps are margin-top only: with no bottom margins there is no collapsing - to reason about, and the gap between any two blocks is the declared value. */ + 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); @@ -153,15 +164,15 @@ --md-gap-chapter: var(--space-6); } [data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * { - margin-block: var(--md-gap-block) 0; + margin-block: 0; } -[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > :first-child { - margin-block-start: 0; +[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + * { + 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"]) { +[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"]) { +[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); } /* Prose list, not a control list: drop the ListItem row padding and express the From e6ba5e6cae16d1882d7d60727fe705147c913801 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 00:41:51 +0800 Subject: [PATCH 4/8] test(ui): pin the DOM half of the rhythm contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS contract added with the first-block fix reads the stylesheet as text, so it stays green against DOM it never sees. The whole rhythm table keys on runtime Astryx output — `data-density`, `astryx-markdown-heading`, `data-level`, `astryx-list-item` — whose names have a single upstream owner and appear in Maka source only inside selectors. An Astryx bump that renames one makes every rule stop matching, the inverted ladder returns, and nothing fails: the check-dead-css entries are an allowlist that marks those classes live, not an assertion that they exist. Pin the other side of the join by rendering MarkdownBody and asserting the hooks are really emitted. Each assertion was mutation-tested against the break it describes. Also guard the one bet the table makes. Its heading rules are typography, not spacing, and Astryx's density RFC (facebook/astryx#839) draws the line at "density shifts heights and spacing, not typography" — so keying them on `density` is honest only while `compact` and "transcript" name the same set. They do today. Rather than add a surface prop for a caller that does not exist, assert the set: a second compact caller fails the test and is told what the choice is. The comment now records this as a risk rather than only as a decision. Addresses the two P2 findings from an adversarial review of the branch. --- .../markdown-rhythm-dom-contract.test.tsx | 131 ++++++++++++++++++ packages/ui/src/styles.css | 15 +- 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx 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..3c0a6c5022 --- /dev/null +++ b/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx @@ -0,0 +1,131 @@ +/** + * 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(); + 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 290cbab29e..e4a4e8af17 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -192,7 +192,20 @@ 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. */ + 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); } From 1a3ecee487be2e14cd8b4b256220de1f0702dff2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 00:53:15 +0800 Subject: [PATCH 5/8] docs(ui): record why nested markdown blocks keep a tighter rhythm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rhythm table's gap rules use the child combinator, so they only reach the document root's own children: a paragraph inside a list item or a blockquote keeps Astryx's compact 4px rather than the table's 8px block gap. That is deliberate — a list item reads as one unit, so blocks inside it belong closer together than blocks in the transcript, and the ladder extends downward instead of inverting — but nothing said so, which left the tier difference looking like an oversight in the scoping. --- packages/ui/src/styles.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index e4a4e8af17..c2187f14c6 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -131,7 +131,7 @@ 16px section (h3-h6) 24px chapter (h1, h2) - Two deliberate choices: + 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), @@ -143,6 +143,13 @@ 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: From 46717442b5876bec2fe9ca262920ab05f5ebbcdb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 02:37:29 +0800 Subject: [PATCH 6/8] fix(ui): keep a typed `hr` above the block step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rhythm table put every top-level gap on one 8px step, which swept the `hr` in with it — an explicit separator ended up reading no wider than the paragraph boundary above it, so typing one changed nothing. That is the same failure as the flattened heading scale, in a different place: structure the author expressed, rendered as no structure at all. Give it the section step instead. A following h1/h2 still wins on specificity ((0,5,0) over (0,3,1)) and keeps its chapter gap, so an `hr` before a major heading is unaffected. The ladder stays monotonic: 4 list / 8 block / 16 section and hr / 24 chapter. Measured against a real transcript: paragraph -> hr was 12px on main, 8px before this commit, 16px now. --- packages/ui/src/styles.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index c2187f14c6..b51ec19fca 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -128,7 +128,7 @@ 4px list items (same level) 8px blocks (paragraph, list, quote, table, code) - 16px section (h3-h6) + 16px section (h3-h6, and either side of an `hr`) 24px chapter (h1, h2) Three deliberate choices: @@ -182,6 +182,17 @@ [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 + * { + 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 { From 739643f143465c079595ad9d248b93af2e6354ac Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 02:37:38 +0800 Subject: [PATCH 7/8] test(ui): join the rhythm contract's declarations to their usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four mutations survived the contract as written, each of them a way to keep the tests green while the rendered ladder stops being what the table declares: - deleting the blanket `> * { margin-block: 0 }` reset, after which Astryx's own end margins survive in the earlier layer and collapse against the gaps - hardcoding a gap value, which leaves the ordered `--md-gap-*` ladder declared but unspent — the first test then asserts nothing about the spacing anyone sees - hardcoding the list gap, detaching the tightest rung, so an inversion could be reintroduced without failing anything - deleting or demoting the `hr` step Close all four, and pin `data-maka-contract="markdown"` on the DOM side: every rule in the table is prefixed with it, and it was the one hook whose two halves nothing joined — rename it and both suites stayed green while all compact spacing died. The hr assertion checks the rung outranks --md-gap-block rather than naming one, so moving it to chapter stays a design call. Each assertion was verified to fail against the mutation it describes. --- .../markdown-rhythm-contract.test.ts | 67 ++++++++++++++++++- .../markdown-rhythm-dom-contract.test.tsx | 10 +++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts index a7861d4c78..566c57b194 100644 --- a/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts +++ b/apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts @@ -80,8 +80,8 @@ describe('transcript markdown rhythm', () => { for (const [, selector, body] of compactBlockRules) { assert.match( selector, - />\s*\*\s*\+/, - 'every block gap must be an adjacent-sibling rule (`> * + …`). A gap is a relation ' + + />[^{]*\+/, + '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 ` + @@ -96,6 +96,57 @@ describe('transcript markdown rhythm', () => { '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 () => { @@ -113,6 +164,18 @@ describe('transcript markdown rhythm', () => { '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 () => { diff --git a/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx b/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx index 3c0a6c5022..185440f155 100644 --- a/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx +++ b/packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx @@ -45,6 +45,16 @@ function compactMarkup(): string { 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"/, From b6e497dd21c3af738cabd46b626c623e9b1058e0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 7 Aug 2026 11:11:26 +0800 Subject: [PATCH 8/8] fix(ui): keep the block gap off non-flow children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. The compact adjacent-sibling gap rules matched it and gave it an 8px margin-block-start, which pushed the viewport-filling overlay 8px down instead of spacing anything — the Mermaid E2E journey measured exactly that. A gap is a relation between two blocks in the document flow, and an overlay is not one, so exclude it on the side that receives the margin. Sitting between two blocks as the `+` antecedent stays fine. --- packages/ui/src/styles.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index b51ec19fca..f6e736b836 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -173,7 +173,15 @@ [data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * { margin-block: 0; } -[data-maka-contract="markdown"] .astryx-markdown[data-density="compact"] > * + * { +/* `: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"]) { @@ -190,7 +198,7 @@ (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 + * { +[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