feat(ui): Orca Dark / Orca Light themes and notification sounds
Two optional named themes from Orca's neutral palette join the Appearance picker (Orca Dark keeps a blue accent so white-on-accent buttons stay legible; Orca Light's status colours are darkened to clear 4.5:1 on their chips). A shown notification can play a sound — None (default), Chime, Pop or Alert — chosen globally and per notification type in Settings → Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file notifPrefs.test.ts
|
||||
* @description Notification prefs: the defaults (sound "none"), a blob stored
|
||||
* before sounds existed loading with sound "none", `soundByType` surviving a
|
||||
* save/load round trip, and `soundFor` preferring a type's override.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
NOTIF_KEY,
|
||||
defaultNotifPrefs,
|
||||
loadNotifPrefs,
|
||||
saveNotifPrefs,
|
||||
soundFor,
|
||||
} from "../notifPrefs";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("notifPrefs", () => {
|
||||
it("defaults to no sound", () => {
|
||||
expect(loadNotifPrefs()).toEqual(defaultNotifPrefs);
|
||||
expect(defaultNotifPrefs.sound).toBe("none");
|
||||
});
|
||||
|
||||
it("loads a blob written before sounds existed with sound none", () => {
|
||||
localStorage.setItem(NOTIF_KEY, JSON.stringify({ enabled: true, onNewSession: true }));
|
||||
const prefs = loadNotifPrefs();
|
||||
expect(prefs.sound).toBe("none");
|
||||
expect(prefs.enabled).toBe(true);
|
||||
expect(prefs.soundByType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips soundByType and resolves a type's sound", () => {
|
||||
saveNotifPrefs({
|
||||
...defaultNotifPrefs,
|
||||
sound: "chime",
|
||||
soundByType: { onTurnComplete: "pop" },
|
||||
});
|
||||
const prefs = loadNotifPrefs();
|
||||
expect(prefs.soundByType).toEqual({ onTurnComplete: "pop" });
|
||||
expect(soundFor(prefs, "onTurnComplete")).toBe("pop");
|
||||
expect(soundFor(prefs, "onSessionError")).toBe("chime");
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,26 @@
|
||||
* the defaults, and the `localStorage` read/write. Settings (which edits them)
|
||||
* and `useNotifications` (which obeys them) both import from here; they used to
|
||||
* carry private copies, so adding a notification type meant editing the same
|
||||
* literal twice and a miss went unnoticed until a toggle did nothing.
|
||||
* literal twice and a miss went unnoticed until a toggle did nothing. A
|
||||
* shown notification also plays a sound: `sound` is the default, and
|
||||
* `soundByType` may override it per notification type (`soundFor`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
export const NOTIF_SOUNDS = ["none", "chime", "pop", "alert"] as const;
|
||||
export type NotifSound = (typeof NOTIF_SOUNDS)[number];
|
||||
|
||||
/** The per-type flags — every key a notification is gated on. */
|
||||
export type NotifType =
|
||||
| "onNewSession"
|
||||
| "onSessionError"
|
||||
| "onAwaitingInput"
|
||||
| "onTurnComplete"
|
||||
| "onSessionComplete"
|
||||
| "onSubagentSpawn";
|
||||
|
||||
export interface NotifPrefs {
|
||||
/** Master switch; when false no notification fires, whatever the flags say. */
|
||||
enabled: boolean;
|
||||
@@ -21,6 +35,9 @@ export interface NotifPrefs {
|
||||
/** The whole session closed (`SessionEnd`), not just one turn. */
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
/** The sound a shown notification plays, unless its type overrides it. */
|
||||
sound: NotifSound;
|
||||
soundByType?: Partial<Record<NotifType, NotifSound>>;
|
||||
}
|
||||
|
||||
// The two "Claude is waiting for you" types default ON: they are the reason
|
||||
@@ -34,8 +51,14 @@ export const defaultNotifPrefs: NotifPrefs = {
|
||||
onTurnComplete: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
sound: "none",
|
||||
};
|
||||
|
||||
/** The sound for one notification type: its override, else the default. */
|
||||
export function soundFor(prefs: NotifPrefs, type: NotifType): NotifSound {
|
||||
return prefs.soundByType?.[type] ?? prefs.sound;
|
||||
}
|
||||
|
||||
/** Reads prefs, merged over the defaults so a stored blob written by an older
|
||||
* build (missing the newer flags) still yields a complete object. */
|
||||
export function loadNotifPrefs(): NotifPrefs {
|
||||
|
||||
@@ -2,10 +2,29 @@
|
||||
* @file Shows a browser notification — the single `notify()` both
|
||||
* `useNotifications` (live events) and Settings' "Send Test Notification"
|
||||
* button call, so the test proves the real delivery path and can report
|
||||
* where it actually landed instead of a bare fire-and-forget.
|
||||
* where it actually landed instead of a bare fire-and-forget. `playSound`
|
||||
* plays one of the generated `public/sounds/*.wav` (see
|
||||
* `scripts/gen-notification-sounds.js`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { NotifSound } from "./notifPrefs";
|
||||
|
||||
const audio = new Map<NotifSound, HTMLAudioElement>();
|
||||
|
||||
/** Plays a notification sound from the start; "none" plays nothing. A browser
|
||||
* that refuses autoplay before a user gesture is left to refuse quietly. */
|
||||
export function playSound(kind: NotifSound): void {
|
||||
if (kind === "none") return;
|
||||
let el = audio.get(kind);
|
||||
if (!el) {
|
||||
el = new Audio(`/sounds/${kind}.wav`);
|
||||
audio.set(kind, el);
|
||||
}
|
||||
el.currentTime = 0;
|
||||
void el.play()?.catch(() => {});
|
||||
}
|
||||
|
||||
export type NotifyVia = "native" | "push" | "local" | "blocked" | "none";
|
||||
|
||||
export interface NotifyError {
|
||||
|
||||
Reference in New Issue
Block a user