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
191 changes: 191 additions & 0 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,194 @@ describe('MARKDOWN-PROSE-RENDER-OWNER-0 contract (#739)', () => {
);
});
});

/**
* MARKDOWN-PROSE-CJK-MIXED-SPACING-0: Maka is CJK-first and its assistant
* prose is dense mixed script, so `.maka-prose` asks for CJK/Latin autospace
* and gives inline code pills their own margin. Both rationales live next to
* the rules in prose.css; this locks that they are still there.
*
* The third pair of tests is the one that matters. Both spacing rules are
* PROSE decoration that reaches fenced code unless carved back out —
* autospace inherits, and `.maka-prose code` (0,1,1) matches `pre > code` too.
* Shipping them uncarved put the first line of every code block 3.25px right
* of the lines below it and drifted mixed-script lines off the monospace grid.
* That is the invariant #1431 already locked for ligatures, reached a second
* time by different properties, so these guard the BOUNDARY as a property
* class: they fail for any future shaping declaration too, not just these two.
*
* Numbers here come from the built Electron app, never headless — headless
* falls PingFang SC back to a serif face while `document.fonts.check` still
* returns true, which voids every font-dependent measurement taken there.
*/
describe('MARKDOWN-PROSE-CJK-MIXED-SPACING-0 contract', () => {
it('.maka-prose asks for CJK/Latin autospace (Chromium defaults to no-autospace)', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const prose = cssBlocks(css).find(({ selectors }) => selectors === '.maka-prose');
assert.ok(prose, 'expected a bare .maka-prose base rule in prose.css');
assert.match(
prose!.decls,
/text-autospace:\s*normal/,
".maka-prose must set text-autospace: normal — Chromium's initial value is no-autospace, so a CJK-first product gets no CJK/Latin boundary spacing unless it asks (measured +1.55px per boundary at 13px)",
);
});

it('inline code keeps a margin gap so code pills do not glue to surrounding Chinese', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const code = cssBlocks(css).find(({ selectors }) => /^\.maka-prose\s+code$/.test(selectors));
assert.ok(code, 'expected a .maka-prose code rule');
assert.match(
code!.decls,
/margin-inline:\s*0\.25em/,
'.maka-prose code must carry margin-inline: 0.25em — text-autospace alone leaves only 1.55px against a pill that already has inline padding and a background',
);
});

/**
* Glyph-reshaping properties that INHERIT into fenced code, each with the
* value that actually neutralises it there. The first version of this
* contract only checked that the property NAME reappeared in the code-block
* tier, which passed when a carve-out was reverted to the leaking value —
* a presence check wearing a neutralisation check's error message.
*/
const INHERITED_SHAPING: Array<[property: string, neutral: RegExp]> = [
['text-autospace', /no-autospace/],
['text-spacing-trim', /space-all/],
['letter-spacing', /normal|:\s*0/],
['word-spacing', /normal|:\s*0/],
['font-variant-ligatures', /none/],
['font-feature-settings', /normal/],
['text-transform', /none/],
];

/**
* Pill chrome on `.maka-prose code`, which also matches `pre > code`. The
* block reset must zero each — `border`/`padding` already leaked once, and
* `margin` leaked in round 1 (the pill's margin-inline offset the first
* source line of every code block by 3.25px).
*/
const PILL_CHROME = ['margin', 'padding', 'background', 'border', 'border-radius'];

/** Longhand-aware property match: `margin` also matches `margin-inline`. */
const declares = (decls: string, property: string) =>
new RegExp(`(?:^|;)\\s*${property}(?:-[a-z-]+)?\\s*:`).test(decls);

/** The declaration text for `property` in `decls`, longhands included. */
const valueOf = (decls: string, property: string) =>
decls.match(new RegExp(`(?:^|;)\\s*${property}(?:-[a-z-]+)?\\s*:([^;]*)`))?.[1]?.trim() ?? '';

/**
* Rules that can hold a fenced block, so an inherited property set on them
* reaches code. A list item and a blockquote can contain one; `h1`–`h4`,
* `th` and `td` cannot (headings are single-line and GFM table cells are
* line-delimited), which is why `.maka-prose h1`'s letter-spacing is not a
* leak and must not trip this.
*
* Known limit: `cssBlocks` is a flat splitter, so a rule nested inside an
* at-rule is invisible here. prose.css has no `@media`; if one is added,
* this scan needs to grow with it.
*/
const fencedCodeAncestors = (blocks: Array<{ selectors: string; decls: string }>) =>
blocks.filter(({ selectors }) => selectors
.split(',')
.some((one) => /^\.maka-prose(\s+(li|blockquote))?$/.test(one.trim())));

