Two things at once, because they're one layout: on a phone, selecting a bed opened the rail as a bottom sheet that COVERED the whole garden, and opening Journal/Assistant hid the mode bar until you closed it. You couldn't see what you were editing, or switch modes without backing out. Now the rail is an in-flow PEEK. Instead of `fixed bottom-0 max-h-70vh` overlaying everything, EditorRail on mobile is a ≤50vh flex child the editor places BETWEEN the canvas and the mode bar: canvas (flex-1, shrinks) → rail peek (≤50vh) → mode bar (always shown) So the garden stays visible in the top band, the mode bar stays reachable below, and you can tap another mode straight from an open panel. The contextual tool strip yields to the peek (gated on !railTab), and tapping a canvas mode closes the panel (deselecting if it was the inspector). Desktop is untouched — the same EditorRail is the right-hand column there (`md:` styles), the mode bar stays `md:hidden`. Answers Steve's two calls from the end-of-run questions: always-visible mode bar + non-occluding inspector. Verified live at 390px (inspector + journal peeks keep the garden and mode bar visible; mode switching works) and 1280px (desktop unchanged). tsc + vitest + build green; DESIGN updated. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
93 lines
3.4 KiB
TypeScript
93 lines
3.4 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 is a fixed 20rem column and closes completely when nothing needs it.
|
|
*
|
|
* On a phone the same tabs render in a bottom sheet, which is where the
|
|
* inspector already lived.
|
|
*/
|
|
|
|
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>
|
|
)
|
|
}
|