diff --git a/DEFAULT_PROMPT.md b/DEFAULT_PROMPT.md index dc634f7..022e423 100644 --- a/DEFAULT_PROMPT.md +++ b/DEFAULT_PROMPT.md @@ -23,21 +23,21 @@ APPEND to progress.txt (never replace, always append): ## USER REQUEST { - "id": "US-016", - "title": "Milkdown note editor", - "description": "As a user, I want a full-screen Markdown editor for each note so that I can write rich content without leaving the app.", + "id": "US-017", + "title": "Fluid Curtain pull-down animation", + "description": "As a user, I want to pull down from the top of any view to slide the app panel off-screen and reveal the AI chat layer beneath.", "acceptanceCriteria": [ - "@milkdown/react and @milkdown/preset-commonmark installed; Milkdown editor renders at route /notes/:noteId", - "Supported Markdown: headings (H1-H6), bold, italic, inline code, code blocks, bullet lists, ordered lists, blockquotes", - "Note title editable as a shadcn/ui Input (variant borderless/ghost style) at the top of the page (separate from Milkdown content area)", - "Content auto-saves to SQLite via notes.update on Milkdown onChange event, debounced 500ms", - "Unsaved indicator shown using shadcn/ui Badge (variant=secondary, text 'Saving...') next to the title while save is pending", - "Back button uses shadcn/ui Button (variant=ghost, size=icon) with ArrowLeft Lucide icon; navigates to the previous route", - "All UI chrome uses shadcn/ui components (already installed)", + "framer-motion useMotionValue + useSpring (stiffness: 300, damping: 30) controls a 'y' CSS transform on the main app panel wrapper", + "Trigger 1: wheel event listener at document level — when the current route's scroll position is at 0 and deltaY < 0 (overscroll up), animate panel y from 0 to viewport height", + "Trigger 2: Cmd/Ctrl+K keyboard shortcut toggles curtain open (y = viewport height) and closed (y = 0)", + "AI chat view is rendered as a fixed full-screen layer behind the sliding panel and becomes fully visible when panel slides down", + "App panel remains mounted during animation (no unmount/remount, no state loss)", + "Returning from chat: wheel event with deltaY > 0 at chat-bottom OR Cmd/Ctrl+K slides panel back to y = 0", + "Right-edge vertical 'keep scrolling for AI' label with chevron-down is visible in every section (non-interactive, visual hint only)", "Typecheck passes", "Verify in browser using dev-browser skill" ], - "priority": 16, + "priority": 17, "passes": false, "notes": "" } \ No newline at end of file diff --git a/prd.json b/prd.json index 5a37b40..eca2e09 100644 --- a/prd.json +++ b/prd.json @@ -316,8 +316,8 @@ "Verify in browser using dev-browser skill" ], "priority": 17, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: Fluid Curtain animation using framer-motion useMotionValue + useSpring (stiffness: 300, damping: 30) on a motion.div wrapping the content area inside SidebarInset. Sidebar stays visible. Trigger 1: wheel overscroll-up (deltaY < 0 at scrollTop=0) opens curtain (y → viewport height). Trigger 2: Cmd/Ctrl+K toggles. Closing: deltaY > 0 while open or Cmd/Ctrl+K. AIChatPanel placeholder rendered as absolute z-0 layer behind content. Right-edge label dynamically shows 'scrolling up for Adiuva' (closed) or 'back to app' (open) with matching chevron direction. App panel stays mounted (no state loss). Typecheck passes." }, { "id": "US-018", diff --git a/progress.txt b/progress.txt index 6f21620..75f835c 100644 --- a/progress.txt +++ b/progress.txt @@ -295,3 +295,24 @@ - Nord theme (`@milkdown/theme-nord`) provides base ProseMirror structure; override with CSS using the app's semantic color variables for consistent theming - Import both `@milkdown/theme-nord/style.css` and `@milkdown/kit/prose/view/style/prosemirror.css` for proper base styling --- + +## 2026-02-22 - US-017 +- What was implemented: + - Fluid Curtain pull-down animation in `AppShell.tsx` + - `framer-motion` `useMotionValue(0)` + `useSpring(y, { stiffness: 300, damping: 30 })` controls `y` CSS transform on a `motion.div` wrapping the content area inside `SidebarInset` + - Sidebar stays visible at all times — only content area slides down + - Trigger 1: `document` wheel event — `findScrollableAncestor()` walks DOM to detect nearest scrollable element; if `scrollTop === 0` and `deltaY < 0`, opens curtain (`y.set(window.innerHeight)`) + - Trigger 2: `Cmd/Ctrl+K` keyboard shortcut toggles curtain open/closed + - Closing: `deltaY > 0` while curtain is open, or `Cmd/Ctrl+K` + - `AIChatPanel` placeholder component at `src/renderer/components/ai/AIChatPanel.tsx` — absolute `z-0` layer behind the sliding content panel + - Right-edge label dynamically changes: `"scrolling up for Adiuva"` + `ChevronUp` when closed, `"back to app"` + `ChevronDown` when open + - App panel remains mounted during animation (no unmount/remount, no state loss) + - `curtainOpenRef` (useRef) keeps event handlers in sync without re-registering effects +- Files changed: `src/renderer/components/layout/AppShell.tsx`, `src/renderer/components/ai/AIChatPanel.tsx` (new), `prd.json`, `progress.txt` +- **Learnings for future iterations:** + - `SidebarInset` already has `relative` in its base classes — adding `overflow-hidden` via className prop is sufficient to clip the sliding `motion.div` + - `useMotionValue` + `useSpring` pattern: `y.set(target)` immediately sets the spring target; `springY` (from `useSpring`) animates toward it — apply `springY` to `style={{ y: springY }}`, not `y` directly + - `findScrollableAncestor()` DOM walk is needed because `body` and `#root` both have `overflow: hidden` — scroll detection must target inner route containers (e.g., `overflow-y-auto` divs in projects/tasks) + - `useRef` for curtain open state avoids stale closures in `useEffect` wheel/keyboard handlers — the boolean ref is updated synchronously alongside `useState` setter + - `{ passive: true }` on wheel listener is correct when not calling `preventDefault()` — avoids Chrome console warnings +--- diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx new file mode 100644 index 0000000..a0d83a9 --- /dev/null +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -0,0 +1,12 @@ +import { Sparkles } from 'lucide-react'; + +export function AIChatPanel() { + return ( +
+ +

