the gutter shows as you choose it, and the tab is in the URL

CHANGES APPEAR IMMEDIATELY. Colours and typefaces go into the frame as CSS custom properties because
that is what the page reads them as. A gutter cannot: only a class can carry a breakpoint, and there is
no variable to set instead. So the classes are swapped on the three elements marked `data-gutter`.

That works for one reason worth stating — the classes are in the SITE's stylesheet, which the frame
already has, and the site safelists every step. A class invented in the admin would silently do
nothing, which is the failure this whole arrangement keeps circling.

Only gutter classes are touched. Those three elements carry the gutter and nothing that looks like it —
a section's own padding lands on the <section> and the band padding on <header> — so stripping `px-N`
from them is safe where doing it to an arbitrary element would not be.

Driven by the dependency list rather than a second code path: `gutter` is a dependency of onDocument, so
changing it hands SitePreview a new function, whose effect runs the previous application's cleanup —
putting the original classes back — and then applies the new ones.

THE TAB IS IN THE URL. `?tab=padding`, same rule as the mode and the device: it is addressable. "Look at
the typefaces" is a link somebody sends, and a reload that drops you back on Colours loses the place you
were working in. Absent means Colours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:36:19 +01:00
co-authored by Claude Opus 5
parent 2b2d60f45c
commit 79b879f3f4
2 changed files with 88 additions and 4 deletions
+37
View File
@@ -442,3 +442,40 @@ export function paintMeasure(document: Document): () => void {
for (const guide of guides) guide.remove();
};
}
/**
* The site's gutter, 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,
* 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 ──
*
* 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.
*/
export function applyGutter(document: Document, classes: string): () => void {
const targets = [...document.querySelectorAll<HTMLElement>('[data-gutter]')];
const before = targets.map((element) => element.className);
const GUTTER = /^(?:md:|lg:)?px-\d+$/;
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(' ');
}
return () => {
targets.forEach((element, index) => {
element.className = before[index]!;
});
};
}
+51 -4
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintMeasure } from '../canvas';
import { applyGutter, paintMeasure } from '../canvas';
import { SitePreview, useDevPort } from '../SitePreview';
import { type LayoutBand } from './PaddingTab';
import { GutterTab, type Gutter } from './GutterTab';
@@ -108,6 +109,21 @@ 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' },
@@ -122,9 +138,23 @@ export const LayoutPanel = () => {
const [stored, setStored] = useState<Layout | null>(null);
const [draft, setDraft] = useState<Layout | null>(null);
const [busy, setBusy] = useState(false);
const [params, setParams] = useSearchParams();
const devPort = useDevPort();
/** Controlled, because the grid stack below decides visibility rather than Radix unmounting. */
const [tab, setTab] = useState('colours');
/*
The open tab, in the URL — `?tab=padding`.
Same rule as the mode and the device: it is addressable. "Look at the typefaces" is a link somebody
sends, and a reload that drops you back on Colours loses the place you were working in.
Absent means Colours, the first one, so an ordinary link carries nothing.
*/
const tab = params.get('tab') ?? 'colours';
const setTab = (next: string) => {
const search = new URLSearchParams(params);
search.set('tab', next);
setParams(search, { replace: true });
};
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
@@ -201,9 +231,26 @@ export const LayoutPanel = () => {
Nothing in View, for the same reason as the builder — chrome of any kind means you are not looking
at the page.
*/
/*
The measure, and the gutter 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.
*/
const gutter = draft ? gutterClasses(draft.padding_x) : '';
const onDocument = useCallback(
(frameDocument: Document) => (mode === 'view' ? undefined : paintMeasure(frameDocument)),
[mode],
(frameDocument: Document) => {
if (mode === 'view') return;
const measure = paintMeasure(frameDocument);
const spacing = applyGutter(frameDocument, gutter);
return () => {
spacing();
measure();
};
},
[mode, gutter],
);
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;