From c3c5f5f03f3054b0c14e7bb3e86272203e51aff4 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Sun, 24 May 2026 21:58:17 -0400 Subject: [PATCH] docs: add implementation plan for mobile event modal guided drawer --- .../plans/2025-05-24-mobile-event-modal.md | 997 ++++++++++++++++++ 1 file changed, 997 insertions(+) create mode 100644 docs/superpowers/plans/2025-05-24-mobile-event-modal.md diff --git a/docs/superpowers/plans/2025-05-24-mobile-event-modal.md b/docs/superpowers/plans/2025-05-24-mobile-event-modal.md new file mode 100644 index 0000000..06b4366 --- /dev/null +++ b/docs/superpowers/plans/2025-05-24-mobile-event-modal.md @@ -0,0 +1,997 @@ +# Mobile Event Edit Modal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the mobile-unusable centred `Dialog` on `EventDialog` with a shadcn `Drawer` (Vaul) that guides the user through three steps — Details, Schedule, Recurrence — while preserving the desktop `Dialog` unchanged. + +**Architecture:** `useIsMobile` drives a conditional render: desktop gets the existing `Dialog`, mobile gets a new `Drawer` that renders one of three step components (`DetailsStep`, `ScheduleStep`, `RecurrenceStep`) at a time. Step state lives in a small inline hook. The single `useForm` instance is shared across all steps so data is never lost. + +**Tech Stack:** Next.js 15, React 19, React Hook Form 7, shadcn/ui (Vaul `Drawer`), Bun test runner, Biome + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/components/ui/drawer.tsx` | **Create** | shadcn Drawer primitives wrapping Vaul | +| `src/components/event-dialog.tsx` | **Modify** | add step hook, `DetailsStep`, `ScheduleStep`, `RecurrenceStep`, Drawer branch | +| `src/components/ui/dialog.tsx` | **Modify** | remove `isMobile` forks from `DialogContent` and `DialogFooter` | +| `tests/event-dialog.test.tsx` | **Modify** | add tests for step sections, progress bars, AI banner, step footer labels | +| `tests/mobile-hook-adoption.test.ts` | **Modify** | add `drawer.tsx` to the hook-driven files list | + +--- + +## Task 1: Install Vaul and add `drawer.tsx` + +**Files:** +- Create: `src/components/ui/drawer.tsx` +- Modify: `package.json`, `bun.lock` + +- [ ] **Step 1: Install Vaul** + +```bash +bun add vaul +``` + +Expected: `vaul` appears in `dependencies` in `package.json`. + +- [ ] **Step 2: Create `src/components/ui/drawer.tsx`** + +```tsx +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { cn } from "@/lib/utils"; + +function Drawer({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +}; +``` + +- [ ] **Step 3: Verify the file compiles with no import errors** + +```bash +cd /path/to/project && bun run build 2>&1 | grep -i "drawer" | head -20 +``` + +Expected: no errors mentioning `drawer.tsx`. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ui/drawer.tsx package.json bun.lock +git commit -m "feat: add shadcn Drawer component (Vaul)" +``` + +--- + +## Task 2: Write failing tests for the Drawer branch + +**Files:** +- Modify: `tests/event-dialog.test.tsx` +- Modify: `tests/mobile-hook-adoption.test.ts` + +- [ ] **Step 1: Add source-level assertions to `tests/event-dialog.test.tsx`** + +Replace the entire file with: + +```tsx +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { getEventFormValuesFromEvent } from "@/lib/event-form"; + +describe("EventDialog public modes", () => { + test("accepts AI-prefilled editable initial values through its public props", () => { + const initialValues = getEventFormValuesFromEvent({ + title: "AI Draft", + start: "2026-04-09T10:00:00.000Z", + recurrenceRule: "FREQ=WEEKLY;INTERVAL=1;BYDAY=TH", + }); + + expect(initialValues.title).toBe("AI Draft"); + expect(initialValues.start).toBe("2026-04-09T10:00:00.000Z"); + expect(initialValues.recurrenceRule).toBe("FREQ=WEEKLY;INTERVAL=1;BYDAY=TH"); + }); + + test("dialog content uses console surface classes instead of generic border and shadow", () => { + const source = readFileSync("src/components/ui/dialog.tsx", "utf8"); + + expect(source).toContain("rounded-[10px]"); + expect(source).toContain("shadow-xl"); + expect(source).not.toContain("rounded-lg border p-6 shadow-lg"); + }); + + test("dialog source uses console section labels and grouped field regions", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("Event details"); + expect(source).toContain("Schedule"); + expect(source).toContain("Recurrence"); + }); + + test("event-dialog imports Drawer for mobile branch", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("from \"@/components/ui/drawer\""); + }); + + test("event-dialog renders three step components", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("DetailsStep"); + expect(source).toContain("ScheduleStep"); + expect(source).toContain("RecurrenceStep"); + }); + + test("event-dialog includes step progress bars", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + // Three progress bar divs rendered inside a grid-cols-3 container + expect(source).toContain("grid-cols-3"); + }); + + test("event-dialog includes step counter badge", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + // Step badge like "1 / 3" + expect(source).toContain("/ 3"); + }); + + test("drawer footer shows Cancel on step 1 and Back on steps 2 and 3", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("Cancel"); + expect(source).toContain("Back"); + }); + + test("drawer footer shows Save only on step 3", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("Save"); + expect(source).toContain("Next"); + }); + + test("event-dialog resets step to 1 on close", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("setStep(1)"); + }); + + test("dialog.tsx no longer forks on isMobile in DialogContent", () => { + const source = readFileSync("src/components/ui/dialog.tsx", "utf8"); + + // isMobile should not be used to conditionally apply max-w inside DialogContent + expect(source).not.toContain("isMobile ? undefined : \"max-w-lg\""); + }); + + test("dialog.tsx no longer forks on isMobile in DialogFooter", () => { + const source = readFileSync("src/components/ui/dialog.tsx", "utf8"); + + // DialogFooter should not branch on isMobile + expect(source).not.toContain("isMobile\n\t\t\t\t? \"flex flex-col-reverse gap-2\""); + }); +}); +``` + +- [ ] **Step 2: Add `drawer.tsx` to hook-driven file list in `tests/mobile-hook-adoption.test.ts`** + +In `tests/mobile-hook-adoption.test.ts`, add `"src/components/ui/drawer.tsx"` to the `HOOK_DRIVEN_FILES` array. The drawer itself does not call `useIsMobile` (the caller decides the branch), so only add it to `HOOK_DRIVEN_FILES` — not to `DIRECT_HOOK_FILES`. The test checks that it contains no Tailwind breakpoint prefixes. + +Replace the `HOOK_DRIVEN_FILES` array: + +```ts +const HOOK_DRIVEN_FILES = [ + "src/app/page.tsx", + "src/app/demo/combined-date-picker/page.tsx", + "src/components/ai-toolbar.tsx", + "src/components/event-dialog.tsx", + "src/components/settings-panel.tsx", + "src/components/ui/calendar.tsx", + "src/components/ui/date-picker.tsx", + "src/components/ui/dialog.tsx", + "src/components/ui/drawer.tsx", + "src/components/ui/input-group.tsx", + "src/components/ui/textarea.tsx", + "src/lib/ui-shell-contract.ts", +]; +``` + +- [ ] **Step 3: Run the new tests and confirm they fail for the right reasons** + +```bash +bun test tests/event-dialog.test.tsx 2>&1 +``` + +Expected: the four new tests (`imports Drawer`, `renders three step components`, `grid-cols-3`, `/ 3`, `setStep(1)`, `no longer forks`) FAIL because the implementation does not exist yet. The first three existing tests should still PASS. + +- [ ] **Step 4: Commit the failing tests** + +```bash +git add tests/event-dialog.test.tsx tests/mobile-hook-adoption.test.ts +git commit -m "test: add failing tests for Drawer mobile branch in EventDialog" +``` + +--- + +## Task 3: Extract step sub-components inside `event-dialog.tsx` + +**Files:** +- Modify: `src/components/event-dialog.tsx` + +These are pure presentational helpers declared at the bottom of the same file. They receive form state as props and render one section's fields. + +- [ ] **Step 1: Add the `StepProps` interface and `DetailsStep` at the bottom of `event-dialog.tsx`** + +Below the closing `};` of `EventDialog`, add: + +```tsx +interface StepProps { + control: ReturnType>["control"]; + register: ReturnType>["register"]; + errors: ReturnType>["formState"]["errors"]; + watch: ReturnType>["watch"]; + setValue: ReturnType>["setValue"]; + isAiDraft: boolean; +} + +function AiDraftBanner() { + return ( +
+ This draft was generated from natural language. Double-check + dates, times, location, recurrence, and links before saving. +
+ ); +} + +function DetailsStep({ control, register, errors, isAiDraft }: Omit) { + const isMobile = useIsMobile(); + return ( +
+ {isAiDraft && } +
+ + + {errors.title && ( +

{errors.title.message}

+ )} +
+
+ +