Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99) #110

Merged
steve merged 2 commits from feat/editor-modes into main 2026-07-22 05:32:47 +00:00
3 changed files with 169 additions and 5 deletions
Showing only changes of commit 9ec626302b - Show all commits
+2 -1
View File
@@ -128,7 +128,8 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server. React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`. - **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag. - **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`. - **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested. - **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
+16
View File
@@ -11,10 +11,22 @@ import type { EditorObject } from './types'
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
export const MAX_SCALE = 20 // px per cm — fully zoomed in export const MAX_SCALE = 20 // px per cm — fully zoomed in
// The editor's one primary activity (#99). On mobile this is the segmented
// control at the bottom of the screen; it decides which tools dock there —
// placing fixtures, placing plants, the journal, or the assistant — so the four
// activities stop competing for the same cramped strip. Desktop keeps its
// side-column layout and treats this as a lighter hint.
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
interface EditorState { interface EditorState {
viewport: Viewport viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
// last using is not a property of the garden.
mode: EditorMode
setMode: (mode: EditorMode) => void
// The selected object OR plop (mutually exclusive; selecting one clears the // The selected object OR plop (mutually exclusive; selecting one clears the
// other). selectedId is a garden object; selectedPlantingId is a plop. // other). selectedId is a garden object; selectedPlantingId is a plop.
selectedId: number | null selectedId: number | null
@@ -82,6 +94,9 @@ export const useEditorStore = create<EditorState>((set) => ({
viewport: { tx: 0, ty: 0, scale: 1 }, viewport: { tx: 0, ty: 0, scale: 1 },
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })), setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
mode: 'fixtures',
setMode: (mode) => set({ mode }),
selectedId: null, selectedId: null,
select: (id) => set({ selectedId: id, selectedPlantingId: null }), select: (id) => set({ selectedId: id, selectedPlantingId: null }),
@@ -129,5 +144,6 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null, railTab: null,
seasonYear: null, seasonYear: null,
journalObjectId: null, journalObjectId: null,
mode: 'fixtures',
Outdated
Review

Default mode 'fixtures' hardcoded twice (initial state and resetTransient)

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Default mode 'fixtures' hardcoded twice (initial state and resetTransient)** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
}), }),
})) }))
+151 -4
View File
@@ -16,7 +16,8 @@ import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint' import { EditorHint } from '@/editor/EditorHint'
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker' import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
import { objectDisplayName } from '@/editor/kinds' import { objectDisplayName } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store' import { useEditorStore, type EditorMode } from '@/editor/store'
import { cn } from '@/lib/cn'
import type { EditorGarden } from '@/editor/types' import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth' import { useMe } from '@/lib/auth'
@@ -83,6 +84,8 @@ export function GardenEditorPage() {
) )
const armedPlant = useEditorStore((s) => s.armedPlant) const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant) const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
const mode = useEditorStore((s) => s.mode)
const setMode = useEditorStore((s) => s.setMode)
const updatePlanting = useUpdatePlanting(gid) const updatePlanting = useUpdatePlanting(gid)
const updateObject = useUpdateObject(gid) const updateObject = useUpdateObject(gid)
@@ -169,6 +172,13 @@ export function GardenEditorPage() {
} }
}, [focusedObjectId, objects, full.data, setFocusedObject]) }, [focusedObjectId, objects, full.data, setFocusedObject])
// Focusing a bed IS "placing plants", so the mobile mode bar follows into Plants
Outdated
Review

🟡 Misleading comment claims focus effect is inert on desktop

error-handling, maintainability · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:175-177 — The comment claims the focus→plants effect is "Inert on desktop". It is not inert: it actively writes to the store on every focus change. The value happens to be unused on desktop, which is different.

🪰 Gadfly · advisory

