Skip to content

fix(typography): bundle Noto Sans JP by default for real Japanese medium weight - #449

Open
interacsean wants to merge 12 commits into
mainfrom
fix/typography/1397-japanese-medium-weight
Open

fix(typography): bundle Noto Sans JP by default for real Japanese medium weight#449
interacsean wants to merge 12 commits into
mainfrom
fix/typography/1397-japanese-medium-weight

Conversation

@interacsean

@interacsean interacsean commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes the Japanese font-medium bug reported in
tailor-professional-service/knowledge#219,
tracked at tailor-inc/platform-planning#1397.

The bug

Inter carries no CJK glyphs, so Japanese text falls through to the OS font — and system Japanese fonts have no 500 weight. Yu Gothic UI on Windows ships only Light/Semilight/Regular/Semibold/Bold, so CSS weight matching resolves font-weight: 500 down to Regular. font-medium accounts for 71 of the 91 weight utilities in core (labels, table cells, card titles), so most of the UI's weight hierarchy silently collapses in Japanese.

Reproduction status, stated plainly: verified macOS 15 is unaffected — it ships Hiragino W0–W9 including W5, and measured ink deltas at 500/600/700 are +15.0%/+27.8%/+42.9%, i.e. a real medium. The Windows failure is inferred, not measured: no Windows machine was available, so it rests on Yu Gothic UI's documented face inventory plus the CSS Fonts 4 matching rule. The original report's claim that Hiragino lacks W5 is outdated for current macOS. The reporter's OS is not stated in the discussion. So the bug is real as reported but has not been reproduced here on any platform — worth confirming before release.

Either way no system-font stack can guarantee the weight, which is what the fix addresses.

What changed

Noto Sans JP Variable ships in @tailor-platform/app-shell/styles. Continuous 100–900 axis, so every weight the design system names is real by construction. No opt-in import.

--app-shell-font-sans replaces the whole stack. --font-sans previously compiled to a literal (body{font-family:Inter Variable,…}), so consumers could not change it at all. It now compiles to var(--app-shell-font-sans, <default>).

Note the name: under prefix(astw) Tailwind emits this theme key as --astw-font-sans, so calling the override that produced the self-referential --astw-font-sans: var(--astw-font-sans, …) — invalid at computed-value time, working only by falling through to the var() fallback. Unprefixed --font-sans is unavailable too, since an unprefixed Tailwind in the consuming app defines it and would silently win.

Metric harmonisation. Shipping the font alone would regress mixed Japanese/Latin strings. Descriptors were measured in Chromium, not derived from font tables:

value
Inter cap height / x-height 0.727em / 0.545em
Inter fontBox ascent + descent 0.97 + 0.24
Noto kanji / kana height 0.795em / 0.794em
Noto fontBox ascent + descent 1.16 + 0.29
  • size-adjust: 94% — Noto's kanji run 1.09× Inter's cap height, oversized next to Inter's large x-height. Chosen from a rendered sweep; above ~96% the Japanese dominates, below ~90% it looks undersized.
  • ascent-override: 103.2% / descent-override: 25.5% / line-gap-override: 0% — pre-divided by the size-adjust, because size-adjust also scales the override percentages (97 × 0.94 = 91, not 97). These land the face on Inter's actual 97%/24% box.

Metric-matched local() fallback, rendered during font-display: swap and for uncovered glyphs. Carries the same size-adjust: 94% as the Noto faces plus Inter's line box, so a subset arriving mid-scroll changes neither row heights nor advance widths. Weight is bucketed across four faces (100–449 → Hiragino W3, 450–549 → W5, 550–649 → W6, 650–900 → W7) so that 500 lands on a real medium; W0–W9 verified present and distinct on macOS 15 by rasterised ink.

