5 Commits

Author SHA1 Message Date
d9ec330aca feat(catalog): searchable tag filter in global catalog overlay
All checks were successful
CI / ci (push) Successful in 1m56s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 16s
Adds a typing-to-filter input above the tag chip list in the filter
sidebar, rendered only when there are more than eight tags. Case-
insensitive substring match; shows "No tags match" when the query
empties the list. Selected tags filtered out of the sidebar remain
active as header pills. Resets with the rest of overlay state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:52:47 +02:00
7890de141e docs(plan): tag selector search implementation
Six-step inline plan per the approved spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:46:59 +02:00
b41aa9301e docs(spec): tag selector search in CatalogSearchOverlay
Captures approved design: in-sidebar typing-to-filter input,
case-insensitive substring match, hidden when tags <= 8,
selected tags filtered out stay active as header pills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:44:15 +02:00
076616cd1b ci: switch release workflow to tag-driven
Trigger on `push: tags` instead of `workflow_dispatch`. The pushed tag
name is the version — removes the bump-input + compute-and-create-tag
dance, avoiding accidental major bumps from the manual dispatch form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:44:09 +02:00
0202d0bb5c docs: add superpowers-friendly state and backlog summary
All checks were successful
CI / ci (push) Successful in 2m0s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
Summarize current v2.4 state and forward-looking backlog in docs/ as a
parallel entry point to the active .planning/ GSD workspace. Lets
superpowers skills (brainstorming → writing-plans → executing-plans)
work from a clean starting point without retiring the existing workflow.

Also ignore .idea/ so JetBrains project files stay local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:16:15 +02:00
10 changed files with 605 additions and 60 deletions

View File

@@ -1,17 +1,9 @@
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
push:
tags:
- 'v*'
jobs:
ci:
@@ -45,25 +37,17 @@ jobs:
cd repo
git checkout ${{ gitea.ref_name }}
- name: Compute version
- name: Resolve version from tag
working-directory: repo
run: |
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG="v0.0.0"
VERSION="${{ gitea.ref_name }}"
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -vxF "$VERSION" | head -n1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG="v0.0.0"
fi
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1)
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2)
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3)
case "${{ gitea.event.inputs.bump }}" in
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
patch) PATCH=$((PATCH+1)) ;;
esac
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
echo "PREV_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "New version: $NEW_VERSION"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
echo "Releasing $VERSION (previous: $PREV_TAG)"
- name: Generate changelog
working-directory: repo
@@ -77,14 +61,6 @@ jobs:
echo "$CHANGELOG" >> "$GITHUB_ENV"
echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
- name: Create and push tag
working-directory: repo
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.jeanlucmakiola.de"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
- name: Build and push Docker image
working-directory: repo
run: |

3
.gitignore vendored
View File

@@ -233,6 +233,9 @@ e2e/pgdata
test-results/
playwright-report/
# JetBrains IDEs (full directory)
.idea/
# Obsidian
.obsidian/

View File

@@ -64,16 +64,12 @@ bun run build # Vite build → dist/client/
## Releasing
Releases are managed by a Gitea Actions workflow (`.gitea/workflows/release.yml`). **Never create tags or releases manually** — always trigger the pipeline.
Releases are tag-driven. The Gitea Actions workflow (`.gitea/workflows/release.yml`) triggers on any pushed tag matching `v*` — it runs CI (lint, test, build), generates a changelog from the previous tag, builds and pushes a Docker image, and creates a Gitea release. The version comes from the tag name.
The workflow runs CI (lint, test, build), computes the next version from the latest tag, generates a changelog, creates the tag, builds and pushes a Docker image, and creates a Gitea release.
Trigger via Gitea API:
To release, tag the desired commit on `Develop` and push:
```bash
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \
-H "Authorization: token <GITEA_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
git tag v2.3.0
git push origin v2.3.0
```
## Branching

115
docs/BACKLOG.md Normal file
View File

