diff --git a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
index 68fa746b4e..76bc188e98 100644
--- a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
+++ b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
@@ -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
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
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 — `
` + 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');
+ });
+});
diff --git a/apps/desktop/src/renderer/styles/prose.css b/apps/desktop/src/renderer/styles/prose.css
index c9ab7dc799..97c1acd8d3 100644
--- a/apps/desktop/src/renderer/styles/prose.css
+++ b/apps/desktop/src/renderer/styles/prose.css
@@ -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
@@ -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 `
` 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,
@@ -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);
}
@@ -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. */
@@ -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;
@@ -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
+ 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.
@@ -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;
diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts
index 854fe30885..b511e95af1 100644
--- a/packages/ui/src/__tests__/markdown-body.test.ts
+++ b/packages/ui/src/__tests__/markdown-body.test.ts
@@ -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 ; 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 - items');
});
+
+it('renders a hard break as a native
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,
+ /
\s*/,
+ 'a hard break must render as a native
followed by the .maka-hardbreak span — the
carries the line-break semantics (AX LineBreak node) and the span carries the 6px gap that CSS cannot put on a
',
+ );
+});
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx
index 6ca4b67eb8..b771e5d8a9 100644
--- a/packages/ui/src/markdown-body.tsx
+++ b/packages/ui/src/markdown-body.tsx
@@ -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 }) => {children},
+ // `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
any height, since an
+ // inline break box takes none. The
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
INSIDE the span is what yields a
+ // spurious "\n\n".
+ // MARKDOWN-PROSE-CJK-MIXED-SPACING-0.
+ br: () => (
+ <>
+
+
+ >
+ ),
// #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 —