@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 +28 -0
- package/dist/charts/index.js +1 -0
- package/dist/charts/motion.js +1 -1
- package/dist/lib/sound.js +167 -0
- package/package.json +2 -1
- package/src/charts/area-chart.svelte +1 -0
- package/src/charts/bar-chart.svelte +1 -0
- package/src/charts/chart-tokens.css +36 -0
- package/src/charts/charts.css +0 -32
- package/src/charts/donut-chart.svelte +1 -0
- package/src/charts/figure.svelte +1 -0
- package/src/charts/gauge-chart.svelte +1 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/legend.svelte +1 -0
- package/src/charts/line-chart.svelte +1 -0
- package/src/charts/motion.ts +1 -1
- package/src/charts/number-chart.svelte +1 -0
- package/src/charts/ring-chart.svelte +1 -0
- package/src/charts/sparkline.svelte +1 -0
- package/src/charts/stat-card.svelte +1 -0
- package/src/charts/time-series-chart.svelte +1 -0
- package/src/components/segmented/segmented-filter.svelte +34 -9
- package/src/lib/sound.test.ts +123 -0
- package/src/lib/sound.ts +166 -0
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
|
package/dist/charts/index.js
CHANGED
package/dist/charts/motion.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cubicInOut } from "svelte/easing";
|
|
2
|
-
/** Mirrors `--chart-motion-reveal` in
|
|
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.
|
|
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";
|
|
@@ -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
|
+
}
|
package/src/charts/charts.css
CHANGED
|
@@ -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(
|
package/src/charts/figure.svelte
CHANGED
package/src/charts/index.ts
CHANGED
package/src/charts/legend.svelte
CHANGED
package/src/charts/motion.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cubicInOut } from "svelte/easing";
|
|
2
2
|
|
|
3
|
-
/** Mirrors `--chart-motion-reveal` in
|
|
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. */
|
|
@@ -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";
|
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
});
|
package/src/lib/sound.ts
ADDED
|
@@ -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
|
+
}
|