🟡 **Misleading comment claims focus effect is inert on desktop** _error-handling, maintainability · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:175-177`** — The comment claims the focus→plants effect is "Inert on desktop". It is not inert: it actively writes to the store on every focus change. The value happens to be unused on desktop, which is different. <sub>🪰 Gadfly · advisory</sub>
// — otherwise the bottom strip would show the object palette while you're inside
// a bed. (Inert on desktop, where the mode bar isn't shown.)
useEffect(() => {
if (focusedObjectId != null) setMode('plants')
}, [focusedObjectId, setMode])
// Selecting anything lands you in the inspector without a click — the rail // Selecting anything lands you in the inspector without a click — the rail
// must never be something you operate before you can edit. Keyed off the // must never be something you operate before you can edit. Keyed off the
// selected ids rather than a "something is selected" boolean, so selecting a // selected ids rather than a "something is selected" boolean, so selecting a
@@ -187,6 +197,22 @@ export function GardenEditorPage() {
selectPlanting(null) selectPlanting(null)
} }
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
// leave an inspector, which is about the selection, alone).
const selectMode = (m: EditorMode) => {
setMode(m)
if (m === 'journal') {
setJournalObjectId(null)
setRailTab('journal')
} else if (m === 'assistant') {
setRailTab('chat')
} else {
const rt = useEditorStore.getState().railTab
Outdated
Review

🟡 Unnecessary imperative store read when closure value is fresh

maintainability · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:211selectMode imperatively reads useEditorStore.getState().railTab when railTab is already destructured in the same component's scope and selectMode is recreated on every render. The imperative read adds confusion without avoiding a stale closure.

🪰 Gadfly · advisory

🟡 **Unnecessary imperative store read when closure value is fresh** _maintainability · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:211`** — `selectMode` imperatively reads `useEditorStore.getState().railTab` when `railTab` is already destructured in the same component's scope and `selectMode` is recreated on every render. The imperative read adds confusion without avoiding a stale closure. <sub>🪰 Gadfly · advisory</sub>
if (rt === 'journal' || rt === 'chat') setRailTab(null)
}
}
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect. // Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -439,7 +465,10 @@ export function GardenEditorPage() {
// the canvas bottom + Fit button under the browser chrome (#85). // the canvas bottom + Fit button under the browser chrome (#85).
return ( return (
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row"> <div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40"> {/* Desktop-only control column. On mobile these move to the bottom mode bar
+ a slim top strip so the canvas — the point of the screen — isn't shoved
into a corner by a stack of controls (#99). */}
<div className="hidden shrink-0 md:block md:w-40">
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}> <h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
</h1> </h1>
@@ -486,9 +515,29 @@ export function GardenEditorPage() {
</div> </div>
<div className="relative flex min-h-0 flex-1 flex-col gap-2"> <div className="relative flex min-h-0 flex-1 flex-col gap-2">
{/* Mobile top strip: the garden identity / season / share that live in the
desktop left column. md:hidden. */}
<div className="flex items-center gap-2 md:hidden">
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
{!canEdit && seasonYear === null && (
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
)}
{years.data && years.data.length > 0 && (
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
)}
{isOwner && (
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
Share
</Button>
)}
</div>
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />} {seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
bottom Plants-mode strip so they don't wrap over the canvas. */}
{focusedObject && ( {focusedObject && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur"> <div className="absolute left-2 top-2 z-20 hidden flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur md:flex">
Outdated
Review

🟠 Duplicated plant toolbar markup between desktop focus toolbar and mobile plants mode strip

maintainability · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:540-629 — Duplicated plant toolbar markup. The desktop focus toolbar (md:flex, lines 540-574) and the mobile bottom Plants-mode strip (md:hidden, lines 591-629) both render an identical SeedTray + "Done" (disarm) + "Clear" button cluster with the same props and callbacks. Only the wrapper layout and one extra "Done planting" button differ. If a future change tweaks styling, adds the upcoming recent-plants strip (#100), or adjusts the Clear button…

🪰 Gadfly · advisory

🟠 **Duplicated plant toolbar markup between desktop focus toolbar and mobile plants mode strip** _maintainability · flagged by 1 model_ - **`web/src/pages/GardenEditorPage.tsx:540-629` — Duplicated plant toolbar markup.** The desktop focus toolbar (`md:flex`, lines 540-574) and the mobile bottom Plants-mode strip (`md:hidden`, lines 591-629) both render an identical `SeedTray` + "Done" (disarm) + "Clear" button cluster with the same props and callbacks. Only the wrapper layout and one extra "Done planting" button differ. If a future change tweaks styling, adds the upcoming recent-plants strip (#100), or adjusts the Clear button… <sub>🪰 Gadfly · advisory</sub>
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline"> <button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name} {garden.name}
</button> </button>
@@ -536,6 +585,49 @@ export function GardenEditorPage() {
)} )}
</div> </div>
{/* Mobile bottom: contextual tools for the current mode + the mode switch
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
(journal/assistant) or the inspector overlays this while open. */}
<div className="shrink-0 md:hidden">
{canEdit && (mode === 'fixtures' || mode === 'plants') && (
Outdated
Review

🟡 Read-only mobile renders an empty mode strip; Fixtures/Plants tabs become inert with no feedback

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Read-only mobile renders an empty mode strip; Fixtures/Plants tabs become inert with no feedback** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
<div className="mb-2 min-h-[2.25rem]">
{mode === 'fixtures' && <Palette />}
{mode === 'plants' &&
(focusedObject?.plantable ? (
Outdated
Review

🟡 SeedTray/Done/Clear controls duplicated verbatim between desktop focus toolbar and mobile Plants-mode panel

maintainability · flagged by 3 models

🪰 Gadfly · advisory

🟡 **SeedTray/Done/Clear controls duplicated verbatim between desktop focus toolbar and mobile Plants-mode panel** _maintainability · flagged by 3 models_ <sub>🪰 Gadfly · advisory</sub>
<div className="flex flex-wrap items-center gap-2">
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={armPlant}
onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')}
/>
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done
</Button>
)}
{focusedPlops.length > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => setClearing(true)}
>
Clear ({focusedPlops.length})
</Button>
)}
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
</Button>
</div>
) : (
<p className="px-1 text-xs text-muted">Tap a bed, then 🌱 Plant here to start planting.</p>
Outdated
Review

🟡 Mobile Plants-mode hint says 'tap a bed' even when a non-plantable object is already focused (via deep-linked ?focus=), unlike the desktop toolbar's explicit 'Not plantable' state

correctness · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:624 — Plants mode on a focused non-plantable object shows a misleading hint and no exit. Verified: focusing any object triggers the effect at :178mode='plants'. The Inspector only exposes the Focus button for object.plantable (Inspector.tsx:139), so in practice users can't focus a non-plantable object from the inspector — but a ?focus=<id> deep link (adopted at :135) to a non-plantable object (e.g. a shed) sets focusedObjectId directly,…

🪰 Gadfly · advisory

🟡 **Mobile Plants-mode hint says 'tap a bed' even when a non-plantable object is already focused (via deep-linked ?focus=), unlike the desktop toolbar's explicit 'Not plantable' state** _correctness · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:624` — Plants mode on a focused non-plantable object shows a misleading hint and no exit.** Verified: focusing any object triggers the effect at `:178` → `mode='plants'`. The Inspector only exposes the Focus button for `object.plantable` (`Inspector.tsx:139`), so in practice users can't focus a non-plantable object from the inspector — but a `?focus=<id>` deep link (adopted at `:135`) to a non-plantable object (e.g. a shed) sets `focusedObjectId` directly,… <sub>🪰 Gadfly · advisory</sub>
))}
</div>
)}
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} />
Outdated
Review

