@candor-ui/react 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -3
  2. package/dist/adapters/use-ag-ui.d.ts +1 -1
  3. package/dist/adapters/use-candor-chat.d.ts +1 -1
  4. package/dist/adapters/use-claude-agent.d.ts +1 -1
  5. package/dist/blocks/copy-field.js +4 -1
  6. package/dist/blocks/degraded-panel.js +3 -1
  7. package/dist/blocks/locked.js +23 -2
  8. package/dist/blocks/relative-time.js +1 -1
  9. package/dist/blocks/status-dot.d.ts +14 -3
  10. package/dist/blocks/status-dot.js +17 -22
  11. package/dist/blocks/step-list.d.ts +2 -1
  12. package/dist/blocks/step-list.js +19 -27
  13. package/dist/blocks/streaming-text.js +3 -1
  14. package/dist/blocks/surface.d.ts +1 -1
  15. package/dist/chat/attachment-preview.d.ts +6 -1
  16. package/dist/chat/attachment-preview.js +9 -3
  17. package/dist/chat/card-registry.js +6 -4
  18. package/dist/chat/citation.js +3 -3
  19. package/dist/chat/composer.js +19 -53
  20. package/dist/chat/context-meter.d.ts +5 -0
  21. package/dist/chat/context-meter.js +15 -1
  22. package/dist/chat/entity-chip.d.ts +3 -1
  23. package/dist/chat/entity-chip.js +2 -2
  24. package/dist/chat/folded-turns.js +3 -1
  25. package/dist/chat/message-actions.js +5 -2
  26. package/dist/chat/message.js +9 -5
  27. package/dist/chat/payload.js +1 -1
  28. package/dist/chat/permission-prompt.d.ts +5 -0
  29. package/dist/chat/permission-prompt.js +36 -2
  30. package/dist/chat/prompt-suggestions.js +4 -1
  31. package/dist/chat/reasoning.js +6 -1
  32. package/dist/chat/thread.js +37 -9
  33. package/dist/chat/tool-call.js +6 -2
  34. package/dist/index.d.ts +9 -0
  35. package/dist/index.js +9 -0
  36. package/dist/lib/motion.d.ts +466 -0
  37. package/dist/lib/motion.js +380 -0
  38. package/dist/lib/sound.d.ts +44 -0
  39. package/dist/lib/sound.js +63 -0
  40. package/dist/styles.css +562 -45
  41. package/dist/ui/accordion.d.ts +23 -0
  42. package/dist/ui/accordion.js +45 -0
  43. package/dist/ui/alert.d.ts +22 -0
  44. package/dist/ui/alert.js +59 -0
  45. package/dist/ui/button.d.ts +1 -1
  46. package/dist/ui/button.js +5 -3
  47. package/dist/ui/checkbox.d.ts +14 -0
  48. package/dist/ui/checkbox.js +32 -0
  49. package/dist/ui/collapsible.d.ts +3 -2
  50. package/dist/ui/collapsible.js +6 -3
  51. package/dist/ui/label.d.ts +10 -0
  52. package/dist/ui/label.js +16 -0
  53. package/dist/ui/popover.d.ts +1 -1
  54. package/dist/ui/popover.js +4 -2
  55. package/dist/ui/progress.d.ts +29 -0
  56. package/dist/ui/progress.js +27 -0
  57. package/dist/ui/skeleton.d.ts +1 -1
  58. package/dist/ui/skeleton.js +5 -2
  59. package/dist/ui/slider.d.ts +28 -0
  60. package/dist/ui/slider.js +41 -0
  61. package/dist/ui/switch.d.ts +14 -0
  62. package/dist/ui/switch.js +23 -0
  63. package/dist/ui/tooltip.d.ts +1 -1
  64. package/dist/ui/tooltip.js +4 -2
  65. package/package.json +2 -3
