Skip to content

fix(ui): restore the transcript's markdown rhythm - #2348

Merged
Astro-Han merged 8 commits into
apache:mainfrom
Astro-Han:fix/markdown-transcript-rhythm
Aug 7, 2026
Merged

fix(ui): restore the transcript's markdown rhythm#2348
Astro-Han merged 8 commits into
apache:mainfrom
Astro-Han:fix/markdown-transcript-rhythm

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

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. Visual distance stopped tracking semantic distance, which is what made a turn read as an undifferentiated block.

The cause is not a wrong number. 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 (Markdown.js:883,908), so a markdown list is spaced as a clickable row (4px block padding + 2px list gap = 10px) rather than as prose. No density value can change that from the outside, so the prose rhythm could not close on Astryx's side.

This owns the compact prose rhythm in one table:

gap value
list items (same level) 4px
blocks (paragraph, list, quote, table, code) 8px
before a section heading (h3–h6) 16px
before a chapter heading (h1, h2) 24px

Every gap is an adjacent-sibling rule over a blanket margin-block: 0. A gap is a relation between two blocks, so the first block matches no gap rule and needs no reset. That is not cosmetic: the reset form (> * plus > :first-child) loses on specificity — :first-child scores (0,4,0) against the heading rules' (0,5,0) — so a turn that opens with a heading kept the 24px chapter gap above its first line and pushed 26px off the top of the bubble where 2px was intended. Most turns open with a heading.

Two scoping choices worth reviewing:

  • 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, not a .maka-turn ancestor. themeProps() reflects visual props as data attributes for exactly this ("consumers target stable data-attribute selectors"), so the rhythm follows the density prop instead of where the markdown sits in the DOM. That retires the position coupling in chat-message.css, which is why no Storybook story could reproduce the transcript before.

The heading scale keeps its two size steps — the argument for flattening Astryx's document ladder is unchanged — and moves into the same table. A separate commit adds what the old rule had no room for: h4–h6 step down in colour, because below h3 the size ladder is spent and growing it back would rebuild the slabs the flattening exists to prevent. Previously h2 and h3 were identical in size, weight and colour, so three levels of structure rendered as three identical bold lines.

Reviewed against opencode (17/15/13 + muted h4–h6), kimi-code (+3/+2/+1 + muted h4) and Astryx's own document mode. All three keep list items tighter than paragraphs; this branch was the only one with the order inverted.

Verification

Computed styles from the live Storybook, Product/Markdown — the layout invariant is the contract here, so the numbers are the evidence.

TranscriptTurn (new story, density="compact")

before after
bubble top → first heading 26px 2px
list item padding 4px 0
list item ↔ list item 10px 4px
paragraph ↔ paragraph 4px 8px
paragraph → list 4px 8px
before h3 / h4 16 / 12px 16px
h2 / h3 / h4 size 14 / 14 / 14px 16 / 14 / 14px
h4 colour --foreground --foreground-secondary

Order restored: 4 < 8 < 16 < 24, each step ~2×.

RichAssistantAnswer (document mode) — zero regression, re-measured after the change: h2 18px, list item padding 4px, list gap 2px, blocks 12/16px — identical to main.

Contracts. Two tests, both halves of the same join, every assertion mutation-tested against the defect it describes:

  • apps/desktop/src/main/__tests__/markdown-rhythm-contract.test.ts — the CSS side. Asserts the ladder's order rather than its values (retuning 8px to 10px is a design call and stays green; list gaps meeting block gaps is the regression), that every gap rule is adjacent-sibling, that ListItem padding is spent, and that two heading size steps survive.
  • packages/ui/src/__tests__/markdown-rhythm-dom-contract.test.tsx — the DOM side. The table keys entirely on runtime Astryx output whose names have a single upstream owner, so a rename would make every rule stop matching with nothing failing. This renders MarkdownBody and asserts the hooks are really emitted.

Ran: both contract suites, check-dead-css --check, check-story-annotations, node --test scripts/check-dead-css.test.mjs scripts/check-story-annotations.test.mjs (21/21), @maka/ui typecheck, typecheck:stories, lint, format:check.

Not run: full npm test and E2E — this changes CSS, stories, tests and one allowlist, with no runtime behaviour touched.

Review focus

A known bet, held by a test. The table's 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". 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. A surface prop would decouple them, but nothing needs it yet, so the assumption is asserted instead: a second compact caller fails keeps compact markdown a transcript-only surface and is told what the choice is. Worth a second opinion on whether that trade is right.

TranscriptTurn is the story that could not exist before. The two existing stories' Real path annotations were both wrong — they named chat, but render document density, which is the Daily Review's mode — and are corrected here.

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.
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.
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.
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.
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.
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.
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.
@Astro-Han
Astro-Han force-pushed the fix/markdown-transcript-rhythm branch from 4d58394 to 739643f Compare August 6, 2026 18:38
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.
@Astro-Han
Astro-Han marked this pull request as ready for review August 7, 2026 03:35
@Astro-Han
Astro-Han merged commit 182ced6 into apache:main Aug 7, 2026
12 checks passed
Astro-Han added a commit that referenced this pull request Aug 21, 2026
Two paragraphs in a chat turn sat 8px apart against 20px body leading, so
a paragraph break was 0.4 of a line — narrower than the line break inside
a paragraph, which is the one vertical distance a reader already knows.
A long answer read as one slab.

