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:
2026-10-01 17:10:46 +07:00
parent eb90244182
commit b71024f244
20 changed files with 487 additions and 55 deletions
@@ -3,7 +3,8 @@
* browser notification, and which preference gates each one. Covers issue #29:
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
* box once notifications are enabled, since those are the two moments the user
* is the blocker.
* is the blocker. Also: a shown notification plays its type's sound, else the
* default one, and "none" plays nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -104,6 +105,46 @@ describe("useNotifications", () => {
expect(Notification).not.toHaveBeenCalled();
});
it("plays the type's own sound, else the default, and nothing for none", async () => {
const played: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
this: HTMLMediaElement
) {
played.push(this.src.split("/").pop()!);
return Promise.resolve();
});
// The hook's own lane list must read as a list, or a later event finds none.
fetchMock.mockImplementation(async (url: string) => ({
ok: true,
json: async () =>
url === "/api/push/send"
? { ok: true, native: true, pushed: 1 }
: { lanes: [], counts: {} },
}));
enable({ sound: "chime", soundByType: { onTurnComplete: "pop", onAwaitingInput: "none" } });
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(played).toEqual(["pop.wav"]));
emit("Notification", "needs permission");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
await new Promise((r) => setTimeout(r, 20));
expect(played).toEqual(["pop.wav"]);
localStorage.setItem(
NOTIF_KEY,
JSON.stringify({
...defaultNotifPrefs,
enabled: true,
sound: "chime",
onSessionComplete: true,
})
);
emit("SessionEnd", "Closed");
await vi.waitFor(() => expect(played).toEqual(["pop.wav", "chime.wav"]));
vi.restoreAllMocks();
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());