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
2 changes: 2 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default function App() {

return (
<AuthProvider>
<DemoBanner />
<NavBar dark={dark} onToggleDark={() => setDark((d) => !d)} />
<SkipToContent />
<DemoBanner/>
<NavBar dark={dark} onToggleDark={() => setDark(d => !d)} />
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/__tests__/darkMode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Dark mode tests — issue #277
* Covers:
* 1. useDarkMode sets data-theme="dark" on <html>
* 2. CSS variables resolve to values that pass WCAG AA contrast (4.5:1 for normal text)
*/

import { renderHook, act } from '@testing-library/react';
import { useDarkMode } from '../hooks/useDarkMode';

// ── helpers ──────────────────────────────────────────────────────────────────

/** sRGB channel linearisation */
function linearise(c) {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}

/** Relative luminance of an #rrggbb hex colour */
function luminance(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
}

/** WCAG contrast ratio between two hex colours */
function contrast(hex1, hex2) {
const l1 = luminance(hex1);
const l2 = luminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}

// ── useDarkMode ───────────────────────────────────────────────────────────────

describe('useDarkMode', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
// default: light preference
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockReturnValue({ matches: false }),
});
});

it('sets data-theme="dark" on documentElement when dark=true', () => {
const { result } = renderHook(() => useDarkMode());
act(() => result.current[1](true));
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});

it('removes data-theme attribute when dark=false', () => {
document.documentElement.setAttribute('data-theme', 'dark');
const { result } = renderHook(() => useDarkMode());
act(() => result.current[1](false));
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});

it('persists preference to localStorage', () => {
const { result } = renderHook(() => useDarkMode());
act(() => result.current[1](true));
expect(localStorage.getItem('darkMode')).toBe('true');
act(() => result.current[1](false));
expect(localStorage.getItem('darkMode')).toBe('false');
});

it('reads initial state from localStorage', () => {
localStorage.setItem('darkMode', 'true');
const { result } = renderHook(() => useDarkMode());
expect(result.current[0]).toBe(true);
});

it('falls back to prefers-color-scheme when no localStorage value', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
const { result } = renderHook(() => useDarkMode());
expect(result.current[0]).toBe(true);
});
});

// ── WCAG AA contrast checks ───────────────────────────────────────────────────
// Values taken directly from index.css token definitions.

describe('WCAG AA contrast — light mode tokens', () => {
const pairs = [
{ label: 'text on bg', fg: '#0f172a', bg: '#ffffff' },
{ label: 'text-muted on bg', fg: '#64748b', bg: '#ffffff' },
{ label: 'accent on bg', fg: '#0369a1', bg: '#ffffff' },
{ label: 'nav-text on nav-bg', fg: '#cbd5e1', bg: '#1e293b' },
{ label: 'error on white', fg: '#dc2626', bg: '#ffffff' },
{ label: 'success on white', fg: '#15803d', bg: '#ffffff' },
];

pairs.forEach(({ label, fg, bg }) => {
it(`${label} meets WCAG AA (≥4.5:1)`, () => {
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
});
});
});

describe('WCAG AA contrast — dark mode tokens', () => {
const pairs = [
{ label: 'text on bg', fg: '#e2e8f0', bg: '#0f172a' },
{ label: 'text-muted on bg', fg: '#94a3b8', bg: '#0f172a' },
{ label: 'accent on bg', fg: '#38bdf8', bg: '#0f172a' },
{ label: 'nav-text on nav-bg', fg: '#cbd5e1', bg: '#1e293b' },
{ label: 'error on dark bg', fg: '#f87171', bg: '#0f172a' },
{ label: 'success on dark bg', fg: '#4ade80', bg: '#0f172a' },
{ label: 'chart-bar on track', fg: '#0ea5e9', bg: '#1e293b' },
{ label: 'badge-high text/bg', fg: '#fca5a5', bg: '#7f1d1d' },
{ label: 'badge-medium text/bg',fg: '#fcd34d', bg: '#78350f' },
{ label: 'badge-low text/bg', fg: '#93c5fd', bg: '#1e3a5f' },
];

pairs.forEach(({ label, fg, bg }) => {
it(`${label} meets WCAG AA (≥4.5:1)`, () => {
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
});
});
});
4 changes: 2 additions & 2 deletions frontend/src/components/FreighterBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ export default function FreighterBanner() {

return (
<div style={{
background: '#7c3aed', color: '#fff', padding: '0.6rem 1.5rem',
background: 'var(--freighter-banner-bg)', color: '#fff', padding: '0.6rem 1.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
}}>
<span>
🦊 Freighter wallet not detected.{' '}
<a href={INSTALL_URL} target="_blank" rel="noreferrer" style={{ color: '#e9d5ff', fontWeight: 600 }}>
<a href={INSTALL_URL} target="_blank" rel="noreferrer" style={{ color: 'var(--freighter-banner-link)', fontWeight: 600 }}>
Install Freighter
</a>{' '}
to connect your wallet and issue or view records.
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/hooks/useDarkMode.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export function useDarkMode() {
});

useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('darkMode', dark);
}, [dark]);

