@oddessentials/agent-guild 0.19.0 → 0.20.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 CHANGED
@@ -120,7 +120,23 @@ puts a crew of little robots in deep space; **Grove** is a calm moss garden
120
120
  of gentle nature spirits; **Gnomeland** is a lantern-lit village of gnome
121
121
  builders, engineers with a little magic. The page
122
122
  follows your system's light or dark setting until you pick one. See
123
- [docs/SKINS.md](docs/SKINS.md) to make another.
123
+ [docs/SKINS.md](docs/SKINS.md) to make another. **Alert sounds**, off until
124
+ you turn them on in the same menu, chime when the session manager confirms
125
+ a stop or restart, remains unavailable after a brief recovery check, or a
126
+ new version is discovered after the initial version check. Session activity
127
+ and assistant responses have no sounds:
128
+ the providers do not yet offer consistently reliable completion signals.
129
+
130
+ Sounds play in one eligible tab per browser and origin after browser playback
131
+ permission (usually a click or keypress). They require Web Locks and writable
132
+ local storage. Opening or reconnecting a page does not replay old alerts,
133
+ even when a different manager has taken over. Closing a browser does not
134
+ stop the manager. After an unexpected disconnect, an already connected page
135
+ checks the local health endpoint twice, two seconds apart, with a one-second
136
+ timeout per request. If both fail, it shows “Manager unavailable” and alerts
137
+ once; it cannot confirm whether the process exited or sessions ended.
138
+ A successful health check, reconnection, or closing the page cancels the alert.
139
+ There is no added polling while connected and no additional monitoring process.
124
140
 
125
141
  ## Commands
126
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,7 @@ const MIME = {
25
25
  '.webp': 'image/webp',
26
26
  '.woff2': 'font/woff2',
27
27
  '.ico': 'image/x-icon',
28
+ '.wav': 'audio/wav',
28
29
  '.json': 'application/json; charset=utf-8',
29
30
  };
30
31
 