@@ -0,0 +1,115 @@
# GearBox — Backlog
*Migrated from `.planning/` on 2026-04-23. Forward-looking only — see `STATE.md` for what's shipped.*
Staging area for work not yet brainstormed into a superpowers plan. To start on anything here, run `superpowers:brainstorming``superpowers:writing-plans``superpowers:subagent-driven-development` (or `executing-plans`).
---
## v2.4 tail — finish line
- [ ] **Verify Phase 38 Admin Tag Management** — all 2 plans marked complete in roadmap, STATE.md says 97%. Run the phase verification and close out v2.4.
- [ ] **Manufacturer picker UI** — backend entity is shipped (`manufacturers` table, service, route, FK on `globalItems`, seeding), but the user-facing picker component (analogous to `CategoryPicker`) is not yet built. The manual add form still takes freeform brand text in places. See also the pending todo below.
---
## Pending todos
Details preserved in `docs/backlog/todos/`. Only truly-open items listed here — the older v2.3 todos (wrong modal, missing images, slow loading, auth redirect, cursor pointer) all shipped in Phase 35.
- [ ] **Make tag selector in global search searchable** (2026-04-20, ui) — the tag selector inside `CatalogSearchOverlay.tsx` should support typing-to-filter. Currently scrollable only.
- [ ] **Add manufacturer picker UI** (from 2026-04-10 "add-manufacturer-entity" todo) — backend shipped; picker component + wiring into the manual add form and catalog enrichment is the remaining slice.
**Dropped** (already resolved but stale in `.planning/todos/pending/`):
- `add-cursor-pointer-to-all-clickable-links` → shipped in Phase 35 (FIX-05)
- `fix-item-image-not-showing-on-collection-overview` → shipped in Phase 35 (FIX-02)
- `fix-storage-service-tests` → resolved; `withImageUrl`/`withImageUrls` both exported and test imports match
- `investigate-slow-image-loading` → shipped in Phase 35 (FIX-03, lazy loading + skeletons)
- `auth-prompt-sign-in-button-should-redirect-directly-to-logto` → shipped in Phase 35 (FIX-04)
- `fix-add-candidate-button-shows-wrong-modal-on-thread-page` → shipped in Phase 35 (FIX-01)
---
## Backlog phases (pre-brainstorm)
These are product intents, not plans. Each needs brainstorming before becoming a superpowers plan. Sourced from `.planning/ROADMAP.md` Backlog section. *Previously-listed entries 999.2, 999.3, 999.4, 999.6 were promoted to shipped phases and are omitted here.*
### 999.1 — Rewrite E2E Tests for OIDC Auth
E2E tests expect local username/password login, but auth moved to Logto. Rewrite with a mock OIDC provider or API-key bypass. Postgres seed migration already done.
### 999.5 — Legal Pages: ToS, Privacy Policy, Compliance
Terms of Service, Privacy Policy, and any required compliance pages. Essential before opening to real users.
### 999.7 — User Feedback System
In-app feedback collection — bug reports, feature requests, general feedback. Simple form, widget, or external integration.
### 999.8 — Analytics Integration
Privacy-respecting analytics (PostHog, Umami, or similar). Usage patterns, popular categories, search behavior, feature adoption. Self-hosted preferred to align with independent ethos.
### 999.9 — Mobile App
PWA first (offline, home-screen install), then evaluate native (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
### 999.10 — Monetization Strategy
How GearBox sustains itself. Options: sponsored/promoted items, premium features, affiliate links. Critical tension: revenue vs. credibility — GearBox's value is *unbiased* gear data. Needs deep discussion before implementation.
### 999.11 — Marketing Website
Standalone marketing site (`www.gearbox.de`) separate from the app (`app.gearbox.de`). Hero, value prop, feature highlights, how-it-works, social proof, sign-up CTA. The public-facing front door.
### 999.12 — Admin UX Polish (new, 2026-04-20)
Overhaul admin panel UX: TanStack Table (sortable/groupable columns), cmdk for GitLab-style composable filter bar (field→operator→value token input), hide FAB on `/admin/*`, replace tag inline form with popup modal, show tags expanded on item rows (collapse to +N when tight), group items by brand, prominent search bar on both admin list pages.
---
## Near-future scope (v2.5 candidates)
Explicitly planned for the next milestone but not yet a phase:
- **Tag-based spec schemas on global items** — key/value typed specs per category, sub-tag hierarchy. Enables proper structured product data per gear class.
- **Global item engagement stats** — view count, likes/saves, setup appearances. Feeds future sorting/ranking.
- **ComparisonTable currency normalization** — hooks are in place; needs real multi-currency test data to prove out.
---
## Deferred requirements (tracked, not scheduled)
Not in any current or v2.5 phase. Promote to a backlog phase when timing is right.
### Personalization
- **PERS-01** — Logged-in feed tuned to the user's collection categories
- **PERS-02** — Feed algorithm recommends by hobby interests
### Reviews & Content
- **REVW-01/02/03** — Structured reviews on catalog items; surface in discovery feed; curated/linked external reviews
- **REV-01/02/03** — Overall star rating, dimension ratings (durability, value), average ratings on item detail
- **MOD-01/02/03** — Freeform text reviews, report inappropriate content, admin review + action *(gated on moderation infra)*
### SEO
- **SEO-01** — Catalog pages crawlable by bots
- **SEO-02** — Meta tags + structured data
### Catalog Seeding
- **SEED-04** — Initial seeding run populates 100+ items across key categories via agent swarm *(crawl scripts exist — this would be the production run)*
### Aggregation
- **AGG-01** — Crowd-verified specs on item detail (manufacturer vs community-measured weight)
- **AGG-02** — "Which setups include this item"
- **AGG-03** — "Commonly paired with" setup composition insights
### Social
- **SOCL-01** — Fork/copy a public setup as template
- **SOCL-02** — Thread candidates auto-populate from global items
- **SOCL-03** — Follow other users
- **SOCL-04** — Activity feed of followed users' content
---
## Workflow
**To start work on something here:**
1. Pick an item above.
2. Run `superpowers:brainstorming` to turn the intent into a spec.
3. For non-trivial work, create a feature branch off `Develop` (e.g., `feature/manufacturer-picker`, `fix/tag-selector-search`).
4. Run `superpowers:writing-plans` to produce `docs/superpowers/plans/YYYY-MM-DD-<name>.md`.
5. Execute with `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`.
6. Remove the item from this doc when shipped.

95
docs/STATE.md Normal file
View File

@@ -0,0 +1,95 @@
# GearBox — Project State
*Snapshot: 2026-04-23. Migrated from `.planning/` (PROJECT.md, ROADMAP.md, STATE.md, REQUIREMENTS.md).*
## What GearBox Is
A gear management and discovery platform. Users catalog their gear (bikepacking, sim racing, any hobby), track weight, price, and source details, research purchases via planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification. A global item database with crowd-verified specs, market-aware pricing, and manufacturer attribution helps users make informed purchase decisions.
Multi-user, public-read, authenticated-write. Granular setup sharing (private / link / public), multi-currency (USD/EUR/GBP) with ECB rates, i18n (English + German).
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
## Where We Are
- **Current milestone:** v2.4 Admin Foundation — executing (Phases 3538 all complete, pending final verification)
- **Latest activity:** 2026-04-20 — quick task `260420-vk0` (image fetch-from-URL, crop, tag routing, tag-form UX fixes)
- **Position in GSD tracker:** "stopped at Completed 38-02-PLAN.md — admin tag management client UI"
- **Legacy planning data:** `.planning/` remains the live GSD workspace. This doc and `BACKLOG.md` are the superpowers-friendly summary.
## Shipped (v1.0 → v2.4)
**v1.0 MVP** — Collection CRUD, images, categories with totals, planning threads, thread resolution, named setups, dashboard, onboarding.
**v1.1 Fixes & Polish** — Thread creation modal, planning tab, image display polish, Lucide icon picker (119 icons), emoji→icon migration.
**v1.2 Collection Power-Ups** — Search + category filter, weight units (g/oz/lb/kg), per-setup classification (base/worn/consumable), donut chart, candidate status tracking.
**v1.3 Research & Decision Tools** — Pros/cons annotation, candidate ranking with drag-reorder, side-by-side comparison with deltas, setup impact preview.
**v2.0 Platform Foundation** — Postgres migration with PGlite test infra, Logto OIDC auth, multi-user isolation, MinIO/S3 storage, global item catalog, user profiles + public setup sharing, reference-item COALESCE merge, tag system, FAB + catalog search overlay, item/catalog detail pages, add-from-catalog flow.
**v2.1 Public Discovery** — Public read with rate limiting, catalog attribution + unique (brand, model), bulk import with upsert, MCP catalog tools (`upsert_catalog_item`, `bulk_upsert_catalog`), discovery landing page, top-nav + mobile bottom tab bar, Setups elevated to top-level route.
**v2.2 User Experience Polish** — Profile page with Logto-backed account management (name/bio/avatar/email/password/delete), fit-within image framing with dominant-color background and crop editor, catalog-driven onboarding (hobby picker → category-grouped item browser → batch create), mobile icon-based action buttons on detail pages.
**v2.3 Global & Social Ready** — Setup visibility (private/link/public) with shares table and 128-bit tokens, ShareModal (Google-Docs-style UX), `/s/:token` shared-setup viewer, multi-currency (market_prices + community_prices, ECB rates cached 24h), ownership-validated community price aggregation (median, 3-report minimum), react-i18next foundation with 7 namespaces and German translations, language picker.
**v2.4 Admin Foundation** (in verification) — Phase 35 bug-fix sweep (wrong modal, missing images, slow loading, auth redirect, cursor-pointer audit), isAdmin flag on users + CLI grant/revoke script, gated `/admin` route with requireAdmin middleware, admin global-item management (browse/edit/delete with search + tag filter), admin tag management (CRUD + parent-child hierarchy with cycle detection). Also during v2.4: manufacturer backend entity (table, FK, service, route, seeding; **picker UI still missing**), catalog ingestion agent scripts (`crawl-manufacturer`, `crawl-all`).
## Tech Stack
React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, **react-i18next**, **Sharp** (image processing), **react-easy-crop** — all on Bun. MinIO for S3-compatible image storage. Docker Compose for dev/prod. Logto (self-hosted OIDC) for auth; API keys for programmatic; OAuth 2.1 + PKCE for Claude mobile/web. MCP server exposes 21+ tools over streamable HTTP.
## Active Constraints
- **Runtime:** Bun (package manager + runtime)
- **Design:** Light, airy, minimalist — white/light backgrounds, lots of whitespace
- **Navigation:** Top nav (desktop) + bottom tab bar (mobile); public discovery landing page for unauth users
- **Auth:** External self-hosted (Logto) — no in-house auth maintenance
- **DB:** PostgreSQL + Drizzle; prices stored as cents; timestamps as unix epoch integers
- **UGC:** Structured input only (ratings, predefined fields) — no freeform until moderation exists
- **Scope:** Multi-user platform with public discovery; SQLite single-user path diverged at v2.0
## Load-Bearing Decisions (current)
| Decision | Rationale |
|---|---|
| External OIDC (Logto) | Avoid in-house auth burden |
| Postgres over SQLite | Multi-user concurrency + provider needs it |
| Structured UGC only (no freeform) | Minimize moderation burden |
| Discovery-first, not social-first | Users come to research decisions |
| COALESCE merge for reference items | Global base + personal overlay, no duplication |
| Catalog-first add with manual fallback | Encourages catalog usage, preserves flexibility |
| Detail pages over slide-out panels | Better UX + shareable URLs |
| Setups as top-level route | Promoted out of Collection tabs (Phase 27) |
| `visibility` text column (not boolean) | Future-proofs for additional sharing modes |
| `shares` table separate from `setups` | Enables per-person shares, write permissions, revocation |
| 128-bit base64url share tokens | URL-safe entropy, no external dep |
| Deactivate/reactivate on visibility change | Share links survive round-trips |
| EUR default currency | Matches early single-user data assumption |
| Module-level ECB rate cache (24h) | Single-process; avoids Redis |
| `isAdmin` boolean flag on users | Simplest; no Logto role claims needed |
| Admin grant via CLI script | No public UI for privilege escalation |
| Manufacturers as dedicated entity (FK, not text) | Avoids "Ortlieb" vs "ORTLIEB" drift; enables logos/URLs |
## Explicitly Out of Scope
- Personalized feed algorithm (needs usage data)
- SSR / static prerendering (TanStack Router research needed; defer)
- Freeform reviews / comments (no moderation infra)
- Image scraping automation (legal gray area)
- User-to-user messaging (moderation burden)
- Wiki-style open item editing (quality risk)
- Marketplace / buy-sell (payment + fraud + legal)
- AI gear recommendations (training data + hallucination)
- Gamification (incentivizes quantity over quality)
- Price tracking / deal alerts (scraping fragility + legal)
- Native mobile app (web-first, responsive; PWA considered — see 999.9)
- Custom comparison parameters (complexity trap; weight/price covers 80%)
- Barcode scanning (poor UX vs catalog search)
- Maintaining SQLite single-user path in parallel (diverged at v2.0)
## Next Up
See `BACKLOG.md` for the forward-looking list (pending todos, unfinished v2.4 verification, backlog phases, deferred requirements).

View File

@@ -0,0 +1,18 @@
---
created: 2026-04-10T09:23:46.394Z
title: Add manufacturer entity with brand details
area: database
files:
- src/db/schema.ts
- src/server/services/global-item.service.ts
---
## Problem
The manual item adding form doesn't include detailed manufacturer info. Currently `brand` is just a text field on items and globalItems. There's no structured manufacturer data (logo, website, country, description). Users can't select from existing manufacturers — they type freeform text which leads to inconsistencies ("Ortlieb" vs "ORTLIEB" vs "ortlieb").
## Solution
Create a `manufacturers` table with fields: `id`, `name` (unique), `logoUrl`, `websiteUrl`, `country`, `description`, `createdAt`. Add a `manufacturerId` FK on `globalItems` (and potentially `items`). Build a manufacturer picker component (like CategoryPicker) with search and inline create. Update the manual add form and catalog enrichment to use the manufacturer entity. This is a significant feature — likely needs its own phase.
Note: This would also improve the MCP agent seeding workflow — agents could create manufacturers first, then reference them when creating catalog items.

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-20T00:00:00.000Z
title: Make tag selector in global search searchable
area: ui
files:
- src/client/components/CatalogSearchOverlay.tsx
---
## Problem
The tag filter in the global search overlay (`CatalogSearchOverlay.tsx`) displays all tags as chips but provides no search/filter input. When there are many tags, users must scroll through all of them to find the one they want — not ergonomic.
## Solution
Add a search input inside the tag filter dropdown/section so users can type to narrow down visible tags before selecting. Similar pattern to how the admin items list (`src/client/routes/admin/items/index.tsx`) handles tag filtering.

View File

@@ -0,0 +1,235 @@
# Tag Selector Search Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a typing-to-filter input above the tag chip list in the filter sidebar of `CatalogSearchOverlay` so users with many tags can narrow the visible list.
**Architecture:** Single-file UI change. Local `tagSearch` state, computed `filteredTags` derived via case-insensitive substring match, conditionally rendered input (only when `tags.length > 8`), muted "No tags match" hint when the filter empties the list, reset on overlay close. No backend, no new files, no new dependencies.
**Tech Stack:** React 19 + TanStack Query (via existing `useTags`) + Tailwind CSS v4.
**Spec:** `docs/superpowers/specs/2026-04-23-tag-selector-search-design.md`
---
## File Structure
**Modify only:**
- `src/client/components/CatalogSearchOverlay.tsx` — add state, reset hook extension, and JSX changes inside the Tags section of the filter sidebar.
No new files.
---
## Task 1: Implement tag search in the filter sidebar
**Files:**
- Modify: `src/client/components/CatalogSearchOverlay.tsx`
### Step 1: Add `tagSearch` local state
- [ ] Add the following state declaration immediately after the existing `const [filterOpen, setFilterOpen] = useState(false);` (currently line 25). This keeps filter-panel state grouped together.
```tsx
const [tagSearch, setTagSearch] = useState("");
```
### Step 2: Extend the close-reset effect to reset `tagSearch`
- [ ] In the existing reset `useEffect` (currently lines 8498), add `setTagSearch("");` to the list of resets so the filter clears on overlay close/reopen. The updated effect looks like:
```tsx
// Reset state when overlay closes
useEffect(() => {
if (!catalogSearchOpen) {
setSearchInput("");
setDebouncedQuery("");
setSelectedTags([]);
setFilterOpen(false);
setTagSearch("");
setWeightMin(0);
setWeightMax(5000);
setPriceMin(0);
setPriceMax(100000);
setManualEntryMode(false);
setSavedItemName(null);
setCatalogSubmitted(false);
}
}, [catalogSearchOpen]);
```
### Step 3: Replace the Tags section JSX with search-input + filtered list + empty hint
- [ ] In the filter sidebar, the Tags section currently spans lines 333356. Replace the existing block with the following. This (a) conditionally renders a small filter input only when `tags.length > 8`, (b) computes a case-insensitive substring filter inline, (c) renders either the filtered chip list or a muted "No tags match" hint.
Current block to replace (exact):
```tsx
{/* Tags */}
<div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags
</h3>
<div className="space-y-1">
{tags.map((tag) => {
const isActive = selectedTags.includes(tag.name);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
{tag.name}
</button>
);
})}
</div>
</div>
```
Replacement block:
```tsx
{/* Tags */}
<div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags
</h3>
{tags.length > 8 && (
<input
type="text"
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
placeholder="Filter tags..."
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
/>
)}
<div className="space-y-1">
{(() => {
const q = tagSearch.trim().toLowerCase();
const filteredTags = q
? tags.filter((t) => t.name.toLowerCase().includes(q))
: tags;
if (filteredTags.length === 0) {
return (
<p className="text-xs text-gray-400 px-2.5 py-1.5">
No tags match
</p>
);
}
return filteredTags.map((tag) => {
const isActive = selectedTags.includes(tag.name);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
{tag.name}
</button>
);
});
})()}
</div>
</div>
```
**Notes:**
- The `(() => { ... })()` IIFE keeps the filter logic local to this block without adding another variable at component top-level. It runs on every render, which is fine — cost is O(n) per keystroke and `tags` is small.
- `q` is trimmed-and-lowercased once so we don't recompute it per tag.
- When `q` is empty, we use `tags` directly to avoid the filter allocation.
- The filter input is only rendered when `tags.length > 8`; below that threshold, the old behavior (no input, no filtering) is preserved.
### Step 4: Run the linter
- [ ] Run:
```bash
bun run lint
```
Expected: exit 0 with no errors. Biome should be happy (tabs, double quotes, organized imports — existing conventions).
If there are complaints about the IIFE or `q` variable, resolve them before continuing. If Biome flags the `no-nested-ternary` or complexity rules, hoist the filter logic into a `useMemo` just above the return statement instead — functionally equivalent, syntactically flatter.
### Step 5: Start the dev server and verify in a browser
- [ ] Run (in a separate terminal or background):
```bash
bun run dev
```
- [ ] Open `http://localhost:5173`, sign in if required, and open the catalog search overlay (FAB or top-nav search). Toggle the Filter icon to open the sidebar.
- [ ] Walk through acceptance criteria from the spec:
1. With >8 tags, the "Filter tags..." input appears above the chip list. With ≤8 tags, the input is hidden.
2. Typing narrows the list immediately (case-insensitive substring).
3. Selecting a tag, then typing a query that excludes it: the chip disappears from the sidebar but the blue pill in the header row remains.
4. Typing a query that matches nothing shows "No tags match".
5. Closing the overlay (Esc / back arrow / clicking outside header) and reopening: the tag search input is empty.
6. Weight/price range filters, active filter pills, and Clear-all still work as before.
- [ ] If any criterion fails, fix inline and re-verify. Do not commit broken work.
### Step 6: Commit
- [ ] Stage and commit:
```bash
git add src/client/components/CatalogSearchOverlay.tsx
git commit -m "$(cat <<'EOF'
feat(catalog): searchable tag filter in global catalog overlay
Adds a typing-to-filter input above the tag chip list in the filter
sidebar, rendered only when there are more than eight tags. Case-
insensitive substring match; shows "No tags match" when the query
empties the list. Selected tags filtered out of the sidebar remain
active as header pills. Resets with the rest of overlay state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Self-Review
**Spec coverage:**
| Spec requirement | Task step |
|---|---|
| Add `tagSearch` local state | Task 1 Step 1 |
| Reset `tagSearch` on overlay close | Task 1 Step 2 |
| Render input only when `tags.length > 8` | Task 1 Step 3 (conditional) |
| Case-insensitive substring match | Task 1 Step 3 (`q`/`includes`) |
| Selected tag filtered out stays active via header pill | Existing behavior preserved (no code removed from header pill row) — verified in Step 5 criterion 3 |
| "No tags match" hint on zero results | Task 1 Step 3 (early return) |
| Placeholder `Filter tags...` | Task 1 Step 3 |
| Style matches minimalist look, narrower for sidebar | Task 1 Step 3 (smaller padding, ring-1, text-xs) |
| Closing and reopening shows empty input | Task 1 Step 2 + Step 5 criterion 5 |
| No regressions to weight/price/pills | Step 5 criterion 6 |
All spec requirements mapped.
**Placeholders:** none. No "TBD" or "handle edge cases" language; all code is shown in full.
**Type consistency:** `tagSearch` is a `string`, always used as `tagSearch.trim().toLowerCase()`. `filteredTags` is `Tag[]` same shape as `tags`. Tag shape from `useTags` is `{ id: number; name: string }` — both fields used correctly.
---
## Execution Handoff
Scope is one file + six small steps. Subagent-driven would be over-engineered. Recommended: inline execution with `superpowers:executing-plans`.

View File

@@ -0,0 +1,66 @@
# Tag Selector Search in CatalogSearchOverlay
**Date:** 2026-04-23
**Status:** Approved, ready for implementation
## Problem
The tag filter in the global catalog search overlay (`src/client/components/CatalogSearchOverlay.tsx`) renders every tag as a chip in a narrow sidebar. When the tag taxonomy grows, users must scroll through the full list to find the one they want. There is no typing-to-filter affordance.
## Goal
Allow users to narrow the visible tag list by typing a query, without disrupting selection or the surrounding filter UX.
## Scope
Single-file change to `src/client/components/CatalogSearchOverlay.tsx`. No backend changes. No new dependencies. No shared component extraction.
## Behavior
**Search state**
- Add a `tagSearch: string` local state, defaulting to `""`.
**Input rendering**
- Render a compact text input at the top of the "Tags" section inside the filter sidebar (above the existing tag chip list).
- Style to match the existing minimalist look: similar to the main catalog search input but narrower and with reduced padding so it fits the 224px sidebar.
- Render the input only when `tags.length > 8`. Below that threshold scrolling isn't an issue and the extra chrome is noise.
- Placeholder text: `Filter tags...`.
**Filter logic**
- Compute the visible tag list as: tags whose `name` includes `tagSearch` (case-insensitive substring match).
- A selected tag that does not match the query is hidden from the sidebar list. It remains active and visible as a blue pill in the header row (existing behavior — no change required).
- When the filter produces zero visible tags, render a small muted hint: `No tags match`.
**Reset**
- When the overlay closes, reset `tagSearch` to `""` by extending the existing reset `useEffect` at lines 8498.
## Non-goals
- No fuzzy matching or ranking — plain case-insensitive substring is sufficient.
- No keyboard navigation of the tag list (arrow keys / Enter to toggle). Users click chips.
- No persistence of `tagSearch` across overlay open/close cycles.
- No changes to the active-pill row, active-filter counting, or weight/price range filters.
- No unit tests — pure UI state, covered by manual UAT verification during milestone close-out.
## Acceptance criteria
1. Typing in the tag search input immediately narrows the visible tag chips by case-insensitive substring match on tag name.
2. Tags that are selected but no longer match the query disappear from the sidebar list and remain selected (visible as blue pills in the header).
3. With 8 or fewer tags, the search input is not rendered.
4. With more than 8 tags and a query that matches none of them, a `No tags match` hint is shown.
5. Closing the overlay and reopening it shows an empty tag search input.
6. No regressions to other filter behavior (weight range, price range, active filter pills, clear-all).
## Files touched
- `src/client/components/CatalogSearchOverlay.tsx`
## Out of scope for this spec
- Tag selector in other components (admin items list, etc.).
- Any server-side tag search or query.
- Hierarchy-aware filtering (parent match reveals children, etc.) — tags are consumed flat from `useTags()` in this component.

View File

@@ -23,6 +23,7 @@ export function CatalogSearchOverlay() {
const [debouncedQuery, setDebouncedQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filterOpen, setFilterOpen] = useState(false);
const [tagSearch, setTagSearch] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);
@@ -87,6 +88,7 @@ export function CatalogSearchOverlay() {
setDebouncedQuery("");
setSelectedTags([]);
setFilterOpen(false);
setTagSearch("");
setWeightMin(0);
setWeightMax(5000);
setPriceMin(0);
@@ -334,8 +336,31 @@ export function CatalogSearchOverlay() {
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags
</h3>
{tags.length > 8 && (
<input
type="text"
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
placeholder="Filter tags..."
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
/>
)}
<div className="space-y-1">
{tags.map((tag) => {
{(() => {
const q = tagSearch.trim().toLowerCase();
const filteredTags = q
? tags.filter((t) =>
t.name.toLowerCase().includes(q),
)
: tags;
if (filteredTags.length === 0) {
return (
<p className="text-xs text-gray-400 px-2.5 py-1.5">
No tags match
</p>
);
}
return filteredTags.map((tag) => {
const isActive = selectedTags.includes(tag.name);
return (
<button
@@ -351,7 +376,8 @@ export function CatalogSearchOverlay() {
{tag.name}
</button>
);
})}
});
})()}
</div>
</div>