feat: Refactored SearchAutocomplete from heroSize boolean to variant st…

- "frontend/src/components/SearchAutocomplete.tsx"
- "frontend/src/App.tsx"
- "frontend/src/App.css"
- "frontend/src/pages/Home.tsx"
- "frontend/src/pages/SearchResults.tsx"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-03-31 08:42:15 +00:00
parent adc86446f1
commit 50675db557
15 changed files with 684 additions and 10 deletions

View file

@ -37,6 +37,11 @@ Ten milestones complete. The system is deployed and running on ub01 at `http://u
- **Related techniques cross-linking** — Every technique page shows up to 4 related techniques scored by creator overlap, topic match, and shared tags. Curated links take priority; dynamic results fill remaining slots.
- **Topic color coding & page transitions** — Per-category accent colors on sub-topic pages and search results. CSS-only fade-in page enter animation on all 7 public pages.
- **Search autocomplete** — Shared SearchAutocomplete component with popular suggestions on empty focus and debounced typeahead on 2+ characters. Used on Home and SearchResults pages.
- **Interaction delight** — Card hover animations (scale + shadow), staggered entrance animations on card grids, featured technique glow treatment, Random Technique discovery button.
- **Topics collapse/expand** — Topics page loads with all categories collapsed. Smooth CSS grid-template-rows animation on expand/collapse.
- **Creator stats pills** — Creator detail page topic stats rendered as colored pill badges matching category colors.
- **Tag overflow** — Shared TagList component caps visible tags at 4 with "+N more" overflow pill. Applied across all 5 tag-rendering sites.
- **Empty subtopic handling** — Subtopics with 0 techniques show "Coming soon" badge instead of dead-end links.
### Stack

View file

@ -7,6 +7,6 @@ Transform Chrysopedia from functionally adequate to engaging and accessible. Add
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Interaction Delight & Discovery | medium | — | ✅ | Cards animate on hover with scale+shadow. Card grids stagger entrance. Featured technique has glow treatment. Random Technique button navigates to a random page. |
| S02 | Topics, Creator Stats & Card Polish | medium | — | | Topics page loads collapsed, expands with animation. Creator stats show colored topic pills. Cards limit tags to 4 with +N more. Empty subtopics show Coming soon. |
| S02 | Topics, Creator Stats & Card Polish | medium | — | | Topics page loads collapsed, expands with animation. Creator stats show colored topic pills. Cards limit tags to 4 with +N more. Empty subtopics show Coming soon. |
| S03 | Global Search & Mobile Navigation | medium | — | ⬜ | Compact search bar in nav on all pages. Cmd+K focuses it. Mobile viewport shows hamburger menu. |
| S04 | Accessibility & SEO Fixes | low | — | ⬜ | Every page has single H1, skip-to-content link, AA contrast text, and descriptive browser tab title. |

View file

@ -0,0 +1,96 @@
---
id: S02
parent: M011
milestone: M011
provides:
- TagList component available for any future tag-rendering sites
- Collapse/expand animation pattern reusable for other accordion-style UI
requires:
[]
affects:
[]
key_files:
- frontend/src/components/TagList.tsx
- frontend/src/pages/TopicsBrowse.tsx
- frontend/src/pages/CreatorDetail.tsx
- frontend/src/pages/Home.tsx
- frontend/src/pages/SearchResults.tsx
- frontend/src/pages/SubTopicPage.tsx
- frontend/src/App.css
key_decisions:
- Used CSS grid-template-rows 0fr/1fr for smooth collapse/expand — no JS height measurement needed
- Added pillClass prop to TagList to preserve existing pill--tag styling in Home.tsx
- Kept single dot separator between video count and topic pills in CreatorDetail
patterns_established:
- Shared TagList component as single source for tag rendering with overflow — all 5 tag sites now use it
- CSS grid-template-rows 0fr/1fr pattern for animating variable-height content without JS
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M011/slices/S02/tasks/T01-SUMMARY.md
- .gsd/milestones/M011/slices/S02/tasks/T02-SUMMARY.md
- .gsd/milestones/M011/slices/S02/tasks/T03-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-31T08:36:24.689Z
blocker_discovered: false
---
# S02: Topics, Creator Stats & Card Polish
**Topics page loads collapsed with smooth CSS grid animation, creator stats use colored topic pills, tags capped at 4 with +N overflow via shared TagList component, empty subtopics show Coming soon badge.**
## What Happened
Three tasks delivered four UI improvements across the frontend.
**T01 — Topics collapse/expand animation.** Changed TopicsBrowse to initialize with an empty expanded set (all categories collapsed on load). Replaced the conditional render (`{isExpanded && ...}`) with an always-rendered wrapper using CSS `grid-template-rows: 0fr/1fr` animation. The wrapper transitions over 300ms, the inner div uses `overflow: hidden; min-height: 0` for smooth clipping. No JS measurement needed.
**T02 — Creator stats colored pills.** Replaced the run-on dot-separated topic stats in CreatorDetail with `badge badge--cat-{slug}` spans wrapped in a flex container. Reuses the `catSlug()` utility and existing badge color classes from the Topics page, so colors are consistent across the app.
**T03 — Shared TagList + empty subtopic handling.** Created `TagList.tsx` component with configurable `max` (default 4) and optional `pillClass` prop. Applied it across all 5 tag-rendering sites: Home (featured + recent cards), SearchResults, SubTopicPage, CreatorDetail. Added `pill--overflow` styling for the "+N more" badge. In TopicsBrowse, subtopics with `technique_count === 0` now render as non-clickable spans with a "Coming soon" pill instead of dead-end links. Added `topic-subtopic--empty` and `pill--coming-soon` CSS.
## Verification
TypeScript type check (`npx tsc --noEmit`) passes with zero errors. Production build (`npm run build`) succeeds — 51 modules transformed, built in 777ms. All key source artifacts confirmed: collapsed init in TopicsBrowse, grid-template-rows CSS rules, badge--cat classes in CreatorDetail, TagList component with overflow logic, Coming soon badge rendering for empty subtopics, TagList imported in all 4 consumer pages.
## Requirements Advanced
- R019 — Topics page initializes collapsed, expand/collapse uses smooth 300ms CSS grid animation
- R026 — Creator stats rendered as colored badge pills using catSlug-based classes
- R027 — TagList component caps visible tags at 4 with +N more overflow badge, applied across all 5 sites
- R028 — Empty subtopics render as non-clickable spans with Coming soon pill badge
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
T03 added a `pillClass` prop to TagList (not in plan) to preserve the existing `pill--tag` class used in Home.tsx. Minor additive change, no impact.
## Known Limitations
None.
## Follow-ups
None.
## Files Created/Modified
- `frontend/src/components/TagList.tsx` — New shared component — renders up to max tags with +N overflow pill
- `frontend/src/pages/TopicsBrowse.tsx` — Collapsed-by-default init, grid animation wrapper, empty subtopic Coming soon badge
- `frontend/src/pages/CreatorDetail.tsx` — Topic stats as colored badge pills in flex container, TagList for technique tags
- `frontend/src/pages/Home.tsx` — Replaced inline tag maps with TagList component (featured + recent cards)
- `frontend/src/pages/SearchResults.tsx` — Replaced inline tag map with TagList component
- `frontend/src/pages/SubTopicPage.tsx` — Replaced inline tag map with TagList component
- `frontend/src/App.css` — Added grid animation rules, topic-pills flex container, pill--overflow, pill--coming-soon, topic-subtopic--empty styles

View file

@ -0,0 +1,72 @@
# S02: Topics, Creator Stats & Card Polish — UAT
**Milestone:** M011
**Written:** 2026-03-31T08:36:24.689Z
# S02 UAT — Topics, Creator Stats & Card Polish
## Preconditions
- Chrysopedia frontend running (http://ub01:8096 or local dev server)
- At least one creator with multiple topic categories in the database
- At least one technique with more than 4 topic tags
- At least one subtopic with 0 techniques
---
## Test 1: Topics Page Loads Collapsed
1. Navigate to `/topics`
2. **Expected:** All 7 category cards are visible but no subtopic lists are shown
3. Click any category card header
4. **Expected:** Subtopics expand with a smooth ~300ms slide animation (not an instant pop)
5. Click the same category header again
6. **Expected:** Subtopics collapse with the same smooth animation
7. Click two different categories in sequence
8. **Expected:** Both expand independently; expanding one does not collapse the other
## Test 2: Empty Subtopic Coming Soon Badge
1. Navigate to `/topics`
2. Expand a category that contains a subtopic with 0 techniques
3. **Expected:** The empty subtopic shows its name with a "Coming soon" pill badge
4. **Expected:** The empty subtopic text is dimmed (opacity ~0.5) and the cursor is `default` (not pointer)
5. Click the empty subtopic
6. **Expected:** Nothing happens — it is not a link, no navigation occurs
7. Hover over the empty subtopic
8. **Expected:** No background highlight (unlike active subtopics)
## Test 3: Creator Stats Colored Pills
1. Navigate to `/creators` and click a creator with multiple topic categories
2. **Expected:** The stats line shows topic categories as colored pill badges (not plain text with dots)
3. **Expected:** Each pill has a distinct color corresponding to its topic category (same colors as Topics page category borders)
4. **Expected:** Pills wrap naturally if the container is narrow (flex-wrap behavior)
## Test 4: Tag Overflow on Cards
1. Navigate to the homepage (`/`)
2. Find a technique card (featured or recent) that has more than 4 tags in the database
3. **Expected:** Exactly 4 tag pills are visible, followed by a "+N more" pill in muted/italic style
4. Navigate to `/search` and search for a term that returns a technique with >4 tags
5. **Expected:** Same overflow behavior — 4 tags + "+N more"
6. Navigate to a subtopic page with a technique that has >4 tags
7. **Expected:** Same overflow behavior
8. Navigate to a creator detail page with a technique that has >4 tags
9. **Expected:** Same overflow behavior
## Test 5: Tag Overflow Edge Cases
1. Find a technique card with exactly 4 tags
2. **Expected:** All 4 tags shown, no "+N more" pill
3. Find a technique card with fewer than 4 tags (e.g., 2)
4. **Expected:** All tags shown, no "+N more" pill
5. Find a technique card with exactly 5 tags
6. **Expected:** 4 tags shown + "+1 more" pill
## Test 6: Collapse Animation is CSS-Based (Not Conditional Render)
1. Navigate to `/topics`
2. Open browser DevTools → Elements panel
3. Expand a category
4. **Expected:** The subtopics wrapper div is always present in the DOM (not conditionally rendered). Its `data-expanded` attribute toggles between true/absent. The `grid-template-rows` CSS property transitions between `0fr` and `1fr`.

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M011/S02/T03",
"timestamp": 1774946107163,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 821,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 84,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,60 @@
# S03: Global Search & Mobile Navigation
**Goal:** Make search accessible from any page and improve mobile navigation
**Goal:** Compact search bar in nav on all non-home pages with Cmd+K focus. Mobile viewport (<768px) shows hamburger menu with stacked nav links and generous touch targets.
**Demo:** After this: Compact search bar in nav on all pages. Cmd+K focuses it. Mobile viewport shows hamburger menu.
## Tasks
- [x] **T01: Refactored SearchAutocomplete from heroSize boolean to variant string prop and wired compact nav search bar with Cmd+K shortcut into App.tsx header on all non-home routes** — Refactor SearchAutocomplete from heroSize boolean to variant string prop ('hero' | 'inline' | 'nav'), add nav-variant CSS (compact sizing, hidden submit button, high z-index dropdown), add globalShortcut prop for Cmd+K/Ctrl+K focus, wire nav search into App.tsx conditionally on non-home routes, update existing callers in Home.tsx and SearchResults.tsx.
This task delivers R020 (Global Search in Navigation).
## Steps
1. In `SearchAutocomplete.tsx`: replace `heroSize?: boolean` with `variant?: 'hero' | 'inline' | 'nav'` (default `'inline'`). Update the className logic: `search-form--hero` when variant=hero, `search-form--inline` when variant=inline, `search-form--nav` when variant=nav. Same pattern for `search-input--*`.
2. When `variant === 'nav'`: hide the submit button (don't render `<button>` — Enter submits the form). Add `search-container--nav` class to the container div for positioning context.
3. Add `globalShortcut?: boolean` prop. When true, register a `useEffect` with a `keydown` listener for Cmd+K (metaKey) / Ctrl+K (ctrlKey) that calls `e.preventDefault()` and focuses `inputRef.current`. Clean up on unmount.
4. In `App.css`: add `.search-form--nav` styles (max-width: 16rem, compact padding), `.search-input--nav` (smaller font, reduced padding), `.search-container--nav .typeahead-dropdown` (z-index: 200 to overlay page content). Add a Cmd+K hint badge (`.search-nav__shortcut`) styled as a subtle kbd element inside the input area.
5. In `App.tsx`: import `useLocation` and `useNavigate` from react-router-dom, import `SearchAutocomplete`. Render `<SearchAutocomplete variant="nav" globalShortcut onSearch={...} placeholder="Search… ⌘K" />` between brand and right section, conditionally when `location.pathname !== '/'`.
6. Update `Home.tsx` caller: change `heroSize` to `variant="hero"`.
7. Update `SearchResults.tsx` caller: remove heroSize (already falsy, so `variant="inline"` default works, but be explicit: add `variant="inline"`).
8. Verify: `cd frontend && npm run build` succeeds. Visually confirm in browser at desktop width.
## Must-Haves
- [ ] SearchAutocomplete accepts variant prop ('hero' | 'inline' | 'nav')
- [ ] Nav variant: compact input, no submit button, high z-index dropdown
- [ ] Cmd+K / Ctrl+K focuses nav search input (globalShortcut prop)
- [ ] Nav search bar appears on all pages except homepage
- [ ] Existing callers (Home.tsx, SearchResults.tsx) updated and unbroken
- [ ] Build succeeds with zero errors
- Estimate: 45m
- Files: frontend/src/components/SearchAutocomplete.tsx, frontend/src/App.tsx, frontend/src/App.css, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx
- Verify: cd frontend && npm run build 2>&1 | tail -5
- [ ] **T02: Add mobile hamburger menu with touch targets and auto-close behavior** — Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens.
This task delivers R021 (Mobile Hamburger Menu).
## Steps
1. In `App.tsx`: add `menuOpen` state via `useState(false)`. Add a `<button className="hamburger-btn" onClick={() => setMenuOpen(v => !v)} aria-label="Toggle navigation" aria-expanded={menuOpen}>` inside `app-header__right`, before the nav element. Use a hamburger icon (three-line SVG or unicode ☰).
2. Add `className` toggle on `app-nav`: when `menuOpen` is true, add `app-nav--open` class.
3. Add `useEffect` watching `location.pathname` to close menu on navigation: `setMenuOpen(false)` when pathname changes.
4. Add `useEffect` for Escape key: listen for `keydown` event, close menu when key is 'Escape' and menuOpen is true.
5. Add outside-click close: `useRef` on the header element, check if click target is outside header when menuOpen, close if so.
6. In `App.css`: add `.hamburger-btn` styles — hidden above 768px (`display: none`), visible below as a 44×44px button with no border/background, color matching nav text. Add `@media (max-width: 768px)` block: hide `.app-nav` by default, show `.app-nav--open` as a full-width column below the header row (position: absolute or flex column, stacked links). Each nav link gets `min-height: 44px; display: flex; align-items: center; padding: 0.75rem 1.5rem` for touch targets. The `.search-container--nav` inside the mobile panel gets `max-width: 100%` and appropriate positioning.
7. Add open/close transition: `max-height` transition or `transform: scaleY` for smooth reveal.
8. Verify: `cd frontend && npm run build` succeeds. Test at mobile viewport in browser.
## Must-Haves
- [ ] Hamburger button visible only below 768px
- [ ] Tapping hamburger toggles nav panel with stacked links
- [ ] All mobile nav touch targets ≥ 44×44px
- [ ] Menu closes on route change
- [ ] Menu closes on Escape key
- [ ] Menu closes on outside click
- [ ] Nav search bar accessible in mobile menu
- [ ] Build succeeds with zero errors
- Estimate: 45m
- Files: frontend/src/App.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,148 @@
# S03 Research: Global Search & Mobile Navigation
## Summary
This slice adds two features to the app shell in `App.tsx`: (1) a compact search bar in the nav on all non-home pages with Cmd+K focus, and (2) a hamburger menu for mobile viewports (<768px). Both are well-understood patterns. The existing `SearchAutocomplete` component already supports `heroSize` toggling a new compact/nav mode is a straightforward addition. No mobile nav exists yet; it's greenfield.
**Requirements owned:** R020 (Global Search in Navigation), R021 (Mobile Hamburger Menu)
## Recommendation
Straightforward implementation — no novel tech, no risky integration. Build the nav search first (it affects desktop and mobile layout), then add the hamburger menu. Both modify `App.tsx` and `App.css` primarily.
## Implementation Landscape
### Current App Shell Structure (`App.tsx`)
```
<header className="app-header">
<Link className="app-header__brand"><h1>Chrysopedia</h1></Link>
<div className="app-header__right">
<nav className="app-nav">
<Link>Home</Link>
<Link>Topics</Link>
<Link>Creators</Link>
<AdminDropdown />
</nav>
</div>
</header>
```
- No `useLocation` imported yet — will need it to conditionally render nav search (hide on `/`)
- `app-header` is `display: flex; justify-content: space-between; padding: 0.75rem 1.5rem`
- `app-header__right` is `display: flex; align-items: center; gap: 1.5rem`
- At `max-width: 640px`: header becomes `flex-direction: column` and right section wraps
- At `max-width: 768px`: no header changes currently (only technique columns go single-col)
### Existing SearchAutocomplete Component
File: `frontend/src/components/SearchAutocomplete.tsx`
- Props: `onSearch`, `placeholder`, `heroSize`, `initialQuery`, `autoFocus`
- `heroSize` boolean toggles between `search-form--hero` and `search-form--inline` CSS classes
- Input class toggles between `search-input--hero` and `search-input--inline`
- Typeahead dropdown is absolutely positioned from `.search-container`
- Has popular suggestions on empty focus + debounced search on 2+ chars
- The component is self-contained with outside-click close and Escape handling
**For nav mode:** Need a third variant (beyond hero/inline). Options:
1. Add a `navCompact` boolean prop that applies `search-form--nav` and `search-input--nav` classes
2. Change `heroSize` to a `variant: 'hero' | 'inline' | 'nav'` string prop
Option 2 is cleaner. The nav variant needs: smaller padding, no submit button (Enter submits), narrower max-width (~16rem), and the typeahead dropdown needs `z-index` high enough to overlay page content.
### Nav Search Layout Plan
- Add `SearchAutocomplete` between brand and nav links in `app-header__right`
- Conditionally render: show when `location.pathname !== '/'` (home has its own hero search)
- Cmd+K handler: global `keydown` listener in App.tsx, calls `inputRef.current?.focus()`. Need to expose a ref or imperative handle from SearchAutocomplete — or add the listener inside SearchAutocomplete when variant is 'nav'.
**Cmd+K implementation:** The simplest approach is a `useEffect` in `App.tsx` that listens for `Cmd+K` (or `Ctrl+K` on non-Mac) and focuses a ref. SearchAutocomplete already has an internal `inputRef` — need to either:
- Forward the ref via `React.forwardRef` so App.tsx can call `.focus()`
- Or put the global keydown listener inside SearchAutocomplete when `variant === 'nav'`
The second is simpler — the component already manages its own input ref. Add `globalShortcut?: boolean` prop, and when true, register `keydown` listener for Cmd+K/Ctrl+K that focuses the input and prevents default browser behavior.
### Mobile Hamburger Menu Plan
No mobile nav exists. Current behavior at 640px: header stacks vertically, nav links wrap. At 768px: no header changes.
**Implementation:**
- Add a hamburger button (☰) visible only below 768px via CSS `display: none` / media query
- Hide `.app-nav` links below 768px by default, show in an overlay/dropdown when hamburger is active
- State: `menuOpen` boolean in App.tsx
- Close on: route change (watch `location` with useEffect), outside click, Escape key
- Touch targets: 44×44px minimum per R021
- The nav search bar should move inside the mobile menu panel on small screens
**CSS approach:**
- `.hamburger-btn` — hidden on desktop, visible below 768px
- `.app-nav` — hidden below 768px when menu closed
- `.app-nav--open` — full-width dropdown below header with stacked links
- Transition: `max-height` or `transform: translateX` for slide-in
**AdminDropdown in mobile:** It's already a dropdown component. In the mobile menu, it should render inline (expanded) rather than as a nested dropdown. Simplest: just let it render as-is inside the mobile panel — it works fine as a sub-dropdown.
### Existing CSS Custom Properties (relevant)
```css
--color-bg-header (header background)
--color-text-on-header (nav link color)
--color-text-on-header-hover
--color-bg-input (search input background)
--color-border (input border)
--color-accent (focus ring)
--color-bg-surface (dropdown background)
--color-shadow-heavy (dropdown shadow)
```
All needed colors already exist as custom properties.
### Existing Responsive Breakpoints
- `max-width: 640px` — header columns, action bar stacking, small card adjustments
- `max-width: 768px` — technique columns single-col
- `min-width: 600px` — key moment grid
The 768px breakpoint for hamburger aligns with the existing 768px media query. The 640px breakpoint can handle further compacting if needed.
## Natural Task Decomposition
### T01: Nav Search Bar with Cmd+K (~45 min)
1. Refactor `SearchAutocomplete` to accept `variant: 'hero' | 'inline' | 'nav'` (backward-compatible — default to hero/inline based on existing heroSize for now, or deprecate heroSize)
2. Add nav-variant CSS: compact sizing, no submit button, appropriate z-index
3. Add `globalShortcut` behavior inside SearchAutocomplete
4. In `App.tsx`: import `useLocation`, conditionally render `<SearchAutocomplete variant="nav" ... />` when not on homepage
5. Wire `onSearch` to `useNavigate` for `/search?q=...`
**Files:** `SearchAutocomplete.tsx`, `App.tsx`, `App.css`
**Verify:** Build succeeds; search bar appears in nav on `/topics`, `/creators`, `/techniques/*`; hidden on `/`; Cmd+K focuses input; typing + Enter navigates to search results
### T02: Mobile Hamburger Menu (~45 min)
1. Add hamburger button markup in `App.tsx` header
2. Add `menuOpen` state with close-on-navigate (useEffect watching location), close-on-escape, close-on-outside-click
3. CSS: hide hamburger above 768px, hide nav links below 768px unless open
4. Mobile menu panel: stacked links with 44×44px touch targets
5. Nav search bar repositioned inside mobile menu panel
**Files:** `App.tsx`, `App.css`
**Verify:** Build succeeds; at viewport <768px, nav links hidden, hamburger visible; tapping hamburger shows nav; navigating closes menu; touch targets 44×44px
## Risks
- **Typeahead z-index in nav:** The dropdown positions absolutely from `.search-container`. In the nav, this needs to overlay page content below the header. Setting `z-index: 100` on the typeahead dropdown (already at 50) should handle it, but verify against sticky elements.
- **SearchAutocomplete prop refactor:** Changing from `heroSize` boolean to `variant` string is a breaking change for Home.tsx and SearchResults.tsx callers. Need to update both call sites. Low risk but must be thorough.
- **Mobile menu + search interaction:** When the mobile menu is open and the user focuses the search input, the typeahead dropdown needs to render within the mobile panel, not escape it. Verify positioning.
## Verification Strategy
```bash
# Build check
cd frontend && npm run build
# Browser verification (manual)
# Desktop: visit /topics, /creators — search bar in nav, Cmd+K focuses
# Homepage: no nav search bar
# Mobile viewport (<768px): hamburger visible, nav hidden, tap shows menu
# Navigate from mobile menu: menu closes
```

View file

@ -0,0 +1,51 @@
---
estimated_steps: 18
estimated_files: 5
skills_used: []
---
# T01: Refactor SearchAutocomplete variant prop and add nav search bar with Cmd+K
Refactor SearchAutocomplete from heroSize boolean to variant string prop ('hero' | 'inline' | 'nav'), add nav-variant CSS (compact sizing, hidden submit button, high z-index dropdown), add globalShortcut prop for Cmd+K/Ctrl+K focus, wire nav search into App.tsx conditionally on non-home routes, update existing callers in Home.tsx and SearchResults.tsx.
This task delivers R020 (Global Search in Navigation).
## Steps
1. In `SearchAutocomplete.tsx`: replace `heroSize?: boolean` with `variant?: 'hero' | 'inline' | 'nav'` (default `'inline'`). Update the className logic: `search-form--hero` when variant=hero, `search-form--inline` when variant=inline, `search-form--nav` when variant=nav. Same pattern for `search-input--*`.
2. When `variant === 'nav'`: hide the submit button (don't render `<button>` — Enter submits the form). Add `search-container--nav` class to the container div for positioning context.
3. Add `globalShortcut?: boolean` prop. When true, register a `useEffect` with a `keydown` listener for Cmd+K (metaKey) / Ctrl+K (ctrlKey) that calls `e.preventDefault()` and focuses `inputRef.current`. Clean up on unmount.
4. In `App.css`: add `.search-form--nav` styles (max-width: 16rem, compact padding), `.search-input--nav` (smaller font, reduced padding), `.search-container--nav .typeahead-dropdown` (z-index: 200 to overlay page content). Add a Cmd+K hint badge (`.search-nav__shortcut`) styled as a subtle kbd element inside the input area.
5. In `App.tsx`: import `useLocation` and `useNavigate` from react-router-dom, import `SearchAutocomplete`. Render `<SearchAutocomplete variant="nav" globalShortcut onSearch={...} placeholder="Search… ⌘K" />` between brand and right section, conditionally when `location.pathname !== '/'`.
6. Update `Home.tsx` caller: change `heroSize` to `variant="hero"`.
7. Update `SearchResults.tsx` caller: remove heroSize (already falsy, so `variant="inline"` default works, but be explicit: add `variant="inline"`).
8. Verify: `cd frontend && npm run build` succeeds. Visually confirm in browser at desktop width.
## Must-Haves
- [ ] SearchAutocomplete accepts variant prop ('hero' | 'inline' | 'nav')
- [ ] Nav variant: compact input, no submit button, high z-index dropdown
- [ ] Cmd+K / Ctrl+K focuses nav search input (globalShortcut prop)
- [ ] Nav search bar appears on all pages except homepage
- [ ] Existing callers (Home.tsx, SearchResults.tsx) updated and unbroken
- [ ] Build succeeds with zero errors
## Inputs
- ``frontend/src/components/SearchAutocomplete.tsx` — existing search component to refactor`
- ``frontend/src/App.tsx` — app shell to add nav search bar into`
- ``frontend/src/App.css` — global styles for nav search variant`
- ``frontend/src/pages/Home.tsx` — existing caller using heroSize prop`
- ``frontend/src/pages/SearchResults.tsx` — existing caller using default props`
## Expected Output
- ``frontend/src/components/SearchAutocomplete.tsx` — refactored with variant prop and globalShortcut behavior`
- ``frontend/src/App.tsx` — nav search bar rendered conditionally on non-home routes`
- ``frontend/src/App.css` — nav search variant styles added`
- ``frontend/src/pages/Home.tsx` — updated to use variant='hero'`
- ``frontend/src/pages/SearchResults.tsx` — updated to use variant='inline'`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,84 @@
---
id: T01
parent: S03
milestone: M011
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/SearchAutocomplete.tsx", "frontend/src/App.tsx", "frontend/src/App.css", "frontend/src/pages/Home.tsx", "frontend/src/pages/SearchResults.tsx"]
key_decisions: ["Kept heroSize prop as deprecated fallback — variant prop takes precedence when both set", "Nav variant hides submit button entirely (Enter submits form)", "Kbd hint badge positioned absolutely inside input area"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "cd frontend && npm run build succeeds with zero errors — all 51 modules transformed, production bundle built in 770ms."
completed_at: 2026-03-31T08:42:08.935Z
blocker_discovered: false
---
# T01: Refactored SearchAutocomplete from heroSize boolean to variant string prop and wired compact nav search bar with Cmd+K shortcut into App.tsx header on all non-home routes
> Refactored SearchAutocomplete from heroSize boolean to variant string prop and wired compact nav search bar with Cmd+K shortcut into App.tsx header on all non-home routes
## What Happened
---
id: T01
parent: S03
milestone: M011
key_files:
- frontend/src/components/SearchAutocomplete.tsx
- frontend/src/App.tsx
- frontend/src/App.css
- frontend/src/pages/Home.tsx
- frontend/src/pages/SearchResults.tsx
key_decisions:
- Kept heroSize prop as deprecated fallback — variant prop takes precedence when both set
- Nav variant hides submit button entirely (Enter submits form)
- Kbd hint badge positioned absolutely inside input area
duration: ""
verification_result: passed
completed_at: 2026-03-31T08:42:08.936Z
blocker_discovered: false
---
# T01: Refactored SearchAutocomplete from heroSize boolean to variant string prop and wired compact nav search bar with Cmd+K shortcut into App.tsx header on all non-home routes
**Refactored SearchAutocomplete from heroSize boolean to variant string prop and wired compact nav search bar with Cmd+K shortcut into App.tsx header on all non-home routes**
## What Happened
Replaced heroSize boolean with variant prop ('hero' | 'inline' | 'nav'), added globalShortcut prop for Cmd+K/Ctrl+K focus, created nav-variant CSS with compact sizing and high z-index dropdown, wired nav search into App.tsx conditionally on non-home routes, updated Home.tsx and SearchResults.tsx callers.
## Verification
cd frontend && npm run build succeeds with zero errors — all 51 modules transformed, production bundle built in 770ms.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build 2>&1 | tail -15` | 0 | ✅ pass | 2900ms |
## Deviations
Kept heroSize prop as deprecated fallback for backwards compat. Added min-width: 20rem to nav typeahead dropdown.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/SearchAutocomplete.tsx`
- `frontend/src/App.tsx`
- `frontend/src/App.css`
- `frontend/src/pages/Home.tsx`
- `frontend/src/pages/SearchResults.tsx`
## Deviations
Kept heroSize prop as deprecated fallback for backwards compat. Added min-width: 20rem to nav typeahead dropdown.
## Known Issues
None.

View file

@ -0,0 +1,47 @@
---
estimated_steps: 20
estimated_files: 2
skills_used: []
---
# T02: Add mobile hamburger menu with touch targets and auto-close behavior
Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens.
This task delivers R021 (Mobile Hamburger Menu).
## Steps
1. In `App.tsx`: add `menuOpen` state via `useState(false)`. Add a `<button className="hamburger-btn" onClick={() => setMenuOpen(v => !v)} aria-label="Toggle navigation" aria-expanded={menuOpen}>` inside `app-header__right`, before the nav element. Use a hamburger icon (three-line SVG or unicode ☰).
2. Add `className` toggle on `app-nav`: when `menuOpen` is true, add `app-nav--open` class.
3. Add `useEffect` watching `location.pathname` to close menu on navigation: `setMenuOpen(false)` when pathname changes.
4. Add `useEffect` for Escape key: listen for `keydown` event, close menu when key is 'Escape' and menuOpen is true.
5. Add outside-click close: `useRef` on the header element, check if click target is outside header when menuOpen, close if so.
6. In `App.css`: add `.hamburger-btn` styles — hidden above 768px (`display: none`), visible below as a 44×44px button with no border/background, color matching nav text. Add `@media (max-width: 768px)` block: hide `.app-nav` by default, show `.app-nav--open` as a full-width column below the header row (position: absolute or flex column, stacked links). Each nav link gets `min-height: 44px; display: flex; align-items: center; padding: 0.75rem 1.5rem` for touch targets. The `.search-container--nav` inside the mobile panel gets `max-width: 100%` and appropriate positioning.
7. Add open/close transition: `max-height` transition or `transform: scaleY` for smooth reveal.
8. Verify: `cd frontend && npm run build` succeeds. Test at mobile viewport in browser.
## Must-Haves
- [ ] Hamburger button visible only below 768px
- [ ] Tapping hamburger toggles nav panel with stacked links
- [ ] All mobile nav touch targets ≥ 44×44px
- [ ] Menu closes on route change
- [ ] Menu closes on Escape key
- [ ] Menu closes on outside click
- [ ] Nav search bar accessible in mobile menu
- [ ] Build succeeds with zero errors
## Inputs
- ``frontend/src/App.tsx` — app shell with nav search bar from T01`
- ``frontend/src/App.css` — global styles to extend with mobile nav rules`
## Expected Output
- ``frontend/src/App.tsx` — hamburger button, menuOpen state, auto-close behavior added`
- ``frontend/src/App.css` — mobile nav styles with hamburger, panel, touch targets, transitions`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -1042,6 +1042,53 @@ a.app-footer__repo:hover {
border-radius: 0.625rem;
}
/* ── Nav search variant ───────────────────────────────────────────────────── */
.search-container--nav {
position: relative;
max-width: 16rem;
margin: 0;
}
.search-form--nav {
gap: 0;
position: relative;
}
.search-input--nav {
padding: 0.375rem 2.75rem 0.375rem 0.75rem;
font-size: 0.8125rem;
border-radius: 0.375rem;
background: var(--color-bg-input);
border-color: var(--color-border);
}
.search-input--nav::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
}
.search-nav__shortcut {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.625rem;
font-family: inherit;
color: var(--color-text-muted);
background: var(--color-bg-page);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.0625rem 0.3125rem;
line-height: 1.4;
pointer-events: none;
}
.search-container--nav .typeahead-dropdown {
z-index: 200;
min-width: 20rem;
}
.btn--search {
background: var(--color-btn-search-bg);
color: var(--color-btn-search-text);

View file

@ -1,4 +1,4 @@
import { Link, Navigate, Route, Routes } from "react-router-dom";
import { Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import Home from "./pages/Home";
import SearchResults from "./pages/SearchResults";
import TechniquePage from "./pages/TechniquePage";
@ -11,14 +11,27 @@ import AdminPipeline from "./pages/AdminPipeline";
import About from "./pages/About";
import AdminDropdown from "./components/AdminDropdown";
import AppFooter from "./components/AppFooter";
import SearchAutocomplete from "./components/SearchAutocomplete";
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const showNavSearch = location.pathname !== "/";
return (
<div className="app">
<header className="app-header">
<Link to="/" className="app-header__brand">
<h1>Chrysopedia</h1>
</Link>
{showNavSearch && (
<SearchAutocomplete
variant="nav"
globalShortcut
placeholder="Search… ⌘K"
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>
)}
<div className="app-header__right">
<nav className="app-nav">
<Link to="/">Home</Link>

View file

@ -19,18 +19,26 @@ import {
interface SearchAutocompleteProps {
onSearch: (query: string) => void;
placeholder?: string;
/** @deprecated Use variant="hero" instead */
heroSize?: boolean;
variant?: 'hero' | 'inline' | 'nav';
initialQuery?: string;
autoFocus?: boolean;
/** When true, Cmd+K / Ctrl+K focuses the input globally */
globalShortcut?: boolean;
}
export default function SearchAutocomplete({
onSearch,
placeholder = "Search techniques…",
heroSize = false,
variant: variantProp,
initialQuery = "",
autoFocus = false,
globalShortcut = false,
}: SearchAutocompleteProps) {
// Resolve variant: explicit prop wins, then legacy heroSize, then default 'inline'
const variant = variantProp ?? (heroSize ? 'hero' : 'inline');
const [query, setQuery] = useState(initialQuery);
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [popularSuggestions, setPopularSuggestions] = useState<SuggestionItem[]>([]);
@ -64,6 +72,19 @@ export default function SearchAutocomplete({
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
// Global Cmd+K / Ctrl+K shortcut to focus input
useEffect(() => {
if (!globalShortcut) return;
function handleGlobalKey(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
}
}
document.addEventListener("keydown", handleGlobalKey);
return () => document.removeEventListener("keydown", handleGlobalKey);
}, [globalShortcut]);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -145,15 +166,15 @@ export default function SearchAutocomplete({
};
return (
<div className="search-container" ref={dropdownRef}>
<div className={`search-container${variant === 'nav' ? ' search-container--nav' : ''}`} ref={dropdownRef}>
<form
onSubmit={handleSubmit}
className={`search-form ${heroSize ? "search-form--hero" : "search-form--inline"}`}
className={`search-form search-form--${variant}`}
>
<input
ref={inputRef}
type="search"
className={`search-input ${heroSize ? "search-input--hero" : "search-input--inline"}`}
className={`search-input search-input--${variant}`}
placeholder={placeholder}
value={query}
onChange={(e) => handleInputChange(e.target.value)}
@ -161,9 +182,14 @@ export default function SearchAutocomplete({
onKeyDown={handleKeyDown}
aria-label="Search techniques"
/>
<button type="submit" className="btn btn--search">
Search
</button>
{variant !== 'nav' && (
<button type="submit" className="btn btn--search">
Search
</button>
)}
{variant === 'nav' && (
<kbd className="search-nav__shortcut">K</kbd>
)}
</form>
{showDropdown && (showPopular || showSearch) && (

View file

@ -104,7 +104,7 @@ export default function Home() {
</p>
<SearchAutocomplete
heroSize
variant="hero"
autoFocus
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>

View file

@ -54,6 +54,7 @@ export default function SearchResults() {
<div className="search-results-page">
{/* Inline search bar */}
<SearchAutocomplete
variant="inline"
initialQuery={q}
onSearch={(newQ) =>
navigate(`/search?q=${encodeURIComponent(newQ)}`, { replace: true })