it('prose text shaping is neutralised at the fenced-code boundary', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const blocks = cssBlocks(css);
const pre = blocks.find(({ selectors }) => selectors === '.maka-prose .maka-code-block pre');
const preCode = blocks.find(({ selectors }) => selectors === '.maka-prose .maka-code-block pre code');
assert.ok(pre, 'expected a .maka-prose .maka-code-block pre rule');
assert.ok(preCode, 'expected a .maka-prose .maka-code-block pre code reset');
const carvedOut = `${pre!.decls};${preCode!.decls}`;

for (const [property, neutral] of INHERITED_SHAPING) {
const setter = fencedCodeAncestors(blocks).find(({ decls }) => declares(decls, property));
if (!setter) continue;
assert.ok(
declares(carvedOut, property),
`${setter.selectors} sets ${property}, which inherits into fenced code and shifts glyphs off the monospace grid. Code is a literal surface (#1431) — the .maka-code-block pre tier must carve ${property} back out`,
);
assert.match(
valueOf(carvedOut, property),
neutral,
`the code-block carve-out for ${property} must actually neutralise it, not just re-declare it — a carve-out reverted to the prose value re-ships the leak`,
);
}
});

it('inline-pill chrome is zeroed at the fenced-code boundary', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const blocks = cssBlocks(css);
const code = blocks.find(({ selectors }) => /^\.maka-prose\s+code$/.test(selectors));
const preCode = blocks.find(({ selectors }) => selectors === '.maka-prose .maka-code-block pre code');
assert.ok(code, 'expected a .maka-prose code rule');
assert.ok(preCode, 'expected a .maka-prose .maka-code-block pre code reset');

for (const property of PILL_CHROME) {
if (!declares(code!.decls, property)) continue;
assert.ok(
declares(preCode!.decls, property),
`.maka-prose code (0,1,1) matches pre > code too, so ${property} leaks into fenced code. The .maka-code-block pre code reset must neutralise ${property}`,
);
assert.match(
valueOf(preCode!.decls, property),
/^(0(px)?|none|transparent)$/,
`the pre code reset for ${property} must zero it — re-declaring it with a non-neutral value re-ships the leak (round 1: margin-inline offset the first source line by 3.25px)`,
);
}
});

it('the inline pill does not indent the block it starts', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const blocks = cssBlocks(css);
const code = blocks.find(({ selectors }) => /^\.maka-prose\s+code$/.test(selectors));
if (!declares(code!.decls, 'margin')) return;
const edge = blocks.find(({ selectors }) => /\.maka-prose\s+code:first-child/.test(selectors));
assert.ok(
edge,
'the pill margin is an inline gap between the pill and its NEIGHBOURING text, but at a block edge there is no neighbour — it indents the block instead (measured 2.98px on a paragraph, list item and blockquote that open with inline code, against 0 for one opening with plain text). A .maka-prose code:first-child rule must drop the leading margin',
);
assert.match(
edge!.decls,
/margin-inline-start:\s*0/,
'.maka-prose code:first-child must zero margin-inline-start so a block opening with a pill keeps the same left edge as its neighbours',
);
});

