4 Commits

Author SHA1 Message Date
d9ec330aca feat(catalog): searchable tag filter in global catalog overlay
All checks were successful
CI / e2e (push) Has been skipped
CI / ci (push) Successful in 1m56s
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
5 changed files with 359 additions and 60 deletions

View File

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

View File

@@ -64,16 +64,12 @@ bun run build # Vite build → dist/client/
## Releasing ## 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. To release, tag the desired commit on `Develop` and push:
Trigger via Gitea API:
```bash ```bash
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \ git tag v2.3.0
-H "Authorization: token <GITEA_TOKEN>" \ git push origin v2.3.0
-H "Content-Type: application/json" \
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
``` ```
## Branching ## Branching

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 [debouncedQuery, setDebouncedQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [tagSearch, setTagSearch] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("grid"); const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [manualEntryMode, setManualEntryMode] = useState(false); const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null); const [savedItemName, setSavedItemName] = useState<string | null>(null);
@@ -87,6 +88,7 @@ export function CatalogSearchOverlay() {
setDebouncedQuery(""); setDebouncedQuery("");
setSelectedTags([]); setSelectedTags([]);
setFilterOpen(false); setFilterOpen(false);
setTagSearch("");
setWeightMin(0); setWeightMin(0);
setWeightMax(5000); setWeightMax(5000);
setPriceMin(0); setPriceMin(0);
@@ -334,24 +336,48 @@ export function CatalogSearchOverlay() {
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags Tags
</h3> </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"> <div className="space-y-1">
{tags.map((tag) => { {(() => {
const isActive = selectedTags.includes(tag.name); const q = tagSearch.trim().toLowerCase();
return ( const filteredTags = q
<button ? tags.filter((t) =>
key={tag.id} t.name.toLowerCase().includes(q),
type="button" )
onClick={() => toggleTag(tag.name)} : tags;
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${ if (filteredTags.length === 0) {
isActive return (
? "bg-blue-50 text-blue-700 font-medium" <p className="text-xs text-gray-400 px-2.5 py-1.5">
: "text-gray-600 hover:bg-gray-50" No tags match
}`} </p>
> );
{tag.name} }
</button> 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>
</div> </div>