@@ -0,0 +1,380 @@
1
+ import * as React from "react";
2
+ /** The CSS each easing name stands for, as a brand file writes it. */
3
+ export const motionEases = {
4
+ standard: "var(--cd-ease)",
5
+ inout: "var(--cd-ease-inout)",
6
+ spring: "var(--cd-ease-spring)",
7
+ linear: "linear",
8
+ };
9
+ /** The family defaults. Durations, easings and amplitudes are 2.2's, so an app with no brand file looks the same. */
10
+ export const motionFamilies = {
11
+ reveal: {
12
+ reports: "Content opened or closed to its measured size; a chevron turns.",
13
+ spatial: true,
14
+ essential: false,
15
+ dur: { default: 200, min: 150, max: 300 },
16
+ outDur: null,
17
+ ease: "standard",
18
+ eases: ["standard", "inout"],
19
+ amp: null,
20
+ variants: ["size", "fade"],
21
+ calm: "Snaps to its measured size.",
22
+ },
23
+ arrive: {
24
+ reports: "Something new, added after the first draw.",
25
+ spatial: true,
26
+ essential: false,
27
+ dur: { default: 240, min: 150, max: 300 },
28
+ outDur: null,
29
+ ease: "standard",
30
+ eases: ["standard", "spring"],
31
+ amp: { name: "distance", default: "8px", min: 0, max: 16, unit: "px", subtle: 0.5 },
32
+ variants: ["rise", "fade", "pop"],
33
+ calm: "Fades in, up to 150 ms.",
34
+ },
35
+ pop: {
36
+ reports: "Added, removed, or a small mark appearing.",
37
+ spatial: true,
38
+ essential: false,
39
+ dur: { default: 200, min: 100, max: 300 },
40
+ outDur: { default: 120, min: 80, max: 300 },
41
+ ease: "standard",
42
+ eases: ["standard", "spring"],
43
+ amp: { name: "scale", default: "0.85", min: 0.8, max: 1, unit: "", subtle: 0 },
44
+ variants: ["scale", "fade"],
45
+ calm: "Fades, up to 150 ms.",
46
+ },
47
+ mark: {
48
+ reports: "A tick or cross drawn when a result settles.",
49
+ spatial: true,
50
+ essential: false,
51
+ dur: { default: 360, min: 200, max: 500 },
52
+ outDur: null,
53
+ ease: "standard",
54
+ eases: ["standard"],
55
+ amp: null,
56
+ variants: ["draw", "pop", "drop"],
57
+ calm: "The finished mark fades in, up to 150 ms.",
58
+ },
59
+ fill: {
60
+ reports: "A bar or ring moving between measured values.",
61
+ spatial: true,
62
+ essential: false,
63
+ dur: { default: 360, min: 200, max: 600 },
64
+ outDur: null,
65
+ ease: "standard",
66
+ eases: ["standard", "inout"],
67
+ amp: null,
68
+ variants: ["grow"],
69
+ calm: "Snaps to the new value; its colour may fade.",
70
+ },
71
+ value: {
72
+ reports: "A number or word changed in place.",
73
+ spatial: false,
74
+ essential: false,
75
+ dur: { default: 120, min: 80, max: 200 },
76
+ outDur: null,
77
+ ease: "standard",
78
+ eases: ["standard"],
79
+ amp: { name: "distance", default: "2px", min: 0, max: 4, unit: "px", subtle: 0 },
80
+ variants: ["fade", "slide"],
81
+ calm: "Crossfades, up to 150 ms.",
82
+ },
83
+ move: {
84
+ reports: "A thing changed place: a reorder, a tab indicator, a toast restacking, a scroll.",
85
+ spatial: true,
86
+ essential: false,
87
+ dur: { default: 200, min: 150, max: 300 },
88
+ outDur: null,
89
+ ease: "standard",
90
+ eases: ["standard", "spring"],
91
+ amp: null,
92
+ variants: ["slide"],
93
+ calm: "Jumps to its new place.",
94
+ },
95
+ overlay: {
96
+ reports: "A dialog, sheet, popover, menu or tooltip opened or closed.",
97
+ spatial: true,
98
+ essential: false,
99
+ dur: { default: 150, min: 100, max: 500 },
100
+ outDur: { default: 150, min: 80, max: 300 },
101
+ ease: "standard",
102
+ eases: ["standard", "inout", "spring"],
103
+ amp: { name: "scale", default: "0.95", min: 0.9, max: 1, unit: "", subtle: 1 },
104
+ variants: ["scale", "slide", "fade"],
105
+ calm: "Fades, up to 150 ms.",
106
+ },
107
+ press: {
108
+ reports: "A press registered.",
109
+ spatial: true,
110
+ essential: false,
111
+ dur: { default: 150, min: 80, max: 200 },
112
+ outDur: null,
113
+ ease: "standard",
114
+ eases: ["standard"],
115
+ amp: { name: "scale", default: "0.96", min: 0.9, max: 1, unit: "", subtle: 0.5 },
116
+ variants: ["scale"],
117
+ calm: "Doesn't scale; the colour still answers.",
118
+ },
119
+ tint: {
120
+ reports: "Hover, focus, selected, a drop target: a colour or opacity change.",
121
+ spatial: false,
122
+ essential: false,
123
+ dur: { default: 150, min: 0, max: 200 },
124
+ outDur: null,
125
+ ease: "standard",
126
+ eases: ["standard", "inout"],
127
+ amp: null,
128
+ variants: ["fade"],
129
+ calm: "Colour and opacity only, up to 150 ms.",
130
+ },
131
+ refuse: {
132
+ reports: "A locked control was pressed: the reason is shown, and under expressive it shakes.",
133
+ spatial: true,
134
+ essential: false,
135
+ dur: { default: 360, min: 200, max: 500 },
136
+ outDur: null,
137
+ ease: "standard",
138
+ eases: ["standard"],
139
+ amp: { name: "distance", default: "3px", min: 0, max: 6, unit: "px", subtle: 0 },
140
+ variants: ["shake"],
141
+ calm: "No shake; the reason's colour still changes.",
142
+ },
143
+ attention: {
144
+ reports: "Something new waits on the person: a ring that grows and fades.",
145
+ spatial: false,
146
+ essential: false,
147
+ dur: { default: 900, min: 600, max: 1600 },
148
+ outDur: null,
149
+ ease: "standard",
150
+ eases: ["standard"],
151
+ amp: { name: "count", default: "1", min: 1, max: 3, unit: "", subtle: 1 },
152
+ variants: ["ring"],
153
+ calm: "One ring that fades without growing, up to 150 ms.",
154
+ },
155
+ live: {
156
+ reports: "Work that is being heard from: a dot's pulse, a caret, a spinner, a shimmer, a skeleton.",
157
+ spatial: false,
158
+ essential: false,
159
+ dur: { default: 1600, min: 1000, max: 3000 },
160
+ outDur: null,
161
+ ease: "standard",
162
+ eases: ["standard", "inout", "linear"],
163
+ amp: null,
164
+ variants: ["pulse"],
165
+ calm: "Still, with its word.",
166
+ },
167
+ clock: {
168
+ reports: "Real time passing: a hold, a deadline, a replay. Painted from the clock.",
169
+ spatial: true,
170
+ essential: true,
171
+ dur: null,
172
+ outDur: null,
173
+ ease: "linear",
174
+ eases: ["linear"],
175
+ amp: null,
176
+ variants: ["glide"],
177
+ calm: "Steps instead of gliding; never off.",
178
+ },
179
+ };
180
+ /** The family names, in the order the docs and the theme builder list them. */
181
+ export const motionFamilyNames = Object.keys(motionFamilies);
182
+ /**
183
+ * True when a family has one way of drawing its change (fill, move, press, tint, refuse, attention, live and clock).
184
+ * Its single variant is fixed: there is no `-variant` token to set, and `motion()` writes none, because the family's
185
+ * utility has nothing to choose between. The docs mark such a family "(fixed)" instead of listing a value that does
186
+ * nothing. A later release that adds a second word turns the token on without a new name.
187
+ */
188
+ export const motionVariantIsFixed = (family) => motionFamilies[family].variants.length < 2;
189
+ /**
190
+ * The CSS value a variant is written as. CSS can only choose a drawing by keyframe name, so a variant's value is its
191
+ * keyframe's: the word with Candor's prefix, which keeps an app's own `@keyframes rise` from taking over.
192
+ */
193
+ export const motionVariantValue = (variant) => `cd-${variant}`;
194
+ const momentKey = Symbol.for("candor.motion.moment");
195
+ const ms = (n) => `${n}ms`;
196
+ /**
197
+ * Builds a moment's chain. Call it once at module level and spread the result into the element's `style`, beside the
198
+ * family utility that the component adds at the honest moment:
199
+ *
200
+ * ```tsx
201
+ * const tick = motion("copy-field", "tick", "pop")
202
+ * <Check style={tick} className={cn(copied && "cd-pop")} />
203
+ * ```
204
+ *
205
+ * The brand reaches the moment with `--cd-motion-copy-field-tick` (its level), `-dur`, `-ease`, the family's amplitude
206
+ * suffix and `-variant`; anything it doesn't set falls back to the family (`--cd-motion-pop-*`), then to `defaults`,
207
+ * then to Candor's. Two families on one element compose: `style={{ ...press, ...tint }}`.
208
+ */
209
+ export function motion(item, moment, family, defaults = {}) {
210
+ const spec = motionFamilies[family];
211
+ const own = `--cd-motion-${item}-${moment}`;
212
+ const fam = `--cd-motion-${family}`;
213
+ const local = `--cd-m-${family}`;
214
+ const style = {
215
+ [local]: `var(${own}, var(${fam}, var(--cd-motion-preset)))`,
216
+ };
217
+ if (spec.dur)
218
+ style[`${local}-dur`] = `var(${own}-dur, var(${fam}-dur, ${ms(defaults.dur ?? spec.dur.default)}))`;
219
+ if (spec.outDur)
220
+ style[`${local}-out-dur`] = `var(${own}-out-dur, var(${fam}-out-dur, ${ms(defaults.outDur ?? spec.outDur.default)}))`;
221
+ if (spec.dur)
222
+ style[`${local}-ease`] = `var(${own}-ease, var(${fam}-ease, ${motionEases[defaults.ease ?? spec.ease]}))`;
223
+ if (spec.amp)
224
+ style[`${local}-amp`] = `var(${own}-${spec.amp.name}, var(${fam}-${spec.amp.name}, ${defaults.amp ?? spec.amp.default}))`;
225
+ if (!motionVariantIsFixed(family)) {
226
+ const variant = motionVariantValue(defaults.variant ?? spec.variants[0]);
227
+ style[`${local}-variant`] = `var(${own}-variant, var(${fam}-variant, ${variant}))`;
228
+ }
229
+ Object.defineProperty(style, momentKey, { value: { item, moment, family }, enumerable: false });
230
+ return style;
231
+ }
232
+ /** The moment a chain was built for, or `undefined` for a plain style object. */
233
+ export const motionMomentOf = (style) => style?.[momentKey];
234
+ /* ---------------------------------------------------------------- mount still */
235
+ const isFinished = (status) => status === "succeeded" || status === "failed";
236
+ const isOver = (status) => isFinished(status) || status === "cancelled" || status === "skipped";
237
+ const runTest = { settled: isFinished, over: isOver };
238
+ export function useSettled(value, test) {
239
+ const { settled: done, over = done } = (test ?? runTest);
240
+ const [settled, setSettled] = React.useState(0);
241
+ const previous = React.useRef(value);
242
+ React.useLayoutEffect(() => {
243
+ const was = previous.current;
244
+ previous.current = value;
245
+ if (was !== value && !over(was) && done(value))
246
+ setSettled((n) => n + 1);
247
+ }, [value, done, over]);
248
+ return settled;
249
+ }
250
+ /**
251
+ * Which keys were added after the component first drew. Keys present at the first render answer false, so a list
252
+ * that loads with fifty entries draws them still; a key added later answers true until it leaves (and true again if
253
+ * it comes back).
254
+ */
255
+ export function useArrived(keys) {
256
+ const [arrived, setArrived] = React.useState(() => new Set());
257
+ const previous = React.useRef(null);
258
+ React.useLayoutEffect(() => {
259
+ const before = previous.current;
260
+ previous.current = keys;
261
+ if (!before)
262
+ return;
263
+ const was = new Set(before);
264
+ const now = new Set(keys);
265
+ const added = keys.filter((key) => !was.has(key));
266
+ const gone = before.filter((key) => !now.has(key));
267
+ if (!added.length && !gone.length)
268
+ return;
269
+ setArrived((set) => {
270
+ const next = new Set(set);
271
+ for (const key of gone)
272
+ next.delete(key);
273
+ for (const key of added)
274
+ next.add(key);
275
+ return next;
276
+ });
277
+ }, [keys]);
278
+ return React.useCallback((key) => arrived.has(key), [arrived]);
279
+ }
280
+ /**
281
+ * Items that just left, kept as inert ghosts for the length of their out moment. Each ghost goes when `left(item)` is
282
+ * called (from its `onAnimationEnd`), or after `fallbackMs` when nothing animated. The default, 600 ms, is longer than
283
+ * the longest out moment a brand can set (pop's 300 ms at the slowest speed, 1.5×, is 450 ms). An item that comes back while its
284
+ * ghost is still leaving takes the ghost's place.
285
+ */
286
+ export function useLeaving(items, keyOf, fallbackMs = 600) {
287
+ const [leaving, setLeaving] = React.useState([]);
288
+ const previous = React.useRef(null);
289
+ React.useLayoutEffect(() => {
290
+ const before = previous.current;
291
+ previous.current = items;
292
+ if (!before)
293
+ return;
294
+ const now = new Set(items.map(keyOf));
295
+ const gone = before.filter((item) => !now.has(keyOf(item)));
296
+ setLeaving((list) => {
297
+ const kept = list.filter((item) => !now.has(keyOf(item)));
298
+ return kept.length === list.length && gone.length === 0 ? list : [...kept, ...gone];
299
+ });
300
+ }, [items, keyOf]);
301
+ React.useEffect(() => {
302
+ if (leaving.length === 0)
303
+ return;
304
+ const timer = window.setTimeout(() => setLeaving([]), fallbackMs);
305
+ return () => window.clearTimeout(timer);
306
+ }, [leaving, fallbackMs]);
307
+ const left = React.useCallback((item) => setLeaving((list) => list.filter((x) => x !== item)), []);
308
+ return { leaving, left };
309
+ }
310
+ /**
311
+ * The person's setting where `el` is: `none` under `data-motion="none"`, `calm` under the OS's reduced-motion setting
312
+ * or `data-motion="calm"`, else `full`. `none` or `calm` anywhere above wins (the same reach as the CSS variants), the
313
+ * OS setting can't be raised by the app, and values that aren't Candor's (Radix writes its own `data-motion` on menu
314
+ * panels) are ignored.
315
+ */
316
+ export function motionMode(el) {
317
+ if (el?.closest('[data-motion="none"]'))
318
+ return "none";
319
+ if (typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
320
+ return "calm";
321
+ if (el?.closest('[data-motion="calm"]'))
322
+ return "calm";
323
+ return "full";
324
+ }
325
+ /**
326
+ * `motionMode` for a component that has to react to it: the person's setting where `ref`'s element is, read after
327
+ * mount and again whenever the app changes `data-motion` or the OS setting changes. It starts as `full` for the first
328
+ * render, since there is nothing to read until the element exists. Use it for behaviour (a timer that must stop),
329
+ * not for styling, which the CSS variants already answer.
330
+ */
331
+ export function useMotionMode(ref) {
332
+ const [mode, setMode] = React.useState("full");
333
+ React.useEffect(() => {
334
+ const read = () => setMode(motionMode(ref.current));
335
+ read();
336
+ const observer = new MutationObserver(read);
337
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"], subtree: true });
338
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
339
+ query?.addEventListener("change", read);
340
+ return () => {
341
+ observer.disconnect();
342
+ query?.removeEventListener("change", read);
343
+ };
344
+ }, [ref]);
345
+ return mode;
346
+ }
347
+ const number = (value) => {
348
+ const n = Number.parseFloat(value);
349
+ return Number.isFinite(n) ? n : undefined;
350
+ };
351
+ /**
352
+ * The level a moment resolves to on `el`, read from its computed style: the moment's token, its family's, the preset,
353
+ * then capped by `data-motion`. A page without Candor's CSS reads as subtle.
354
+ */
355
+ export function motionLevel(el, moment) {
356
+ if (!el || typeof getComputedStyle === "undefined")
357
+ return 1;
358
+ const style = getComputedStyle(el);
359
+ const read = (name) => number(style.getPropertyValue(name));
360
+ const { item, moment: name, family } = typeof moment === "string" ? { item: undefined, moment: undefined, family: moment } : moment;
361
+ const level = (item ? read(`--cd-motion-${item}-${name}`) : undefined) ?? read(`--cd-motion-${family}`) ?? read("--cd-motion-preset") ?? 1;
362
+ const cap = read("--cd-motion-cap") ?? 2;
363
+ const resolved = Math.max(0, Math.min(2, Math.round(Math.min(level, cap))));
364
+ return (motionFamilies[family].essential ? Math.max(1, resolved) : resolved);
365
+ }
366
+ /**
367
+ * For motion that CSS can't stop (smooth scrolling, the Web Animations API, a bar painted from the clock): a stable
368
+ * function that answers, at the moment of the motion, whether it may play on `ref`'s element. False under
369
+ * `data-motion="none"`, under the calm level (reduced motion), and when the moment's resolved level is 0. Pass the
370
+ * moment's chain from `motion()`, or a family name for the family's level.
371
+ */
372
+ export function useMotionAllowed(ref, moment) {
373
+ const target = typeof moment === "string" ? moment : motionMomentOf(moment);
374
+ return React.useCallback(() => {
375
+ const el = ref.current;
376
+ if (motionMode(el) !== "full")
377
+ return false;
378
+ return target ? motionLevel(el, target) > 0 : true;
379
+ }, [ref, target]);
380
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The names and the reading half of Candor's sound layer. A brand file names a sound per event
3
+ * (`--cd-sound-needs-you: "/sounds/needs-you.mp3"`); this file knows the five events, reads that token from the
4
+ * page's computed style, and holds the small arithmetic the Pro hooks share. It plays nothing: nothing is fetched
5
+ * until `useAttention` or `sound-setting` plays a sound, and a sound is always off until the person turns it on.
6
+ *
7
+ * Tokens, all optional, all read when a sound is about to play:
8
+ * - `--cd-sound-<event>`: a string, the path of an MP3 hosted by the app. No token, no sound, silently.
9
+ * - `--cd-sound-<event>-ogg`: a string, an Ogg/Opus alternative the browser may prefer.
10
+ * - `--cd-sound-volume`: 0 to 1, how loud every sound is. `--cd-sound-<event>-volume`: 0 to 1, scales that for one event.
11
+ *
12
+ * Safe to import on the server: the document is touched only inside `readSoundToken`.
13
+ */
14
+ /** The five things the app may make a sound for. A closed list: `reply` and `silent` have no `AttentionKind` until 3.0. */
15
+ export type SoundEvent = "needs-you" | "finished" | "failed" | "reply" | "silent";
16
+ /** Every event, in the order the settings control lists them. */
17
+ export declare const soundEvents: readonly SoundEvent[];
18
+ /** The events a sound is offered for once the person turns sound on; `reply` and `silent` stay off until they pick them. */
19
+ export declare const defaultSoundEvents: readonly SoundEvent[];
20
+ /** A sound as the brand names it: where the file is, an optional Ogg alternative, and how loud (0 to 1). */
21
+ export type SoundToken = {
22
+ src: string;
23
+ ogg?: string | undefined;
24
+ volume: number;
25
+ };
26
+ /** The longest a sound may be, in seconds. `theme check` measures what it can read. */
27
+ export declare const maxSoundSeconds = 1.5;
28
+ /** The least time between two plays of the same event, in milliseconds. A burst plays once. */
29
+ export declare const soundCoalesceMs = 3000;
30
+ /** A CSS custom property's value as the text it names: quotes removed, or the inside of `url(…)`. Empty when unset. */
31
+ export declare function soundString(raw: string): string;
32
+ /** How loud `event` is, 0 to 1: the global `--cd-sound-volume` times the event's own, each 1 when unset. 1 on the server. */
33
+ export declare function readSoundVolume(event: SoundEvent, element?: Element | undefined): number;
34
+ /**
35
+ * The brand's sound for `event`, read from the computed style of `element` (the document's root when left out), or
36
+ * `undefined` when the brand names none, or on the server.
37
+ */
38
+ export declare function readSoundToken(event: SoundEvent, element?: Element | undefined): SoundToken | undefined;
39
+ /**
40
+ * Whether a play at `now` is allowed, given when each event last played and when anything last played: the same event
41
+ * waits `soundCoalesceMs` after it played, and any sound in the same instant (a burst) is the one already playing.
42
+ * Pure; the caller keeps the clock.
43
+ */
44
+ export declare function mayPlay(event: SoundEvent, last: Partial<Record<SoundEvent, number>>, now: number, burstMs?: number): boolean;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The names and the reading half of Candor's sound layer. A brand file names a sound per event
3
+ * (`--cd-sound-needs-you: "/sounds/needs-you.mp3"`); this file knows the five events, reads that token from the
4
+ * page's computed style, and holds the small arithmetic the Pro hooks share. It plays nothing: nothing is fetched
5
+ * until `useAttention` or `sound-setting` plays a sound, and a sound is always off until the person turns it on.
6
+ *
7
+ * Tokens, all optional, all read when a sound is about to play:
8
+ * - `--cd-sound-<event>`: a string, the path of an MP3 hosted by the app. No token, no sound, silently.
9
+ * - `--cd-sound-<event>-ogg`: a string, an Ogg/Opus alternative the browser may prefer.
10
+ * - `--cd-sound-volume`: 0 to 1, how loud every sound is. `--cd-sound-<event>-volume`: 0 to 1, scales that for one event.
11
+ *
12
+ * Safe to import on the server: the document is touched only inside `readSoundToken`.
13
+ */
14
+ /** Every event, in the order the settings control lists them. */
15
+ export const soundEvents = ["needs-you", "finished", "failed", "reply", "silent"];
16
+ /** The events a sound is offered for once the person turns sound on; `reply` and `silent` stay off until they pick them. */
17
+ export const defaultSoundEvents = ["needs-you", "finished", "failed"];
18
+ /** The longest a sound may be, in seconds. `theme check` measures what it can read. */
19
+ export const maxSoundSeconds = 1.5;
20
+ /** The least time between two plays of the same event, in milliseconds. A burst plays once. */
21
+ export const soundCoalesceMs = 3000;
22
+ /** A CSS custom property's value as the text it names: quotes removed, or the inside of `url(…)`. Empty when unset. */
23
+ export function soundString(raw) {
24
+ const v = raw.trim();
25
+ const url = /^url\(\s*(['"]?)(.*?)\1\s*\)$/.exec(v);
26
+ const text = url ? (url[2] ?? "") : v;
27
+ return text.replace(/^(['"])(.*)\1$/, "$2").trim();
28
+ }
29
+ const unit = (raw) => {
30
+ const n = Number.parseFloat(raw);
31
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : undefined;
32
+ };
33
+ const styleOf = (element) => typeof document === "undefined" || typeof getComputedStyle === "undefined" ? undefined : getComputedStyle(element ?? document.documentElement);
34
+ /** How loud `event` is, 0 to 1: the global `--cd-sound-volume` times the event's own, each 1 when unset. 1 on the server. */
35
+ export function readSoundVolume(event, element) {
36
+ const style = styleOf(element);
37
+ if (!style)
38
+ return 1;
39
+ return (unit(style.getPropertyValue("--cd-sound-volume")) ?? 1) * (unit(style.getPropertyValue(`--cd-sound-${event}-volume`)) ?? 1);
40
+ }
41
+ /**
42
+ * The brand's sound for `event`, read from the computed style of `element` (the document's root when left out), or
43
+ * `undefined` when the brand names none, or on the server.
44
+ */
45
+ export function readSoundToken(event, element) {
46
+ const style = styleOf(element);
47
+ const src = style && soundString(style.getPropertyValue(`--cd-sound-${event}`));
48
+ if (!style || !src)
49
+ return undefined;
50
+ const ogg = soundString(style.getPropertyValue(`--cd-sound-${event}-ogg`));
51
+ return { src, ...(ogg && { ogg }), volume: readSoundVolume(event, element) };
52
+ }
53
+ /**
54
+ * Whether a play at `now` is allowed, given when each event last played and when anything last played: the same event
55
+ * waits `soundCoalesceMs` after it played, and any sound in the same instant (a burst) is the one already playing.
56
+ * Pure; the caller keeps the clock.
57
+ */
58
+ export function mayPlay(event, last, now, burstMs = 400) {
59
+ const before = last[event];
60
+ if (before !== undefined && now - before < soundCoalesceMs)
61
+ return false;
62
+ return !soundEvents.some((e) => (last[e] ?? -Infinity) > now - burstMs);
63
+ }