it('hard breaks render as a block span with height — a native <br> cannot be spaced by CSS', async () => {
const css = stripCssComments(await readFile(PROSE_CSS, 'utf8'));
const hb = cssBlocks(css).find(({ selectors }) => /\.maka-hardbreak$/.test(selectors));
assert.ok(hb, 'expected a .maka-hardbreak rule in prose.css');
assert.match(
hb!.decls,
/display:\s*block/,
'.maka-hardbreak must be display: block — an inline box cannot take a height, which is exactly why a native <br> is unfixable in CSS',
);
assert.match(
hb!.decls,
/height:\s*var\(--space-1-5\)/,
'.maka-hardbreak must take its 6px from --space-1-5 so the hard-break gap lands between the 4px line gap and the 16px paragraph gap',
);
// The DOM half — `<br>` + span, and remark-breaks still producing the
// break — is locked by rendering in packages/ui's markdown-body.test.ts.

// `remark-breaks` makes EVERY single newline a hard break, so the 6px
// lands on list-item and blockquote continuation lines too, where it
// inverts the grouping: measured 10.5px inside one list item against
// 6.5px between two of them. Those containers already group their own
// content; only a paragraph break carries the section split.
const scoped = cssBlocks(css).find(({ selectors }) =>
/\.maka-prose\s+li\s+\.maka-hardbreak/.test(selectors));
assert.ok(
scoped,
'the hard-break gap must be dropped inside li/blockquote — a continuation line there rendered further apart (10.5px) than two separate list items (6.5px), inverting the grouping',
);
assert.match(
scoped!.selectors,
/blockquote\s+\.maka-hardbreak/,
'blockquote needs the same carve-out as li — its content is already grouped, so a break inside it is a continuation, not a section split',
);
assert.match(scoped!.decls, /height:\s*0/, 'the carve-out must zero the gap');
});
});
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/styles/prose.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@
font-size: var(--font-size-base);
line-height: var(--leading-normal);
word-wrap: break-word;
/* CJK/Latin boundary spacing, +1.55px per boundary (1/8 em at 13px), also
across inline element boundaries. Chromium's initial value is
`no-autospace`, not the spec's `normal`, so it has to be asked for.
Not on :root — that would shift composer and settings metrics too.
MARKDOWN-PROSE-CJK-MIXED-SPACING-0 locks this. */
text-autospace: normal;
/* No max-width here: the reading measure is owned by .maka-message-row
(var(--maka-chat-measure), the same cap the composer uses), so the
assistant block matches the composer width. An inner 72ch cap used to
Expand All @@ -112,6 +118,25 @@
margin: 0 0 var(--space-3);
text-wrap: pretty;
}
/* Hard break inside a paragraph. `remark-breaks` makes every single newline
one of these, and LLM answers use `**subhead**\nbody` for section splits, so
unstyled the ~4px the line box gives renders the stronger split tighter than
the 16px between paragraphs. CSS cannot space a native `<br>` at all, hence
the block span markdown-body pairs with it. 6px lands the gap at 10px.
MARKDOWN-PROSE-CJK-MIXED-SPACING-0 locks this. */
.maka-hardbreak {
display: block;
height: var(--space-1-5);
}
/* `remark-breaks` makes EVERY newline a break, including continuation lines
inside a list item or a blockquote — containers that already group their
own content, where the gap inverted the grouping (measured 10.5px inside
one list item against 6.5px between two of them). Only a paragraph break
carries a section split. */
.maka-prose li .maka-hardbreak,
.maka-prose blockquote .maka-hardbreak {
height: 0;
}
.maka-prose h1,
.maka-prose h2,
.maka-prose h3,
Expand Down Expand Up @@ -177,6 +202,17 @@
border-radius: var(--radius-control);
transition: border-bottom-color var(--duration-quick) var(--ease-out-strong), background var(--duration-quick) var(--ease-out-strong), box-shadow var(--duration-quick) var(--ease-out-strong);
}
/* That margin is the gap between the pill and its NEIGHBOURING text — at a
block edge there is no neighbour, so it indents the block instead: measured
2.98px on a paragraph, list item and blockquote opening with inline code,
against 0 for one opening with plain text, i.e. a ragged left edge down a
list of such items. Drop it at both edges. */
.maka-prose code:first-child {
margin-inline-start: 0;
}
.maka-prose code:last-child {
margin-inline-end: 0;
}
.maka-prose a:hover {
border-bottom-color: var(--link);
}
Expand Down Expand Up @@ -208,6 +244,12 @@
two pills). Bg-only pills leave ~5.5px of inter-line air, matching
plain text; the dropped border was 3% alpha and read as invisible. */
padding: 0 var(--space-1-5);
/* Autospace reaches across this boundary already, but its 1.55px is sized
for bare glyphs; against a pill with its own padding and background the
code still reads glued. Additive, measured: 78.36 → 81.48 (autospace)
→ 87.45px (both), i.e. 4.55px of clear space per side against the 3.58px
of a literal space. MARKDOWN-PROSE-CJK-MIXED-SPACING-0 locks this. */
margin-inline: 0.25em;
/* PR-UI-LAYOUT-28: inline code radius 4 → 5. Tiny bump but
* pulls it slightly out of the "tight tag" feel into a calmer
* pill rhythm that matches the rest of the warm-canvas chrome. */
Expand Down Expand Up @@ -294,6 +336,13 @@
font-size: var(--font-size-ui);
line-height: var(--leading-normal);
white-space: pre;
/* The literal-code boundary: no prose text shaping crosses into fenced code,
the same invariant #1431 locked for ligatures. `.maka-prose` sets
`text-autospace: normal` and autospace inherits, so Chinese comments next
to Latin identifiers drifted 1.6px off the monospace grid per boundary.
Block tier only — the inline pill wants autospace on both sides.
Any future prose shaping belongs here too; the contract fails without it. */
text-autospace: no-autospace;
}
.maka-prose .maka-code-block pre code {
padding: 0;
Expand All @@ -303,6 +352,12 @@
box, so a leftover border paints a rounded outline around every wrapped
line box inside the pre (clearly visible in dark). */
border: 0;
/* Same boundary: `.maka-prose code` (0,1,1) matches `pre > code` too, and a
<code> inside `white-space: pre` is one inline box spanning every line, so
the pill's margin-inline offset only the FIRST source line (36.25 vs 33).
`border: 0` above does not clear the pill's radius, so zero that too. */
margin: 0;
border-radius: 0;
}

