diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 26bc77ad2..ea3fed71a 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CoreProvider } from "@multica/core/platform"; -import { pickLocale } from "@multica/core/i18n"; +import { pickLocale, type SupportedLocale } from "@multica/core/i18n"; import { useAuthStore } from "@multica/core/auth"; import { useWelcomeStore } from "@multica/core/onboarding"; import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries"; @@ -21,6 +21,18 @@ import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge"; import { createDesktopLocaleAdapter } from "./platform/i18n-adapter"; import { RESOURCES } from "@multica/views/locales"; +// BCP-47 region tags for the attribute, mirroring +// apps/web/app/layout.tsx HTML_LANG. index.html ships a static lang="en"; +// we sync it to the resolved locale at boot so screen readers announce the +// right language AND the Japanese-scoped CJK font override in globals.css +// (`html[lang|="ja"]`) can take effect. +const HTML_LANG: Record = { + en: "en", + "zh-Hans": "zh-CN", + ko: "ko-KR", + ja: "ja-JP", +}; + function AppContent() { const user = useAuthStore((s) => s.user); @@ -303,6 +315,15 @@ export default function App() { [locale], ); + // Keep in sync with the resolved locale (index.html hardcodes + // "en"). Drives the lang-scoped Japanese CJK font override and a11y. + // useLayoutEffect (not useEffect) so lang is committed before the first + // paint — otherwise Japanese users would see one frame of Kanji rendered + // with the Chinese-first fallback stack before the override kicks in. + useLayoutEffect(() => { + document.documentElement.lang = HTML_LANG[locale]; + }, [locale]); + // React to OS-level language changes detected by main on focus regain. // Only act when the user is following the system signal (no explicit // Settings choice) — otherwise their preference wins. Cross-device sync diff --git a/apps/desktop/src/renderer/src/font-fallback-order.test.ts b/apps/desktop/src/renderer/src/font-fallback-order.test.ts index 4e7499597..b48fc7918 100644 --- a/apps/desktop/src/renderer/src/font-fallback-order.test.ts +++ b/apps/desktop/src/renderer/src/font-fallback-order.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"]; const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"]; +const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"]; function expectChineseFontsBeforeKoreanFonts(source: string) { const chineseIndexes = chineseFonts.map((font) => source.indexOf(font)); @@ -19,6 +20,23 @@ function expectChineseFontsBeforeKoreanFonts(source: string) { } } +// Japanese Kanji share the Han Unicode block with Chinese, so the global +// Chinese-first stack must stay Chinese-first (no zh regression) while a +// Japanese-first CJK stack is scoped to html[lang|="ja"]. App.tsx syncs +// document.documentElement.lang so the selector matches at runtime. +function expectJapaneseScopedOverride(source: string) { + expect(source).toContain('html[lang|="ja"]'); + + const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font)); + expect(japaneseIndexes).not.toContain(-1); + + const firstJapanese = Math.min(...japaneseIndexes); + const lastChinese = Math.max( + ...chineseFonts.map((font) => source.lastIndexOf(font)), + ); + expect(firstJapanese).toBeLessThan(lastChinese); +} + describe("CJK font fallback order", () => { it("keeps desktop Chinese font fallbacks before Korean font fallbacks", () => { const desktopCss = readFileSync( @@ -28,4 +46,13 @@ describe("CJK font fallback order", () => { expectChineseFontsBeforeKoreanFonts(desktopCss); }); + + it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => { + const desktopCss = readFileSync( + resolve(process.cwd(), "src/renderer/src/globals.css"), + "utf8", + ); + + expectJapaneseScopedOverride(desktopCss); + }); }); diff --git a/apps/desktop/src/renderer/src/globals.css b/apps/desktop/src/renderer/src/globals.css index c170deb2c..6496a738d 100644 --- a/apps/desktop/src/renderer/src/globals.css +++ b/apps/desktop/src/renderer/src/globals.css @@ -31,6 +31,26 @@ monospace; } +/* Japanese-scoped CJK override. Japanese Kanji share the Han Unicode block + with Chinese, and CSS font-fallback order is not changed by — + so the global Chinese-first stack above would give Japanese users Chinese + glyph shapes for shared ideographs. We keep the global stack Chinese-first + (no regression for zh users) and promote Japanese fonts ahead of the + Chinese/Korean families only when the locale is Japanese. App.tsx syncs + document.documentElement.lang to the active locale so this selector + matches. Mirrors the lang-scoped override in apps/web/app/layout.tsx. + `[lang|="ja"]` is the BCP-47 language-range selector: it matches exactly + `ja` or `ja-` (App.tsx sets `ja-JP`), never unrelated subtags + such as `jam`. */ +html[lang|="ja"] { + --font-sans: "Inter Variable", "Inter", "Hiragino Sans", + "Hiragino Kaku Gothic ProN", "Yu Gothic", "YuGothic", "Meiryo", + "Noto Sans CJK JP", "Noto Sans JP", -apple-system, + BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", + "Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic", + "Noto Sans CJK KR", sans-serif; +} + @source "../../../../../packages/ui/**/*.tsx"; @source "../../../../../packages/core/**/*.{ts,tsx}"; @source "../../../../../packages/views/**/*.{ts,tsx}"; diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index a34b120c9..ebee47a9c 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -11,21 +11,13 @@ import { i18n, type Lang } from "@/lib/i18n"; import { uiTranslations, localeLabels } from "@/lib/translations"; import { DocsSettings } from "@/components/docs-settings"; +// Inter (Latin UI face) is exposed under `--font-inter`. The full `--font-sans` +// stack — Inter + the per-locale CJK fallback chain, including the Japanese-first +// override scoped to `` — is composed in static CSS in +// ./global.css (CSP-safe, no inline