Take the block rung to 12px, which is Astryx's own paragraph rhythm at
document density (spacingParagraphDefault, --spacing-3). The transcript
keeps its own heading scale and its own 4px list rung and only stops
being denser than the design system between two paragraphs.

Where the fix does NOT belong, because the obvious reading is wrong:
`density="compact"` in chat-turn.tsx has not owned transcript block
spacing since #2348. That table zeroes every Astryx margin on the
document's children and declares the four gaps itself, in @layer
components — a Markdown density token, or a makaTheme.ts override of
`astryx-markdown-paragraph`, lands in an earlier layer and is inert.
`--md-gap-block` is the only authority, so the call site now says so
rather than leaving the next reader to re-derive it from commit history.

One rung moves, so code blocks and blockquotes move with it: they are
already carried by the same `> * + *` step (Astryx skips its codeblock
wrapper whenever `components.code` is set, which Maka always does), and
splitting a per-element rung out would re-introduce the multi-value
spacing this table exists to collapse. What that costs is contrast at
the top of the ladder — the section step falls from 2x the block gap to
1.33x — and the heading rungs stay put anyway, because heading spacing
is #1857's decision and not this change's business.

Measured in Storybook against live computed styles, before and after,
on Product/Markdown → TranscriptTurn and Product/Shell Official AppShell
→ Native Conversation:

  list rows   4px   4px   unchanged
  blocks      8px  12px   paragraph, list, quote, code block
  section    16px  16px   unchanged (h3-h6)
  chapter    24px  24px   unchanged (h1, h2)

Daily Review renders at density="default" and is out of the selector, so
its ladder is byte-for-byte what it was: 12/12 paragraphs, 16/16 quote
and table, straight from Astryx.

Generated-by: Claude Code
Astro-Han added a commit that referenced this pull request Aug 21, 2026
styles.css told readers the "compact == transcript" assumption was held by
`__tests__/markdown-rhythm-dom-contract.test.tsx`. That file was deleted in
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
Astro-Han added a commit that referenced this pull request Aug 21, 2026
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
citing 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 only what cannot be noticed without a test. Both failure modes here
are silent: no error, no failing check, just spacing that quietly stops
being what the table declares, because a CSS selector that matches nothing
never complains.

- The table selects entirely on DOM Astryx generates at runtime —
  data-density, astryx-markdown-heading + data-level, astryx-list-item.
  Those names have one upstream owner and appear in Maka only inside
  selectors, so a rename kills every rule at once. Astryx is bumped
  regularly (0.4.0 in #2983, 0.4.3 in flight, plus the Dependabot minor
  group), so this is a recurring event, not a hypothetical.
- Astryx's ListItem carries CONTROL row padding that `density` cannot reach
  from outside. That padding is what inverted the ladder in #2348 — list
  items ~10px apart against 4px paragraphs — and one rule neutralizes it.
  Lose the rule and the original defect returns, silently.

- Plus the assumption the comment actually names: compact markdown still
  has exactly one caller. The table carries heading TYPOGRAPHY on a density
  key, which Astryx's own RFC says density must not do, so the key is
  honest only while `compact` and "transcript" are the same set.

Deliberately NOT pinned: the ladder's declared values and their order. A
first draft asserted them, and it was the wrong instinct — a reversed
ladder has to be typed on purpose into four adjacent lines under a comment
explaining the order, and it is visible the moment anyone looks at a
transcript. It also would not have caught the defect it claimed to guard:
#2348's inversion came from the ListItem padding above, which a test
reading only the declared variables cannot see. Same for the
adjacent-sibling gap form, the `hr` rung and the two heading size tiers —
those are how the table is written, not what it promises.

Why this is 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. Two of the three assertions here render
the real component and check the join between Astryx's output and Maka's
selectors; the third asserts an outcome the table promises, not a syntax.
The stylesheet-reading half does not revive 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.

Six mutations, each verified to fail the assertion that describes it:
deleting the ListItem padding reset; un-zeroing it; un-zeroing it while
naming the reset 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.

Generated-by: Claude Code
Astro-Han added a commit that referenced this pull request Aug 21, 2026
Transcript paragraphs sat 8px apart against 20px body leading — a paragraph
break narrower than the line break inside a paragraph. Raise the block rung
to 12px, which is Astryx's own `spacingParagraphDefault`, so the transcript
stops being denser than the design system between two paragraphs while
keeping its own heading scale (#1857) and list rung untouched.

The spacing is owned by the compact rhythm table in packages/ui/src/styles.css,
not by Astryx's density tokens: the table zeroes Astryx's margins outright and
declares every gap itself, so retuning `--md-gap-block` is the only lever.

Adds two contract assertions for the ways that table can break silently — the
runtime DOM hooks its selectors need still being emitted, and the ListItem
control padding still being neutralized (the mechanism that inverted the
ladder in #2348). Declared values are deliberately not pinned; a wrong number
is visible the moment anyone looks at a transcript.

Review caught a third assertion claiming compact markdown was transcript-only.
It was already false — the desktop Artifact Preview has rendered
`<MarkdownBody density="compact">` since #2506 — and passed anyway. Removed
rather than widened, since holding it means a library test grepping
application source. The shared surface and the unguarded typography coupling
are documented next to the heading rules instead.

Generated-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant