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
+90 -33
View File
@@ -71,6 +71,7 @@ import {
FileDown,
Eraser,
Zap,
Play,
GitBranch,
ShieldCheck,
ShieldAlert,
@@ -91,16 +92,37 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
import { notify } from "../lib/notify";
import {
loadNotifPrefs,
saveNotifPrefs,
NOTIF_SOUNDS,
type NotifPrefs,
type NotifSound,
type NotifType,
} from "../lib/notifPrefs";
import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { SettingsSwitchRow } from "../components/settings/SettingsRow";
import {
SettingsRow,
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { WSMessage } from "../lib/types";
// The per-type notification switches, in the order Settings lists them.
const NOTIF_TYPES: [NotifType, string][] = [
["onAwaitingInput", "awaitingInput"],
["onNewSession", "newSession"],
["onTurnComplete", "turnComplete"],
["onSessionComplete", "sessionComplete"],
["onSessionError", "sessionError"],
["onSubagentSpawn", "subagentSpawned"],
];
// ─── Helpers ───
interface SystemInfo {
@@ -582,40 +604,75 @@ export function Settings({
{notifPrefs.enabled && (
<div className="space-y-3 pt-4 border-t border-border">
<SettingsRow
label={t("notifications.sound.title")}
description={t("notifications.sound.description")}
control={
<div className="flex items-center gap-2">
<select
aria-label={t("notifications.sound.title")}
value={notifPrefs.sound}
onChange={(e) =>
updateNotifPrefs({ sound: e.target.value as NotifSound })
}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary"
>
{NOTIF_SOUNDS.map((k) => (
<option key={k} value={k}>
{t(`notifications.sound.${k}`)}
</option>
))}
</select>
<button
type="button"
onClick={() => playSound(notifPrefs.sound)}
disabled={notifPrefs.sound === "none"}
className="btn-ghost text-xs disabled:opacity-50"
>
<Play className="w-3 h-3" />
{t("notifications.sound.preview")}
</button>
</div>
}
/>
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold">
{t("notifications.notifyWhen")}
</p>
<div>
<SettingsSwitchRow
checked={notifPrefs.onAwaitingInput}
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
label={t("notifications.awaitingInput")}
/>
<SettingsSwitchRow
checked={notifPrefs.onNewSession}
onChange={(v) => updateNotifPrefs({ onNewSession: v })}
label={t("notifications.newSession")}
/>
<SettingsSwitchRow
checked={notifPrefs.onTurnComplete}
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
label={t("notifications.turnComplete")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSessionComplete}
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
label={t("notifications.sessionComplete")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSessionError}
onChange={(v) => updateNotifPrefs({ onSessionError: v })}
label={t("notifications.sessionError")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSubagentSpawn}
onChange={(v) => updateNotifPrefs({ onSubagentSpawn: v })}
label={t("notifications.subagentSpawned")}
/>
{NOTIF_TYPES.map(([type, labelKey]) => (
<div key={type} className="border-b border-border/60 last:border-b-0">
<SettingsSwitchRow
checked={notifPrefs[type]}
onChange={(v) => updateNotifPrefs({ [type]: v })}
label={t(`notifications.${labelKey}`)}
/>
{notifPrefs[type] && (
<div className="pb-2">
<SettingsSegmented
ariaLabel={`${t(`notifications.${labelKey}`)} — ${t("notifications.sound.forType")}`}
value={notifPrefs.soundByType?.[type] ?? "default"}
onChange={(v) => {
const next = { ...notifPrefs.soundByType };
if (v === "default") delete next[type];
else next[type] = v;
updateNotifPrefs({ soundByType: next });
if (v !== "default") playSound(v);
}}
options={[
{
value: "default" as const,
label: t("notifications.sound.useDefault"),
},
...NOTIF_SOUNDS.map((k) => ({
value: k,
label: t(`notifications.sound.${k}`),
})),
]}
/>
</div>
)}
</div>
))}
</div>
<div className="pt-3 border-t border-border">