mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(studio): 34 typefaces with real weights and italics, a categorised font picker
The book catalogue grows from 7 families to 34, all self-hosted through @fontsource under the SIL OFL 1.1 or the Apache License 2.0: condensed and poster faces, heavy retro display, elegant display serifs, Montserrat, twelve scripts and DM Mono. Weights run from 100 to 900, each family offering only the weights it ships, and the serifs gain real italics. The catalogue lives in @trek/shared (book-fonts.data.ts) with each family's roles (display, heading, body, script, mono), weights, italics and letter width, so the Studio, the findings, the design engine and the assistant read one list. Page operations and the design engine set a weight a family lacks at its nearest one, describe_book_page names the role and the weight, and font_mix no longer counts scripts and mono faces, warning above three. The Studio gets StudioFontPicker: the current family set in itself, a searchable list grouped by kind with every family in its own face and the book's own families first, and a weight slider over the weights the family has. Faces load only when used; the assistant's page snapshot embeds only the faces a page draws, the service worker installs only the latin files of the original seven families, and the faces of an opened book are cached for offline use. BOOK_SCHEMA_REV goes to 2.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { folioInk } from './folioColour'
|
||||
import { isNumbered } from './bookSheets'
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PageNumbers({
|
||||
textAlign: at.align,
|
||||
fontFamily: fontStack(cfg.font),
|
||||
fontSize: `${size}pt`,
|
||||
fontWeight: 500,
|
||||
fontWeight: nearestWeight(cfg.font, 500),
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
color: ink.color,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
|
||||
} from '@trek/shared'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
@@ -417,7 +417,7 @@ export function ElementView({
|
||||
// the conversion exactly.
|
||||
fontSize: `${el.size}pt`,
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontWeight: nearestWeight(el.font, el.weight),
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { bookElementId as elementId } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -325,7 +325,7 @@ export function StudioCanvas({
|
||||
// The document is in pt; the overlay is on screen at the same zoom.
|
||||
fontSize: `${el.size * (96 / 72) * zoom}px`,
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontWeight: nearestWeight(el.font, el.weight),
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { BOOK_FONT_ORDER } from './bookFonts';
|
||||
import { matchFonts, pickFont } from './fontPicker';
|
||||
import { StudioFontPicker } from './StudioFontPicker';
|
||||
|
||||
/**
|
||||
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
|
||||
* a list grouped by kind with every family in its own face, the book's own
|
||||
* families first, a search, and a weight that lands on one the family has.
|
||||
*/
|
||||
|
||||
const t = (k: string, params?: Record<string, string | number>) =>
|
||||
params ? `${k} ${Object.values(params).join(' ')}` : k;
|
||||
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
|
||||
|
||||
const frame = { x: 10, y: 10, w: 80, h: 20 };
|
||||
function bookIn(...fonts: string[]): BookDocument {
|
||||
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
|
||||
const elements = fonts.map(
|
||||
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
|
||||
);
|
||||
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
|
||||
return {
|
||||
...doc,
|
||||
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
|
||||
spreads: [{ ...cover, elements }, ...rest],
|
||||
};
|
||||
}
|
||||
|
||||
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.setState({ doc: null });
|
||||
});
|
||||
|
||||
describe('choosing a family', () => {
|
||||
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
|
||||
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
|
||||
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
|
||||
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
|
||||
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
|
||||
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
|
||||
expect(matchFonts('mono', groupName)).toEqual(['mono']);
|
||||
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
|
||||
expect(matchFonts('nothing like it', groupName)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the picker', () => {
|
||||
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
|
||||
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
|
||||
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
|
||||
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
|
||||
expect(current.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
|
||||
|
||||
open();
|
||||
expect(current.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
|
||||
const groups = screen.getAllByRole('group');
|
||||
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
|
||||
const used = within(groups[0] as HTMLElement).getAllByRole('option');
|
||||
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
|
||||
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
|
||||
// Every family, once per group it is in, each set in its own face.
|
||||
const rows = screen.getAllByRole('option');
|
||||
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
|
||||
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
|
||||
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
|
||||
expect(anton?.textContent).toContain('400');
|
||||
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
|
||||
open();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
|
||||
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
|
||||
fireEvent.click(screen.getByText('Anton'));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
|
||||
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
|
||||
open();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
|
||||
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
|
||||
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
|
||||
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
// A second click on the open button closes it too.
|
||||
open();
|
||||
open();
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
|
||||
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
|
||||
import { Check, ChevronDown, Search, X } from 'lucide-react';
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-fonts.css';
|
||||
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
|
||||
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
|
||||
|
||||
/**
|
||||
* The typeface picker, shared by the text inspector and the travel inspector.
|
||||
*
|
||||
* Thirty four families do not fit as a wall of buttons the way seven did, and
|
||||
* a plain list of names says nothing about how a face looks, which is the only
|
||||
* thing anyone is choosing between. So the current family sits on one line,
|
||||
* set in itself, and opens a list grouped the way people look for type
|
||||
* (a sans, a serif, something for a poster, a script to lay over a photo)
|
||||
* where every family is again set in its own face. The families the book is
|
||||
* already set in come first, because the second text of a book is usually set
|
||||
* like the first.
|
||||
*
|
||||
* The list opens inside the inspector rather than floating over the page: the
|
||||
* inspector scrolls, and a popover pinned to a control that scrolls away is a
|
||||
* popover pointing at nothing.
|
||||
*/
|
||||
|
||||
function FontRow({
|
||||
id,
|
||||
on,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
id: BookFontId;
|
||||
on: boolean;
|
||||
weight: number;
|
||||
onPick: () => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
const face = BOOK_FONTS[id];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
|
||||
onClick={onPick}
|
||||
title={face.name}
|
||||
>
|
||||
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
|
||||
{face.name}
|
||||
</span>
|
||||
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
|
||||
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FontGroup({
|
||||
label,
|
||||
ids,
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
label: string;
|
||||
ids: BookFontId[];
|
||||
font: string;
|
||||
weight: number;
|
||||
onPick: (id: BookFontId) => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
if (!ids.length) return null;
|
||||
return (
|
||||
<div className="st-fontpick-group" role="group" aria-label={label}>
|
||||
<div className="st-fontpick-label">{label}</div>
|
||||
{ids.map((id) => (
|
||||
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The families the open book is set in, read from the document the Studio holds. */
|
||||
function useFontsInBook(): BookFontId[] {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
|
||||
}
|
||||
|
||||
export function StudioFontPicker({
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
font: string;
|
||||
weight: number;
|
||||
onPick: (patch: FontPick) => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const listId = useId();
|
||||
const inBook = useFontsInBook();
|
||||
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
|
||||
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
|
||||
const found = matchFonts(query, groupName);
|
||||
|
||||
// Opened on purpose, so typing goes straight to the search.
|
||||
useEffect(() => {
|
||||
if (open) search.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
trigger.current?.focus();
|
||||
};
|
||||
const choose = (id: BookFontId) => {
|
||||
onPick(pickFont(id, weight));
|
||||
close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className="st-fontpick-current"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
>
|
||||
<span
|
||||
className="st-fontpick-face"
|
||||
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
|
||||
>
|
||||
{current.name}
|
||||
</span>
|
||||
<span className="st-fontpick-span">{groupName(current.category)}</span>
|
||||
<ChevronDown size={14} className="st-fontpick-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="st-fontpick-panel">
|
||||
<div className="st-search st-fontpick-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
ref={search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.fonts.search')}
|
||||
aria-label={t('journey.studio.fonts.search')}
|
||||
aria-controls={listId}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
|
||||
{!query && (
|
||||
<FontGroup
|
||||
label={t('journey.studio.fonts.used')}
|
||||
ids={inBook}
|
||||
font={font}
|
||||
weight={weight}
|
||||
onPick={choose}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{BOOK_FONT_CATEGORIES.map((category) => (
|
||||
<FontGroup
|
||||
key={category}
|
||||
label={groupName(category)}
|
||||
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
|
||||
font={font}
|
||||
weight={weight}
|
||||
onPick={choose}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { Swatches } from './StudioSwatches'
|
||||
import { formatBookCoords } from '@trek/shared'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
import { italicTitle } from './fontPicker'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -323,7 +325,7 @@ export function StudioInspector({
|
||||
nothing about a typeface — and grouped, because "which serif"
|
||||
is a different question from "serif or sans".
|
||||
*/}
|
||||
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -344,7 +346,7 @@ export function StudioInspector({
|
||||
<button type="button"
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={t('journey.studio.italic')}
|
||||
title={italicTitle(el.font, el.weight, t)}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,8 @@ export default function StudioShell() {
|
||||
const [bookView, setBookView] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
|
||||
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
||||
useWarmBookFaces()
|
||||
|
||||
if (s.isMobile) {
|
||||
return createPortal(
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
import { sourceIdOf, useMapSources } from './mapSources'
|
||||
import { printDpi, tileView } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
@@ -82,7 +83,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
* wrong typeface and no way to fix it.
|
||||
*/}
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={false}>
|
||||
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine
|
||||
font={el.font}
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { BOOK_FONT_ORDER } from './bookFonts';
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls';
|
||||
import { italicTitle } from './fontPicker';
|
||||
import { WeightLine } from './StudioTypeControls';
|
||||
|
||||
const t = (k: string) => k;
|
||||
|
||||
describe('FontButtons', () => {
|
||||
it('offers every bundled family in the picker order and marks the current one', () => {
|
||||
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
|
||||
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
|
||||
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
|
||||
});
|
||||
|
||||
it('keeps the weight when the new family ships it', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
|
||||
});
|
||||
|
||||
it('moves the weight to one the new family really has', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeightLine', () => {
|
||||
it('greys the weights the family does not ship and says why', () => {
|
||||
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
|
||||
it('steps through only the weights the family has and says the drawn one by name and number', () => {
|
||||
render(<WeightLine font="oswald" weight={700} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
|
||||
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
|
||||
expect(bold.disabled).toBe(true);
|
||||
expect(bold.title).toBe('journey.studio.weightMissing');
|
||||
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
|
||||
expect(regular.disabled).toBe(false);
|
||||
expect(regular.getAttribute('aria-checked')).toBe('true');
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement;
|
||||
// Oswald ships 200 to 700: six stops, the last one drawn.
|
||||
expect([slider.min, slider.max, slider.value]).toEqual(['0', '5', '5']);
|
||||
expect(slider.getAttribute('aria-valuetext')).toBe('journey.studio.weight.700 700');
|
||||
expect(screen.getByText('journey.studio.weight.700 700')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hands the picked weight back', () => {
|
||||
it('hands back the weight at the stop picked', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: '600' }));
|
||||
expect(onPick).toHaveBeenCalledWith(600);
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } });
|
||||
expect(onPick).toHaveBeenCalledWith(100);
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '8' } });
|
||||
expect(onPick).toHaveBeenLastCalledWith(900);
|
||||
});
|
||||
|
||||
it('shows the one weight of a family that has one, with nothing to drag, and the weight it really draws', () => {
|
||||
render(<WeightLine font="bebas" weight={700} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.queryByRole('slider')).toBeNull();
|
||||
expect(screen.getByText('journey.studio.weightOnly')).toBeTruthy();
|
||||
expect(screen.getByText('journey.studio.weight.400 400')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reads a family this build does not know as the default one', () => {
|
||||
render(<WeightLine font="gone" weight={500} onPick={vi.fn()} t={t} />);
|
||||
expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('italicTitle', () => {
|
||||
it('says when the browser slants a family that has no italic of its own', () => {
|
||||
expect(italicTitle('garamond', 400, t)).toBe('journey.studio.italic');
|
||||
expect(italicTitle('sans', 400, t)).toBe('journey.studio.italicSlanted');
|
||||
expect(italicTitle('playfair', 300, t)).toBe('journey.studio.italic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +1,27 @@
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import type { BookFontId } from './bookFonts';
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
|
||||
import { Choice, Line } from './StudioControls';
|
||||
import type { BookWeight } from '@trek/shared';
|
||||
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
|
||||
import { Line } from './StudioControls';
|
||||
|
||||
/**
|
||||
* The typeface buttons and the weight line, shared by the text inspector and
|
||||
* the travel inspector so both set type the same way.
|
||||
* The weight line, shared by the text inspector and the travel inspector so
|
||||
* both set type the same way. The typeface itself is picked in
|
||||
* StudioFontPicker.tsx, the italic's title comes from fontPicker.ts.
|
||||
*/
|
||||
|
||||
/** The weight set the book schema allows, taken from it so there is one list. */
|
||||
type BookWeight = BookTextElement['weight'];
|
||||
type T = (key: string) => string;
|
||||
|
||||
const WEIGHTS = [400, 500, 600, 700] as const;
|
||||
/** The weights a family ships, lightest first; Poppins' for a family this build does not know. */
|
||||
const weightsOf = (font: string) => (BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans).weights;
|
||||
|
||||
/** One button per bundled family, each set in its own face. */
|
||||
export function FontButtons({
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
}: {
|
||||
font: string;
|
||||
weight: BookWeight;
|
||||
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-fonts">
|
||||
{BOOK_FONT_ORDER.map((id) => {
|
||||
const face = BOOK_FONTS[id];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`st-font ${font === id ? 'is-on' : ''}`}
|
||||
style={{ fontFamily: face.stack }}
|
||||
onClick={() =>
|
||||
onPick({
|
||||
font: id,
|
||||
// A family that does not ship this weight would render a
|
||||
// synthesised bold, a smeared regular in print, so the
|
||||
// weight moves to the nearest one it really has.
|
||||
weight: nearestWeight(id, weight) as BookWeight,
|
||||
})
|
||||
}
|
||||
title={face.name}
|
||||
>
|
||||
{face.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The four weights, with the ones the family does not ship greyed out. */
|
||||
/**
|
||||
* A slider over the weights the family really has, and only those.
|
||||
*
|
||||
* Nine chips from 100 to 900 with most of them greyed out read as a broken
|
||||
* control for a family with two weights, and as a wall for one with nine.
|
||||
* The slider steps through what exists, says the weight by its name and its
|
||||
* number, and a family with a single weight shows that weight and nothing to
|
||||
* drag.
|
||||
*/
|
||||
export function WeightLine({
|
||||
font,
|
||||
weight,
|
||||
@@ -60,22 +29,36 @@ export function WeightLine({
|
||||
t,
|
||||
}: {
|
||||
font: string;
|
||||
weight: BookWeight;
|
||||
weight: number;
|
||||
onPick: (weight: BookWeight) => void;
|
||||
t: (k: string) => string;
|
||||
t: T;
|
||||
}) {
|
||||
const weights = weightsOf(font);
|
||||
const drawn = nearestWeight(font, weight);
|
||||
const at = Math.max(0, weights.indexOf(drawn as BookWeight));
|
||||
const word = `${t(`journey.studio.weight.${drawn}`)} ${drawn}`;
|
||||
return (
|
||||
<Line label={t('journey.studio.weight')}>
|
||||
<Choice
|
||||
value={weight}
|
||||
options={WEIGHTS.map((w) => ({
|
||||
value: w,
|
||||
label: String(w),
|
||||
disabled: !hasWeight(font, w),
|
||||
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
<div className="st-field st-weight">
|
||||
{weights.length > 1 ? (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={weights.length - 1}
|
||||
step={1}
|
||||
value={at}
|
||||
aria-label={t('journey.studio.weight')}
|
||||
aria-valuetext={word}
|
||||
onChange={(e) => {
|
||||
const next = weights[Number(e.target.value)];
|
||||
if (next !== undefined) onPick(next);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="st-weight-only">{t('journey.studio.weightOnly')}</span>
|
||||
)}
|
||||
<span className="st-weight-value">{word}</span>
|
||||
</div>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
@@ -140,8 +140,8 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { weight: number }) => el.weight
|
||||
const light = (el: { weight: number }) => Math.max(400, el.weight - 100)
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
|
||||
@@ -1,43 +1,209 @@
|
||||
/**
|
||||
* The @font-face rules behind the book's typefaces.
|
||||
*
|
||||
* bookFonts.ts names the seven families; this is where the faces they need
|
||||
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
|
||||
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
|
||||
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
|
||||
* they are down here rather than up there.
|
||||
* The catalogue in @trek/shared (book-fonts.data.ts) names the families, the
|
||||
* weights each ships and the weights it has a real italic for; this is where
|
||||
* those faces arrive, one import per face. Poppins 300 to 700 and
|
||||
* MuseoModerno 400, 700 and 800 come with the app chrome in main.tsx and are
|
||||
* not repeated here: importing a face twice only duplicates every rule.
|
||||
*
|
||||
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
|
||||
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
|
||||
* cost the app's first paint nothing. Once that chunk is loaded they are
|
||||
* ordinary stylesheets on the document, which is what lets printSheets copy
|
||||
* them into the print frame with everything else.
|
||||
* is set in Lora or Anton, so the rules travel with the Studio chunk and cost
|
||||
* the app's first paint nothing. Once that chunk is loaded they are ordinary
|
||||
* stylesheets on the document, which is what lets printSheets copy them into
|
||||
* the print frame with everything else.
|
||||
*
|
||||
* Upright only, as everywhere else in the app: the editor's italic toggle has
|
||||
* always been the browser's synthetic slant, and a second file per weight is a
|
||||
* lot of download to change that.
|
||||
* A rule is not a download. The browser fetches a face's file only when text
|
||||
* on the page is set in it, and only the subsets (latin, latin-ext, cyrillic
|
||||
* and so on) whose characters that text uses, so a book set in two families
|
||||
* loads two families however long this list grows. The print frame loads the
|
||||
* same way, and the assistant's page snapshot embeds only the faces a page
|
||||
* draws (snapshotFonts.ts).
|
||||
*
|
||||
* Real italics for the serifs, where the browser's slant reads as a mistake;
|
||||
* the sans faces keep the synthetic oblique they always had, which saves a
|
||||
* second file per weight nobody would tell apart.
|
||||
*/
|
||||
import '@fontsource/poppins/100.css'
|
||||
import '@fontsource/poppins/200.css'
|
||||
import '@fontsource/poppins/800.css'
|
||||
import '@fontsource/poppins/900.css'
|
||||
|
||||
import '@fontsource/inter/100.css'
|
||||
import '@fontsource/inter/200.css'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/600.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
import '@fontsource/inter/800.css'
|
||||
import '@fontsource/inter/900.css'
|
||||
|
||||
import '@fontsource/montserrat/100.css'
|
||||
import '@fontsource/montserrat/200.css'
|
||||
import '@fontsource/montserrat/300.css'
|
||||
import '@fontsource/montserrat/400.css'
|
||||
import '@fontsource/montserrat/500.css'
|
||||
import '@fontsource/montserrat/600.css'
|
||||
import '@fontsource/montserrat/700.css'
|
||||
import '@fontsource/montserrat/800.css'
|
||||
import '@fontsource/montserrat/900.css'
|
||||
|
||||
import '@fontsource/lora/400.css'
|
||||
import '@fontsource/lora/500.css'
|
||||
import '@fontsource/lora/600.css'
|
||||
import '@fontsource/lora/700.css'
|
||||
import '@fontsource/lora/400-italic.css'
|
||||
import '@fontsource/lora/500-italic.css'
|
||||
import '@fontsource/lora/600-italic.css'
|
||||
import '@fontsource/lora/700-italic.css'
|
||||
|
||||
import '@fontsource/eb-garamond/400.css'
|
||||
import '@fontsource/eb-garamond/500.css'
|
||||
import '@fontsource/eb-garamond/600.css'
|
||||
import '@fontsource/eb-garamond/700.css'
|
||||
import '@fontsource/eb-garamond/800.css'
|
||||
import '@fontsource/eb-garamond/400-italic.css'
|
||||
import '@fontsource/eb-garamond/500-italic.css'
|
||||
import '@fontsource/eb-garamond/600-italic.css'
|
||||
import '@fontsource/eb-garamond/700-italic.css'
|
||||
import '@fontsource/eb-garamond/800-italic.css'
|
||||
|
||||
import '@fontsource/playfair-display/400.css'
|
||||
import '@fontsource/playfair-display/500.css'
|
||||
import '@fontsource/playfair-display/600.css'
|
||||
import '@fontsource/playfair-display/700.css'
|
||||
import '@fontsource/playfair-display/800.css'
|
||||
import '@fontsource/playfair-display/900.css'
|
||||
import '@fontsource/playfair-display/400-italic.css'
|
||||
import '@fontsource/playfair-display/500-italic.css'
|
||||
import '@fontsource/playfair-display/600-italic.css'
|
||||
import '@fontsource/playfair-display/700-italic.css'
|
||||
import '@fontsource/playfair-display/800-italic.css'
|
||||
import '@fontsource/playfair-display/900-italic.css'
|
||||
|
||||
import '@fontsource/museomoderno/500.css'
|
||||
import '@fontsource/museomoderno/600.css'
|
||||
import '@fontsource/fraunces/100.css'
|
||||
import '@fontsource/fraunces/200.css'
|
||||
import '@fontsource/fraunces/300.css'
|
||||
import '@fontsource/fraunces/400.css'
|
||||
import '@fontsource/fraunces/500.css'
|
||||
import '@fontsource/fraunces/600.css'
|
||||
import '@fontsource/fraunces/700.css'
|
||||
import '@fontsource/fraunces/800.css'
|
||||
import '@fontsource/fraunces/900.css'
|
||||
import '@fontsource/fraunces/100-italic.css'
|
||||
import '@fontsource/fraunces/200-italic.css'
|
||||
import '@fontsource/fraunces/300-italic.css'
|
||||
import '@fontsource/fraunces/400-italic.css'
|
||||
import '@fontsource/fraunces/500-italic.css'
|
||||
import '@fontsource/fraunces/600-italic.css'
|
||||
import '@fontsource/fraunces/700-italic.css'
|
||||
import '@fontsource/fraunces/800-italic.css'
|
||||
import '@fontsource/fraunces/900-italic.css'
|
||||
|
||||
import '@fontsource/cormorant-garamond/300.css'
|
||||
import '@fontsource/cormorant-garamond/400.css'
|
||||
import '@fontsource/cormorant-garamond/500.css'
|
||||
import '@fontsource/cormorant-garamond/600.css'
|
||||
import '@fontsource/cormorant-garamond/700.css'
|
||||
import '@fontsource/cormorant-garamond/300-italic.css'
|
||||
import '@fontsource/cormorant-garamond/400-italic.css'
|
||||
import '@fontsource/cormorant-garamond/500-italic.css'
|
||||
import '@fontsource/cormorant-garamond/600-italic.css'
|
||||
import '@fontsource/cormorant-garamond/700-italic.css'
|
||||
|
||||
import '@fontsource/dm-serif-display/400.css'
|
||||
import '@fontsource/dm-serif-display/400-italic.css'
|
||||
|
||||
import '@fontsource/bodoni-moda/400.css'
|
||||
import '@fontsource/bodoni-moda/500.css'
|
||||
import '@fontsource/bodoni-moda/600.css'
|
||||
import '@fontsource/bodoni-moda/700.css'
|
||||
import '@fontsource/bodoni-moda/800.css'
|
||||
import '@fontsource/bodoni-moda/900.css'
|
||||
import '@fontsource/bodoni-moda/400-italic.css'
|
||||
import '@fontsource/bodoni-moda/500-italic.css'
|
||||
import '@fontsource/bodoni-moda/600-italic.css'
|
||||
import '@fontsource/bodoni-moda/700-italic.css'
|
||||
import '@fontsource/bodoni-moda/800-italic.css'
|
||||
import '@fontsource/bodoni-moda/900-italic.css'
|
||||
|
||||
import '@fontsource/bebas-neue/400.css'
|
||||
|
||||
import '@fontsource/anton/400.css'
|
||||
|
||||
import '@fontsource/oswald/200.css'
|
||||
import '@fontsource/oswald/300.css'
|
||||
import '@fontsource/oswald/400.css'
|
||||
import '@fontsource/oswald/500.css'
|
||||
import '@fontsource/oswald/600.css'
|
||||
import '@fontsource/oswald/700.css'
|
||||
|
||||
import '@fontsource/league-gothic/400.css'
|
||||
|
||||
import '@fontsource/big-shoulders-display/100.css'
|
||||
import '@fontsource/big-shoulders-display/200.css'
|
||||
import '@fontsource/big-shoulders-display/300.css'
|
||||
import '@fontsource/big-shoulders-display/400.css'
|
||||
import '@fontsource/big-shoulders-display/500.css'
|
||||
import '@fontsource/big-shoulders-display/600.css'
|
||||
import '@fontsource/big-shoulders-display/700.css'
|
||||
import '@fontsource/big-shoulders-display/800.css'
|
||||
import '@fontsource/big-shoulders-display/900.css'
|
||||
|
||||
import '@fontsource/archivo-black/400.css'
|
||||
|
||||
import '@fontsource/museomoderno/100.css'
|
||||
import '@fontsource/museomoderno/200.css'
|
||||
import '@fontsource/museomoderno/300.css'
|
||||
import '@fontsource/museomoderno/500.css'
|
||||
import '@fontsource/museomoderno/600.css'
|
||||
import '@fontsource/museomoderno/900.css'
|
||||
|
||||
import '@fontsource/shrikhand/400.css'
|
||||
|
||||
import '@fontsource/alfa-slab-one/400.css'
|
||||
|
||||
import '@fontsource/bricolage-grotesque/200.css'
|
||||
import '@fontsource/bricolage-grotesque/300.css'
|
||||
import '@fontsource/bricolage-grotesque/400.css'
|
||||
import '@fontsource/bricolage-grotesque/500.css'
|
||||
import '@fontsource/bricolage-grotesque/600.css'
|
||||
import '@fontsource/bricolage-grotesque/700.css'
|
||||
import '@fontsource/bricolage-grotesque/800.css'
|
||||
|
||||
import '@fontsource/abril-fatface/400.css'
|
||||
|
||||
import '@fontsource/caveat/400.css'
|
||||
import '@fontsource/caveat/500.css'
|
||||
import '@fontsource/caveat/600.css'
|
||||
import '@fontsource/caveat/700.css'
|
||||
|
||||
import '@fontsource/dancing-script/400.css'
|
||||
import '@fontsource/dancing-script/500.css'
|
||||
import '@fontsource/dancing-script/600.css'
|
||||
import '@fontsource/dancing-script/700.css'
|
||||
|
||||
import '@fontsource/pacifico/400.css'
|
||||
|
||||
import '@fontsource/great-vibes/400.css'
|
||||
|
||||
import '@fontsource/sacramento/400.css'
|
||||
|
||||
import '@fontsource/kaushan-script/400.css'
|
||||
|
||||
import '@fontsource/allura/400.css'
|
||||
|
||||
import '@fontsource/parisienne/400.css'
|
||||
|
||||
import '@fontsource/satisfy/400.css'
|
||||
|
||||
import '@fontsource/yellowtail/400.css'
|
||||
|
||||
import '@fontsource/homemade-apple/400.css'
|
||||
|
||||
import '@fontsource/permanent-marker/400.css'
|
||||
|
||||
import '@fontsource/dm-mono/300.css'
|
||||
import '@fontsource/dm-mono/400.css'
|
||||
import '@fontsource/dm-mono/500.css'
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { bookSample, faceSpec, useWarmBookFaces, warmBookFaces } from './bookFontWarm';
|
||||
|
||||
/**
|
||||
* The open book's faces go into the offline cache as it opens
|
||||
* (FE-STUDIO-FONTWARM): each face it uses is asked for once, with the book's
|
||||
* own characters, and a face that does not load costs nothing.
|
||||
*/
|
||||
|
||||
const frame = { x: 10, y: 10, w: 80, h: 20 };
|
||||
const text = (id: string, words: string, over: Record<string, unknown> = {}) =>
|
||||
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, ...over }) as BookElement;
|
||||
|
||||
function book(elements: BookElement[]): BookDocument {
|
||||
const doc = blankBook({ title: 'Kraków', newId: (p) => `${p}-x` });
|
||||
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
|
||||
return { ...doc, spreads: [{ ...cover, elements }, ...rest] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.setState({ doc: null });
|
||||
});
|
||||
|
||||
describe('the faces of a book', () => {
|
||||
it('FE-STUDIO-FONTWARM-001: samples each character of the book once, the title and the figures with it', () => {
|
||||
const sample = bookSample(book([text('t1', 'Łódź Łódź'), text('t2', ' ')]));
|
||||
expect(sample).toContain('Ł');
|
||||
expect(sample).toContain('ó');
|
||||
expect(sample).toContain('K');
|
||||
expect(sample.startsWith('0123456789')).toBe(true);
|
||||
expect(new Set(sample).size).toBe(sample.length);
|
||||
expect(sample).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-002: names a face the way document.fonts reads it', () => {
|
||||
expect(faceSpec({ font: 'serif', weight: 700, italic: true })).toBe('italic 700 16px "Lora"');
|
||||
expect(faceSpec({ font: 'caveat', weight: 400, italic: false })).toBe('400 16px "Caveat"');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-003: asks for every face once, counts the ones that loaded and never rejects', async () => {
|
||||
const doc = book([
|
||||
text('t1', 'Porto', { font: 'caveat', weight: 700 }),
|
||||
text('t2', 'Douro', { font: 'serif', italic: true }),
|
||||
]);
|
||||
const load = vi.fn(async (spec: string, _text?: string) => (spec.includes('Caveat') ? [{} as FontFace] : []));
|
||||
expect(await warmBookFaces(doc, { load })).toBe(1);
|
||||
const specs = load.mock.calls.map(([spec]) => spec);
|
||||
expect(specs).toEqual(expect.arrayContaining(['700 16px "Caveat"', 'italic 400 16px "Lora"']));
|
||||
expect(load.mock.calls[0]?.[1]).toContain('P');
|
||||
|
||||
const broken = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
expect(await warmBookFaces(doc, { load: broken })).toBe(0);
|
||||
expect(await warmBookFaces(doc, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-004: warms when the set of faces changes, not on every edit of the words', async () => {
|
||||
const load = vi.fn(async () => [] as FontFace[]);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load } });
|
||||
const doc = book([text('t1', 'Porto', { font: 'anton' })]);
|
||||
act(() => useStudioStore.setState({ doc }));
|
||||
renderHook(() => useWarmBookFaces());
|
||||
await act(async () => {});
|
||||
const first = load.mock.calls.length;
|
||||
expect(first).toBeGreaterThan(0);
|
||||
|
||||
const retyped = book([text('t1', 'Porto, again', { font: 'anton' })]);
|
||||
await act(async () => useStudioStore.setState({ doc: retyped }));
|
||||
expect(load.mock.calls.length).toBe(first);
|
||||
|
||||
const another = book([text('t1', 'Porto', { font: 'anton' }), text('t2', 'Lisboa', { font: 'vibes' })]);
|
||||
await act(async () => useStudioStore.setState({ doc: another }));
|
||||
expect(load.mock.calls.length).toBeGreaterThan(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BOOK_FONT_FACES, bookFacesUsed, type BookDocument, type BookFaceUse } from '@trek/shared';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
|
||||
/**
|
||||
* The faces of the open book, loaded as soon as it opens, so it reads in its
|
||||
* own type offline as well.
|
||||
*
|
||||
* The service worker installs the latin files of the seven families the book
|
||||
* shipped with (vite.config.js); every other face is cached by its runtime
|
||||
* rule the first time it is fetched. A face the browser only fetches once
|
||||
* text is drawn in it would be cached only for the pages someone happened to
|
||||
* look at, so a book opened online and read offline later would show its
|
||||
* third chapter in the fallback. Asking for every face the book uses, with the
|
||||
* characters it sets, fetches each one now, through the worker, into that
|
||||
* cache: a face nobody uses is never downloaded.
|
||||
*/
|
||||
|
||||
/** The book's own characters, for asking the right subsets of each face: its words, its title, the figures. */
|
||||
export function bookSample(doc: BookDocument): string {
|
||||
const seen = new Set<string>('0123456789');
|
||||
const take = (s: string) => {
|
||||
for (const ch of s) if (ch.trim()) seen.add(ch);
|
||||
};
|
||||
take(doc.title);
|
||||
for (const spread of doc.spreads) {
|
||||
for (const el of spread.elements) if (el.kind === 'text') take(el.text);
|
||||
}
|
||||
// A face's subsets are chosen by its unicode ranges, so one character of each is enough; the cap keeps it short.
|
||||
return [...seen].slice(0, 512).join('');
|
||||
}
|
||||
|
||||
/** One face as the CSS font shorthand `document.fonts.load` takes. */
|
||||
export function faceSpec(use: BookFaceUse): string {
|
||||
const family = BOOK_FONT_FACES[use.font].name;
|
||||
return `${use.italic ? 'italic ' : ''}${use.weight} 16px "${family}"`;
|
||||
}
|
||||
|
||||
type FontLoader = Pick<FontFaceSet, 'load'>;
|
||||
|
||||
/** Fetch every face the book needs; resolves with how many loaded, never rejects. */
|
||||
export async function warmBookFaces(
|
||||
doc: BookDocument,
|
||||
fonts: FontLoader | undefined = globalThis.document?.fonts
|
||||
): Promise<number> {
|
||||
if (!fonts) return 0;
|
||||
const sample = bookSample(doc);
|
||||
const loads = await Promise.allSettled(bookFacesUsed(doc).map((use) => fonts.load(faceSpec(use), sample)));
|
||||
return loads.filter((r) => r.status === 'fulfilled' && r.value.length > 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warms the faces of the book the Studio holds whenever the set of faces it
|
||||
* uses changes; typing into a text does not, so an edit costs nothing here.
|
||||
*/
|
||||
export function useWarmBookFaces(): void {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
|
||||
useEffect(() => {
|
||||
const current = useStudioStore.getState().doc;
|
||||
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
|
||||
if (key && current) void warmBookFaces(current);
|
||||
}, [key]);
|
||||
}
|
||||
@@ -1,118 +1,51 @@
|
||||
import {
|
||||
BOOK_FONT_CATEGORIES, BOOK_FONT_FACES, BOOK_FONTS_IDS, bookFontHasItalic, bookFontHasWeight, bookLighterWeight,
|
||||
nearestBookWeight,
|
||||
type BookFontCategory, type BookFontFace, type BookFontFamily,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The book's typefaces.
|
||||
* The book's typefaces, as the Studio draws them.
|
||||
*
|
||||
* ── Why this file exists ──────────────────────────────────────────────────
|
||||
*
|
||||
* The font stack used to name Georgia for its serif. Georgia is a system font:
|
||||
* it is on most desktops and on no Linux container, so the print renderer would
|
||||
* quietly substitute something else and a book proofread in one typeface would
|
||||
* print in another. The existing Journey PDF export has the same bug today — it
|
||||
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
|
||||
* print in another.
|
||||
*
|
||||
* So every family here is self-hosted through @fontsource, all of them OFL, and
|
||||
* So every family is self-hosted through @fontsource, OFL or Apache 2.0, and
|
||||
* every stack ends in a generic rather than in another named font nobody
|
||||
* shipped. What the editor draws is what the renderer has.
|
||||
*
|
||||
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
|
||||
* here and not imported there falls back to exactly the system font this file
|
||||
* was written against, which is how five of the seven shipped (#2183).
|
||||
*
|
||||
* ── Why these seven ───────────────────────────────────────────────────────
|
||||
*
|
||||
* A photo book needs more than one voice and fewer than a menu. Two workhorse
|
||||
* sans faces, three serifs across the range from bookish to high-contrast, and
|
||||
* two display faces for a cover. Anything past that is a font picker rather
|
||||
* than a design decision.
|
||||
* The catalogue itself (names, stacks, weights, italics, roles) lives in
|
||||
* @trek/shared (book-fonts.data.ts), because the findings, the design engine
|
||||
* and the assistant read the same families. This file only gives the Studio
|
||||
* its view of it; the faces are loaded next door in bookFontFaces.ts.
|
||||
*/
|
||||
|
||||
export interface BookFont {
|
||||
export type BookFontId = BookFontFamily
|
||||
|
||||
export interface BookFont extends BookFontFace {
|
||||
id: BookFontId
|
||||
/** What it is called, shown as itself in the picker. */
|
||||
name: string
|
||||
/** The CSS stack. Ends generic — never in a font that is not bundled. */
|
||||
stack: string
|
||||
/** Where it belongs, for grouping the picker. */
|
||||
kind: 'sans' | 'serif' | 'display'
|
||||
/**
|
||||
* Weights actually loaded. Asking for 700 on a family that ships one weight
|
||||
* gets a synthesised bold, which prints as a smeared version of the regular.
|
||||
*/
|
||||
weights: number[]
|
||||
}
|
||||
|
||||
export type BookFontId =
|
||||
| 'sans' | 'serif' | 'display'
|
||||
| 'inter' | 'garamond' | 'playfair' | 'bebas'
|
||||
|
||||
/**
|
||||
* `sans`, `serif` and `display` keep their names because documents already
|
||||
* contain them — the three original slots, now pointing at bundled families
|
||||
* rather than at whatever the machine happened to have.
|
||||
*/
|
||||
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
|
||||
sans: {
|
||||
id: 'sans',
|
||||
name: 'Poppins',
|
||||
stack: '"Poppins", system-ui, sans-serif',
|
||||
kind: 'sans',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
inter: {
|
||||
id: 'inter',
|
||||
name: 'Inter',
|
||||
stack: '"Inter", system-ui, sans-serif',
|
||||
kind: 'sans',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
serif: {
|
||||
id: 'serif',
|
||||
name: 'Lora',
|
||||
stack: '"Lora", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
garamond: {
|
||||
id: 'garamond',
|
||||
name: 'EB Garamond',
|
||||
stack: '"EB Garamond", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
playfair: {
|
||||
id: 'playfair',
|
||||
name: 'Playfair Display',
|
||||
stack: '"Playfair Display", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
display: {
|
||||
id: 'display',
|
||||
name: 'MuseoModerno',
|
||||
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
|
||||
kind: 'display',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
bebas: {
|
||||
id: 'bebas',
|
||||
name: 'Bebas Neue',
|
||||
stack: '"Bebas Neue", Impact, sans-serif',
|
||||
kind: 'display',
|
||||
// One weight only. Asking for bold gets a synthesised one, which is why
|
||||
// the inspector greys the weights this family does not have.
|
||||
weights: [400],
|
||||
},
|
||||
}
|
||||
export const BOOK_FONTS = Object.fromEntries(
|
||||
BOOK_FONTS_IDS.map(id => [id, { id, ...BOOK_FONT_FACES[id] }]),
|
||||
) as unknown as Record<BookFontId, BookFont>
|
||||
|
||||
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
|
||||
// app's voice rather than the book's, and a book set in the interface font
|
||||
// reads as a screenshot of the app.
|
||||
|
||||
/** The picker's order: workhorses first, display last. */
|
||||
export const BOOK_FONT_ORDER: BookFontId[] = [
|
||||
'sans', 'inter',
|
||||
'serif', 'garamond', 'playfair',
|
||||
'display', 'bebas',
|
||||
]
|
||||
/** The picker's order: by category (sans first, mono last), the catalogue's order within one. */
|
||||
export const BOOK_FONT_ORDER: BookFontId[] = BOOK_FONT_CATEGORIES.flatMap(category =>
|
||||
BOOK_FONTS_IDS.filter(id => BOOK_FONT_FACES[id].category === category))
|
||||
|
||||
/** The families of one picker group, in picker order. */
|
||||
export function fontsIn(category: BookFontCategory): BookFontId[] {
|
||||
return BOOK_FONT_ORDER.filter(id => BOOK_FONTS[id].category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS stack for a font id.
|
||||
@@ -126,20 +59,20 @@ export function fontStack(id: string): string {
|
||||
}
|
||||
|
||||
/** Whether a family actually ships the weight being asked for. */
|
||||
export function hasWeight(id: string, weight: number): boolean {
|
||||
const font = BOOK_FONTS[id as BookFontId]
|
||||
return font ? font.weights.includes(weight) : true
|
||||
}
|
||||
export const hasWeight = bookFontHasWeight
|
||||
|
||||
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
|
||||
export const hasItalic = bookFontHasItalic
|
||||
|
||||
/**
|
||||
* The nearest weight a family really has.
|
||||
*
|
||||
* A synthesised bold is a smeared regular in print, so a family with one weight
|
||||
* gets that weight rather than a request the renderer has to fake.
|
||||
* gets that weight rather than a request the renderer has to fake. The
|
||||
* renderer draws every element through this as well, so a book whose weight
|
||||
* was set before its family changed still prints in a face that exists.
|
||||
*/
|
||||
export function nearestWeight(id: string, weight: number): number {
|
||||
const font = BOOK_FONTS[id as BookFontId]
|
||||
if (!font || font.weights.includes(weight)) return weight
|
||||
return font.weights.reduce((best, w) =>
|
||||
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
|
||||
}
|
||||
export const nearestWeight = nearestBookWeight
|
||||
|
||||
/** The lighter weight a travel element sets beside its figures, one the family has. */
|
||||
export const lighterWeight = bookLighterWeight
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BOOK_FONT_CATEGORIES, type BookFontCategory, type BookWeight } from '@trek/shared';
|
||||
import { BOOK_FONTS, fontsIn, hasItalic, nearestWeight, type BookFontId } from './bookFonts';
|
||||
|
||||
/**
|
||||
* The type controls' decisions, apart from their markup: which families a
|
||||
* search finds, the weight a newly picked family is set at, and what the row
|
||||
* and the italic button say. StudioFontPicker.tsx and StudioTypeControls.tsx
|
||||
* draw them.
|
||||
*/
|
||||
|
||||
export type FontT = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface FontPick {
|
||||
font: BookFontId;
|
||||
weight: BookWeight;
|
||||
}
|
||||
|
||||
/** The family, its weight moved onto one the new family has. */
|
||||
export function pickFont(id: BookFontId, weight: number): FontPick {
|
||||
// A family that does not ship this weight would render a synthesised bold,
|
||||
// a smeared regular in print, so the weight moves to the nearest one it has.
|
||||
return { font: id, weight: nearestWeight(id, weight) as BookWeight };
|
||||
}
|
||||
|
||||
/** The families that match a search, by name, id or group, in picker order. */
|
||||
export function matchFonts(query: string, groupName: (c: BookFontCategory) => string): BookFontId[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return BOOK_FONT_CATEGORIES.flatMap((category) => {
|
||||
const group = groupName(category).toLowerCase();
|
||||
return fontsIn(category).filter(
|
||||
(id) => !q || BOOK_FONTS[id].name.toLowerCase().includes(q) || id.includes(q) || group.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The weights a family has, as its row says them: one number, or the first and the last. */
|
||||
export function weightSpan(id: BookFontId, t: FontT): string {
|
||||
const ws = BOOK_FONTS[id].weights;
|
||||
const first = ws[0] ?? 400;
|
||||
const last = ws[ws.length - 1] ?? first;
|
||||
return first === last ? String(first) : t('journey.studio.fonts.range', { from: first, to: last });
|
||||
}
|
||||
|
||||
/** The italic button's words: plain where the family has a real italic, the reason where the browser slants it. */
|
||||
export function italicTitle(font: string, weight: number, t: FontT): string {
|
||||
return hasItalic(font, nearestWeight(font, weight)) ? t('journey.studio.italic') : t('journey.studio.italicSlanted');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
|
||||
import { snapshotFontCss } from './snapshotFonts';
|
||||
|
||||
/**
|
||||
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
|
||||
@@ -126,6 +127,8 @@ export async function snapshotSpread(
|
||||
hiddenPhotoIds: ReadonlySet<number>
|
||||
): Promise<PageSnapshot | null> {
|
||||
await picturesLoaded(root, hiddenPhotoIds);
|
||||
// Only the faces the page draws travel with it (snapshotFonts.ts); without any, the library looks for itself.
|
||||
const fontCss = await snapshotFontCss(root);
|
||||
// A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
|
||||
const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
|
||||
const { domToBlob } = await import('modern-screenshot');
|
||||
@@ -137,6 +140,7 @@ export async function snapshotSpread(
|
||||
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
|
||||
filter: (node) => keepNode(node, hiddenPhotoIds),
|
||||
timeout: LOAD_TIMEOUT_MS,
|
||||
...(fontCss ? { font: { cssText: fontCss } } : {}),
|
||||
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
|
||||
});
|
||||
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
coversAny,
|
||||
cssWeight,
|
||||
faceRulesOf,
|
||||
faceUsesOf,
|
||||
parseFaceRule,
|
||||
parseUnicodeRange,
|
||||
pickFaceRules,
|
||||
snapshotFontCss,
|
||||
type FaceRule,
|
||||
} from './snapshotFonts';
|
||||
|
||||
/**
|
||||
* The faces a page snapshot carries (FE-STUDIO-SNAPFONT): what the page draws
|
||||
* is read off the DOM, the browser's choice of rule is repeated, and only the
|
||||
* faces it would pick go into the image, each with its file inline.
|
||||
*/
|
||||
|
||||
const BASE = 'https://trek.example/assets/app.css';
|
||||
const LATIN = 'U+0000-00FF, U+0131, U+0152-0153';
|
||||
const EXT = 'U+0100-02BA, U+1E00-1E9F';
|
||||
|
||||
const rule = (family: string, weight: number, italic: boolean, range: string, file: string): FaceRule => ({
|
||||
family,
|
||||
weight,
|
||||
italic,
|
||||
unicodeRange: range,
|
||||
url: `https://trek.example/assets/${file}`,
|
||||
});
|
||||
|
||||
const RULES = [
|
||||
rule('Lora', 400, false, LATIN, 'lora-latin-400-normal.woff2'),
|
||||
rule('Lora', 400, false, EXT, 'lora-latin-ext-400-normal.woff2'),
|
||||
rule('Lora', 700, false, LATIN, 'lora-latin-700-normal.woff2'),
|
||||
rule('Lora', 400, true, LATIN, 'lora-latin-400-italic.woff2'),
|
||||
rule('Caveat', 400, false, LATIN, 'caveat-latin-400-normal.woff2'),
|
||||
rule('Anton', 400, false, LATIN, 'anton-latin-400-normal.woff2'),
|
||||
];
|
||||
|
||||
const chars = (s: string) => new Set([...s].map((c) => c.codePointAt(0) ?? 0));
|
||||
const files = (picked: FaceRule[]) => picked.map((r) => r.url.split('/').pop()).sort();
|
||||
|
||||
function page(html: string): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = html;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('reading a rule', () => {
|
||||
it('FE-STUDIO-SNAPFONT-001: reads family, weight, slant, range and the woff2 file of an @font-face rule', () => {
|
||||
const css =
|
||||
"@font-face { font-family: 'Lora'; font-style: italic; font-display: swap; font-weight: 700; " +
|
||||
'src: url(./files/lora-latin-700-italic.woff2) format("woff2"), url(./files/lora-latin-700-italic.woff) format("woff"); ' +
|
||||
`unicode-range: ${LATIN}; }`;
|
||||
expect(parseFaceRule(css, BASE)).toEqual({
|
||||
family: 'Lora',
|
||||
weight: 700,
|
||||
italic: true,
|
||||
unicodeRange: LATIN,
|
||||
url: 'https://trek.example/assets/files/lora-latin-700-italic.woff2',
|
||||
});
|
||||
const plain = '@font-face { font-family: "Anton"; src: url("/assets/anton.woff") format("woff"); }';
|
||||
expect(parseFaceRule(plain, BASE)).toMatchObject({
|
||||
weight: 400,
|
||||
italic: false,
|
||||
unicodeRange: '',
|
||||
url: 'https://trek.example/assets/anton.woff',
|
||||
});
|
||||
expect(parseFaceRule('.x { color: red; }', BASE)).toBeNull();
|
||||
expect(parseFaceRule('@font-face { font-family: "NoFile"; }', BASE)).toBeNull();
|
||||
expect(parseFaceRule('@font-face { src: url(a.woff2); }', BASE)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-002: reads weights and unicode ranges as CSS does', () => {
|
||||
expect([cssWeight('bold'), cssWeight('normal'), cssWeight('300'), cssWeight('5000')]).toEqual([700, 400, 300, 400]);
|
||||
expect(parseUnicodeRange('U+0000-00FF, U+0131, U+4??')).toEqual([
|
||||
[0, 0xff],
|
||||
[0x131, 0x131],
|
||||
[0x400, 0x4ff],
|
||||
]);
|
||||
expect(parseUnicodeRange('U+zz, ')).toEqual([]);
|
||||
expect(coversAny(LATIN, chars('Lisboa'))).toBe(true);
|
||||
expect(coversAny(EXT, chars('Lisboa'))).toBe(false);
|
||||
expect(coversAny(EXT, chars('Łódź'))).toBe(true);
|
||||
expect(coversAny('', chars('anything'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choosing the faces', () => {
|
||||
it('FE-STUDIO-SNAPFONT-003: picks one rule per subset the text reaches, at the weight and slant it is set in', () => {
|
||||
const uses = new Map([
|
||||
['lora|700|false', chars('Porto')],
|
||||
['caveat|400|false', chars('travel')],
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, uses))).toEqual(['caveat-latin-400-normal.woff2', 'lora-latin-700-normal.woff2']);
|
||||
// Polish needs the extended subset beside the basic one.
|
||||
const polish = new Map([['lora|400|false', chars('Kraków Łódź')]]);
|
||||
expect(files(pickFaceRules(RULES, polish))).toEqual([
|
||||
'lora-latin-400-normal.woff2',
|
||||
'lora-latin-ext-400-normal.woff2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-004: falls back the way the browser does: the nearest weight, the upright face, nothing for an unknown family', () => {
|
||||
expect(files(pickFaceRules(RULES, new Map([['lora|600|false', chars('a')]])))).toEqual([
|
||||
'lora-latin-700-normal.woff2',
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, new Map([['lora|400|true', chars('a')]])))).toEqual([
|
||||
'lora-latin-400-italic.woff2',
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, new Map([['anton|400|true', chars('a')]])))).toEqual([
|
||||
'anton-latin-400-normal.woff2',
|
||||
]);
|
||||
expect(pickFaceRules(RULES, new Map([['georgia|400|false', chars('a')]]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading the page', () => {
|
||||
it('FE-STUDIO-SNAPFONT-005: reads every face the text under the root is set in, with its characters', () => {
|
||||
const root = page(
|
||||
'<p style="font-family: \'Lora\', Georgia, serif; font-weight: 700">Porto</p>' +
|
||||
'<svg><text style="font-family: Caveat, cursive; font-style: italic">ab</text></svg>' +
|
||||
'<p style="font-family: Lora; font-weight: bold">Té</p>' +
|
||||
'<p style="font-family: Anton"> </p>'
|
||||
);
|
||||
const uses = faceUsesOf(root);
|
||||
expect([...uses.keys()].sort()).toEqual(['caveat|400|true', 'lora|700|false']);
|
||||
expect(uses.get('lora|700|false')).toEqual(chars('PortoTé'));
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-006: reads the face rules of the stylesheets it may read, inside grouping rules too', () => {
|
||||
const face = (family: string) => ({
|
||||
cssText: `@font-face { font-family: "${family}"; src: url(/assets/${family}.woff2) format("woff2"); }`,
|
||||
});
|
||||
const fake = {
|
||||
baseURI: 'https://trek.example/',
|
||||
styleSheets: [
|
||||
{
|
||||
href: null,
|
||||
cssRules: [face('Lora'), { cssText: '.x{}' }, { cssText: '@media print {}', cssRules: [face('Anton')] }],
|
||||
},
|
||||
{
|
||||
href: 'https://cdn.example/a.css',
|
||||
get cssRules(): never {
|
||||
throw new DOMException('cross origin', 'SecurityError');
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as Document;
|
||||
expect(faceRulesOf(fake).map((r) => [r.family, r.url])).toEqual([
|
||||
['Lora', 'https://trek.example/assets/Lora.woff2'],
|
||||
['Anton', 'https://trek.example/assets/Anton.woff2'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the snapshot css', () => {
|
||||
it('FE-STUDIO-SNAPFONT-007: inlines exactly the faces the page draws, and leaves out one that will not load', async () => {
|
||||
const root = page(
|
||||
'<p style="font-family: Lora; font-weight: 700">Porto</p><p style="font-family: Caveat">travel</p>'
|
||||
);
|
||||
const load = vi.fn(async (url: string) =>
|
||||
url.includes('caveat') ? null : new Blob(['font'], { type: 'font/woff2' })
|
||||
);
|
||||
const css = await snapshotFontCss(root, load, RULES);
|
||||
expect(load.mock.calls.map(([url]) => url.split('/').pop()).sort()).toEqual([
|
||||
'caveat-latin-400-normal.woff2',
|
||||
'lora-latin-700-normal.woff2',
|
||||
]);
|
||||
expect(css).toContain('@font-face{font-family:"Lora";font-style:normal;font-weight:700;unicode-range:U+0000-00FF');
|
||||
expect(css).toContain('src:url(data:font/woff2;base64,');
|
||||
expect(css).not.toContain('Caveat');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-008: answers null when the page draws no bundled face or none of its files load, so the library looks for itself', async () => {
|
||||
const root = page('<p style="font-family: Georgia">Porto</p>');
|
||||
expect(await snapshotFontCss(root, vi.fn(), RULES)).toBeNull();
|
||||
const lora = page('<p style="font-family: Lora">Porto</p>');
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
expect(await snapshotFontCss(lora, failing, RULES)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* The typefaces a page snapshot for the assistant carries, and only those.
|
||||
*
|
||||
* modern-screenshot draws the page as an SVG image, and an SVG image cannot
|
||||
* reach the document's fonts, so every face it shows has to travel inside it.
|
||||
* Left to itself the library embeds every @font-face rule of every family the
|
||||
* page names: all nine weights of Montserrat, both styles, every script
|
||||
* subset, woff and woff2. With thirty four book families that is megabytes of
|
||||
* fonts per page for a picture whose text uses one weight of two families.
|
||||
*
|
||||
* So the snapshot reads what the page really draws (family, weight, slant and
|
||||
* the characters set in it), picks the one rule per subset the browser would
|
||||
* pick for that, and hands the library exactly those faces as data URLs. A
|
||||
* page set in Lora and Caveat embeds the latin files of Lora and Caveat, and
|
||||
* nothing else.
|
||||
*/
|
||||
|
||||
/** One @font-face rule, as far as the choice of face goes. */
|
||||
export interface FaceRule {
|
||||
family: string;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
/** The CSS unicode-range, or '' for a face that covers everything. */
|
||||
unicodeRange: string;
|
||||
/** The woff2 file, else the first file the rule names, resolved to a full URL. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** What the page draws in one face: the characters set in it. */
|
||||
type FaceUses = Map<string, Set<number>>;
|
||||
|
||||
const faceKey = (family: string, weight: number, italic: boolean) => `${family.toLowerCase()}|${weight}|${italic}`;
|
||||
|
||||
const unquote = (v: string) => v.trim().replace(/^["']|["']$/g, '');
|
||||
|
||||
/** A rule's descriptors by name, read from its text: `font-weight: 700` and the like. */
|
||||
function descriptors(css: string): Map<string, string> {
|
||||
const body = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}'));
|
||||
const out = new Map<string, string>();
|
||||
for (const part of body.split(';')) {
|
||||
const at = part.indexOf(':');
|
||||
if (at > 0) out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A CSS weight as a number: the keywords as CSS reads them, anything else as 400. */
|
||||
export function cssWeight(value: string): number {
|
||||
if (value === 'bold') return 700;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : 400;
|
||||
}
|
||||
|
||||
/** The file a rule's src names, the woff2 one first, resolved against where the stylesheet came from. */
|
||||
function sourceOf(src: string, base: string): string | null {
|
||||
const urls = [...src.matchAll(/url\((["']?)([^"')]+)\1\) ?(?:format\(["']?([\w-]+)["']?\))?/g)];
|
||||
const pick = urls.find((m) => m[3] === 'woff2') ?? urls[0];
|
||||
if (!pick?.[2]) return null;
|
||||
try {
|
||||
return new URL(pick[2], base).href;
|
||||
} catch {
|
||||
// A source no URL can be made of is a face the snapshot does without.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One @font-face rule read from its text, or null for any other rule or one that names no file. */
|
||||
export function parseFaceRule(css: string, base: string): FaceRule | null {
|
||||
if (!css.trimStart().toLowerCase().startsWith('@font-face')) return null;
|
||||
const d = descriptors(css);
|
||||
const family = unquote(d.get('font-family') ?? '');
|
||||
const url = sourceOf(d.get('src') ?? '', base);
|
||||
if (!family || !url) return null;
|
||||
return {
|
||||
family,
|
||||
weight: cssWeight(d.get('font-weight') || '400'),
|
||||
italic: /italic|oblique/i.test(d.get('font-style') ?? ''),
|
||||
unicodeRange: d.get('unicode-range') ?? '',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/** A unicode-range as code point spans: `U+0000-00FF, U+0131, U+4??` all read. */
|
||||
export function parseUnicodeRange(range: string): [number, number][] {
|
||||
return range
|
||||
.split(',')
|
||||
.map((part) => part.trim().replace(/^u\+/i, ''))
|
||||
.filter(Boolean)
|
||||
.flatMap((part): [number, number][] => {
|
||||
if (part.includes('?')) {
|
||||
return [[Number.parseInt(part.replace(/\?/g, '0'), 16), Number.parseInt(part.replace(/\?/g, 'F'), 16)]];
|
||||
}
|
||||
const [from, to] = part.split('-');
|
||||
const a = Number.parseInt(from ?? '', 16);
|
||||
const b = to === undefined ? a : Number.parseInt(to, 16);
|
||||
return Number.isFinite(a) && Number.isFinite(b) ? [[a, b]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a rule's range takes any of the characters; a rule without one takes them all. */
|
||||
export function coversAny(range: string, chars: ReadonlySet<number>): boolean {
|
||||
if (!range) return true;
|
||||
const spans = parseUnicodeRange(range);
|
||||
for (const c of chars) if (spans.some(([a, b]) => c >= a && c <= b)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules the browser would draw these uses with: per family and slant, the
|
||||
* nearest weight it has (the renderer only ever asks for weights a family
|
||||
* ships, so this is the exact one in practice), an upright face where there is
|
||||
* no italic, and of those only the subsets whose range the text reaches.
|
||||
*/
|
||||
export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceRule[] {
|
||||
const picked = new Map<string, FaceRule>();
|
||||
for (const [key, chars] of uses) {
|
||||
const [family = '', weightText = '400', italicText = 'false'] = key.split('|');
|
||||
const weight = Number(weightText);
|
||||
const ofFamily = rules.filter((r) => r.family.toLowerCase() === family);
|
||||
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
|
||||
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
|
||||
if (!styled.length) continue;
|
||||
const nearest = styled.reduce((best, r) =>
|
||||
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
|
||||
).weight;
|
||||
for (const rule of styled) {
|
||||
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
|
||||
}
|
||||
}
|
||||
return [...picked.values()];
|
||||
}
|
||||
|
||||
/** The faces the text under `root` is set in, each with the characters it sets. */
|
||||
export function faceUsesOf(root: Element): FaceUses {
|
||||
const uses: FaceUses = new Map();
|
||||
const view = root.ownerDocument.defaultView;
|
||||
if (!view) return uses;
|
||||
const walker = root.ownerDocument.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const words = node.textContent ?? '';
|
||||
const parent = node.parentElement;
|
||||
if (!parent || !words.trim()) continue;
|
||||
const style = view.getComputedStyle(parent);
|
||||
const family = unquote(style.fontFamily.split(',')[0] ?? '');
|
||||
if (!family) continue;
|
||||
const key = faceKey(family, cssWeight(style.fontWeight), /italic|oblique/.test(style.fontStyle));
|
||||
const chars = uses.get(key) ?? new Set<number>();
|
||||
for (const ch of words) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code !== undefined && code > 32) chars.add(code);
|
||||
}
|
||||
uses.set(key, chars);
|
||||
}
|
||||
return uses;
|
||||
}
|
||||
|
||||
/** Every @font-face rule the document's stylesheets hold, inside grouping rules too. */
|
||||
export function faceRulesOf(doc: Document): FaceRule[] {
|
||||
const out: FaceRule[] = [];
|
||||
const walk = (list: CSSRuleList, base: string) => {
|
||||
for (const rule of Array.from(list)) {
|
||||
const face = parseFaceRule(rule.cssText, base);
|
||||
if (face) out.push(face);
|
||||
else if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const sheet of Array.from(doc.styleSheets)) {
|
||||
let rules: CSSRuleList;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
// A stylesheet from another origin cannot be read, and holds no book face.
|
||||
continue;
|
||||
}
|
||||
walk(rules, sheet.href ?? doc.baseURI);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A file as a data URL. */
|
||||
function asDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('font read failed'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
type FetchFont = (url: string) => Promise<Blob | null>;
|
||||
|
||||
/** A face file from this server, out of the cache when the page has already drawn it. */
|
||||
const fetchFont: FetchFont = async (url) => {
|
||||
if (new URL(url).origin !== window.location.origin) return null;
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'force-cache',
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
return res.ok ? res.blob() : null;
|
||||
};
|
||||
|
||||
/** One face as a rule the snapshot can carry: its file inline. */
|
||||
async function inlineRule(rule: FaceRule, load: FetchFont): Promise<string | null> {
|
||||
try {
|
||||
const blob = await load(rule.url);
|
||||
if (!blob) return null;
|
||||
const range = rule.unicodeRange ? `unicode-range:${rule.unicodeRange};` : '';
|
||||
const family = rule.family.replace(/"/g, '');
|
||||
const style = rule.italic ? 'italic' : 'normal';
|
||||
return `@font-face{font-family:"${family}";font-style:${style};font-weight:${rule.weight};${range}src:url(${await asDataUrl(blob)});}`;
|
||||
} catch {
|
||||
// A face that cannot be fetched is drawn in its fallback; the page still goes.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The @font-face rules for the faces the page under `root` draws, with their
|
||||
* files inline, ready for modern-screenshot's `font.cssText`. Null when there
|
||||
* is nothing to carry, so the caller lets the library look for itself.
|
||||
*/
|
||||
export async function snapshotFontCss(
|
||||
root: Element,
|
||||
load: FetchFont = fetchFont,
|
||||
available: readonly FaceRule[] = faceRulesOf(root.ownerDocument)
|
||||
): Promise<string | null> {
|
||||
const rules = pickFaceRules(available, faceUsesOf(root));
|
||||
if (!rules.length) return null;
|
||||
const inlined = await Promise.all(rules.map((rule) => inlineRule(rule, load)));
|
||||
const css = inlined.filter((r): r is string => r !== null);
|
||||
return css.length ? css.join('\n') : null;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/* ============================================================================
|
||||
TREK Studio: the typeface picker and the weight slider.
|
||||
|
||||
Its own file beside studio.css, which is at its size ceiling. Same rules as
|
||||
there: every selector is scoped under `.trek-dash` (dashboard.css resets
|
||||
`button` at a specificity a bare class loses to), and every colour is one of
|
||||
the dashboard tokens, so the picker follows the user's theme and accent.
|
||||
|
||||
The family names are set in their own faces and at the user's text size,
|
||||
which is why they scale with --fs-scale-body rather than sitting at a fixed
|
||||
pixel size: a picker is read, and a name too small to read is a name that
|
||||
tells you nothing about its face.
|
||||
============================================================================ */
|
||||
|
||||
.trek-dash .st-fontpick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-current,
|
||||
.trek-dash .st-fontpick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
color: var(--ink);
|
||||
transition:
|
||||
background 0.14s,
|
||||
border-color 0.14s,
|
||||
color 0.14s;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-current {
|
||||
height: 40px;
|
||||
padding-inline: 10px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface);
|
||||
}
|
||||
.trek-dash .st-fontpick-current:hover {
|
||||
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
|
||||
}
|
||||
.trek-dash .st-fontpick-current:focus-visible,
|
||||
.trek-dash .st-fontpick-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.trek-dash .st-fontpick.is-open .st-fontpick-current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-face {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
font-size: calc(16px * var(--fs-scale-body, 1));
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-span {
|
||||
flex: none;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-caret {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.trek-dash .st-fontpick.is-open .st-fontpick-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* The open list. It sits in the inspector's flow and scrolls on its own, so
|
||||
the inspector around it keeps its place. */
|
||||
.trek-dash .st-fontpick-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklch, var(--surface) 70%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.trek-dash .st-fontpick-search {
|
||||
margin: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px 4px 6px;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-group + .st-fontpick-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.trek-dash .st-fontpick-label {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 8px 8px 4px;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
background: color-mix(in oklch, var(--surface) 92%, transparent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-row {
|
||||
min-height: 38px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-fontpick-row:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-fontpick-row.is-on {
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklch, var(--accent) 10%, transparent);
|
||||
}
|
||||
.trek-dash .st-fontpick-tick {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-none {
|
||||
padding: 10px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ----------------- weight ----------------- */
|
||||
.trek-dash .st-weight {
|
||||
width: 100%;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-value,
|
||||
.trek-dash .st-weight > .st-weight-only {
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-value {
|
||||
flex: none;
|
||||
min-width: 84px;
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-only {
|
||||
flex: 1;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-fontpick-caret,
|
||||
.trek-dash .st-fontpick-current,
|
||||
.trek-dash .st-fontpick-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1583,37 +1583,7 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ----------------- font picker ----------------- *
|
||||
* Each family set in itself. A row of chips reading "Poppins / Lora / Bebas" in
|
||||
* the interface font tells you their names and nothing about how they look,
|
||||
* which is the only thing anyone is choosing between.
|
||||
*/
|
||||
.trek-dash .st-fonts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.trek-dash .st-font {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklch, var(--surface) 55%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
transition: background .14s, border-color .14s, color .14s;
|
||||
}
|
||||
.trek-dash .st-font:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .st-font.is-on {
|
||||
color: var(--ink);
|
||||
border-color: color-mix(in oklch, var(--accent) 50%, transparent);
|
||||
background: color-mix(in oklch, var(--accent) 10%, transparent);
|
||||
}
|
||||
/* The typeface picker lives in studio-fonts.css. */
|
||||
.trek-dash .st-chip:disabled { opacity: .32; cursor: default; }
|
||||
|
||||
/* ----------------- save state ----------------- *
|
||||
|
||||
Reference in New Issue
Block a user