spacing is three bands, not one gutter

Top Nav, Sections and Footer each get their own horizontal padding, plus the
one space that band owns: the room under the rule, the gap between bands, the
room above the footer.

The preview marker now names its band, so a change moves one band and not all
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 04:24:05 +01:00
co-authored by Claude Opus 5
parent 9c904040c4
commit 8307591712
4 changed files with 204 additions and 141 deletions
+19 -42
View File
@@ -6,12 +6,12 @@ import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
import { usePreviewable, useViewMode } from '../ViewMode';
import { applyGutter, paintMeasure } from '../canvas';
import { applySpacing, paintMeasure, type SpacingDraft } from '../canvas';
import { SitePreview, useDevPort } from '../SitePreview';
import { DeviceToggle } from '../DeviceToggle';
import { useEditorSlot } from '../useEditorSlot';
import { type LayoutBand } from './PaddingTab';
import { GutterTab, type Gutter } from './GutterTab';
import { SpacingTab } from './SpacingTab';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -59,8 +59,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;
/** Where the space goes, band by band. See SpacingTab, and the site's own spacingClasses. */
spacing: SpacingDraft;
/**
* Which face plays each role when nothing has been chosen, read from the site's own stylesheet.
*
@@ -111,21 +111,6 @@ const themeProperties = (draft: Layout): Record<string, string | null> => ({
'--font-display': draft.font_display && `"${draft.font_display}", sans-serif`,
});
/**
* The gutter as the classes the site would emit.
*
* Mirrors `gutterClasses` in the site's layout-settings.ts, and is a copy for the same reason
* `themeProperties` is: showing a change before it is saved means producing it here. The check is the
* same — save, and if the live preview and the saved page disagree, this copy is the wrong one.
*/
const gutterClasses = (gutter: Gutter): string => {
const prefix = { base: '', md: 'md:', lg: 'lg:' } as const;
return (['base', 'md', 'lg'] as const)
.filter((breakpoint) => typeof gutter[breakpoint] === 'number')
.map((breakpoint) => `${prefix[breakpoint]}px-${gutter[breakpoint]}`)
.join(' ');
};
const COLOURS = [
{ key: 'background' as const, label: 'Background', fallback: '#ffffff' },
{ key: 'foreground' as const, label: 'Text', fallback: '#000000' },
@@ -187,7 +172,7 @@ export const LayoutPanel = () => {
made in a build script or a stylesheet.
*/
const WRITABLE = [
'padding_x',
'spacing',
'background',
'foreground',
'accent',
@@ -234,24 +219,26 @@ export const LayoutPanel = () => {
at the page.
*/
/*
The measure, and the gutter being drafted.
The measure, and the spacing being drafted.
`gutter` is in the dependencies, so changing it hands SitePreview a new function: its effect runs
the previous application's cleanup — which puts the original classes back — and then applies the new
ones. A padding shows in the page as it is chosen, like a colour does, with no save and no reload.
`spacingKey` is in the dependencies, so changing a number hands SitePreview a new function: its
effect runs the previous application's cleanup — which puts the original classes back — and then
applies the new ones. A gap shows in the page as it is chosen, like a colour does, with no save.
*/
const gutter = draft ? gutterClasses(draft.padding_x) : '';
// Serialised, so the callback is rebuilt when a NUMBER changes rather than when the object identity
// does — a fresh `{}` every render would re-apply the whole preview on every keystroke elsewhere.
const spacingKey = JSON.stringify(draft?.spacing ?? {});
const onDocument = useCallback(
(frameDocument: Document) => {
const measure = paintMeasure(frameDocument);
const spacing = applyGutter(frameDocument, gutter);
const space = applySpacing(frameDocument, JSON.parse(spacingKey) as SpacingDraft);
return () => {
spacing();
space();
measure();
};
},
[gutter],
[spacingKey],
);
const [, setEditor] = useEditorSlot();
@@ -364,21 +351,11 @@ export const LayoutPanel = () => {
<div className="mb-3">
<DeviceToggle size="section" />
</div>
{/*
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.
All three widths at once, and deliberately NOT tied to the device toggle: a gutter is a
progression, 4 then 8 then 12, and it is judged as one. Reading it off three separate
visits to the same control is how a site ends up tighter on a tablet than on a phone.
*/}
<GutterTab
gutter={draft.padding_x}
{/* Three bands. PaddingTab is still the per-SECTION control, for the pages. */}
<SpacingTab
spacing={draft.spacing}
steps={draft.padding_steps}
onChange={(padding_x) => patch({ padding_x })}
onChange={(spacing) => patch({ spacing })}
/>
</AccordionContent>
</AccordionItem>