Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support

This commit is contained in:
2026-06-16 21:41:04 +03:00
parent 713f396c5c
commit 5d3aba608a
20 changed files with 1191 additions and 40 deletions
+50
View File
@@ -75,3 +75,53 @@
font-size: 10px;
color: var(--text-secondary);
}
/* Managed list of user-uploaded fonts under the Type panel's font control. */
.fontList {
display: grid;
gap: var(--space-2);
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
}
.fontItem {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-3);
}
.fontNameWrap {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.fontName {
font-size: 13px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* "Variable" tag on a variable-font row in the managed list — passive chrome,
bordered rather than filled (arch 09 §4). */
.fontBadge {
flex: none;
padding: 1px var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.fontMeta {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
+124 -30
View File
@@ -8,10 +8,17 @@
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
*/
import { useMemo, useRef } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset';
import type { JsonObject } from '@core/spec-config';
import { humanizeBytes } from '@core/storage-estimate';
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { removeFont, uploadFontFiles } from '../services/fonts';
import { confirm } from '../stores/ConfirmStore';
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
import { Button } from './Button';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
import styles from './ThemeFields.module.css';
@@ -24,24 +31,36 @@ const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). The roster
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate.
* The built-in roster, each labelled in its own family so the dropdown previews
* the typeface (the type analogue of the color dropdowns' swatches). Loaded
* before render by the chart-renderer's font gate. User-uploaded fonts are merged
* ahead of these at render (see `TypeControls`).
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
const ROSTER_FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(
({ value, label }) => ({ value, label, labelStyle: { fontFamily: value } }),
);
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel.
type Weight = '' | '400' | '500' | '600' | '700' | 'custom';
/** A variable font's badge text — its weight range when present, else just "Variable". */
function variableBadge(axes: FontAxis[] | undefined): string {
const wght = axes?.find((a) => a.tag === 'wght');
return wght ? `Variable ${wght.min}${wght.max}` : 'Variable';
}
// Numeric font weights as an enum; '' is the unset (default) sentinel. The full
// 100900 range is offered so a variable font's weight axis is fully selectable;
// a static face simply faux-renders the weights it doesn't physically carry.
type Weight = '' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'custom';
const weightOptions: SelectControlOption<Weight>[] = [
{ value: '', label: 'Theme default' },
{ value: '100', label: 'Thin' },
{ value: '200', label: 'Extra Light' },
{ value: '300', label: 'Light' },
{ value: '400', label: 'Normal' },
{ value: '500', label: 'Medium' },
{ value: '600', label: 'Semibold' },
{ value: '700', label: 'Bold' },
{ value: '800', label: 'Extra Bold' },
{ value: '900', label: 'Black' },
];
const weightValue = (v: unknown): Weight => {
if (v === undefined) return '';
@@ -57,37 +76,112 @@ const weightValue = (v: unknown): Weight => {
export function TypeControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const fonts = useFontStore((s) => s.fonts);
const fileRef = useRef<HTMLInputElement>(null);
// User uploads first, then a divider, then the built-in roster — so a brand's
// own faces lead the list (SelectControl's `dividerBefore` draws the boundary).
const fontOptions = useMemo<SelectControlOption<string>[]>(() => {
const userOptions = fonts.map((f) => ({
value: fontFamilyStack(f.family),
label: f.family,
labelStyle: { fontFamily: fontFamilyStack(f.family) },
}));
const roster = ROSTER_FONT_OPTIONS.map((o, i) =>
i === 0 && userOptions.length > 0 ? { ...o, dividerBefore: true } : o,
);
return [...userOptions, ...roster];
}, [fonts]);
const currentFont =
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined;
typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined;
const setWeight = (path: ConfigPath) => (w: Weight) =>
set(path, w === '' ? undefined : Number(w));
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files;
if (picked && picked.length > 0) void uploadFontFiles(picked);
e.target.value = ''; // let the same file be re-picked after a removal
};
const onRemoveFont = async (id: number, family: string) => {
const ok = await confirm({
title: 'Remove font',
message:
`Remove "${family}"? Charts using it fall back to a default font. ` +
'This only removes the uploaded file from Astrolabe.',
confirmLabel: 'Remove',
danger: true,
});
if (ok) removeFont(id);
};
return (
<div className={styles.panel}>
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
<div className={styles.field}>
<span className={styles.fieldLabel}>Font</span>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
<div className={styles.control}>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={fontOptions}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
Upload font
</Button>
<input
ref={fileRef}
type="file"
accept=".woff2,.woff,.ttf,.otf"
multiple
hidden
onChange={onPickFiles}
/>
</div>
</div>
{fonts.length > 0 && (
<ul className={styles.fontList}>
{fonts.map((f) => (
<li key={f.id} className={styles.fontItem}>
<span className={styles.fontNameWrap}>
<span
className={styles.fontName}
style={{ fontFamily: fontFamilyStack(f.family) }}
>
{f.family}
</span>
{isVariableFont(f.axes) && (
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
)}
</span>
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
<Button
variant="ghost"
onClick={() => void onRemoveFont(f.id, f.family)}
aria-label={`Remove ${f.family}`}
>
Remove
</Button>
</li>
))}
</ul>
)}
</ControlSection>
<ControlSection title="Title">