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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
## [Unreleased]

### Added
- **Confirmation Dialog System:** Replaced browser's native `alert`/`confirm` with a custom, accessible, and themed modal system.
- **Features:**
- Dual-theme support (Glassmorphism & Neobrutalism).
- Asynchronous `useConfirm` hook returning a Promise.
- Specialized variants (danger, warning, info) with appropriate styling and icons.
- Fully accessible `Modal` component (added `role="dialog"`, `aria-labelledby`, `aria-modal`).
- **Technical:** Created `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`. Updated `web/pages/GroupDetails.tsx` to use the new system.

- **Error Boundary System:** Implemented a global React Error Boundary to catch render errors gracefully.
- **Features:**
- Dual-theme support (Glassmorphism & Neobrutalism) for the error fallback UI.
Expand All @@ -22,6 +30,8 @@
- Keyboard navigation support for Groups page, enabling accessibility for power users.

### Changed
- **Web App:** Refactored `GroupDetails` destructive actions (Delete Group, Delete Expense, Leave Group, Remove Member) to use the new `ConfirmDialog` instead of `window.confirm`.
- **Accessibility:** Updated `Modal` component to include proper ARIA roles and labels, fixing a long-standing accessibility gap.
- Updated JULES_PROMPT.md based on review of successful PRs:
- Emphasized complete system implementation over piecemeal changes
- Added best practices from successful PRs (Toast system, keyboard navigation iteration)
Expand Down
59 changes: 59 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,39 @@ colors: {

## Component Patterns

### Confirmation Dialog System

**Date:** 2026-01-21
**Context:** Replacing window.confirm with accessible modal

To handle destructive actions asynchronously while maintaining UI consistency:

```tsx
// 1. Setup in App
<ConfirmProvider>
<App />
</ConfirmProvider>

// 2. Usage in Component
const { confirm } = useConfirm();

const handleDelete = async () => {
const result = await confirm({
title: 'Delete Item',
description: 'Are you sure?',
variant: 'danger', // danger | warning | info
confirmText: 'Delete'
});

if (result) {
await deleteItem();
}
}
```

**Key Implementation Detail:**
Uses a promise-based approach (`new Promise(resolve => ...)`) stored in state/ref to bridge the imperative `confirm()` call with the declarative React UI rendering.

### Error Boundary Pattern

**Date:** 2026-01-14
Expand Down Expand Up @@ -190,6 +223,9 @@ When making a div clickable (like a card), you must ensure it's accessible:
</Modal>
```

**Accessibility Update (2026-01-21):**
Modals must include `role="dialog"`, `aria-modal="true"`, and `aria-labelledby="title-id"` on the overlay container to be properly detected by screen readers (and testing tools).

### Toast Notification Pattern

**Date:** 2026-01-01
Expand Down Expand Up @@ -486,6 +522,29 @@ _Document errors and their solutions here as you encounter them._

## Recent Implementation Reviews

### ✅ Successful PR Pattern: Confirmation Dialog System (#255)

**Date:** 2026-01-21
**Context:** Replacing native confirm dialogs with custom UI

**What was implemented:**
1. Created `ConfirmContext` using Promise pattern for async/await usage
2. Created `ConfirmDialog` component wrapping existing `Modal`
3. Enhanced `Modal` with proper ARIA attributes (`role="dialog"`, `aria-labelledby`)
4. Replaced native `window.confirm` in `GroupDetails.tsx`

**Why it succeeded:**
- ✅ Improved UX (no more native browser alerts)
- ✅ Improved Accessibility (Modal roles + keyboard support)
- ✅ Maintained dual-theme support (Glass/Neo)
- ✅ Clean integration via Context API (easy to use `const { confirm } = useConfirm()`)

**Key learnings:**
- Modals need explicit ARIA roles to be testable/accessible.
- Promise-based context API allows keeping the call site logic simple (`if (await confirm()) ...`).

---

### ✅ Successful PR Pattern: Error Boundary (#240)

**Date:** 2026-01-14
Expand Down
14 changes: 7 additions & 7 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
- Impact: App doesn't crash, users can recover
- Size: ~80 lines

- [x] **[ux]** Confirmation dialog for destructive actions
- Completed: 2026-01-21
- Files: Created `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/components/ui/Modal.tsx`
- Context: Replaced window.confirm with custom accessible modal system
- Impact: Prevents accidental data loss, matches app theme, improves accessibility
- Size: ~100 lines

### Mobile

- [ ] **[ux]** Pull-to-refresh with haptic feedback on all list screens
Expand Down Expand Up @@ -77,13 +84,6 @@
- Size: ~35 lines
- Added: 2026-01-01

- [ ] **[ux]** Confirmation dialog for destructive actions
- Files: Create `web/components/ui/ConfirmDialog.tsx`, integrate
- Context: Confirm before deleting groups/expenses
- Impact: Prevents accidental data loss
- Size: ~70 lines
- Added: 2026-01-01

### Mobile

- [ ] **[ux]** Swipe-to-delete for expenses with undo option
Expand Down
19 changes: 11 additions & 8 deletions web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ThemeWrapper } from './components/layout/ThemeWrapper';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ConfirmProvider } from './contexts/ConfirmContext';
import { ToastContainer } from './components/ui/Toast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { Auth } from './pages/Auth';
Expand Down Expand Up @@ -50,14 +51,16 @@ const App = () => {
return (
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
<ConfirmProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</ThemeProvider>
);
Expand Down
94 changes: 94 additions & 0 deletions web/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { AlertTriangle, Info } from 'lucide-react';
import React from 'react';
import { THEMES } from '../../constants';
import { useTheme } from '../../contexts/ThemeContext';
import { Button } from './Button';
import { Modal } from './Modal';

export type ConfirmVariant = 'danger' | 'warning' | 'info';

export interface ConfirmDialogProps {
isOpen: boolean;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: ConfirmVariant;
onConfirm: () => void;
onCancel: () => void;
}

export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
description,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
onConfirm,
onCancel,
}) => {
const { style, mode } = useTheme();
const isNeo = style === THEMES.NEOBRUTALISM;
Comment on lines +31 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove unused mode variable.

The mode variable is destructured from useTheme() but is never used in this component.

♻️ Suggested fix
-  const { style, mode } = useTheme();
+  const { style } = useTheme();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { style, mode } = useTheme();
const isNeo = style === THEMES.NEOBRUTALISM;
const { style } = useTheme();
const isNeo = style === THEMES.NEOBRUTALISM;
🤖 Prompt for AI Agents
In `@web/components/ui/ConfirmDialog.tsx` around lines 31 - 32, The destructured
but unused variable `mode` from useTheme() should be removed to eliminate the
unused variable warning: update the destructuring in the ConfirmDialog component
(where useTheme() is called) to only pull `style` (e.g., change `const { style,
mode } = useTheme()` to `const { style } = useTheme()`), leaving the rest of the
logic that uses `style`/`isNeo` (`THEMES.NEOBRUTALISM`, `isNeo`) untouched.


// Determine styles based on variant
const getIcon = () => {
switch (variant) {
case 'danger':
return <AlertTriangle size={32} className={isNeo ? 'text-black' : 'text-red-500'} />;
case 'warning':
return <AlertTriangle size={32} className={isNeo ? 'text-black' : 'text-yellow-500'} />;
case 'info':
return <Info size={32} className={isNeo ? 'text-black' : 'text-blue-500'} />;
}
};

const getIconBg = () => {
switch (variant) {
case 'danger':
return isNeo ? 'bg-red-400 border-2 border-black rounded-none' : 'bg-red-500/20 rounded-full';
case 'warning':
return isNeo ? 'bg-yellow-400 border-2 border-black rounded-none' : 'bg-yellow-500/20 rounded-full';
case 'info':
return isNeo ? 'bg-blue-400 border-2 border-black rounded-none' : 'bg-blue-500/20 rounded-full';
}
};

const getButtonVariant = () => {
switch (variant) {
case 'danger': return 'danger';
case 'warning': return 'primary';
case 'info': return 'primary';
default: return 'primary';
}
};

return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="ghost" onClick={onCancel}>
{cancelText}
</Button>
<Button variant={getButtonVariant()} onClick={onConfirm} autoFocus>
{confirmText}
</Button>
</>
Comment on lines +71 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider focusing the Cancel button for destructive actions.

The autoFocus on the Confirm button (line 76) may lead to accidental confirmations for danger and warning variants when users press Enter. Accessibility best practices recommend focusing the safer action (Cancel) for destructive dialogs.

🛡️ Suggested fix
      footer={
        <>
-          <Button variant="ghost" onClick={onCancel}>
+          <Button variant="ghost" onClick={onCancel} autoFocus={variant === 'danger' || variant === 'warning'}>
            {cancelText}
          </Button>
-          <Button variant={getButtonVariant()} onClick={onConfirm} autoFocus>
+          <Button variant={getButtonVariant()} onClick={onConfirm} autoFocus={variant === 'info'}>
            {confirmText}
          </Button>
        </>
      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
footer={
<>
<Button variant="ghost" onClick={onCancel}>
{cancelText}
</Button>
<Button variant={getButtonVariant()} onClick={onConfirm} autoFocus>
{confirmText}
</Button>
</>
footer={
<>
<Button variant="ghost" onClick={onCancel} autoFocus={variant === 'danger' || variant === 'warning'}>
{cancelText}
</Button>
<Button variant={getButtonVariant()} onClick={onConfirm} autoFocus={variant === 'info'}>
{confirmText}
</Button>
</>
}
🤖 Prompt for AI Agents
In `@web/components/ui/ConfirmDialog.tsx` around lines 71 - 79, The ConfirmDialog
currently sets autoFocus on the Confirm button which can cause accidental
destructive confirmations; update the footer so that autoFocus is applied
conditionally: if getButtonVariant() returns a destructive variant (e.g.,
"danger" or "warning") move the autoFocus prop from the confirm Button
(onConfirm) to the cancel Button (onCancel), otherwise keep autoFocus on the
confirm Button; reference ConfirmDialog, getButtonVariant, onConfirm, onCancel,
cancelText and confirmText when making this change.

}
>
<div className="flex flex-col items-center text-center sm:flex-row sm:text-left sm:items-start gap-4">
<div className={`p-3 shrink-0 ${getIconBg()}`}>
{getIcon()}
</div>
<div>
<p className={`text-base leading-relaxed ${isNeo ? 'text-black' : 'text-white/80'}`}>
{description}
</p>
</div>
</div>
</Modal>
);
};
6 changes: 3 additions & 3 deletions web/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="modal-title">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Hardcoded id="modal-title" may conflict if multiple modals render simultaneously.

If two Modal instances are open at the same time, both will have elements with id="modal-title", violating the HTML requirement that IDs be unique. Consider generating a unique ID (e.g., using useId() from React 19) and passing it to both aria-labelledby and the title element.

Suggested approach using React's useId
+import { useId } from 'react';
 
 export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer }) => {
   const { style, mode } = useTheme();
+  const titleId = useId();
 
   // ... variants ...
 
   return (
     <AnimatePresence>
       {isOpen && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="modal-title">
+        <div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby={titleId}>
           {/* ... */}
-              <h3 id="modal-title" className={...}>{title}</h3>
+              <h3 id={titleId} className={...}>{title}</h3>
🤖 Prompt for AI Agents
In `@web/components/ui/Modal.tsx` at line 46, The Modal component currently
hardcodes id="modal-title", which can collide when multiple modals render;
update the Modal (in web/components/ui/Modal.tsx) to generate a unique id (e.g.,
call React's useId() at top of the Modal component), assign that generated id to
the dialog's aria-labelledby attribute and to the modal title element (the
element that currently has id="modal-title"); add the necessary import for useId
and replace the hardcoded string with the generated id so each Modal instance
gets a unique, matching aria-labelledby/title id pair.

<motion.div
variants={overlayVariants}
initial="hidden"
Expand All @@ -64,8 +64,8 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
>
{/* Header */}
<div className={`p-6 flex justify-between items-center ${style === THEMES.NEOBRUTALISM ? 'border-b-2 border-black bg-neo-main text-white' : 'border-b border-white/10 bg-white/5'}`}>
<h3 className={`text-2xl font-bold ${style === THEMES.NEOBRUTALISM ? 'uppercase font-mono tracking-tighter' : ''}`}>{title}</h3>
<button onClick={onClose} className="hover:rotate-90 transition-transform duration-200">
<h3 id="modal-title" className={`text-2xl font-bold ${style === THEMES.NEOBRUTALISM ? 'uppercase font-mono tracking-tighter' : ''}`}>{title}</h3>
<button onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
<X size={24} />
</button>
Comment on lines +68 to 70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add type="button" to prevent unintended form submission.

The close button lacks an explicit type attribute. Buttons default to type="submit", which can cause unintended form submissions when the Modal wraps form content.

Proposed fix
-              <button onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
+              <button type="button" onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
                 <X size={24} />
               </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
<X size={24} />
</button>
<button type="button" onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
<X size={24} />
</button>
🧰 Tools
🪛 Biome (2.1.2)

[error] 68-68: Provide an explicit type prop for the button element.

The default type of a button is submit, which causes the submission of a form when placed inside a form element. This is likely not the behaviour that you want inside a React application.
Allowed button types are: submit, button or reset

(lint/a11y/useButtonType)

🤖 Prompt for AI Agents
In `@web/components/ui/Modal.tsx` around lines 68 - 70, The close button in
Modal.tsx currently can act as a submit button when placed inside forms; update
the <button> element that calls onClose (the button rendering the X icon) to
include an explicit type="button" attribute so it never triggers form
submission, keeping the onClose handler and existing classes/aria-label
unchanged.

</div>
Expand Down
73 changes: 73 additions & 0 deletions web/contexts/ConfirmContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { createContext, useCallback, useContext, useState } from 'react';
import { ConfirmDialog, ConfirmVariant } from '../components/ui/ConfirmDialog';

interface ConfirmOptions {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: ConfirmVariant;
}

interface ConfirmContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}

const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);

export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
description: '',
});
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);

const confirm = useCallback((options: ConfirmOptions) => {
setOptions(options);
setIsOpen(true);
return new Promise<boolean>((resolve) => {
setResolveRef(() => resolve);
});
}, []);
Comment on lines +26 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider handling concurrent confirm() calls.

If confirm() is called while a dialog is already open, the previous promise's resolve function is overwritten, leaving the earlier promise unresolved indefinitely. While unlikely in typical usage, this could cause issues if triggered programmatically.

Optional: Reject previous promise on new confirm call
   const confirm = useCallback((options: ConfirmOptions) => {
+    // Resolve any pending promise as false before opening new dialog
+    if (resolveRef) {
+      resolveRef(false);
+      setResolveRef(null);
+    }
     setOptions(options);
     setIsOpen(true);
     return new Promise<boolean>((resolve) => {
       setResolveRef(() => resolve);
     });
-  }, []);
+  }, [resolveRef]);
🤖 Prompt for AI Agents
In `@web/contexts/ConfirmContext.tsx` around lines 26 - 32, The current confirm
function overwrites the previous pending promise's resolver, leaving it
unresolved; update confirm to first check for an existing resolver (e.g.,
resolveRef.current or the existing value held by setResolveRef) and settle it
(call the previous resolve with false or call a stored reject) before storing
the new resolve and returning the new Promise; specifically, in confirm, before
setResolveRef(() => resolve) call the existing resolver if present to
resolve(false) (or reject) so the previous promise is not left hanging, then
proceed to setOptions, setIsOpen, and set the new resolver.


const handleConfirm = useCallback(() => {
setIsOpen(false);
if (resolveRef) {
resolveRef(true);
setResolveRef(null);
}
}, [resolveRef]);

const handleCancel = useCallback(() => {
setIsOpen(false);
if (resolveRef) {
resolveRef(false);
setResolveRef(null);
}
}, [resolveRef]);

return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<ConfirmDialog
isOpen={isOpen}
title={options.title}
description={options.description}
confirmText={options.confirmText}
cancelText={options.cancelText}
variant={options.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
);
};

export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (context === undefined) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
Loading
Loading