Skip to content

fix(ui): give CJK prose its own leading, heading rungs and smoothing - #4617

Merged
Astro-Han merged 4 commits into
apache:mainfrom
Astro-Han:fix/markdown-cjk-prose-typography
Sep 3, 2026
Merged

fix(ui): give CJK prose its own leading, heading rungs and smoothing#4617
Astro-Han merged 4 commits into
apache:mainfrom
Astro-Han:fix/markdown-cjk-prose-typography

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Chinese transcript text looked thin and structureless compared with other agent clients, and the first suspect was the font. It is not: Han glyphs already resolve to PingFang SC (verified by pixel-comparing the rendered stack against the candidate faces). The problem is three product typography decisions that are fine for Latin chrome and wrong for CJK prose:

  • Leading. Prose sat on the body role's 20px leading at 14px (1.43). Han glyphs fill the whole em box, so that ratio leaves no air between lines. Markdown prose now inherits line-height: 1.6 from the markdown root; headings and code keep the leading their own role tokens declare, and chrome outside prose is untouched.

  • Heading scale. The compact scale put h3 at body size, relying on weight alone. PingFang's Regular and Semibold are close enough that a Chinese h3 was indistinguishable from a bold sentence, so a turn lost a whole level of structure. Sizes stay where main put them (h1/h2 16px, everything else body size), because chat-first clients — Claude Code, opencode, Lody — all keep h2 within 1.14× and h3 at body size, and a model that emits ### every few lines turns bigger rungs into slabs. h3 steps down in ink instead and joins the muted tier with h4–h6: colour separates it from <strong> at no cost in vertical space.

  • Smoothing. body set -webkit-font-smoothing: antialiased, which strips the stroke dilation macOS applies and thins 14px Han glyphs further, most visibly on dark backgrounds. Astryx's reset sets the same on :where(body), so deleting Maka's copy alone changed nothing (caught in review); the body role rule now declares auto explicitly and the whole document inherits it. Fenced code keeps the antialiased Astryx's CodeBlock sets on its own pre; that surface is Latin-first and left alone.

  • Emphasis. Han faces ship no italic, so *emphasis* in a Chinese turn rendered as a synthetic shear of PingFang. Chinese typography marks emphasis with a dot under each character instead. The rule keys on the script of the turn's own prose: MarkdownBody sets data-maka-script="han" when the source outside code fences and inline code contains Han, and the CSS matches that. It does not key on the document lang, which is the UI locale and says nothing about what the model wrote.

text-autospace: normal is also set on the markdown root so Han/Latin and Han/digit boundaries get the conventional hair space when the model did not type one.

Net: the body smoothing declaration flips from antialiased to auto, the compact h3 rule joins the muted tier, two new prose rules, and one boolean on the markdown root. No new tokens.

Verification

Storybook Product/Markdown → TranscriptTurn, 640px viewport at 2x. The story sample gains an emphasis run and a mixed Han/Latin sentence in this branch, so BEFORE is built from main's CSS with that sample change applied, and AFTER from this branch.

Light:

Transcript markdown, light, before and after

Dark:

Transcript markdown, dark, before and after

  • Windows and Linux checked the same way from a throwaway GitHub Actions run (windows-latest with Segoe UI + Microsoft YaHei, ubuntu-24.04 with Noto Sans CJK SC): every rule here is platform-neutral except the removed -webkit-font-smoothing, which only ever applied on macOS, and both platforms rendered as expected.
  • npm --workspace @maka/ui run test:dist: 330 pass, 0 fail. markdown-han-script pins the Han detection (code excluded) and the root marker; markdown-rhythm-contract is unchanged.
  • npm run format and npm run lint: clean.
  • Computed styles checked with Playwright against the built story: body, paragraphs and inline code compute -webkit-font-smoothing: auto (was antialiased on both main and the first revision of this branch); paragraphs, list items and blockquotes compute 22.4px leading; headings and fenced code keep their own (h2 24px, h3 20px, pre 19px).
  • Not run: desktop E2E. No E2E pins these values.

Review focus

The heading rungs are keyed on data-density="compact", which the desktop Artifact Preview also asks for, so .md previews move with the transcript. The existing comment in styles.css already records that shared key as deliberate; this change keeps it.

