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:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
{/*
|
||||
|
||||
Reference in New Issue
Block a user