@vale-dinap/lucidly 0.4.0 → 0.5.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/CHANGELOG.md +7 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/web/app.js +245 -0
- package/web/index.html +6 -0
- package/web/style.css +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to Lucidly are listed here. The format follows [Keep a Chang
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Optional sound alerts**, with the bell next to keep-awake in the top bar: a short sound when a session finishes, asks you something or runs into trouble, in any of your tabs. Off by default. Each event has its own sound, chosen from a dropdown (17 sounds in three groups: gentle ones, questions, and louder alerts such as Alarm, Siren and Buzzer; or None for no sound) and tried with *Test*, and there is a volume slider. *Only when I'm not looking* (on by default) keeps quiet for the session on screen while the page is visible. The sounds are made in the browser, no files. Browsers only allow sound after you interact with a page: after a reload the bell turns amber until your first click or key press.
|
|
12
|
+
|
|
7
13
|
## [0.4.0] - 2026-11-11
|
|
8
14
|
|
|
9
15
|
### Added
|
|
@@ -68,6 +74,7 @@ All notable changes to Lucidly are listed here. The format follows [Keep a Chang
|
|
|
68
74
|
|
|
69
75
|
First public release.
|
|
70
76
|
|
|
77
|
+
[0.5.0]: https://github.com/vale-dinap/lucidly/compare/v0.4.0...v0.5.0
|
|
71
78
|
[0.4.0]: https://github.com/vale-dinap/lucidly/compare/v0.3.0...v0.4.0
|
|
72
79
|
[0.3.0]: https://github.com/vale-dinap/lucidly/compare/v0.2.0...v0.3.0
|
|
73
80
|
[0.2.0]: https://github.com/vale-dinap/lucidly/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -144,6 +144,7 @@ In the browser:
|
|
|
144
144
|
- Switch views with the buttons at the left of the toolbar under the timeline (it stays on screen while you scroll), or `V` to cycle through *Glance*, *Simple*, *Detailed*, *Files* and **Actions**. In Simple and Detailed, the **filters** next to them narrow the list: *All*, *Failed*, *Recovered*, *Subagents* and *Questions* (the exchanges answered without any work), each with its count. When the toolbar is too narrow (for example with the side panel open), they fold into one menu button showing the current filter, with a red dot if anything is still failed. *Hide Q&A* applies to *All* only, and greys out with the other filters. Search is a magnifier until you click it or press `/`. The flame button (or `H`) turns on **token heat**: tasks and steps glow from green to red by the tokens they used (fresh input, cache writes and output, the way the token meter counts them), relative to the rest of the run, on a log scale so only the real outliers turn red. Tasks and questions are ranked together, steps among steps. It is off by default; hover a glowing node or dot for its tokens. Actions is a flat, chronological list of everything the agent did: commands, edits, reads, searches, subagent calls. Filter by kind, show only *Failed* actions or only *Recovered* ones (each failure is tagged *failed*, or *failed → recovered* in grey), search (`/`), or limit to one task. Each failure shows its actual error line. Select a row to see its full input and output, and *Show in path* to jump back to the step it belongs to. A long shell command is shown in parts: its shell lines, the script it runs (`node - <<'EOF'`, `python -c "…"`, highlighted as JavaScript or Python), and the content it writes to a file (`cat > a.py <<'EOF'`), with *Show the whole command* for the command as recorded. For commands that change files, long lines that wrap continue indented so each real line stands out, and dense JavaScript has *Reformat for reading*: line breaks and indentation for display only, with *Show as written* to go back. *Copy commands* copies the listed shell commands, with failed ones marked. In the side panel, every card has its own **copy button**: your messages, the agent's text, plans and results (as Markdown), thinking, commands and each of their parts (without the `$` prompt; a script as written, even when shown reformatted), inputs, outputs and errors (whole, even when only the first lines are shown), and diffs (as a unified diff). A tick confirms the copy.
|
|
145
145
|
- The **pin** in the overview's top-right corner (or `P`) keeps the overview on screen too while you scroll. It is pinned by default: scrolled down, it tightens into a compact card (about half the height) that still shows everything: the stats on one line, the timeline, the subagents on a single row (parallel ones are counted on a *+N* marker), the navigator and the legend. It unfolds again back at the top, and what you were reading stays put during the transition. Unpin it to let it scroll away; Lucidly remembers the choice. The arrows button at the right of the toolbar (or `E`) expands or collapses everything.
|
|
146
146
|
- **Keep awake** (the cup next to the status in the top bar) stops the device from sleeping, like a no-sleep page. Turn it on, and optionally choose *Only while the agent is working*: the device stays awake while a turn runs and may sleep once the work is done and the session is idle (or has had no news for 30 minutes). The cup steams while it is holding the device awake. It uses the browser's Screen Wake Lock, so it works while the Lucidly tab is visible: a hidden or minimised tab lets the device sleep, and it takes effect again when you come back.
|
|
147
|
+
- **Sounds** (the bell next to keep-awake) plays a short sound when a session finishes, asks you something or runs into trouble, in any of your tabs. It is off by default. Each event has its own sound, chosen from a dropdown (17 sounds in three groups: gentle ones, questions, and louder alerts such as Alarm, Siren and Buzzer; or None for no sound) and tried with *Test*, and the volume can be set. With *Only when I'm not looking* (on by default), the session on screen stays quiet while the page is visible. Browsers only play sound after you interact with a page, so after a reload the bell turns amber until your first click or key press.
|
|
147
148
|
- A session opens at the top, on the overview and its timeline, with the side panel closed until you select something. Whenever you're not at the end of the list, a round **arrow button** floats at the bottom right, with a count of actions that arrived since you scrolled away. Click it (or press `F` or `End`) to jump to the end and follow the agent, keeping the current step in view. If the side panel is closed it stays closed: the list just scrolls along. Scrolling or clicking pauses following. **Open session** (the folder button on the left, the run's title, or `O`) lists the Claude Code sessions of this project or of all projects. All projects are grouped by project folder, each group headed by the folder's name, its session count and when it last changed (a green dot if a session in it is live). This project comes first; groups fold, and the ones open by default are this project's and any with a live session (or all of them, when there are only a few). *Expand all* / *Collapse all* sits at the top, and what you open or close stays that way.
|
|
148
149
|
- **Tabs** let you keep several sessions open. Picking a session replaces the one on screen; `Ctrl`/`⌘`-click it, middle-click it or use its **New tab** button to open it in a new tab instead (the **+** at the end of the tab strip does the same). From two tabs on, a strip at the very top of the page, above the top bar, shows them, each with a dot in the orb's colours and a count of running subagents. A tab you're not looking at that asks you something, finishes or runs into trouble flashes, then glows until you open it, and the page title shows how many do. Picking a session that's already in a tab switches to it. Each tab keeps your place (view, selection, spotlight, scroll). `[` / `]` move between tabs, a middle click closes one, and they come back after a reload. Closing a tab stops following its session; one no page has shown for 10 minutes stops by itself.
|
|
149
150
|
- The download button, next to *Open session* on the left, saves a **snapshot** of the run as a `.lucidly.json` file, and *Open a saved snapshot…* in the session list reopens one. You don't need this for Claude Code sessions, which reopen straight from their transcripts. It's for keeping a run after its transcript is cleaned up, sharing it with someone who doesn't have the transcript, and runs streamed from other agents through stdin, which have no transcript to reopen.
|
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -3755,6 +3755,7 @@ function tabsChanged() {
|
|
|
3755
3755
|
T.es = es;
|
|
3756
3756
|
es.addEventListener('tabs', (e) => {
|
|
3757
3757
|
const { runs } = JSON.parse(e.data);
|
|
3758
|
+
const turned = [];
|
|
3758
3759
|
for (const [id, sum] of Object.entries(runs)) {
|
|
3759
3760
|
const tab = tabOf(id);
|
|
3760
3761
|
if (!tab) continue;
|
|
@@ -3764,6 +3765,7 @@ function tabsChanged() {
|
|
|
3764
3765
|
}
|
|
3765
3766
|
const before = tab.sum?.state;
|
|
3766
3767
|
tab.sum = sum;
|
|
3768
|
+
if (before && before !== sum.state && TAB_ATTN.has(sum.state)) turned.push([tab, sum.state]);
|
|
3767
3769
|
if (sum.title) tab.title = sum.title;
|
|
3768
3770
|
if (before && before !== sum.state && TAB_ATTN.has(sum.state) && (tab.uid !== T.active || document.hidden)) {
|
|
3769
3771
|
tab.attn = true;
|
|
@@ -3774,6 +3776,7 @@ function tabsChanged() {
|
|
|
3774
3776
|
}
|
|
3775
3777
|
renderTabs();
|
|
3776
3778
|
saveTabs();
|
|
3779
|
+
soundFor(turned);
|
|
3777
3780
|
});
|
|
3778
3781
|
}
|
|
3779
3782
|
|
|
@@ -4067,6 +4070,240 @@ document.addEventListener('change', (e) => {
|
|
|
4067
4070
|
void syncWake();
|
|
4068
4071
|
});
|
|
4069
4072
|
|
|
4073
|
+
// ───────────────────────────── sounds
|
|
4074
|
+
/*
|
|
4075
|
+
* Optional sound alerts: a short sound when a session finishes, asks you something or runs into
|
|
4076
|
+
* trouble, the same moments that make a tab flash (see tabsChanged), for every open tab. Off by
|
|
4077
|
+
* default. Each event has its own sound, chosen from a few, or None. "Only when I'm not looking"
|
|
4078
|
+
* (on by default) keeps quiet for the session on screen while the page is visible. The sounds are
|
|
4079
|
+
* made here (Web Audio), no files.
|
|
4080
|
+
*
|
|
4081
|
+
* Browsers only play sound once you've interacted with the page: turning sounds on, or a Test
|
|
4082
|
+
* button, counts. After a reload they wait for your first click or key, and the bell says so.
|
|
4083
|
+
*/
|
|
4084
|
+
const SND_KINDS = ['done', 'you', 'bad'];
|
|
4085
|
+
const SND_LABEL = {
|
|
4086
|
+
done: ['Finished', 'The agent finished its turn.'],
|
|
4087
|
+
you: ['Asked you', 'It asked you something, and waits for your answer.'],
|
|
4088
|
+
bad: ['In trouble', 'Failures in the current step that nothing has fixed yet.'],
|
|
4089
|
+
};
|
|
4090
|
+
const SND_DEFAULT = { done: 'chime', you: 'question', bad: 'low' };
|
|
4091
|
+
S.snd = (() => {
|
|
4092
|
+
const d = { on: false, tones: { ...SND_DEFAULT }, away: true, vol: 0.6 };
|
|
4093
|
+
try {
|
|
4094
|
+
const v = JSON.parse(localStorage.getItem('lucidly.sound') ?? 'null');
|
|
4095
|
+
if (v && typeof v === 'object') {
|
|
4096
|
+
// (Names are checked against the sounds once those are defined, see SOUNDS.)
|
|
4097
|
+
const known = { has: (t) => typeof t === 'string' };
|
|
4098
|
+
const tones = { ...d.tones };
|
|
4099
|
+
for (const k of SND_KINDS) {
|
|
4100
|
+
const t = v.tones?.[k] ?? (v.kinds?.[k] === false ? 'none' : undefined);
|
|
4101
|
+
if (known.has(t)) tones[k] = t;
|
|
4102
|
+
}
|
|
4103
|
+
return { on: !!v.on, away: v.away !== false, vol: Number(v.vol) || d.vol, tones };
|
|
4104
|
+
}
|
|
4105
|
+
} catch {
|
|
4106
|
+
// Unreadable: the defaults.
|
|
4107
|
+
}
|
|
4108
|
+
return d;
|
|
4109
|
+
})();
|
|
4110
|
+
const saveSnd = () => {
|
|
4111
|
+
try {
|
|
4112
|
+
localStorage.setItem('lucidly.sound', JSON.stringify(S.snd));
|
|
4113
|
+
} catch {
|
|
4114
|
+
// Storage blocked: the choice lasts until the page closes.
|
|
4115
|
+
}
|
|
4116
|
+
};
|
|
4117
|
+
|
|
4118
|
+
let sndCtx = null;
|
|
4119
|
+
let sndLast = 0;
|
|
4120
|
+
/** The audio context, resumed when the browser allows it (needs a gesture on the page). */
|
|
4121
|
+
function audio() {
|
|
4122
|
+
if (!sndCtx) {
|
|
4123
|
+
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
|
4124
|
+
if (!AC) return null;
|
|
4125
|
+
sndCtx = new AC();
|
|
4126
|
+
sndCtx.addEventListener('statechange', renderSound);
|
|
4127
|
+
}
|
|
4128
|
+
if (sndCtx.state === 'suspended') sndCtx.resume().catch(() => {});
|
|
4129
|
+
return sndCtx;
|
|
4130
|
+
}
|
|
4131
|
+
const sndBlocked = () => S.snd.on && (!sndCtx || sndCtx.state !== 'running');
|
|
4132
|
+
// Your first click or key on the page lets sound play (after a reload).
|
|
4133
|
+
for (const ev of ['pointerdown', 'keydown'])
|
|
4134
|
+
addEventListener(ev, () => S.snd.on && sndBlocked() && audio(), { capture: true, passive: true });
|
|
4135
|
+
|
|
4136
|
+
/** One soft note: a quick attack and a gentle fade. */
|
|
4137
|
+
function note(c, out, at, freq, dur, type = 'sine', level = 1, to = 0) {
|
|
4138
|
+
const o = c.createOscillator();
|
|
4139
|
+
const g = c.createGain();
|
|
4140
|
+
o.type = type;
|
|
4141
|
+
o.frequency.setValueAtTime(freq, at);
|
|
4142
|
+
// A pitch that slides (a knock's thud, a rising call).
|
|
4143
|
+
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
|
|
4144
|
+
g.gain.setValueAtTime(0.0001, at);
|
|
4145
|
+
g.gain.exponentialRampToValueAtTime(level, at + 0.012);
|
|
4146
|
+
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
|
4147
|
+
o.connect(g).connect(out);
|
|
4148
|
+
o.start(at);
|
|
4149
|
+
o.stop(at + dur + 0.05);
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
/** One held note, at full level until just before its end (alarms, sirens), its pitch sliding to `to`. */
|
|
4153
|
+
function hold(c, out, at, freq, dur, type = 'square', level = 0.3, to = 0) {
|
|
4154
|
+
const o = c.createOscillator();
|
|
4155
|
+
const g = c.createGain();
|
|
4156
|
+
o.type = type;
|
|
4157
|
+
o.frequency.setValueAtTime(freq, at);
|
|
4158
|
+
if (to) o.frequency.linearRampToValueAtTime(to, at + dur);
|
|
4159
|
+
g.gain.setValueAtTime(0.0001, at);
|
|
4160
|
+
g.gain.exponentialRampToValueAtTime(level, at + 0.01);
|
|
4161
|
+
g.gain.setValueAtTime(level, at + Math.max(0.01, dur - 0.03));
|
|
4162
|
+
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
|
4163
|
+
o.connect(g).connect(out);
|
|
4164
|
+
o.start(at);
|
|
4165
|
+
o.stop(at + dur + 0.05);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
/**
|
|
4169
|
+
* The sounds, in groups for the dropdown. Each draws its notes from time `t` into `out` (whose
|
|
4170
|
+
* gain is the volume). Gentle ones are soft and short; questions sound like they call you; alerts
|
|
4171
|
+
* are louder, longer and harsher, to be noticed from across the room.
|
|
4172
|
+
*/
|
|
4173
|
+
const SND_GROUPS = [
|
|
4174
|
+
[
|
|
4175
|
+
'Gentle',
|
|
4176
|
+
{
|
|
4177
|
+
chime: ['Chime', (c, o, t) => [523.25, 659.25, 783.99].forEach((f, i) => note(c, o, t + i * 0.11, f, i === 2 ? 0.8 : 0.5))],
|
|
4178
|
+
bell: ['Bell', (c, o, t) => (note(c, o, t, 659.25, 1.6, 'sine', 0.9), note(c, o, t, 1318.5, 0.9, 'sine', 0.3), note(c, o, t, 1810, 0.5, 'sine', 0.14))],
|
|
4179
|
+
marimba: ['Marimba', (c, o, t) => [[0, 783.99], [0.14, 1046.5]].forEach(([dt, f]) => (note(c, o, t + dt, f, 0.32), note(c, o, t + dt, f * 4, 0.06, 'sine', 0.15)))],
|
|
4180
|
+
harp: ['Harp', (c, o, t) => [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => note(c, o, t + i * 0.07, f, 0.7, 'triangle', 0.7))],
|
|
4181
|
+
glass: ['Glass', (c, o, t) => (note(c, o, t, 1567.98, 0.9, 'sine', 0.55), note(c, o, t + 0.12, 2093, 1.1, 'sine', 0.42))],
|
|
4182
|
+
},
|
|
4183
|
+
],
|
|
4184
|
+
[
|
|
4185
|
+
'Questions',
|
|
4186
|
+
{
|
|
4187
|
+
question: ['Question', (c, o, t) => (note(c, o, t, 587.33, 0.45), note(c, o, t + 0.2, 880, 0.9), note(c, o, t + 0.2, 1760, 0.4, 'sine', 0.12))],
|
|
4188
|
+
doorbell: ['Doorbell', (c, o, t) => [[0, 659.25, 0.9], [0.45, 523.25, 1.3]].forEach(([dt, f, d]) => (note(c, o, t + dt, f, d, 'sine', 0.9), note(c, o, t + dt, f * 2, d / 2, 'sine', 0.25)))],
|
|
4189
|
+
ping: ['Ping', (c, o, t) => (note(c, o, t, 1318.5, 0.6, 'sine', 0.8), note(c, o, t, 2637, 0.25, 'sine', 0.15))],
|
|
4190
|
+
knock: ['Knock knock', (c, o, t) => [0, 0.17].forEach((dt) => note(c, o, t + dt, 190, 0.13, 'sine', 1, 95))],
|
|
4191
|
+
rising: ['Rising', (c, o, t) => (note(c, o, t, 440, 0.4, 'sine', 0.9, 880), note(c, o, t + 0.34, 880, 0.5, 'sine', 0.7))],
|
|
4192
|
+
},
|
|
4193
|
+
],
|
|
4194
|
+
[
|
|
4195
|
+
'Alerts',
|
|
4196
|
+
{
|
|
4197
|
+
low: ['Low tone', (c, o, t) => (note(c, o, t, 311.13, 0.4, 'triangle', 0.9), note(c, o, t + 0.22, 233.08, 0.75, 'triangle', 0.9))],
|
|
4198
|
+
beep: ['Beep', (c, o, t) => [0, 0.18].forEach((dt) => note(c, o, t + dt, 880, 0.12, 'square', 0.28))],
|
|
4199
|
+
triple: ['Triple beep', (c, o, t) => [0, 0.13, 0.26].forEach((dt) => hold(c, o, t + dt, 1000, 0.09, 'square', 0.3))],
|
|
4200
|
+
alarm: ['Alarm', (c, o, t) => [0, 1, 2, 3, 4, 5].forEach((i) => hold(c, o, t + i * 0.17, i % 2 ? 660 : 880, 0.15, 'square', 0.4))],
|
|
4201
|
+
siren: ['Siren', (c, o, t) => [0, 0.9].forEach((dt) => (hold(c, o, t + dt, 600, 0.45, 'sawtooth', 0.28, 1200), hold(c, o, t + dt + 0.45, 1200, 0.45, 'sawtooth', 0.28, 600)))],
|
|
4202
|
+
buzzer: ['Buzzer', (c, o, t) => (hold(c, o, t, 110, 0.7, 'sawtooth', 0.5), hold(c, o, t, 116, 0.7, 'square', 0.3))],
|
|
4203
|
+
klaxon: ['Klaxon', (c, o, t) => [[0, [466.16, 554.37], 0.35], [0.45, [415.3, 493.88], 0.55]].forEach(([dt, fs, d]) => fs.forEach((f) => hold(c, o, t + dt, f, d, 'square', 0.3)))],
|
|
4204
|
+
},
|
|
4205
|
+
],
|
|
4206
|
+
];
|
|
4207
|
+
const SOUNDS = Object.fromEntries(SND_GROUPS.flatMap(([, g]) => Object.entries(g)));
|
|
4208
|
+
// A saved sound that no longer exists falls back to the default.
|
|
4209
|
+
for (const k of SND_KINDS) if (S.snd.tones[k] !== 'none' && !SOUNDS[S.snd.tones[k]]) S.snd.tones[k] = SND_DEFAULT[k];
|
|
4210
|
+
|
|
4211
|
+
function playSound(tone) {
|
|
4212
|
+
const s = SOUNDS[tone];
|
|
4213
|
+
if (!s) return;
|
|
4214
|
+
const c = audio();
|
|
4215
|
+
if (!c) return;
|
|
4216
|
+
const out = c.createGain();
|
|
4217
|
+
out.gain.value = 0.32 * S.snd.vol;
|
|
4218
|
+
out.connect(c.destination);
|
|
4219
|
+
s[1](c, out, c.currentTime + 0.02);
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
/**
|
|
4223
|
+
* Sessions whose state just changed ([tab, new state] pairs): plays one sound for them all, the
|
|
4224
|
+
* most pressing (in trouble, then asked you, then finished), at most every couple of seconds.
|
|
4225
|
+
*/
|
|
4226
|
+
function soundFor(changes) {
|
|
4227
|
+
const s = S.snd;
|
|
4228
|
+
if (!s.on) return;
|
|
4229
|
+
const wanted = changes.filter(([tab, st]) => s.tones[st] !== 'none' && !(s.away && !document.hidden && tab.uid === T.active)).map(([, st]) => st);
|
|
4230
|
+
const kind = ['bad', 'you', 'done'].find((k) => wanted.includes(k));
|
|
4231
|
+
if (!kind || Date.now() - sndLast < 2000) return;
|
|
4232
|
+
sndLast = Date.now();
|
|
4233
|
+
playSound(s.tones[kind]);
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
function soundState() {
|
|
4237
|
+
const s = S.snd;
|
|
4238
|
+
if (!s.on) return ['off', 'Off'];
|
|
4239
|
+
if (!globalThis.AudioContext && !globalThis.webkitAudioContext) return ['err', "This browser can't play sounds"];
|
|
4240
|
+
if (sndBlocked()) return ['wait', 'Waiting for a click on the page: browsers only play sound after you interact with it'];
|
|
4241
|
+
const kinds = SND_KINDS.filter((k) => s.tones[k] !== 'none').map((k) => SND_LABEL[k][0].toLowerCase());
|
|
4242
|
+
if (!kinds.length) return ['wait', 'On, but every event is set to None'];
|
|
4243
|
+
return ['on', `On: ${kinds.join(', ')}${s.away ? ", when you're not looking" : ''}`];
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
function renderSound() {
|
|
4247
|
+
const [st, text] = soundState();
|
|
4248
|
+
const btn = $('#soundBtn');
|
|
4249
|
+
btn.dataset.snd = st;
|
|
4250
|
+
const label = st === 'off' ? 'Sound alerts' : `Sound alerts: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
4251
|
+
if (btn.title !== label) {
|
|
4252
|
+
btn.title = label;
|
|
4253
|
+
btn.setAttribute('aria-label', label);
|
|
4254
|
+
}
|
|
4255
|
+
const pop = $('#soundPop');
|
|
4256
|
+
if (pop.hidden) return;
|
|
4257
|
+
const s = S.snd;
|
|
4258
|
+
const rows = SND_KINDS.map(
|
|
4259
|
+
(k) =>
|
|
4260
|
+
`<div class="sn-ev"><span class="sn-l"><b>${SND_LABEL[k][0]}</b><small>${SND_LABEL[k][1]}</small></span><select data-snd="tone" data-kind="${k}" aria-label="Sound when ${SND_LABEL[k][0].toLowerCase()}"><option value="none"${s.tones[k] === 'none' ? ' selected' : ''}>None</option>${SND_GROUPS.map(([g, list]) => `<optgroup label="${g}">${Object.entries(list).map(([v, [l]]) => `<option value="${v}"${s.tones[k] === v ? ' selected' : ''}>${l}</option>`).join('')}</optgroup>`).join('')}</select><button type="button" class="tbtn" data-sndtest="${k}" title="Play this sound"${s.tones[k] === 'none' ? ' disabled' : ''}>Test</button></div>`,
|
|
4261
|
+
).join('');
|
|
4262
|
+
setHTML(
|
|
4263
|
+
pop,
|
|
4264
|
+
`<div class="pop-h">Sounds</div>
|
|
4265
|
+
<label class="switch wk-row"><input type="checkbox" data-snd="on"${s.on ? ' checked' : ''} /><span></span>Play a sound when a session…</label>
|
|
4266
|
+
${
|
|
4267
|
+
s.on
|
|
4268
|
+
? `${rows}
|
|
4269
|
+
<label class="wk-opt sn-away"><input type="checkbox" data-snd="away"${s.away ? ' checked' : ''} /><span><b>Only when I'm not looking</b><small>No sound for the session on screen while this page is visible.</small></span></label>
|
|
4270
|
+
<label class="sn-vol">Volume<input type="range" min="0.1" max="1" step="0.05" value="${s.vol}" data-snd="vol" aria-label="Volume" /></label>
|
|
4271
|
+
<div class="wk-st st-${st}"><i></i>${esc(text)}</div>`
|
|
4272
|
+
: ''
|
|
4273
|
+
}
|
|
4274
|
+
<p class="wk-note">For the sessions in all your tabs.</p>`,
|
|
4275
|
+
);
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
function toggleSound(show = $('#soundPop').hidden) {
|
|
4279
|
+
$('#soundPop').hidden = !show;
|
|
4280
|
+
$('#soundBtn').classList.toggle('on', show);
|
|
4281
|
+
renderSound();
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
document.addEventListener('change', (e) => {
|
|
4285
|
+
const t = e.target;
|
|
4286
|
+
if (!(t instanceof HTMLInputElement || t instanceof HTMLSelectElement) || !t.dataset.snd) return;
|
|
4287
|
+
const s = S.snd;
|
|
4288
|
+
if (t.dataset.snd === 'on') {
|
|
4289
|
+
s.on = t.checked;
|
|
4290
|
+
// Turning it on is the click that lets the browser play sound.
|
|
4291
|
+
if (s.on) audio();
|
|
4292
|
+
} else if (t.dataset.snd === 'tone') {
|
|
4293
|
+
// Picking a sound plays it.
|
|
4294
|
+
s.tones[t.dataset.kind] = t.value;
|
|
4295
|
+
playSound(t.value);
|
|
4296
|
+
}
|
|
4297
|
+
else if (t.dataset.snd === 'away') s.away = t.checked;
|
|
4298
|
+
else if (t.dataset.snd === 'vol') {
|
|
4299
|
+
s.vol = Number(t.value);
|
|
4300
|
+
playSound(SND_KINDS.map((k) => s.tones[k]).find((v) => v !== 'none') ?? 'chime');
|
|
4301
|
+
}
|
|
4302
|
+
saveSnd();
|
|
4303
|
+
renderSound();
|
|
4304
|
+
});
|
|
4305
|
+
renderSound();
|
|
4306
|
+
|
|
4070
4307
|
function toggleTokens(show = $('#tokensPop').hidden) {
|
|
4071
4308
|
$('#tokensPop').hidden = !show;
|
|
4072
4309
|
$('#tokens').classList.toggle('on', show);
|
|
@@ -4268,6 +4505,13 @@ document.addEventListener('click', (e) => {
|
|
|
4268
4505
|
if (on('#wakeBtn')) return toggleWake();
|
|
4269
4506
|
if (!on('#wakePop') && !$('#wakePop').hidden) toggleWake(false);
|
|
4270
4507
|
if (on('#wakePop')) return;
|
|
4508
|
+
if (on('#soundBtn')) return toggleSound();
|
|
4509
|
+
if (!on('#soundPop') && !$('#soundPop').hidden) toggleSound(false);
|
|
4510
|
+
if ((el = on('[data-sndtest]'))) {
|
|
4511
|
+
audio();
|
|
4512
|
+
return playSound(S.snd.tones[el.dataset.sndtest]);
|
|
4513
|
+
}
|
|
4514
|
+
if (on('#soundPop')) return;
|
|
4271
4515
|
if (on('#emptyOpen')) return void openPicker();
|
|
4272
4516
|
if ((el = on('[data-view]'))) return setView(el.dataset.view);
|
|
4273
4517
|
if (on('[data-pin]')) return togglePin();
|
|
@@ -4770,6 +5014,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
4770
5014
|
schedule();
|
|
4771
5015
|
} else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
|
|
4772
5016
|
else if (e.key === 'Escape' && !$('#wakePop').hidden) toggleWake(false);
|
|
5017
|
+
else if (e.key === 'Escape' && !$('#soundPop').hidden) toggleSound(false);
|
|
4773
5018
|
else if (e.key === 'Escape' && !$('#picker').open) {
|
|
4774
5019
|
S.follow = false;
|
|
4775
5020
|
S.selected = null;
|
package/web/index.html
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
</button>
|
|
30
30
|
<div class="pop wake-pop" id="wakePop" hidden></div>
|
|
31
31
|
</div>
|
|
32
|
+
<div class="wake-wrap">
|
|
33
|
+
<button class="ibtn snd" id="soundBtn" aria-haspopup="dialog" aria-label="Sound alerts" title="Sound alerts">
|
|
34
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z M10 20.5a2.1 2.1 0 0 0 4 0" /><path class="waves" d="M20 7.5a6 6 0 0 1 1 3.5 M4 7.5a6 6 0 0 0-1 3.5" /><path class="mute" d="M4 4l16 16" /></svg>
|
|
35
|
+
</button>
|
|
36
|
+
<div class="pop wake-pop" id="soundPop" hidden></div>
|
|
37
|
+
</div>
|
|
32
38
|
<div class="grow"></div>
|
|
33
39
|
<div class="meter-wrap">
|
|
34
40
|
<span class="pill warn" id="outdated" hidden title="Lucidly's engine was updated after this server started. Stop it and run lucidly again to apply the update: some data (such as token counts) may be missing until then.">Restart Lucidly to update</span>
|
package/web/style.css
CHANGED
|
@@ -1280,3 +1280,28 @@ pre .dh { color: var(--faint); font-weight: 600; }
|
|
|
1280
1280
|
.plist .pnew:hover { background: var(--accent-bg); border-color: color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); }
|
|
1281
1281
|
.picker-hint { flex: none; padding: 0 18px 8px; color: var(--faint); font-size: 11.5px; }
|
|
1282
1282
|
.picker-hint:empty { display: none; }
|
|
1283
|
+
|
|
1284
|
+
/* ───────── sound alerts (the bell next to keep awake) */
|
|
1285
|
+
.snd { color: var(--faint); }
|
|
1286
|
+
.snd svg { width: 19px; height: 19px; stroke-width: 1.7; }
|
|
1287
|
+
.snd .waves { opacity: 0; transition: opacity 0.2s; }
|
|
1288
|
+
.snd .mute { opacity: 0; }
|
|
1289
|
+
.snd[data-snd='off'] .mute { opacity: 1; }
|
|
1290
|
+
.snd[data-snd='on'] { color: var(--accent); }
|
|
1291
|
+
.snd[data-snd='on'] .waves { opacity: 1; }
|
|
1292
|
+
.snd[data-snd='wait'] { color: var(--warn); }
|
|
1293
|
+
.snd[data-snd='err'] { color: var(--bad); }
|
|
1294
|
+
#soundPop { width: 390px; }
|
|
1295
|
+
.sn-ev { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 10px 2px; }
|
|
1296
|
+
.sn-l { flex: 1; min-width: 0; font-size: 12.5px; }
|
|
1297
|
+
.sn-l b { font-weight: 560; }
|
|
1298
|
+
.sn-l small { display: block; color: var(--muted); font-size: 11.5px; margin-top: 2px; line-height: 1.35; }
|
|
1299
|
+
.sn-ev select { flex: none; width: 118px; height: 24px; margin-top: -2px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); font-size: 12px; cursor: pointer; }
|
|
1300
|
+
.sn-ev .tbtn:disabled { opacity: 0.4; cursor: default; }
|
|
1301
|
+
.sn-ev .tbtn { flex: none; height: 24px; padding: 0 9px; font-size: 12px; margin-top: -2px; }
|
|
1302
|
+
.sn-away { padding-top: 10px; border-top: 1px solid var(--border); }
|
|
1303
|
+
.sn-vol { display: flex; align-items: center; gap: 10px; margin: 0 0 10px 2px; font-size: 12.5px; color: var(--muted); }
|
|
1304
|
+
.sn-vol input { flex: 1; accent-color: var(--accent); }
|
|
1305
|
+
.wk-st.st-on { color: var(--text); }
|
|
1306
|
+
.wk-st.st-on i { background: var(--accent); }
|
|
1307
|
+
.wk-st.st-wait i { background: var(--warn); }
|