/* Syntax highlight palette for highlight.js / lowlight class names.
Expand Down Expand Up @@ -452,6 +507,12 @@
white-space: nowrap;
letter-spacing: var(--tracking-normal);
}
/* Don't reach for `word-break: auto-phrase` on `td` for squeezed Chinese cells:
measured inert in Chromium 150 — identical breaks to `normal` under both
`lang="zh"` and `lang="en"`, because the engine's phrase model is
Japanese-only. (The document lang IS set at runtime — syncUiLocaleDocument
in locale-context.tsx — so this is not a lang-tagging gap.)
`line-break: strict` / `word-break: keep-all` do work, at an overflow cost. */
.maka-prose hr {
margin: var(--space-4) 0;
border: 0;
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,18 @@ it('preserves GFM task-list HAST classes so prose.css task-list rules match (#73
assert.match(markup, /class="contains-task-list"/, 'bareElement must preserve the HAST className remark-gfm sets on the task-list <ul>; dropping it (round-2 regression) makes prose.css .maka-prose ul.contains-task-list rules stop matching');
assert.match(markup, /class="task-list-item"/, 'bareElement must preserve the HAST className remark-gfm sets on task-list <li> items');
});

it('renders a hard break as a native <br> plus the spacing span (MARKDOWN-PROSE-CJK-MIXED-SPACING-0)', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: '**小节标题**\n正文内容',
}));

// The source is a SINGLE newline on purpose: `remarkBreaks` is what makes it
// a break at all, and without the plugin .maka-hardbreak becomes unreachable
// dead CSS — a regression no CSS contract can see.
assert.match(
markup,
/<br\s*\/?>\s*<span class="maka-hardbreak" aria-hidden="true">/,
'a hard break must render as a native <br> followed by the .maka-hardbreak span — the <br> carries the line-break semantics (AX LineBreak node) and the span carries the 6px gap that CSS cannot put on a <br>',
);
});
17 changes: 17 additions & 0 deletions packages/ui/src/markdown-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ export function MarkdownBody(props: { text: string; streaming?: boolean }) {
// Wrap block code with a language pill header + copy affordance.
// Surface the detected language so users can verify highlighting.
pre: ({ children, ...rest }) => <CodeBlock {...rest}>{children}</CodeBlock>,
// `remarkBreaks` above makes every single newline a hard break, and
// model answers use `**subhead**\nbody` for section splits, so this
// element routinely carries one. The span does all the spacing work
// (39px → 45px); CSS cannot give a native <br> any height, since an
// inline break box takes none. The <br> buys exactly one thing, and
// it is not visual: without it the `LineBreak` node disappears from
// the accessibility tree — the same trade TABLE-A11Y-SEMANTICS-0
// below refuses for table roles. Copy fidelity is a single "\n"
// either way; nesting the <br> INSIDE the span is what yields a
// spurious "\n\n".
// MARKDOWN-PROSE-CJK-MIXED-SPACING-0.
br: () => (
<>
<br />
<span className="maka-hardbreak" aria-hidden="true" />
</>
),
// #618 item 5: the horizontal scroller for over-wide tables lives on
// a wrapper div. Scrolling on the table itself requires
// `display: block`, which stops the element generating a table box —
Expand Down
Loading