@heyramzi/ui 2.17.0 → 2.19.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.
- package/README.md +28 -0
- package/dist/lib/sound.js +167 -0
- package/dist/thread/logic.js +11 -30
- package/package.json +2 -1
- 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/src/thread/README.md +2 -2
- package/src/thread/index.ts +0 -1
- package/src/thread/logic.ts +2 -19
- package/src/thread/thread.svelte +2 -15
- package/src/thread/types.ts +2 -10
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
|
|
@@ -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/dist/thread/logic.js
CHANGED
|
@@ -57,28 +57,17 @@ export function resolveThreadLabels(overrides) {
|
|
|
57
57
|
export function fillCount(label, count) {
|
|
58
58
|
return label.replaceAll("{count}", String(count));
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
...events.map((event, index) => ({
|
|
72
|
-
at: at(event.at),
|
|
73
|
-
index: nodes.length + index,
|
|
74
|
-
event
|
|
75
|
-
})),
|
|
76
|
-
...entries.map((entry, index) => ({
|
|
77
|
-
at: at(entry.at),
|
|
78
|
-
index: nodes.length + events.length + index,
|
|
79
|
-
entry
|
|
80
|
-
}))
|
|
81
|
-
].toSorted((a, b) => a.at - b.at || a.index - b.index);
|
|
60
|
+
/** Conversations and events in time order, each run of events between two conversations as one item. */
|
|
61
|
+
export function interleave(nodes, events) {
|
|
62
|
+
const timed = [...nodes.map((node, index) => ({
|
|
63
|
+
at: at(node.comment.createdAt),
|
|
64
|
+
index,
|
|
65
|
+
node
|
|
66
|
+
})), ...events.map((event, index) => ({
|
|
67
|
+
at: at(event.at),
|
|
68
|
+
index: nodes.length + index,
|
|
69
|
+
event
|
|
70
|
+
}))].toSorted((a, b) => a.at - b.at || a.index - b.index);
|
|
82
71
|
const items = [];
|
|
83
72
|
for (const row of timed) {
|
|
84
73
|
if ("node" in row) {
|
|
@@ -89,14 +78,6 @@ export function interleave(nodes, events, entries = []) {
|
|
|
89
78
|
});
|
|
90
79
|
continue;
|
|
91
80
|
}
|
|
92
|
-
if ("entry" in row) {
|
|
93
|
-
items.push({
|
|
94
|
-
type: "entry",
|
|
95
|
-
key: `entry-${row.entry.id}`,
|
|
96
|
-
entry: row.entry
|
|
97
|
-
});
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
81
|
const last = items.at(-1);
|
|
101
82
|
if (last?.type === "events") last.events.push(row.event);
|
|
102
83
|
else items.push({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
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",
|
|
@@ -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
|
+
}
|
package/src/thread/README.md
CHANGED
|
@@ -24,8 +24,8 @@ One comment thread for any subject: a task, a project, a doc or a diagram. The t
|
|
|
24
24
|
- **Resolve.** Pass `onResolve` and each root gets a check. Resolved roots fold away behind a "Show N resolved" button.
|
|
25
25
|
- **Anchor.** `anchor` carries `{ nodeId, dx, dy, x, y }` for a canvas pin. A pinned comment shows a chip, and `onAnchor` fires on a click so the canvas can move to the node. `anchorChip` replaces the chip.
|
|
26
26
|
- **Attachments.** The `attachments` snippet draws under a comment's body.
|
|
27
|
-
- **Events.** `events` takes `ThreadEvent`s (a whole sentence, a time, an optional Phosphor icon) and draws each as one `Trace` line between the conversations, in time order. `whenEvent(event)` formats its time. `interleave(nodes, events
|
|
28
|
-
- **
|
|
27
|
+
- **Events.** `events` takes `ThreadEvent`s (a whole sentence, a time, an optional Phosphor icon) and draws each as one `Trace` line between the conversations, in time order. `whenEvent(event)` formats its time. `interleave(nodes, events)` gives the same order without the component.
|
|
28
|
+
- **Footer.** `footer` draws above the composer, such as an agent's question waiting on an answer. `onPost` decides where a root post goes.
|
|
29
29
|
- **Heading.** `heading={false}` keeps the title for screen readers only, for a thread its tab already names.
|
|
30
30
|
- **Long bodies.** A body over 6 lines folds behind `showMore` and `showLess`.
|
|
31
31
|
- **Labels.** Every string comes from `labels`, a partial of `ThreadLabels`. `showResolved` holds a `{count}` slot. `when(comment)` formats the time.
|
package/src/thread/index.ts
CHANGED
package/src/thread/logic.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ThreadComment,
|
|
3
|
-
ThreadEntry,
|
|
4
3
|
ThreadEvent,
|
|
5
4
|
ThreadItem,
|
|
6
5
|
ThreadLabels,
|
|
@@ -72,23 +71,11 @@ export function fillCount(label: string, count: number): string {
|
|
|
72
71
|
return label.replaceAll("{count}", String(count));
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
* other items as one item.
|
|
78
|
-
*/
|
|
79
|
-
export function interleave(
|
|
80
|
-
nodes: ThreadNode[],
|
|
81
|
-
events: ThreadEvent[],
|
|
82
|
-
entries: ThreadEntry[] = [],
|
|
83
|
-
): ThreadItem[] {
|
|
74
|
+
/** Conversations and events in time order, each run of events between two conversations as one item. */
|
|
75
|
+
export function interleave(nodes: ThreadNode[], events: ThreadEvent[]): ThreadItem[] {
|
|
84
76
|
const timed = [
|
|
85
77
|
...nodes.map((node, index) => ({ at: at(node.comment.createdAt), index, node })),
|
|
86
78
|
...events.map((event, index) => ({ at: at(event.at), index: nodes.length + index, event })),
|
|
87
|
-
...entries.map((entry, index) => ({
|
|
88
|
-
at: at(entry.at),
|
|
89
|
-
index: nodes.length + events.length + index,
|
|
90
|
-
entry,
|
|
91
|
-
})),
|
|
92
79
|
].toSorted((a, b) => a.at - b.at || a.index - b.index);
|
|
93
80
|
const items: ThreadItem[] = [];
|
|
94
81
|
for (const row of timed) {
|
|
@@ -96,10 +83,6 @@ export function interleave(
|
|
|
96
83
|
items.push({ type: "node", key: row.node.comment.id, node: row.node });
|
|
97
84
|
continue;
|
|
98
85
|
}
|
|
99
|
-
if ("entry" in row) {
|
|
100
|
-
items.push({ type: "entry", key: `entry-${row.entry.id}`, entry: row.entry });
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
86
|
const last = items.at(-1);
|
|
104
87
|
if (last?.type === "events") last.events.push(row.event);
|
|
105
88
|
else items.push({ type: "events", key: row.event.id, events: [row.event] });
|
package/src/thread/thread.svelte
CHANGED
|
@@ -15,13 +15,7 @@
|
|
|
15
15
|
resolveThreadLabels,
|
|
16
16
|
resolvedCount,
|
|
17
17
|
} from "./logic.js";
|
|
18
|
-
import type {
|
|
19
|
-
ThreadComment,
|
|
20
|
-
ThreadEntry,
|
|
21
|
-
ThreadEvent,
|
|
22
|
-
ThreadLabels,
|
|
23
|
-
ThreadPost,
|
|
24
|
-
} from "./types.js";
|
|
18
|
+
import type { ThreadComment, ThreadEvent, ThreadLabels, ThreadPost } from "./types.js";
|
|
25
19
|
|
|
26
20
|
interface Props {
|
|
27
21
|
comments: ThreadComment[];
|
|
@@ -54,9 +48,6 @@
|
|
|
54
48
|
canEdit?: (comment: ThreadComment) => boolean;
|
|
55
49
|
/** False hides the visible title, for a thread already named by its tab. Screen readers still get it. */
|
|
56
50
|
heading?: boolean;
|
|
57
|
-
/** Rows the host draws through `entry`, placed in time among the comments. */
|
|
58
|
-
entries?: ThreadEntry[];
|
|
59
|
-
entry?: Snippet<[ThreadEntry]>;
|
|
60
51
|
/** Drawn above the composer, such as a question waiting on an answer. */
|
|
61
52
|
footer?: Snippet;
|
|
62
53
|
class?: string;
|
|
@@ -80,8 +71,6 @@
|
|
|
80
71
|
onAnchor,
|
|
81
72
|
canEdit = () => false,
|
|
82
73
|
heading = true,
|
|
83
|
-
entries = [],
|
|
84
|
-
entry,
|
|
85
74
|
footer,
|
|
86
75
|
class: className,
|
|
87
76
|
}: Props = $props();
|
|
@@ -91,7 +80,7 @@
|
|
|
91
80
|
let resolved = $derived(resolvedCount(nodes));
|
|
92
81
|
let showResolved = $state(false);
|
|
93
82
|
let shown = $derived(showResolved ? nodes : nodes.filter((node) => !node.comment.resolved));
|
|
94
|
-
let items = $derived(interleave(shown, events
|
|
83
|
+
let items = $derived(interleave(shown, events));
|
|
95
84
|
|
|
96
85
|
function shortDate(at: string): string {
|
|
97
86
|
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(new Date(at));
|
|
@@ -321,8 +310,6 @@
|
|
|
321
310
|
{#each items as item (item.key)}
|
|
322
311
|
{#if item.type === "events"}
|
|
323
312
|
<li><ThreadEvents events={item.events} when={whenEvent} /></li>
|
|
324
|
-
{:else if item.type === "entry"}
|
|
325
|
-
<li class="min-w-0">{@render entry?.(item.entry)}</li>
|
|
326
313
|
{:else}
|
|
327
314
|
{@const node = item.node}
|
|
328
315
|
<li
|
package/src/thread/types.ts
CHANGED
|
@@ -59,18 +59,10 @@ export type ThreadEvent = {
|
|
|
59
59
|
icon?: PhosphorIcon;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
export type ThreadEntry = {
|
|
64
|
-
id: string;
|
|
65
|
-
/** ISO timestamp. */
|
|
66
|
-
at: string;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/** The thread in time order: a conversation, a run of events between two of them, or a host's entry. */
|
|
62
|
+
/** The thread in time order: a conversation, or a run of events between two of them. */
|
|
70
63
|
export type ThreadItem =
|
|
71
64
|
| { type: "node"; key: string; node: ThreadNode }
|
|
72
|
-
| { type: "events"; key: string; events: ThreadEvent[] }
|
|
73
|
-
| { type: "entry"; key: string; entry: ThreadEntry };
|
|
65
|
+
| { type: "events"; key: string; events: ThreadEvent[] };
|
|
74
66
|
|
|
75
67
|
export type ThreadLabels = {
|
|
76
68
|
title: string;
|