Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Transcript markdown rhythm contract.
*
* Two failure modes, both SILENT — no error, no failing check, just spacing
* that quietly stops being what the table declares. That is the whole reason
* this file exists; a CSS selector that matches nothing never complains.
*
* 1. The table selects entirely on DOM Astryx generates at runtime —
* `data-density`, `astryx-markdown-heading` + `data-level`,
* `astryx-list-item`. Those names have a single upstream owner and appear
* in Maka only inside selectors, so an Astryx 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 rather than a
* hypothetical.
*
* 2. Astryx's ListItem carries CONTROL row padding that `density` cannot reach
* from outside. That padding is what inverted the ladder in the first place
* (#2348: list items ~10px apart against 4px paragraphs, so same-level
* items read as further apart than separate paragraphs), and one rule
* neutralizes it. Lose that rule and the original defect returns.
*
* Deliberately NOT pinned: the ladder's declared VALUES and their order. A
* reversed ladder has to be typed on purpose into four adjacent lines under a
* comment that explains the order, and it is visible the moment anyone looks
* at a transcript — unlike the two above, which are invisible until someone
* measures. 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.
*
* Also not pinned, and deliberately not testable from here: WHICH surfaces
* ask for compact. That set spans workspaces — `chat-turn.tsx` here and
* Artifact Preview in `apps/desktop` — so asserting it would mean a library
* test reading application source, which is the dependency backwards. It is
* documented where it is decided instead, next to the heading rules in
* styles.css that the sharing actually matters for.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import { MarkdownBody } from '../markdown-body.js';

const UI_SRC = resolve(import.meta.dirname, '..', '..', 'src');

const SAMPLE = ['## Heading two', '', 'A paragraph.', '', '1. First item', '2. Second item'].join('\n');

function compactMarkup(): string {
return renderToStaticMarkup(<MarkdownBody text={SAMPLE} density="compact" />);
}

describe('transcript markdown rhythm', () => {
it('emits the DOM hooks the rhythm table selects on', () => {
const markup = compactMarkup();
// Every rule in the table is prefixed with this, so it is the one hook
// whose two halves — selector prefix and runtime attribute — nothing else
// joins. Rename it and all compact spacing dies silently.
assert.match(
markup,
/data-maka-contract="markdown"/,
'the `data-maka-contract="markdown"` wrapper is gone. Every rule in the rhythm table ' +
'is scoped on it, so all compact prose spacing and the heading scale are now dead.',
);
assert.match(
markup,
/<div[^>]*role="document"[^>]*data-density="compact"|<div[^>]*data-density="compact"[^>]*role="document"/,
'the document root no longer carries `data-density="compact"`. Every rule in the ' +
'rhythm table is scoped on it, so all compact prose spacing is now dead.',
);
assert.match(
markup,
/class="[^"]*\bastryx-markdown\b/,
'the document root no longer carries the `astryx-markdown` class the table selects on',
);
assert.match(
markup,
/<h2[^>]*class="[^"]*\bastryx-markdown-heading\b/,
'headings no longer carry `astryx-markdown-heading`; the transcript heading scale is dead',
);
assert.match(
markup,
/<h2[^>]*data-level="2"/,
'headings no longer carry `data-level`. The table splits h1/h2 from h3-h6 on it, so ' +
'without it every heading collapses to one tier — the exact defect #2348 replaced.',
);
assert.match(
markup,
/class="[^"]*\bastryx-list-item\b/,
'list rows no longer carry `astryx-list-item`, so the rule below cannot reach them. ' +
'If markdown lists stopped rendering through Astryx `List` that is good news, but ' +
'the list rhythm needs re-measuring rather than silently inheriting whatever ' +
'replaced it.',
);
});

