Build image / build-and-push (push) Successful in 12s
- Fold the account menu into the editor's mobile strip. Hiding the global header removed the only sign-out on mobile in the editor; the strip now carries it, so the space win stays but sign-out is one tap away. - EditorRail peek cap vh → dvh, matching the dvh-bounded editor column, so it can't overrun the visible viewport and push the mode bar off-screen. - Mobile editor height 4rem → 3rem: with the header hidden, only <main>'s py-6 (3rem) is outside the editor, so 4rem left ~16px dead. Comment corrected. - Trim two comments that duplicated nearby docs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
106 lines
4.3 KiB
TypeScript
106 lines
4.3 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 capped-height 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. `tall` raises that
|
|
* cap for panel modes (journal/history/assistant), where reading and typing are
|
|
* the task and a half-height peek felt cramped; the inspector keeps the shorter
|
|
* peek so the canvas it describes stays in view.
|
|
*/
|
|
|
|
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,
|
|
tall = false,
|
|
}: {
|
|
tabs: RailTab[]
|
|
activeId: string
|
|
onActivate: (id: string) => void
|
|
onClose: () => void
|
|
/** Raise the mobile peek's height cap (panel modes want the room). */
|
|
tall?: boolean
|
|
}) {
|
|
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 (the cap doesn't apply there).
|
|
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
|
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
|
|
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
|
|
// visible area and shove the mode bar off-screen (same #85 reasoning).
|
|
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
|
|
'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>
|
|
)
|
|
}
|