Skip to content

fix(ui): space CJK-Latin mixed prose in Markdown answers - #1482

Merged
Astro-Han merged 4 commits into
mainfrom
fix/prose-cjk-mixed-spacing
Jul 25, 2026
Merged

Astro-Han merged 4 commits into
mainfrom
fix/prose-cjk-mixed-spacing

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Maka is CJK-first and its assistant prose is dense mixed script — Chinese wrapped around identifiers, paths, version numbers and line numbers. Measuring that surface against the built app turned up three spacing gaps, plus a literal-code boundary that has to hold for them. Refs #1433 (item 5, reframed).

text-autospace: normal on .maka-prose. Chromium's initial value is no-autospace, not the spec's normal, so the CJK/Latin boundary spacing has to be asked for. It reaches across inline element boundaries, so <code> pills get it on both sides too. Scoped to the prose layer rather than :root: the composer, sidebar and settings surfaces would all shift text metrics, which is a separate call.

margin-inline: 0.25em on inline code. Autospace alone leaves 1.55px against a pill that already carries padding and a filled background, where the code still reads glued to the surrounding Chinese. The two stack rather than replace each other — measured 78.36px → 81.48px (autospace) → 87.45px (both) on the same paragraph. Zeroed at block edges: that margin is the gap to neighbouring text, and at a block edge there is none, so it indented the block instead (2.98px, a ragged left edge down a list of items opening with code).

<br> + .maka-hardbreak for hard breaks. remark-breaks turns every single newline into a hard break, and model answers lean on **subhead**\nbody to separate sections, so this gap routinely carries a section split. It had only the 4px the line box already gives, against 16px between paragraphs — the stronger semantic split rendered tighter than the weaker one. CSS cannot space a native <br>: display:block, height, margin and content:"\A" each leave the paragraph at exactly 39px, because an inline break box cannot take a height. So markdown-body emits the native <br> followed by a block span. Both are needed: the span alone drops the LineBreak node from the accessibility tree (verified via CDP), and the <br> alone cannot be given a height. Together they keep the AX node, keep Selection.toString() at a single \n, and land the gap at 10px between the 4px line gap and the 16px paragraph gap.

The hard-break gap is scoped to paragraphs. remark-breaks makes every newline a break, so the 6px also landed on continuation lines inside list items and blockquotes, inverting the grouping: 10.5px inside one list item against 6.5px between two of them. Those containers already group their own content.

The literal-code boundary. The first two rules are prose decoration, and both reach fenced code unless carved back out — text-autospace inherits, and .maka-prose code (0,1,1) matches pre > code as well as the inline pill. This is the invariant #1431 already locked for ligatures ("code is a literal surface"), reached a second time by different properties, so .maka-code-block pre now carves prose shaping out as a named boundary and the contract guards the boundary rather than the two known leaks.

MARKDOWN-PROSE-CJK-MIXED-SPACING-0 locks the CSS tier; packages/ui's render test locks the DOM tier.

Verification

npm --workspace @maka/desktop run test:dist 2850/2850 · npm --workspace @maka/ui run test · npm run typecheck · npm run lint · npm run format:check · node scripts/check-dead-css.mjs.

Measured in the built Electron app (Electron 43.1.1 / Chromium 150), not headless. That distinction is load-bearing: headless Chromium silently falls PingFang SC back to a serif face while document.fonts.check still reports true, so every font-dependent number taken there is void. Confirmed with CSS.getPlatformFontsForNode that the real stack resolves to PingFang SC / .SF NS / Geist Mono (custom).

measurement before after
CJK/Latin boundary 0 +1.55px (1/8 em at 13px)
Chinese ↔ code pill 0 +4.5px per side (autospace + margin, measured additive)
hard-break gap 4px 10px
fenced code, first line vs rest 36.25px vs 33px 33px vs 33px
block opening with an inline pill +2.98px indent 0
list-item continuation vs item gap 10.5px vs 6.5px 6.5px vs 6.5px
paragraph gap (unchanged) 16px 16px

The contract asserts the neutral value of each carve-out, not just that the property name reappears — the first version was a presence check wearing a neutralisation check's error message, and reverting a carve-out to the leaking value kept it green. Deletion-sensitivity was checked by breaking it four ways and confirming each goes red: removing the pre autospace carve-out, removing the pre code margin reset, and adding a fresh letter-spacing to .maka-prose with no carve-out. Removing remarkBreaks now fails packages/ui's render test, which previously would have left .maka-hardbreak as unreachable dead CSS with every assertion still green.

Review focus

