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
+58 -3
View File
@@ -5,11 +5,12 @@ import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useAutosave } from '../useAutosave';
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 { useEditorSlot } from '../useEditorSlot';
import { type LayoutBand } from './PaddingTab';
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.
@@ -59,6 +60,12 @@ type Layout = {
padding_steps: number[];
/** Where the space goes, band by band. See SpacingTab, and the site's own spacingClasses. */
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.
*
@@ -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. */
const SECTIONS_OPEN = ['colours', 'typefaces', 'padding'];
const SECTIONS_OPEN = ['colours', 'typefaces', 'sizes', 'padding'];
/**
* A section's title.
@@ -124,6 +131,36 @@ const SECTIONS_OPEN = ['colours', 'typefaces', 'padding'];
*/
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 = [
{ key: 'background' as const, label: 'Background', fallback: '#ffffff' },
{ key: 'foreground' as const, label: 'Text', fallback: '#000000' },
@@ -170,6 +207,7 @@ export const LayoutPanel = () => {
*/
const WRITABLE = [
'spacing',
'font_sizes',
'background',
'foreground',
'accent',
@@ -226,17 +264,20 @@ export const LayoutPanel = () => {
// 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.
const spacingKey = JSON.stringify(draft?.spacing ?? {});
const sizesCss = draft ? fontSizeRules(draft.font_sizes, draft.text_sizes) : '';
const onDocument = useCallback(
(frameDocument: Document) => {
const measure = paintMeasure(frameDocument);
const space = applySpacing(frameDocument, JSON.parse(spacingKey) as SpacingDraft);
const sizes = applyFontSizes(frameDocument, sizesCss);
return () => {
sizes();
space();
measure();
};
},
[spacingKey],
[spacingKey, sizesCss],
);
const [, setEditor] = useEditorSlot();
@@ -346,6 +387,20 @@ export const LayoutPanel = () => {
</>
</AccordionContent>
</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">
<AccordionTrigger className={SECTION_TITLE}>
{/*