@@ -450,7 +451,7 @@ export function createManagerServer({
450
451
 
451
452
  function handleEvents(ws) {
452
453
  eventClients.add(ws);
453
- safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
454
+ safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
454
455
  ws.on('close', () => eventClients.delete(ws));
455
456
  ws.on('message', () => { /* events socket is server -> client only */ });
456
457
  }
package/web/alerts.js ADDED
@@ -0,0 +1,155 @@
1
+ // Sounds follow manager availability and new releases, never session activity.
2
+ export const SOUNDS = {
3
+ stopped: 'sounds/manager-stopped.wav',
4
+ update: 'sounds/update-available.wav',
5
+ };
6
+
7
+ export const MAX_ALERT_AGE_MS = 10000;
8
+ export const RECOVERY_WAIT_MS = 2000;
9
+ export const HEALTH_TIMEOUT_MS = 1000;
10
+ const PLAYED_KEY = 'agentGuild.playedSounds';
11
+
12
+ /** Queue eligible pages, allowing another page to try if playback fails.
13
+ * Record only successful playback, under the same cross-tab lock. A bounded
14
+ * ledger prevents delayed tabs from replaying the event after lock release.
15
+ * Without shared coordination, stay silent rather than multiply alerts.
16
+ */
17
+ export async function playOnce(key, play, { locks = globalThis.navigator?.locks, storage, fresh = () => true, related = [] } = {}) {
18
+ if (!locks) return false;
19
+ try {
20
+ storage ??= globalThis.localStorage;
21
+ if (!storage) return false;
22
+ return await locks.request('agentGuild.sound', async () => {
23
+ if (!fresh()) return false;
24
+ let played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
25
+ if (!Array.isArray(played)) played = [];
26
+ if (played.includes(key) || related.some((other) => played.includes(other))) return false;
27
+ // Check storage is writable before making a sound.
28
+ storage.setItem(PLAYED_KEY, JSON.stringify(played.slice(-127)));
29
+ try {
30
+ if (await play() === false) return false;
31
+ } catch { return false; }
32
+ storage.setItem(PLAYED_KEY, JSON.stringify([...played.slice(-127), key]));
33
+ return true;
34
+ });
35
+ } catch { return false; }
36
+ }
37
+
38
+ /** A live connection rearms an availability alert for a later outage. */
39
+ export async function rearmSound(key, { locks = globalThis.navigator?.locks, storage } = {}) {
40
+ if (!locks) return;
41
+ try {
42
+ storage ??= globalThis.localStorage;
43
+ if (!storage) return;
44
+ await locks.request('agentGuild.sound', () => {
45
+ const played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
46
+ if (Array.isArray(played) && played.includes(key)) {
47
+ storage.setItem(PLAYED_KEY, JSON.stringify(played.filter((item) => item !== key)));
48
+ }
49
+ });
50
+ } catch { /* Optional sound coordination may be unavailable. */ }
51
+ }
52
+
53
+ /** Two bounded health checks, only after losing a previously live connection.
54
+ * Reconnect, shutdown and page departure cancel both requests and timers.
55
+ * The callback's freshness check also cancels playback waiting on another tab.
56
+ */
57
+ export function managerLossWatcher({ reachable, unavailable, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
58
+ let live = false;
59
+ let pending = null;
60
+ const cancel = () => {
61
+ pending?.abort();
62
+ pending = null;
63
+ live = false;
64
+ };
65
+ const check = async (signal) => {
66
+ const request = new AbortController();
67
+ const abort = () => request.abort();
68
+ signal.addEventListener('abort', abort, { once: true });
69
+ const timer = setTimer(abort, HEALTH_TIMEOUT_MS);
70
+ try { return await reachable(request.signal); }
71
+ catch { return false; }
72
+ finally {
73
+ clearTimer(timer);
74
+ signal.removeEventListener('abort', abort);
75
+ }
76
+ };
77
+ const wait = (signal) => new Promise((resolve) => {
78
+ const done = () => {
79
+ clearTimer(timer);
80
+ signal.removeEventListener('abort', done);
81
+ resolve();
82
+ };
83
+ const timer = setTimer(done, RECOVERY_WAIT_MS);
84
+ signal.addEventListener('abort', done, { once: true });
85
+ });
86
+ return {
87
+ connected() { cancel(); live = true; },
88
+ cancel,
89
+ async disconnected() {
90
+ if (!live) return;
91
+ live = false;
92
+ const controller = new AbortController();
93
+ pending = controller;
94
+ const at = now();
95
+ const fresh = () => pending === controller && !controller.signal.aborted && now() - at <= MAX_ALERT_AGE_MS;
96
+ if (await check(controller.signal) || !fresh()) return;
97
+ await wait(controller.signal);
98
+ if (!fresh() || await check(controller.signal) || !fresh()) return;
99
+ unavailable(at, fresh);
100
+ },
101
+ };
102
+ }
103
+
104
+ /** Reconnecting establishes a baseline; only a live stop event alerts. */
105
+ export function stopWatcher() {
106
+ let instance = null;
107
+ let stopped = false;
108
+ return {
109
+ connected(next) {
110
+ if (next !== instance) stopped = false;
111
+ instance = next;
112
+ },
113
+ stopped() {
114
+ if (!instance || stopped) return null;
115
+ stopped = true;
116
+ return instance;
117
+ },
118
+ };
119
+ }
120
+
121
+ // Published releases use semantic versions, including numerically ordered
122
+ // prerelease identifiers. Build metadata does not affect precedence.
123
+ function versionParts(value) {
124
+ return typeof value === 'string' ? value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/) : null;
125
+ }
126
+ function newer(a, b) {
127
+ const av = versionParts(a), bv = versionParts(b);
128
+ for (let i = 1; i <= 3; i++) if (+av[i] !== +bv[i]) return +av[i] > +bv[i];
129
+ if (!av[4] || !bv[4]) return Boolean(bv[4]) && !av[4];
130
+ const ap = av[4].split('.'), bp = bv[4].split('.');
131
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
132
+ if (ap[i] === bp[i]) continue;
133
+ if (ap[i] === undefined || bp[i] === undefined) return bp[i] === undefined;
134
+ const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
135
+ if (an && bn) return +ap[i] > +bp[i];
136
+ if (an !== bn) return bn;
137
+ return ap[i] > bp[i];
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /** The first successful version report is a baseline, even after nulls.
143
+ * Reconnect snapshots also advance it silently. Never alert on a rollback.
144
+ */
145
+ export function updateWatcher() {
146
+ let seen = null;
147
+ return (upgrade, baseline = false) => {
148
+ const version = upgrade?.latestVersion;
149
+ if (!versionParts(version)) return false;
150
+ if (!seen) { seen = version; return false; }
151
+ if (!newer(version, seen)) return false;
152
+ seen = version;
153
+ return !baseline && upgrade.available === true;
154
+ };
155
+ }
package/web/app.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Agent Guild web page. A thin client of the session manager's local API:
2
2
  // it never owns sessions, so closing the page leaves them running.
3
3
 
4
+ import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
5
+
4
6
  const TOKEN_KEY = 'agentGuild.token';
5
7
  const CWD_KEY = 'agentGuild.cwd';
6
8
  const ACCOUNTS_KEY = 'agentGuild.accounts';
@@ -13,6 +15,7 @@ const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
13
15
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
14
16
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
15
17
  const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
18
+ const SOUND_KEY = 'agentGuild.sound';
16
19
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
17
20
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
18
21
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -36,6 +39,8 @@ const state = {
36
39
  activeId: null,
37
40
  eventsSocket: null,
38
41
  eventsRetry: 0,
42
+ pageAway: false,
43
+ managerUnavailable: false,
39
44
  /** True while the events socket is open. */
40
45
  connected: false,
41
46
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
@@ -206,6 +211,80 @@ function changeSkin(input) {
206
211
  revealChange(input.closest('label'), () => { document.documentElement.dataset.skin = skin; });
207
212
  }
208
213
 
214
+ // ---- sound ----------------------------------------------------------------
215
+
216
+ const audio = {};
217
+ const stopAlert = stopWatcher();
218
+ const updateAlert = updateWatcher();
219
+ const managerLoss = managerLossWatcher({
220
+ async reachable(signal) {
221
+ const res = await fetch('/api/v1/health', { cache: 'no-store', signal });
222
+ const health = await res.json();
223
+ return res.ok && health.ok === true && health.name === 'agent-guild';
224
+ },
225
+ unavailable(at, fresh) {
226
+ state.managerUnavailable = true;
227
+ showManagerUnavailable();
228
+ alertSound('stopped', `unavailable.${state.managerInstance}`, at, fresh, [`stopped.${state.managerInstance}`]);
229
+ },
230
+ });
231
+
232
+ function soundOn() {
233
+ return load(SOUND_KEY) === 'on';
234
+ }
235
+
236
+ /** Keep the tiny clips ready while the manager can still serve them. */
237
+ function prepareSounds() {
238
+ if (!soundOn()) return;
239
+ for (const [name, file] of Object.entries(SOUNDS)) {
240
+ audio[name] ??= new Audio(file);
241
+ audio[name].preload = 'auto';
242
+ if (audio[name].error) audio[name].load();
243
+ }
244
+ }
245
+
246
+ /** Plays a sound when sounds are on. Browsers allow it once the user has clicked or typed on the page. */
247
+ function playSound(name) {
248
+ if (!soundOn()) return Promise.resolve(false);
249
+ audio[name] ??= new Audio(SOUNDS[name]);
250
+ audio[name].currentTime = 0;
251
+ return audio[name].play().then(() => true);
252
+ }
253
+
254
+ /** Plays an alert in one open page only; `key` names the event. */
255
+ function alertSound(name, key, at = Date.now(), fresh = () => true, related = []) {
256
+ if (soundOn()) playOnce(key, () => playSound(name), {
257
+ fresh: () => !state.pageAway && soundOn() && fresh() && Date.now() - at <= MAX_ALERT_AGE_MS,
258
+ related,
259
+ });
260
+ }
261
+
262
+ function changeSound(input) {
263
+ save(SOUND_KEY, input.checked ? 'on' : null);
264
+ prepareSounds();
265
+ playSound('update').catch(() => {});
266
+ }
267
+
268
+ /** A hello identifies this manager lifetime, independent of connections. */
269
+ function managerConnected(msg) {
270
+ prepareSounds();
271
+ state.managerInstance = msg.startedAt && msg.pid ? `${msg.pid}.${msg.startedAt}` : null;
272
+ stopAlert.connected(state.managerInstance);
273
+ state.managerUnavailable = false;
274
+ if (state.managerInstance) managerLoss.connected();
275
+ else managerLoss.cancel();
276
+ if (state.managerInstance) rearmSound(`unavailable.${state.managerInstance}`);
277
+ }
278
+
279
+ /** A confirmed manager stop; a browser socket closing never calls this. */
280
+ function managerGone() {
281
+ managerLoss.cancel();
282
+ state.managerUnavailable = false;
283
+ const instance = stopAlert.stopped();
284
+ if (instance) alertSound('stopped', `stopped.${instance}`, Date.now(),
285
+ () => state.managerInstance === instance && !state.connected, [`unavailable.${instance}`]);
286
+ }
287
+
209
288
  /** Places the open menu under its button, right-aligned with it and kept on screen. */
210
289
  function placeAppearanceMenu() {
211
290
  const menu = $('appearance-menu');
@@ -269,9 +348,10 @@ function renderUpgrade() {
269
348
  note.title = title;
270
349
  }
271
350
 
272
- function setUpgrade(upgrade) {
351
+ function setUpgrade(upgrade, baseline = false) {
273
352
  const before = state.upgrade;
274
353
  state.upgrade = upgrade || null;
354
+ if (updateAlert(state.upgrade, baseline)) alertSound('update', `update.${state.upgrade.latestVersion}`);
275
355
  renderUpgrade();
276
356
  renderVersion();
277
357
  if ($('changelog').open) renderChangelog();
@@ -3068,7 +3148,9 @@ class TerminalView {
3068
3148
  this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
3069
3149
  this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
3070
3150
  suppressQueryReplies(this.term);
3071
- this.term.onData((data) => this.send({ type: 'input', data }));
3151
+ this.term.onData((data) => {
3152
+ this.send({ type: 'input', data });
3153
+ });
3072
3154
  this.opened = false;
3073
3155
  this.disposed = false;
3074
3156
  this.retry = 0;
@@ -3324,7 +3406,7 @@ function leaveStopping() {
3324
3406
  function restartGaveUp() {
3325
3407
  if (!state.stopping || !state.restarting) return;
3326
3408
  state.restarting = false;
3327
- setConnection('down', 'Session manager stopped');
3409
+ setConnection('down', state.stopRemaining === null ? 'Manager unavailable' : 'Session manager stopped');
3328
3410
  showStopped('stopped', 'The session manager did not come back',
3329
3411
  `Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
3330
3412
  }
@@ -3386,14 +3468,26 @@ function showManagerStopped() {
3386
3468
 
3387
3469
  // ---- events ---------------------------------------------------------------
3388
3470
 
3471
+ function showManagerUnavailable() {
3472
+ setConnection('down', 'Manager unavailable. Trying to reconnect…');
3473
+ if (state.stopping) {
3474
+ clearTimeout(restartTimer);
3475
+ showStopped('stopped', 'Manager unavailable', 'The manager stopped responding before confirming shutdown. Sessions may still be running. This page will reconnect automatically.');
3476
+ }
3477
+ }
3478
+
3389
3479
  function connectEvents() {
3480
+ if (state.pageAway) return;
3390
3481
  const ws = new WebSocket(wsUrl('/events'));
3391
3482
  state.eventsSocket = ws;
3392
3483
  ws.onopen = () => {
3484
+ if (state.pageAway || state.eventsSocket !== ws) return;
3485
+ managerLoss.cancel();
3393
3486
  state.eventsRetry = 0;
3394
3487
  setConnection('ok', 'Connected to session manager');
3395
3488
  };
3396
3489
  ws.onmessage = (event) => {
3490
+ if (state.pageAway || state.eventsSocket !== ws) return;
3397
3491
  const msg = JSON.parse(event.data);
3398
3492
  if (msg.type === 'hello') {
3399
3493
  // The manager is back after a stop or restart; the page picks up where it was.
@@ -3406,9 +3500,10 @@ function connectEvents() {
3406
3500
  setConnection('ok', 'Connected to session manager');
3407
3501
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
3408
3502
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
3503
+ managerConnected(msg);
3409
3504
  renderSessions();
3410
3505
  sessionsShown = true;
3411
- setUpgrade(msg.upgrade);
3506
+ setUpgrade(msg.upgrade, true);
3412
3507
  loadNews();
3413
3508
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
3414
3509
  if ($('changelog').open) loadChangelog();
@@ -3427,6 +3522,7 @@ function connectEvents() {
3427
3522
  enterStopping(0, msg.restart === true);
3428
3523
  state.stopRemaining = Number(msg.remaining) || 0;
3429
3524
  showManagerStopped();
3525
+ managerGone();
3430
3526
  } else if (msg.type === 'session.created' || msg.type === 'session.updated') {
3431
3527
  upsertSession(msg.session);
3432
3528
  } else if (msg.type === 'session.removed') {
@@ -3439,16 +3535,21 @@ function connectEvents() {
3439
3535
  }
3440
3536
  };
3441
3537
  ws.onclose = () => {
3442
- if (state.stopping) {
3538
+ if (state.pageAway || state.eventsSocket !== ws) return;
3539
+ if (state.managerUnavailable) {
3540
+ showManagerUnavailable();
3541
+ } else if (state.stopping && state.stopRemaining !== null) {
3443
3542
  showManagerStopped();
3444
3543
  } else {
3445
- setConnection('down', 'Session manager not reachable. Run "agent-guild open" to start it.');
3544
+ setConnection('down', 'Connection interrupted. Trying to reconnect…');
3446
3545
  }
3546
+ managerLoss.disconnected();
3447
3547
  // Keep trying: after a stop, a relaunched manager brings the page back by itself.
3448
3548
  const delay = Math.min(5000, 500 * 2 ** state.eventsRetry++);
3449
3549
  setTimeout(async () => {
3550
+ if (state.pageAway || state.eventsSocket !== ws) return;
3450
3551
  try { await loadProviders(); } catch (err) { if (err instanceof AuthError) return showAuth(err.message); }
3451
- connectEvents();
3552
+ if (!state.pageAway && state.eventsSocket === ws) connectEvents();
3452
3553
  }, delay);
3453
3554
  };
3454
3555
  }
@@ -3476,6 +3577,7 @@ let statsInterval;
3476
3577
  let newsTimer;
3477
3578
 
3478
3579
  function showAuth(message = '') {
3580
+ managerLoss.cancel();
3479
3581
  closeModels();
3480
3582
  closeNews();
3481
3583
  closeChangelog();
@@ -3611,6 +3713,16 @@ $('news').addEventListener('close', () => {
3611
3713
  document.addEventListener('visibilitychange', () => {
3612
3714
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
3613
3715
  });
3716
+ addEventListener('pagehide', () => {
3717
+ state.pageAway = true;
3718
+ managerLoss.cancel();
3719
+ state.eventsSocket?.close();
3720
+ });
3721
+ addEventListener('pageshow', (event) => {
3722
+ if (!event.persisted) return;
3723
+ state.pageAway = false;
3724
+ connectEvents();
3725
+ });
3614
3726
  $('version').addEventListener('click', openChangelog);
3615
3727
  $('changelog-close').addEventListener('click', closeChangelog);
3616
3728
  $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
@@ -3629,6 +3741,7 @@ $('changelog-restart').addEventListener('click', () => {
3629
3741
  });
3630
3742
  addEventListener('storage', (e) => {
3631
3743
  if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3744
+ else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
3632
3745
  // Another tab reordered the cards.
3633
3746
  else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3634
3747
  });
@@ -3674,6 +3787,7 @@ $('upgrade').addEventListener('click', upgradeManager);
3674
3787
  $('appearance-menu').addEventListener('change', (e) => {
3675
3788
  if (e.target.name === 'skin') changeSkin(e.target);
3676
3789
  else if (e.target.name === 'theme') changeTheme(e.target);
3790
+ else if (e.target.name === 'sound') changeSound(e.target);
3677
3791
  });
3678
3792
  $('appearance-menu').addEventListener('toggle', (e) => {
3679
3793
  if (e.newState !== 'open') return;
@@ -3707,6 +3821,7 @@ $('providers').addEventListener('pointerout', (e) => {
3707
3821
  });
3708
3822
  applyTheme(currentTheme());
3709
3823
  renderSkinChoices();
3824
+ $('sound').checked = soundOn();
3710
3825
  // Follow the system setting until the user picks a theme.
3711
3826
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
3712
3827
  if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
package/web/index.html CHANGED
@@ -36,6 +36,10 @@
36
36
  <label class="choice"><input type="radio" name="theme" value="light"><span class="mode-light">Light</span></label>
37
37
  <label class="choice"><input type="radio" name="theme" value="dark"><span class="mode-dark">Dark</span></label>
38
38
  </fieldset>
39
+ <fieldset>
40
+ <legend>Sound</legend>
41
+ <label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
42
+ </fieldset>
39
43
  </div>
40
44
  <button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
41
45
  <button id="stop-manager" class="btn danger stop-manager" type="button" hidden title="Stop the session manager. This ends every session.">Stop manager</button>
@@ -0,0 +1,22 @@
1
+
2
+
3
+ Interface Sounds (1.0)
4
+
5
+ Created/distributed by Kenney (www.kenney.nl)
6
+ Creation date: 11-02-2020
7
+
8
+ ------------------------------
9
+
10
+ License: (Creative Commons Zero, CC0)
11
+ http://creativecommons.org/publicdomain/zero/1.0/
12
+
13
+ This content is free to use in personal, educational and commercial projects.
14
+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
15
+
16
+ ------------------------------
17
+
18
+ Donate: http://support.kenney.nl
19
+ Patreon: http://patreon.com/kenney/
20
+
21
+ Follow on Twitter for updates:
22
+ http://twitter.com/KenneyNL
Binary file
Binary file
package/web/styles.css CHANGED
@@ -22,7 +22,8 @@
22
22
  --font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
- --icon-external: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
25
+ --icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
26
+ --icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
26
27
  --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
27
28
  --icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
28
29
  --icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
@@ -341,6 +342,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
341
342
  background: currentColor; mask: var(--icon-sun) center / contain no-repeat;
342
343
  }
343
344
  :root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
345
+ .appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
344
346
  .appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
345
347
  .appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
346
348
  .appearance-menu {