Three-way review (Codex, an independent agent, Kimi K3 256K) all returned FAIL on the first revision and converged on the same defect class; the second commit is the response. What they found, and what changed:

  • Prose decoration leaked into fenced code. Confirmed independently — the first source line of every code block started 3.25px right of the lines below it, and CJK/Latin code lines drifted off the monospace grid. Fixed at the boundary rather than as two patches, since this was the second arrival of the same invariant at the same seam. (One reviewer rated the margin leak P3 as "symmetric, nearly invisible" from reading the cascade; measuring it shows it is not symmetric — a <code> in white-space: pre is one inline box across all lines, so only line 1 moves.)
  • word-break: auto-phrase on td was inert. Removed. Chromium's phrase model is Japanese-only: identical break positions to normal under lang="zh-CN" and lang="en", while lang="ja" does change. Double-gated by the renderer's static <html lang="en">. The rule, its comment and its test all asserted a behaviour that does not occur.
  • The bare span cost the AX LineBreak node. Fixed by emitting <br> + span. Nesting the <br> inside the span instead is what produces a spurious "\n\n" on copy; the sibling form has no such cost.
  • The contract was source-regex only, and both leaks passed it. The DOM half moved to packages/ui's render test where AST → DOM belongs, and the CSS half now guards the boundary as a property class.

An E2E computed-style spec was suggested by all three and not taken: MAKA_E2E_FIXTURE is a scenario enum, not free-text injection, so pinning specific Markdown would mean adding a product-layer fixture scenario — disproportionate here. The two seams used instead are a real render (packages/ui) and a boundary-completeness contract.

Still open as a judgement call: text-autospace stays on .maka-prose, so a user's own message bubble does not get the same treatment as the assistant's answer. LobeChat puts it on :root. Widening it is a follow-up, not an oversight.

