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">
+2 -1
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import {
DATASETS_STORE,
FONTS_STORE,
SNIPPETS_STORE,
THEMES_STORE,
_resetDbForTests,
@@ -16,7 +17,7 @@ import {
put,
} from './db';
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE];
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE];
/** Open the raw database at `version` with a custom (or absent) upgrade body. */
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
+5 -3
View File
@@ -11,16 +11,18 @@ const DB_NAME = 'astrolabe';
/**
* Store-layout version. Bump only when the set of object stores / indexes
* changes — independent of per-record schema versions (see snippet-migrations).
* v2 added the `themes` store (custom chart themes).
* v2 added the `themes` store (custom chart themes); v3 added the `fonts` store
* (user-uploaded font faces — binary bytes, stored verbatim by structured clone).
*/
const DB_VERSION = 2;
const DB_VERSION = 3;
export const SNIPPETS_STORE = 'snippets';
export const DATASETS_STORE = 'datasets';
export const THEMES_STORE = 'themes';
export const FONTS_STORE = 'fonts';
/** Every object store the app expects — the open-time verification checklist. */
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const;
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE] as const;
let dbPromise: Promise<IDBDatabase> | null = null;
+5
View File
@@ -35,6 +35,11 @@ export function readTextFile(file: File): Promise<string> {
return file.text();
}
/** Read a picked file's raw bytes (rejects on an unreadable file) — for font uploads. */
export function readBinaryFile(file: File): Promise<ArrayBuffer> {
return file.arrayBuffer();
}
/** Copy `text` to the clipboard (rejects when the browser blocks access). The
* one place outside a component that touches the clipboard API. */
export function copyText(text: string): Promise<void> {
+57
View File
@@ -0,0 +1,57 @@
/**
* FontFace registration seam (docs/architecture/02; arch 00 — only
* infrastructure touches browser APIs).
*
* Turns stored FontAsset bytes into live `FontFace`s on `document.fonts`, so the
* chart renderer's font gate (`ensureFontsLoaded` in chart-renderer.ts) and the
* Type-panel dropdown resolve user fonts exactly like the @fontsource roster.
* Registration is keyed by font id (so a delete removes precisely the right
* face), idempotent (re-registering an id replaces its face), and a no-op where
* the Font Loading API is absent (tests, SSR).
*
* A static file is one weight, registered with default descriptors (normal/400);
* a config requesting bold falls back to the browser's faux-bold. A **variable**
* file is registered with its parsed weight/width ranges (`variableFontDescriptors`),
* so the whole weight axis renders from one upload and the Type panel's weight
* controls drive it for real.
*/
import { type FontAsset, variableFontDescriptors } from '@core/font-asset';
/** id → the live face we added, so we can remove exactly it on delete/replace. */
const registered = new Map<number, FontFace>();
/** Register (or replace) a stored font as a live `FontFace` on `document.fonts`. */
export function registerFontAsset(asset: FontAsset): void {
if (typeof document === 'undefined' || !document.fonts) return;
unregisterFont(asset.id); // replace any prior face for this id (re-import)
let face: FontFace;
try {
// Variable fonts get weight/width ranges so the browser resolves a requested
// weight against the axis; a static font passes no descriptors (normal/400).
face = new FontFace(asset.family, asset.data, variableFontDescriptors(asset.axes));
} catch {
return; // a malformed family name or unreadable source — nothing to register
}
registered.set(asset.id, face);
document.fonts.add(face);
// Decode eagerly so the next chart render finds the face ready. A decode
// failure is non-fatal — the chart renders with fallback metrics — which is
// arch 02's sanctioned graceful-fallback case, kept explicit, not buried.
face.load().catch(() => {});
}
/** Register a batch (startup hydration). */
export function registerFontAssets(assets: ReadonlyArray<FontAsset>): void {
for (const asset of assets) registerFontAsset(asset);
}
/** Remove a previously-registered face by font id. No-op if not registered. */
export function unregisterFont(id: number): void {
if (typeof document === 'undefined' || !document.fonts) return;
const face = registered.get(id);
if (face) {
document.fonts.delete(face);
registered.delete(id);
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_FONT_VERSION } from '@core/font-asset';
import { migrateFontAsset } from './font-migrations';
const buf = (n: number): ArrayBuffer => new Uint8Array(n).buffer;
describe('migrateFontAsset', () => {
it('passes a current record through unchanged (plus version stamp)', () => {
const record = {
id: 4,
version: CURRENT_FONT_VERSION,
family: 'Brand Sans',
data: buf(2048),
format: 'woff2' as const,
fileName: 'Brand-Sans.woff2',
source: 'file' as const,
size: 2048,
created: '2026-06-15T10:00:00.000Z',
modified: '2026-06-15T10:00:00.000Z',
};
expect(migrateFontAsset(record)).toEqual(record);
});
it('fills missing or invalid fields with safe defaults', () => {
const migrated = migrateFontAsset({ id: '9', fileName: 'x.ttf' });
expect(migrated.id).toBe(9);
expect(migrated.version).toBe(CURRENT_FONT_VERSION);
expect(migrated.family).toBe('Custom font');
expect(migrated.format).toBe('ttf'); // recovered from the file extension
expect(migrated.data).toBeInstanceOf(ArrayBuffer);
expect(migrated.size).toBe(0);
expect(migrated.source).toBe('file');
});
it('keeps valid variation axes and drops a malformed axes value', () => {
const withAxes = migrateFontAsset({
id: 2,
family: 'Fixel',
data: buf(1),
format: 'ttf',
fileName: 'Fixel.ttf',
axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
});
expect(withAxes.axes).toEqual([{ tag: 'wght', min: 100, default: 400, max: 900 }]);
const badAxes = migrateFontAsset({
id: 3,
family: 'X',
data: buf(1),
format: 'ttf',
fileName: 'x.ttf',
axes: 'not-an-array',
});
expect(badAxes.axes).toBeUndefined();
});
it('keeps unknown fields written by a newer build', () => {
const migrated = migrateFontAsset({
id: 1,
family: 'Next',
data: buf(1),
format: 'otf',
fileName: 'n.otf',
futureField: 'kept',
});
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Read-time migration for FontAsset records (docs/architecture/02 §4).
*
* Mirrors snippet/dataset/theme migrations: every font read from storage passes
* through `migrateFontAsset`, which fills missing/invalid fields and stamps the
* current version. Unknown fields are tolerated (spread the original, only fill
* gaps) so a record written by a newer build round-trips without loss.
*/
import {
CURRENT_FONT_VERSION,
detectFontFormat,
type FontAsset,
type FontAxis,
type FontFormat,
type FontSource,
} from '@core/font-asset';
const FORMATS: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'ttf', 'otf'];
/** Accept a stored `axes` array only if it's shaped like FontAxis records. */
function migrateAxes(raw: unknown): FontAxis[] | undefined {
if (!Array.isArray(raw)) return undefined;
const axes = raw.filter(
(a): a is FontAxis =>
!!a &&
typeof (a as FontAxis).tag === 'string' &&
typeof (a as FontAxis).min === 'number' &&
typeof (a as FontAxis).max === 'number',
);
return axes.length > 0 ? axes : undefined;
}
/** Upgrade a raw stored record to the current FontAsset shape. */
export function migrateFontAsset(raw: unknown): FontAsset {
const r = { ...(raw as Record<string, unknown>) };
const fileName = typeof r.fileName === 'string' ? r.fileName : '';
const format: FontFormat = FORMATS.includes(r.format as FontFormat)
? (r.format as FontFormat)
: (detectFontFormat(fileName) ?? 'woff2');
const data = r.data instanceof ArrayBuffer ? r.data : new ArrayBuffer(0);
const source: FontSource = r.source === 'google' ? 'google' : 'file';
const axes = migrateAxes(r.axes);
delete r.axes; // re-attach the validated value below (or omit it entirely)
return {
...r,
id: typeof r.id === 'number' ? r.id : Number(r.id),
version: CURRENT_FONT_VERSION,
family: typeof r.family === 'string' ? r.family : 'Custom font',
data,
format,
fileName,
source,
...(axes ? { axes } : {}),
size: typeof r.size === 'number' ? r.size : data.byteLength,
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Font asset persistence adapter (docs/architecture/02; scope doc §4 → fonts).
*
* The typed seam between the FontStore and IndexedDB's `fonts` object store.
* Exposes plain async functions returning domain `FontAsset` objects and migrates
* every record on read — same contract as dataset-store / theme-store. The
* payload is binary (`ArrayBuffer`), stored verbatim by the structured-clone
* algorithm the generic `put`/`getAll` already use; no special handling needed.
*/
import { CURRENT_FONT_VERSION, type FontAsset } from '@core/font-asset';
import { FONTS_STORE, del, getAll, put } from './db';
import { migrateFontAsset } from './font-migrations';
/** Load every uploaded font, upgrading each record to the current shape. */
export async function loadFonts(): Promise<FontAsset[]> {
const records = await getAll<unknown>(FONTS_STORE);
return records.map(migrateFontAsset);
}
/** Persist a font asset at the current schema version. Propagates failures. */
export async function saveFont(font: FontAsset): Promise<void> {
await put(FONTS_STORE, { ...font, version: CURRENT_FONT_VERSION });
}
/** Permanently remove a font asset by id. */
export async function deleteFont(id: number): Promise<void> {
await del(FONTS_STORE, id);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* User font persistence wiring (docs/architecture/01 §5; scope doc §4 → fonts).
*
* The font sibling of `theme-persistence.ts`: a startup subscriber that diffs the
* `fonts` array against the previous snapshot and writes upserts/deletes through
* to the IndexedDB adapter. The store stays browser-free; failures surface as a
* toast rather than silent loss. Fonts change on explicit add/delete, so there is
* no debounce.
*/
import { deleteFont, saveFont } from '../infrastructure/font-store';
import { notify } from '../stores/NotificationStore';
import { useFontStore } from '../stores/FontStore';
type Unsubscribe = () => void;
function fontError(op: 'save' | 'delete', err: unknown) {
notify({
kind: 'error',
title: op === 'delete' ? "Couldn't delete the font" : "Couldn't save the font",
message:
'A storage error stopped Astrolabe from completing the last font change, so it may not ' +
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
detail: err instanceof Error ? `Font ${op} failed: ${err.name}: ${err.message}` : String(err),
});
}
/** Persist font upserts and deletions whenever the array changes. */
export function wireFontPersistence(): Unsubscribe {
let prevFonts = useFontStore.getState().fonts;
return useFontStore.subscribe((s) => {
const next = s.fonts;
if (next === prevFonts) return;
const prev = prevFonts;
prevFonts = next;
for (const old of prev) {
if (!next.some((n) => n.id === old.id)) {
deleteFont(old.id).catch((err) => fontError('delete', err));
}
}
for (const n of next) {
const old = prev.find((p) => p.id === n.id);
if (old !== n) saveFont(n).catch((err) => fontError('save', err));
}
});
}
+21
View File
@@ -11,17 +11,22 @@
import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme';
import type { FontAsset } from '@core/font-asset';
import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store';
import { loadFonts } from '../infrastructure/font-store';
import { registerFontAssets } from '../infrastructure/font-faces';
import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
import { wireSnippetPersistence } from './snippet-persistence';
import { wireDatasetPersistence } from './dataset-persistence';
import { wireThemePersistence } from './theme-persistence';
import { wireFontPersistence } from './font-persistence';
import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter';
@@ -61,15 +66,31 @@ export async function initApp(): Promise<void> {
notify(storageErrorNotification('load', err));
}
// User-uploaded font faces (scope doc §4). Same failure posture: a font that
// can't be loaded just won't be offered or rendered, falling back to the stack's
// generic family.
let fonts: FontAsset[] = [];
try {
fonts = await loadFonts();
} catch (err) {
notify(storageErrorNotification('load', err));
}
useSnippetStore.getState().hydrate(snippets);
useDatasetStore.getState().hydrate(datasets);
useCustomThemeStore.getState().hydrate(themes);
useFontStore.getState().hydrate(fonts);
// Register the loaded faces on document.fonts BEFORE the first chart render so
// the renderer's font gate (ensureFontsLoaded) can resolve them like the roster.
registerFontAssets(fonts);
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set
// — otherwise it would redundantly re-save every record on each startup.
wireSnippetPersistence();
wireDatasetPersistence();
wireThemePersistence();
wireFontPersistence();
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
+117
View File
@@ -0,0 +1,117 @@
/**
* Font upload service (scope doc §4 → User font upload) — orchestration over the
* pure core helpers, the FontStore, and the browser seams.
*
* The deterministic work (format detection, family derivation, uniqueness) lives
* in `@core/font-asset` + `@core/naming`; this layer reads the file bytes through
* the infrastructure adapter, validates, commits to the store (the persistence
* subscriber writes through to IndexedDB), registers the live `FontFace`, and
* reports one summary toast. Deleting a font unregisters its face and removes the
* record (the subscriber deletes it from IndexedDB).
*/
import {
createFontAsset,
detectFontFormat,
familyNameFromFileName,
MAX_FONT_BYTES,
parseFontAxes,
} from '@core/font-asset';
import { makeUniqueName } from '@core/naming';
import { humanizeBytes } from '@core/storage-estimate';
import { readBinaryFile } from '../infrastructure/file-transfer';
import { registerFontAsset, unregisterFont } from '../infrastructure/font-faces';
import { notify } from '../stores/NotificationStore';
import { useFontStore } from '../stores/FontStore';
/** Why a picked file was skipped, for the summary toast. */
interface Skip {
fileName: string;
reason: string;
}
/**
* Add one or more picked font files to the library. Each file is validated
* independently (unsupported format / over the size cap are skipped, not fatal),
* given a unique family name, stored, and registered. Reports how many were added
* and why any were skipped — never throws on a bad file.
*/
export async function uploadFontFiles(files: FileList | File[]): Promise<void> {
const list = Array.from(files);
if (list.length === 0) return;
const added: string[] = [];
const skipped: Skip[] = [];
for (const file of list) {
const format = detectFontFormat(file.name);
if (!format) {
skipped.push({ fileName: file.name, reason: 'unsupported format' });
continue;
}
let data: ArrayBuffer;
try {
data = await readBinaryFile(file);
} catch {
skipped.push({ fileName: file.name, reason: 'could not be read' });
continue;
}
if (data.byteLength > MAX_FONT_BYTES) {
skipped.push({
fileName: file.name,
reason: `larger than ${humanizeBytes(MAX_FONT_BYTES)}`,
});
continue;
}
// Unique within the library (and within this batch — the store grew on the
// previous iteration, so re-read its families each time).
const family = makeUniqueName(
familyNameFromFileName(file.name),
useFontStore.getState().fonts.map((f) => f.family),
);
// Detect variation axes (variable fonts) so the face registers with the
// right weight/width ranges — uncompressed ttf/otf only; others stay static.
const axes = parseFontAxes(data, format);
const stored = useFontStore
.getState()
.add(createFontAsset({ family, data, format, fileName: file.name, axes }));
registerFontAsset(stored);
added.push(family);
}
reportUpload(added, skipped);
}
/** Remove a font: unregister its live face, then drop the record. */
export function removeFont(id: number): void {
unregisterFont(id);
useFontStore.getState().remove(id);
}
/** One summary toast covering what was added and what was skipped. */
function reportUpload(added: string[], skipped: Skip[]): void {
const skips = skipped.length
? `Skipped ${skipped.map((s) => `${s.fileName} (${s.reason})`).join(', ')}.`
: '';
if (added.length === 0) {
notify({
kind: skipped.length ? 'error' : 'info',
title: skipped.length ? "Couldn't add the font" : 'No fonts added',
message:
skips || 'Choose a .woff2, .woff, .ttf, or .otf file to add a font to your chart themes.',
});
return;
}
const noun = added.length === 1 ? 'font' : 'fonts';
const message = `Added ${added.join(', ')}. ${skips}`.trim();
notify({
kind: skipped.length ? 'warning' : 'success',
title: `Added ${added.length} ${noun}`,
message,
});
}
+4
View File
@@ -60,6 +60,10 @@ export function exportWorkspace(now: Date = new Date()): void {
return;
}
// TODO(fonts §08): the envelope does not yet carry user-uploaded FontAsset
// bytes, so a theme/snippet referencing an uploaded font imports on another
// machine with the fallback family. Embed the referenced faces (base64) here —
// best done with task #3's shared font-bytes→embeddable-string helper.
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createFontAsset, type FontAsset } from '@core/font-asset';
import { useFontStore } from './FontStore';
const asset = (family: string, id = 1): FontAsset =>
createFontAsset({
family,
data: new Uint8Array(8).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
id,
});
beforeEach(() => useFontStore.getState().reset());
describe('FontStore', () => {
it('add assigns the next free id (one past the max)', () => {
const a = useFontStore.getState().add(asset('A'));
const b = useFontStore.getState().add(asset('B'));
expect(a.id).toBe(1);
expect(b.id).toBe(2);
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['A', 'B']);
});
it('addFonts reassigns ids and appends without clobbering', () => {
useFontStore.getState().add(asset('Existing'));
useFontStore.getState().addFonts([asset('X', 1), asset('Y', 1)]);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.id)).toEqual([1, 2, 3]);
expect(fonts.map((f) => f.family)).toEqual(['Existing', 'X', 'Y']);
});
it('remove drops only the matching id; hydrate replaces wholesale', () => {
const a = useFontStore.getState().add(asset('A'));
useFontStore.getState().add(asset('B'));
useFontStore.getState().remove(a.id);
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['B']);
useFontStore.getState().hydrate([asset('Loaded', 5)]);
expect(useFontStore.getState().fonts).toHaveLength(1);
expect(useFontStore.getState().fonts[0].family).toBe('Loaded');
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* User font library (scope doc §4 → User font upload; spec §04 → Chart theme).
*
* The durable collection of uploaded font faces, referenced by `family` from a
* config's font slots. Same two-layer shape as DatasetStore / CustomThemeStore:
* low-level mutators (`add`/`addFonts`/`remove`) are the single place the `fonts`
* array changes; a startup subscriber writes them through to IndexedDB.
*
* Persistence is NOT done here — `font-persistence.ts` observes this store and
* writes through to the adapter — and neither is `FontFace` registration (a
* browser side effect, owned by `services/fonts.ts` + `infrastructure/font-faces`),
* so the store stays browser-free.
*/
import { create } from 'zustand';
import type { FontAsset } from '@core/font-asset';
export interface FontState {
fonts: FontAsset[];
/** Replace the library from storage. */
hydrate: (fonts: FontAsset[]) => void;
/** Add a fully-formed asset, assigning the next free id. Returns the stored record. */
add: (font: FontAsset) => FontAsset;
/** Batch add (import path): reassign ids from the store's authority and append. */
addFonts: (incoming: FontAsset[]) => void;
/** Remove a font by id. */
remove: (id: number) => void;
/** Reset to initial state (tests). */
reset: () => void;
}
/** Next free numeric id — one past the max (same contract as `nextDatasetId`/`nextThemeId`). */
function nextFontId(fonts: ReadonlyArray<FontAsset>): number {
return fonts.reduce((max, f) => Math.max(max, f.id), 0) + 1;
}
export const useFontStore = create<FontState>((set, get) => ({
fonts: [],
hydrate: (fonts) => set({ fonts }),
add: (font) => {
const withId = { ...font, id: nextFontId(get().fonts) };
set((s) => ({ fonts: [...s.fonts, withId] }));
return withId;
},
addFonts: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = nextFontId(s.fonts);
const withIds = incoming.map((f) => ({ ...f, id: nextId++ }));
return { fonts: [...s.fonts, ...withIds] };
});
},
remove: (id) => set((s) => ({ fonts: s.fonts.filter((f) => f.id !== id) })),
reset: () => set({ fonts: [] }),
}));