diff --git a/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md b/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md
new file mode 100644
index 0000000..54fca6f
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-25-filter-dimensions-design.md
@@ -0,0 +1,167 @@
+# Filter Dimensions — Design
+
+**Issue:** [#188 — Make filtering one composable component both screens extend](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/188)
+**Date:** 2026-08-25
+**Status:** Approved
+
+## Goal
+
+Filtering becomes one component that both the storefront and the admin inventory extend, where a screen contributes filter *dimensions* rather than the component carrying a flag per screen.
+
+## Where this starts from
+
+#169 made `FilterDrawer` and `ActiveFilterChips` shared. Three things it did not do:
+
+- **Per-screen differences are booleans.** `showFavorites`, `showStatus`, and `priceRange: null` standing in for "no slider here". A third screen means a third flag, and a screen-specific control means the shared component learning about that screen.
+- **The bar was never shared.** The `Filters (N)` button, the drawer's open state and the tally are written twice — in `InventoryFilters.tsx` and inline in `App.tsx`. They have already diverged: the admin bolts `+ (filters.status === null ? 0 : 1)` onto the count by hand, because status is in its drawer and not in the storefront's.
+- **One filter is outside the system.** The storefront's `Not sold / Sold / All` preset lives in the always-visible bar, and the shared component cannot express that placement.
+
+## Decisions
+
+Settled in conversation before this was written.
+
+| Question | Decision |
+| --- | --- |
+| What drives the change | Screens must be able to inject controls the shared component knows nothing about |
+| What an injected control declares | A full filter dimension — render, chips, placement — so it behaves exactly like a built-in one |
+| Placement | A dimension declares `bar` or `drawer`, which absorbs the availability preset |
+| How a dimension is expressed | Plain data, not components or context |
+| The tally | The number of chips |
+| Test runner | Add vitest, scoped to the dimensions |
+
+## Architecture
+
+### The contract
+
+```ts
+interface Chip {
+ key: string;
+ label: string;
+ /** Tags carry their own colour (#185); nothing else has one. */
+ color?: string;
+ onRemove: () => void;
+}
+
+interface FilterContext {
+ filters: ItemFilters;
+ onChange: (next: ItemFilters) => void;
+ categories: Category[];
+ tags: ItemTag[];
+ /** Null where a screen has no catalogue-wide range to bound a slider with. */
+ priceRange: { min_cents: number; max_cents: number } | null;
+}
+
+interface FilterDimension {
+ key: string;
+ placement: 'bar' | 'drawer';
+ /** Drawer sections carry a heading; bar controls render bare. */
+ heading?: string;
+ render(ctx: FilterContext): ReactNode;
+ /** Empty when this dimension is not filtering anything. */
+ chips(ctx: FilterContext): Chip[];
+}
+```
+
+### Why plain data
+
+`chips()` must be callable without anything being rendered. `FilterDrawer` sets `destroyOnHidden`, so its sections are unmounted whenever the drawer is closed — which is exactly when the chip row matters most. A design where sections register themselves on mount would lose every drawer chip the moment the drawer closed.
+
+That rules out the otherwise-idiomatic React answer of context plus self-registering children, and it rules out components carrying static metadata, since reading their chips would mean rendering them. Plain data has no mount order, no lifecycle, and no dependency on the drawer being open.
+
+It also makes the interesting logic pure functions, which is what makes the test story below worth anything.
+
+### Files
+
+The new files go under `src/components/filters/` rather than a top-level `src/filters/`. A `src/filters/` directory beside the existing `src/filters.ts` would leave `import … from './filters'` resolving by bundler convention rather than by intent, which is not a thing to leave to convention. `filters.ts` keeps its current path and its current job.
+
+| File | Change |
+| --- | --- |
+| `src/components/filters/dimension.ts` | New. The three types above. No JSX. |
+| `src/components/filters/standardDimensions.tsx` | New. `categories`, `tags`, `price`, `favorites`, `status`, `availability`. `.tsx`, since `render` returns JSX. |
+| `src/components/filters/FilterBar.tsx` | New. Bar dimensions, the `Filters (N)` button, the chip row, the drawer. |
+| `src/components/FilterDrawer.tsx` | Absorbed into `FilterBar` as its drawer shell, then deleted. |
+| `src/components/ActiveFilterChips.tsx` | Becomes `src/components/filters/FilterChips.tsx`: takes `Chip[]` and `onClear`, knows nothing about filters. |
+| `src/admin/InventoryFilters.tsx` | Reduces to composing four dimensions. |
+| `src/App.tsx` | Loses the `Segmented`, the button, `drawerOpen` and `activeCount`. |
+| `src/filters.ts` | `activeFilterCount` and `hasActiveFilters` deleted. |
+
+### Composition at each screen
+
+```tsx
+// Storefront
+
+
+// Admin inventory
+
+```
+
+Order in the array is render order. `availability` is the only `bar` dimension today.
+
+`FilterBar` also takes what the context needs — `filters`, `onChange`, `onClear`, `categories`, `tags`, `priceRange` — plus `resultCount`, which the drawer footer reads for its `Show N items` button.
+
+The `favorites` dimension only sets `favoritesOnly`. Prompting a signed-out visitor to sign in stays where it is: `useCatalogue` reports `needsFavoritesAuth` and the page owns the modal. A dimension does not need to know a session exists.
+
+## Data flow
+
+`FilterBar` owns exactly one piece of state: whether the drawer is open. Everything else is derived.
+
+1. The page owns `ItemFilters` and keeps the URL as its source of truth. Unchanged.
+2. `FilterBar` builds one `FilterContext` and passes it to every dimension.
+3. Bar dimensions render inline; drawer dimensions render as sections inside the drawer.
+4. Chips come from `dimensions.flatMap(d => d.chips(ctx))`.
+5. The tally is `chips.length`.
+6. A dimension's `onChange` replaces the whole `ItemFilters`, exactly as the controls do today.
+
+Dimensions never own filter state, never read the URL, and never fetch. Given the same context, a dimension renders the same thing and reports the same chips.
+
+## Behaviour changes
+
+Three, all consequences of the tally being the chips, and all intended.
+
+**Admin with three statuses shows `Filters (3)`, not `Filters (1)`.** Consistent with categories and tags, which already count per selection.
+
+**Choosing `Sold` or `All` on the storefront produces a removable chip.** Today that row shows nothing for availability. The chip makes the "way out" visible where the filter was set, rather than only on an empty grid.
+
+**`hasActiveFilters` is deleted.** Its job was deciding whether an empty grid reads as "No items match these filters" with a way out, or "No items yet — check back soon". That becomes `chips.length > 0`. This is why the availability chip is required rather than optional: without it, a storefront filtered to `Sold` with no results would report itself as an empty shop.
+
+`Not sold` remains the default and produces no chip, so it neither counts nor appears — a filter nobody chose should not read as one.
+
+## Testing
+
+### Unit — vitest, new
+
+The frontend has no unit runner. Adding one is in scope, kept minimal: vitest, jsdom only if a test needs it, and no component-rendering library. The target is `chips()` and the dimension helpers, which are pure and awkward to reach end-to-end.
+
+Cases worth having:
+
+- Each dimension reports no chips when its slice of `ItemFilters` is empty.
+- Categories and tags report one chip per selection; status reports one per status.
+- `availability` reports nothing at `Not sold` and a chip at `Sold` and at `All`.
+- A tag chip carries the tag's colour; a tag missing from the loaded options still produces a chip, uncoloured.
+- A chip's `onRemove` produces the expected `ItemFilters`, and removing one of several leaves the rest.
+
+### End-to-end — existing, extended
+
+`filters.spec.ts`, `admin-inventory-filters.spec.ts` and `favorites-filter.spec.ts` already cover this UI through page objects and should keep passing with changes only where the three behaviour changes above are visible. Two assertions to add:
+
+- The admin tally reads `Filters (3)` with three statuses selected.
+- Choosing `Sold` on the storefront shows a removable chip that clears back to the default.
+
+### Known interference
+
+`filters.spec.ts` and `favorites-filter.spec.ts` each contain one assertion against the *unfiltered* grid that currently fails on any branch, because the development database has grown past what the unpaginated storefront can render inside a 5 second timeout. That is [#186](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/186) and is not caused by this work. Those two are expected to fail locally until #186 is resolved, and their failure must not be read as a regression here.
+
+## Error handling
+
+There is no new failure mode. Dimensions are pure and synchronous; they do no I/O.
+
+The one degraded state is data that has not arrived: `categories` and `tags` are empty and `priceRange` is null while `/api/filters` is in flight. Every dimension already handles this — the category and tag controls render antd's `Empty`, the price slider is omitted without bounds, and a chip for a tag missing from the options falls back to `Tag {id}` uncoloured rather than rendering blank. That behaviour is preserved rather than redesigned.
+
+A dimension that throws while rendering is a programming error and is caught by the catalogue's existing error boundary, as the current controls are.
+
+## Not in scope
+
+- `ItemFilters`, the URL serialisation and the parsing in `filters.ts`.
+- Anything in the backend.
+- `Categories.tsx`'s own tree adapter, which builds a different shape for a real antd `Tree` and is deliberately separate (#182).
+- Pagination, and the two e2e assertions blocked on it (#186).