From 79b879f3f4c934a9807b4ca25da044bf233b1c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 05:36:19 +0100 Subject: [PATCH] the gutter shows as you choose it, and the tab is in the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
and the band padding on
— 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) --- web/canvas.ts | 37 ++++++++++++++++++++++++ web/sections/LayoutPanel.tsx | 55 +++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/web/canvas.ts b/web/canvas.ts index 8dfc691..a23c7e2 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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
, 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. + */ +export function applyGutter(document: Document, classes: string): () => void { + const targets = [...document.querySelectorAll('[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]!; + }); + }; +} diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index bc16ce2..7a3b08d 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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 => ({ '--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(null); const [draft, setDraft] = useState(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(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
{error ?? 'Loading…'}
;