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
+99
View File
@@ -0,0 +1,99 @@
import { DEVICES, type DeviceName } from '../ViewMode';
/**
* The site's horizontal gutter: how far the content sits from the edge of the window.
*
* ── One setting, not one per band ──
*
* The header row, the content column and the footer's inner block all share the site's measure, and all
* three want the same space at the edges. That is what makes this a GUTTER rather than three paddings
* that happen to agree — and setting it once is the only arrangement in which they cannot drift.
*
* A section's own padding still exists and composes on top of this. This is the floor everything starts
* from; `PaddingTab` is the per-band control, and it stays for the pages.
*
* ── The device toggle picks the width ──
*
* Same as everywhere else in this panel: Mobile writes `base`, Tablet `md`, Desktop `lg`, and they
* cascade — so one gutter at every width is one choice rather than three.
*/
export type Gutter = Partial<Record<'base' | 'md' | 'lg', number>>;
const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = { mobile: 'base', tablet: 'md', desktop: 'lg' };
/** What this breakpoint falls back to: the nearest smaller one that has a value. */
const inherited = (gutter: Gutter, breakpoint: 'base' | 'md' | 'lg'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = gutter[smaller];
if (typeof step === 'number') return step;
}
return null;
};
export const GutterTab = ({
gutter,
device,
steps,
onChange,
}: {
gutter: Gutter;
device: DeviceName;
steps: number[];
onChange: (gutter: Gutter) => void;
}) => {
const breakpoint = BREAKPOINT[device];
const own = gutter[breakpoint];
const from = inherited(gutter, breakpoint);
const set = (value: string) => {
const next = { ...gutter };
// Removed rather than stored as null: an absence is what "inherit" means, and storing a marker for
// it would leave two ways to say the same thing.
if (value === '') delete next[breakpoint];
else next[breakpoint] = Number(value);
onChange(next);
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline gap-2">
<h4 className="text-xs font-medium">Horizontal padding</h4>
<span className="text-[10px] text-muted-foreground">
The space between the content and the edge of the window
</span>
{/* Which width is being set, said here because 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">
<div className="flex flex-col gap-1">
<label className="text-xs">Gutter</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(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, so the empty option means what
it does there: none. */}
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
<span className="truncate text-[10px] text-muted-foreground">
Applies to the header, the page and the footer
</span>
</div>
</div>
</div>
);
};
+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>