feat: Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-…

- "frontend/src/pages/TopicsBrowse.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T02
This commit is contained in:
jlightner 2026-03-30 11:48:51 +00:00
parent 3f3fe065f8
commit 75332343cb
5 changed files with 261 additions and 97 deletions

View file

@ -15,7 +15,7 @@ Steps:
- Estimate: 15m
- Files: config/canonical_tags.yaml (on ub01 via SSH), frontend/src/App.css
- Verify: curl -s http://ub01:8096/api/v1/topics | python3 -c "import sys,json; d=json.load(sys.stdin); assert len(d)==7, f'Expected 7 categories, got {len(d)}'; names=[c['name'] for c in d]; assert 'Music Theory' in names, f'Music Theory not found in {names}'; print('PASS: 7 categories including Music Theory')" && cd frontend && npx grep-cli --no-error 'music-theory' src/App.css || grep -q 'music-theory' frontend/src/App.css && echo 'PASS: badge CSS present'
- [ ] **T02: Redesign TopicsBrowse.tsx to card grid layout with updated CSS** — Rewrite `TopicsBrowse.tsx` from the current vertical accordion list to a responsive card grid layout, and replace the topics CSS section in `App.css` with card-based styles.
- [x] **T02: Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-column card grid with category color accents, summary stats, and expand/collapse toggles** — Rewrite `TopicsBrowse.tsx` from the current vertical accordion list to a responsive card grid layout, and replace the topics CSS section in `App.css` with card-based styles.
The card layout should:
- Use CSS grid: 2 columns on desktop (min-width > 768px), 1 column on mobile

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M006/S05/T01",
"timestamp": 1774871058689,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
},
{
"command": "echo 'PASS: badge CSS present'",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,81 @@
---
id: T02
parent: S05
milestone: M006
provides: []
requires: []
affects: []
key_files: ["frontend/src/pages/TopicsBrowse.tsx", "frontend/src/App.css"]
key_decisions: ["Used 3px colored left border + small colored dot for category visual differentiation — subtler than full colored header, maintains dark theme cohesion", "Computed totalTechniques client-side via sub_topics.reduce() — no API changes needed"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend build passes (800ms, no errors). Live CSS and JS bundles contain topic-card class references. API returns 7 categories. Browser verification confirms 2-column desktop grid, 1-column mobile, filter narrows to matching cards, expand/collapse toggles work correctly."
completed_at: 2026-03-30T11:48:43.282Z
blocker_discovered: false
---
# T02: Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-column card grid with category color accents, summary stats, and expand/collapse toggles
> Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-column card grid with category color accents, summary stats, and expand/collapse toggles
## What Happened
---
id: T02
parent: S05
milestone: M006
key_files:
- frontend/src/pages/TopicsBrowse.tsx
- frontend/src/App.css
key_decisions:
- Used 3px colored left border + small colored dot for category visual differentiation — subtler than full colored header, maintains dark theme cohesion
- Computed totalTechniques client-side via sub_topics.reduce() — no API changes needed
duration: ""
verification_result: passed
completed_at: 2026-03-30T11:48:43.283Z
blocker_discovered: false
---
# T02: Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-column card grid with category color accents, summary stats, and expand/collapse toggles
**Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-column card grid with category color accents, summary stats, and expand/collapse toggles**
## What Happened
Replaced the single-column accordion layout with a CSS grid card layout. Each card shows a colored left border and dot (via existing badge custom properties), category name, description, sub-topic + technique count stats line, and expand/collapse toggle. Grid is 2 columns on desktop, 1 on mobile. CSS rewritten from .topics-list/.topic-category* to .topics-grid/.topic-card* classes. All existing functionality preserved (filter, expand/collapse, sub-topic navigation). Deployed to ub01 and verified in browser at both desktop and mobile viewports.
## Verification
Frontend build passes (800ms, no errors). Live CSS and JS bundles contain topic-card class references. API returns 7 categories. Browser verification confirms 2-column desktop grid, 1-column mobile, filter narrows to matching cards, expand/collapse toggles work correctly.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 800ms |
| 2 | `curl CSS bundle | grep topic-card` | 0 | ✅ pass | 200ms |
| 3 | `curl JS bundle | grep topic-card` | 0 | ✅ pass | 200ms |
| 4 | `curl API /topics | assert len==7` | 0 | ✅ pass | 300ms |
| 5 | `Browser: desktop 2-col grid + mobile 1-col + filter + toggle` | 0 | ✅ pass | 5000ms |
## Deviations
Plan verification command used grep on HTML for topic-category classes, but SPA serves class names in JS bundles — adapted to check bundle contents instead.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/pages/TopicsBrowse.tsx`
- `frontend/src/App.css`
## Deviations
Plan verification command used grep on HTML for topic-category classes, but SPA serves class names in JS bundles — adapted to check bundle contents instead.
## Known Issues
None.

View file

@ -1839,11 +1839,11 @@ body {
}
/*
TOPICS BROWSE
TOPICS BROWSE Card Grid Layout
*/
.topics-browse {
max-width: 56rem;
max-width: 64rem;
}
.topics-browse__title {
@ -1869,7 +1869,7 @@ body {
font-family: inherit;
color: var(--color-text-primary);
background: var(--color-bg-input);
margin-bottom: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
@ -1879,67 +1879,92 @@ body {
box-shadow: 0 0 0 2px var(--color-accent-focus);
}
/* ── Topics hierarchy ─────────────────────────────────────────────────────── */
/* ── Card grid ────────────────────────────────────────────────────────────── */
.topics-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
.topics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.topic-category {
.topic-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-border);
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px var(--color-shadow);
display: flex;
flex-direction: column;
}
.topic-category__header {
.topic-card__body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.topic-card__name {
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.875rem 1.25rem;
gap: 0.5rem;
margin: 0;
}
.topic-card__dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.topic-card__desc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
line-height: 1.45;
margin: 0;
}
.topic-card__stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
margin-top: 0.25rem;
}
.topic-card__stats-sep {
color: var(--color-border);
}
.topic-card__toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.375rem;
padding: 0.25rem 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-family: inherit;
transition: background 0.15s;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-accent);
cursor: pointer;
transition: color 0.15s;
}
.topic-category__header:hover {
background: var(--color-bg-surface-hover);
}
.topic-category__chevron {
font-size: 0.625rem;
color: var(--color-text-muted);
flex-shrink: 0;
width: 0.75rem;
}
.topic-category__name {
font-size: 1rem;
font-weight: 700;
.topic-card__toggle:hover {
color: var(--color-text-primary);
}
.topic-category__desc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
flex: 1;
}
.topic-category__count {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
margin-left: auto;
}
/* ── Sub-topics ───────────────────────────────────────────────────────────── */
/* ── Sub-topics inside card ───────────────────────────────────────────────── */
.topic-subtopics {
border-top: 1px solid var(--color-border);
@ -1949,10 +1974,10 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem 0.625rem 2.75rem;
padding: 0.5rem 1.25rem;
text-decoration: none;
color: inherit;
font-size: 0.875rem;
font-size: 0.8125rem;
transition: background 0.1s;
}
@ -1974,7 +1999,7 @@ body {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
@ -2037,12 +2062,16 @@ body {
font-size: 1.375rem;
}
.topic-category__desc {
.topics-grid {
grid-template-columns: 1fr;
}
.topic-card__desc {
display: none;
}
.topic-subtopic {
padding-left: 2rem;
padding-left: 1rem;
}
}

View file

@ -1,16 +1,23 @@
/**
* Topics browse page (R008).
*
* Two-level hierarchy: 6 top-level categories with expandable/collapsible
* sub-topics. Each sub-topic shows technique_count and creator_count.
* Filter input narrows categories and sub-topics.
* Click sub-topic search results filtered to that topic.
* Responsive card grid layout with 7 top-level categories.
* Each card shows: category name, description, summary stats
* (sub-topic count + total technique count), and an expand/collapse
* toggle revealing sub-topics as links to search.
*
* Filter input narrows visible cards by category name + sub-topic names.
*/
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchTopics, type TopicCategory } from "../api/public-client";
/** Derive the badge CSS slug from a category name. */
function catSlug(name: string): string {
return name.toLowerCase().replace(/\s+/g, "-");
}
export default function TopicsBrowse() {
const [categories, setCategories] = useState<TopicCategory[]>([]);
const [loading, setLoading] = useState(true);
@ -62,7 +69,7 @@ export default function TopicsBrowse() {
// Apply filter: show categories whose name or sub-topics match
const lowerFilter = filter.toLowerCase();
const filtered = filter
? categories
? (categories
.map((cat) => {
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
const matchingSubs = cat.sub_topics.filter((st) =>
@ -74,7 +81,7 @@ export default function TopicsBrowse() {
}
return null;
})
.filter(Boolean) as TopicCategory[]
.filter(Boolean) as TopicCategory[])
: categories;
if (loading) {
@ -104,51 +111,76 @@ export default function TopicsBrowse() {
{filtered.length === 0 ? (
<div className="empty-state">
No topics matching "{filter}"
No topics matching &ldquo;{filter}&rdquo;
</div>
) : (
<div className="topics-list">
{filtered.map((cat) => (
<div key={cat.name} className="topic-category">
<button
className="topic-category__header"
onClick={() => toggleCategory(cat.name)}
aria-expanded={expanded.has(cat.name)}
>
<span className="topic-category__chevron">
{expanded.has(cat.name) ? "▼" : "▶"}
</span>
<span className="topic-category__name">{cat.name}</span>
<span className="topic-category__desc">{cat.description}</span>
<span className="topic-category__count">
{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}
</span>
</button>
<div className="topics-grid">
{filtered.map((cat) => {
const slug = catSlug(cat.name);
const isExpanded = expanded.has(cat.name);
const totalTechniques = cat.sub_topics.reduce(
(sum, st) => sum + st.technique_count,
0,
);
{expanded.has(cat.name) && (
<div className="topic-subtopics">
{cat.sub_topics.map((st) => (
<Link
key={st.name}
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
className="topic-subtopic"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="topic-subtopic__count">
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
</span>
<span className="topic-subtopic__separator">·</span>
<span className="topic-subtopic__count">
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
</span>
</span>
</Link>
))}
return (
<div
key={cat.name}
className="topic-card"
style={{
borderLeftColor: `var(--color-badge-cat-${slug}-text)`,
}}
>
<div className="topic-card__body">
<h3 className="topic-card__name">
<span
className="topic-card__dot"
style={{
background: `var(--color-badge-cat-${slug}-text)`,
}}
/>
{cat.name}
</h3>
<p className="topic-card__desc">{cat.description}</p>
<div className="topic-card__stats">
<span>{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}</span>
<span className="topic-card__stats-sep">·</span>
<span>{totalTechniques} technique{totalTechniques !== 1 ? "s" : ""}</span>
</div>
<button
className="topic-card__toggle"
onClick={() => toggleCategory(cat.name)}
aria-expanded={isExpanded}
>
{isExpanded ? "Hide sub-topics ▲" : "Show sub-topics ▼"}
</button>
</div>
)}
</div>
))}
{isExpanded && (
<div className="topic-subtopics">
{cat.sub_topics.map((st) => (
<Link
key={st.name}
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
className="topic-subtopic"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="topic-subtopic__count">
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
</span>
<span className="topic-subtopic__separator">·</span>
<span className="topic-subtopic__count">
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
</span>
</span>
</Link>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>