返回 CodeWhale
presence.tsx
根目录 / web / components / presence.tsx
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
144 lines Plain Text