Files
fofos-admin/web/sections/FontSizesTab.tsx
T
pastilhasandClaude Opus 5 5f7e4d7d61 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>
2026-09-01 08:03:21 +01:00

101 lines
3.4 KiB
TypeScript

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>
);
};