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:
+56
-16
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user