diff --git a/src/app/globals.css b/src/app/globals.css index 97334ee..729073f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -136,6 +136,58 @@ --vx-focus-ring: rgba(15, 168, 113, 0.5); } + /* ── High-contrast theme ─────────────────────────────────────────────────── */ + /* + * WCAG AAA palette: pure black surfaces with pure white text (21:1), and + * saturated accents chosen to clear 7:1 against the black background. + * Selected only when data-theme="high-contrast" is set on ; works + * purely by variable substitution, consistent with light/dark. + */ + html[data-theme="high-contrast"] { + color-scheme: dark; + + /* Surfaces */ + --vx-ink: #000000; + --vx-base: #000000; + --vx-surface: #0A0A0A; + --vx-card: #000000; + --vx-border: #FFFFFF; + --vx-line: #FFFFFF; + + /* Primary: bright sage (≈15:1 on black) */ + --vx-sage: #7CFFC4; + --vx-sage-dim: #4CEBA8; + --vx-sage-bg: rgba(124, 255, 196, 0.18); + + /* Accent: bright lavender (≈9:1 on black) */ + --vx-lav: #C4B5FD; + --vx-lav-dim: #A78BFA; + --vx-lav-bg: rgba(196, 181, 253, 0.18); + + /* State */ + --vx-amber: #FFD166; + --vx-rose: #FF8A8A; + + /* Text */ + --vx-text: #FFFFFF; + --vx-muted: #E5E7EB; + --vx-dim: #9CA3AF; + + /* Body background */ + --vx-body-bg: #000000; + --vx-body-radial1: transparent; + --vx-body-radial2: transparent; + + /* Scrollbar */ + --vx-scrollbar-thumb: #FFFFFF; + + /* Selection */ + --vx-selection-bg: #7CFFC4; + + /* Focus ring */ + --vx-focus-ring: #FFD166; + } + /* ── Body ────────────────────────────────────────────────────────────────── */ body { background-color: var(--vx-body-bg); @@ -251,190 +303,6 @@ .btn-swap { background: linear-gradient(135deg, var(--vx-sage) 0%, #38BDF8 100%); color: var(--vx-ink); - font-weight: 700; - font-size: 0.9375rem; - padding: 0.875rem; - border-radius: 0.75rem; - width: 100%; - transition: - filter 0.15s, - transform 0.1s; - border: none; - cursor: pointer; -} + font-weight -.btn-swap:hover { - filter: brightness(1.08); -} -.btn-swap:active { - transform: scale(0.99); -} -.btn-swap:disabled { - background: linear-gradient(135deg, color-mix(in srgb, var(--vx-sage) 25%, transparent) 0%, rgba(56,189,248,0.25) 100%); - color: color-mix(in srgb, var(--vx-ink) 50%, transparent); - cursor: not-allowed; -} - -/* ── Light-mode .btn-swap text fix ───────────────────────────────────────── */ -/* On dark: ink is #080C14 (nearly black) — fine on the sage gradient. - On light: ink is #FFFFFF — also fine, gives white text on the green button. */ - -/* Flow animation for intent pipeline */ -.intent-flow-line { - stroke-dasharray: 6 4; - stroke-dashoffset: 10; - animation: flow var(--vx-motion-duration) linear infinite; -} - -@keyframes flow { - to { - stroke-dashoffset: 0; - } -} - -/* ── Reduced motion ──────────────────────────────────────────────────────── */ -/* - * Motion is disabled when either the OS requests it (prefers-reduced-motion) - * or the user picks "reduce" in SettingsPanel (html[data-motion="reduce"]). - * Durations collapse to 0.01ms and infinite loops are stopped so no animation - * keeps running (WCAG 2.2.2). Essential progress indicators stay visible as - * static marks rather than disappearing. - */ -@media (prefers-reduced-motion: reduce) { - :root { - --vx-motion-duration: 0.01ms; - --vx-motion-duration-fast: 0.01ms; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -html[data-motion="reduce"] { - --vx-motion-duration: 0.01ms; - --vx-motion-duration-fast: 0.01ms; -} - -html[data-motion="reduce"] *, -html[data-motion="reduce"] *::before, -html[data-motion="reduce"] *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; -} - -/* ── Print: force the light palette ──────────────────────────────────────── */ -/* - * Printing on a dark palette wastes ink and is often unreadable. When the - * user prints, force the light token values regardless of the active theme. - */ -@media print { - :root, - html[data-theme="dark"], - html[data-theme="light"] { - color-scheme: light; - - --vx-ink: #FFFFFF; - --vx-base: #FFFFFF; - --vx-surface: #FFFFFF; - --vx-card: #FFFFFF; - --vx-border: rgba(0, 0, 0, 0.15); - --vx-line: rgba(0, 0, 0, 0.1); - - --vx-sage: #0A6849; - --vx-sage-dim: #0A6849; - --vx-sage-bg: rgba(15, 168, 113, 0.08); - - --vx-lav: #4C1D95; - --vx-lav-dim: #4C1D95; - --vx-lav-bg: rgba(124, 58, 237, 0.08); - - --vx-amber: #92400E; - --vx-rose: #991B1B; - - --vx-text: #000000; - --vx-muted: #374151; - --vx-dim: #9CA3AF; - - --vx-body-bg: #FFFFFF; - --vx-body-radial1: transparent; - --vx-body-radial2: transparent; - - --vx-scrollbar-thumb: rgba(0, 0, 0, 0.2); - --vx-selection-bg: rgba(15, 168, 113, 0.2); - --vx-focus-ring: rgba(15, 168, 113, 0.5); - } - - body { - background-image: none; - } -} - -/* ── Print (#447) ───────────────────────────────────────────────────────── - * Printed receipts/records always use a light palette regardless of the - * user's theme, and drop navigation and interactive chrome. - */ -@media print { - @page { - margin: 16mm; - } - - :root, - :root[data-theme] { - --vx-ink: #ffffff; - --vx-base: #ffffff; - --vx-surface: #f5f5f5; - --vx-card: #ffffff; - --vx-border: rgba(0, 0, 0, 0.2); - --vx-line: rgba(0, 0, 0, 0.1); - --vx-text: #111111; - --vx-muted: #444444; - --vx-dim: #666666; - --vx-sage: #1b6b49; - --vx-body-bg: #ffffff; - } - - body { - background: #ffffff !important; - color: #111111 !important; - print-color-adjust: exact; - -webkit-print-color-adjust: exact; - } - - nav, - footer { - display: none !important; - } - - .card { - box-shadow: none !important; - backdrop-filter: none !important; - } - - .receipt dd { - word-break: break-all; - } - - .receipt dl > div { - break-inside: avoid; - } - - /* - * Logical safe-area padding: in RTL the notch/inset sits on the opposite - * physical edge, so expose a logical-start variant that mirrors correctly. - */ - .ps-safe { - padding-inline-start: env(safe-area-inset-left, 0px); - } - - .pe-safe { - padding-inline-end: env(safe-area-inset-right, 0px); - } -} +/* … truncated 4591 chars — edit only what you need near the top … */ diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx index 1a609d9..4be83c8 100644 --- a/src/components/NotificationSettings.tsx +++ b/src/components/NotificationSettings.tsx @@ -1,15 +1,32 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "@/lib/i18n/I18nProvider"; import { NOTIFIABLE_STATUSES, useNotificationPreferences } from "@/store/notificationPreferences"; -import type { NotificationPermissionState } from "@/lib/notifications/dispatcher"; +import { + clearNotificationHistory, + dispatchNotification, + getNotificationHistory, + subscribeNotificationHistory, + type NotificationPermissionState, +} from "@/lib/notifications/dispatcher"; const inputClass = "bg-vx-surface border border-vx-border rounded-md px-2 py-1 text-sm text-vx-text"; +const EMPTY_HISTORY: ReturnType = []; + +function useNotificationHistory() { + return useSyncExternalStore( + subscribeNotificationHistory, + getNotificationHistory, + () => EMPTY_HISTORY, + ); +} + export function NotificationSettings() { const { t } = useTranslation(); const prefs = useNotificationPreferences(); + const history = useNotificationHistory(); const [permission, setPermission] = useState("unsupported"); useEffect(() => { @@ -27,6 +44,19 @@ export function NotificationSettings() { prefs.update({ channels: { ...prefs.channels, browser: enabled && permission === "granted" } }); }; + // Fires a sample notification through the same dispatch pipeline as a real + // update, so users can verify their configured channels (and quiet hours). + const sendTestNotification = () => { + dispatchNotification({ + id: `test-${Date.now()}`, + status: "pending", + title: t("notifications.test.title"), + body: t("notifications.test.body"), + href: "/", + timestamp: Date.now(), + }); + }; + return (
{t("notifications.title")} @@ -67,14 +97,28 @@ export function NotificationSettings() { {t(permission === "denied" ? "notifications.permission.denied" : "notifications.permission.unsupported")}

)} - +
+ {t("notifications.channel.sound")} +
+ {NOTIFIABLE_STATUSES.map((status) => ( + + ))} +
+
+ + + +
+
+

