Spacing, on a section and on a component

A Spacing handle on the section bar and in the component tools, opening an editor below the preview —
the same place the text editor goes, and for the same reason: a dialog covers the thing being spaced.

The device toggle picks the breakpoint. Two axes across three widths is six controls, five of them for a
width you are not looking at; this way you switch to Mobile, see the phone, and change the padding you
can watch changing. The site's GUTTER is the exception and shows all three at once, because it is one
axis and one value and it is a progression judged as a whole.

Inherit and 0 stay separate options, and the select names the value it is inheriting rather than leaving
it to be worked out.

Written IMMEDIATELY rather than drafted. A step is one choice from a short list and the effect is on
screen before the menu closes; a Save for it would be a second thing to remember on a panel whose other
verbs already act.

Spacing sits before Delete in the section bar, ordered by how often each is wanted rather than how they
read — the destructive one furthest from where the hand arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 06:29:38 +01:00
co-authored by Claude Opus 5
parent 5e351aa19f
commit 18c01de6f2
3 changed files with 249 additions and 24 deletions
+48 -20
View File
@@ -49,13 +49,28 @@ const ACCENT = '#3b82f6';
* presses Edit, and it arrived with the section. Fetching it again on every Edit would be a request to
* be told something already in hand.
*/
export type CanvasComponent = { id: number; column: number; kind: string; content?: { html?: string } };
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two'; components: CanvasComponent[] };
export type CanvasComponent = {
id: number;
column: number;
kind: string;
content?: { html?: string };
padding?: Record<string, { x?: number; y?: number }>;
};
export type CanvasSection = {
id: number;
name: string;
layout: 'one' | 'two';
padding?: Record<string, { x?: number; y?: number }>;
components: CanvasComponent[];
};
export type Canvas = {
sections: CanvasSection[];
onAddSection: () => void;
onDeleteSection: (section: CanvasSection) => void;
/** Somebody wants to change the space around a band or around one thing in it. */
onSpaceSection: (section: CanvasSection) => void;
onSpaceComponent: (component: CanvasComponent) => void;
/** A column wants something in it. */
onAddComponent: (section: CanvasSection, column: number) => void;
/** Something already in a column was pressed. */
@@ -220,25 +235,37 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
style(shape, ['opacity:0.7']);
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Delete';
style(remove, [
'margin-left:auto',
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'opacity:0.7',
'text-decoration:underline',
]);
const onRemove = () => canvas.onDeleteSection(section);
remove.addEventListener('click', onRemove);
undo.push(() => remove.removeEventListener('click', onRemove));
/*
Spacing before Delete, and both on the right.
bar.append(label, shape, remove);
The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and
deleting a band is not, so the destructive one sits furthest from where the hand arrives.
*/
const actions: HTMLElement[] = [];
for (const [text, run, first] of [
['Spacing', () => canvas.onSpaceSection(section), true],
['Delete', () => canvas.onDeleteSection(section), false],
] as const) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = text;
style(action, [
first ? 'margin-left:auto' : '',
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'opacity:0.7',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
actions.push(action);
}
bar.append(label, shape, ...actions);
element.append(bar);
undo.push(() => bar.remove());
@@ -294,6 +321,7 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
for (const [label, run] of [
['Edit', () => canvas.onEditComponent(component)],
['Spacing', () => canvas.onSpaceComponent(component)],
['Delete', () => canvas.onDeleteComponent(component)],
] as const) {
const action = document.createElement('button');
+82 -4
View File
@@ -7,6 +7,7 @@ import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { pageFromParam, usePages, type Page } from '../usePages';
@@ -40,7 +41,7 @@ export const PageBuilderPanel = () => {
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
usePreviewable();
const { mode } = useViewMode();
const { mode, device } = useViewMode();
const [error, setError] = useState<string | null>(null);
const [sections, setSections] = useState<CanvasSection[]>([]);
@@ -59,7 +60,17 @@ export const PageBuilderPanel = () => {
addComponent: (section: CanvasSection, column: number) => void;
editComponent: (component: CanvasComponent) => void;
removeComponent: (component: CanvasComponent) => void;
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
spaceSection: (section: CanvasSection) => void;
spaceComponent: (component: CanvasComponent) => void;
}>({
add: () => {},
remove: () => {},
addComponent: () => {},
editComponent: () => {},
removeComponent: () => {},
spaceSection: () => {},
spaceComponent: () => {},
});
const [adding, setAdding] = useState(false);
const [creating, setCreating] = useState(false);
/** Where a component is about to be added, while the kind is being chosen. */
@@ -70,6 +81,12 @@ export const PageBuilderPanel = () => {
);
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
/** Whose spacing is being changed. A section or one component, never both. */
const [spacing, setSpacing] = useState<
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
>(null);
/** The scale the API publishes, so this file holds no copy of it. */
const [steps, setSteps] = useState<number[]>([]);
/*
Bumped after a write, and passed to SitePreview to remount the frame.
@@ -116,6 +133,8 @@ export const PageBuilderPanel = () => {
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
onEditComponent: (component) => handlersRef.current.editComponent(component),
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
});
},
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
@@ -185,8 +204,11 @@ export const PageBuilderPanel = () => {
useEffect(() => {
if (!page) return;
void clientRef.current
.get<{ data: CanvasSection[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => setSections(response.data))
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => {
setSections(response.data);
if (response.steps) setSteps(response.steps);
})
.catch((failure: unknown) => setError(readableError(failure)));
}, [page, rendered]);
@@ -307,12 +329,56 @@ export const PageBuilderPanel = () => {
}
};
/*
Spacing is written IMMEDIATELY, like adding and deleting.
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
already act.
*/
const saveSpacing = async (padding: Padding) => {
if (!page || !spacing) return;
setSpacing({ ...spacing, padding });
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
try {
await clientRef.current.patch(url, { padding });
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const spaceSection = (section: CanvasSection) =>
setSpacing({
kind: 'section',
id: section.id,
sectionId: section.id,
label: section.name,
padding: (section.padding ?? {}) as Padding,
});
const spaceComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
const stored = owner.components.find((one) => one.id === component.id);
setSpacing({
kind: 'component',
id: component.id,
sectionId: owner.id,
label: component.kind === 'video' ? 'Video' : 'Text',
padding: (stored?.padding ?? {}) as Padding,
});
};
handlersRef.current = {
add: () => setAdding(true),
remove: (sectionId: number) => void deleteSection(sectionId),
addComponent,
editComponent,
removeComponent: (component) => void deleteComponent(component),
spaceSection,
spaceComponent,
};
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
@@ -353,6 +419,18 @@ export const PageBuilderPanel = () => {
title="Choose a video"
/>
{spacing && (
<SpacingEditor
label={spacing.label}
padding={spacing.padding}
device={device}
steps={steps}
busy={false}
onChange={(padding) => void saveSpacing(padding)}
onClose={() => setSpacing(null)}
/>
)}
{editing && (
<FreeTextEditor
html={editing.html}
+119
View File
@@ -0,0 +1,119 @@
import { Button } from '@/components/ui/button';
import { DEVICES, type DeviceName } from '../ViewMode';
/**
* The padding of one section or one component, at the width being previewed.
*
* ── The device toggle picks the breakpoint ──
*
* Two axes across three widths is six controls, five of them for a width you are not looking at. So the
* bar's Desktop / Tablet / Mobile decides which one these write to: switch to Mobile, see the phone,
* change the padding you can watch changing.
*
* The site's GUTTER is the exception and shows all three at once — one axis, one value, and it is a
* progression judged as a whole. Here it is not.
*
* Mobile base every width
* Tablet md 768 and up
* Desktop lg 1024 and up
*
* ── Inherit and 0 are different instructions ──
*
* Unset takes the smaller breakpoint's value, which is what makes "one padding everywhere" a single
* choice. Zero means none HERE even though the smaller width has some. The select names the value it is
* inheriting rather than leaving it to be worked out.
*/
export type Padding = Partial<Record<'base' | 'md' | 'lg', { x?: number; y?: number }>>;
const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = { mobile: 'base', tablet: 'md', desktop: 'lg' };
const inherited = (padding: Padding, breakpoint: 'base' | 'md' | 'lg', axis: 'x' | 'y'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = padding[smaller]?.[axis];
if (typeof step === 'number') return step;
}
return null;
};
export const SpacingEditor = ({
label,
padding,
device,
steps,
busy,
onChange,
onClose,
}: {
/** What is being spaced — the band's name, or the kind of component. */
label: string;
padding: Padding;
device: DeviceName;
steps: number[];
busy: boolean;
onChange: (padding: Padding) => void;
onClose: () => void;
}) => {
const breakpoint = BREAKPOINT[device];
const set = (axis: 'x' | 'y', value: string) => {
const at = { ...(padding[breakpoint] ?? {}) };
if (value === '') delete at[axis];
else at[axis] = Number(value);
const next = { ...padding };
// An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an absence
// instead of becoming a thing that has to be told apart from one later.
if (Object.keys(at).length) next[breakpoint] = at;
else delete next[breakpoint];
onChange(next);
};
const axis = (name: 'x' | 'y', title: string) => {
const own = padding[breakpoint]?.[name];
const from = inherited(padding, breakpoint, name);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">{title}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(name, event.currentTarget.value)}
disabled={busy}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* Mobile is the smallest, so there is nothing below it and the empty option means none. */}
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
};
return (
<div className="flex shrink-0 flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-baseline gap-2">
<h4 className="text-xs font-medium">Spacing — {label}</h4>
<span className="text-[10px] text-muted-foreground">
editing {DEVICES[device].label.toLowerCase()} — {breakpoint}
</span>
<Button size="sm" variant="ghost" className="ml-auto h-7 px-2 text-xs" onClick={onClose}>
Done
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{axis('x', 'Horizontal')}
{axis('y', 'Vertical')}
</div>
</div>
);
};