Files
pansy/web/src/editor/EditorRail.tsx
T
steveandClaude Opus 4.8 78ee892b64
Build image / build-and-push (push) Successful in 10s
Address inspector-peek review: stale docs + selection cleanup
Gadfly on #101:
- EditorRail's module doc still described a "fixed 20rem column / bottom
  sheet"; updated to the in-flow peek (desktop column, phone ≤50vh peek
  between canvas and mode bar).
- (correctness) A canvas-mode tap only deselected when railTab was
  'inspector', so a selection made, then routed through Journal/Assistant,
  survived a return to Fixtures/Plants — the canvas kept highlighting it
  with no inspector. Canvas modes now clear the selection unconditionally.
- Dedup: the "closing the inspector deselects" pair lived in both selectMode
  and the rail's onClose; extracted a clearSelection() helper (also used by
  exitFocus) and fixed the now-stale "leave an inspector alone" comment.

tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:49:28 -04:00

96 lines
3.7 KiB
TypeScript

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* The editor's side rail, and the answer to "four things want one rail".
*
* The inspector, history, journal, and (later) the chat panel all want the same
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
* one rail — which keeps the canvas one width instead of a different
* width per panel, and means adding the journal or chat is adding a tab.
*
* Two constraints shaped it:
*
* - Selecting an object must land you in the inspector with no extra click.
* The editor watches the selection and switches to that tab itself, so the
* rail never becomes a thing you have to operate before you can edit.
* - The canvas has to stay worth watching while the agent edits it, so the rail
* closes completely when nothing needs it.
*
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
* Phone: an in-flow PEEK (#101) — a ≤50vh panel the editor's flex column places
* BETWEEN the canvas and the always-visible mode bar, so the canvas shrinks to
* keep the garden visible above it and the mode bar reachable below, rather than
* a bottom sheet that covered the whole garden.
*/
export interface RailTab {
id: string
label: string
/** Rendered lazily: only the active tab's content is built. */
render: () => ReactNode
/** Shown as a dot on the tab — a count, or true for a plain marker. */
badge?: number | boolean
}
export function EditorRail({
tabs,
activeId,
onActivate,
onClose,
}: {
tabs: RailTab[]
activeId: string
onActivate: (id: string) => void
onClose: () => void
}) {
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
if (!active) return null
return (
<div
className={cn(
// Phone: an in-flow PEEK — a capped-height panel that sits between the
// canvas and the always-visible mode bar (the editor's flex column places
// it there), so the garden stays visible above it and the mode bar stays
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
// fixed-width column beside the canvas.
'flex max-h-[50vh] min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
)}
>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onActivate(tab.id)}
aria-current={tab.id === active.id ? 'page' : undefined}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
'focus-visible:ring-2 focus-visible:ring-accent/40',
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
)}
>
{tab.label}
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
{tab.badge === true ? '•' : tab.badge}
</span>
)}
</button>
))}
<button
type="button"
onClick={onClose}
aria-label="Close panel"
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
</div>
)
}