Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 54 additions & 186 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>; 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);
Expand Down Expand Up @@ -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 … */
104 changes: 94 additions & 10 deletions src/components/NotificationSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof getNotificationHistory> = [];

function useNotificationHistory() {
return useSyncExternalStore(
subscribeNotificationHistory,
getNotificationHistory,
() => EMPTY_HISTORY,
);
}

export function NotificationSettings() {
const { t } = useTranslation();
const prefs = useNotificationPreferences();
const history = useNotificationHistory();
const [permission, setPermission] = useState<NotificationPermissionState>("unsupported");

useEffect(() => {
Expand All @@ -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 (
<fieldset className="space-y-2 text-xs text-vx-muted">
<legend className="font-medium mb-1">{t("notifications.title")}</legend>
Expand Down Expand Up @@ -67,14 +97,28 @@ export function NotificationSettings() {
{t(permission === "denied" ? "notifications.permission.denied" : "notifications.permission.unsupported")}
</p>
)}
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={prefs.channels.sound}
onChange={(e) => prefs.update({ channels: { ...prefs.channels, sound: e.target.checked } })}
/>
{t("notifications.channel.sound")}
</label>
<div role="group" aria-label={t("notifications.channel.sound")} className="space-y-1">
<span className="block">{t("notifications.channel.sound")}</span>
<div className="flex flex-wrap gap-3">
{NOTIFIABLE_STATUSES.map((status) => (
<label key={status} className="flex items-center gap-1.5 capitalize">
<input
type="checkbox"
checked={prefs.channels.sound[status]}
onChange={(e) =>
prefs.update({
channels: {
...prefs.channels,
sound: { ...prefs.channels.sound, [status]: e.target.checked },
},
})
}
/>
{status}
</label>
))}
</div>
</div>
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={prefs.showDetails} onChange={(e) => prefs.update({ showDetails: e.target.checked })} />
{t("notifications.showDetails")}
Expand Down Expand Up @@ -105,6 +149,46 @@ export function NotificationSettings() {
))}
</select>
</label>

<button
type="button"
onClick={sendTestNotification}
className="rounded-md border border-vx-border bg-vx-surface px-2 py-1 text-sm text-vx-text hover:bg-vx-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-accent"
>
{t("notifications.test.button")}
</button>

<section aria-label={t("notifications.inbox.title")} className="pt-2 border-t border-vx-border space-y-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{t("notifications.inbox.title")}</h3>
{history.length > 0 && (
<button
type="button"
onClick={() => clearNotificationHistory()}
className="text-vx-dim hover:text-vx-text underline focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-accent rounded"
>
{t("notifications.inbox.clear")}
</button>
)}
</div>
{history.length === 0 ? (
<p className="text-vx-dim">{t("notifications.inbox.empty")}</p>
) : (
<ul className="space-y-1 max-h-48 overflow-y-auto">
{history.map((entry) => (
<li key={entry.id}>
<a
href={entry.href}
className="block rounded px-1 py-0.5 hover:bg-vx-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-accent"
>
<span className="text-vx-text">{entry.title}</span>
<span className="block text-vx-dim">{new Date(entry.timestamp).toLocaleString()}</span>
</a>
</li>
))}
</ul>
)}
</section>
</fieldset>
);
}
Loading
Loading