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…'}
;