Several things were rejected while measuring, each for a recorded reason: narrowing the reading measure (Paseo, opencode and LobeChat all have no fixed measure at all — the premise in #1433 item 5 does not hold); letter-spacing: 0.02em (conflicts with the CJK-first no-tracking decision already in prose.css); font-synthesis: style (PingFang SC has a real Semibold, so all three settings render identically); a CJK font in the mono stack (code-block Chinese already falls back to PingFang SC, and Geist Mono resolves correctly); remark-cjk-friendly (10 emphasis cases pass on the current remark — only intraword _ fails, which is CommonMark's design and applies to Latin equally); and text-box-trim (LobeChat shipped, narrowed and reverted it inside a week).

Maka is CJK-first and its assistant prose is dense mixed script — Chinese
wrapped around identifiers, paths, version numbers and line numbers. Four
spacing gaps showed up when measuring that surface against the built app.

- `text-autospace: normal` on `.maka-prose`. Chromium's initial value is
  `no-autospace`, not the spec's `normal`, so the CJK/Latin boundary spacing
  has to be asked for. Measured +1.55px per boundary at the 13px body (1/8 em),
  and it reaches across inline element boundaries, so `<code>` pills get it on
  both sides too. Scoped to the prose layer rather than `:root`: the composer,
  sidebar and settings surfaces would all shift text metrics, which is a
  separate call.

- `margin-inline: 0.25em` on inline code. Autospace alone leaves 1.55px against
  a pill that already carries padding and a filled background, where the code
  still reads glued to the surrounding Chinese. The two stack rather than
  replace each other.

- `word-break: auto-phrase` on `td`. A squeezed Chinese body cell otherwise
  breaks mid-word. Only `td` — `th` is `white-space: nowrap`, where a break
  rule is inert.

- `.maka-hardbreak` for hard breaks. `remark-breaks` turns every single newline
  into a hard break, and model answers lean on `**subhead**\nbody` to separate
  sections, so this gap routinely carries a section split. It had only the 4px
  the line box already gives, against 16px between paragraphs — the stronger
  semantic split rendered tighter than the weaker one. CSS cannot space a
  native `<br>` (display:block, height, margin and content:"\A" each leave the
  paragraph at exactly 39px), so markdown-body renders it as a block span that
  can take a height. The gap lands at 10px, between the 4px line gap and the
  16px paragraph gap.

Every number here comes from the built Electron app via CDP, not headless:
headless Chromium silently falls PingFang SC back to a serif face while
`document.fonts.check` still reports true, which invalidates any font-dependent
measurement taken there.

Rejected while measuring, each for a recorded reason: narrowing the reading
measure (Paseo, opencode and LobeChat all have no fixed measure at all);
`letter-spacing: 0.02em` (conflicts with the CJK-first no-tracking decision in
prose.css); `font-synthesis: style` (PingFang SC has a real Semibold, so the
three settings render identically); a CJK font in the mono stack (code-block
Chinese already falls back to PingFang SC and Geist Mono resolves correctly);
`remark-cjk-friendly` (10 emphasis cases pass on the current remark; only
intraword `_` fails, which is CommonMark's design and applies to Latin too);
and `text-box-trim` (LobeChat shipped, narrowed and reverted it within a week).

MARKDOWN-PROSE-CJK-MIXED-SPACING-0 locks all four.
…ert rule

Three-way review of #1482 (Codex / independent agent / Kimi K3) converged on
the same defect class, reproduced here in the built app (Electron 43.1.1,
Chromium 150):

- `margin-inline: 0.25em` on `.maka-prose code` (0,1,1) also matched
  `pre > code`. A <code> inside `white-space: pre` is ONE inline box spanning
  every line, so the margin offset only the FIRST source line of every fenced
  block: measured 36.25px against 33px for the lines below it.
- `text-autospace: normal` is inherited, so it reached fenced code too and
  drifted CJK/Latin lines ~1.6px off the monospace grid per boundary.

Both are the invariant #1431 already locked for ligatures — code is a literal
surface — reached a second time by different properties, so the fix is the
boundary rather than the two leaks: the `.maka-code-block pre` tier now carves
prose shaping back out, and the contract fails when ANY future shaping
declaration lands on `.maka-prose` without a carve-out (verified by adding a
letter-spacing declaration and watching it go red).

Also:

- Drop `td { word-break: auto-phrase }`. Measured inert for Chinese in this
  engine: identical break positions to `normal` under `lang="zh-CN"` and
  `lang="en"`; only `lang="ja"` changes, since Chromium's phrase model is
  Japanese-only. Double-gated by the renderer's static `<html lang="en">`.
  The rule, its comment and its assertion all claimed a behaviour that does
  not occur.
- Emit `<br>` + the spacing span instead of replacing the `<br>`. Measured
  with CDP: the bare span dropped the `LineBreak` node from the accessibility
  tree. Both elements keep the AX node, keep Selection.toString() at a single
  "\n", and still take the paragraph to 45px.
- Move the DOM half of the contract into packages/ui's render test, where
  AST → DOM belongs. It asserts on a single-newline source, so removing
  `remarkBreaks` now fails the suite instead of silently making
  `.maka-hardbreak` dead CSS.
The rules' rationale was written out three or four times across prose.css,
markdown-body.tsx and both test files — measurement methodology, rejected
alternatives and the full <br> negative result each restated per site.
Keep the reason and the number at the rule; drop the retelling.
188 added lines instead of 275, 48 comment lines instead of 112.
…ean something

Round-2 review (Codex, an independent agent, z.ai glm-5.2) found three more
instances of one shape: a rule whose scope is wider than its intent. All
measured in the shipping runtime.

- `margin-inline` on the inline pill is the gap between the pill and its
  NEIGHBOURING text — at a block edge there is no neighbour, so it indented
  the block instead. A paragraph, list item or blockquote opening with inline
  code sat 2.98px right of one opening with plain text: a ragged left edge
  down a list of such items. Zeroed at both edges.

- `.maka-hardbreak`'s 6px is for the section split LLM answers write as
  `**subhead**\nbody`, but `remark-breaks` makes EVERY newline a break, so it
  also landed on continuation lines inside list items and blockquotes — where
  it inverted the grouping: 10.5px inside one list item against 6.5px between
  two of them. Those containers already group their own content, so the gap is
  dropped there and kept in paragraphs.

- `border-radius` reached fenced code through the same `.maka-prose code`
  selector as `margin`; `border: 0` does not clear it. Zeroed in the reset.

The boundary contract missed the first two because it was a presence check
wearing a neutralisation check's error message: it asserted the property NAME
reappeared in the code-block tier, never the value. Reverting a carve-out to
the leaking value kept it green — verified by mutation, twice. It now asserts
the neutral value, scans every rule that can contain a fenced block (not just
the bare `.maka-prose` one, and not `h1`/`th`, which cannot hold code), and
covers the two new invariants. All four mutations that previously shipped a
regression green now fail.

Three comment facts corrected while here: the renderer does NOT pin
`lang="en"` (syncUiLocaleDocument sets it at runtime — the auto-phrase removal
stands on the Japanese-only phrase model alone), the `<br>` buys the AX
LineBreak node and nothing else (the span carries all 6px and copy fidelity is
identical either way), and the pill rule got its measured numbers back.
@Astro-Han
Astro-Han merged commit 62de81a into main Jul 25, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/prose-cjk-mixed-spacing branch July 25, 2026 15:14
orangeCatDeveloper added a commit to orangeCatDeveloper/maka that referenced this pull request Sep 7, 2026
The desktop dist suite hung its CI job on run 34061414876 with every
reported test passing: the vite-workspace-packages test file completed
both subtests, but its node:test child process stayed alive for 811s
until the workspace runner hit the 900s ceiling and killed the run.

The lingering handle is a chokidar fs.watch on the temporary repository
root, orphaned by a TOCTOU race between chokidar close() and the
in-flight scan of the dev-server restart that the first test triggers
(apache#4940 has the full fs-level attribution). The second test dodged this
class in apache#4748 by dropping its watcher; the first test needs its watcher
to assert restart-on-manifest-change, so it cannot.

--test-force-exit makes the runner exit once every test has finished,
so completed tests still report their real results while post-test
lingering handles can no longer hold the workspace run hostage.

Fixes apache#4940
Generated-by: GLM-5.3-Flash (pi)

The "//test:dist" key documents the removal condition next to the flag:
drop it once the chokidar closer-drop fix ships upstream (apache#1482) and
reaches vite, tracked in apache#4940.
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