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
+56 -16
View File
@@ -575,38 +575,78 @@ export function paintMeasure(document: Document): () => void {
}
/**
* The site's gutter, applied to a rendered page before it is saved.
* The site's spacing, applied to a rendered page before it is saved.
*
* ── Why this can be classes and not variables ──
*
* Colours and typefaces go into the frame as CSS custom properties, because that is what the page reads
* them as. A gutter is Tailwind classes — `px-4 md:px-8` — because only a class can carry a breakpoint,
* them as. Spacing is Tailwind classes — `px-4 md:px-8` — because only a class can carry a breakpoint,
* and there is no variable to set instead.
*
* That works here for one reason: the classes are in the SITE's stylesheet, which the frame already
* has. The site safelists every step (`@source inline` in styles.css), so any value this writes is one
* the frame can already render. A class invented here would silently do nothing.
*
* ── Only gutter classes are touched ──
* ── Named bands, and why that replaced one gutter ──
*
* The elements carrying `data-gutter` hold the gutter and nothing else that looks like it: a section's
* own padding lands on the <section>, and the band padding on <header>. So removing everything matching
* `px-N` from these three is safe, where doing it to an arbitrary element would not be.
* Every marked element used to take the same `px-*`. Now the header, the bands and the footer each have
* their own, so the marker carries which one it is — `data-gutter="header"`, `data-space="footer"` —
* and this rewrites one band without touching the other two.
*
* ── Only the matching utility is touched ──
*
* Each target has a pattern naming exactly what this may remove: `px-*` from a gutter, `mb-*` from the
* header, `space-y-*` from the bands. Everything else on the element survives, which is what makes it
* safe to do this to a `<header>` carrying a border and a background as well.
*
* The one exception is a SECTION, whose own padding is also a `px-*` on the marked element — so while
* the panel is open its padding reads as the site gutter. Harmless on the layout page, whose bands have
* none, and that page is the only one this runs against.
*/
export function applyGutter(document: Document, classes: string): () => void {
const targets = [...document.querySelectorAll<HTMLElement>('[data-gutter]')];
const before = targets.map((element) => element.className);
export type SpacingDraft = {
header?: { paddingX?: Steps; marginBottom?: Steps };
sections?: { paddingX?: Steps; gap?: Steps };
footer?: { paddingX?: Steps; marginTop?: Steps };
};
const GUTTER = /^(?:md:|lg:)?px-\d+$/;
export type Steps = Partial<Record<'base' | 'md' | 'lg', number>>;
for (const element of targets) {
const kept = element.className.split(/\s+/).filter((name) => name && !GUTTER.test(name));
element.className = [...kept, ...classes.split(/\s+/).filter(Boolean)].join(' ');
/** Mirrors `scale` in the site's layout-settings.ts. Same reason as themeProperties: a copy that shows
* a change before it is saved. If the live preview and the saved page disagree, this one is wrong. */
export const scaleClasses = (utility: string, steps: Steps | undefined): string => {
if (!steps) return '';
const prefix = { base: '', md: 'md:', lg: 'lg:' } as const;
return (['base', 'md', 'lg'] as const)
.filter((breakpoint) => typeof steps[breakpoint] === 'number')
.map((breakpoint) => `${prefix[breakpoint]}${utility}-${steps[breakpoint]}`)
.join(' ');
};
/** One selector, the utility it owns, and the classes to put there. */
const TARGETS = [
{ selector: '[data-gutter="header"]', utility: 'px', read: (s: SpacingDraft) => s.header?.paddingX },
{ selector: '[data-gutter="sections"]', utility: 'px', read: (s: SpacingDraft) => s.sections?.paddingX },
{ selector: '[data-gutter="footer"]', utility: 'px', read: (s: SpacingDraft) => s.footer?.paddingX },
{ selector: '[data-space="header"]', utility: 'mb', read: (s: SpacingDraft) => s.header?.marginBottom },
{ selector: '[data-space="sections"]', utility: 'space-y', read: (s: SpacingDraft) => s.sections?.gap },
{ selector: '[data-space="footer"]', utility: 'mt', read: (s: SpacingDraft) => s.footer?.marginTop },
] as const;
export function applySpacing(document: Document, spacing: SpacingDraft): () => void {
const touched: Array<[HTMLElement, string]> = [];
for (const target of TARGETS) {
const pattern = new RegExp(`^(?:md:|lg:)?${target.utility}-\\d+$`);
const classes = scaleClasses(target.utility, target.read(spacing)).split(/\s+/).filter(Boolean);
for (const element of document.querySelectorAll<HTMLElement>(target.selector)) {
touched.push([element, element.className]);
const kept = element.className.split(/\s+/).filter((name) => name && !pattern.test(name));
element.className = [...kept, ...classes].join(' ');
}
}
return () => {
targets.forEach((element, index) => {
element.className = before[index]!;
});
for (const [element, before] of touched) element.className = before;
};
}
-83
View File
@@ -1,83 +0,0 @@
import { useDevice } from '../DeviceToggle';
/**
* The site's horizontal gutter: how far the content sits from the edge of the window.
*
* ── One setting, three places ──
*
* 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.
*
* ── All three widths at once, and NOT tied to the device toggle ──
*
* The per-band control does it the other way: one set of inputs writing to whichever width the preview
* is showing. That is right when there are two axes and three bands, where showing everything would be
* eighteen inputs.
*
* Here there is one axis and one value, so all three fit on a row — and seeing them together is the
* point. 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.
*/
export type Gutter = Partial<Record<'base' | 'md' | 'lg', number>>;
/** The device toggle's names, and the breakpoint each one writes. */
const BREAKPOINT = { desktop: 'lg', tablet: 'md', mobile: 'base' } as const;
/** What a width falls back to: the nearest smaller one that has a value. */
const inherited = (gutter: Gutter, key: 'base' | 'md' | 'lg'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[key]) {
const step = gutter[smaller];
if (typeof step === 'number') return step;
}
return null;
};
export const GutterTab = ({
gutter,
steps,
onChange,
}: {
gutter: Gutter;
/** The scale, sent by the API so this file holds no copy of it. */
steps: number[];
onChange: (gutter: Gutter) => void;
}) => {
const [device] = useDevice();
const key = BREAKPOINT[device];
const set = (value: string) => {
const next = { ...gutter };
// Removed rather than stored as null: an absence is what "inherit" means.
if (value === '') delete next[key];
else next[key] = Number(value);
onChange(next);
};
const own = gutter[key];
const from = inherited(gutter, key);
return (
<div className="flex flex-col gap-1">
<h4 className="text-xs font-medium">Top Nav</h4>
<label className="text-xs text-muted-foreground">Horizontal padding</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"
>
{/* Mobile is the smallest, so there is nothing below it to inherit from. */}
<option value="">{key === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
};
+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>
+129
View File
@@ -0,0 +1,129 @@
import { useDevice } from '../DeviceToggle';
import type { SpacingDraft, Steps } from '../canvas';
/**
* Where the space goes: three bands, each with its own numbers.
*
* It was one gutter for the whole site, on the argument that the header, the bands and the footer hold
* the same measure and should not drift. They do hold the same measure — and they still want different
* edges, because the menu is not the page. So the guarantee moved: three values, chosen in one place,
* side by side.
*
* ── One value per field ──
*
* The device toggle above says which width is being edited. Showing all three would be eighteen
* selects, seventeen of them for a screen you are not looking at.
*
* Unset inherits the smaller width, so "the same everywhere" is one choice. Zero is the other
* instruction — none HERE even though the phone has some — which is why they are separate options.
*/
const BREAKPOINT = { desktop: 'lg', tablet: 'md', mobile: 'base' } as const;
/** The nearest smaller width that has a value, or null. */
const inherited = (steps: Steps | undefined, key: 'base' | 'md' | 'lg'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[key]) {
const step = steps?.[smaller];
if (typeof step === 'number') return step;
}
return null;
};
/** The vocabulary. A knob is a line here, the class it emits on the site, and a field in the API. */
const BANDS = [
{
key: 'header' as const,
label: 'Top Nav',
fields: [
{ key: 'paddingX' as const, label: 'Horizontal padding' },
{ key: 'marginBottom' as const, label: 'Margin bottom' },
],
},
{
key: 'sections' as const,
label: 'Sections',
fields: [
{ key: 'paddingX' as const, label: 'Horizontal padding' },
{ key: 'gap' as const, label: 'Space between' },
],
},
{
key: 'footer' as const,
label: 'Footer',
fields: [
{ key: 'paddingX' as const, label: 'Horizontal padding' },
{ key: 'marginTop' as const, label: 'Margin top' },
],
},
];
export const SpacingTab = ({
spacing,
steps,
onChange,
}: {
spacing: SpacingDraft;
/** The scale, sent by the API so this file holds no copy of it. */
steps: number[];
onChange: (spacing: SpacingDraft) => void;
}) => {
const [device] = useDevice();
const key = BREAKPOINT[device];
const set = (band: string, field: string, value: string) => {
const bands = spacing as Record<string, Record<string, Steps> | undefined>;
const at: Steps = { ...(bands[band]?.[field] ?? {}) };
// Removed rather than stored as null: an absence is what "inherit" means, all the way up.
if (value === '') delete at[key];
else at[key] = Number(value);
const nextBand = { ...(bands[band] ?? {}) };
if (Object.keys(at).length) nextBand[field] = at;
else delete nextBand[field];
const next = { ...bands };
if (Object.keys(nextBand).length) next[band] = nextBand;
else delete next[band];
onChange(next as SpacingDraft);
};
return (
<div className="flex flex-col gap-4">
{BANDS.map((band) => (
<div key={band.key} className="flex flex-col gap-2">
<h4 className="text-xs font-medium">{band.label}</h4>
<div className="grid grid-cols-2 gap-3">
{band.fields.map((field) => {
const current = (spacing as Record<string, Record<string, Steps> | undefined>)[band.key]?.[field.key];
const own = current?.[key];
const from = inherited(current, key);
return (
<div key={field.key} className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">{field.label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(band.key, field.key, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* Mobile is the smallest, so there is nothing below it to inherit from. */}
<option value="">
{key === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
})}
</div>
</div>
))}
</div>
);
};