feat: Added hover-to-open with 150ms leave delay and matchMedia desktop…

- "frontend/src/components/AdminDropdown.tsx"

GSD-Task: S05/T01
This commit is contained in:
jlightner 2026-04-03 04:41:04 +00:00
parent 8b912e5a6f
commit 4735463649
9 changed files with 441 additions and 4 deletions

View file

@ -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. |

View file

@ -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 `<Link>` 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

View file

@ -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`)

View file

@ -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
}

View file

@ -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

View file

@ -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 && <div>}`), 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 && <div className="admin-dropdown__menu">}`
- **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<ReturnType<typeof setTimeout>>();
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:
<div className="admin-dropdown" ref={dropdownRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
```
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.

View file

@ -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

View file

@ -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.

View file

@ -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<HTMLDivElement>(null);
const isDesktopRef = useRef(false);
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="admin-dropdown" ref={dropdownRef}>
<div
className="admin-dropdown"
ref={dropdownRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
className="admin-dropdown__trigger"
onClick={() => setOpen((prev) => !prev)}