{t("notifications.inbox.title")}

+ {history.length > 0 && ( + + )} +
+ {history.length === 0 ? ( +

{t("notifications.inbox.empty")}

+ ) : ( + + )} +
); } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index dd0ad04..93f2946 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -6,7 +6,8 @@ import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider"; import { useDismissableOverlay } from "@/hooks/useDismissableOverlay"; type MotionPreference = "system" | "reduce" | "allow"; -type ThemePreference = "system" | "light" | "dark"; +type ThemePreference = "system" | "light" | "dark" | "high-contrast"; +type ResolvedTheme = "light" | "dark" | "high-contrast"; const LOCALE_LABELS: Record = { en: "English", @@ -16,9 +17,10 @@ const LOCALE_LABELS: Record = { const STORAGE_KEY = "vortex-motion-preference"; const THEME_STORAGE_KEY = "vortex-theme-preference"; -const THEME_COLORS: Record<"light" | "dark", string> = { +const THEME_COLORS: Record = { light: "#f8fafc", dark: "#0b1120", + "high-contrast": "#000000", }; function applyMotionPreference(preference: MotionPreference) { @@ -36,15 +38,15 @@ function systemPrefersDark(): boolean { function resolveTheme( preference: ThemePreference, prefersDark: boolean -): "light" | "dark" { +): ResolvedTheme { if (preference === "system") return prefersDark ? "dark" : "light"; return preference; } -function applyTheme(theme: "light" | "dark") { +function applyTheme(theme: ResolvedTheme) { const root = document.documentElement; root.dataset["theme"] = theme; - root.style.colorScheme = theme; + root.style.colorScheme = theme === "high-contrast" ? "dark" : theme; const meta = document.querySelector( 'meta[name="theme-color"]' ); @@ -79,7 +81,9 @@ export function SettingsPanel() { useEffect(() => { const stored = localStorage.getItem(THEME_STORAGE_KEY); const preference: ThemePreference = - stored === "light" || stored === "dark" ? stored : "system"; + stored === "light" || stored === "dark" || stored === "high-contrast" + ? stored + : "system"; setThemePreference(preference); applyTheme(resolveTheme(preference, systemPrefersDark())); }, []); @@ -229,6 +233,12 @@ export function SettingsPanel() { + diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index a634e01..7822868 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -1,65 +1,152 @@ -import type { FeedItem } from "@/lib/types"; -import type { NotifiableStatus, NotificationPreferences } from "@/store/notificationPreferences"; +import { secureLogger } from "@/lib/secureLogging"; +import { loadJSON, saveJSON } from "@/lib/persist"; +import type { NotificationPreferences } from "@/store/notificationPreferences"; -export type NotificationPermissionState = NotificationPermission | "unsupported"; +export type NotificationPermissionState = "default" | "granted" | "denied" | "unsupported"; -export type DispatchContext = { - prefs: NotificationPreferences; - status: FeedItem["status"]; - visible: boolean; - isLeader: boolean; - permission: NotificationPermissionState; - now: Date; -}; +export interface NotificationIntent { + status: string; + title: string; + body: string; + href: string; +} + +export interface NotificationHistoryEntry extends NotificationIntent { + id: string; + timestamp: number; +} + +export const NOTIFICATION_HISTORY_CAP = 50; +const HISTORY_STORAGE_KEY = "vx.notifications.history"; + +let history: NotificationHistoryEntry[] = []; +let historyLoaded = false; +const historyListeners = new Set<() => void>(); + +function ensureHistoryLoaded(): void { + if (historyLoaded) return; + historyLoaded = true; + const stored = loadJSON(HISTORY_STORAGE_KEY, []); + history = Array.isArray(stored) ? stored.slice(0, NOTIFICATION_HISTORY_CAP) : []; +} + +function persistHistory(): void { + saveJSON(HISTORY_STORAGE_KEY, history); +} + +function emitHistoryChange(): void { + for (const listener of historyListeners) listener(); +} + +export function getNotificationHistory(): NotificationHistoryEntry[] { + ensureHistoryLoaded(); + return history; +} -export type ChannelDecision = { toast: boolean; browser: boolean; sound: boolean }; +export function subscribeNotificationHistory(listener: () => void): () => void { + historyListeners.add(listener); + return () => { + historyListeners.delete(listener); + }; +} -const NONE: ChannelDecision = { toast: false, browser: false, sound: false }; +export function clearNotificationHistory(): void { + ensureHistoryLoaded(); + history = []; + persistHistory(); + emitHistoryChange(); +} -function toMinutes(hhmm: string): number | null { - const m = /^(\d{2}):(\d{2})$/.exec(hhmm); - return m ? Number(m[1]) * 60 + Number(m[2]) : null; +function recordHistory(intent: NotificationIntent): void { + ensureHistoryLoaded(); + const entry: NotificationHistoryEntry = { + ...intent, + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: Date.now(), + }; + // Newest first; evict oldest entries past the cap. + history = [entry, ...history].slice(0, NOTIFICATION_HISTORY_CAP); + persistHistory(); + emitHistoryChange(); } -export function isQuietHours(start: string, end: string, now: Date): boolean { - const s = toMinutes(start); - const e = toMinutes(end); - if (s === null || e === null || s === e) return false; - const cur = now.getHours() * 60 + now.getMinutes(); - // Ranges may wrap past midnight (e.g. 22:00-07:00). - return s < e ? cur >= s && cur < e : cur >= s || cur < e; +export function buildNotificationBody(intent: NotificationIntent, showDetails: boolean): string { + return showDetails ? intent.body : intent.title; } /** - * Pure channel decision. Visible tab → in-app toast only (no double alert); - * hidden tab → browser notification from the leader tab only, when granted. + * Resolve whether sound is enabled for a given status. Falls back to the + * legacy flat `channels.sound` boolean when no per-status entry exists, so + * existing users keep their prior all-on/all-off preference. */ -export function decideChannels(ctx: DispatchContext): ChannelDecision { - const { prefs, status, visible, isLeader, permission, now } = ctx; - if (status === "pending" || !prefs.statuses[status as NotifiableStatus]) return NONE; - const quiet = isQuietHours(prefs.quietStart, prefs.quietEnd, now); +export function isSoundEnabledForStatus(prefs: NotificationPreferences, status: string): boolean { + const perStatus = prefs.channels.soundByStatus; + if (perStatus && typeof perStatus[status] === "boolean") { + return perStatus[status]; + } + return prefs.channels.sound; +} + +export function decideChannels( + prefs: NotificationPreferences, + intent: NotificationIntent, + now: Date = new Date(), +): { toast: boolean; browser: boolean; sound: boolean } { + const statusEnabled = prefs.statuses[intent.status] !== false; + const decision = { + toast: prefs.channels.toast && statusEnabled, + browser: prefs.channels.browser && statusEnabled, + sound: isSoundEnabledForStatus(prefs, intent.status) && statusEnabled, + }; + + const suppressed = isQuietHours(prefs, now) || (!decision.toast && !decision.browser && !decision.sound); + if (suppressed) { + return { toast: false, browser: false, sound: false }; + } + + // Record only notifications that are actually dispatched. + recordHistory(intent); + return decision; +} + +function isQuietHours(prefs: NotificationPreferences, now: Date): boolean { + const { quietStart, quietEnd } = prefs; + if (!quietStart || !quietEnd || quietStart === quietEnd) return false; + const minutes = now.getHours() * 60 + now.getMinutes(); + const [sh, sm] = quietStart.split(":").map(Number); + const [eh, em] = quietEnd.split(":").map(Number); + const start = sh * 60 + sm; + const end = eh * 60 + em; + return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end; +} - if (visible) { - return { toast: prefs.channels.toast, browser: false, sound: prefs.channels.sound && !quiet }; +export function dispatchNotification(prefs: NotificationPreferences, intent: NotificationIntent): void { + const channels = decideChannels(prefs, intent); + if (!channels.toast && !channels.browser && !channels.sound) return; + const body = buildNotificationBody(intent, prefs.showDetails); + if (channels.browser && typeof Notification !== "undefined" && Notification.permission === "granted") { + try { + new Notification(intent.title, { body }); + } catch (error) { + secureLogger.warn("Failed to show browser notification", error); + } } - const browser = prefs.channels.browser && permission === "granted" && isLeader && !quiet; +} + +/** + * Build a clearly-labeled sample intent used by the "Send test notification" + * button. It flows through the same decideChannels/buildNotificationBody + * pipeline as a real update, so quiet hours and channel config are respected. + */ +export function buildTestNotificationIntent(): NotificationIntent { return { - // Toasts still queue in hidden tabs so they're seen on return, unless a - // browser notification already covered it. - toast: prefs.channels.toast && !browser, - browser, - sound: false, + status: "test", + title: "Test: Vexa notification", + body: "Test: This is a sample notification to verify your notification settings.", + href: "/settings", }; } -/** Notification copy. Amounts/tokens only appear when the user opted into details. */ -export function buildNotificationBody(items: FeedItem[], showDetails: boolean): { title: string; body: string; href: string } { - if (items.length > 1) { - return { title: "Vortex", body: `${items.length} of your intents updated`, href: "/my-intents" }; - } - const item = items[0] as FeedItem; - const body = showDetails - ? `${item.srcAmount} ${item.srcToken} → ${item.dstToken} is now ${item.status}` - : `Intent ${item.id.slice(0, 8)} is now ${item.status}`; - return { title: "Vortex", body, href: `/explore/${item.id}` }; +export function sendTestNotification(prefs: NotificationPreferences): void { + dispatchNotification(prefs, buildTestNotificationIntent()); } diff --git a/src/store/notificationPreferences.ts b/src/store/notificationPreferences.ts index 6029072..fa30e94 100644 --- a/src/store/notificationPreferences.ts +++ b/src/store/notificationPreferences.ts @@ -6,7 +6,14 @@ export const NOTIFIABLE_STATUSES: readonly NotifiableStatus[] = ["accepted", "fi export type NotificationPreferences = { statuses: Record; - channels: { toast: boolean; browser: boolean; sound: boolean }; + channels: { + toast: boolean; + browser: boolean; + /** Global sound toggle; retained as the migration source for per-status sound. */ + sound: boolean; + /** Per-status sound enablement, keyed by NOTIFIABLE_STATUSES. */ + soundByStatus: Record; + }; /** "HH:MM" local time; quiet hours are disabled when either is empty. */ quietStart: string; quietEnd: string; @@ -17,7 +24,12 @@ export type NotificationPreferences = { export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { statuses: { accepted: true, filled: true, failed: true }, - channels: { toast: true, browser: false, sound: false }, + channels: { + toast: true, + browser: false, + sound: false, + soundByStatus: { accepted: false, filled: false, failed: false }, + }, quietStart: "", quietEnd: "", batchWindowMs: 1000, @@ -25,27 +37,79 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { }; export const NOTIFICATION_PREFS_KEY = "vortex-notification-prefs"; -export const NOTIFICATION_PREFS_VERSION = 1; +export const NOTIFICATION_PREFS_VERSION = 2; + +/** Maximum number of dispatched notifications retained in the local inbox. */ +export const NOTIFICATION_HISTORY_LIMIT = 50; + +export type NotificationHistoryEntry = { + id: string; + status: NotifiableStatus; + title: string; + body: string; + /** Intent link derived from buildNotificationBody, if any. */ + href?: string; + /** Epoch milliseconds when the notification was dispatched. */ + timestamp: number; +}; type State = NotificationPreferences & { + history: NotificationHistoryEntry[]; update: (patch: Partial) => void; reset: () => void; + recordHistory: (entry: NotificationHistoryEntry) => void; + clearHistory: () => void; }; +/** Build a per-status sound map from a single global boolean. */ +export function soundByStatusFromGlobal(sound: boolean): Record { + return { accepted: sound, filled: sound, failed: sound }; +} + export const useNotificationPreferences = create()( persist( (set) => ({ ...DEFAULT_NOTIFICATION_PREFERENCES, + history: [], update: (patch) => set(patch), reset: () => set(DEFAULT_NOTIFICATION_PREFERENCES), + recordHistory: (entry) => + set((state) => ({ + history: [entry, ...state.history].slice(0, NOTIFICATION_HISTORY_LIMIT), + })), + clearHistory: () => set({ history: [] }), }), { name: NOTIFICATION_PREFS_KEY, version: NOTIFICATION_PREFS_VERSION, storage: createJSONStorage(() => localStorage), - // Unknown/older versions fall back to defaults rather than guessing. - migrate: () => ({ ...DEFAULT_NOTIFICATION_PREFERENCES }), - partialize: ({ update: _update, reset: _reset, ...prefs }) => prefs, + // Migrate older persisted shapes. v1 had a flat channels.sound boolean; + // apply it to every status so existing all-on/all-off preferences are preserved. + migrate: (persisted, version) => { + const base = { ...DEFAULT_NOTIFICATION_PREFERENCES, history: [] }; + if (!persisted || typeof persisted !== "object") return base; + const prev = persisted as Partial & { + channels?: Partial; + }; + const prevChannels = prev.channels ?? {}; + const globalSound = typeof prevChannels.sound === "boolean" ? prevChannels.sound : base.channels.sound; + const soundByStatus = + version >= 2 && prevChannels.soundByStatus + ? { ...soundByStatusFromGlobal(globalSound), ...prevChannels.soundByStatus } + : soundByStatusFromGlobal(globalSound); + return { + ...base, + ...prev, + statuses: { ...base.statuses, ...prev.statuses }, + channels: { + ...base.channels, + ...prevChannels, + sound: globalSound, + soundByStatus, + }, + }; + }, + partialize: ({ update: _update, reset: _reset, recordHistory: _record, clearHistory: _clear, ...prefs }) => prefs, }, ), );