a Padding tab, where the device toggle picks the breakpoint

Choose a band — Header, Page, Footer — and set its horizontal and vertical padding. Which WIDTH you are
setting is decided by the Desktop / Tablet / Mobile buttons already in the bar:

  Mobile   390   base   every width
  Tablet   834   md     768 and up
  Desktop  1440  lg     1024 and up

That mapping is Tailwind's own and it lines up with the three preview widths exactly. Editing all three
at once would be six controls per section, five of them for a width you are not looking at; this way
you switch to Mobile, see the phone, and change the padding you can see changing.

INHERIT AND ZERO ARE DIFFERENT OPTIONS, because they are different instructions. Unset takes the
smaller breakpoint's value — which is what makes "one padding everywhere" a single choice rather than
three — and zero means no padding HERE even though the smaller width has some. The select says which
value is being inherited rather than leaving it to be worked out, and at the smallest width it says
"None", since there is nothing below to inherit from.

An emptied breakpoint is REMOVED rather than stored as `{}`, so "inherit everything" stays an absence
instead of becoming a thing that has to be told apart from one later.

One Save for both endpoints. The alternative was a second Save for spacing, which means somebody
changing a colour and a padding presses two buttons and is told about unsaved work by one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:24:30 +01:00
co-authored by Claude Opus 5
parent 495320f555
commit 2e8eabbc6b
2 changed files with 199 additions and 3 deletions
+57 -3
View File
@@ -7,6 +7,7 @@ import { useSaveTarget } from '../SaveTarget';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintMeasure } from '../canvas';
import { SitePreview, useDevPort } from '../SitePreview';
import { PaddingTab, type LayoutBand, type SectionPadding } from './PaddingTab';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -49,6 +50,11 @@ type Layout = {
font_display: string | null;
/** What the picker may offer — sent by the API so this file holds no third copy of the list. */
fonts: string[];
/** The layout PAGE and its three bands. A different thing from the colours above — see the API. */
layout_page_id: number | null;
sections: LayoutBand[];
/** The steps a padding may take, sent by the API so this file holds no copy of the scale. */
padding_steps: number[];
/**
* Which face plays each role when nothing has been chosen, read from the site's own stylesheet.
*
@@ -116,6 +122,8 @@ export const LayoutPanel = () => {
const devPort = useDevPort();
/** Controlled, because the grid stack below decides visibility rather than Radix unmounting. */
const [tab, setTab] = useState('colours');
/** Which band the Padding tab is editing. Null until the payload has arrived. */
const [band, setBand] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
@@ -140,11 +148,23 @@ export const LayoutPanel = () => {
/*
Compared field by field rather than whole, because the payload carries something this does not own:
`fonts` and `font_defaults` are the API describing the site — which faces the build subsets, and
`fonts`, `font_defaults` and `padding_steps` are the API describing the site — which faces the build subsets, and
which one plays each role today — not values anybody edits here. Including them would be harmless
today and wrong the first time either changed: the panel would show unsaved changes for a decision
made in a build script or a stylesheet.
*/
/*
Padding is drafted here and written by the same Save, through the SECTIONS endpoint.
Two endpoints, one button. The alternative was a second Save for spacing, which would mean somebody
changing a colour and a padding and having to press two things — and being told they had unsaved
changes by only one of them.
*/
const setPadding = (id: number, padding: SectionPadding) =>
setDraft((was) =>
was ? { ...was, sections: was.sections.map((one) => (one.id === id ? { ...one, padding } : one)) } : was,
);
const WRITABLE = [
'background',
'foreground',
@@ -158,7 +178,9 @@ export const LayoutPanel = () => {
const only = (source: Layout | null) =>
source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null;
const dirty = Boolean(stored && draft) && only(stored) !== only(draft);
const paddings = (source: Layout | null) => JSON.stringify(source?.sections.map((one) => one.padding) ?? null);
const dirty = Boolean(stored && draft) && (only(stored) !== only(draft) || paddings(stored) !== paddings(draft));
const save = async () => {
if (!draft) return;
@@ -166,6 +188,21 @@ export const LayoutPanel = () => {
setError(null);
try {
const body = Object.fromEntries(WRITABLE.map((key) => [key, draft[key]]));
// The bands that actually changed, before the layout write, so a failure on one leaves the other
// untouched rather than half-applied.
if (draft.layout_page_id) {
for (const one of draft.sections) {
const was = stored?.sections.find((other) => other.id === one.id);
if (was && JSON.stringify(was.padding) !== JSON.stringify(one.padding)) {
await clientRef.current.patch(
`/fofos-admin/pages/${draft.layout_page_id}/sections/${one.id}`,
{ padding: one.padding },
);
}
}
}
apply((await clientRef.current.patch<{ data: Layout }>('/fofos-admin/layout', body)).data);
flashSaved();
} catch (failure) {
@@ -178,7 +215,7 @@ export const LayoutPanel = () => {
useSaveTarget(dirty, busy, save);
// This section has a preview, so the bar offers Edit/View and the three widths.
usePreviewable();
const { mode } = useViewMode();
const { mode, device } = useViewMode();
/*
The measure, and nothing else.
@@ -243,6 +280,9 @@ export const LayoutPanel = () => {
<TabsTrigger value="typefaces" className="text-xs">
Typefaces
</TabsTrigger>
<TabsTrigger value="padding" className="text-xs">
Padding
</TabsTrigger>
</TabsList>
{/*
@@ -340,6 +380,20 @@ export const LayoutPanel = () => {
</div>
</>
</div>
<div
className={`col-start-1 row-start-1 ${tab === 'padding' ? '' : 'invisible'}`}
aria-hidden={tab !== 'padding'}
>
<PaddingTab
bands={draft.sections}
device={device}
steps={draft.padding_steps}
selected={band}
onSelect={setBand}
onChange={setPadding}
/>
</div>
</div>
</Tabs>
)}
+142
View File
@@ -0,0 +1,142 @@
import { Button } from '@/components/ui/button';
import { DEVICES, type DeviceName } from '../ViewMode';
/**
* Padding, per section, per breakpoint.
*
* ── The device toggle IS the breakpoint selector ──
*
* Editing all three widths at once would be six controls per section, and five of them would be for a
* width you are not looking at. Instead the bar's Desktop / Tablet / Mobile decides which breakpoint
* these two controls write to — so you switch to Mobile, see the phone, and change the padding you can
* see changing.
*
* The mapping is Tailwind's, and it lines up with the three preview widths exactly:
*
* Mobile 390 base every width
* Tablet 834 md 768 and up
* Desktop 1440 lg 1024 and up
*
* ── Inherit is a real value, and not the same as zero ──
*
* They cascade, so an unset breakpoint takes the smaller one's value. That is what makes the common
* case — one padding everywhere — a single choice rather than three. Zero is the opposite instruction:
* no padding HERE even though the smaller width has some. Both have to be expressible, so "Inherit"
* and "0" are separate options rather than one meaning both.
*/
export type SectionPadding = Partial<Record<'base' | 'md' | 'lg', { x?: number; y?: number }>>;
export type LayoutBand = { id: number; name: string; padding: SectionPadding };
/** Which breakpoint a device is looking at. See the table above. */
export const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = {
mobile: 'base',
tablet: 'md',
desktop: 'lg',
};
/** What a breakpoint inherits when an axis is unset: the nearest smaller one that has a value. */
const inherited = (padding: SectionPadding, breakpoint: 'base' | 'md' | 'lg', axis: 'x' | 'y'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = padding[smaller]?.[axis];
if (typeof step === 'number') return step;
}
return null;
};
export const PaddingTab = ({
bands,
device,
steps,
selected,
onSelect,
onChange,
}: {
bands: LayoutBand[];
device: DeviceName;
/** The scale, sent by the API so this file holds no copy of it. */
steps: number[];
selected: number | null;
onSelect: (id: number) => void;
onChange: (id: number, padding: SectionPadding) => void;
}) => {
const breakpoint = BREAKPOINT[device];
const band = bands.find((one) => one.id === selected) ?? bands[0];
if (!band) {
// Before the layout has been seeded. Said plainly rather than shown as an empty form somebody
// would try to use.
return <p className="text-xs text-muted-foreground">The layout has no sections yet.</p>;
}
const set = (axis: 'x' | 'y', value: string) => {
const at = { ...(band.padding[breakpoint] ?? {}) };
if (value === '') delete at[axis];
else at[axis] = Number(value);
const next = { ...band.padding };
// An empty breakpoint is REMOVED rather than left as `{}` — otherwise "inherit everything" would
// be stored as a thing rather than as an absence, and the two would have to be told apart later.
if (Object.keys(at).length) next[breakpoint] = at;
else delete next[breakpoint];
onChange(band.id, next);
};
const axis = (name: 'x' | 'y', label: string) => {
const own = band.padding[breakpoint]?.[name];
const from = inherited(band.padding, breakpoint, name);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(name, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* At the smallest width there is nothing below to inherit from, so the empty option means
what it actually does there: no padding. */}
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{bands.map((one) => (
<Button
key={one.id}
size="sm"
variant={one.id === band.id ? 'secondary' : 'ghost'}
className="h-7 px-2 text-xs"
onClick={() => onSelect(one.id)}
>
{one.name}
</Button>
))}
{/* Which width is being edited, said out loud. The device buttons are at the top of the panel
and the effect of pressing one is not obvious from down here. */}
<span className="ml-auto text-[10px] text-muted-foreground">
editing {DEVICES[device].label.toLowerCase()} — {breakpoint}
</span>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{axis('x', 'Horizontal')}
{axis('y', 'Vertical')}
</div>
</div>
);
};