diff --git a/client/components/ui/save-status.jsx b/client/components/ui/save-status.jsx new file mode 100644 index 0000000..a527a20 --- /dev/null +++ b/client/components/ui/save-status.jsx @@ -0,0 +1,38 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import { Check, CloudUpload, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const STATES = { + saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' }, + saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' }, + error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' }, +}; + +/** + * Tiny animated pill that reflects auto-save state. Renders nothing while idle — + * the page communicates "changes save automatically" once, statically. + */ +export function SaveStatus({ status, className }) { + const state = STATES[status]; + return ( + + {state && ( + + + {state.text} + + )} + + ); +} diff --git a/client/hooks/useAutoSave.js b/client/hooks/useAutoSave.js new file mode 100644 index 0000000..8e944b8 --- /dev/null +++ b/client/hooks/useAutoSave.js @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Debounced auto-save with a status the UI can render. + * + * const { status, schedule, flush } = useAutoSave(payload => api.save(payload)); + * - schedule(payload[, delayMs]) — (re)arms the debounce with the latest payload + * - flush() — saves any pending payload immediately (e.g. on blur) + * - status — 'idle' | 'saving' | 'saved' | 'error' ('saved' fades to 'idle') + * + * Pending changes are flushed on unmount so navigating away never loses edits. + */ +export function useAutoSave(saveFn, defaultDelay = 400) { + const [status, setStatus] = useState('idle'); + const timer = useRef(null); + const pending = useRef(null); + const saveRef = useRef(saveFn); + saveRef.current = saveFn; + + const run = useCallback(async (payload) => { + pending.current = null; + setStatus('saving'); + try { + await saveRef.current(payload); + setStatus('saved'); + } catch { + setStatus('error'); // saveFn is responsible for surfacing the error (toast) + } + }, []); + + const schedule = useCallback((payload, delay = defaultDelay) => { + pending.current = payload; + clearTimeout(timer.current); + timer.current = setTimeout(() => run(payload), delay); + }, [run, defaultDelay]); + + const flush = useCallback(() => { + if (pending.current != null) { + clearTimeout(timer.current); + run(pending.current); + } + }, [run]); + + // Fade the "Saved" confirmation back to idle. + useEffect(() => { + if (status !== 'saved') return undefined; + const t = setTimeout(() => setStatus('idle'), 2000); + return () => clearTimeout(t); + }, [status]); + + // Never lose a pending edit on unmount. + useEffect(() => () => { + clearTimeout(timer.current); + if (pending.current != null) { + Promise.resolve(saveRef.current(pending.current)).catch(() => {}); + } + }, []); + + return { status, schedule, flush }; +} diff --git a/client/hooks/useAutoSave.test.jsx b/client/hooks/useAutoSave.test.jsx new file mode 100644 index 0000000..f6ff066 --- /dev/null +++ b/client/hooks/useAutoSave.test.jsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAutoSave } from './useAutoSave'; + +describe('useAutoSave', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('debounces: only the latest payload is saved, once', async () => { + const save = vi.fn().mockResolvedValue(); + const { result } = renderHook(() => useAutoSave(save, 400)); + + act(() => { + result.current.schedule({ a: 1 }); + result.current.schedule({ a: 2 }); + result.current.schedule({ a: 3 }); + }); + expect(save).not.toHaveBeenCalled(); + + await act(async () => { await vi.advanceTimersByTimeAsync(400); }); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith({ a: 3 }); + expect(result.current.status).toBe('saved'); + }); + + it('per-call delay overrides the default', async () => { + const save = vi.fn().mockResolvedValue(); + const { result } = renderHook(() => useAutoSave(save, 400)); + + act(() => { result.current.schedule({ slow: true }, 900); }); + await act(async () => { await vi.advanceTimersByTimeAsync(400); }); + expect(save).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(save).toHaveBeenCalledWith({ slow: true }); + }); + + it('flush() saves a pending payload immediately and is a no-op when idle', async () => { + const save = vi.fn().mockResolvedValue(); + const { result } = renderHook(() => useAutoSave(save, 400)); + + await act(async () => { result.current.flush(); }); + expect(save).not.toHaveBeenCalled(); + + act(() => { result.current.schedule({ b: 1 }, 900); }); + await act(async () => { result.current.flush(); }); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith({ b: 1 }); + + // payload no longer pending — flushing again must not double-save + await act(async () => { result.current.flush(); }); + expect(save).toHaveBeenCalledTimes(1); + }); + + it('reports error status when the save rejects, then recovers on next save', async () => { + const save = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(); + const { result } = renderHook(() => useAutoSave(save, 100)); + + act(() => { result.current.schedule({ x: 1 }); }); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + expect(result.current.status).toBe('error'); + + act(() => { result.current.schedule({ x: 2 }); }); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + expect(result.current.status).toBe('saved'); + }); + + it('"saved" fades back to idle after 2 seconds', async () => { + const save = vi.fn().mockResolvedValue(); + const { result } = renderHook(() => useAutoSave(save, 100)); + + act(() => { result.current.schedule({ y: 1 }); }); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + expect(result.current.status).toBe('saved'); + + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(result.current.status).toBe('idle'); + }); + + it('flushes a pending payload on unmount so edits are never lost', async () => { + const save = vi.fn().mockResolvedValue(); + const { result, unmount } = renderHook(() => useAutoSave(save, 900)); + + act(() => { result.current.schedule({ unsaved: true }); }); + expect(save).not.toHaveBeenCalled(); + + unmount(); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith({ unsaved: true }); + }); +}); diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index ee8ceec..5919012 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -6,6 +6,8 @@ import { } from 'lucide-react'; import { api } from '@/api'; import { useAuth } from '@/hooks/useAuth'; +import { useAutoSave } from '@/hooks/useAutoSave'; +import { SaveStatus } from '@/components/ui/save-status'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; @@ -34,7 +36,7 @@ function formatDateTime(value) { + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -function SectionCard({ title, icon: Icon, subtitle, children }) { +function SectionCard({ title, icon: Icon, subtitle, action, children }) { return (
@@ -45,6 +47,7 @@ function SectionCard({ title, icon: Icon, subtitle, children }) {

{title}

{subtitle &&

{subtitle}

}
+ {action &&
{action}
}
{children}
@@ -362,13 +365,7 @@ function EditProfile({ profile, onSaved }) { // Exported: rendered on the Settings page ("Notifications" section). Lives here // because it shares asSettings/CheckRow/SectionCard with the rest of this file. -export function NotificationPreferences({ settings, onSaved }) { - const [form, setForm] = useState(settings); - const [saving, setSaving] = useState(false); - - useEffect(() => setForm(settings), [settings]); - const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); - +function buildNotificationPayload(form) { const payload = { email: form.email || form.notification_email || '', notifications_enabled: !!(form.notifications_enabled ?? form.enabled), @@ -383,26 +380,48 @@ export function NotificationPreferences({ settings, onSaved }) { payload.notify_1d = payload.notify_1_day; payload.notify_day_of = payload.notify_due; payload.notify_daily_overdue = payload.notify_overdue; + return payload; +} - const save = async () => { - setSaving(true); - try { - const data = await api.updateProfileSettings(payload); - toast.success('Notification preferences saved.'); - onSaved(asSettings(data)); - } catch (err) { +export function NotificationPreferences({ settings }) { + const [form, setForm] = useState(settings); + + // Auto-save: toggles persist almost instantly, the email field debounces so + // we never save a half-typed address. Local form stays the source of truth — + // no parent refresh that could clobber in-flight edits. + const { status, schedule, flush } = useAutoSave( + (payload) => api.updateProfileSettings(payload).catch((err) => { toast.error(err.message || 'Failed to save notification preferences.'); - } finally { - setSaving(false); - } - }; + throw err; + }), + ); + + const set = (k, v, delay) => setForm(prev => { + const next = { ...prev, [k]: v }; + schedule(buildNotificationPayload(next), delay); + return next; + }); + + const payload = buildNotificationPayload(form); return ( - + } + >
- set('email', e.target.value)} placeholder="you@example.com" /> + set('email', e.target.value, 900)} + onBlur={flush} + placeholder="you@example.com" + />
set('notifications_enabled', v)} /> @@ -413,11 +432,6 @@ export function NotificationPreferences({ settings, onSaved }) { set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
-
- -
); } @@ -442,23 +456,37 @@ export function PushNotifications({ settings, onSaved }) { const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0]; - const save = async () => { + // Auto-save. Toggle/channel persist immediately; URL and chat ID debounce. + // The token is deliberately NOT auto-saved while typing — a half-typed token + // must never overwrite a working one. It saves on blur, when complete. + const buildPatch = (over = {}) => { + const s = { enabled, channel, url, chatId, ...over }; + return { + notify_push_enabled: s.enabled, + push_channel: s.channel, + push_url: (s.url || '').trim() || null, + push_chat_id: (s.chatId || '').trim() || null, + }; + }; + + const { status, schedule, flush } = useAutoSave( + (patch) => api.updateProfileSettings(patch).catch((err) => { + toast.error(err.message || 'Failed to save push settings.'); + throw err; + }), + ); + + const saveToken = async () => { + const t = token.trim(); + if (!t) return; setSaving(true); try { - const patch = { - notify_push_enabled: enabled, - push_channel: channel, - push_url: url.trim() || null, - push_chat_id: chatId.trim() || null, - }; - if (token.trim()) patch.push_token = token.trim(); - await api.updateProfileSettings(patch); - setTokenSet(!!token.trim() || tokenSet); + await api.updateProfileSettings({ ...buildPatch(), push_token: t }); + setTokenSet(true); setToken(''); - toast.success('Push notification settings saved.'); - onSaved?.(); + toast.success('Token saved.'); } catch (err) { - toast.error(err.message || 'Failed to save push settings.'); + toast.error(err.message || 'Failed to save token.'); } finally { setSaving(false); } @@ -477,7 +505,12 @@ export function PushNotifications({ settings, onSaved }) { }; return ( - + } + >
{/* Master toggle — same CheckRow pattern as the email section */} @@ -485,7 +518,7 @@ export function PushNotifications({ settings, onSaved }) { id="push-enabled" label="Enable push notifications" checked={enabled} - onChange={setEnabled} + onChange={(v) => { setEnabled(v); schedule(buildPatch({ enabled: v }), 150); }} /> {enabled && (

@@ -503,7 +536,7 @@ export function PushNotifications({ settings, onSaved }) {

-
+
- +

Settings save as you change them.

); diff --git a/client/pages/SettingsPage.jsx b/client/pages/SettingsPage.jsx index 745f3a9..b3c500e 100644 --- a/client/pages/SettingsPage.jsx +++ b/client/pages/SettingsPage.jsx @@ -13,6 +13,8 @@ import { import { Switch } from '@/components/ui/switch'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/hooks/useAuth'; +import { useAutoSave } from '@/hooks/useAutoSave'; +import { SaveStatus } from '@/components/ui/save-status'; import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage'; export const LINK_IMPORT_PREF_KEY = 'link_import_ask'; @@ -287,7 +289,6 @@ export default function SettingsPage() { const [settings, setSettings] = useState(DEFAULTS); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [saving, setSaving] = useState(false); const loadSettings = useCallback(() => { setLoading(true); @@ -300,31 +301,36 @@ export default function SettingsPage() { useEffect(() => { loadSettings(); }, [loadSettings]); - const set = (k, v) => setSettings((p) => ({ ...p, [k]: v })); + const buildPayload = (s) => ({ + currency: s.currency, + date_format: s.date_format, + grace_period_days: s.grace_period_days, + drift_threshold_pct: s.drift_threshold_pct, + tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner, + tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '', + tracker_show_search_sort: s.tracker_show_search_sort, + tracker_show_summary_cards: s.tracker_show_summary_cards, + tracker_show_safe_to_spend: s.tracker_show_safe_to_spend, + tracker_show_overdue_command_center: s.tracker_show_overdue_command_center, + tracker_show_drift_insights: s.tracker_show_drift_insights, + }); - const handleSave = async () => { - setSaving(true); - try { - await api.saveSettings({ - currency: settings.currency, - date_format: settings.date_format, - grace_period_days: settings.grace_period_days, - drift_threshold_pct: settings.drift_threshold_pct, - tracker_show_bank_projection_banner: settings.tracker_show_bank_projection_banner, - tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '', - tracker_show_search_sort: settings.tracker_show_search_sort, - tracker_show_summary_cards: settings.tracker_show_summary_cards, - tracker_show_safe_to_spend: settings.tracker_show_safe_to_spend, - tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center, - tracker_show_drift_insights: settings.tracker_show_drift_insights, - }); - toast.success('Settings saved.'); - } catch (err) { + // Auto-save: every change persists on its own — no Save button. Toggles and + // selects feel instant (short debounce); typed inputs get a longer one so we + // don't save half-typed numbers. + const { status: saveStatus, schedule } = useAutoSave( + (payload) => api.saveSettings(payload).catch((err) => { toast.error(err.message || 'Failed to save settings.'); - } finally { - setSaving(false); - } - }; + throw err; + }), + ); + + const set = (k, v, delay) => setSettings((p) => { + const next = { ...p, [k]: v }; + schedule(buildPayload(next), delay); + return next; + }); + const setTyped = (k, v) => set(k, v, 900); // for keystroke-driven inputs if (loading) { return ( @@ -352,10 +358,17 @@ export default function SettingsPage() { return (
- {/* Page header — flat on background */} -
-

Settings

-

Manage your display, billing, and notification preferences

+ {/* Page header — flat on background, live save status on the right */} +
+
+

Settings

+

+ Manage your display, billing, and notification preferences · changes save automatically +

+
+
+ +
{/* Appearance */} @@ -471,7 +484,7 @@ export default function SettingsPage() { min={0} max={30} value={settings.grace_period_days} - onChange={(e) => set('grace_period_days', parseInt(e.target.value, 10) || 0)} + onChange={(e) => setTyped('grace_period_days', parseInt(e.target.value, 10) || 0)} className="w-20" /> days @@ -488,7 +501,7 @@ export default function SettingsPage() { max={25} step={1} value={settings.drift_threshold_pct ?? '5'} - onChange={(e) => set('drift_threshold_pct', e.target.value)} + onChange={(e) => setTyped('drift_threshold_pct', e.target.value)} className="w-20 font-mono" /> % @@ -496,14 +509,7 @@ export default function SettingsPage() { - {/* Save button — right-aligned below all cards */} -
- -
- - {/* Notifications — email + push reminder preferences (save independently) */} + {/* Notifications — email + push reminder preferences (auto-save too) */}
diff --git a/package-lock.json b/package-lock.json index a4d5e72..2aabf85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bill-tracker", - "version": "0.38.4", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bill-tracker", - "version": "0.38.4", + "version": "0.39.0", "license": "ISC", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", @@ -53,9 +53,11 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", + "jsdom": "^29.1.1", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "vite": "^5.4.10", @@ -92,6 +94,57 @@ "ajv": ">=8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1666,6 +1719,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz", + "integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2091,6 +2297,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -5285,6 +5509,55 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -5312,6 +5585,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5679,6 +5960,17 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -5943,6 +6235,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -6586,6 +6888,20 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6605,6 +6921,58 @@ "license": "MIT", "peer": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6685,6 +7053,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6842,6 +7217,14 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6909,6 +7292,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -7944,6 +8340,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -8401,6 +8810,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8642,6 +9058,95 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9054,6 +9559,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9353,6 +9869,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -10412,6 +10935,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10737,6 +11273,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -10976,6 +11542,14 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -11593,6 +12167,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -12268,6 +12855,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -12509,6 +13103,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12530,6 +13144,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -12735,6 +13362,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -13344,6 +13981,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -13351,6 +14001,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -13774,6 +14434,23 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 31d9151..e10310d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.38.4", + "version": "0.39.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { @@ -61,9 +61,11 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", + "jsdom": "^29.1.1", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "vite": "^5.4.10", diff --git a/vite.config.mjs b/vite.config.mjs index 0157625..bff66ce 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -68,7 +68,7 @@ export default defineConfig({ // Server tests stay on node:test (`npm run test`); client tests run with // `npm run test:client`; `npm run test:all` runs both. test: { - environment: 'node', - include: ['client/**/*.test.js'], + environment: 'node', // hook/component tests opt into jsdom via @vitest-environment + include: ['client/**/*.test.{js,jsx}'], }, });