| 1 | "use client"; |
| 2 | |
| 3 | /** |
| 4 | * <PresenceDot> and <WhaleOrb> — the desktop client's two shapes, on the web. |
| 5 | * |
| 6 | * The GPUI app says presence with exactly two forms: a 6px lamp beside a word |
| 7 | * (`workspace/dock/whale.rs:786`) and a round port holding the live particle |
| 8 | * whale (`:702`). The palette already moved to that client; these are the |
| 9 | * shapes that went with it. |
| 10 | * |
| 11 | * Two rules come from the app and are load-bearing here, not decoration: |
| 12 | * |
| 13 | * 1. The dot never speaks alone. `presence_color` notes that "the word is |
| 14 | * already the honest claim; this only colours it" — so the colour is a |
| 15 | * second channel, never the only one. Colour-blind readers and screen |
| 16 | * readers get the same sentence everyone else does. |
| 17 | * 2. A saturated hue reads neon at dot size. The app steps its success hue |
| 18 | * back in alpha for exactly this. The web palette's state hues are already |
| 19 | * the warm-muted set (`--gpui-moss` / `--gpui-rust` / `--gpui-tan`), so |
| 20 | * they need no alpha step — the rule is inherited, the number is not. |
| 21 | */ |
| 22 | |
| 23 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 24 | |
| 25 | export type Presence = "live" | "attention" | "idle" | "human"; |
| 26 | |
| 27 | const DOT_COLOR: Record<Presence, string> = { |
| 28 | live: "var(--gpui-moss)", |
| 29 | attention: "var(--gpui-rust)", |
| 30 | human: "var(--gpui-tan)", |
| 31 | idle: "var(--gpui-ink-mute)", |
| 32 | }; |
| 33 | |
| 34 | /** |
| 35 | * A presence lamp and the word it belongs to. `label` is required: a bare |
| 36 | * coloured dot is a claim nobody can read. |
| 37 | */ |
| 38 | export function PresenceDot({ |
| 39 | presence, |
| 40 | label, |
| 41 | className = "", |
| 42 | }: { |
| 43 | presence: Presence; |
| 44 | label: string; |
| 45 | className?: string; |
| 46 | }) { |
| 47 | return ( |
| 48 | <span className={`inline-flex items-center gap-1.5 ${className}`}> |
| 49 | <span |
| 50 | aria-hidden="true" |
| 51 | className="inline-block h-1.5 w-1.5 shrink-0 rounded-full" |
| 52 | style={{ backgroundColor: DOT_COLOR[presence] }} |
| 53 | /> |
| 54 | <span>{label}</span> |
| 55 | </span> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | /** The app's particle style, verbatim: rgb(144,185,255) at 0.7 (`pet/tests.rs:5`). */ |
| 60 | const PARTICLE = "rgba(144, 185, 255, 0.7)"; |
| 61 | const POINT_COUNT = 190; |
| 62 | const VIEW = 512; |
| 63 | |
| 64 | /** |
| 65 | * The whale as the client draws it: points, not a silhouette. |
| 66 | * |
| 67 | * Sampled from the same `WHALE_MARK` outline the footer mark uses, so the |
| 68 | * brand shape has one source. Points drift on a slow sine — one orchestrated |
| 69 | * motion, not a loop of effects — and hold still under `prefers-reduced-motion`. |
| 70 | */ |
| 71 | export function WhaleOrb({ |
| 72 | size = 104, |
| 73 | label = "Codewhale", |
| 74 | path, |
| 75 | className = "", |
| 76 | }: { |
| 77 | size?: number; |
| 78 | label?: string; |
| 79 | /** The brand outline to sample. Pass `WHALE_MARK` from `./whale`. */ |
| 80 | path: string; |
| 81 | className?: string; |
| 82 | }) { |
| 83 | const pathRef = useRef<SVGPathElement | null>(null); |
| 84 | const [points, setPoints] = useState<Array<[number, number]>>([]); |
| 85 | const [still, setStill] = useState(true); |
| 86 | |
| 87 | useEffect(() => { |
| 88 | const el = pathRef.current; |
| 89 | if (!el) return; |
| 90 | const total = el.getTotalLength(); |
| 91 | const sampled: Array<[number, number]> = []; |
| 92 | for (let i = 0; i < POINT_COUNT; i += 1) { |
| 93 | const p = el.getPointAtLength((i / POINT_COUNT) * total); |
| 94 | sampled.push([p.x, p.y]); |
| 95 | } |
| 96 | setPoints(sampled); |
| 97 | }, [path]); |
| 98 | |
| 99 | useEffect(() => { |
| 100 | const query = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 101 | const apply = () => setStill(query.matches); |
| 102 | apply(); |
| 103 | query.addEventListener("change", apply); |
| 104 | return () => query.removeEventListener("change", apply); |
| 105 | }, []); |
| 106 | |
| 107 | // Deterministic per-point phase: the drift must not resample every render. |
| 108 | const phases = useMemo( |
| 109 | () => points.map((_, i) => ((i * 37) % 100) / 100), |
| 110 | [points], |
| 111 | ); |
| 112 | |
| 113 | return ( |
| 114 | <span |
| 115 | className={`codewhale-orb inline-flex items-center justify-center overflow-hidden rounded-full ${className}`} |
| 116 | style={{ width: size, height: size }} |
| 117 | role="img" |
| 118 | aria-label={label} |
| 119 | > |
| 120 | <svg viewBox={`0 0 ${VIEW} ${VIEW}`} width={size} height={size} aria-hidden="true"> |
| 121 | <path ref={pathRef} d={path} fill="none" stroke="none" /> |
| 122 | {points.map(([x, y], i) => ( |
| 123 | <circle |
| 124 | key={i} |
| 125 | cx={x} |
| 126 | cy={y} |
| 127 | r={4} |
| 128 | fill={PARTICLE} |
| 129 | style={ |
| 130 | still |
| 131 | ? undefined |
| 132 | : { |
| 133 | animation: `codewhale-orb-drift 6s ease-in-out ${( |
| 134 | phases[i] * -6 |
| 135 | ).toFixed(2)}s infinite`, |
| 136 | } |
| 137 | } |
| 138 | /> |
| 139 | ))} |
| 140 | </svg> |
| 141 | </span> |
| 142 | ); |
| 143 | } |
| 144 |