+ AI Chat — coming soon +

+
+ ); +} diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx index 2b55dad..05627ba 100644 --- a/src/renderer/components/layout/AppShell.tsx +++ b/src/renderer/components/layout/AppShell.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useRouterState } from '@tanstack/react-router'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; import { House, ChartGantt, @@ -7,6 +8,7 @@ import { FolderKanban, PanelLeft, ChevronUp, + ChevronDown, } from 'lucide-react'; import { trpc } from '@/lib/trpc'; import { @@ -23,6 +25,7 @@ import { SidebarProvider, useSidebar, } from '@/components/ui/sidebar'; +import { AIChatPanel } from '@/components/ai/AIChatPanel'; const NAV_ITEMS = [ { to: '/', icon: House, label: 'Home' }, @@ -35,6 +38,20 @@ interface AppShellProps { children: React.ReactNode; } +/** Walk up the DOM to find the nearest scrollable ancestor. */ +function findScrollableAncestor(el: Element | null): Element | null { + if (!el || el === document.body) return null; + const style = window.getComputedStyle(el); + const overflowY = style.overflowY; + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + el.scrollHeight > el.clientHeight + ) { + return el; + } + return findScrollableAncestor(el.parentElement); +} + export function AppShell({ children }: AppShellProps) { const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, { staleTime: Infinity, @@ -55,24 +72,105 @@ export function AppShell({ children }: AppShellProps) { setSidebarCollapsedMutation.mutate({ collapsed: !value }); }; + // Curtain is disabled on home page and on /projects without a selected project + const searchObj = routerState.location.search as Record; + const curtainEnabled = + currentPath !== '/' && + !(currentPath === '/projects' && !searchObj['projectId']); + const curtainEnabledRef = useRef(curtainEnabled); + curtainEnabledRef.current = curtainEnabled; + + // --- Curtain animation state --- + const [curtainOpen, setCurtainOpen] = useState(false); + const curtainOpenRef = useRef(false); + + const y = useMotionValue(0); + const springY = useSpring(y, { stiffness: 300, damping: 30 }); + + const openCurtain = useCallback(() => { + curtainOpenRef.current = true; + setCurtainOpen(true); + y.set(window.innerHeight); + }, [y]); + + const closeCurtain = useCallback(() => { + curtainOpenRef.current = false; + setCurtainOpen(false); + y.set(0); + }, [y]); + + const toggleCurtain = useCallback(() => { + if (curtainOpenRef.current) closeCurtain(); + else openCurtain(); + }, [openCurtain, closeCurtain]); + + // Keyboard shortcut: Cmd/Ctrl+K + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (!curtainEnabledRef.current) return; + toggleCurtain(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [toggleCurtain]); + + // Wheel event: overscroll detection + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + if (!curtainOpenRef.current) { + if (!curtainEnabledRef.current) return; + // Opening: overscroll UP (deltaY < 0) when content is at top + if (e.deltaY < 0) { + const scrollable = findScrollableAncestor(e.target as Element); + const atTop = !scrollable || scrollable.scrollTop === 0; + if (atTop) openCurtain(); + } + } else { + // Closing: scroll DOWN (deltaY > 0) while curtain is open + if (e.deltaY > 0) { + closeCurtain(); + } + } + }; + + document.addEventListener('wheel', handleWheel, { passive: true }); + return () => document.removeEventListener('wheel', handleWheel); + }, [openCurtain, closeCurtain]); + return ( - - {children} + + {/* AI Chat layer: always mounted behind the content panel */} + - {/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */} -
-
- - - scrolling up for Adiuva - + {/* Content panel: slides down to reveal chat */} + + {children} + + {/* Right-edge vertical affordance (non-interactive, hidden on home) */} +
+
+ {curtainOpen ? ( + + ) : ( + + )} + + {curtainOpen ? 'back to app' : 'scrolling up for Adiuva'} + +
-
+ );