diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index e22dfde9e4..7335f3ce58 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -118,9 +118,10 @@ do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides. **Variants:** `default` (primary), `destructive`, `secondary` (soft fill — the default non-primary look), `outline` (transparent + 1px inset ring, no -fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel", -"Clear"), `textStrong` (bold underlined inline affordance — "Change", -"Open logs"). +fill/shadow), `ghost`, `floating` (a control loose from any surface — opaque +popover fill + `shadow-md`, hover lifts the glyph only), `link`, `text` +(boxless quiet inline — "Cancel", "Clear"), `textStrong` (bold underlined +inline affordance — "Change", "Open logs"). **Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` @@ -196,6 +197,11 @@ blurred backdrop. (color mode, tool-call display, usage period). Replaces radio piles and pill rows. - **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper. +- **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that + fans sibling toggles out on hover: `direction` `vertical` | `horizontal` + (split around the hub) | `arc`. Discs are `Button` `floating` off / + `default` on; tips anchor left by default. Use it where a row of rarely + touched toggles is costing input width (the composer's voice controls). ## Layout diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 444ea564c3..fdbb64b8c0 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -26,6 +26,11 @@ const buttonVariants = cva( secondary: 'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', + // A control floating free of any surface (fan-menu discs, detached + // chips): the menu/popover treatment — opaque popover fill + the + // shared `shadow-md` ring-and-drop. Hover only lifts the glyph; a fill + // change on a lone disc reads as a toggle flipping. + floating: 'bg-popover text-(--ui-text-secondary) shadow-md hover:text-(--ui-text-primary)', link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`, // Boxless inline-text action (no bg/border). Quiet by default — reads as // muted label text, underlines on hover (e.g. "Cancel", "Clear"). diff --git a/apps/desktop/src/components/ui/fan-menu.test.ts b/apps/desktop/src/components/ui/fan-menu.test.ts new file mode 100644 index 0000000000..62808177aa --- /dev/null +++ b/apps/desktop/src/components/ui/fan-menu.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { fanOffsets } from './fan-menu' + +const STEP = 28 + +const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y) + +describe('fanOffsets', () => { + it('stacks a column straight up, one step apart', () => { + expect(fanOffsets('vertical', 3, STEP)).toEqual([ + { x: 0, y: -STEP }, + { x: 0, y: -2 * STEP }, + { x: 0, y: -3 * STEP } + ]) + }) + + it('splits a row around the hub in reading order, extra one on the right', () => { + expect(fanOffsets('horizontal', 2, STEP)).toEqual([ + { x: -STEP, y: 0 }, + { x: STEP, y: 0 } + ]) + expect(fanOffsets('horizontal', 3, STEP)).toEqual([ + { x: -STEP, y: 0 }, + { x: STEP, y: 0 }, + { x: 2 * STEP, y: 0 } + ]) + expect(fanOffsets('horizontal', 4, STEP).map(p => p.x)).toEqual([-2 * STEP, -STEP, STEP, 2 * STEP]) + }) + + // Neighbours never touch in any direction: the whole point of the step. + it('keeps arc neighbours one step apart and clear of the hub', () => { + for (const count of [1, 2, 3, 5, 8]) { + const points = fanOffsets('arc', count, STEP) + const hub = { x: 0, y: 0 } + + expect(points).toHaveLength(count) + + for (const p of points) { + expect(p.y).toBeLessThan(0) + expect(dist(p, hub)).toBeGreaterThanOrEqual(STEP - 1e-6) + } + + for (let i = 1; i < count; i++) { + expect(dist(points[i - 1], points[i])).toBeCloseTo(STEP, 6) + } + } + }) + + it('centres the arc over the hub', () => { + const points = fanOffsets('arc', 4, STEP) + + expect(points[0].x).toBeCloseTo(-points[3].x, 6) + expect(points[1].x).toBeCloseTo(-points[2].x, 6) + }) +}) diff --git a/apps/desktop/src/components/ui/fan-menu.tsx b/apps/desktop/src/components/ui/fan-menu.tsx new file mode 100644 index 0000000000..895b798670 --- /dev/null +++ b/apps/desktop/src/components/ui/fan-menu.tsx @@ -0,0 +1,397 @@ +import { + type CSSProperties, + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react' +import { createPortal } from 'react-dom' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +export type FanDirection = 'horizontal' | 'vertical' | 'arc' + +export interface FanMenuItem { + id: string + icon: ReactNode + label: string + /** A toggle that is ON. Solid primary disc, so it reads from across the room. */ + active?: boolean + disabled?: boolean + onSelect: () => void +} + +export interface FanMenuProps { + /** The always-visible control. Hovering or focusing it fans `items` out. */ + hub: FanMenuItem & { className?: string } + items: readonly FanMenuItem[] + direction?: FanDirection + /** Accessible name for the fanned group. */ + label: string + /** Gap between discs, px. Discs are the hub's measured size; the step + * between them is that size plus this. */ + gap?: number + /** Where each disc's tooltip sits. Defaults to left, so tips hang off the + * side of the fan rather than covering the next disc up. */ + tipAnchor?: 'top' | 'left' | 'right' | 'bottom' + /** The hairline caret on the hub that says "there is more". */ + hint?: boolean +} + +interface Point { + x: number + y: number +} + +/** Quick with a touch of overshoot: the discs pop into place, not float. */ +const POP = 'cubic-bezier(0.2, 1.25, 0.4, 1)' +const OPEN_MS = 160 +const CLOSE_MS = 100 +const STAGGER_MS = 20 +/** Grace while the pointer crosses the gap between the hub and a disc. */ +const HIDE_DELAY_MS = 150 +/** Widest an arc will spread before it grows its radius instead. */ +const ARC_MAX_SPAN_DEG = 150 + +/** + * Disc centres relative to the hub, in px. `step` is one disc plus one gap, + * so neighbours never touch in any direction. + * + * - vertical: a column straight up, in order. + * - horizontal: reading order, split around the hub — the first half to the + * left, the rest to the right, the extra one (odd counts) on the right. + * - arc: a semicircle above, spread so adjacent discs sit one `step` apart + * along the chord; past 150° the radius grows to fit instead. + */ +export function fanOffsets(direction: FanDirection, count: number, step: number): Point[] { + const out: Point[] = [] + + if (direction === 'vertical') { + for (let i = 0; i < count; i++) { + out.push({ x: 0, y: -(i + 1) * step }) + } + + return out + } + + if (direction === 'horizontal') { + const left = Math.floor(count / 2) + + for (let i = 0; i < count; i++) { + out.push({ x: i < left ? -(left - i) * step : (i - left + 1) * step, y: 0 }) + } + + return out + } + + if (count === 1) { + return [{ x: 0, y: -step }] + } + + let radius = step * 1.15 + let delta = 2 * Math.asin(Math.min(1, step / (2 * radius))) + const maxSpan = (ARC_MAX_SPAN_DEG * Math.PI) / 180 + + if (delta * (count - 1) > maxSpan) { + delta = maxSpan / (count - 1) + radius = step / (2 * Math.sin(delta / 2)) + } + + const start = Math.PI / 2 + (delta * (count - 1)) / 2 + + for (let i = 0; i < count; i++) { + const a = start - i * delta + + out.push({ x: Math.cos(a) * radius, y: -Math.sin(a) * radius }) + } + + return out +} + +const sameRect = (a: DOMRect | null, b: DOMRect) => + a !== null && a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height + +/** + * One control that fans its siblings out on hover — a column, a row centred + * on it, or an arc. The hub stays in the flow where the consumer puts it; the + * discs portal to `body` so an `overflow-hidden` parent cannot clip them, and + * are re-anchored on scroll and resize. + * + * Hover, geometry and open/close timing live here. The consumer only + * re-renders when its own items change; pointer traffic never reaches it. + */ +export function FanMenu({ + direction = 'vertical', + gap = 4, + hint = true, + hub, + items, + label, + tipAnchor = 'left' +}: FanMenuProps) { + const anchorRef = useRef(null) + const [rect, setRect] = useState(null) + const [open, setOpen] = useState(false) + const hideTimer = useRef(undefined) + const unmountTimer = useRef(undefined) + + const clearTimers = useCallback(() => { + window.clearTimeout(hideTimer.current) + window.clearTimeout(unmountTimer.current) + hideTimer.current = undefined + }, []) + + const measure = useCallback(() => { + const el = anchorRef.current + + if (el) { + const next = el.getBoundingClientRect() + + setRect(current => (sameRect(current, next) ? current : next)) + } + }, []) + + const show = useCallback(() => { + clearTimers() + measure() + setOpen(true) + }, [clearTimers, measure]) + + const hide = useCallback(() => { + clearTimers() + setOpen(false) + unmountTimer.current = window.setTimeout(() => setRect(null), CLOSE_MS + STAGGER_MS * items.length) + }, [clearTimers, items.length]) + + const hideSoon = useCallback(() => { + window.clearTimeout(hideTimer.current) + hideTimer.current = window.setTimeout(hide, HIDE_DELAY_MS) + }, [hide]) + + const stayOpen = useCallback(() => { + window.clearTimeout(hideTimer.current) + hideTimer.current = undefined + }, []) + + useEffect(() => clearTimers, [clearTimers]) + + // The hub can move under a parked fan: its container grows, the window + // resizes, an ancestor scrolls. + const anchored = rect !== null + + useEffect(() => { + if (!anchored) { + return + } + + window.addEventListener('scroll', measure, true) + window.addEventListener('resize', measure) + + return () => { + window.removeEventListener('scroll', measure, true) + window.removeEventListener('resize', measure) + } + }, [anchored, measure]) + + // Enter/leave alone can strand the fan open. A disc that goes `disabled` + // under the pointer (a toggle turning pending right after the click) stops + // receiving pointer events, so its `pointerleave` never fires; a fast + // flick can skip the leave the same way. Watch the document while open and + // close as soon as the pointer is over neither the hub nor a disc. + useEffect(() => { + if (!open) { + return + } + + const onMove = (event: PointerEvent) => { + const target = event.target instanceof Element ? event.target : null + + const inside = + target !== null && + (anchorRef.current?.contains(target) === true || target.closest('[data-slot="fan-menu"]') !== null) + + if (inside) { + stayOpen() + } else if (hideTimer.current === undefined) { + hideSoon() + } + } + + document.addEventListener('pointermove', onMove, true) + + return () => document.removeEventListener('pointermove', onMove, true) + }, [hideSoon, open, stayOpen]) + + const leaveUnlessWithin = (event: React.FocusEvent, then: () => void) => { + if (!(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) { + then() + } + } + + return ( + <> + {/* The wrapper takes the hover, not the button: a disabled hub swallows + pointer events, and the fan must still open around it. */} + leaveUnlessWithin(event, hideSoon)} + onFocus={show} + onPointerEnter={show} + onPointerLeave={hideSoon} + ref={anchorRef} + > + + + + {hint ? ( + + ) : null} + + {rect + ? createPortal( + , + document.body + ) + : null} + + ) +} + +function discStyle(target: Point, index: number, entered: boolean): CSSProperties { + // Folded, a disc sits halfway to its slot rather than under the hub: one + // that mounts beneath a parked pointer picks up :hover and keeps it (a + // transform-only move never re-hit-tests), so it would pop out pre-lit. + const k = entered ? 1 : 0.5 + const delay = index * STAGGER_MS + + return { + opacity: entered ? 1 : 0, + scale: entered ? '1' : '0.5', + translate: `${(target.x * k).toFixed(2)}px ${(target.y * k).toFixed(2)}px`, + transition: entered + ? `translate ${OPEN_MS}ms ${POP} ${delay}ms, scale ${OPEN_MS}ms ${POP} ${delay}ms, opacity 80ms ease-out ${delay}ms` + : `translate ${CLOSE_MS}ms ease-in, scale ${CLOSE_MS}ms ease-in, opacity ${CLOSE_MS}ms ease-in` + } +} + +const FanCluster = memo(function FanCluster({ + direction, + items, + label, + onClose, + onPointerEnter, + onPointerLeave, + open, + rect, + step, + tipAnchor +}: { + direction: FanDirection + items: readonly FanMenuItem[] + label: string + onClose: () => void + onPointerEnter: () => void + onPointerLeave: () => void + open: boolean + rect: DOMRect + step: number + tipAnchor: NonNullable +}) { + const rootRef = useRef(null) + // Mount folded, then unfold on the next style pass so the translate + // actually transitions instead of landing in place. The layout read forces + // Chromium to resolve the folded style before the change. + const [entered, setEntered] = useState(false) + + useLayoutEffect(() => { + if (open) { + void rootRef.current?.offsetWidth + } + + setEntered(open) + }, [open]) + + const offsets = fanOffsets(direction, items.length, step) + + return ( + // The root is the hub's footprint and takes no pointer events itself, so + // the real hub underneath keeps its hover; only the discs are hittable. +
{ + if (event.key === 'Escape') { + event.stopPropagation() + onClose() + } + }} + ref={rootRef} + role="group" + style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }} + > + {items.map((item, index) => ( + + + + ))} +
+ ) +})