🟡 mode can stay stuck on 'assistant' with no active tab/panel if the agent capability flips off live

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟡 **mode can stay stuck on 'assistant' with no active tab/panel if the agent capability flips off live** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
</div>
{railTab && ( {railTab && (
<EditorRail <EditorRail
tabs={railTabs} tabs={railTabs}
@@ -543,10 +635,14 @@ export function GardenEditorPage() {
onActivate={setRailTab} onActivate={setRailTab}
onClose={() => { onClose={() => {
// Only the inspector is *about* the selection, so only closing it // Only the inspector is *about* the selection, so only closing it
// deselects; dismissing History leaves the canvas as you had it. // deselects; dismissing a panel leaves the canvas as you had it. Any
// panel rail (journal/history/chat) drops back to a canvas mode so the
// mobile mode bar reappears.
if (railTab === 'inspector') { if (railTab === 'inspector') {
select(null) select(null)
selectPlanting(null) selectPlanting(null)
} else {
setMode('fixtures')
Review

🔴 Closing Journal/Assistant/History rail forces mode back to 'fixtures' even while a bed is still focused, contradicting the focus→plants invariant and hiding the seed tray behind the object palette

correctness, error-handling · flagged by 5 models

  • web/src/pages/GardenEditorPage.tsx:645 — Closing a panel rail (journal/history/chat) unconditionally calls setMode('fixtures'), which can violate the "focusing a bed puts you in Plants mode" invariant established at line 178-180. Reachable path: focus a bed (mode→plants via the effect) → tap Journal mode (selectMode('journal') opens the rail, mode=journal) → close the rail. The close handler sets mode back to fixtures while focusedObjectId is still set, so the mobile bottom s…

🪰 Gadfly · advisory

🔴 **Closing Journal/Assistant/History rail forces mode back to 'fixtures' even while a bed is still focused, contradicting the focus→plants invariant and hiding the seed tray behind the object palette** _correctness, error-handling · flagged by 5 models_ - `web/src/pages/GardenEditorPage.tsx:645` — Closing a panel rail (journal/history/chat) unconditionally calls `setMode('fixtures')`, which can violate the "focusing a bed puts you in Plants mode" invariant established at line 178-180. Reachable path: focus a bed (mode→`plants` via the effect) → tap **Journal** mode (`selectMode('journal')` opens the rail, mode=`journal`) → close the rail. The close handler sets mode back to `fixtures` while `focusedObjectId` is still set, so the mobile bottom s… <sub>🪰 Gadfly · advisory</sub>
} }
setRailTab(null) setRailTab(null)
}} }}
@@ -575,3 +671,54 @@ export function GardenEditorPage() {
</div> </div>
) )
} }
// The mobile primary mode switch (#99): one always-there tab bar so "placing
// beds", "planting", "journaling" and "assistant" stop competing for the same
// strip. Assistant is dropped when the instance has no model configured.
const MODES: { id: EditorMode; label: string; icon: string }[] = [
{ id: 'fixtures', label: 'Fixtures', icon: '🛠️' },
{ id: 'plants', label: 'Plants', icon: '🌱' },
{ id: 'journal', label: 'Journal', icon: '📓' },
{ id: 'assistant', label: 'Assistant', icon: '💬' },
]
function ModeBar({
Outdated
Review

🟡 ModeBar component defined inline instead of in editor components directory

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **ModeBar component defined inline instead of in editor components directory** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
mode,
onSelect,
hasAssistant,
}: {
mode: EditorMode
onSelect: (m: EditorMode) => void
hasAssistant: boolean
}) {
const modes = hasAssistant ? MODES : MODES.filter((m) => m.id !== 'assistant')
Outdated
Review

🟡 ModeBar can have no active tab when assistant capability is removed

correctness · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:694 — When hasAssistant flips from true to false (e.g. capabilities query refreshes after an admin disables the agent), the ModeBar filters out the assistant tab, but mode may still be 'assistant'. No tab will be highlighted as active, leaving the mode bar in an inconsistent state. Fix: either normalize mode to 'fixtures' when the current mode is no longer in the filtered list, or keep the assistant tab disabled rather than removing it.

🪰 Gadfly · advisory

🟡 **ModeBar can have no active tab when assistant capability is removed** _correctness · flagged by 1 model_ * `web/src/pages/GardenEditorPage.tsx:694` — When `hasAssistant` flips from `true` to `false` (e.g. capabilities query refreshes after an admin disables the agent), the `ModeBar` filters out the assistant tab, but `mode` may still be `'assistant'`. No tab will be highlighted as active, leaving the mode bar in an inconsistent state. **Fix:** either normalize `mode` to `'fixtures'` when the current mode is no longer in the filtered list, or keep the assistant tab disabled rather than removing it. <sub>🪰 Gadfly · advisory</sub>
return (
<div
role="tablist"
aria-label="Editor mode"
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
>
{modes.map((m) => {
const active = mode === m.id
return (
<button
key={m.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onSelect(m.id)}
className={cn(
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
)}
>
<span aria-hidden className="text-lg leading-none">
{m.icon}
</span>
{m.label}
</button>
)
})}
</div>
)
}