Expand Down
76 changes: 74 additions & 2 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,18 @@

/* Semantic aliases for light mode */
--bg: #ffffff;
--surface: #f8fafc;
--surface-2: #f1f5f9;
--text: #0f172a;
--text-muted: #64748b;
--accent: #0284c7;
--btn-primary: #0284c7;
--accent: #0369a1;
--btn-primary: #0369a1;
--input-bg: #f1f5f9;
--border: #cbd5e1;
--nav-bg: #1e293b;
--nav-text: #cbd5e1;

/* Role badges */
--role-patient-bg: #e0f2fe;
--role-patient-text: #0284c7;
--role-patient-border: #7dd3fc;
Expand Down Expand Up @@ -158,6 +162,8 @@
.dark {
/* Semantic aliases for dark mode */
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #0f172a;
--text: #e2e8f0;
--text-muted: #94a3b8;
--accent: #38bdf8;
Expand All @@ -166,11 +172,77 @@
--border: #334155;
--nav-bg: #1e293b;
--nav-text: #cbd5e1;

/* Role badges */
--role-patient-bg: #0c2a4a;
--role-patient-text: #38bdf8;
--role-patient-border: #0ea5e9;
--role-issuer-bg: #052e16;
--role-issuer-text: #4ade80;
--role-issuer-border: #22c55e;

/* Semantic */
--color-error: #f87171;
--color-error-bg: rgba(248, 113, 113, 0.1);
--color-error-border: rgba(248, 113, 113, 0.2);
--color-success: #4ade80;
--color-success-bg: rgba(74, 222, 128, 0.1);
--color-success-border: rgba(74, 222, 128, 0.2);
--color-warning: #fbbf24;
--color-warning-bg: #78350f;
--color-warning-text: #fcd34d;
--color-info: #60a5fa;
--color-info-bg: rgba(96, 165, 250, 0.1);
--color-info-border: rgba(96, 165, 250, 0.2);
--color-muted: #94a3b8;
--color-muted-bg: rgba(148, 163, 184, 0.1);
--color-muted-border: rgba(148, 163, 184, 0.2);

/* Chart / data */
--chart-bar: #0ea5e9;
--chart-track: #1e293b;
--chart-label: #94a3b8;

/* Badge severity */
--badge-high-bg: #7f1d1d;
--badge-high-text: #fca5a5;
--badge-medium-bg: #78350f;
--badge-medium-text: #fcd34d;
--badge-low-bg: #1e3a5f;
--badge-low-text: #93c5fd;

/* Status badges */
--badge-active-bg: #14532d;
--badge-active-text: #86efac;
--badge-revoked-bg: #7f1d1d;
--badge-revoked-text: #fca5a5;
--badge-pending-bg: #78350f;
--badge-pending-text: #fde68a;
--badge-approved-bg: #14532d;
--badge-approved-text: #86efac;
--badge-rejected-bg: #7f1d1d;
--badge-rejected-text: #fca5a5;

/* Dose badges */
--dose-complete-bg: #166534;
--dose-complete-text: #86efac;
--dose-partial-bg: #1e3a5f;
--dose-partial-text: #93c5fd;

/* Modals / overlays */
--modal-bg: #1e293b;
--modal-border: #334155;
--overlay-bg: rgba(0, 0, 0, 0.7);

/* Misc */
--freighter-banner-bg: #7c3aed;
--freighter-banner-link: #e9d5ff;
--copy-btn-color: #64748b;
--copy-btn-hover: #38bdf8;
--copy-btn-success: #4ade80;
--copy-tooltip-bg: #1e293b;
--copy-tooltip-border: #334155;
--copy-tooltip-text: #4ade80;
--focus-ring: #38bdf8;
--focus-ring-offset: #0f172a;
}
Expand Down
34 changes: 27 additions & 7 deletions frontend/src/pages/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ import Tooltip from '../components/Tooltip';
const s = {
page: { maxWidth: 700, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' },
table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' },
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)', color: 'var(--text-muted)' },
td: { padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)', color: 'var(--text)', wordBreak: 'break-all' },
btn: { padding: '0.45rem 1rem', background: 'var(--btn-primary)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.9rem' },
btnDanger: { padding: '0.45rem 0.75rem', background: 'var(--color-error)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.85rem' },
btnSuccess: { padding: '0.45rem 0.75rem', background: 'var(--color-success)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
input: { padding: '0.5rem 0.75rem', background: 'var(--input-bg)', border: '1px solid var(--border)', borderRadius: 6, color: 'var(--text)', fontSize: '0.9rem', flex: 1 },
row: { display: 'flex', gap: '0.75rem', marginBottom: '1.5rem', alignItems: 'center' },
keyBox: { marginTop: '1rem', padding: '0.75rem 1rem', background: 'var(--color-success-bg)', border: '1px solid var(--color-success-border)', borderRadius: 8, color: 'var(--color-success)', fontSize: '0.85rem', wordBreak: 'break-all' },
badge: (revoked) => ({
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: 4, fontSize: '0.75rem',
background: revoked ? 'var(--badge-revoked-bg)' : 'var(--badge-active-bg)',
color: revoked ? 'var(--badge-revoked-text)' : 'var(--badge-active-text)',
}),
statusBadge: (status) => {
const map = {
pending: ['var(--badge-pending-bg)', 'var(--badge-pending-text)'],
approved: ['var(--badge-approved-bg)', 'var(--badge-approved-text)'],
rejected: ['var(--badge-rejected-bg)', 'var(--badge-rejected-text)'],
};
const [bg, color] = map[status] || ['var(--surface-2)', 'var(--text-muted)'];
return { display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: 4, fontSize: '0.75rem', background: bg, color };
th: { textAlign: 'left', padding: '0.75rem', borderBottom: '1px solid #334155', color: '#94a3b8' },
td: { padding: '0.75rem', borderBottom: '1px solid #1e293b', color: '#e2e8f0', wordBreak: 'break-all' },
btn: { padding: '0.6rem 1rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.9rem', minHeight: '44px', minWidth: '44px' },
Expand Down Expand Up @@ -57,14 +78,14 @@ export default function AdminDashboard() {
if (!publicKey) {
return (
<div style={s.page}>
<p style={{ color: '#94a3b8', marginBottom: '1rem' }}>Connect your admin wallet to manage API keys.</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>Connect your admin wallet to manage API keys.</p>
<button style={s.btn} onClick={connect}>Connect Wallet</button>
</div>
);
}

if (role !== 'issuer') {
return <div style={s.page}><p style={{ color: '#f87171' }}>Access denied: admin role required.</p></div>;
return <div style={s.page}><p style={{ color: 'var(--color-error)' }}>Access denied: admin role required.</p></div>;
}

const handleCreate = async (e) => {
Expand Down Expand Up @@ -149,7 +170,7 @@ export default function AdminDashboard() {
</button>
</form>

{error && <p style={{ color: '#f87171', marginBottom: '1rem' }}>{error}</p>}
{error && <p style={{ color: 'var(--color-error)', marginBottom: '1rem' }}>{error}</p>}

{newKey && (
<div style={s.keyBox} role="alert">
Expand All @@ -160,7 +181,7 @@ export default function AdminDashboard() {
)}

{keys.length === 0 ? (
<p style={{ color: '#64748b' }}>No API keys yet.</p>
<p style={{ color: 'var(--text-muted)' }}>No API keys yet.</p>
) : (
<table style={s.table} aria-label="API keys">
<thead>
Expand Down Expand Up @@ -196,12 +217,11 @@ export default function AdminDashboard() {
</table>
)}

{/* ── Issuer Onboarding Applications ── */}
<div style={s.section}>
<h3 style={s.h3}>Issuer Onboarding Applications</h3>
{reviewError && <p style={{ color: '#f87171', marginBottom: '0.75rem' }}>{reviewError}</p>}
{reviewError && <p style={{ color: 'var(--color-error)', marginBottom: '0.75rem' }}>{reviewError}</p>}
{applications.length === 0 ? (
<p style={{ color: '#64748b' }}>No applications yet.</p>
<p style={{ color: 'var(--text-muted)' }}>No applications yet.</p>
) : (
<table style={s.table} aria-label="Issuer applications">
<thead>
Expand Down
Loading
Loading