From 942b2d15cd510930b5bc56551f770037d352ff84 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 19:29:29 +0900 Subject: [PATCH] feat(ui): bundle Noto Sans JP locally + UI font picker with system fonts - Bundles @fontsource/noto-sans-jp and @fontsource/inter via npm so we don't need to relax CSP to allow Google Fonts CDN. - New src/lib/fontFamily.ts with localStorage-backed preset stacks (auto / notoSansJp / yuGothic / meiryo / hiragino / custom) and querySystemFonts() via the Web Local Font Access API. - tokens.css exposes --ol-font-sans-active so the runtime override can swap the body font without a CSS rebuild. - Adds a "UI font" row to PersonalizeSection in SettingsModal: select with presets, "Load all fonts" button (one-shot user-gesture-gated permission prompt), free-form custom name, error reporting. - Default cascade in tokens.css now puts Noto Sans JP before any CJK fallback, so Japanese hosts no longer get rendered with Microsoft YaHei (zh-Hans glyph variants). Also adds a "Quiet mode" toggle in the same section: when on, the capsule suppresses transient labels (translating / processing / "N characters inserted" / cancelled). Errors stay visible. quietMode.ts holds the small localStorage helper + 'ol:quiet-mode-changed' event. Note: i18n strings here include keys for follow-up PRs (translation toggle, custom polish styles, per-app overrides). They are scoped to the new sections in those PRs and are no-ops on this branch alone. --- openless-all/app/package-lock.json | 27 ++++ openless-all/app/package.json | 2 + .../app/src/components/SettingsModal.tsx | 149 ++++++++++++++++++ openless-all/app/src/i18n/en.ts | 52 ++++++ openless-all/app/src/i18n/ja.ts | 56 ++++++- openless-all/app/src/i18n/ko.ts | 52 ++++++ openless-all/app/src/i18n/zh-CN.ts | 52 ++++++ openless-all/app/src/i18n/zh-TW.ts | 52 ++++++ openless-all/app/src/lib/fontFamily.ts | 140 ++++++++++++++++ openless-all/app/src/lib/quietMode.ts | 20 +++ openless-all/app/src/main.tsx | 10 ++ openless-all/app/src/styles/tokens.css | 15 +- 12 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 openless-all/app/src/lib/fontFamily.ts create mode 100644 openless-all/app/src/lib/quietMode.ts diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 4f9b7c9f..fd8f7241 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -8,6 +8,8 @@ "name": "openless-app", "version": "1.2.24-4", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/noto-sans-jp": "^5.2.9", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.1", @@ -59,6 +61,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -710,6 +713,24 @@ "node": ">=12" } }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/noto-sans-jp": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz", + "integrity": "sha512-6yVGiofnrnbiJjU8ch4JGSs2HqyozxMvsVyVmFwOnDH3u//qQKLqmKM4n0lqN0637uaJNzUW9nIJqbbMgHVQzw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1445,6 +1466,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1514,6 +1536,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1690,6 +1713,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -1839,6 +1863,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1976,6 +2001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2030,6 +2056,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4f2e75f4..5339c1ba 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -11,6 +11,8 @@ "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" }, "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/noto-sans-jp": "^5.2.9", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 62e98e85..ecab61d2 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -10,6 +10,14 @@ import { Icon } from './Icon'; import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings'; import { Row } from './ui/Row'; import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale'; +import { + type FontFamilyId, + readFontFamily, + readFontFamilyCustom, + setFontFamily, + querySystemFonts, +} from '../lib/fontFamily'; +import { readQuietCompletion, setQuietCompletion } from '../lib/quietMode'; import { exportErrorLog, fetchLatestBetaRelease, @@ -222,6 +230,17 @@ function PersonalizeSection() { ['large', t('modal.personalize.fontLarge')], ]; + // UI フォント(PolishMode の出力スタイルではなく、アプリ内 UI のフォントファミリ)。 + // localStorage キーは fontFamily.ts 側で管理(`ol-font-family` / `ol-font-family-custom`)。 + const [fontFamilyId, setFontFamilyIdState] = useState(() => readFontFamily()); + const [fontFamilyCustom, setFontFamilyCustomState] = useState(() => readFontFamilyCustom()); + const [systemFonts, setSystemFonts] = useState([]); + const [systemFontsLoading, setSystemFontsLoading] = useState(false); + const [systemFontsError, setSystemFontsError] = useState(null); + + // サイレントモード — Capsule のテキストオーバーレイ抑制。`ol-quiet-completion` をそのまま使う。 + const [quietMode, setQuietModeState] = useState(() => readQuietCompletion()); + return (
@@ -257,6 +276,136 @@ function PersonalizeSection() { })}
+ +
+
+ + +
+ {fontFamilyId === 'custom' && ( + { + const name = e.target.value; + setFontFamilyCustomState(name); + setFontFamily('custom', name); + }} + placeholder={t('modal.personalize.fontFamilyCustomPlaceholder')} + style={{ + padding: '6px 10px', + borderRadius: 8, + border: '0.5px solid var(--ol-line-strong)', + background: 'var(--ol-surface)', + color: 'var(--ol-ink)', + fontFamily: 'inherit', + fontSize: 12.5, + width: '100%', + maxWidth: 360, + }} + /> + )} + {systemFontsError && ( +
+ {systemFontsError} +
+ )} +
+
+ + +
, string> = { + notoSansJp: "'Noto Sans JP', sans-serif", + yuGothic: "'Yu Gothic UI', 'Yu Gothic', 'Meiryo', sans-serif", + meiryo: "'Meiryo', sans-serif", + hiragino: "'Hiragino Sans', 'Hiragino Kaku Gothic ProN', sans-serif", +}; + +const FALLBACK_TAIL = "'Microsoft YaHei', system-ui, sans-serif"; + +const FONT_FAMILY_KEY = 'ol-font-family'; +const FONT_FAMILY_CUSTOM_KEY = 'ol-font-family-custom'; + +function escapeFontName(name: string): string { + // Quote the family name; CSS allows unquoted single-word names but anything + // with spaces or punctuation must be quoted. Always quote to keep things + // predictable. Replace any embedded single quotes defensively. + return `'${name.replace(/'/g, '\\\'')}'`; +} + +export function readFontFamily(): FontFamilyId { + try { + const v = window.localStorage.getItem(FONT_FAMILY_KEY); + if ( + v === 'auto' || + v === 'notoSansJp' || + v === 'yuGothic' || + v === 'meiryo' || + v === 'hiragino' || + v === 'custom' + ) { + return v; + } + } catch { + /* localStorage unavailable: ignore */ + } + return 'auto'; +} + +export function readFontFamilyCustom(): string { + try { + return window.localStorage.getItem(FONT_FAMILY_CUSTOM_KEY) ?? ''; + } catch { + return ''; + } +} + +export function applyFontFamily(id: FontFamilyId, customName?: string): void { + const root = document.documentElement; + if (id === 'auto') { + root.style.removeProperty('--ol-font-sans-active'); + return; + } + if (id === 'custom') { + const name = (customName ?? '').trim(); + if (!name) { + root.style.removeProperty('--ol-font-sans-active'); + return; + } + root.style.setProperty( + '--ol-font-sans-active', + `'Inter', ${escapeFontName(name)}, ${FALLBACK_TAIL}`, + ); + return; + } + const stack = PRESET_STACKS[id]; + root.style.setProperty( + '--ol-font-sans-active', + `'Inter', ${stack}, ${FALLBACK_TAIL}`, + ); +} + +export function setFontFamily(id: FontFamilyId, customName?: string): void { + applyFontFamily(id, customName); + try { + window.localStorage.setItem(FONT_FAMILY_KEY, id); + if (id === 'custom' && customName !== undefined) { + window.localStorage.setItem(FONT_FAMILY_CUSTOM_KEY, customName); + } + } catch { + /* ignore */ + } +} + +/** + * Enumerate fonts installed on the host via the Web Local Font Access API. + * Returns unique family names sorted alphabetically. Returns an empty array + * (without throwing) when the API is unavailable or the user denied access; + * the caller should treat that as "feature not available" and keep the + * default presets. + * + * Must be called from a user gesture (button click) — Chromium gates this + * API behind a one-time permission prompt. + */ +export async function querySystemFonts(): Promise { + type LocalFont = { family: string }; + const w = window as unknown as { + queryLocalFonts?: () => Promise; + }; + if (typeof w.queryLocalFonts !== 'function') return []; + try { + const fonts = await w.queryLocalFonts(); + const families = new Set(); + for (const f of fonts) { + if (f.family) families.add(f.family); + } + return Array.from(families).sort((a, b) => + a.localeCompare(b, 'ja', { sensitivity: 'base' }), + ); + } catch (err) { + // Permission denied or API blocked — caller falls back to manual entry. + // eslint-disable-next-line no-console + console.warn('[fontFamily] queryLocalFonts failed:', err); + return []; + } +} + +// Apply the saved choice on module load so the override takes effect before +// the first paint. main.tsx imports this for its side effect. +applyFontFamily(readFontFamily(), readFontFamilyCustom()); diff --git a/openless-all/app/src/lib/quietMode.ts b/openless-all/app/src/lib/quietMode.ts new file mode 100644 index 00000000..8e4bb143 --- /dev/null +++ b/openless-all/app/src/lib/quietMode.ts @@ -0,0 +1,20 @@ +// Quiet mode 状態(capsule のテキストオーバーレイ抑制)の localStorage アクセスを集約。 +// Capsule.tsx と SettingsModal.tsx 双方から同じキー(ol-quiet-completion)を参照する。 + +const QUIET_COMPLETION_KEY = 'ol-quiet-completion'; + +export function readQuietCompletion(): boolean { + try { + return window.localStorage.getItem(QUIET_COMPLETION_KEY) === 'true'; + } catch { + return false; + } +} + +export function setQuietCompletion(value: boolean): void { + try { + window.localStorage.setItem(QUIET_COMPLETION_KEY, value ? 'true' : 'false'); + } catch { + /* localStorage unavailable: ignore */ + } +} diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 0c592ec5..0506df64 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -2,6 +2,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; import i18n from "./i18n"; // 副作用:触发 i18next init +import "./lib/fontFamily"; // 副作用:把保存された UIフォントの選択を初期適用 +// Bundle web fonts locally so they load without network access (CSP-safe) +import "@fontsource/inter/400.css"; +import "@fontsource/inter/500.css"; +import "@fontsource/inter/600.css"; +import "@fontsource/inter/700.css"; +import "@fontsource/noto-sans-jp/400.css"; +import "@fontsource/noto-sans-jp/500.css"; +import "@fontsource/noto-sans-jp/600.css"; +import "@fontsource/noto-sans-jp/700.css"; import "./styles/tokens.css"; import "./styles/global.css"; diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index da26024f..2138f7b8 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -1,7 +1,5 @@ /* OpenLess design tokens — black + white + electric blue accent, glassy */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); - :root { /* Palette — neutrals */ --ol-white: #ffffff; @@ -51,8 +49,12 @@ --ol-r-2xl: 22px; --ol-r-pill: 999px; - /* Typography */ - --ol-font-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + /* Typography + ja: prefer Genshin Gothic (bundled) and Windows/Mac native Japanese fonts + before falling back to Chinese fonts; this prevents Japanese text from + being rendered with Chinese glyphs (e.g. "直" "骨" "户" shape variants) + on Windows when Microsoft YaHei would otherwise be selected. */ + --ol-font-sans: 'Inter', 'Noto Sans JP', 'Genshin Gothic', 'Yu Gothic UI', 'Hiragino Sans', 'Meiryo', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --ol-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', 'Consolas', monospace; /* Status colors (sparse use only) */ @@ -67,7 +69,10 @@ body { margin: 0; - font-family: var(--ol-font-sans); + /* `--ol-font-sans-active` is set by src/lib/fontFamily.ts when the user + picks a non-default font in Style settings. Falls back to the default + cascade when unset. */ + font-family: var(--ol-font-sans-active, var(--ol-font-sans)); color: var(--ol-ink); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;