shell: the panel, createPanel(site) (moved from wayBetter for X)
packages/shell/src/panel (waybetter/shell/panel): an app's own tab. An app describes itself (PanelSite: brand and the home page's words, its own sections and their order, its theme store, catalog and the preview component a theme document is drawn in, Make's examples and docs, the install controller, its languages, what its native side serves) and createPanel returns the component for ShellSite.panel. It is in the shell package because it is drawn in the frame and shares its stores, native bridge and language. - Panel.tsx (header, settings search over every settings section, navigation, toasts; Accounts and Updates), nav.ts (which built-in sections an app gets: each shows only when the app serves what it needs; the app's sections in its order), Home.tsx (hero, highlights, About from the app's data), controls.tsx + panel.css. - Appearance.tsx, ThemeBuilder.tsx + builderModel.ts + builder.css, fonts/ (FontBrowser, FontRoles, catalog, the offline Google Fonts list; scripts/google-fonts-catalog.mjs regenerates it): the theme documents are the runtime's, the store, catalog, accents, draft palette, file suffix and preview are the app's (PanelSite.themes). - Plugins.tsx (the plugins list and custom CSS over the host's plugins_*/custom_css_*), StoreSection.tsx, MakePanel.tsx + makeModel.ts + MarkdownDoc.tsx + markdown.ts + make.css (the examples, the theme file and the docs are the app's), Settings.tsx (links and session over settings_get/set with `siteLinks`, keyword alerts, the media panel), FeatureToggles.tsx (useFeatures + FeatureGroups over features_get/set: the app names its flags and groups), PowerSettings.tsx (deck defaults from site.deck: its flags and presets; DeckPreset gets `name` and `hint`), BackupPanel.tsx + backup.css, DeveloperPanel.tsx, LanguageSettings.tsx, compare.ts. - frame: language.ts (createLanguage: follow the site / the system / one language, i18n_set; moved from the app), appI18n.ts (createAppI18n: the app's typed i18n instance, useT, dates and numbers; the app passes React's useSyncExternalStore), formatDate in i18n.ts (ShellI18n.date). - tests/panel.test.ts: every panel key has English and is used, sections per app, the builder model, the font catalog, plugin header rules, the UI kit demos, Markdown, compare. - npm package: exports ./shell/panel, ./shell/panel/*, ./shell/panel/*.css; the build is scripts/build.mjs (the shell is compiled with the runtime sources it imports under one root and its imports are pointed at dist/runtime; stylesheets and the font list are copied).
This commit is contained in:
parent
6ef78c0c30
commit
00b193506f
44 changed files with 4864 additions and 2 deletions
236
packages/shell/src/panel/fonts/FontBrowser.tsx
Normal file
236
packages/shell/src/panel/fonts/FontBrowser.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// The font browser: every Google Fonts family (offline catalog) with live previews rendered in the
|
||||
// font itself, category filters and search, a detail view with editable sample text and weight
|
||||
// selection, plus the built-in and system fonts. Google fonts are downloaded natively once (woff2
|
||||
// from fonts.googleapis.com / fonts.gstatic.com into app data), then the site's page loads them from bytes.
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FONT_CHOICES, fontStack, sanitizeFonts } from '../../../../runtime/src/themeSpec.ts';
|
||||
import { invoke } from '../../frame/bridge.ts';
|
||||
import { useT } from '../../frame/hooks.ts';
|
||||
import { tx } from '../../frame/i18n.ts';
|
||||
import { Button, Segmented, Slider } from '../controls.tsx';
|
||||
import { panelThemes } from '../site.ts';
|
||||
import { themeStore, useThemeState } from '../themes.ts';
|
||||
import {
|
||||
CATEGORIES, availableWeights, bytesLabel, defaultWeights, loadCatalog, searchFonts, specimenUrl, stackFor, weightsLabel,
|
||||
type CatalogFont, type Category, type Weights,
|
||||
} from './catalog.ts';
|
||||
|
||||
export type FontPick = { family: string[]; source?: 'google' };
|
||||
const PAGE = 60;
|
||||
const SAMPLES = [
|
||||
'The quick brown fox jumps over the lazy dog',
|
||||
'Your timeline, your way. 1,284 likes · 3.2K reposts',
|
||||
'Pijamalı hasta yağız şoföre çabucak güvendi',
|
||||
];
|
||||
|
||||
/** Renders `text` in `font`: the installed family, else a tiny text-subset preview fetched natively. */
|
||||
function FontText({ font, text, weight, className, eager }: { font: CatalogFont; text: string; weight?: number; className?: string; eager?: boolean }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [family, setFamily] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(!!eager);
|
||||
const { ensureShellFont, isInstalled, previewFont } = themeStore();
|
||||
const installed = isInstalled(font.family);
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
const node = ref.current;
|
||||
if (!node || typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
|
||||
const observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } }, { rootMargin: '120px' });
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
useEffect(() => {
|
||||
if (!visible || !text) return;
|
||||
let live = true;
|
||||
const timer = setTimeout(() => {
|
||||
const load = installed
|
||||
? ensureShellFont(font.family).then(ok => ok ? font.family : null)
|
||||
: previewFont(font.family, font.axis ? { kind: 'range', min: font.axis[0], max: font.axis[1] } : { kind: 'list', values: font.weights.slice(0, 9) }, [...new Set(text)].join('').slice(0, 120));
|
||||
void load.then(name => { if (live) setFamily(name); });
|
||||
}, eager ? 250 : 0);
|
||||
return () => { live = false; clearTimeout(timer); };
|
||||
}, [visible, text, font.family, installed]);
|
||||
return <span ref={ref} className={`${className ?? ''} ${family ? 'is-ready' : 'is-loading'}`}
|
||||
style={{ fontFamily: family ? `"${family}", ${stackFor(font).slice(1).join(', ')}` : undefined, fontWeight: weight }}>{text}</span>;
|
||||
}
|
||||
|
||||
export function FontBrowser({ role, current, onPick, onClose }: { role: string; current: string | null; onPick(pick: FontPick): void; onClose(): void }) {
|
||||
const t = useT();
|
||||
const { fonts: installedFonts } = useThemeState();
|
||||
const [tab, setTab] = useState<'google' | 'system'>('google');
|
||||
const [catalog, setCatalog] = useState<CatalogFont[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<Category | 'all'>('all');
|
||||
const [onlyInstalled, setOnlyInstalled] = useState(false);
|
||||
const [limit, setLimit] = useState(PAGE);
|
||||
const [selected, setSelected] = useState<string | null>(current);
|
||||
const [active, setActive] = useState(0);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const list = useRef<HTMLUListElement>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { void loadCatalog().then(setCatalog); search.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); } };
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
const installed = useMemo(() => new Set(installedFonts.map(font => font.family)), [installedFonts]);
|
||||
const results = useMemo(() => searchFonts(catalog, { query, category, only: onlyInstalled ? installed : undefined }), [catalog, query, category, onlyInstalled, installed]);
|
||||
useEffect(() => { setLimit(PAGE); setActive(0); list.current?.scrollTo({ top: 0 }); }, [query, category, onlyInstalled]);
|
||||
const shown = results.slice(0, limit);
|
||||
const detail = catalog.find(font => font.family === selected) ?? shown[0] ?? null;
|
||||
|
||||
const onListKey = (event: ReactKeyboardEvent) => {
|
||||
if (!shown.length) return;
|
||||
const move = (index: number) => {
|
||||
const next = Math.max(0, Math.min(shown.length - 1, index));
|
||||
setActive(next);
|
||||
list.current?.querySelectorAll('li')[next]?.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); move(active + 1); }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); if (active === 0 && event.target === list.current) search.current?.focus(); else move(active - 1); }
|
||||
else if (event.key === 'PageDown') { event.preventDefault(); move(active + 8); }
|
||||
else if (event.key === 'PageUp') { event.preventDefault(); move(active - 8); }
|
||||
else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(shown[active]!.family); }
|
||||
};
|
||||
|
||||
// Inside the panel (not the whole window): site panes are native webviews above the shell elsewhere.
|
||||
return createPortal(<div className="wb-sheet-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div ref={root} className="wb-sheet wb-fb" role="dialog" aria-modal="true" aria-label={t('wb.fonts.chooseFor', { role })}>
|
||||
<header className="wb-sheet-head">
|
||||
<div>
|
||||
<h2>{t('wb.fonts.choose')} <span>{t('wb.fonts.forRole', { role })}</span></h2>
|
||||
<p>{tab === 'google' ? t('wb.fonts.googleCount', { count: catalog.length || 1800 }) : t('wb.fonts.systemHint')}</p>
|
||||
</div>
|
||||
<Segmented<'google' | 'system'> label={t('wb.fonts.source')} size="sm" value={tab} onChange={setTab} options={[{ value: 'google', label: 'Google Fonts' }, { value: 'system', label: t('wb.fonts.builtIn') }]}/>
|
||||
<button type="button" className="wb-sheet-close" aria-label={t('wb.common.close')} onClick={onClose}><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="m3.5 3.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></button>
|
||||
</header>
|
||||
{tab === 'system' ? <SystemFonts current={current} onPick={onPick}/> : <>
|
||||
<div className="wb-fb-tools">
|
||||
<label className="bt-search wb-fb-search">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.75" fill="none" stroke="currentColor" strokeWidth="1.5"/><path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||||
<input ref={search} type="search" aria-label={t('wb.fonts.search')} placeholder={t('wb.fonts.searchPlaceholder')} value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); list.current?.focus(); setActive(0); }
|
||||
if (event.key === 'Enter' && shown[0]) { event.preventDefault(); setSelected(shown[0].family); }
|
||||
}}/>
|
||||
</label>
|
||||
<div className="wb-fb-cats" role="radiogroup" aria-label={t('wb.fonts.category')}>
|
||||
{CATEGORIES.map(item => <button key={item} type="button" role="radio" aria-checked={category === item} className={category === item ? 'is-on' : ''} onClick={() => setCategory(item)}>{tx(`wb.fonts.category.${item}`)}</button>)}
|
||||
<button type="button" role="checkbox" aria-checked={onlyInstalled} className={`wb-fb-installed ${onlyInstalled ? 'is-on' : ''}`} onClick={() => setOnlyInstalled(!onlyInstalled)}>
|
||||
{t('wb.fonts.downloaded')}{installed.size ? ` · ${installed.size}` : ''}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wb-fb-body">
|
||||
<ul ref={list} className="wb-fb-list" role="listbox" tabIndex={0} aria-label={t('wb.fonts.families')} aria-activedescendant={shown[active] ? `wb-fb-${active}` : undefined} onKeyDown={onListKey}>
|
||||
{!catalog.length && <li className="wb-fb-empty">{t('wb.fonts.loadingCatalog')}</li>}
|
||||
{catalog.length > 0 && !shown.length && <li className="wb-fb-empty">{t('wb.fonts.noMatch', { query })}</li>}
|
||||
{shown.map((font, index) => <li key={font.family} id={`wb-fb-${index}`} role="option" aria-selected={font.family === selected}
|
||||
className={`${index === active ? 'is-active' : ''} ${font.family === selected ? 'is-on' : ''}`}
|
||||
onPointerEnter={() => setActive(index)} onClick={() => { setActive(index); setSelected(font.family); }}>
|
||||
<FontText font={font} text={font.family} className="wb-fb-name"/>
|
||||
<span className="wb-fb-meta">{tx(`wb.fonts.categoryName.${font.category}`)} · {font.axis ? t('wb.fonts.variable') : t('wb.fonts.weightCount', { count: font.weights.length })}</span>
|
||||
{installed.has(font.family) && <span className="wb-fb-tag">{t('wb.fonts.downloaded')}</span>}
|
||||
{current === font.family && <span className="wb-fb-tag is-accent">{t('wb.fonts.inUse')}</span>}
|
||||
</li>)}
|
||||
{results.length > limit && <li className="wb-fb-more"><Button size="sm" tone="ghost" onClick={() => setLimit(limit + PAGE)}>{t('wb.fonts.showMore', { count: Math.min(PAGE, results.length - limit), total: results.length })}</Button></li>}
|
||||
</ul>
|
||||
<FontDetail key={detail?.family ?? 'none'} font={detail} role={role} onPick={onPick}/>
|
||||
</div>
|
||||
</>}
|
||||
<footer className="wb-sheet-foot">
|
||||
{t('wb.fonts.footer')}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.querySelector('.btp') ?? document.body);
|
||||
}
|
||||
|
||||
function FontDetail({ font, role, onPick }: { font: CatalogFont | null; role: string; onPick(pick: FontPick): void }) {
|
||||
const t = useT();
|
||||
const { installFont } = themeStore();
|
||||
const { fonts } = useThemeState();
|
||||
const [sample, setSample] = useState(SAMPLES[0]!);
|
||||
const [weight, setWeight] = useState(400);
|
||||
const [weights, setWeights] = useState<Weights | null>(font ? defaultWeights(font) : null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
if (!font) return;
|
||||
const available = availableWeights(font);
|
||||
setWeight(available.includes(400) ? 400 : available[0] ?? 400);
|
||||
}, [font?.family]);
|
||||
if (!font || !weights) return <div className="wb-fb-detail is-empty"><p>{t('wb.fonts.detailEmpty', { role })}</p></div>;
|
||||
const installed = fonts.find(item => item.family === font.family);
|
||||
const available = availableWeights(font);
|
||||
const pick = async () => {
|
||||
setError('');
|
||||
if (!installed || JSON.stringify(installed.weights) !== JSON.stringify(weights)) {
|
||||
setBusy(true);
|
||||
try { await installFont(font.family, weights); }
|
||||
catch (reason) { setBusy(false); setError(reason instanceof Error ? reason.message : String(reason)); return; }
|
||||
setBusy(false);
|
||||
}
|
||||
onPick({ family: stackFor(font), source: 'google' });
|
||||
};
|
||||
const toggle = (value: number) => {
|
||||
if (weights.kind !== 'list') return;
|
||||
const values = weights.values.includes(value) ? weights.values.filter(item => item !== value) : [...weights.values, value].sort((a, b) => a - b);
|
||||
if (values.length) setWeights({ kind: 'list', values });
|
||||
};
|
||||
return <div className="wb-fb-detail">
|
||||
<div className="wb-fb-hero">
|
||||
<FontText font={font} text={font.family} className="wb-fb-hero-name" weight={weight} eager/>
|
||||
<span className="wb-fb-hero-meta">{tx(`wb.fonts.categoryName.${font.category}`)} · {font.axis ? t('wb.fonts.variableRange', { min: font.axis[0], max: font.axis[1] }) : t('wb.fonts.weightsList', { weights: font.weights.join(', ') })}{installed ? ` · ${t('wb.fonts.downloadedSize', { size: bytesLabel(installed.bytes) })}` : ''}</span>
|
||||
</div>
|
||||
<div className="wb-fb-sample">
|
||||
<FontText font={font} text={sample || font.family} className="wb-fb-sample-text" weight={weight} eager/>
|
||||
<textarea aria-label={t('wb.fonts.sample')} value={sample} maxLength={120} rows={2} spellCheck={false} onChange={event => setSample(event.target.value)}/>
|
||||
<div className="wb-fb-samples">{SAMPLES.map(text => <button key={text} type="button" className={text === sample ? 'is-on' : ''} onClick={() => setSample(text)}>{text.slice(0, 18)}…</button>)}</div>
|
||||
</div>
|
||||
<div className="wb-fb-field">
|
||||
<span>{t('wb.fonts.previewWeight')}</span>
|
||||
{font.axis
|
||||
? <Slider label={t('wb.fonts.previewWeight')} min={font.axis[0]} max={font.axis[1]} step={10} value={weight} format={v => String(v)} onChange={setWeight}/>
|
||||
: <div className="wb-fb-weights" role="radiogroup" aria-label={t('wb.fonts.previewWeight')}>{available.map(value => <button key={value} type="button" role="radio" aria-checked={weight === value} className={weight === value ? 'is-on' : ''} onClick={() => setWeight(value)}>{value}</button>)}</div>}
|
||||
</div>
|
||||
<div className="wb-fb-field">
|
||||
<span>{t('wb.panel.updates.download')}</span>
|
||||
{weights.kind === 'range'
|
||||
? <div className="wb-fb-range">
|
||||
<Slider label={t('wb.fonts.lightest')} min={font.axis![0]} max={weights.max} step={100} value={weights.min} format={v => t('wb.fonts.fromWeight', { weight: v })} onChange={min => setWeights({ ...weights, min })}/>
|
||||
<Slider label={t('wb.fonts.heaviest')} min={weights.min} max={font.axis![1]} step={100} value={weights.max} format={v => t('wb.fonts.toWeight', { weight: v })} onChange={max => setWeights({ ...weights, max })}/>
|
||||
</div>
|
||||
: <div className="wb-fb-weights" role="group" aria-label={t('wb.fonts.weightsToDownload')}>{available.map(value => <button key={value} type="button" role="checkbox" aria-checked={weights.values.includes(value)} className={weights.values.includes(value) ? 'is-on' : ''} onClick={() => toggle(value)}>{value}</button>)}</div>}
|
||||
<small>{font.axis ? t('wb.fonts.variableFile') : t('wb.fonts.staticFiles')} {weightsLabel(weights)}</small>
|
||||
</div>
|
||||
{error && <p className="wb-fb-error" role="alert">{error}</p>}
|
||||
<div className="wb-fb-actions">
|
||||
<Button tone="primary" disabled={busy} onClick={() => void pick()}>{busy ? t('wb.fonts.downloading') : installed && JSON.stringify(installed.weights) === JSON.stringify(weights) ? t('wb.fonts.useFor', { role }) : t('wb.fonts.downloadUseFor', { role })}</Button>
|
||||
<Button tone="ghost" onClick={() => void invoke('open_external_url', { url: specimenUrl(font.family) }).catch(() => {})}>{t('wb.fonts.license')}</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SystemFonts({ current, onPick }: { current: string | null; onPick(pick: FontPick): void }) {
|
||||
const t = useT();
|
||||
const [custom, setCustom] = useState('');
|
||||
const valid = sanitizeFonts([custom.trim()]).length === 1;
|
||||
return <div className="wb-fb-system">
|
||||
<ul className="wb-fb-grid">
|
||||
{(panelThemes().fontChoices ?? FONT_CHOICES).filter(choice => choice.family.length).map(choice => <li key={choice.id}>
|
||||
<button type="button" className={current === choice.family[0] ? 'is-on' : ''} onClick={() => onPick({ family: choice.family })}>
|
||||
<span style={{ fontFamily: fontStack(choice.family) }}>{choice.key ? tx(choice.key, undefined, choice.label) : choice.label}</span>
|
||||
<small style={{ fontFamily: fontStack(choice.family) }}>Aa Bb 0123 · The quick brown fox</small>
|
||||
</button>
|
||||
</li>)}
|
||||
</ul>
|
||||
<form className="wb-fb-custom" onSubmit={event => { event.preventDefault(); if (valid) onPick({ family: [custom.trim(), 'system-ui', 'sans-serif'] }); }}>
|
||||
<label><span>{t('wb.fonts.installedFont')}</span>
|
||||
<input value={custom} placeholder={t('wb.fonts.installedPlaceholder')} onChange={event => setCustom(event.target.value)} style={valid ? { fontFamily: fontStack([custom.trim()]) } : undefined}/></label>
|
||||
<Button type="submit" size="sm" disabled={!valid}>{t('wb.fonts.useIt')}</Button>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
108
packages/shell/src/panel/fonts/FontRoles.tsx
Normal file
108
packages/shell/src/panel/fonts/FontRoles.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// "Which font goes where": one row per font role. Every role inherits the interface font until it
|
||||
// is given its own (from Google Fonts, the bundled Geist fonts or an installed system font), and
|
||||
// every role but the interface can fine-tune weight, size, letter spacing and tabular figures.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FONT_ROLE_NAMES, ROLE_LIMITS, fontStack, sanitizeFonts, type FontRole, type FontRoleName, type FontRoleSpec, type FontSpec } from '../../../../runtime/src/themeSpec.ts';
|
||||
import { useT } from '../../frame/hooks.ts';
|
||||
import { formatNumber, t, tx } from '../../frame/i18n.ts';
|
||||
import { Button, Select, Slider, Toggle } from '../controls.tsx';
|
||||
import { panelSite, panelThemes } from '../site.ts';
|
||||
import { themeStore, useThemeState } from '../themes.ts';
|
||||
import { FontBrowser, type FontPick } from './FontBrowser.tsx';
|
||||
import '../builder.css';
|
||||
|
||||
/** A font role's name and hint in the active language (`fonts.role.<role>.label/.hint`). */
|
||||
export const roleInfo = (role: FontRoleName) => ({ label: tx(`wb.fonts.role.${role}.label`), hint: tx(`wb.fonts.role.${role}.hint`) });
|
||||
|
||||
const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
const spec = (role: FontRole): FontRoleSpec => role === 'inherit' ? { family: [] } : role;
|
||||
const tidy = (next: FontRoleSpec): FontRole => Object.keys(next).length === 1 && !next.family.length ? 'inherit' : next;
|
||||
|
||||
/** The name a role shows: its own first family, or where it inherits from. */
|
||||
export function roleLabel(font: FontSpec, role: FontRoleName): { name: string; inherited: boolean; stack?: string } {
|
||||
const own = font.roles[role];
|
||||
if (own !== 'inherit' && own.family.length) return { name: own.family[0]!, inherited: false, stack: fontStack(own.family) };
|
||||
const siteFont = panelThemes().siteFont;
|
||||
if (role === 'interface') return { name: siteFont, inherited: true };
|
||||
if (role === 'mono') return { name: t('wb.fonts.monospace'), inherited: true, stack: fontStack(['Cascadia Mono', 'Consolas', 'ui-monospace', 'monospace']) };
|
||||
const base = font.roles.interface;
|
||||
if (base !== 'inherit' && base.family.length) return { name: base.family[0]!, inherited: true, stack: fontStack(base.family) };
|
||||
return { name: role === 'chrome' ? `Geist (${panelSite().brand.name})` : siteFont, inherited: true };
|
||||
}
|
||||
|
||||
/** Loads the Google fonts a font spec names into the panel so its labels render in them. */
|
||||
export function useShellFonts(font: FontSpec) {
|
||||
const { fonts } = useThemeState();
|
||||
const { ensureShellFont, isInstalled } = themeStore();
|
||||
useEffect(() => {
|
||||
for (const role of FONT_ROLE_NAMES) {
|
||||
const value = font.roles[role];
|
||||
if (value !== 'inherit' && value.source === 'google' && value.family[0] && isInstalled(value.family[0])) void ensureShellFont(value.family[0]);
|
||||
}
|
||||
}, [font, fonts]);
|
||||
}
|
||||
|
||||
export function FontRoles({ font, onRole, compact }: { font: FontSpec; onRole(role: FontRoleName, next: FontRole, commit?: boolean): void; compact?: boolean }) {
|
||||
const t = useT();
|
||||
const [browsing, setBrowsing] = useState<FontRoleName | null>(null);
|
||||
const [open, setOpen] = useState<FontRoleName | null>(null);
|
||||
useShellFonts(font);
|
||||
const pick = (role: FontRoleName, choice: FontPick) => {
|
||||
const current = spec(font.roles[role]);
|
||||
const next: FontRoleSpec = { ...current, family: sanitizeFonts(choice.family) };
|
||||
if (choice.source) next.source = choice.source; else delete next.source;
|
||||
onRole(role, tidy(next));
|
||||
setBrowsing(null);
|
||||
};
|
||||
return <div className={`wb-roles ${compact ? 'is-compact' : ''}`}>
|
||||
{FONT_ROLE_NAMES.map(role => {
|
||||
const label = roleLabel(font, role);
|
||||
const value = font.roles[role];
|
||||
const own = spec(value);
|
||||
const tuned = role !== 'interface' && (own.weight !== undefined || own.size !== undefined || own.tracking !== undefined || !!own.tabular);
|
||||
const set = (patch: Partial<FontRoleSpec>, commit = true) => {
|
||||
const next: FontRoleSpec = { ...own, ...patch };
|
||||
for (const key of Object.keys(patch) as Array<keyof FontRoleSpec>) if (patch[key] === undefined) delete next[key];
|
||||
onRole(role, tidy(next), commit);
|
||||
};
|
||||
const info = roleInfo(role);
|
||||
return <div key={role} className={`wb-role ${open === role ? 'is-open' : ''}`} role="group" aria-label={info.label}>
|
||||
<div className="wb-role-main">
|
||||
<div className="wb-role-copy">
|
||||
<strong>{info.label}</strong>
|
||||
{!compact && <small>{info.hint}</small>}
|
||||
</div>
|
||||
<button type="button" className={`wb-role-font ${label.inherited ? 'is-inherited' : ''}`} onClick={() => setBrowsing(role)}
|
||||
aria-label={label.inherited ? t('wb.fonts.roleInherited', { role: info.label, font: label.name }) : t('wb.fonts.roleOwn', { role: info.label, font: label.name })}>
|
||||
<span className="wb-role-sample" style={{ fontFamily: label.stack, fontWeight: own.weight, letterSpacing: own.tracking !== undefined ? `${own.tracking}em` : undefined, fontVariantNumeric: own.tabular ? 'tabular-nums' : undefined }}>
|
||||
{role === 'numbers' ? `${formatNumber(1284)} · ${formatNumber(3200, { notation: 'compact' })}` : role === 'mono' ? 'const x = 42;' : label.name}
|
||||
</span>
|
||||
<span className="wb-role-name">{label.inherited ? t('wb.fonts.inherits', { font: label.name }) : label.name}{own.source === 'google' && <em>Google</em>}</span>
|
||||
</button>
|
||||
<div className="wb-role-actions">
|
||||
{role !== 'interface' && <button type="button" className={`wb-role-tune ${tuned ? 'is-tuned' : ''}`} aria-expanded={open === role} aria-label={t('wb.fonts.fineTune', { role: info.label })} title={t('wb.fonts.fineTuneTitle')}
|
||||
onClick={() => setOpen(open === role ? null : role)}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 5h6m3 0h1M3 11h1m3 0h6M9 3.5v3M4 9.5v3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||||
</button>}
|
||||
{value !== 'inherit' && <Button size="sm" tone="ghost" onClick={() => onRole(role, 'inherit')} title={t('wb.fonts.inheritAgain')}>{t('wb.common.reset')}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
{open === role && role !== 'interface' && <div className="wb-role-tuning">
|
||||
<label><span>{t('wb.fonts.weight')}</span>
|
||||
<Select label={t('wb.fonts.roleWeight', { role: info.label })} value={String(own.weight ?? 'auto')} width={130}
|
||||
options={[{ value: 'auto', label: t('wb.fonts.asSite') }, ...WEIGHTS.map(w => ({ value: String(w), label: String(w), style: { fontWeight: w } }))]}
|
||||
onChange={v => set({ weight: v === 'auto' ? undefined : Number(v) })}/></label>
|
||||
<label><span>{t('wb.fonts.size')}</span>
|
||||
<Slider label={t('wb.fonts.roleSize', { role: info.label })} min={ROLE_LIMITS.size[0] * 100} max={ROLE_LIMITS.size[1] * 100} step={1} value={Math.round((own.size ?? 1) * 100)} format={v => t('wb.appearance.percent', { value: v / 100 })}
|
||||
onChange={v => set({ size: v === 100 ? undefined : v / 100 }, false)} onCommit={v => set({ size: v === 100 ? undefined : v / 100 })}/></label>
|
||||
<label><span>{t('wb.fonts.spacing')}</span>
|
||||
<Slider label={t('wb.fonts.roleSpacing', { role: info.label })} min={ROLE_LIMITS.tracking[0] * 1000} max={ROLE_LIMITS.tracking[1] * 1000} step={2} value={Math.round((own.tracking ?? 0) * 1000)} format={v => `${formatNumber(v / 1000, { minimumFractionDigits: 3, maximumFractionDigits: 3 })} em`}
|
||||
onChange={v => set({ tracking: v === 0 ? undefined : v / 1000 }, false)} onCommit={v => set({ tracking: v === 0 ? undefined : v / 1000 })}/></label>
|
||||
<label className="is-inline"><span>{t('wb.fonts.tabular')}</span><Toggle label={t('wb.fonts.roleTabular', { role: info.label })} checked={!!own.tabular} onChange={tabular => set({ tabular: tabular || undefined })}/></label>
|
||||
</div>}
|
||||
</div>;
|
||||
})}
|
||||
{browsing && <FontBrowser role={roleInfo(browsing).label} current={spec(font.roles[browsing]).family[0] ?? null}
|
||||
onPick={choice => pick(browsing, choice)} onClose={() => setBrowsing(null)}/>}
|
||||
</div>;
|
||||
}
|
||||
80
packages/shell/src/panel/fonts/catalog.ts
Normal file
80
packages/shell/src/panel/fonts/catalog.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// The Google Fonts catalog the font browser shows: an offline snapshot (googleFontsCatalog.json,
|
||||
// regenerated by packages/shell/scripts/google-fonts-catalog.mjs) of family names, categories and
|
||||
// weights. The list itself is never fetched by the app; only the font files the owner picks are
|
||||
// (natively). Pure helpers, shared with the tests.
|
||||
|
||||
import { textLocale } from '../../frame/i18n.ts';
|
||||
|
||||
export type Category = 'sans' | 'serif' | 'display' | 'handwriting' | 'mono';
|
||||
export type CatalogFont = { family: string; category: Category; axis: [number, number] | null; weights: number[]; rank: number };
|
||||
export type Weights = { kind: 'range'; min: number; max: number } | { kind: 'list'; values: number[] };
|
||||
|
||||
/** Filter chips; labels are `fonts.category.<id>` (short) and `fonts.categoryName.<id>` (full) in the catalogs. */
|
||||
export const CATEGORIES: Array<Category | 'all'> = ['all', 'sans', 'serif', 'display', 'handwriting', 'mono'];
|
||||
|
||||
type Raw = { families: Array<[string, string, [number, number] | null, number[], number]> };
|
||||
const KNOWN = new Set<Category>(['sans', 'serif', 'display', 'handwriting', 'mono']);
|
||||
const NAME = /^[A-Za-z0-9]+( [A-Za-z0-9]+)*$/;
|
||||
|
||||
export function parseCatalog(raw: unknown): CatalogFont[] {
|
||||
const families = (raw as Raw | null)?.families;
|
||||
if (!Array.isArray(families)) return [];
|
||||
return families
|
||||
.filter(([family, category, , weights]) => typeof family === 'string' && NAME.test(family) && family.length <= 48 && KNOWN.has(category as Category) && Array.isArray(weights))
|
||||
.map(([family, category, axis, weights, rank]) => ({
|
||||
family, category: category as Category,
|
||||
axis: Array.isArray(axis) && axis.length === 2 ? [axis[0], axis[1]] as [number, number] : null,
|
||||
weights: weights.filter(w => Number.isInteger(w) && w >= 100 && w <= 1000), rank: Number(rank) || 9999,
|
||||
}));
|
||||
}
|
||||
|
||||
let loaded: Promise<CatalogFont[]> | null = null;
|
||||
/** The bundled catalog, loaded on first use (it is ~90 KB, so it stays out of the shell's first paint). */
|
||||
export function loadCatalog(): Promise<CatalogFont[]> {
|
||||
loaded ??= import('./googleFontsCatalog.json').then(module => parseCatalog(module.default));
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** Search by name (prefix matches first, then popularity), within a category and optionally only installed ones. */
|
||||
export function searchFonts(fonts: CatalogFont[], options: { query?: string; category?: Category | 'all'; only?: Set<string> }): CatalogFont[] {
|
||||
const query = (options.query ?? '').trim().toLowerCase();
|
||||
const list = fonts.filter(font =>
|
||||
(!options.category || options.category === 'all' || font.category === options.category)
|
||||
&& (!options.only || options.only.has(font.family))
|
||||
&& (!query || font.family.toLowerCase().includes(query)));
|
||||
if (!query) return list.sort((a, b) => a.rank - b.rank);
|
||||
const score = (font: CatalogFont) => {
|
||||
const name = font.family.toLowerCase();
|
||||
return name === query ? 0 : name.startsWith(query) ? 1 : name.split(' ').some(word => word.startsWith(query)) ? 2 : 3;
|
||||
};
|
||||
return list.sort((a, b) => score(a) - score(b) || a.rank - b.rank);
|
||||
}
|
||||
|
||||
/** What to download by default: the whole weight axis of a variable font, else regular and bold (or the nearest). */
|
||||
export function defaultWeights(font: CatalogFont): Weights {
|
||||
if (font.axis) return { kind: 'range', min: font.axis[0], max: font.axis[1] };
|
||||
const pick = (target: number) => font.weights.reduce((best, w) => Math.abs(w - target) < Math.abs(best - target) ? w : best, font.weights[0] ?? 400);
|
||||
return { kind: 'list', values: [...new Set([pick(400), pick(700)])].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
/** The weights a font can be previewed at (every 100 inside the axis, or the static weights). */
|
||||
export const availableWeights = (font: CatalogFont) => font.axis
|
||||
? Array.from({ length: 9 }, (_, i) => (i + 1) * 100).filter(w => w >= font.axis![0] && w <= font.axis![1])
|
||||
: font.weights;
|
||||
|
||||
/** A stack that falls back sensibly while the font loads, or where it lacks a glyph. */
|
||||
export function stackFor(font: Pick<CatalogFont, 'family' | 'category'>): string[] {
|
||||
const fallback: Record<Category, string[]> = {
|
||||
sans: ['system-ui', 'sans-serif'], serif: ['Georgia', 'serif'], display: ['system-ui', 'sans-serif'],
|
||||
handwriting: ['cursive'], mono: ['ui-monospace', 'monospace'],
|
||||
};
|
||||
return [font.family, ...fallback[font.category]];
|
||||
}
|
||||
|
||||
export const weightsLabel = (weights: Weights) => weights.kind === 'range' ? `${weights.min}–${weights.max}` : weights.values.join(', ');
|
||||
const numberLocale = () => textLocale().split('-x-')[0] || 'en';
|
||||
export const bytesLabel = (bytes: number) => bytes >= 1_000_000
|
||||
? `${new Intl.NumberFormat(numberLocale(), { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(bytes / 1_000_000)} MB`
|
||||
: `${new Intl.NumberFormat(numberLocale()).format(Math.max(1, Math.round(bytes / 1000)))} KB`;
|
||||
/** Google Fonts' own page for a family (its license and designers), opened in the browser. */
|
||||
export const specimenUrl = (family: string) => `https://fonts.google.com/specimen/${family.replace(/ /g, '+')}`;
|
||||
1
packages/shell/src/panel/fonts/googleFontsCatalog.json
Normal file
1
packages/shell/src/panel/fonts/googleFontsCatalog.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue