/** * Shared top-right toolbar pieces (prototype-review Phase 1). The canvas pill and the * prototype pill render the SAME controls so they never drift: laser / comment buttons * (store-level, zero coupling), a Hide-UI toggle (one shared body class), and controlled * Device / Theme dropdowns whose wiring stays local to each surface (canvas mutates * selection/board; play mutates play.device/theme). usePopover + Popover live here too so * both App and Play share the popover chrome. */ import { createPortal } from 'react-dom' import { useEffect, useRef, useState, type ReactNode } from 'react' import { useStore, CONFIG, SOURCE_REVEALED, cap, stoppedFor } from './store.ts' import { STOPPED_TIP, useStopped } from './api.ts' import { NO_COMMENTS_HERE, useComments } from './comments-store.ts' import { UNPLACED } from './hash.ts' import { Tip } from './Tip.tsx' import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SlideFrameIcon, SunIcon, deviceIcon } from './icons.tsx' const commentsStore = () => useComments.getState() /** Shared popover machinery: trigger position, outside-click close, portal to the app * root (glass never nests - a nested backdrop-filter cannot sample the page). */ export function usePopover() { const [open, setOpen] = useState(false) const [pos, setPos] = useState({ left: 0, top: 0 }) const boxRef = useRef(null) const menuRef = useRef(null) const toggle = () => { if (!open && boxRef.current) { const r = boxRef.current.getBoundingClientRect() setPos({ left: r.left, top: r.bottom + 10 }) } setOpen(!open) } useEffect(() => { if (!open) return const close = (e: PointerEvent) => { const t = e.target as globalThis.Node if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false) } window.addEventListener('pointerdown', close) return () => window.removeEventListener('pointerdown', close) }, [open]) return { open, setOpen, pos, boxRef, menuRef, toggle } } export function Popover({ pop, children, dark }: { pop: ReturnType; children: ReactNode; dark?: boolean }) { const app = document.querySelector('.sh-app') if (!pop.open || !app) return null return createPortal(
{children}
, app, ) } // ---- Hide UI: one shared, binary body class (canvas + prototype) --------------------- // The state IS the body class - one source of truth for both pills, so they can never // desync. Not persisted, so a page refresh always restores the chrome (the safety net // for a forgotten shortcut). toggleHideUI is the only writer; useHideUI subscribes. const hideListeners = new Set<() => void>() export const isHideUI = () => document.body.classList.contains('sh-hide-ui') export function toggleHideUI() { document.body.classList.toggle('sh-hide-ui', !isHideUI()) hideListeners.forEach((f) => f()) } export function useHideUI() { const [on, setOn] = useState(isHideUI) useEffect(() => { const f = () => setOn(isHideUI()) hideListeners.add(f) return () => { hideListeners.delete(f) } }, []) return on } // ---- shared controls ---------------------------------------------------------------- /** Two-row shortcut tooltip: one action per row, key right-aligned - the provenance * tooltip's bold-value/muted-key language, shared by every two-shortcut button. */ function TipRows({ rows }: { rows: [string, string][] }) { return ( {rows.map(([label, key]) => {label}{key})} ) } /** Laser toggle - store-level, identical on canvas and prototype. The per-surface code * broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode, * since the two are one-at-a-time). */ export function LaserButton() { const laser = useStore((s) => s.laser) const laserInspect = useStore((s) => s.laserInspect) const showAnchor = useComments((s) => s.showAnchor) // stripped build: the paths are not in the bundle, so the button that would // copy them has nothing honest to offer if (!SOURCE_REVEALED) return null return ( }> ) } /** Comment toggle - store-level, identical on canvas and prototype. * A viewer whose role stops at view on this board sees the LOCKED capability: * the honest message ("not available for you yet") and the Ask-to-comment * path into the owner's request queue (04-solution §2.35). */ export function CommentButton() { const commentMode = useComments((s) => s.commentMode) const show = useComments((s) => s.show) const me = useComments((s) => s.me) const myBoards = useComments((s) => s.myBoards) const board = useStore((s) => s.board) const asked = useRef(0) const stopped = useStopped() const lockedOut = !!me && !!myBoards && myBoards[board] === 'view' // the canvas stopped (spec 28 A1.11): off, saying why - a comment could not be sent if (stopped && !commentMode) { return ( Comments need the canvas running{STOPPED_TIP}}> ) } if (lockedOut) { return ( Commenting is not available for you yetclick to ask the owner}> ) } return ( }> ) } /** Hide-UI toggle - the dedicated binary control (no auto-fade, no hover-reveal). Its * tooltip carries the way back; H is the shortcut, wired per-surface. */ export function HideUIButton() { const on = useHideUI() return ( Hide all UIpress H to reveal}> ) } /** Controlled device dropdown. `value` is the active device name ('fill' allowed when * includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter * owns what select does. */ export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, stageOnly, hint, dark }: { value: string | null onSelect: (name: string | null) => void includeDefault?: boolean includeFill?: boolean /** slides mode: the slide's own stage, fit to the window, as a first-class device */ includeSlide?: boolean /** slides mode: a slide is a fixed stage - only the stage views (slide, fill), no viewports */ stageOnly?: boolean hint?: ReactNode dark?: boolean }) { const pop = usePopover() const entries = stageOnly ? [] : Object.entries(CONFIG.viewports) const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) } const triggerIcon = value === 'fill' ? : value === 'slide' ? : deviceIcon(value, 16) return (
Device view{hint && {hint}}}> {includeSlide && <> } {includeDefault && <> } {entries.map(([name, vp], i) => ( ))} {includeFill && }
) } /** Controlled theme dropdown. `value` drives the trigger (the level the picker acts on - * the scope MAJORITY on the canvas); `checked` is the menu tick and can differ (undefined * = tick `value`; null = tick nothing, e.g. a mixed selection). onSelect owns the write. */ export function ThemePicker({ value, checked, onSelect, hint, dark }: { value: string; checked?: string | null; onSelect: (t: string) => void; hint?: ReactNode; dark?: boolean }) { const pop = usePopover() const pick = (t: string) => { onSelect(t); pop.setOpen(false) } const tick = checked === undefined ? value : checked return (
Theme{hint && {hint}}}> {CONFIG.themes.map((t) => ( ))}
) }