Both faces are clamped to Japanese codepoint blocks via one JP_BLOCKS constant — kana, kanji, CJK punctuation, fullwidth forms, and the compatibility blocks carrying ㈱ ㍿ ㎡, plus Extension B for name kanji. Upstream's ranges also cover arrows, dingbats and letterlike symbols shared with Latin; leaving those in meant a Latin-only app fetched a Japanese subset the first time it rendered the DataTable's boolean (U+2713). 116 of 124 subsets survive the clamp; the other 8 ship neither face nor file.

Vendoring: committed, matching Inter. inter.css and its 7 woff2 files are already vendored this way — copied verbatim from fontsource and committed, with no script and no devDependency in the build path. The 116 Noto subsets follow the same convention, and the URLs are written ./fonts/files/* so they resolve once Tailwind inlines the stylesheet into dist/app-shell-core.css.

This started out generating them at build time to keep ~5 MB of binaries out of git. That divergence didn't survive scrutiny:

  • The cost lands almost entirely on full clones (packed history 6.9 MB → ~12 MB). It is not paid by CI — actions/checkout defaults to fetch-depth: 1 and 12 of 13 workflows here are shallow, so historical blobs are never fetched. No effect on Actions minutes, the published package, or test time (nothing in the suite reads the fonts).
  • Against that, the build step was a real failure surface — it broke CI once on a lint rule — and a cold build now needs nothing but a checkout.

scripts/sync-noto-fonts.mjs survives as a manual re-vendoring tool (pnpm --filter @tailor-platform/app-shell vendor:fonts) rather than being deleted: clamping subsets to JP_BLOCKS and the fallback weight ladder are where four review defects lived, so re-deriving them by hand on the next bump is a trap worth avoiding. Since the committed stylesheet is now the artifact of record, its header carries the reasoning the script used to hold. Renovate is disabled for @fontsource-variable/** so bumps are deliberate — not because 5 MB is dangerous, but because 116 binary files a week for glyph tweaks is noise.

What it costs, and who pays

Because this is on by default, every consumer pays the build-output cost, including Latin-only apps:

before after
npm install, unpacked 1.15 MB / 39 files 6.25 MB / 159 files
git packed history 6.9 MB ~12 MB (full clones only)
consumer build output 177 KB (7 Inter subsets) ~5.0 MB (123 subsets)
app-shell-core.css raw 98 KB 200 KB (+101 KB)
app-shell-core.css gzipped 15 KB 45 KB (+30 KB)

A bundler emits every unicode-range subset it can see; it cannot know at build time which characters the data will contain, and ERP content is DB-driven. Verified with a clean build of both examples: 124 Noto files each.

End users download far less, because unicode-range defers subsetting to runtime. Measured in the browser, not estimated:

page Noto subsets fetched
Latin-only, including a boolean DataTable column 0 — Inter and the system font only
Japanese-heavy demo 27 of 116 (~1.1 MB)

Modelled against the real subset tables: ~237 KB for all kana, ~910 KB for a typical first Japanese screen. Weight costs nothing extra — every weight shares one file.

Upgrade impact

Japanese previously rendered in the OS font and now renders in Noto on every platform. This is a bug fix rather than a staged breaking change — the weight scale the package already documented was simply not true for Japanese — so there is no migration entry: an app that upgrades and does nothing gets the correct weights. The rendering change and the --app-shell-font-sans opt-out are described in the changeset, which reaches consumers as the changelog.

Japanese runs measure ~6% narrower. Narrower text can relieve truncation and wrapping but cannot cause it, so the risk is under-filled boxes and moved ellipsis positions, not overflow. Latin is unaffected; line boxes are unchanged.

Two findings worth knowing:

  • The shift is the size-adjust, not the typeface. Noto at 100% measures within 0.18% of Hiragino, because CJK glyphs sit on a full-width em grid. size-adjust: 100% would leave widths essentially untouched, at the cost of the optical match. This also bounds the Windows uncertainty: Yu Gothic UI is very likely ~1em/glyph too, so its delta should also be ≈ the size-adjust.
  • The missing weight was never a layout bug. Hiragino's 400 and 500 advance widths differ by 0.00–0.27% — weight changes ink, not width, on an em grid. So the bug being fixed is purely visual, and the layout change comes entirely from the font swap.

Measured across nine constrained shapes: 5 identical, button intrinsic width −8px, a sidebar label stops truncating, and two cases with widths deliberately tuned to the boundary drop a line (2→1, 3→2).

Verification

  • Weight ladder: Japanese 500 is +18.0% ink over 400, 600 +29.3%, 700 +40.1% — four distinct weights where there was one.
  • Layout-neutral: Latin-only, Japanese-only and mixed lines all measure 20px, against 23–25px unharmonised.
  • Cold build from a clean tree works (the CI path — generated assets are gitignored); sync is idempotent.
  • type-check, lint, test (1554 tests / 81 files), fmt, and the generated-skill drift check all clean.

For the reviewer

  • size-adjust: 94% is an aesthetic call, not a derived constant. The measured kanji/cap ratio of 1.0926 sits inside the conventional 1.05–1.10 band for Japanese/Latin mixing, so 100% is defensible — and would make this change nearly layout-neutral. One constant in sync-noto-fonts.mjs; the overrides derive from it. Worth a second opinion.
  • The Windows local() fallback names are unverified. local() matches PostScript/full names, never family names — local("Hiragino Sans") silently fails where local("HiraginoSans-W3") resolves. macOS names confirmed; YuGothicUI-Regular etc. follow the same convention but could not be checked here. A wrong name degrades to today's behaviour rather than breaking.
  • Per-script weight mapping is out of scope. Noto 500 and Inter 500 don't read as identical weight — CJK packs more stroke into the same area. Mapping one semantic token to per-script numerics changes the token layer and deserves its own PR.
  • Someone should open a Japanese screen on Windows before release, both to confirm the bug and to confirm the fix. That's the one gap the local evidence can't close. The Windows local() names in the fallback ladder (YuGothic-Medium, YuGothicUI-Semibold, YuGothicUI-Bold) are unverified from macOS; an unresolvable local() is skipped, so a wrong name degrades to the next entry rather than breaking.

Review history

An adversarial review of the first implementation raised 13 findings; 10 survived a hostile refutation panel and are fixed in 1508ccd1. The four substantive ones were all in the generated stylesheet: the fallback bucketed weight 500 onto Regular faces (reintroducing this PR's own bug, and regressing macOS, where the family pre-empted the cascade that already found Hiragino W5); the fallback omitted size-adjust, so only the vertical half of "metric-matched" was implemented; Noto's unicode-range was unclamped, so pulled a Japanese subset into Latin-only apps; and the fallback's range omitted 4,140 codepoints Noto covered. The size figures above were also wrong — 46 KB was the stylesheet total, reused as a delta.

🤖 Generated with Claude Code

Inter carries no CJK glyphs, so Japanese text fell through to the system
font. System Japanese fonts have no 500 weight — Yu Gothic UI on Windows
ships only Light/Semilight/Regular/Semibold/Bold — so CSS weight matching
resolved `font-weight: 500` down to Regular. `font-medium`, which accounts
for 71 of the 91 weight utilities in core, was invisible in Japanese.

Ship Noto Sans JP Variable (continuous 100-900 axis) behind a subpath
export, metric-harmonised against Inter so mixed Japanese/Latin strings
read at one optical size and a line containing Japanese is exactly as tall
as one without. The descriptors were measured in Chromium, not derived from
font tables: Inter cap 0.727em / fontBox 0.97+0.24, Noto kanji 0.795em /
fontBox 1.16+0.29. `size-adjust: 94%` came from a rendered sweep; the
ascent/descent overrides are pre-divided by it because size-adjust also
scales override percentages.

`--font-sans` now compiles to `var(--astw-font-sans, <default>)` rather than
a literal, which is what lets the opt-in stylesheet — or a consumer — swap
the stack without rebuilding the package.

The 5 MB of subsets are vendored from a pinned devDependency at build time
rather than committed, keeping the binaries out of git history.

Refs: tailor-inc/platform-planning#1397
Leaves nextjs-app deliberately not opted in, so the two builds demonstrate
both paths: vite-app emits 124 subsets / 4.98 MB, nextjs-app emits none.
Adds a Typography and Fonts section covering the default stack, the
--astw-font-sans override, the Japanese bundle and why it is opt-in, with
measured download figures. Notes the font-medium caveat in the catalogue
design-system source so the generated app-shell-patterns skill carries it.
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

API Design Review completed successfully!

API design review complete for PR #449. No High or Medium issues found. The changes are purely additive: a new opt-in subpath export (./fonts/noto-sans-jp) and a new CSS custom property override (--astw-font-sans). No TypeScript/React public API surface was modified. Verdict: Approve.

The section restated the whole Yu Gothic UI weight-inventory rationale that
docs/concepts/styling-theming.md already covers, at 17 lines against the
one-line house style of the caveats around it — two copies of the same
explanation, free to drift apart.

It also closed with advice to avoid weight-based hierarchy in Japanese and
reach for text-muted-foreground or a size step instead. An agent reading the
skill cannot tell whether the consuming app imported the font bundle, so it
would apply that defensively and strip weight hierarchy out of Japanese UI in
apps where the bundle is present and weights work correctly — degrading the
fixed case. Dropped.

What remains is the actionable part: the tokens need the bundle, here is the
import. Rationale stays in the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean
interacsean marked this pull request as ready for review August 18, 2026 04:42
@interacsean
interacsean requested a review from a team as a code owner August 18, 2026 04:42
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (de6ef76) #449 (ee6738f) +/-
Coverage 90.0% 90.0% 0.0%
Test Execution Time 2m4s 2m2s -2s
Details
  |                     | main (de6ef76) | #449 (ee6738f) | +/-  |
  |---------------------|----------------|----------------|------|
  | Coverage            |          90.0% |          90.0% | 0.0% |
  |   Files             |            126 |            126 |    0 |
  |   Lines             |           5174 |           5174 |    0 |
  |   Covered           |           4661 |           4661 |    0 |
+ | Test Execution Time |           2m4s |           2m2s |  -2s |

Code coverage of files in pull request scope (0.0% → 0.0%)

Files Coverage +/- Status
packages/core/package.json 0.0% 0.0% modified

Reported by octocov

@interacsean interacsean changed the title fix(typography): add opt-in Japanese font bundle for real medium weight fix(typography)!: bundle Noto Sans JP by default for real Japanese medium weight Aug 21, 2026
interacsean added a commit that referenced this pull request Aug 21, 2026
… to Japanese

Addresses an adversarial review of #449. Four defects, all in the generated
stylesheet, plus the claims that described them.

1. The local fallback bucketed weights `100 500` onto Regular faces only
   (HiraginoSans-W3, YuGothicUI-Regular), so `font-weight: 500` inside
   `AppShell JP Fallback` resolved to Regular — the exact bug this package
   fixes. Worse, the family sits ahead of `ui-sans-serif`, so on macOS it
   pre-empted the system cascade that already found Hiragino W5: a regression
   on the one platform that was working. Replaced with a four-bucket ladder
   (100-449 W3, 450-549 W5, 550-649 W6, 650-900 W7). Hiragino W0-W9 verified
   present and distinct on macOS 15 by rasterised ink; Yu Gothic (non-UI) is
   tried before Yu Gothic UI at 500 because only the former ships a Medium.

2. The fallback carried ascent/descent overrides but no `size-adjust`, while
   the Noto faces it stands in for carry 94%. Only the vertical half of
   "metric-matched" was implemented, so every Japanese run reflowed ~6%
   narrower when a subset landed. Both edits are needed together: 94% with the
   pre-divided 103.2%/25.5% lands on Inter's box, 94% with 97%/24% does not.

3. Noto's `unicode-range` reused upstream verbatim, covering arrows, dingbats,
   geometric shapes and letterlike symbols shared with Latin. The DataTable
   renders "✓" (U+2713) for every boolean cell, and only Noto covered it — so a
   Latin-only app fetched a Japanese subset on its first boolean column, and ✓
   and ✗ rendered from different fonts. Both faces are now clamped to Japanese
   blocks; 116 of 124 subsets survive and the other 8 ship neither face nor
   file, which also drops ~240 KB.

4. The fallback's `unicode-range` was a hand-written 7-range list while Noto's
   came from upstream, leaving 4,140 codepoints — ㈱ ㍿ ㎡ and all of CJK
   Extension B — with no metric-matched fallback. Both sides now derive from
   one `JP_BLOCKS` constant, so coverage cannot drift when the pinned upstream
   re-subsets.

Also corrects the numbers, which were wrong in three user-facing places: the
stylesheet grows 98 KB -> 200 KB raw and 15 KB -> 45 KB gzipped, not "160 KB
(46 KB gzipped)" — 46 KB was the total reused as a delta. And the claim that
system Japanese fonts have no 500 weight is now scoped to Windows, since
current macOS ships Hiragino W5 and the PR body already said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
interacsean and others added 4 commits August 21, 2026 14:27
Ships Noto Sans JP in `@tailor-platform/app-shell/styles` rather than behind
the `./fonts/noto-sans-jp` subpath, so `font-medium` is a real weight in
Japanese with no opt-in step. globals.css imports the generated stylesheet and
theme.bridge.css names both families in the default stack.

The subpath export is removed: it would now declare all 124 faces a second time
if imported alongside the styles it duplicates.

Renames the consumer override to `--app-shell-font-sans`. Under `prefix(astw)`
Tailwind emits this very theme key as `--astw-font-sans`, so the previous name
compiled to the self-referential `--astw-font-sans: var(--astw-font-sans, ...)`
— invalid at computed-value time, and only working by falling through to the
var() fallback. Unprefixed `--font-sans` is not available either: an unprefixed
Tailwind in the consuming app defines it and would silently win.

Generated URLs move from `./files/*` to `./fonts/files/*`. The stylesheet is now
inlined into dist/app-shell-core.css at the dist root rather than emitted at
dist/fonts/, so the old relative paths would not resolve.

Consequences, documented in docs/migrations.md and the changeset: Japanese now
renders in Noto on every platform instead of the OS font, measuring ~6% narrower
(relieves truncation, cannot cause it; Latin unaffected, line boxes unchanged).
Every consumer's build output gains ~5 MB of subsets and ~160 KB of stylesheet
(46 KB gzipped) whether or not they render Japanese. Verified both examples emit
124 subsets, that a Latin-only page fetches none of them, and that a
Japanese-heavy page fetches 27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… to Japanese

Addresses an adversarial review of #449. Four defects, all in the generated
stylesheet, plus the claims that described them.

1. The local fallback bucketed weights `100 500` onto Regular faces only
   (HiraginoSans-W3, YuGothicUI-Regular), so `font-weight: 500` inside
   `AppShell JP Fallback` resolved to Regular — the exact bug this package
   fixes. Worse, the family sits ahead of `ui-sans-serif`, so on macOS it
   pre-empted the system cascade that already found Hiragino W5: a regression
   on the one platform that was working. Replaced with a four-bucket ladder
   (100-449 W3, 450-549 W5, 550-649 W6, 650-900 W7). Hiragino W0-W9 verified
   present and distinct on macOS 15 by rasterised ink; Yu Gothic (non-UI) is
   tried before Yu Gothic UI at 500 because only the former ships a Medium.

2. The fallback carried ascent/descent overrides but no `size-adjust`, while
   the Noto faces it stands in for carry 94%. Only the vertical half of
   "metric-matched" was implemented, so every Japanese run reflowed ~6%
   narrower when a subset landed. Both edits are needed together: 94% with the
   pre-divided 103.2%/25.5% lands on Inter's box, 94% with 97%/24% does not.

3. Noto's `unicode-range` reused upstream verbatim, covering arrows, dingbats,
   geometric shapes and letterlike symbols shared with Latin. The DataTable
   renders "✓" (U+2713) for every boolean cell, and only Noto covered it — so a
   Latin-only app fetched a Japanese subset on its first boolean column, and ✓
   and ✗ rendered from different fonts. Both faces are now clamped to Japanese
   blocks; 116 of 124 subsets survive and the other 8 ship neither face nor
   file, which also drops ~240 KB.

4. The fallback's `unicode-range` was a hand-written 7-range list while Noto's
   came from upstream, leaving 4,140 codepoints — ㈱ ㍿ ㎡ and all of CJK
   Extension B — with no metric-matched fallback. Both sides now derive from
   one `JP_BLOCKS` constant, so coverage cannot drift when the pinned upstream
   re-subsets.

Also corrects the numbers, which were wrong in three user-facing places: the
stylesheet grows 98 KB -> 200 KB raw and 15 KB -> 45 KB gzipped, not "160 KB
(46 KB gzipped)" — 46 KB was the total reused as a delta. And the claim that
system Japanese fonts have no 500 weight is now scoped to Windows, since
current macOS ships Hiragino W5 and the PR body already said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oxlint's unicorn/no-array-sort rejects Array#sort() because it mutates in place;
packages/core lint failed in CI on the two sort calls added with the range
algebra. Semantically identical — both call sites discard the pre-sort order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is a bug fix, not a staged breaking change: the weight scale the package
already documented was simply not true for Japanese. docs/migrations.md is
explicitly for changes that require editing an application, and nothing here
does — an app that upgrades and does nothing gets the correct weights.

The rendering change and the --app-shell-font-sans opt-out stay described in the
changeset, which is what reaches consumers as the changelog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean
interacsean force-pushed the fix/typography/1397-japanese-medium-weight branch from d14962a to 3d2e85d Compare August 21, 2026 04:28
@interacsean interacsean changed the title fix(typography)!: bundle Noto Sans JP by default for real Japanese medium weight fix(typography): bundle Noto Sans JP by default for real Japanese medium weight Aug 21, 2026
…ting them

Matches how Inter is vendored. inter.css and its 7 woff2 files are committed
verbatim from fontsource with no script and no devDependency in the build path;
generating Noto at build time diverged from that for no benefit that survived
scrutiny.

The cost of committing is ~5 MB in .git, paid by full clones. It is not paid by
CI: actions/checkout defaults to fetch-depth 1, and 12 of 13 workflows here are
shallow, so historical blobs are never fetched. It does not affect Actions
minutes, the published package, or test time — nothing in the suite reads the
fonts. What it buys is the removal of a build step that had already broken CI
once, plus a cold build that needs nothing but a checkout.

scripts/sync-noto-fonts.mjs stays as a manual re-vendoring tool rather than
being deleted: clamping every subset to JP_BLOCKS and the fallback weight ladder
are where four review defects lived, and re-deriving them by hand on the next
bump would be a good way to reintroduce them. It is out of `build` and `dev`,
reachable as `pnpm --filter @tailor-platform/app-shell vendor:fonts`.

Since the committed stylesheet is now the artifact of record, its header carries
the reasoning the script used to hold: why size-adjust is 94% and that it is an
aesthetic call, why the ascent/descent overrides are pre-divided, why the
fallback is bucketed per weight, and why both families are clamped to Japanese.

Renovate is disabled for @fontsource-variable/** — not because 5 MB is
dangerous, but because a weekly PR shuffling 116 binary files for glyph tweaks
nobody requested is review noise. Bumps become deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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