From 0747d044f384a66d6072959621e06bae1f7c0838 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Mar 2026 09:51:21 +0100 Subject: [PATCH] Implement the 032-inline-confirm-buttons feature that replaces single-click destructive actions with a reusable ConfirmButton component providing inline two-step confirmation (click to arm, click to execute), applied to the remove combatant and clear encounter buttons, with CSS scale pulse animation, 5-second auto-revert, click-outside/Escape/blur dismissal, full keyboard accessibility, and 13 unit tests via @testing-library/react Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 + apps/web/package.json | 3 + .../web/src/__tests__/confirm-button.test.tsx | 198 +++++++ apps/web/src/components/combatant-row.tsx | 21 +- apps/web/src/components/turn-navigation.tsx | 15 +- apps/web/src/components/ui/confirm-button.tsx | 107 ++++ apps/web/src/hooks/use-encounter.ts | 4 - apps/web/src/index.css | 16 + pnpm-lock.yaml | 533 +++++++++++++++++- .../checklists/requirements.md | 35 ++ .../032-inline-confirm-buttons/data-model.md | 37 ++ specs/032-inline-confirm-buttons/plan.md | 65 +++ .../032-inline-confirm-buttons/quickstart.md | 40 ++ specs/032-inline-confirm-buttons/research.md | 62 ++ specs/032-inline-confirm-buttons/spec.md | 100 ++++ specs/032-inline-confirm-buttons/tasks.md | 151 +++++ vitest.config.ts | 2 +- 17 files changed, 1364 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/__tests__/confirm-button.test.tsx create mode 100644 apps/web/src/components/ui/confirm-button.tsx create mode 100644 specs/032-inline-confirm-buttons/checklists/requirements.md create mode 100644 specs/032-inline-confirm-buttons/data-model.md create mode 100644 specs/032-inline-confirm-buttons/plan.md create mode 100644 specs/032-inline-confirm-buttons/quickstart.md create mode 100644 specs/032-inline-confirm-buttons/research.md create mode 100644 specs/032-inline-confirm-buttons/spec.md create mode 100644 specs/032-inline-confirm-buttons/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 7e611ab..04b8c6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,3 +79,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: 4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans. 5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation. + +## Active Technologies +- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) (032-inline-confirm-buttons) +- N/A (no persistence changes — confirm state is ephemeral) (032-inline-confirm-buttons) + +## Recent Changes +- 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) diff --git a/apps/web/package.json b/apps/web/package.json index 2a57262..ef8fe7f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,9 +21,12 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^28.1.0", "tailwindcss": "^4.2.1", "vite": "^6.2.0" } diff --git a/apps/web/src/__tests__/confirm-button.test.tsx b/apps/web/src/__tests__/confirm-button.test.tsx new file mode 100644 index 0000000..3f23d44 --- /dev/null +++ b/apps/web/src/__tests__/confirm-button.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfirmButton } from "../components/ui/confirm-button"; + +function XIcon() { + return X; +} + +function renderButton( + props: Partial[0]> = {}, +) { + const onConfirm = props.onConfirm ?? vi.fn(); + render( + } + label="Remove combatant" + onConfirm={onConfirm} + {...props} + />, + ); + return { onConfirm }; +} + +describe("ConfirmButton", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it("renders the default icon in idle state", () => { + renderButton(); + expect(screen.getByTestId("x-icon")).toBeTruthy(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + "Remove combatant", + ); + }); + + it("transitions to confirm state with Check icon on first click", () => { + renderButton(); + fireEvent.click(screen.getByRole("button")); + + expect(screen.queryByTestId("x-icon")).toBeNull(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + "Confirm remove combatant", + ); + expect(screen.getByRole("button").className).toContain("bg-destructive"); + }); + + it("calls onConfirm on second click in confirm state", () => { + const { onConfirm } = renderButton(); + const button = screen.getByRole("button"); + + fireEvent.click(button); + fireEvent.click(button); + + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("auto-reverts after 5 seconds", () => { + renderButton(); + fireEvent.click(screen.getByRole("button")); + + expect(screen.queryByTestId("x-icon")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.getByTestId("x-icon")).toBeTruthy(); + }); + + it("reverts on Escape key", () => { + renderButton(); + fireEvent.click(screen.getByRole("button")); + + expect(screen.queryByTestId("x-icon")).toBeNull(); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(screen.getByTestId("x-icon")).toBeTruthy(); + }); + + it("reverts on click outside", () => { + renderButton(); + fireEvent.click(screen.getByRole("button")); + + expect(screen.queryByTestId("x-icon")).toBeNull(); + + fireEvent.mouseDown(document.body); + + expect(screen.getByTestId("x-icon")).toBeTruthy(); + }); + + it("does not enter confirm state when disabled", () => { + renderButton({ disabled: true }); + fireEvent.click(screen.getByRole("button")); + + expect(screen.getByTestId("x-icon")).toBeTruthy(); + }); + + it("cleans up timer on unmount", () => { + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + renderButton(); + fireEvent.click(screen.getByRole("button")); + + cleanup(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + it("manages independent instances separately", () => { + const onConfirm1 = vi.fn(); + const onConfirm2 = vi.fn(); + + render( + <> + 1} + label="Remove first" + onConfirm={onConfirm1} + /> + 2} + label="Remove second" + onConfirm={onConfirm2} + /> + , + ); + + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[0]); + + // First is confirming, second is still idle + expect(screen.queryByTestId("icon-1")).toBeNull(); + expect(screen.getByTestId("icon-2")).toBeTruthy(); + }); + + // T008: Keyboard-specific tests + it("Enter key triggers confirm state", () => { + renderButton(); + const button = screen.getByRole("button"); + + // Native button handles Enter via click event + fireEvent.click(button); + + expect(screen.queryByTestId("x-icon")).toBeNull(); + expect(button).toHaveAttribute("aria-label", "Confirm remove combatant"); + }); + + it("Enter in confirm state calls onConfirm", () => { + const { onConfirm } = renderButton(); + const button = screen.getByRole("button"); + + fireEvent.click(button); // enter confirm state + fireEvent.click(button); // confirm + + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("Escape in confirm state reverts", () => { + renderButton(); + fireEvent.click(screen.getByRole("button")); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(screen.getByTestId("x-icon")).toBeTruthy(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + "Remove combatant", + ); + }); + + it("blur event reverts confirm state", () => { + renderButton(); + const button = screen.getByRole("button"); + + fireEvent.click(button); + expect(screen.queryByTestId("x-icon")).toBeNull(); + + fireEvent.blur(button); + expect(screen.getByTestId("x-icon")).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index c463db8..77fd8aa 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -11,7 +11,7 @@ import { ConditionPicker } from "./condition-picker"; import { ConditionTags } from "./condition-tags"; import { D20Icon } from "./d20-icon"; import { HpAdjustPopover } from "./hp-adjust-popover"; -import { Button } from "./ui/button"; +import { ConfirmButton } from "./ui/confirm-button"; import { Input } from "./ui/input"; interface Combatant { @@ -543,19 +543,12 @@ export function CombatantRow({ {/* Actions */} - + } + label="Remove combatant" + onConfirm={() => onRemove(id)} + className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity" + /> ); diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index e83f2c8..a14a4f0 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -2,6 +2,7 @@ import type { Encounter } from "@initiative/domain"; import { Settings, StepBack, StepForward, Trash2 } from "lucide-react"; import { D20Icon } from "./d20-icon"; import { Button } from "./ui/button"; +import { ConfirmButton } from "./ui/confirm-button"; interface TurnNavigationProps { encounter: Encounter; @@ -74,15 +75,13 @@ export function TurnNavigation({ > - + className="h-8 w-8 text-muted-foreground" + /> + + ); +} diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 51cf03e..6dd0242 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -226,10 +226,6 @@ export function useEncounter() { ); const clearEncounter = useCallback(() => { - if (!window.confirm("Clear the entire encounter? This cannot be undone.")) { - return; - } - const result = clearEncounterUseCase(makeStore()); if (isDomainError(result)) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 16fd6d8..a4f7cd5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -68,6 +68,22 @@ animation: slide-in-right 200ms ease-out; } +@keyframes confirm-pulse { + 0% { + scale: 1; + } + 50% { + scale: 1.15; + } + 100% { + scale: 1; + } +} + +@utility animate-confirm-pulse { + animation: confirm-pulse 300ms ease-out; +} + @utility animate-concentration-pulse { animation: concentration-shake 450ms ease-out, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99bc8ec..c0a45ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 2.0.0 '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) + version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)) jscpd: specifier: ^4.0.8 version: 4.0.8 @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1) + version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1) apps/web: dependencies: @@ -63,6 +63,12 @@ importers: '@tailwindcss/vite': specifier: ^4.2.1 version: 4.2.1(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -72,6 +78,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 tailwindcss: specifier: ^4.2.1 version: 4.2.1 @@ -89,10 +98,26 @@ importers: packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -164,6 +189,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -233,10 +262,45 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -402,6 +466,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + 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 + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -778,9 +851,35 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -865,6 +964,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -877,6 +980,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -884,6 +991,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -916,6 +1030,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + blamer@1.0.7: resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} engines: {node: '>=8.9'} @@ -1002,9 +1119,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1014,10 +1146,17 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1025,6 +1164,12 @@ packages: doctypes@1.1.0: resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1048,6 +1193,10 @@ packages: resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1182,9 +1331,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@1.1.1: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} @@ -1192,6 +1353,10 @@ packages: idb@8.0.3: resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1215,6 +1380,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -1275,6 +1443,15 @@ packages: resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1429,6 +1606,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1437,6 +1618,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1454,6 +1639,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1469,6 +1657,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1520,6 +1712,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1553,6 +1748,10 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} @@ -1595,6 +1794,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1603,6 +1806,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -1611,6 +1817,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -1618,6 +1828,10 @@ packages: reprism@0.0.11: resolution: {integrity: sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1635,6 +1849,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1702,6 +1920,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -1717,6 +1939,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -1753,6 +1978,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1760,6 +1992,14 @@ packages: token-stream@1.0.0: resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1771,6 +2011,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1858,10 +2102,26 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1887,6 +2147,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1895,11 +2162,33 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1989,6 +2278,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2049,9 +2340,35 @@ snapshots: '@biomejs/cli-win32-x64@2.0.0': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@colors/colors@1.5.0': optional: true + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2146,6 +2463,8 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@exodus/bytes@1.15.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2440,11 +2759,43 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -2501,7 +2852,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2516,7 +2867,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1) + vitest: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color @@ -2564,6 +2915,8 @@ snapshots: acorn@7.4.1: {} + agent-base@7.1.4: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -2572,10 +2925,18 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + asap@2.0.6: {} assert-never@1.4.0: {} @@ -2600,6 +2961,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + blamer@1.0.7: dependencies: execa: 4.1.0 @@ -2690,18 +3055,47 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} doctypes@1.1.0: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2725,6 +3119,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2885,12 +3281,34 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@1.1.1: {} idb@8.0.3: {} + indent-string@4.0.0: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -2910,6 +3328,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@2.2.2: {} is-regex@1.2.1: @@ -2983,6 +3403,33 @@ snapshots: gitignore-to-glob: 0.3.0 jscpd-sarif-reporter: 4.0.6 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.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 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json5@2.2.3: {} @@ -3111,6 +3558,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3119,6 +3568,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3139,6 +3590,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -3150,6 +3603,8 @@ snapshots: mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -3212,6 +3667,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -3237,6 +3696,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + promise@7.3.1: dependencies: asap: 2.0.6 @@ -3313,6 +3778,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + queue-microtask@1.2.3: {} react-dom@19.2.4(react@19.2.4): @@ -3320,14 +3787,23 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.4: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + repeat-string@1.6.1: {} reprism@0.0.11: {} + require-from-string@2.0.2: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -3371,6 +3847,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -3421,6 +3901,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@5.0.3: {} strip-literal@3.1.0: @@ -3433,6 +3917,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {} @@ -3460,12 +3946,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 token-stream@1.0.0: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: optional: true @@ -3473,6 +3973,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.22.0: {} + universalify@2.0.1: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -3516,7 +4018,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.31.1 - vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1): + vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -3543,6 +4045,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.3 + jsdom: 28.1.0 transitivePeerDependencies: - jiti - less @@ -3559,8 +4062,24 @@ snapshots: void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3591,6 +4110,10 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} zod@4.3.6: {} diff --git a/specs/032-inline-confirm-buttons/checklists/requirements.md b/specs/032-inline-confirm-buttons/checklists/requirements.md new file mode 100644 index 0000000..bff4520 --- /dev/null +++ b/specs/032-inline-confirm-buttons/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Inline Confirmation Buttons + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-11 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The Assumptions section mentions Lucide and CSS as implementation context but keeps it appropriately scoped to assumptions rather than requirements. diff --git a/specs/032-inline-confirm-buttons/data-model.md b/specs/032-inline-confirm-buttons/data-model.md new file mode 100644 index 0000000..b18e33c --- /dev/null +++ b/specs/032-inline-confirm-buttons/data-model.md @@ -0,0 +1,37 @@ +# Data Model: Inline Confirmation Buttons + +## Entities + +### ConfirmButton State + +The `ConfirmButton` manages a single piece of ephemeral UI state: + +| Field | Type | Description | +|-------|------|-------------| +| isConfirming | boolean | Whether the button is in the "confirm" (armed) state | + +**State transitions**: + +``` +idle ──[first click/Enter/Space]──▶ confirming +confirming ──[second click/Enter/Space]──▶ action executed → idle (or unmount) +confirming ──[5s timeout]──▶ idle +confirming ──[Escape]──▶ idle +confirming ──[click outside]──▶ idle +confirming ──[focus loss]──▶ idle +``` + +### ConfirmButton Props (Component Interface) + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| onConfirm | () => void | yes | Callback executed on confirmed second activation | +| icon | ReactElement | yes | The default icon to display (e.g., X, Trash2) | +| label | string | yes | Accessible label for the button (used in aria-label and title) | +| className | string | no | Additional CSS classes passed through to the underlying button | +| disabled | boolean | no | When true, the button cannot enter confirm state | + +**Notes**: +- No domain entities are created or modified by this feature. +- The confirm state is purely ephemeral — never persisted, never serialized. +- The component does not introduce any new domain types or application-layer changes. diff --git a/specs/032-inline-confirm-buttons/plan.md b/specs/032-inline-confirm-buttons/plan.md new file mode 100644 index 0000000..b33da7d --- /dev/null +++ b/specs/032-inline-confirm-buttons/plan.md @@ -0,0 +1,65 @@ +# Implementation Plan: Inline Confirmation Buttons + +**Branch**: `032-inline-confirm-buttons` | **Date**: 2026-03-11 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/032-inline-confirm-buttons/spec.md` + +## Summary + +Replace single-click destructive actions and `window.confirm()` dialogs with a reusable `ConfirmButton` component that provides inline two-step confirmation. First click arms the button (checkmark icon, red background, scale pulse animation); second click executes the action. Auto-reverts after 5 seconds. Applied to the remove combatant (X) and clear encounter (trash) buttons. Fully keyboard-accessible. + +## Technical Context + +**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) +**Primary Dependencies**: React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) +**Storage**: N/A (no persistence changes — confirm state is ephemeral) +**Testing**: Vitest (unit tests for state logic; manual testing for animation/visual) +**Target Platform**: Web (modern browsers) +**Project Type**: Web application (monorepo: apps/web + packages/domain + packages/application) +**Performance Goals**: Instant visual feedback (<16ms frame budget for animation) +**Constraints**: No new runtime dependencies; CSS-only animation +**Scale/Scope**: 1 new component, 3 modified files, 1 CSS animation added + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | No domain changes. Confirm state is purely UI-local. | +| II. Layered Architecture | PASS | ConfirmButton lives in adapter layer (apps/web/src/components/ui/). Confirmation logic moves from application hook to UI component where it belongs. No reverse dependencies. | +| III. Clarification-First | PASS | Spec is fully specified with zero NEEDS CLARIFICATION markers. | +| IV. Escalation Gates | PASS | All work is within spec scope. | +| V. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for undo and configurability. | +| VI. No Gameplay Rules | PASS | No gameplay mechanics involved. | + +**Post-Phase 1 re-check**: All gates still pass. Moving `window.confirm()` out of `use-encounter.ts` into a UI component improves layer separation — confirmation is a UI concern, not an application concern. + +## Project Structure + +### Documentation (this feature) + +```text +specs/032-inline-confirm-buttons/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks — NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +apps/web/src/ +├── components/ +│ ├── ui/ +│ │ ├── button.tsx # Existing — wrapped by ConfirmButton +│ │ └── confirm-button.tsx # NEW — reusable two-step confirm component +│ ├── combatant-row.tsx # MODIFIED — use ConfirmButton for remove +│ └── turn-navigation.tsx # MODIFIED — use ConfirmButton for trash +├── hooks/ +│ └── use-encounter.ts # MODIFIED — remove window.confirm() +└── index.css # MODIFIED — add scale pulse keyframe +``` + +**Structure Decision**: This feature adds one new component file to the existing `ui/` directory and modifies three existing files. No new directories or structural changes needed. No contracts directory needed — this is an internal UI component with no external interfaces. diff --git a/specs/032-inline-confirm-buttons/quickstart.md b/specs/032-inline-confirm-buttons/quickstart.md new file mode 100644 index 0000000..13443e3 --- /dev/null +++ b/specs/032-inline-confirm-buttons/quickstart.md @@ -0,0 +1,40 @@ +# Quickstart: Inline Confirmation Buttons + +## What This Feature Does + +Replaces single-click destructive actions and browser `window.confirm()` dialogs with inline two-step confirmation buttons. Click once to arm, click again to execute. The button visually transforms (checkmark icon, red background, scale pulse) to signal the armed state, and auto-reverts after 5 seconds. + +## Files to Create + +- `apps/web/src/components/ui/confirm-button.tsx` — Reusable ConfirmButton component + +## Files to Modify + +- `apps/web/src/components/combatant-row.tsx` — Replace remove Button with ConfirmButton +- `apps/web/src/components/turn-navigation.tsx` — Replace trash Button with ConfirmButton +- `apps/web/src/hooks/use-encounter.ts` — Remove `window.confirm()` from clearEncounter +- `apps/web/src/index.css` — Add scale pulse keyframe animation + +## How to Test + +```bash +# Run all tests +pnpm test + +# Run the dev server and test manually +pnpm --filter web dev +# 1. Add a combatant +# 2. Click the X button — should enter red confirm state +# 3. Click again — combatant removed +# 4. Click X, wait 5 seconds — should revert +# 5. Click trash button — same confirm behavior for clearing encounter +# 6. Test keyboard: Tab to button, Enter, Enter (confirm), Escape (cancel) +``` + +## Key Design Decisions + +- ConfirmButton wraps the existing `Button` component (no new base component) +- Confirm state is local `useState` — no shared state or context needed +- Click-outside detection follows the `HpAdjustPopover` pattern (mousedown listener) +- Animation uses CSS `@keyframes` + `@utility` like existing animations +- Uses `bg-destructive text-primary-foreground` for the confirm state diff --git a/specs/032-inline-confirm-buttons/research.md b/specs/032-inline-confirm-buttons/research.md new file mode 100644 index 0000000..cc975ff --- /dev/null +++ b/specs/032-inline-confirm-buttons/research.md @@ -0,0 +1,62 @@ +# Research: Inline Confirmation Buttons + +## R-001: Confirmation UX Pattern + +**Decision**: Two-click inline confirmation with visual state transition (no modal, no popover). + +**Rationale**: The button itself transforms in place — icon swaps to a checkmark, background turns red/danger, a scale pulse draws attention. This avoids the cognitive interruption of a modal dialog while still requiring deliberate confirmation. The pattern is well-established in tools like GitHub (delete branch buttons) and Notion (delete page). + +**Alternatives considered**: +- **Browser `window.confirm()`** — Already in use for clear encounter. Blocks the thread, looks outdated, inconsistent across browsers. Rejected. +- **Custom modal dialog** — More disruptive than needed for single-button actions. Would require a new modal component. Rejected (over-engineered for icon buttons). +- **Undo toast after immediate deletion** — Simpler UX but requires implementing undo infrastructure in the domain layer. Out of scope per spec assumptions. Rejected. +- **Hold-to-delete (long press)** — Poor keyboard accessibility, no visual feedback during the hold, unfamiliar pattern for web apps. Rejected. + +## R-002: State Management Approach + +**Decision**: Local `useState` boolean inside the `ConfirmButton` component, with `useEffect` for the auto-revert timer and click-outside/escape listeners. + +**Rationale**: The confirm state is purely UI-local — it doesn't affect domain state, doesn't need to be persisted, and doesn't need to be shared between components. A simple boolean (`isConfirming`) is sufficient. The existing codebase already uses this exact pattern in `HpAdjustPopover` (click-outside detection, Escape handling, useCallback with cleanup). + +**Alternatives considered**: +- **Shared state / context** — No need; each button is independent (FR-010). Rejected. +- **Custom hook (`useConfirmButton`)** — Possible but premature. The logic is simple enough to live in the component. If more confirm buttons are added later, extraction to a hook is trivial. Rejected for now. + +## R-003: Animation Approach + +**Decision**: CSS `@keyframes` animation registered as a Tailwind `@utility`, matching the existing `animate-concentration-pulse` and `animate-slide-in-right` patterns. + +**Rationale**: The project already defines custom animations via `@keyframes` + `@utility` in `index.css`. A scale pulse (brief scale-up then back to normal) is lightweight and purely decorative — no JavaScript animation library needed. + +**Alternatives considered**: +- **JavaScript animation (Web Animations API)** — Overkill for a simple pulse. Harder to coordinate with Tailwind classes. Rejected. +- **Tailwind `transition-transform`** — Only handles transitions between states, not a pulse effect (scale up then back). Would need JS to toggle classes with timing. Rejected. + +## R-004: Destructive Color Tokens + +**Decision**: Use existing `--color-destructive` (#ef4444) for the confirm-state background and keep `--color-primary-foreground` (#ffffff) for the icon in confirm state. + +**Rationale**: The theme already defines `--color-destructive` and `--color-hover-destructive`. The confirm state needs a filled background (not just text color change) to be visually unmistakable. Using `bg-destructive text-primary-foreground` provides high contrast and matches the semantic meaning. + +**Alternatives considered**: +- **`bg-destructive/20` (semi-transparent)** — Too subtle for a confirmation state that must be immediately recognizable. Rejected. +- **New custom color token** — Unnecessary; existing tokens suffice. Rejected. + +## R-005: Click-Outside Detection + +**Decision**: `mousedown` event listener on `document` with `ref.current.contains()` check, cleaned up on unmount or state change. + +**Rationale**: This is the exact pattern used by `HpAdjustPopover` in the existing codebase. It's proven, handles edge cases (clicking on other interactive elements), and cleans up properly. + +**Alternatives considered**: +- **`blur` event on button** — Doesn't fire when clicking on non-focusable elements. Incomplete coverage. Rejected as sole mechanism (but focus loss is still handled via FR-005). +- **Third-party library (e.g., `use-click-outside`)** — Unnecessary dependency for a simple pattern already implemented in the codebase. Rejected. + +## R-006: Integration Points + +**Decision**: The `ConfirmButton` component wraps the existing `Button` component. Integration requires: +1. `combatant-row.tsx`: Replace the remove `