Layout's Padding tab sets the site's gutter

Not a per-band control. The header, the content column and the footer share one measure and should
share one number at its edges — three sections' worth of controls would be three ways to disagree about
the same decision.

PaddingTab is untouched and stays. It is the per-band control and it is right for the pages, where a
band genuinely does want its own spacing. This panel just is not where that decision belongs.

Same interaction as everything else here: the device toggle picks the breakpoint, Inherit and 0 are
separate options because they are separate instructions, and the select says which value it is
inheriting rather than leaving it to be worked out.

Saved with the colours and the typefaces, because it is a field of the same row — so the per-band write
path this panel had briefly is gone with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:29:01 +01:00
co-authored by Claude Opus 5
parent 2e8eabbc6b
commit 08d1a3089f
2 changed files with 115 additions and 37 deletions
+16 -37
View File
@@ -7,7 +7,8 @@ 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';
import { type LayoutBand } from './PaddingTab';
import { GutterTab, type Gutter } from './GutterTab';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -55,6 +56,8 @@ type Layout = {
sections: LayoutBand[];
/** The steps a padding may take, sent by the API so this file holds no copy of the scale. */
padding_steps: number[];
/** The site's horizontal gutter, per breakpoint. */
padding_x: Gutter;
/**
* Which face plays each role when nothing has been chosen, read from the site's own stylesheet.
*
@@ -122,8 +125,6 @@ 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();
@@ -153,19 +154,8 @@ export const LayoutPanel = () => {
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 = [
'padding_x',
'background',
'foreground',
'accent',
@@ -178,9 +168,7 @@ export const LayoutPanel = () => {
const only = (source: Layout | null) =>
source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null;
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 dirty = Boolean(stored && draft) && only(stored) !== only(draft);
const save = async () => {
if (!draft) return;
@@ -189,20 +177,6 @@ export const LayoutPanel = () => {
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) {
@@ -385,13 +359,18 @@ export const LayoutPanel = () => {
className={`col-start-1 row-start-1 ${tab === 'padding' ? '' : 'invisible'}`}
aria-hidden={tab !== 'padding'}
>
<PaddingTab
bands={draft.sections}
{/*
The site's gutter, not a per-band padding.
PaddingTab is the per-band control and it stays — for the pages, where a band really
does want its own spacing. Here the three wrappers share one measure and should share
one number, so this is one select rather than three sections' worth.
*/}
<GutterTab
gutter={draft.padding_x}
device={device}
steps={draft.padding_steps}
selected={band}
onSelect={setBand}
onChange={setPadding}
onChange={(padding_x) => patch({ padding_x })}
/>
</div>
</div>