@heyramzi/ui 2.18.0 → 2.19.1

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.
package/README.md CHANGED
@@ -95,6 +95,34 @@ Many components take their content through snippets, so you keep full control:
95
95
  <Button variant="outline" size="sm" onclick={save}>Save</Button>
96
96
  ```
97
97
 
98
+ ## Sound
99
+
100
+ `@heyramzi/ui/lib/sound` gives the kit's controls interaction sounds from
101
+ [cuelume](https://cuelume.dev). They're off until the person turns them on, and the choice is
102
+ saved in their browser. Start it once in the root layout:
103
+
104
+ ```svelte
105
+ <script lang="ts">
106
+ import { onMount } from "svelte";
107
+ import { startSound } from "@heyramzi/ui/lib/sound";
108
+
109
+ onMount(() => startSound());
110
+ </script>
111
+ ```
112
+
113
+ It reads each control's `data-slot`, so buttons tap, switches toggle, dialog triggers open and
114
+ menu items select without a prop. `SLOT_CUES` in `src/lib/sound.ts` is the full map. The rest:
115
+
116
+ - **The setting.** Call `setSound(on)` from the click that flips your Sound toggle. It resolves
117
+ `false` when the browser still blocks audio. `soundOn()` reads the saved choice.
118
+ - **Outcomes.** `cue("success")` after a save, `cue("error")` on a failure, `cue("ready")` when a
119
+ long job lands. It plays nothing while sound is off.
120
+ - **Recording.** `const resume = silence()` before the mic opens and `resume()` after, or the
121
+ sounds end up in the take.
122
+ - **Opting out.** `data-sound="off"` on an element quiets it and everything inside. A control
123
+ that sets its own `data-cuelume-*` attribute keeps it. Text fields are quiet unless you add
124
+ `data-cuelume-type`.
125
+
98
126
  ## shadcn-svelte
99
127
 
100
128
  The primitives in `src/shadcn` are copies of
@@ -1,3 +1,4 @@
1
+ import "../../src/charts/chart-tokens.css";
1
2
  import "../../src/charts/charts.css";
2
3
  export { default as BarChart } from "../../src/charts/bar-chart.svelte";
3
4
  export { default as AreaChart } from "../../src/charts/area-chart.svelte";
@@ -1,5 +1,5 @@
1
1
  import { cubicInOut } from "svelte/easing";
2
- /** Mirrors `--chart-motion-reveal` in charts.css, for when no element is mounted to read it from. */
2
+ /** Mirrors `--chart-motion-reveal` in chart-tokens.css, for when no element is mounted to read it from. */
3
3
  export const REVEAL_MS = 1100;
4
4
  /** Rescales the axes when the data changes; the first render never animates. */
5
5
  export const DOMAIN_MOTION = {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Interface sounds from cuelume, off until the person turns them on. Call `startSound()` once in
3
+ * the root layout: it marks the kit's controls by their `data-slot` and binds them, so no
4
+ * component carries a sound attribute of its own. Outcomes (saved, failed, ready) are `cue()`
5
+ * calls in the app. The cue for each job follows https://cuelume.dev/agents.md, "Which cue?".
6
+ */
7
+ import { bind, play, setEnabled, unlock } from "cuelume";
8
+ /**
9
+ * Which kit slots sound, and how. Text fields are left out on purpose: a keystroke sound in every
10
+ * input is too much for tools people type in all day, so an app opts a field in with
11
+ * `data-cuelume-type`.
12
+ */
13
+ export const SLOT_CUES = {
14
+ button: { binding: "tap" },
15
+ "code-block-copy-button": {
16
+ binding: "tap",
17
+ cue: "success",
18
+ subtle: true
19
+ },
20
+ switch: { binding: "toggle" },
21
+ checkbox: { binding: "toggle" },
22
+ toggle: { binding: "toggle" },
23
+ "sidebar-trigger": {
24
+ binding: "toggle",
25
+ subtle: true
26
+ },
27
+ "dialog-trigger": { binding: "open" },
28
+ "sheet-trigger": { binding: "open" },
29
+ "alert-dialog-trigger": { binding: "open" },
30
+ "popover-trigger": {
31
+ binding: "open",
32
+ subtle: true
33
+ },
34
+ "dropdown-menu-trigger": {
35
+ binding: "open",
36
+ subtle: true
37
+ },
38
+ "select-trigger": {
39
+ binding: "open",
40
+ subtle: true
41
+ },
42
+ "dialog-close": { binding: "close" },
43
+ "sheet-close": { binding: "close" },
44
+ "popover-close": {
45
+ binding: "close",
46
+ subtle: true
47
+ },
48
+ "select-item": { binding: "select" },
49
+ "dropdown-menu-item": { binding: "select" },
50
+ "dropdown-menu-checkbox-item": { binding: "select" },
51
+ "dropdown-menu-radio-item": { binding: "select" },
52
+ "context-menu-item": { binding: "select" },
53
+ "command-item": { binding: "select" },
54
+ "tabs-trigger": { binding: "select" },
55
+ "radio-group-item": { binding: "select" },
56
+ "toggle-group-item": { binding: "select" }
57
+ };
58
+ const BINDING_ATTRS = [
59
+ "tap",
60
+ "type",
61
+ "select",
62
+ "toggle",
63
+ "open",
64
+ "close",
65
+ "navigate"
66
+ ].map((b) => `data-cuelume-${b}`);
67
+ const STORAGE_KEY = "heyramzi:sound";
68
+ /** The saved setting, read on first use so a server render never touches storage. */
69
+ let wanted;
70
+ /** Open `silence()` holds. Any one of them mutes everything, whatever the setting says. */
71
+ let holds = 0;
72
+ function readSaved() {
73
+ try {
74
+ return localStorage.getItem(STORAGE_KEY) === "on";
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ function audible() {
80
+ wanted ??= typeof window === "undefined" ? false : readSaved();
81
+ return wanted && holds === 0;
82
+ }
83
+ function apply() {
84
+ setEnabled(audible());
85
+ }
86
+ /** Whether the person turned sound on. A `silence()` hold doesn't change it. */
87
+ export function soundOn() {
88
+ audible();
89
+ return wanted === true;
90
+ }
91
+ /**
92
+ * Turns sound on or off and saves it. Call it from the click that flips the setting: turning on
93
+ * unlocks audio inside that gesture, and the result says whether the browser lets it play.
94
+ */
95
+ export function setSound(on) {
96
+ wanted = on;
97
+ try {
98
+ localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
99
+ } catch {}
100
+ apply();
101
+ return on ? unlock() : Promise.resolve(false);
102
+ }
103
+ /**
104
+ * Mutes every cue until the returned function runs, such as while the mic records, so no sound
105
+ * lands in the take. Holds stack; each release counts once.
106
+ */
107
+ export function silence() {
108
+ holds++;
109
+ apply();
110
+ let released = false;
111
+ return () => {
112
+ if (released) return;
113
+ released = true;
114
+ holds--;
115
+ apply();
116
+ };
117
+ }
118
+ /** Plays a cue for an outcome the app knows about, only while sound is on and nothing silences it. */
119
+ export function cue(name, options) {
120
+ if (audible()) play(name, options);
121
+ }
122
+ function mark(el) {
123
+ const slot = SLOT_CUES[el.getAttribute("data-slot") ?? ""];
124
+ if (!slot || el.closest("[data-sound=\"off\"]")) return;
125
+ // A control that names its own cue keeps it.
126
+ if (BINDING_ATTRS.some((attr) => el.hasAttribute(attr))) return;
127
+ el.setAttribute(`data-cuelume-${slot.binding}`, slot.cue ?? "");
128
+ if (slot.subtle && !el.hasAttribute("data-cuelume-emphasis")) {
129
+ el.setAttribute("data-cuelume-emphasis", "subtle");
130
+ }
131
+ }
132
+ /** Stamps the cuelume attribute on every kit control under `root`, `root` included. */
133
+ export function markSlots(root) {
134
+ if (root instanceof Element && root.hasAttribute("data-slot")) mark(root);
135
+ for (const el of root.querySelectorAll("[data-slot]")) mark(el);
136
+ }
137
+ /**
138
+ * Binds the kit's controls under `root` and keeps marking the ones that mount later. Safe on the
139
+ * server, where it does nothing. Returns the function that stops it.
140
+ */
141
+ export function startSound(root = globalThis.document) {
142
+ if (typeof window === "undefined" || !root) return () => {};
143
+ apply();
144
+ markSlots(root);
145
+ const observer = new MutationObserver((records) => {
146
+ for (const record of records) {
147
+ if (record.type === "attributes") {
148
+ if (record.target instanceof Element) mark(record.target);
149
+ continue;
150
+ }
151
+ for (const node of record.addedNodes) {
152
+ if (node instanceof Element) markSlots(node);
153
+ }
154
+ }
155
+ });
156
+ observer.observe(root, {
157
+ childList: true,
158
+ subtree: true,
159
+ attributes: true,
160
+ attributeFilter: ["data-slot"]
161
+ });
162
+ const unbind = bind(root);
163
+ return () => {
164
+ observer.disconnect();
165
+ unbind();
166
+ };
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.18.0",
3
+ "version": "2.19.1",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -398,6 +398,7 @@
398
398
  "@zag-js/svelte": "^1.46.0",
399
399
  "@zag-js/tour": "^1.46.0",
400
400
  "clsx": "^2.1.1",
401
+ "cuelume": "^0.2.8",
401
402
  "d3-scale": "^4.0.2",
402
403
  "d3-shape": "^3.2.0",
403
404
  "layerchart": "^2.9.1",
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./area-chart.css";
6
7
  import { Area, Axis, Chart, Highlight, LinearGradient, Spline, Svg, Tooltip } from "layerchart";
7
8
  import { curveMonotoneX } from "d3-shape";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./bar-chart.css";
6
7
  import { Axis, Bars, Chart, Highlight, Svg, Tooltip } from "layerchart";
7
8
  import { scaleBand } from "d3-scale";
@@ -0,0 +1,36 @@
1
+ /* Each chart imports these beside its own stylesheet, so a deep import such as
2
+ `@heyramzi/ui/charts/dashboard` keeps its card edge without the barrel. */
3
+
4
+ /* Shared chart tokens, held at zero specificity by `:where()`. The reveal pair
5
+ is the entrance every chart shares; motion.ts mirrors its duration. */
6
+ :where(
7
+ .hr-area-chart,
8
+ .hr-bar-chart,
9
+ .hr-line-chart,
10
+ .hr-sparkline,
11
+ .hr-number-chart,
12
+ .hr-stat-card,
13
+ .hr-legend,
14
+ .hr-ts-chart,
15
+ .hr-donut-chart,
16
+ .hr-ring,
17
+ .hr-gauge
18
+ ) {
19
+ --chart-label: var(--muted-foreground, oklch(0.5 0.01 286));
20
+ --chart-grid: color-mix(in srgb, var(--border, oklch(0.5 0.01 286)) 50%, transparent);
21
+ --chart-ease-out: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
22
+ --chart-motion-fast: var(--duration-quick, 150ms);
23
+ --chart-motion: var(--duration-fast, 250ms);
24
+ --chart-motion-slow: 800ms;
25
+ --chart-motion-reveal: 1100ms;
26
+ --chart-ease-reveal: cubic-bezier(0.85, 0, 0.15, 1);
27
+ --chart-stagger: 30ms;
28
+ --chart-crosshair: color-mix(
29
+ in srgb,
30
+ var(--muted-foreground, oklch(0.5 0.01 286)) 60%,
31
+ transparent
32
+ );
33
+ /* The card step draws its own hairline, so a chart card carries no border of its own. */
34
+ --chart-card-shadow: var(--elev-card);
35
+ --chart-card-shadow-hover: var(--elev-card-hover);
36
+ }
@@ -67,38 +67,6 @@
67
67
  font-variant-numeric: tabular-nums;
68
68
  }
69
69
 
70
- /* Shared chart tokens, held at zero specificity by `:where()`. The reveal pair
71
- is the entrance every chart shares; motion.ts mirrors its duration. */
72
- :where(
73
- .hr-area-chart,
74
- .hr-bar-chart,
75
- .hr-line-chart,
76
- .hr-sparkline,
77
- .hr-number-chart,
78
- .hr-stat-card,
79
- .hr-legend,
80
- .hr-ts-chart,
81
- .hr-donut-chart,
82
- .hr-ring,
83
- .hr-gauge
84
- ) {
85
- --chart-label: var(--muted-foreground, oklch(0.5 0.01 286));
86
- --chart-grid: color-mix(in srgb, var(--border, oklch(0.5 0.01 286)) 65%, transparent);
87
- --chart-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
88
- --chart-motion-fast: 150ms;
89
- --chart-motion: 250ms;
90
- --chart-motion-slow: 800ms;
91
- --chart-motion-reveal: 1100ms;
92
- --chart-ease-reveal: cubic-bezier(0.85, 0, 0.15, 1);
93
- --chart-stagger: 30ms;
94
- --chart-crosshair: color-mix(
95
- in srgb,
96
- var(--muted-foreground, oklch(0.5 0.01 286)) 60%,
97
- transparent
98
- );
99
- --chart-card-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.04), 0 1px 3px 0 oklch(0 0 0 / 0.06);
100
- }
101
-
102
70
  /* Axis labels, grid rules and hover highlights for every chart, defined once.
103
71
  `:where()` keeps specificity at 0 so a component can still override them. */
104
72
  :where(
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./donut-chart.css";
6
7
  import { Arc, Chart, Group, Pie, Svg, Text } from "layerchart";
7
8
  import { scaleOrdinal } from "d3-scale";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./figure.css";
6
7
  import { cn } from "../lib/utils.js";
7
8
  import type { Snippet } from "svelte";
@@ -8,6 +8,7 @@
8
8
  // =========================
9
9
  // IMPORTS
10
10
  // =========================
11
+ import "./chart-tokens.css";
11
12
  import "./gauge-chart.css";
12
13
  import { cn } from "../lib/utils.js";
13
14
  import AnimatedValue from "./animated-value.svelte";
@@ -1,3 +1,4 @@
1
+ import "./chart-tokens.css";
1
2
  import "./charts.css";
2
3
 
3
4
  export { default as BarChart } from "./bar-chart.svelte";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./legend.css";
6
7
  import { cn } from "../lib/utils.js";
7
8
 
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./line-chart.css";
6
7
  import {
7
8
  Area,
@@ -1,6 +1,6 @@
1
1
  import { cubicInOut } from "svelte/easing";
2
2
 
3
- /** Mirrors `--chart-motion-reveal` in charts.css, for when no element is mounted to read it from. */
3
+ /** Mirrors `--chart-motion-reveal` in chart-tokens.css, for when no element is mounted to read it from. */
4
4
  export const REVEAL_MS = 1100;
5
5
 
6
6
  /** Rescales the axes when the data changes; the first render never animates. */
@@ -13,6 +13,7 @@
13
13
  // =========================
14
14
  // IMPORTS
15
15
  // =========================
16
+ import "./chart-tokens.css";
16
17
  import "./number-chart.css";
17
18
  import { Minus, TrendDown, TrendUp } from "phosphor-svelte";
18
19
  import { cn } from "../lib/utils.js";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./ring-chart.css";
6
7
  import type { Snippet } from "svelte";
7
8
  import { cn } from "../lib/utils.js";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./sparkline.css";
6
7
  import { Area, Chart, Highlight, LinearGradient, Points, Spline, Svg, Tooltip } from "layerchart";
7
8
  import { curveMonotoneX } from "d3-shape";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./stat-card.css";
6
7
  import { Area, Bars, Chart, Highlight, LinearGradient, Spline, Svg, Tooltip } from "layerchart";
7
8
  import { scaleBand } from "d3-scale";
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import "./chart-tokens.css";
5
6
  import "./time-series-chart.css";
6
7
  import { scaleTime } from "d3-scale";
7
8
  import { curveMonotoneX } from "d3-shape";
@@ -13,6 +13,8 @@
13
13
  splitSegments,
14
14
  } from "./collapse.js";
15
15
  import { segmentedTriggerClass } from "./segmented.js";
16
+ import { Icon } from "../icon/index.js";
17
+ import { Mark } from "../mark/index.js";
16
18
 
17
19
  // =========================
18
20
  // TYPES
@@ -42,6 +44,10 @@
42
44
  max?: number | "none";
43
45
  /** What the menu segment says. Translate at the call site. */
44
46
  moreLabel?: string;
47
+ /** An option with an `image` or `icon` draws the mark alone, and its label
48
+ * moves to `aria-label` and the hover title. For a toolbar with no room
49
+ * for words, where every mark is a brand people know. */
50
+ iconOnly?: boolean;
45
51
  class?: string;
46
52
  }
47
53
 
@@ -57,6 +63,7 @@
57
63
  block = true,
58
64
  max = SEGMENT_MAX,
59
65
  moreLabel,
66
+ iconOnly = false,
60
67
  class: className,
61
68
  }: Props = $props();
62
69
 
@@ -99,21 +106,39 @@
99
106
  <SegmentedTrack fill={block}>
100
107
  {#each split.visible as option (option.value)}
101
108
  {@const active = value === option.value}
109
+ {@const markOnly = iconOnly && Boolean(option.image || option.icon)}
102
110
  <button
103
111
  type="button"
104
112
  class={segmentedTriggerClass({ fill: block, active })}
105
113
  onclick={() => pick(option.value)}
106
114
  aria-pressed={active}
115
+ aria-label={markOnly ? option.label : undefined}
116
+ title={markOnly ? option.label : undefined}
107
117
  >
108
- <Segment
109
- label={option.label}
110
- count={option.count}
111
- icon={option.icon}
112
- image={option.image}
113
- done={option.done}
114
- badge={option.badge}
115
- {active}
116
- />
118
+ <!-- WHY the mark is drawn here, not by `Segment`: `Segment` always
119
+ writes the label, and a count or dot beside a bare mark reads as noise. -->
120
+ {#if markOnly && option.image}
121
+ <Mark
122
+ src={option.image}
123
+ label={option.label}
124
+ class={cn(
125
+ "opacity-75 transition-opacity duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/segment:opacity-100",
126
+ active && "opacity-100",
127
+ )}
128
+ />
129
+ {:else if markOnly && option.icon}
130
+ <Icon icon={option.icon} size={16} class="shrink-0" />
131
+ {:else}
132
+ <Segment
133
+ label={option.label}
134
+ count={option.count}
135
+ icon={option.icon}
136
+ image={option.image}
137
+ done={option.done}
138
+ badge={option.badge}
139
+ {active}
140
+ />
141
+ {/if}
117
142
  </button>
118
143
  {/each}
119
144
  {#snippet after()}
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
+
3
+ const cuelume = vi.hoisted(() => ({
4
+ bind: vi.fn(() => () => {}),
5
+ play: vi.fn(),
6
+ setEnabled: vi.fn(),
7
+ unlock: vi.fn(() => Promise.resolve(true)),
8
+ }));
9
+ vi.mock("cuelume", () => cuelume);
10
+
11
+ // The module holds the setting and the silence count, so each test loads a fresh copy.
12
+ async function load() {
13
+ vi.resetModules();
14
+ return import("./sound.js");
15
+ }
16
+
17
+ function lastEnabled(): unknown {
18
+ return cuelume.setEnabled.mock.calls.at(-1)?.[0];
19
+ }
20
+
21
+ beforeEach(() => {
22
+ localStorage.clear();
23
+ document.body.innerHTML = "";
24
+ for (const fn of Object.values(cuelume)) fn.mockClear();
25
+ });
26
+
27
+ afterEach(() => {
28
+ document.body.innerHTML = "";
29
+ });
30
+
31
+ describe("markSlots", () => {
32
+ it("gives each kit control the binding for its job", async () => {
33
+ const { markSlots } = await load();
34
+ document.body.innerHTML = `
35
+ <button data-slot="button">Save</button>
36
+ <button data-slot="switch"></button>
37
+ <button data-slot="dropdown-menu-trigger"></button>
38
+ <button data-slot="code-block-copy-button"></button>
39
+ <input data-slot="input" />`;
40
+ markSlots(document);
41
+ const [button, sw, menu, copy, input] = document.body.children;
42
+ expect(button.getAttribute("data-cuelume-tap")).toBe("");
43
+ expect(sw.hasAttribute("data-cuelume-toggle")).toBe(true);
44
+ expect(menu.hasAttribute("data-cuelume-open")).toBe(true);
45
+ expect(menu.getAttribute("data-cuelume-emphasis")).toBe("subtle");
46
+ expect(copy.getAttribute("data-cuelume-tap")).toBe("success");
47
+ // Text fields stay quiet unless the app opts one in.
48
+ expect(input.getAttributeNames().some((n) => n.startsWith("data-cuelume-"))).toBe(false);
49
+ });
50
+
51
+ it("leaves a control alone that names its own cue or sits under data-sound=off", async () => {
52
+ const { markSlots } = await load();
53
+ document.body.innerHTML = `
54
+ <button data-slot="button" data-cuelume-close="">Delete</button>
55
+ <div data-sound="off"><button data-slot="button">Quiet</button></div>`;
56
+ markSlots(document);
57
+ const [own, quiet] = document.querySelectorAll("button");
58
+ expect(own.hasAttribute("data-cuelume-tap")).toBe(false);
59
+ expect(quiet.hasAttribute("data-cuelume-tap")).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("startSound", () => {
64
+ it("binds the root and marks controls that mount later", async () => {
65
+ const { startSound } = await load();
66
+ const stop = startSound(document);
67
+ expect(cuelume.bind).toHaveBeenCalledWith(document);
68
+ const later = document.createElement("button");
69
+ later.setAttribute("data-slot", "tabs-trigger");
70
+ document.body.append(later);
71
+ await Promise.resolve();
72
+ expect(later.hasAttribute("data-cuelume-select")).toBe(true);
73
+ stop();
74
+ });
75
+
76
+ it("starts muted when nothing was saved", async () => {
77
+ const { startSound, soundOn } = await load();
78
+ startSound(document)();
79
+ expect(soundOn()).toBe(false);
80
+ expect(lastEnabled()).toBe(false);
81
+ });
82
+
83
+ it("starts audible when the person turned sound on before", async () => {
84
+ localStorage.setItem("heyramzi:sound", "on");
85
+ const { startSound } = await load();
86
+ startSound(document)();
87
+ expect(lastEnabled()).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("setSound, silence and cue", () => {
92
+ it("saves the choice and unlocks audio only when turning on", async () => {
93
+ const { setSound } = await load();
94
+ await expect(setSound(true)).resolves.toBe(true);
95
+ expect(localStorage.getItem("heyramzi:sound")).toBe("on");
96
+ expect(cuelume.unlock).toHaveBeenCalledOnce();
97
+ await expect(setSound(false)).resolves.toBe(false);
98
+ expect(localStorage.getItem("heyramzi:sound")).toBe("off");
99
+ expect(cuelume.unlock).toHaveBeenCalledOnce();
100
+ });
101
+
102
+ it("mutes while any hold is open, and a second release changes nothing", async () => {
103
+ const { setSound, silence, cue } = await load();
104
+ await setSound(true);
105
+ const first = silence();
106
+ const second = silence();
107
+ cue("success");
108
+ expect(cuelume.play).not.toHaveBeenCalled();
109
+ first();
110
+ first();
111
+ expect(lastEnabled()).toBe(false);
112
+ second();
113
+ expect(lastEnabled()).toBe(true);
114
+ cue("success", { emphasis: "strong" });
115
+ expect(cuelume.play).toHaveBeenCalledWith("success", { emphasis: "strong" });
116
+ });
117
+
118
+ it("plays nothing while sound is off", async () => {
119
+ const { cue } = await load();
120
+ cue("error");
121
+ expect(cuelume.play).not.toHaveBeenCalled();
122
+ });
123
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Interface sounds from cuelume, off until the person turns them on. Call `startSound()` once in
3
+ * the root layout: it marks the kit's controls by their `data-slot` and binds them, so no
4
+ * component carries a sound attribute of its own. Outcomes (saved, failed, ready) are `cue()`
5
+ * calls in the app. The cue for each job follows https://cuelume.dev/agents.md, "Which cue?".
6
+ */
7
+ import { bind, play, setEnabled, unlock, type PlayOptions, type SoundName } from "cuelume";
8
+
9
+ type Binding = "tap" | "select" | "toggle" | "open" | "close" | "navigate";
10
+
11
+ interface SlotCue {
12
+ binding: Binding;
13
+ /** Another cue than the binding's own, as `data-cuelume-tap="success"` plays `success`. */
14
+ cue?: SoundName;
15
+ subtle?: true;
16
+ }
17
+
18
+ /**
19
+ * Which kit slots sound, and how. Text fields are left out on purpose: a keystroke sound in every
20
+ * input is too much for tools people type in all day, so an app opts a field in with
21
+ * `data-cuelume-type`.
22
+ */
23
+ export const SLOT_CUES: Readonly<Record<string, SlotCue>> = {
24
+ button: { binding: "tap" },
25
+ "code-block-copy-button": { binding: "tap", cue: "success", subtle: true },
26
+ switch: { binding: "toggle" },
27
+ checkbox: { binding: "toggle" },
28
+ toggle: { binding: "toggle" },
29
+ "sidebar-trigger": { binding: "toggle", subtle: true },
30
+ "dialog-trigger": { binding: "open" },
31
+ "sheet-trigger": { binding: "open" },
32
+ "alert-dialog-trigger": { binding: "open" },
33
+ "popover-trigger": { binding: "open", subtle: true },
34
+ "dropdown-menu-trigger": { binding: "open", subtle: true },
35
+ "select-trigger": { binding: "open", subtle: true },
36
+ "dialog-close": { binding: "close" },
37
+ "sheet-close": { binding: "close" },
38
+ "popover-close": { binding: "close", subtle: true },
39
+ "select-item": { binding: "select" },
40
+ "dropdown-menu-item": { binding: "select" },
41
+ "dropdown-menu-checkbox-item": { binding: "select" },
42
+ "dropdown-menu-radio-item": { binding: "select" },
43
+ "context-menu-item": { binding: "select" },
44
+ "command-item": { binding: "select" },
45
+ "tabs-trigger": { binding: "select" },
46
+ "radio-group-item": { binding: "select" },
47
+ "toggle-group-item": { binding: "select" },
48
+ };
49
+
50
+ const BINDING_ATTRS = ["tap", "type", "select", "toggle", "open", "close", "navigate"].map(
51
+ (b) => `data-cuelume-${b}`,
52
+ );
53
+ const STORAGE_KEY = "heyramzi:sound";
54
+
55
+ /** The saved setting, read on first use so a server render never touches storage. */
56
+ let wanted: boolean | undefined;
57
+ /** Open `silence()` holds. Any one of them mutes everything, whatever the setting says. */
58
+ let holds = 0;
59
+
60
+ function readSaved(): boolean {
61
+ try {
62
+ return localStorage.getItem(STORAGE_KEY) === "on";
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function audible(): boolean {
69
+ wanted ??= typeof window === "undefined" ? false : readSaved();
70
+ return wanted && holds === 0;
71
+ }
72
+
73
+ function apply(): void {
74
+ setEnabled(audible());
75
+ }
76
+
77
+ /** Whether the person turned sound on. A `silence()` hold doesn't change it. */
78
+ export function soundOn(): boolean {
79
+ audible();
80
+ return wanted === true;
81
+ }
82
+
83
+ /**
84
+ * Turns sound on or off and saves it. Call it from the click that flips the setting: turning on
85
+ * unlocks audio inside that gesture, and the result says whether the browser lets it play.
86
+ */
87
+ export function setSound(on: boolean): Promise<boolean> {
88
+ wanted = on;
89
+ try {
90
+ localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
91
+ } catch {
92
+ // Private windows and blocked storage keep the choice for this page only.
93
+ }
94
+ apply();
95
+ return on ? unlock() : Promise.resolve(false);
96
+ }
97
+
98
+ /**
99
+ * Mutes every cue until the returned function runs, such as while the mic records, so no sound
100
+ * lands in the take. Holds stack; each release counts once.
101
+ */
102
+ export function silence(): () => void {
103
+ holds++;
104
+ apply();
105
+ let released = false;
106
+ return () => {
107
+ if (released) return;
108
+ released = true;
109
+ holds--;
110
+ apply();
111
+ };
112
+ }
113
+
114
+ /** Plays a cue for an outcome the app knows about, only while sound is on and nothing silences it. */
115
+ export function cue(name: SoundName, options?: PlayOptions): void {
116
+ if (audible()) play(name, options);
117
+ }
118
+
119
+ function mark(el: Element): void {
120
+ const slot = SLOT_CUES[el.getAttribute("data-slot") ?? ""];
121
+ if (!slot || el.closest('[data-sound="off"]')) return;
122
+ // A control that names its own cue keeps it.
123
+ if (BINDING_ATTRS.some((attr) => el.hasAttribute(attr))) return;
124
+ el.setAttribute(`data-cuelume-${slot.binding}`, slot.cue ?? "");
125
+ if (slot.subtle && !el.hasAttribute("data-cuelume-emphasis")) {
126
+ el.setAttribute("data-cuelume-emphasis", "subtle");
127
+ }
128
+ }
129
+
130
+ /** Stamps the cuelume attribute on every kit control under `root`, `root` included. */
131
+ export function markSlots(root: ParentNode): void {
132
+ if (root instanceof Element && root.hasAttribute("data-slot")) mark(root);
133
+ for (const el of root.querySelectorAll("[data-slot]")) mark(el);
134
+ }
135
+
136
+ /**
137
+ * Binds the kit's controls under `root` and keeps marking the ones that mount later. Safe on the
138
+ * server, where it does nothing. Returns the function that stops it.
139
+ */
140
+ export function startSound(root: ParentNode = globalThis.document): () => void {
141
+ if (typeof window === "undefined" || !root) return () => {};
142
+ apply();
143
+ markSlots(root);
144
+ const observer = new MutationObserver((records) => {
145
+ for (const record of records) {
146
+ if (record.type === "attributes") {
147
+ if (record.target instanceof Element) mark(record.target);
148
+ continue;
149
+ }
150
+ for (const node of record.addedNodes) {
151
+ if (node instanceof Element) markSlots(node);
152
+ }
153
+ }
154
+ });
155
+ observer.observe(root as Node, {
156
+ childList: true,
157
+ subtree: true,
158
+ attributes: true,
159
+ attributeFilter: ["data-slot"],
160
+ });
161
+ const unbind = bind(root);
162
+ return () => {
163
+ observer.disconnect();
164
+ unbind();
165
+ };
166
+ }