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