From 5f7e4d7d61c3593bb76af3288ae0fe70fe1634d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 08:03:21 +0100 Subject: [PATCH] Font Sizes, per role, per width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/canvas.ts | 30 ++++++++++ web/sections/FontSizesTab.tsx | 100 ++++++++++++++++++++++++++++++++++ web/sections/LayoutPanel.tsx | 61 ++++++++++++++++++++- 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 web/sections/FontSizesTab.tsx diff --git a/web/canvas.ts b/web/canvas.ts index f611231..909c8e6 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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 ``, 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('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; + }; +} diff --git a/web/sections/FontSizesTab.tsx b/web/sections/FontSizesTab.tsx new file mode 100644 index 0000000..d048109 --- /dev/null +++ b/web/sections/FontSizesTab.tsx @@ -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>>>; + +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> | 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>>)[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) }; + if (Object.keys(at).length) next[role] = at; + else delete next[role]; + + onChange(next as FontSizes); + }; + + return ( +
+ {ROLES.map((role) => { + const current = sizes[role.key]; + const own = current?.[key]; + const from = inherited(current, key); + + return ( +
+ + +
+ ); + })} +
+ ); +}; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 675b966..94443e0 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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; /** * 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 => ({ }); /** 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 => { + 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 = () => { + + + + Font Sizes ({DEVICES[device].label}) + + + + patch({ font_sizes })} + /> + + {/*