diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx
index 7db51dd..4e34fc4 100644
--- a/web/DetailPanel.tsx
+++ b/web/DetailPanel.tsx
@@ -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 (
-
- {options.map(({ value, Icon }) => (
-
- ))}
-
- );
+ // 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 ;
};
+
export const DetailPanel = () => {
const [params] = useSearchParams();
const raw = params.get('section');
@@ -183,7 +163,7 @@ export const DetailPanel = () => {
-
+
diff --git a/web/DeviceToggle.tsx b/web/DeviceToggle.tsx
new file mode 100644
index 0000000..8fc6ead
--- /dev/null
+++ b/web/DeviceToggle.tsx
@@ -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 (
+
+ {OPTIONS.map(({ value, Icon }) => (
+
+ ))}
+
+ );
+};
diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx
index 9d741a2..b39cbb3 100644
--- a/web/sections/LayoutPanel.tsx
+++ b/web/sections/LayoutPanel.tsx
@@ -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 = () => {
Spacing
+ {/*
+ 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.
+ */}
+