the gutter shows all three widths at once

One control, three values, horizontal only — Mobile, Tablet, Desktop on one row, smallest first because
that is the direction they cascade.

Not tied to the device toggle, and the difference from the per-band control is the reason. That one has
two axes and three bands, where showing every width would be eighteen inputs, so it writes to whichever
width the preview is showing. Here there is one axis and one value, so all three fit — and seeing them
together is the point. A gutter is a progression, 4 then 8 then 12, judged as a whole; reading it off
three separate visits to the same control is how a site ends up tighter on a tablet than on a phone.

Inherit still says what it is inheriting, and Mobile still says "None", because there is nothing below
it to inherit from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:32:01 +01:00
co-authored by Claude Opus 5
parent 08d1a3089f
commit 2b2d60f45c
2 changed files with 63 additions and 52 deletions
+58 -50
View File
@@ -1,9 +1,7 @@
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 ──
* ── 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
@@ -12,20 +10,35 @@ import { DEVICES, type DeviceName } from '../ViewMode';
* 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 ──
* ── All three widths at once, and NOT tied to the device toggle ──
*
* 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.
* 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>>;
const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = { mobile: 'base', tablet: 'md', desktop: 'lg' };
/**
* The three widths, smallest first, with the name the admin uses for each.
*
* Smallest first because that is the direction they cascade: `md` inherits from `base`, `lg` from `md`.
* Read left to right, the row is the progression.
*/
const WIDTHS = [
{ key: 'base' as const, label: 'Mobile', hint: 'every width' },
{ key: 'md' as const, label: 'Tablet', hint: '768 and up' },
{ key: 'lg' as const, label: 'Desktop', hint: '1024 and up' },
];
/** What this breakpoint falls back to: the nearest smaller one that has a value. */
const inherited = (gutter: Gutter, breakpoint: 'base' | 'md' | 'lg'): number | null => {
/** 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[breakpoint]) {
for (const smaller of below[key]) {
const step = gutter[smaller];
if (typeof step === 'number') return step;
}
@@ -34,25 +47,20 @@ const inherited = (gutter: Gutter, breakpoint: 'base' | 'md' | 'lg'): number | n
export const GutterTab = ({
gutter,
device,
steps,
onChange,
}: {
gutter: Gutter;
device: DeviceName;
/** The scale, sent by the API so this file holds no copy of it. */
steps: number[];
onChange: (gutter: Gutter) => void;
}) => {
const breakpoint = BREAKPOINT[device];
const own = gutter[breakpoint];
const from = inherited(gutter, breakpoint);
const set = (value: string) => {
const set = (key: 'base' | 'md' | 'lg', 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);
// Removed rather than stored as null: an absence is what "inherit" means, and a marker for it would
// leave two ways to say the same thing.
if (value === '') delete next[key];
else next[key] = Number(value);
onChange(next);
};
@@ -61,38 +69,38 @@ export const GutterTab = ({
<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}
The space between the content and the edge of the window — the header, the page and the footer
</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 className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{WIDTHS.map(({ key, label, hint }) => {
const own = gutter[key];
const from = inherited(gutter, key);
return (
<div key={key} className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(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 and the empty
option means what it actually does there: none. */}
<option value="">
{key === '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">{hint}</span>
</div>
);
})}
</div>
</div>
);
+5 -2
View File
@@ -364,11 +364,14 @@ export const LayoutPanel = () => {
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.
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}
device={device}
steps={draft.padding_steps}
onChange={(padding_x) => patch({ padding_x })}
/>