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.