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