Font Sizes, per role, per width

A fifth section between Fonts and Spacing. The same four roles the families use,
so a template asks for a role and gets both — and a role used at two sizes on
purpose collapses to one, which was the trade taken over a separate list of text
roles.

The live preview builds the same stylesheet the site renders, because a size is
three values and an inline style cannot carry a media query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 08:03:21 +01:00
co-authored by Claude Opus 5
parent 8f26d0d3b6
commit 5f7e4d7d61
3 changed files with 188 additions and 3 deletions
+30
View File
@@ -655,3 +655,33 @@ export function applySpacing(document: Document, spacing: SpacingDraft): () => v
} }
}; };
} }
/**
* The chosen type sizes, applied to a rendered page before they are saved.
*
* ── A stylesheet, not classes and not properties ──
*
* Colours and families are custom properties on `<html>`, which the panel can set directly. A size is
* three values, one per breakpoint, so it has to carry media queries — and only a stylesheet can. The
* site renders exactly this element, marked `data-font-sizes`; this finds it or makes one.
*
* The cleanup restores what was there, including removing an element the site had not rendered — which
* is the ordinary case, since it renders none until something is chosen.
*/
export function applyFontSizes(document: Document, css: string): () => void {
const existing = document.querySelector<HTMLStyleElement>('style[data-font-sizes]');
const element = existing ?? document.createElement('style');
const before = existing?.textContent ?? null;
if (!existing) {
element.setAttribute('data-font-sizes', '');
document.head.append(element);
}
element.textContent = css;
return () => {
if (before === null) element.remove();
else element.textContent = before;
};
}
+100
View File
@@ -0,0 +1,100 @@
import { useDevice } from '../DeviceToggle';
/**
* How big each role is, at the width being edited.
*
* ── The same four roles as Fonts ──
*
* A template asks for a role and gets both the face and the size, so this section is the one above it
* with numbers instead of names. What that costs is worth knowing: a role used at two sizes on purpose
* collapses to one — `font-sub` was three sizes across the site and is now a single choice.
*
* ── One value per field ──
*
* The tabs at the top of the panel say which width. Unset inherits the smaller one, so "the same
* everywhere" is one choice; the option names what it is inheriting rather than leaving it to be
* worked out.
*/
export type FontSizes = Partial<Record<'title' | 'body' | 'sub' | 'display', Partial<Record<'base' | 'md' | 'lg', string>>>>;
const BREAKPOINT = { desktop: 'lg', tablet: 'md', mobile: 'base' } as const;
/** The order they are read in: biggest first, which is how a type scale is judged. */
const ROLES = [
{ key: 'display' as const, label: 'Display' },
{ key: 'title' as const, label: 'Headings' },
{ key: 'body' as const, label: 'Body' },
{ key: 'sub' as const, label: 'Menu' },
];
const inherited = (
steps: Partial<Record<'base' | 'md' | 'lg', string>> | undefined,
key: 'base' | 'md' | 'lg',
): string | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[key]) {
const step = steps?.[smaller];
if (step) return step;
}
return null;
};
export const FontSizesTab = ({
sizes,
steps,
onChange,
}: {
sizes: FontSizes;
/** The scale, sent by the API so this file holds no copy of it. */
steps: string[];
onChange: (sizes: FontSizes) => void;
}) => {
const [device] = useDevice();
const key = BREAKPOINT[device];
const set = (role: string, value: string) => {
const at = { ...((sizes as Record<string, Partial<Record<string, string>>>)[role] ?? {}) };
// Removed rather than stored empty: an absence is what "inherit" means, all the way up.
if (value === '') delete at[key];
else at[key] = value;
const next = { ...(sizes as Record<string, unknown>) };
if (Object.keys(at).length) next[role] = at;
else delete next[role];
onChange(next as FontSizes);
};
return (
<div className="grid grid-cols-2 gap-3">
{ROLES.map((role) => {
const current = sizes[role.key];
const own = current?.[key];
const from = inherited(current, key);
return (
<div key={role.key} className="flex flex-col gap-1">
<label className="text-xs">{role.label}</label>
<select
value={own ?? ''}
onChange={(event) => set(role.key, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* Mobile is the smallest, so there is nothing below it to inherit from — and nothing
chosen there means the stylesheet's own size. */}
<option value="">
{key === 'base' ? 'Default' : from === null ? 'Inherit (default)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={step}>
{step}
</option>
))}
</select>
</div>
);
})}
</div>
);
};
+58 -3
View File
@@ -5,11 +5,12 @@ import { readableError } from '../errors';
import { useFlash } from '../useFlash'; import { useFlash } from '../useFlash';
import { useAutosave } from '../useAutosave'; import { useAutosave } from '../useAutosave';
import { DEVICES, usePreviewable, useViewMode } from '../ViewMode'; import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
import { applySpacing, paintMeasure, type SpacingDraft } from '../canvas'; import { applyFontSizes, applySpacing, paintMeasure, type SpacingDraft } from '../canvas';
import { SitePreview, useDevPort } from '../SitePreview'; import { SitePreview, useDevPort } from '../SitePreview';
import { useEditorSlot } from '../useEditorSlot'; import { useEditorSlot } from '../useEditorSlot';
import { type LayoutBand } from './PaddingTab'; import { type LayoutBand } from './PaddingTab';
import { SpacingTab } from './SpacingTab'; import { SpacingTab } from './SpacingTab';
import { FontSizesTab, type FontSizes } from './FontSizesTab';
/** /**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces. * The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -59,6 +60,12 @@ type Layout = {
padding_steps: number[]; padding_steps: number[];
/** Where the space goes, band by band. See SpacingTab, and the site's own spacingClasses. */ /** Where the space goes, band by band. See SpacingTab, and the site's own spacingClasses. */
spacing: SpacingDraft; spacing: SpacingDraft;
/** How big each role is, per breakpoint. See FontSizesTab. */
font_sizes: FontSizes;
/** The type scale, sent by the API so this file holds no copy of it. */
text_steps: string[];
/** Each step's length, so the live preview can build the same CSS the site would. */
text_sizes: Record<string, string>;
/** /**
* Which face plays each role when nothing has been chosen, read from the site's own stylesheet. * Which face plays each role when nothing has been chosen, read from the site's own stylesheet.
* *
@@ -110,7 +117,7 @@ const themeProperties = (draft: Layout): Record<string, string | null> => ({
}); });
/** Every section, open from the start. The values below must match the AccordionItems. */ /** Every section, open from the start. The values below must match the AccordionItems. */
const SECTIONS_OPEN = ['colours', 'typefaces', 'padding']; const SECTIONS_OPEN = ['colours', 'typefaces', 'sizes', 'padding'];
/** /**
* A section's title. * A section's title.
@@ -124,6 +131,36 @@ const SECTIONS_OPEN = ['colours', 'typefaces', 'padding'];
*/ */
const SECTION_TITLE = 'text-sm font-semibold tracking-wide'; const SECTION_TITLE = 'text-sm font-semibold tracking-wide';
/**
* The chosen sizes as CSS.
*
* Mirrors `fontSizeRules` in the site's layout-settings.ts, and is a copy for the same reason
* `themeProperties` is: showing a change before it is saved means producing it here. The check is the
* same — save, and if the live preview and the saved page disagree, this copy is the wrong one.
*
* The lengths come from the API rather than a list held here, so only the SHAPE is duplicated.
*/
const fontSizeRules = (sizes: FontSizes, lengths: Record<string, string>): string => {
const queries = [
['base', null],
['md', '768px'],
['lg', '1024px'],
] as const;
return queries
.map(([breakpoint, width]) => {
const declarations = (['title', 'body', 'sub', 'display'] as const)
.map((role) => [role, lengths[sizes[role]?.[breakpoint] ?? '']] as const)
.filter(([, length]) => length)
.map(([role, length]) => `--text-${role}:${length}`)
.join(';');
if (!declarations) return '';
return width ? `@media (min-width:${width}){:root{${declarations}}}` : `:root{${declarations}}`;
})
.join('');
};
const COLOURS = [ const COLOURS = [
{ key: 'background' as const, label: 'Background', fallback: '#ffffff' }, { key: 'background' as const, label: 'Background', fallback: '#ffffff' },
{ key: 'foreground' as const, label: 'Text', fallback: '#000000' }, { key: 'foreground' as const, label: 'Text', fallback: '#000000' },
@@ -170,6 +207,7 @@ export const LayoutPanel = () => {
*/ */
const WRITABLE = [ const WRITABLE = [
'spacing', 'spacing',
'font_sizes',
'background', 'background',
'foreground', 'foreground',
'accent', 'accent',
@@ -226,17 +264,20 @@ export const LayoutPanel = () => {
// Serialised, so the callback is rebuilt when a NUMBER changes rather than when the object identity // Serialised, so the callback is rebuilt when a NUMBER changes rather than when the object identity
// does — a fresh `{}` every render would re-apply the whole preview on every keystroke elsewhere. // does — a fresh `{}` every render would re-apply the whole preview on every keystroke elsewhere.
const spacingKey = JSON.stringify(draft?.spacing ?? {}); const spacingKey = JSON.stringify(draft?.spacing ?? {});
const sizesCss = draft ? fontSizeRules(draft.font_sizes, draft.text_sizes) : '';
const onDocument = useCallback( const onDocument = useCallback(
(frameDocument: Document) => { (frameDocument: Document) => {
const measure = paintMeasure(frameDocument); const measure = paintMeasure(frameDocument);
const space = applySpacing(frameDocument, JSON.parse(spacingKey) as SpacingDraft); const space = applySpacing(frameDocument, JSON.parse(spacingKey) as SpacingDraft);
const sizes = applyFontSizes(frameDocument, sizesCss);
return () => { return () => {
sizes();
space(); space();
measure(); measure();
}; };
}, },
[spacingKey], [spacingKey, sizesCss],
); );
const [, setEditor] = useEditorSlot(); const [, setEditor] = useEditorSlot();
@@ -346,6 +387,20 @@ export const LayoutPanel = () => {
</> </>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="sizes" className="rounded-lg border border-border px-4">
<AccordionTrigger className={SECTION_TITLE}>
<span>
Font Sizes <span className="font-normal text-muted-foreground">({DEVICES[device].label})</span>
</span>
</AccordionTrigger>
<AccordionContent>
<FontSizesTab
sizes={draft.font_sizes}
steps={draft.text_steps}
onChange={(font_sizes) => patch({ font_sizes })}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="padding" className="rounded-lg border border-border px-4"> <AccordionItem value="padding" className="rounded-lg border border-border px-4">
<AccordionTrigger className={SECTION_TITLE}> <AccordionTrigger className={SECTION_TITLE}>
{/* {/*