feat(settings): auto-save preferences with live save status (batch 0.39.0)

Replace all Save buttons on the Settings page with debounced auto-save:

- useAutoSave hook: debounce with latest-payload-wins, flush() for blur,
  pending-edit flush on unmount, status machine (idle/saving/saved/error)
  with saved fading back to idle. Covered by 6 Vitest tests (fake timers).
- SaveStatus pill (framer-motion) in the page header and notification card
  headers — Saving…/Saved/Save failed.
- Timing per control: toggles/selects/channel ~150-400ms; typed inputs
  (email, URLs, grace period, drift pct) 900ms + flush on blur.
- Push token never auto-saves mid-type: saves on blur only, so a partial
  token can never overwrite a working one.
- Notification cards no longer refetch parent settings on save (would
  clobber in-flight edits under auto-save).
- Decision: no undo toast — settings are non-destructive and instantly
  re-editable; undo would add noise without safety.
- vitest include now picks up .jsx tests; jsdom + @testing-library/react
  added as devDependencies.
This commit is contained in:
null 2026-06-12 02:08:42 -05:00
parent 8ef794a94a
commit d9a441dff6
8 changed files with 1003 additions and 93 deletions

View File

@ -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 (
<AnimatePresence mode="wait">
{state && (
<motion.span
key={status}
initial={{ opacity: 0, y: -2 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 2 }}
transition={{ duration: 0.15 }}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium',
state.cls,
className,
)}
>
<state.icon className={cn('h-3 w-3', status === 'saving' && 'animate-pulse')} />
{state.text}
</motion.span>
)}
</AnimatePresence>
);
}

View File

@ -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 };
}

View File

@ -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 });
});
});

View File

@ -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 (
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
@ -45,6 +47,7 @@ function SectionCard({ title, icon: Icon, subtitle, children }) {
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
{action && <div className="ml-auto shrink-0">{action}</div>}
</div>
<div>{children}</div>
</section>
@ -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 (
<SectionCard title="Notification Preferences" icon={Mail} subtitle="Manage email reminders for your bills.">
<SectionCard
title="Notification Preferences"
icon={Mail}
subtitle="Manage email reminders for your bills. Changes save automatically."
action={<SaveStatus status={status} />}
>
<div className="px-6 py-5 space-y-4">
<div className="space-y-1.5 max-w-md">
<label htmlFor="profile-email" className="text-xs font-medium text-muted-foreground">Email</label>
<Input id="profile-email" type="email" value={payload.email} onChange={e => set('email', e.target.value)} placeholder="you@example.com" />
<Input
id="profile-email"
type="email"
value={payload.email}
onChange={e => set('email', e.target.value, 900)}
onBlur={flush}
placeholder="you@example.com"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<CheckRow id="n-enabled" label="Notifications enabled" checked={payload.notifications_enabled} onChange={v => set('notifications_enabled', v)} />
@ -413,11 +432,6 @@ export function NotificationPreferences({ settings, onSaved }) {
<CheckRow id="n-amount" label="Notify on price changes" checked={payload.notify_amount_change} onChange={v => set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
</div>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Preferences'}
</Button>
</div>
</SectionCard>
);
}
@ -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 (
<SectionCard title="Push Notifications" icon={Bell} subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram.">
<SectionCard
title="Push Notifications"
icon={Bell}
subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram. Changes save automatically."
action={<SaveStatus status={status} />}
>
<div className="px-6 py-5 space-y-5">
{/* 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 && (
<p className="text-xs text-muted-foreground -mt-3 pl-1">
@ -503,7 +536,7 @@ export function PushNotifications({ settings, onSaved }) {
<button
key={c.value}
type="button"
onClick={() => setChannel(c.value)}
onClick={() => { setChannel(c.value); schedule(buildPatch({ channel: c.value }), 150); }}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
channel === c.value
? 'border-primary bg-primary text-primary-foreground'
@ -522,7 +555,8 @@ export function PushNotifications({ settings, onSaved }) {
<label className="text-xs font-medium text-muted-foreground">{ch.urlLabel}</label>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onChange={e => { setUrl(e.target.value); schedule(buildPatch({ url: e.target.value }), 900); }}
onBlur={flush}
placeholder={ch.urlHint}
autoComplete="off"
/>
@ -539,7 +573,8 @@ export function PushNotifications({ settings, onSaved }) {
<Input
value={token}
onChange={e => setToken(e.target.value)}
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'}
onBlur={saveToken}
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token — saves when you click away'}
type="password"
autoComplete="off"
/>
@ -551,7 +586,8 @@ export function PushNotifications({ settings, onSaved }) {
<label className="text-xs font-medium text-muted-foreground">{ch.chatIdLabel}</label>
<Input
value={chatId}
onChange={e => setChatId(e.target.value)}
onChange={e => { setChatId(e.target.value); schedule(buildPatch({ chatId: e.target.value }), 900); }}
onBlur={flush}
placeholder="e.g. 123456789"
autoComplete="off"
/>
@ -584,22 +620,20 @@ export function PushNotifications({ settings, onSaved }) {
)}
</div>
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between gap-3">
<div className="px-6 py-4 border-t border-border/50 flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={test}
disabled={testing || !enabled || !url.trim()}
disabled={testing || saving || !enabled || !url.trim()}
className="gap-1.5"
>
{testing
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Sending</>
: <><SendHorizontal className="h-3.5 w-3.5" />Send test</>}
</Button>
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save'}
</Button>
<p className="text-[11px] text-muted-foreground">Settings save as you change them.</p>
</div>
</SectionCard>
);

View File

@ -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 (
<div>
{/* Page header — flat on background */}
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your display, billing, and notification preferences</p>
{/* Page header — flat on background, live save status on the right */}
<div className="mb-8 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage your display, billing, and notification preferences · changes save automatically
</p>
</div>
<div className="pt-1.5">
<SaveStatus status={saveStatus} />
</div>
</div>
{/* 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"
/>
<span className="text-sm text-muted-foreground">days</span>
@ -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"
/>
<span className="text-sm text-muted-foreground">%</span>
@ -496,14 +509,7 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* Save button — right-aligned below all cards */}
<div className="flex justify-end mt-6">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</div>
{/* Notifications — email + push reminder preferences (save independently) */}
{/* Notifications — email + push reminder preferences (auto-save too) */}
<div id="notifications" className="mt-6 scroll-mt-24">
<NotificationsSection />
</div>

681
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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}'],
},
});