diff --git a/web/canvas.ts b/web/canvas.ts index b957bcb..da513fe 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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
, and the band padding on
. 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 `
` 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('[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>; - 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(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; }; } diff --git a/web/sections/GutterTab.tsx b/web/sections/GutterTab.tsx deleted file mode 100644 index 667fabc..0000000 --- a/web/sections/GutterTab.tsx +++ /dev/null @@ -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>; - -/** 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 ( -
-

Top Nav

- - -
- ); -}; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 7df9121..e58fdb7 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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 => ({ '--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 = () => {
- {/* - 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. - */} - patch({ padding_x })} + onChange={(spacing) => patch({ spacing })} /> diff --git a/web/sections/SpacingTab.tsx b/web/sections/SpacingTab.tsx new file mode 100644 index 0000000..bfabe3a --- /dev/null +++ b/web/sections/SpacingTab.tsx @@ -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 | 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 ( +
+ {BANDS.map((band) => ( +
+

{band.label}

+
+ {band.fields.map((field) => { + const current = (spacing as Record | undefined>)[band.key]?.[field.key]; + const own = current?.[key]; + const from = inherited(current, key); + + return ( +
+ + +
+ ); + })} +
+
+ ))} +
+ ); +};