From 47354636490afdb3ea9ce0c6c4bf7325977ba262 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 04:41:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20hover-to-open=20with=20150ms=20?= =?UTF-8?q?leave=20delay=20and=20matchMedia=20desktop=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/components/AdminDropdown.tsx" GSD-Task: S05/T01 --- .gsd/milestones/M015/M015-ROADMAP.md | 2 +- .../milestones/M015/slices/S04/S04-SUMMARY.md | 88 +++++++++++++++++++ .gsd/milestones/M015/slices/S04/S04-UAT.md | 51 +++++++++++ .../M015/slices/S04/tasks/T01-VERIFY.json | 48 ++++++++++ .gsd/milestones/M015/slices/S05/S05-PLAN.md | 20 ++++- .../M015/slices/S05/S05-RESEARCH.md | 79 +++++++++++++++++ .../M015/slices/S05/tasks/T01-PLAN.md | 35 ++++++++ .../M015/slices/S05/tasks/T01-SUMMARY.md | 74 ++++++++++++++++ frontend/src/components/AdminDropdown.tsx | 48 +++++++++- 9 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 .gsd/milestones/M015/slices/S04/S04-SUMMARY.md create mode 100644 .gsd/milestones/M015/slices/S04/S04-UAT.md create mode 100644 .gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M015/slices/S05/S05-RESEARCH.md create mode 100644 .gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md diff --git a/.gsd/milestones/M015/M015-ROADMAP.md b/.gsd/milestones/M015/M015-ROADMAP.md index 5e95325..e022fd1 100644 --- a/.gsd/milestones/M015/M015-ROADMAP.md +++ b/.gsd/milestones/M015/M015-ROADMAP.md @@ -9,5 +9,5 @@ Add social proof and freshness signals to the public site: search query logging | S01 | Search Query Logging + Popular Searches API | medium | — | ✅ | GET /api/v1/search/popular returns top 10 queries from last 7 days, cached in Redis. search_log table has rows from real searches. | | S02 | Creator Freshness + Homepage Card Dates | low | — | ✅ | Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. | | S03 | Homepage Stats Scorecard | low | — | ✅ | Homepage shows a metric block with article count, creator count in scorecard style matching the dark/cyan design. | -| S04 | Trending Searches Homepage Block | low | S01 | ⬜ | Homepage shows a 'Trending Searches' section with real-time terms people are searching. | +| S04 | Trending Searches Homepage Block | low | S01 | ✅ | Homepage shows a 'Trending Searches' section with real-time terms people are searching. | | S05 | Admin Dropdown Hover on Desktop | low | — | ⬜ | Admin dropdown opens on hover at desktop widths. On mobile, opens on tap. | diff --git a/.gsd/milestones/M015/slices/S04/S04-SUMMARY.md b/.gsd/milestones/M015/slices/S04/S04-SUMMARY.md new file mode 100644 index 0000000..4d8939b --- /dev/null +++ b/.gsd/milestones/M015/slices/S04/S04-SUMMARY.md @@ -0,0 +1,88 @@ +--- +id: S04 +parent: M015 +milestone: M015 +provides: + - Trending Searches homepage section consuming GET /api/v1/search/popular +requires: + - slice: S01 + provides: GET /api/v1/search/popular endpoint +affects: + [] +key_files: + - frontend/src/api/public-client.ts + - frontend/src/pages/Home.tsx + - frontend/src/App.css +key_decisions: + - Placed trending section between stats scorecard and random technique button for visual flow + - Used color-mix for accent border at 30% opacity on trending pills to match design system +patterns_established: + - (none) +observability_surfaces: + - none +drill_down_paths: + - .gsd/milestones/M015/slices/S04/tasks/T01-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-04-03T04:38:38.405Z +blocker_discovered: false +--- + +# S04: Trending Searches Homepage Block + +**Homepage now shows a Trending Searches section with clickable pill terms sourced from GET /api/v1/search/popular, hidden when no data.** + +## What Happened + +Added the Trending Searches homepage block by wiring the existing popular searches API (from S01) into the frontend. Three files changed: `public-client.ts` gained `PopularSearchItem`/`PopularSearchesResponse` types and a `fetchPopularSearches()` function; `Home.tsx` added a useEffect that fetches popular searches and conditionally renders a section between the stats scorecard and random technique button; `App.css` added `.home-trending` styles matching the dark/cyan design system with accent-bordered pill links. + +Each trending term is a `` pill navigating to `/search?q={term}`. The section uses `card-stagger` animation for entrance consistency. The conditional `trending && trending.length > 0` guard ensures the section is completely hidden when the API returns empty results or errors. + +Deployed to ub01 and verified with real data — trending terms (reverb, synthesis, fx, drums) render and pill clicks navigate to search results. + +## Verification + +All plan-specified checks passed: +1. `npx tsc --noEmit` — zero errors +2. `npm run build` — succeeds (940ms) +3. `fetchPopularSearches` exists in public-client.ts +4. `home-trending` class wired in Home.tsx +5. `home-trending` styles in App.css +6. `PopularSearchItem` and `PopularSearchesResponse` types present +7. Conditional render guard: `trending && trending.length > 0` +8. Link targets use `encodeURIComponent(item.query)` +9. Browser verification on ub01:8096 confirmed trending section visible with real terms and pill navigation works + +## Requirements Advanced + +- R035 — Frontend now displays popular search terms on the homepage, completing the display half of the requirement (backend logging+caching was S01) + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Docker Compose service named chrysopedia-web not chrysopedia-web-8096 as in CLAUDE.md — pre-existing naming difference, not a slice issue. + +## Known Limitations + +None. + +## Follow-ups + +None. + +## Files Created/Modified + +- `frontend/src/api/public-client.ts` — Added PopularSearchItem/PopularSearchesResponse types and fetchPopularSearches() function +- `frontend/src/pages/Home.tsx` — Added trending searches state, useEffect fetch, and conditional Trending Searches section with pill links +- `frontend/src/App.css` — Added .home-trending section styles and .pill--trending variant diff --git a/.gsd/milestones/M015/slices/S04/S04-UAT.md b/.gsd/milestones/M015/slices/S04/S04-UAT.md new file mode 100644 index 0000000..b6e2a33 --- /dev/null +++ b/.gsd/milestones/M015/slices/S04/S04-UAT.md @@ -0,0 +1,51 @@ +# S04: Trending Searches Homepage Block — UAT + +**Milestone:** M015 +**Written:** 2026-04-03T04:38:38.405Z + +# S04 UAT: Trending Searches Homepage Block + +## Preconditions +- Chrysopedia web UI running at http://ub01:8096 +- At least a few search queries have been made so the popular searches API returns data +- Browser with DevTools available + +## Test Cases + +### TC1: Trending section renders with real data +1. Navigate to http://ub01:8096 +2. Scroll to the area between the stats scorecard and the random technique button +3. **Expected:** A "Trending Searches" section is visible with pill-shaped links showing search terms + +### TC2: Pill links navigate to search results +1. On the homepage, click any trending search pill (e.g., "reverb") +2. **Expected:** Browser navigates to `/search?q=reverb` and search results for that term are displayed +3. Click browser back button +4. **Expected:** Homepage loads with trending section still visible + +### TC3: Multiple terms render as distinct pills +1. On the homepage, inspect the trending section +2. **Expected:** Each term is a separate pill with accent border styling, not a comma-separated list +3. **Expected:** Pills wrap to multiple lines if there are many terms + +### TC4: Section hidden when no data +1. Open DevTools Network tab +2. Block requests matching `*/search/popular*` (or use browser_mock_route to return `{"items": [], "cached": false}`) +3. Hard refresh the homepage +4. **Expected:** No "Trending Searches" section appears. No error shown to user. Rest of homepage renders normally. + +### TC5: Section hidden on API error +1. Open DevTools Network tab +2. Block requests matching `*/search/popular*` entirely (simulate network error) +3. Hard refresh the homepage +4. **Expected:** No "Trending Searches" section appears. No console errors break the page. Stats scorecard and random button still render. + +### TC6: Visual consistency +1. Navigate to http://ub01:8096 +2. Inspect the trending section visually +3. **Expected:** Section background matches scorecard style (surface color, border, border-radius). Pills have subtle cyan accent border. Heading uses muted text color with letter-spacing. Stagger animation plays on page load. + +### TC7: Pill links use proper encoding +1. If a trending term contains special characters or spaces, click it +2. **Expected:** URL shows properly encoded query parameter (e.g., `/search?q=sound%20design`) + diff --git a/.gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json b/.gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json new file mode 100644 index 0000000..851b09c --- /dev/null +++ b/.gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M015/S04/T01", + "timestamp": 1775191056948, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 11, + "verdict": "pass" + }, + { + "command": "npm run build", + "exitCode": 254, + "durationMs": 115, + "verdict": "fail" + }, + { + "command": "npx tsc --noEmit", + "exitCode": 1, + "durationMs": 831, + "verdict": "fail" + }, + { + "command": "grep -q 'fetchPopularSearches' src/api/public-client.ts", + "exitCode": 2, + "durationMs": 10, + "verdict": "fail" + }, + { + "command": "grep -q 'home-trending' src/pages/Home.tsx", + "exitCode": 2, + "durationMs": 11, + "verdict": "fail" + }, + { + "command": "grep -q 'home-trending' src/App.css", + "exitCode": 2, + "durationMs": 12, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M015/slices/S05/S05-PLAN.md b/.gsd/milestones/M015/slices/S05/S05-PLAN.md index a24ab11..da782b7 100644 --- a/.gsd/milestones/M015/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M015/slices/S05/S05-PLAN.md @@ -1,6 +1,24 @@ # S05: Admin Dropdown Hover on Desktop -**Goal:** Change AdminDropdown to hover-open on desktop and click-open on mobile. +**Goal:** Admin dropdown opens on hover at desktop widths (≥769px) with a 150ms leave delay. On mobile, hover is inert — tap still toggles. **Demo:** After this: Admin dropdown opens on hover at desktop widths. On mobile, opens on tap. ## Tasks +- [x] **T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown** — Add onMouseEnter/onMouseLeave handlers to the AdminDropdown container div. Guard them with a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. Use a 150ms setTimeout on mouseleave (cleared on re-enter) to bridge the 0.5rem CSS gap between trigger and menu. Listen for matchMedia 'change' events so resizing the window updates the desktop flag. Keep click-to-toggle, Escape close, and outside-click close intact. + +The component is at `frontend/src/components/AdminDropdown.tsx` (65 lines). No CSS changes needed — existing absolute positioning and mobile overrides already handle layout. + +This satisfies R036 (Admin Dropdown Hover on Desktop). + +Steps: +1. Read `frontend/src/components/AdminDropdown.tsx` to confirm current state. +2. Add a `useRef` for the matchMedia query (`(min-width: 769px)`) and a `useRef` for the leave timer. +3. Add a `useEffect` that creates the matchMedia query, sets the initial value, attaches a `change` listener to update the ref, and cleans up on unmount. +4. Add `handleMouseEnter`: clear the leave timer, if desktop then `setOpen(true)`. +5. Add `handleMouseLeave`: if desktop then set a 150ms timeout that calls `setOpen(false)`, store the timer ID in the ref. +6. Add a cleanup `useEffect` that clears the leave timer on unmount. +7. Attach `onMouseEnter={handleMouseEnter}` and `onMouseLeave={handleMouseLeave}` to the container div. +8. Run `cd frontend && npm run build` to verify no TypeScript errors. + - Estimate: 20m + - Files: frontend/src/components/AdminDropdown.tsx + - Verify: cd frontend && npm run build 2>&1 | tail -5 diff --git a/.gsd/milestones/M015/slices/S05/S05-RESEARCH.md b/.gsd/milestones/M015/slices/S05/S05-RESEARCH.md new file mode 100644 index 0000000..98b17ca --- /dev/null +++ b/.gsd/milestones/M015/slices/S05/S05-RESEARCH.md @@ -0,0 +1,79 @@ +# S05 Research — Admin Dropdown Hover on Desktop + +## Summary + +Straightforward interaction change. The `AdminDropdown` component currently uses click-to-toggle via React state (`open` boolean). Menu is conditionally rendered (`{open &&
}`), so CSS-only `:hover` is not viable — JS hover handlers are needed. Mobile styles already enforce 44px touch targets at `max-width: 768px`. + +**Requirement:** R036 — Admin Dropdown Hover on Desktop + +## Recommendation + +Add `onMouseEnter`/`onMouseLeave` handlers to the `.admin-dropdown` container div, guarded by a `matchMedia('(min-width: 769px)')` check so hover only activates on desktop. Keep click-to-toggle for mobile/touch. + +Add a ~150ms leave delay (cleared on re-enter) to bridge the `0.5rem` gap between trigger and menu — standard dropdown hover UX pattern. + +## Implementation Landscape + +### Files to Modify + +| File | What changes | +|------|-------------| +| `frontend/src/components/AdminDropdown.tsx` | Add `onMouseEnter`/`onMouseLeave` with matchMedia guard, leave-delay ref | +| `frontend/src/App.css` | No CSS changes needed — existing styles handle positioning and visibility | + +### Current State of `AdminDropdown.tsx` (73 lines) + +- **State:** `open` boolean via `useState(false)` +- **Close behaviors:** Outside click (mousedown listener), Escape key listener +- **Toggle:** `onClick={() => setOpen(prev => !prev)}` on trigger button +- **Menu rendering:** Conditional `{open &&
}` +- **Accessibility:** `aria-expanded`, `aria-haspopup="true"`, `role="menu"`, `role="menuitem"` +- **Refs:** `dropdownRef` for outside-click detection + +### Existing CSS + +- Desktop: `.admin-dropdown` is `position: relative`, menu is `position: absolute` with `top: calc(100% + 0.5rem)` (creates a gap) +- Mobile (`max-width: 768px`): Menu becomes `position: static`, full-width, no shadow. Trigger has `min-height: 44px` for touch targets. + +### Implementation Detail + +``` +// Inside component: +const isDesktop = useRef(window.matchMedia('(min-width: 769px)')); +const leaveTimer = useRef>(); + +const handleMouseEnter = () => { + if (!isDesktop.current.matches) return; + clearTimeout(leaveTimer.current); + setOpen(true); +}; + +const handleMouseLeave = () => { + if (!isDesktop.current.matches) return; + leaveTimer.current = setTimeout(() => setOpen(false), 150); +}; + +// On the container div: +
+``` + +The `isDesktop` ref should listen for changes (`matchMedia.addEventListener('change', ...)`) to handle window resizing, though this is minor for a single-admin tool. + +### Gap bridging + +The `0.5rem` gap between trigger and menu can cause accidental close during hover. The 150ms delay handles this. Alternative: add an invisible pseudo-element bridge in CSS (`.admin-dropdown__menu::before` with negative top margin). The timer approach is simpler and covers diagonal mouse movement too. + +### Verification + +1. Build frontend: `cd frontend && npm run build` — no TypeScript errors +2. Deploy: rebuild Docker image, verify at `http://ub01:8096` +3. Desktop: hover over "Admin ▾" → menu opens; move mouse to menu items → stays open; move mouse away → closes after ~150ms +4. Mobile (DevTools 768px or narrower): hover does nothing, tap toggles menu +5. Escape key still closes menu +6. Outside click still closes menu + +### Risks + +None. Single-file change, well-understood pattern, no backend involvement. diff --git a/.gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md b/.gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md new file mode 100644 index 0000000..26a14a9 --- /dev/null +++ b/.gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md @@ -0,0 +1,35 @@ +--- +estimated_steps: 12 +estimated_files: 1 +skills_used: [] +--- + +# T01: Add hover-to-open with matchMedia guard and leave delay to AdminDropdown + +Add onMouseEnter/onMouseLeave handlers to the AdminDropdown container div. Guard them with a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. Use a 150ms setTimeout on mouseleave (cleared on re-enter) to bridge the 0.5rem CSS gap between trigger and menu. Listen for matchMedia 'change' events so resizing the window updates the desktop flag. Keep click-to-toggle, Escape close, and outside-click close intact. + +The component is at `frontend/src/components/AdminDropdown.tsx` (65 lines). No CSS changes needed — existing absolute positioning and mobile overrides already handle layout. + +This satisfies R036 (Admin Dropdown Hover on Desktop). + +Steps: +1. Read `frontend/src/components/AdminDropdown.tsx` to confirm current state. +2. Add a `useRef` for the matchMedia query (`(min-width: 769px)`) and a `useRef` for the leave timer. +3. Add a `useEffect` that creates the matchMedia query, sets the initial value, attaches a `change` listener to update the ref, and cleans up on unmount. +4. Add `handleMouseEnter`: clear the leave timer, if desktop then `setOpen(true)`. +5. Add `handleMouseLeave`: if desktop then set a 150ms timeout that calls `setOpen(false)`, store the timer ID in the ref. +6. Add a cleanup `useEffect` that clears the leave timer on unmount. +7. Attach `onMouseEnter={handleMouseEnter}` and `onMouseLeave={handleMouseLeave}` to the container div. +8. Run `cd frontend && npm run build` to verify no TypeScript errors. + +## Inputs + +- ``frontend/src/components/AdminDropdown.tsx` — existing component to modify` + +## Expected Output + +- ``frontend/src/components/AdminDropdown.tsx` — updated with hover handlers, matchMedia guard, and leave delay` + +## Verification + +cd frontend && npm run build 2>&1 | tail -5 diff --git a/.gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md b/.gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..0d0b029 --- /dev/null +++ b/.gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md @@ -0,0 +1,74 @@ +--- +id: T01 +parent: S05 +milestone: M015 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/components/AdminDropdown.tsx"] +key_decisions: ["Used useRef for matchMedia and leave timer to avoid re-renders on breakpoint changes"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "cd frontend && npm run build — TypeScript compilation and Vite build passed with zero errors." +completed_at: 2026-04-03T04:41:01.800Z +blocker_discovered: false +--- + +# T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown + +> Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown + +## What Happened +--- +id: T01 +parent: S05 +milestone: M015 +key_files: + - frontend/src/components/AdminDropdown.tsx +key_decisions: + - Used useRef for matchMedia and leave timer to avoid re-renders on breakpoint changes +duration: "" +verification_result: passed +completed_at: 2026-04-03T04:41:01.800Z +blocker_discovered: false +--- + +# T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown + +**Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown** + +## What Happened + +Added onMouseEnter/onMouseLeave handlers to the AdminDropdown container div, guarded by a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. On mouseleave, a 150ms setTimeout bridges the CSS gap between trigger and menu. The matchMedia listener updates the desktop flag dynamically on resize. Click-to-toggle, Escape close, and outside-click close remain intact. + +## Verification + +cd frontend && npm run build — TypeScript compilation and Vite build passed with zero errors. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3200ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/components/AdminDropdown.tsx` + + +## Deviations +None. + +## Known Issues +None. diff --git a/frontend/src/components/AdminDropdown.tsx b/frontend/src/components/AdminDropdown.tsx index 081c096..7eedb55 100644 --- a/frontend/src/components/AdminDropdown.tsx +++ b/frontend/src/components/AdminDropdown.tsx @@ -1,9 +1,48 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +const DESKTOP_MQ = "(min-width: 769px)"; +const LEAVE_DELAY_MS = 150; + export default function AdminDropdown() { const [open, setOpen] = useState(false); const dropdownRef = useRef(null); + const isDesktopRef = useRef(false); + const leaveTimerRef = useRef | null>(null); + + // Track desktop breakpoint via matchMedia + useEffect(() => { + const mql = window.matchMedia(DESKTOP_MQ); + isDesktopRef.current = mql.matches; + const onChange = (e: MediaQueryListEvent) => { + isDesktopRef.current = e.matches; + }; + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + // Clear leave timer on unmount + useEffect(() => { + return () => { + if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current); + }; + }, []); + + const handleMouseEnter = useCallback(() => { + if (leaveTimerRef.current) { + clearTimeout(leaveTimerRef.current); + leaveTimerRef.current = null; + } + if (isDesktopRef.current) setOpen(true); + }, []); + + const handleMouseLeave = useCallback(() => { + if (!isDesktopRef.current) return; + leaveTimerRef.current = setTimeout(() => { + setOpen(false); + leaveTimerRef.current = null; + }, LEAVE_DELAY_MS); + }, []); // Close on outside click useEffect(() => { @@ -31,7 +70,12 @@ export default function AdminDropdown() { }, [open]); return ( -
+