one device control, read from the URL

Spacing's settings are per width, so the section has to say which width it is editing — and the answer
must be the one the preview is already showing, not a second selector able to disagree with it.

It reads `?device=` rather than the ViewMode context, and that is not a preference. The editor is a
SIBLING panel: an element built inside the detail panel resolves context where it is RENDERED, so a
toggle handed over would look up a tree with no provider in it and quietly show the default forever.

The URL is the shared state, which means there is nothing to share. Three components read one
parameter, so they cannot disagree, and the preview follows without being subscribed to anything — it
reads the same value. More editor sections can take the control by rendering it.

The bar's own toggle is now the same component. It had its own copy of the options and the handler,
which is exactly how two controls for one thing start to differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 03:53:02 +01:00
co-authored by Claude Opus 5
parent 53d6a0324d
commit 852c53dd09
3 changed files with 99 additions and 29 deletions
+9 -29
View File
@@ -11,7 +11,8 @@ import { PageBuilderPanel } from './sections/PageBuilderPanel';
import { PublishButton } from './PublishButton';
import { SyncButton } from './SyncButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName } from './ViewMode';
import { DEVICES, ViewModeProvider, useViewMode } from './ViewMode';
import { DeviceToggle } from './DeviceToggle';
import { MessagesPanel } from './MessagesPanel';
import { PlannedSection } from './PlannedSection';
import { useReady } from './useReady';
@@ -86,37 +87,16 @@ const SaveButton = () => {
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
const DeviceToggle = () => {
const { previewable, device, setDevice } = useViewMode();
const BarDeviceToggle = () => {
const { previewable } = useViewMode();
if (!previewable) return null;
const options: { value: DeviceName; Icon: typeof Monitor }[] = [
{ value: 'desktop', Icon: Monitor },
{ value: 'tablet', Icon: Tablet },
{ value: 'mobile', Icon: Smartphone },
];
return (
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{options.map(({ value, Icon }) => (
<Button
key={value}
size="sm"
variant={device === value ? 'secondary' : 'ghost'}
className="h-5 gap-1 px-2 text-xs"
onClick={() => setDevice(value)}
// The width is in the tooltip rather than the label. It is the thing somebody occasionally
// needs and never needs three of at once, and three widths in a row is a row of numbers.
title={`${DEVICES[value].label} — ${DEVICES[value].width}px`}
>
<Icon className="size-3" />
<span className="sr-only">{DEVICES[value].label}</span>
</Button>
))}
</div>
);
// The same control the editor sections use — see DeviceToggle, which reads `?device=` rather than a
// context so every panel on the screen shares one answer.
return <DeviceToggle size="bar" />;
};
export const DetailPanel = () => {
const [params] = useSearchParams();
const raw = params.get('section');
@@ -183,7 +163,7 @@ export const DetailPanel = () => {
<div className="flex flex-1 justify-start" />
<div className="flex justify-center">
<DeviceToggle />
<BarDeviceToggle />
</div>
<div className="flex flex-1 items-center justify-end gap-2">
+74
View File
@@ -0,0 +1,74 @@
import { Monitor, Smartphone, Tablet } from 'lucide-react';
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import type { DeviceName } from './ViewMode';
/**
* Which width everything is showing, and the control for changing it.
*
* ── Why this reads the URL and not the context ──
*
* `useViewMode` is a context, and the editor is a SIBLING panel: an element built inside the detail
* panel resolves its context where it is RENDERED, not where it was created, so a toggle handed to the
* editor would look up a tree that has no provider in it and quietly show the default forever.
*
* The device already lives in `?device=`, which every panel can read. So this is the shared controller
* the whole screen wants and there is nothing to share: the URL is the state, three components read it,
* and they cannot disagree because there is only one of it.
*
* That is also why the preview follows along for free. It is not subscribed to this control; it reads
* the same parameter.
*/
export const useDevice = (): [DeviceName, (next: DeviceName) => void] => {
const [params, setParams] = useSearchParams();
const raw = params.get('device');
const device: DeviceName = raw === 'tablet' || raw === 'mobile' ? raw : 'desktop';
const setDevice = useCallback(
(next: DeviceName) => {
const search = new URLSearchParams(params);
search.set('device', next);
setParams(search, { replace: true });
},
[params, setParams],
);
return [device, setDevice];
};
const OPTIONS: { value: DeviceName; Icon: typeof Monitor }[] = [
{ value: 'desktop', Icon: Monitor },
{ value: 'tablet', Icon: Tablet },
{ value: 'mobile', Icon: Smartphone },
];
/**
* The control itself, for an editor section that has per-view settings.
*
* `size` because the one in the panel's bar is chrome and these are part of a form: the bar's is as
* small as a bar allows, and a section's sits above its own fields and should read as belonging to
* them.
*/
export const DeviceToggle = ({ size = 'bar' }: { size?: 'bar' | 'section' }) => {
const [device, setDevice] = useDevice();
const compact = size === 'bar';
return (
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{OPTIONS.map(({ value, Icon }) => (
<Button
key={value}
size="sm"
variant={device === value ? 'secondary' : 'ghost'}
className={compact ? 'h-5 gap-1 px-2 text-xs' : 'h-6 gap-1 px-2 text-xs'}
onClick={() => setDevice(value)}
aria-label={value}
title={value}
>
<Icon className="size-3" />
</Button>
))}
</div>
);
};
+16
View File
@@ -8,6 +8,7 @@ import { useSaveTarget } from '../SaveTarget';
import { usePreviewable, useViewMode } from '../ViewMode';
import { applyGutter, paintMeasure } 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';
@@ -359,6 +360,21 @@ export const LayoutPanel = () => {
<AccordionItem value="padding" className="rounded-lg border border-border px-4">
<AccordionTrigger className="text-xs">Spacing</AccordionTrigger>
<AccordionContent>
{/*
The view this section's settings are FOR, at the top of it.
Spacing is per width, so the section has to say which width it is editing — and the answer
is the same one the preview is showing, not a second selector that could disagree with it.
The control reads `?device=`, so pressing it here moves the preview, the bar's toggle and
this section together because all three are reading one value.
At the top rather than beside the fields: it scopes everything below it, and a control that
scopes a group belongs above the group.
*/}
<div className="mb-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Editing</span>
<DeviceToggle size="section" />
</div>
{/*
The site's gutter, not a per-band padding.