/**
* The other half of the join above: the class is emitted AND something
* spends its control padding. Markdown hands its list a hardcoded
* `density="compact"` in both modes, so the row keeps a control's block
* padding no matter what the caller asks for — the real list-item gap is
* `padding + gap`, and only zeroing the padding makes `--md-gap-list` the
* whole distance between two items.
*/
it('neutralizes the ListItem control padding the ladder inverted on', async () => {
const css = (await readFile(join(UI_SRC, 'styles.css'), 'utf8')).replace(/\/\*[\s\S]*?\*\//g, '');
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. Astryx spaces ' +
'a markdown list as a clickable row, which is what made same-level items read as ' +
'further apart than separate paragraphs (#2348).',
);
assert.match(
rule[1],
/padding-block\s*:\s*0/,
'ListItem block padding is no longer zeroed on the compact surface, so the real ' +
'list-item gap is padding + gap and the declared ladder is not the spacing you get. ' +
`Found: { ${rule[1].trim()} }`,
);
});
});
10 changes: 10 additions & 0 deletions packages/ui/src/chat-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,16 @@ const AssistantAnswerBubble = memo(function AssistantAnswerBubble(props: Assista
text={props.text}
streaming={props.phase === 'streaming'}
settledText={settledText}
// Names the surface, and not exclusively: the desktop Artifact
// Preview asks for compact too and takes the same rules, so retuning
// them here is retuning them there. What this prop does NOT do is set
// this turn's block spacing. Every top-level gap in a transcript turn
// comes from the rhythm table in styles.css, which keys on the
// `data-density="compact"` this prop reflects and overrides Astryx's
// own margins outright. So `compact` still buys the transcript
// heading scale and the tighter rhythm inside a list item or a quote,
// and reading it as "paragraphs are squeezed here" is the wrong
// file — retune `--md-gap-block` instead.
density="compact"
/>
{truncated && (
Expand Down
78 changes: 62 additions & 16 deletions packages/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,48 @@
by owning every compact prose gap in one place:

4px list items (same level)
8px blocks (paragraph, list, quote, table, code)
12px blocks (paragraph, list, quote, table, code)
16px section (h3-h6, and either side of an `hr`)
24px chapter (h1, h2)

Why the block rung is 12px and not the 8px this table first shipped: 8px
restored the ORDER the table exists to fix, but never argued its value.
Body leading here is 20px, so 8px put two paragraphs 0.4 of a line apart —
a paragraph break narrower than the line break inside a paragraph, which is
the one distance a reader already knows. 12px is Astryx's own paragraph
rhythm at document density (spacingParagraphDefault, `--spacing-3`), so this
is the block step returning to the design system rather than a Maka number:
the transcript keeps its own HEADING scale and its own list rung, and only
stops being denser than the design system between two paragraphs. Measured
in Storybook (Product/Markdown → TranscriptTurn) rather than estimated.

It costs contrast at the top of the ladder — the section step falls from 2x
the block gap to 1.33x, so a heading now separates mainly on weight, size
and colour rather than on space. That is the price of leaving the heading
rungs alone, which is deliberate: heading spacing is what #1857 and this
table already decided, and this change is about paragraphs only.

What holds this: __tests__/markdown-rhythm-contract.test.tsx, and it pins
only the two ways this table can break SILENTLY — the runtime hooks these
selectors need still being emitted (an Astryx rename kills every rule at
once, and a selector that matches nothing never complains), and the
ListItem control padding still being neutralized (the mechanism that
inverted the ladder in the first place). The values below are NOT pinned,
deliberately: a wrong number is visible the moment anyone looks at a
transcript, so it does not need a test to be noticed — which is not true of
either of the other two.

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.
that surface out of the blast radius. Compact is not the transcript
alone, though — the desktop Artifact Preview asks for it too, so it takes
these gaps as well. For the block rung that is the point rather than a
cost: 12px IS the document rhythm, so the preview moves toward Astryx's
default rather than further from it. The heading rules below are the ones
where sharing is arguable, and they say so.
- 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
Expand All @@ -211,11 +243,11 @@
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.
4px instead of the 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 < 12px 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:
Expand All @@ -232,7 +264,7 @@
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-block: var(--space-3);
--md-gap-section: var(--space-4);
--md-gap-chapter: var(--space-6);
}
Expand Down Expand Up @@ -290,14 +322,28 @@
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. */
the same set, and they DO NOT. Two surfaces ask for compact: the transcript
(chat-turn.tsx) and the desktop Artifact Preview
(apps/desktop/src/renderer/artifact-preview.tsx, since #2506), so the
preview pane has been rendering `.md` files at transcript heading sizes
with dimmed h4-h6 ever since. Nothing scopes it out: the
`[data-maka-contract="markdown"]` prefix is on every MarkdownBody, so
`density` is the whole key.

Left shared on purpose, not by omission. The preview is a narrow side pane
showing a document a few lines at a time, which is the shape the flattening
was designed for, and no report has called it wrong. Splitting the key —
moving these three rules onto a surface attribute the transcript sets and
the preview does not — is a visible change to the preview's typography and
belongs in its own change, argued and looked at on that surface rather
than smuggled in under a spacing token.

So: a THIRD compact surface inherits transcript typography silently, and
nothing catches it. That is unguarded, and it stays unguarded knowingly —
the caller set spans workspaces, so the only test that could hold it is a
library test grepping application source. One was written and it was wrong
on its first commit, passing while already false. A comment that is checked
when these rules are read beats an assertion that lies. */
[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);
}
Expand Down