the mode is in the URL, and the controls are tabs

?mode=edit

docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL, and 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
`mode=edit` and `mode=view` rather than the tautological `view=view`. Absent means View — the default
and the common case — so an ordinary link carries nothing and the parameter appears once somebody has
chosen.

The DEVICE deliberately stays in React state. It is a way of looking rather than a thing being looked
at, and nobody has wanted to send one. Worth keeping that distinction deliberate rather than letting
both drift into whichever place was convenient.

COLOURS AND TYPEFACES ARE TABS. They were two stacked cards taking as much height as both together, and
every pixel of it came off the preview — which is the thing being judged. One at a time costs a click
and gives the page back most of the panel.

The 55% cap on the controls is gone with them: one tab is short enough not to need capping, which is
the other thing this bought.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:00:21 +01:00
co-authored by Claude Opus 5
parent dc45fb7f11
commit 8d5c4797ad
2 changed files with 63 additions and 23 deletions
+34 -6
View File
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router';
/**
* How the open section is being looked at: editing or just viewing, and at what width.
@@ -17,6 +18,13 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
*
* So the bar shows these only when the open section says it has a preview, the same way Save renders
* nothing when there is nothing to save.
*
* ── Where each piece of state lives ──
*
* The MODE is in the URL: it is addressable, and a link to a page being edited is a different thing
* from a link to the page. The DEVICE is not, yet — it is a way of looking rather than a thing being
* looked at, and nobody has wanted to send one. That distinction is worth keeping deliberate rather
* than letting both drift into whichever place was convenient.
*/
export type Mode = 'edit' | 'view';
@@ -68,14 +76,34 @@ const ViewModeContext = createContext<ViewModeValue>({
});
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
/*
View, not Edit.
const [params, setParams] = useSearchParams();
Opening a section is nearly always to LOOK at something — to check what a change did, or to see the
state of the site before deciding anything. Editing is the deliberate act, so it is the one that
takes a click. Defaulting the other way puts a wall of controls in front of the thing they act on.
/*
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, setMode] = useState<Mode>('view');
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],
);
const [device, setDevice] = useState<DeviceName>('desktop');
const [previewable, setPreviewable] = useState(false);
+29 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
@@ -24,7 +25,8 @@ import { SitePreview, useDevPort } from '../SitePreview';
* content. A panel offering to rearrange them offers a power that should not exist, and every use of it
* would be a decision made in the wrong place.
*
* What is left is the part that IS a content decision: the colours and the typefaces.
* What is left is the part that IS a content decision: the colours and the typefaces, as tabs — one at
* a time, so the preview keeps the height.
*
* ── The preview is the point ──
*
@@ -221,19 +223,28 @@ export const LayoutPanel = () => {
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} onDocument={onDocument} />
{/*
The controls, and only in Edit.
The controls as TABS, and only in Edit.
`shrink-0` so they never give up height to the preview, `max-h-[55%]` so they can never take
the whole panel either — on a short window the preview would otherwise be squeezed to nothing,
which is the failure this whole change is about. Past that they scroll on their own rather than
scrolling the preview off the top.
They were two stacked cards, which took as much height as both of them together — and every
pixel of it came off the preview, which is the thing being judged. One at a time costs a click
and gives the page back most of the panel.
`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' && (
<div className="flex max-h-[55%] shrink-0 flex-col gap-4 overflow-auto">
{/* ── Colours ──────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Colours</h3>
<Tabs defaultValue="colours" className="shrink-0 gap-2">
<TabsList className="h-7">
<TabsTrigger value="colours" className="text-xs">
Colours
</TabsTrigger>
<TabsTrigger value="typefaces" className="text-xs">
Typefaces
</TabsTrigger>
</TabsList>
<TabsContent value="colours" className="rounded-lg border border-border p-4">
<>
<div className="flex flex-wrap gap-4">
{COLOURS.map(({ key, label, fallback }) => (
<div key={key} className="flex items-center gap-2">
@@ -262,12 +273,11 @@ export const LayoutPanel = () => {
</div>
))}
</div>
</section>
{/* ── Typefaces ────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Typefaces</h3>
</>
</TabsContent>
<TabsContent value="typefaces" className="rounded-lg border border-border p-4">
<>
<div className="grid gap-3 sm:grid-cols-2">
{ROLES.map(({ key, label, hint }) => (
<div key={key} className="flex flex-col gap-1">
@@ -292,9 +302,11 @@ export const LayoutPanel = () => {
</div>
))}
</div>
</section>
</div>
</>
</TabsContent>
</Tabs>
)}
</div>
);
};