From 852c53dd09663d819855602f8b999b06fb2cd6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 03:53:02 +0100 Subject: [PATCH] one device control, read from the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/DetailPanel.tsx | 38 +++++------------- web/DeviceToggle.tsx | 74 ++++++++++++++++++++++++++++++++++++ web/sections/LayoutPanel.tsx | 16 ++++++++ 3 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 web/DeviceToggle.tsx 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. + */} +
+ Editing + +
{/* The site's gutter, not a per-band padding.