Comparison with other clients

Read from the source of six chat clients with a GUI markdown surface (opencode, Lody, kimi-code, deer-flow, LobeChat, and Astryx's own Chat/Markdown defaults); Codex and Gemini CLI render in a terminal and were skipped.

Common practice This branch
Body size 14px (5 of 6) 14px
Body leading Two camps: 1.43–1.5 (Astryx, deer-flow) and 1.6–1.75 (opencode 1.6, kimi 1.625, Lody 1.75) 1.6, same as opencode
Headings inside a turn Flattened everywhere: h1 1.21–1.29×, h2 1.07–1.14×, h3 at body size, h4+ muted (opencode, Lody) 1.14× for h1/h2, body size below, muted from h3 down — same shape, one level of colour earlier
Heading weight 600 600
-webkit-font-smoothing: antialiased Split: Lody, kimi, Astryx set it on body; opencode, LobeChat, deer-flow do not Not set. Maka is CJK-first, and the platform default keeps the stroke dilation 14px Han glyphs need
Paragraph / list-item gap 8–12px / 4–8px 12px / 4px
CJK handling None: zero hits for text-autospace or :lang(zh) in all eight repos. Only the two Chinese-team clients (Lody, LobeChat) list a Chinese face at all Chinese faces in the stack, text-autospace on prose

So every value here sits inside what other clients ship, and the one place this branch goes beyond them — text-autospace — is a gap they all share rather than a choice they made. Note that Chromium's computed default is no-autospace (measured on main), so the declaration is a real change, not a restatement of the default.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — diagnosis (font resolution and computed-style probes), the CSS change, comments, screenshots and this description, reviewed by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Chinese transcript text rendered on the same type roles as chrome: 20px
leading at 14px, h3 at body size, and body-wide
-webkit-font-smoothing: antialiased. Han glyphs fill the em box, so that
leading leaves no air between lines; PingFang's Regular and Semibold are
close enough that a body-size h3 is indistinguishable from a bold
sentence; and antialiased strips the stroke dilation macOS applies, which
thins 14px Han glyphs further, most visibly on dark backgrounds.

Prose now inherits line-height 1.6 and text-autospace from the markdown
root, so headings and code keep their own role leading. The compact
heading scale moves one rung up (h1/h2 18px, h3 16px, h4+ body size and
muted) so h3 has a size step instead of relying on weight. The smoothing
override is dropped in favour of the platform default; it was the only
consumer of that declaration.

Generated-by: Claude Code
@github-actions github-actions Bot added the effort/S Under 100 readable lines label Sep 3, 2026
Han faces ship no italic, so `*emphasis*` in a Chinese turn rendered as
a synthetic shear of PingFang. Chinese typography marks emphasis with a
dot under each character; the rule is keyed on :lang(zh), which
LocaleProvider keeps in step with the UI locale, so Latin prose keeps its
italic. The transcript story sample gains an emphasis run and a mixed
Han/Latin sentence so the case is visible in Storybook.

Generated-by: Claude Code
@Astro-Han
Astro-Han marked this pull request as ready for review September 3, 2026 06:28

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at exact head 1b228f5788e972cdc8d1bd11c85b054b20502f26. No P0 or P1 — two P2s and one P3, none blocking.

The diagnosis is the strong part of this. Ruling the font out by pixel-comparing the rendered stack against the candidate faces, before touching anything, is what turned "Chinese text looks thin" into three separable decisions. The heading selectors are exhaustive and mutually exclusive across all six levels, and the measured values match what the description claims: paragraphs 20.0004 → 22.4px, h2 16/24 → 18/28, h3 14/20 → 16/24, h4 unchanged.

P2 — the smoothing removal is a no-op; the third fix did not land

maka-tokens.css:1410-1413 drops Maka's body smoothing, but the app still imports Astryx's reset, which sets -webkit-font-smoothing: antialiased and -moz-osx-font-smoothing: grayscale on :where(body). Measured on both base and head, body, Chinese paragraphs, headings and code all still compute antialiased. The platform stroke dilation the description says this restores never comes back.

The useful part of that measurement is what it rules out in both directions: the deletion is not a global regression for the rest of the app either — it simply changes nothing. Of the three decisions this PR names, leading and the heading scale landed; smoothing did not. If the intent is Han glyphs specifically, it needs an override on the CJK prose root that beats the reset's computed value, not the removal of a duplicate declaration.

Evidence boundary: the computed values above come from a Playwright run against the built Storybook on both commits. I confirmed only the static half — after this diff the repository declares font-smoothing nowhere, so anything still computing antialiased must come from a dependency.

P2 — em:lang(zh) keys on the interface language, not the language of the text

syncUiLocaleDocument writes lang on documentElement only, and its value is the resolved UiLocale'zh' or 'en'. Markdown content carries no lang of its own, so every em in a turn inherits the interface language.

Model output is not in the interface language. With the UI in Chinese and an English answer, text-emphasis: filled dot applies to Latin text, which marks every letter — and a Chinese interface reading English technical content is one of the more common combinations here. The mirror case is the one the rule exists for: with the UI in English and a Chinese answer, the emphasis stays a synthetic shear of PingFang, unfixed.

Keying on content rather than chrome would mean a lang on the rendered markdown subtree derived from the turn's own text, or a heuristic on the run itself. Either is more than this change should carry, so this is a comment rather than a request — but the rule's own justification is about Han faces having no italic, and that is a property of the text, not of the menu language.

P3 — code inherits the prose leading, against the comment beside it

styles.css:418-420 puts line-height: 1.6 on .astryx-markdown, and the comment says headings and code keep the leading their own role tokens declare. Headings do; code does not establish one, so inline and fenced code both inherit 1.6 — 14px fenced code moves 20.0004 → 22.4px. Either restore --text-code-leading at the code boundary or scope the 1.6 to prose containers.

One note on the test evidence

markdown-rhythm-contract is not vacuous — changing an existing padding-block: 0 to 1px fails it — but it does not cover this rewrite: renaming the new h3 selector to [data-level="30"] leaves the suite green. That matches the file's own statement that heading tiers are deliberately unpinned, so it is a boundary rather than a defect; it just means the suite passing is not evidence for the new h3 rung.

label and test are terminal green on this head.

简体中文

1b228f5788e972cdc8d1bd11c85b054b20502f26 上批准。没有 P0/P1——两条 P2、一条 P3,都不阻塞。

诊断是这个 PR 最强的部分。 在动手之前先把渲染结果和候选字体逐像素比对、从而排除字体,是把「中文看起来发虚」这句话拆成三个可分别处理的决定的关键。标题选择器在六个级别上互斥且无遗漏,实测值也和描述一致。

P2:移除 smoothing 是空操作,三项修复里的第三项根本没落地。 maka-tokens.css:1410-1413 删掉了 Maka 在 body 上的 smoothing,但应用仍然导入 Astryx 的 reset,而后者在 :where(body) 上照样设置 -webkit-font-smoothing: antialiased。在 base 和 head 上分别实测,body、中文段落、标题和 code 的计算值都仍是 antialiased。描述里说要恢复的平台描边并没有回来。

这次测量有用的地方在于它两个方向都排除了:这次删除也不是全局回归——它什么都没改变。这个 PR 点名的三个决定里,行高和标题梯级落地了,平滑没有。如果目标就是汉字,需要在中文正文根上覆盖 reset 的计算值,而不是删掉一条重复声明。

证据边界:上面的计算值来自在两个 commit 的 Storybook 构建上跑的 Playwright。我只核了静态那一半——这个 diff 之后仓库里已经没有任何地方声明 font-smoothing,所以仍然算出 antialiased 的来源必然在依赖里。

P2:em:lang(zh) 跟的是界面语言,不是文字本身的语言。 syncUiLocaleDocument 只在 documentElement 上写 lang,值就是解析后的 UiLocale('zh''en')。markdown 内容自己不带 lang,所以一个 turn 里所有 em 继承的都是界面语言。

模型的输出并不使用界面语言。 界面是中文、回答是英文时,text-emphasis: filled dot 会作用在拉丁文本上,给每一个字母都加点——而「中文界面读英文技术内容」恰恰是这里相当常见的组合。反过来那一半正是这条规则存在的理由:界面是英文、回答是中文时,着重仍然是 PingFang 的合成倾斜,没被修好。

要按内容而不是按界面来判断,意味着给渲染出的 markdown 子树按这段文字本身推导一个 lang,或者对这次运行做启发式判断。两者都超出这次改动该承担的范围,所以这条是意见而非要求——但这条规则自己给的理由是「汉字字体没有斜体」,那是文字的属性,不是菜单语言的属性。

P3:code 继承了正文行高,和紧挨着它的注释相反。 styles.css:418-420line-height: 1.6 放在 .astryx-markdown 上,而注释说标题和 code 保持各自角色 token 声明的行高。标题确实如此;code 并没有建立自己的行高,于是行内和围栏代码都继承了 1.6——14px 围栏代码从 20.0004px 变成 22.4px。要么在 code 边界恢复 --text-code-leading,要么把 1.6 限定在正文容器上。

关于测试证据的一点说明。 markdown-rhythm-contract 不是空转(把既有的 padding-block: 0 改成 1px 会挂),但它不覆盖这次的选择器重写:把新的 h3 选择器改名成 [data-level="30"],整套仍然全绿。这和该文件自己声明「标题分档刻意不钉」是一致的,所以这是边界而不是缺陷——只是意味着这套测试通过并不能作为新 h3 分档的证据

这个 head 上 labeltest 已终态通过。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

Chat-first clients (Claude Code, opencode, Lody) keep h2 within 1.14× of
body and h3 at body size, and the transcript scale was flattened for the
same reason: a model emits `##` and `###` every few lines, and bigger
rungs cut an answer into slabs. So h1/h2 go back to 16px. h3 still cannot
be a body-size bold line in CJK — PingFang's Regular and Semibold are too
close for it to read as anything but a bold sentence — so it steps down in
ink and joins the muted tier with h4-h6 rather than stepping up in size.

Generated-by: Claude Code
…t's script

Review on apache#4617 measured that deleting Maka's body smoothing changed
nothing: Astryx's reset still sets antialiased on :where(body), so the
body role rule now declares auto explicitly. It also showed em:lang(zh)
follows the UI locale, which says nothing about what the model wrote —
a Chinese interface reading an English answer dotted every letter, and an
English interface reading Chinese kept the sheared italic. MarkdownBody
now marks its root with data-maka-script="han" when the prose (code
excluded) contains Han, and the emphasis rule keys on that instead.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Addressed in d3cb718 (and the heading change in 5d40074, which landed between your head and this one).

P2 smoothing — confirmed, fixed at the body role rule. @astryxdesign/core/src/reset.css sets antialiased on :where(body) under @layer reset, so deleting Maka's copy left the computed value untouched. The body rule in maka-tokens.css now declares auto explicitly; measured on the rebuilt story, body, paragraphs and inline code compute auto. Fenced code still computes antialiased because Astryx's CodeBlock sets it on its own pre; that surface is Latin-first and I left it alone. Kept it document-wide rather than on the CJK prose root: Maka is CJK-first, and the reset's value was never a product decision.

P2 em:lang(zh) — confirmed, re-keyed on the text. Astryx renders <em> directly with no component slot, so per-run detection is not available; the granularity is the turn. MarkdownBody now sets data-maka-script="han" on its root when the source outside fences and inline code contains Han, and the emphasis rule matches that attribute instead of :lang. A Chinese UI reading an English answer keeps its italic; an English UI reading a Chinese answer gets the dots. markdown-han-script.test.tsx pins the detection and the marker.

P3 code leading — half confirmed, comment fixed, no CSS change. Inline code does inherit 22.4px. Fenced code does not: pre computes 19px on both main and this branch (CodeBlock declares its own), so the 20 → 22.4 movement you describe for fenced code does not occur. Inline code following the line it sits in is the intended behaviour — a different leading on the inline run makes that line's box uneven — so the comment now says exactly that rather than claiming code keeps a role leading.

Test evidence note — agreed, no action. The heading tiers stay unpinned per the file's own statement.

@Astro-Han
Astro-Han merged commit 726fb80 into apache:main Sep 3, 2026
1 check passed
@Astro-Han
Astro-Han deleted the fix/markdown-cjk-prose-typography branch September 3, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/S Under 100 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants