there is no View, only Edit

The bar offered Edit and View, and View existed to see the page without the builder's outlines and
handles on top of it. It was the DEFAULT, which put a click in front of every arrival at the one panel
whose job is editing — and it put a branch in every painter that did nothing but wait for that click.

So the mode is gone rather than defaulted the other way: no toggle, no `?mode=`, no guard. The clean
page is still one click away and always was — it is the site, on its own URL. What the admin owes is
the editing surface, always.

The left group in the bar stays, empty. The middle one is centred against the BAR rather than against
its neighbours, so removing the left group would let Desktop/Tablet/Mobile drift as the right group
changes width.

The preview's caption stays too. It reads as a duplicate of the device buttons and is not: they say the
width, and it says which SERVER — development, not production — which nothing else on the screen does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:23:37 +01:00
co-authored by Claude Opus 5
parent 8c9e4ecb85
commit 8f673792d1
5 changed files with 32 additions and 87 deletions
+6 -37
View File
@@ -11,7 +11,7 @@ import { PageBuilderPanel } from './sections/PageBuilderPanel';
import { PublishButton } from './PublishButton';
import { SyncButton } from './SyncButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName } from './ViewMode';
import { MessagesPanel } from './MessagesPanel';
import { PlannedSection } from './PlannedSection';
import { useReady } from './useReady';
@@ -84,39 +84,6 @@ const SaveButton = () => {
);
};
/**
* Editing, or just looking.
*
* Two buttons rather than one that toggles, so the current state is readable without knowing what the
* label means. A single button saying "View" has to be interpreted — is that what it is, or what it
* would become? — and that question is asked every time.
*/
const ModeToggle = () => {
const { previewable, mode, setMode } = useViewMode();
if (!previewable) return null;
const options: { value: Mode; label: string; Icon: typeof Pencil }[] = [
{ value: 'edit', label: 'Edit', Icon: Pencil },
{ value: 'view', label: 'View', Icon: Eye },
];
return (
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{options.map(({ value, label, Icon }) => (
<Button
key={value}
size="sm"
variant={mode === value ? 'secondary' : 'ghost'}
className="h-5 gap-1 px-2 text-xs"
onClick={() => setMode(value)}
>
<Icon className="size-3" />
{label}
</Button>
))}
</div>
);
};
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
const DeviceToggle = () => {
@@ -209,9 +176,11 @@ export const DetailPanel = () => {
would never be in the same place twice.
*/}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
<div className="flex flex-1 justify-start">
<ModeToggle />
</div>
{/* Empty, and deliberately still here: the middle group is centred against the BAR, so
removing the left group would let Desktop/Tablet/Mobile drift left as the right group
changes width. It held Edit/View — see the note in ViewMode about why there is no
longer a mode to toggle. */}
<div className="flex flex-1 justify-start" />
<div className="flex justify-center">
<DeviceToggle />
+6 -8
View File
@@ -101,7 +101,7 @@ export const SitePreview = ({
const clientRef = useRef(client);
clientRef.current = client;
const { device, mode } = useViewMode();
const { device } = useViewMode();
const { width } = DEVICES[device];
const [ready, setReady] = useState(false);
@@ -305,13 +305,11 @@ export const SitePreview = ({
</div>
</div>
{/* Only while editing. In View the preview is the whole point, and a caption is a line of height
taken from it to say something the device buttons already show. */}
{mode === 'edit' && (
<p className="shrink-0 text-[10px] text-muted-foreground">
The development server, live at {width}px. Save to see a change here.
</p>
)}
{/* Always, now that editing is the only mode. It says which server this is and at what width,
which the device buttons show but the SERVER half of it — dev, not production — they do not. */}
<p className="shrink-0 text-[10px] text-muted-foreground">
The development server, live at {width}px. Save to see a change here.
</p>
</section>
);
};
+15 -33
View File
@@ -30,7 +30,17 @@ import { useSearchParams } from 'react-router';
* The rule was already right; the exception was not.
*/
export type Mode = 'edit' | 'view';
/*
There is no Mode any more.
The bar offered Edit and View, and View existed to see the page without the builder's outlines and
handles on top of it. It was the default, and it turned out to be the wrong default and then the
wrong feature: this panel is where somebody EDITS, and a second mode meant every arrival had a click
in front of it and every guard in the panels had a branch that did nothing but wait for that click.
The clean page is still one click away — it is the site, on its own URL. What the admin owes is the
editing surface, always.
*/
export type DeviceName = 'desktop' | 'tablet' | 'mobile';
/**
@@ -60,9 +70,8 @@ export const DEVICES: Record<DeviceName, { label: string; width: number }> = {
};
type ViewModeValue = {
mode: Mode;
device: DeviceName;
setMode: (mode: Mode) => void;
setDevice: (device: DeviceName) => void;
/** True while a section with a preview is open. The bar shows its controls only then. */
previewable: boolean;
@@ -70,9 +79,8 @@ type ViewModeValue = {
};
const ViewModeContext = createContext<ViewModeValue>({
mode: 'edit',
device: 'desktop',
setMode: () => {},
setDevice: () => {},
previewable: false,
announcePreviewable: () => {},
@@ -82,33 +90,7 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const [params, setParams] = useSearchParams();
/*
The mode lives in the URL — `?mode=edit`.
docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL. Whether you are
editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody,
and a link to a page being edited is a different thing from a link to the page.
`mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads
as `mode=edit` and `mode=view` rather than the tautological `view=view`.
Absent means View, which is the default and the common case — so an ordinary link carries nothing,
and the parameter only appears once somebody has chosen.
*/
const mode: Mode = params.get('mode') === 'edit' ? 'edit' : 'view';
const setMode = useCallback(
(next: Mode) => {
const search = new URLSearchParams(params);
// Written even for the default, once somebody has touched it: a URL that says `mode=view` is
// unambiguous, and a toggle that sometimes leaves no trace reads as a toggle that failed.
search.set('mode', next);
setParams(search, { replace: true });
},
[params, setParams],
);
/*
The device, alongside the mode, for the same reason.
The device lives in the URL, for the reason the whole file gives: it is addressable.
Absent means desktop — the default and the common case — so an ordinary link carries neither
parameter and each appears only once somebody has chosen it.
@@ -130,7 +112,7 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const announcePreviewable = useCallback((has: boolean) => setPreviewable(has), []);
return (
<ViewModeContext.Provider value={{ mode, device, setMode, setDevice, previewable, announcePreviewable }}>
<ViewModeContext.Provider value={{ device, setDevice, previewable, announcePreviewable }}>
{children}
</ViewModeContext.Provider>
);
+3 -6
View File
@@ -219,7 +219,7 @@ export const LayoutPanel = () => {
useSaveTarget(dirty, busy, save);
// This section has a preview, so the bar offers Edit/View and the three widths.
usePreviewable();
const { mode, device } = useViewMode();
const { device } = useViewMode();
/*
The measure, and nothing else.
@@ -242,7 +242,6 @@ export const LayoutPanel = () => {
const onDocument = useCallback(
(frameDocument: Document) => {
if (mode === 'view') return;
const measure = paintMeasure(frameDocument);
const spacing = applyGutter(frameDocument, gutter);
return () => {
@@ -250,7 +249,7 @@ export const LayoutPanel = () => {
measure();
};
},
[mode, gutter],
[gutter],
);
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
@@ -292,8 +291,7 @@ export const LayoutPanel = () => {
`shrink-0` so they never take height from the preview, and no `max-h` any more: one tab is short
enough that it does not need capping, which is the other thing this bought.
*/}
{mode === 'edit' && (
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2">
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2">
<TabsList className="h-7">
<TabsTrigger value="colours" className="text-xs">
Colours
@@ -425,7 +423,6 @@ export const LayoutPanel = () => {
</div>
</div>
</Tabs>
)}
</div>
);
+2 -3
View File
@@ -65,7 +65,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, device } = useViewMode();
const { device } = useViewMode();
const [error, setError] = useState<string | null>(null);
const [sections, setSections] = useState<CanvasSection[]>([]);
@@ -162,7 +162,6 @@ export const PageBuilderPanel = () => {
const onDocument = useCallback(
(frameDocument: Document) => {
if (mode === 'view') return;
frameDoc.current = frameDocument;
return paintCanvas(frameDocument, {
sections,
@@ -179,7 +178,7 @@ export const PageBuilderPanel = () => {
},
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
[sections, mode],
[sections],
);
/*