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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user