From 08d1a3089fd166d2640620049385607a378bf00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 05:29:01 +0100 Subject: [PATCH] Layout's Padding tab sets the site's gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/sections/GutterTab.tsx | 99 ++++++++++++++++++++++++++++++++++++ web/sections/LayoutPanel.tsx | 53 ++++++------------- 2 files changed, 115 insertions(+), 37 deletions(-) create mode 100644 web/sections/GutterTab.tsx diff --git a/web/sections/GutterTab.tsx b/web/sections/GutterTab.tsx new file mode 100644 index 0000000..85f3bc1 --- /dev/null +++ b/web/sections/GutterTab.tsx @@ -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>; + +const BREAKPOINT: Record = { 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 ( +
+
+

Horizontal padding

+ + The space between the content and the edge of the window + + {/* 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. */} + + editing {DEVICES[device].label.toLowerCase()} — {breakpoint} + +
+ +
+
+ + + + Applies to the header, the page and the footer + +
+
+
+ ); +}; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index bc8c346..5936e59 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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(null); const [error, setError] = useState(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'} > - patch({ padding_x })} />