#169 made the filter drawer shared, which was the right first move and is not the finish. What it left is a component that knows about every screen that uses it: showFavorites, showStatus, and priceRange: null as a way of saying "no slider here". Adding a screen means adding a flag; adding a screen-specific control means the shared component learning about it.
The bar around the drawer was never shared at all. The Filters (N) button, the open state and the tally are written twice — once in InventoryFilters.tsx, once inline in App.tsx — and have already diverged, because the admin bolts on + (filters.status === null ? 0 : 1) by hand.
And one filter sits outside the system entirely: the storefront's Not sold / Sold / All preset, because it belongs in the always-visible bar rather than in the drawer, and the shared component has no way to express that.
The shape
A screen composes a list of filter dimensions. Each declares where it renders, how to render it, and what chips it contributes:
The standard dimensions ship as exported factories. A screen that needs something bespoke writes its own and the component treats it identically — it appears in the chip row and counts toward the tally without the shared code knowing what it is.
chips() is a pure function of the filter state, deliberately. The drawer sets destroyOnHidden, so its sections are unmounted whenever it is closed, which is exactly when chips matter most. Any design where sections register themselves on mount loses the chip row the moment the drawer closes. That constraint is what rules out the otherwise-idiomatic context-and-children approach.
The tally becomes the chips
activeFilterCount and ActiveFilterChips compute the same thing by two routes today and agree by coincidence. Under this design the count is the number of chips, so they cannot disagree.
That changes three visible behaviours, recorded here rather than discovered later:
Admin with three statuses selected shows Filters (3), not Filters (1), matching how categories and tags already count.
Choosing Sold or All on the storefront produces a removable chip, where today nothing appears in that row.
hasActiveFilters is deleted. Its job — deciding whether an empty grid reads as "No items match these filters" with a way out, or as an empty shop — becomes chips.length > 0.
Testing
chips() being pure is most of the point, and the frontend has no unit test runner at all, so today it could only be covered end-to-end, expensively and indirectly. A minimal vitest setup scoped to the dimensions is part of this work.
Not in scope
The filter state itself. ItemFilters, the URL serialisation and the parsing are untouched — dimensions render and describe, they never own the state.
#169 made the filter drawer shared, which was the right first move and is not the finish. What it left is a component that knows about every screen that uses it: `showFavorites`, `showStatus`, and `priceRange: null` as a way of saying "no slider here". Adding a screen means adding a flag; adding a screen-specific control means the shared component learning about it.
The bar around the drawer was never shared at all. The `Filters (N)` button, the open state and the tally are written twice — once in `InventoryFilters.tsx`, once inline in `App.tsx` — and have already diverged, because the admin bolts on `+ (filters.status === null ? 0 : 1)` by hand.
And one filter sits outside the system entirely: the storefront's `Not sold / Sold / All` preset, because it belongs in the always-visible bar rather than in the drawer, and the shared component has no way to express that.
## The shape
A screen composes a list of **filter dimensions**. Each declares where it renders, how to render it, and what chips it contributes:
```ts
interface FilterDimension {
key: string;
placement: 'bar' | 'drawer';
heading?: string;
render(ctx: FilterContext): ReactNode;
chips(ctx: FilterContext): Chip[];
}
```
The standard dimensions ship as exported factories. A screen that needs something bespoke writes its own and the component treats it identically — it appears in the chip row and counts toward the tally without the shared code knowing what it is.
**`chips()` is a pure function of the filter state, deliberately.** The drawer sets `destroyOnHidden`, so its sections are unmounted whenever it is closed, which is exactly when chips matter most. Any design where sections register themselves on mount loses the chip row the moment the drawer closes. That constraint is what rules out the otherwise-idiomatic context-and-children approach.
## The tally becomes the chips
`activeFilterCount` and `ActiveFilterChips` compute the same thing by two routes today and agree by coincidence. Under this design the count *is* the number of chips, so they cannot disagree.
That changes three visible behaviours, recorded here rather than discovered later:
- Admin with three statuses selected shows **Filters (3)**, not **Filters (1)**, matching how categories and tags already count.
- Choosing **Sold** or **All** on the storefront produces a removable chip, where today nothing appears in that row.
- `hasActiveFilters` is deleted. Its job — deciding whether an empty grid reads as "No items match these filters" with a way out, or as an empty shop — becomes `chips.length > 0`.
## Testing
`chips()` being pure is most of the point, and the frontend has **no unit test runner at all**, so today it could only be covered end-to-end, expensively and indirectly. A minimal vitest setup scoped to the dimensions is part of this work.
## Not in scope
The filter state itself. `ItemFilters`, the URL serialisation and the parsing are untouched — dimensions render and describe, they never own the state.
Design: `docs/superpowers/specs/2026-08-25-filter-dimensions-design.md`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
#169 made the filter drawer shared, which was the right first move and is not the finish. What it left is a component that knows about every screen that uses it:
showFavorites,showStatus, andpriceRange: nullas a way of saying "no slider here". Adding a screen means adding a flag; adding a screen-specific control means the shared component learning about it.The bar around the drawer was never shared at all. The
Filters (N)button, the open state and the tally are written twice — once inInventoryFilters.tsx, once inline inApp.tsx— and have already diverged, because the admin bolts on+ (filters.status === null ? 0 : 1)by hand.And one filter sits outside the system entirely: the storefront's
Not sold / Sold / Allpreset, because it belongs in the always-visible bar rather than in the drawer, and the shared component has no way to express that.The shape
A screen composes a list of filter dimensions. Each declares where it renders, how to render it, and what chips it contributes:
The standard dimensions ship as exported factories. A screen that needs something bespoke writes its own and the component treats it identically — it appears in the chip row and counts toward the tally without the shared code knowing what it is.
chips()is a pure function of the filter state, deliberately. The drawer setsdestroyOnHidden, so its sections are unmounted whenever it is closed, which is exactly when chips matter most. Any design where sections register themselves on mount loses the chip row the moment the drawer closes. That constraint is what rules out the otherwise-idiomatic context-and-children approach.The tally becomes the chips
activeFilterCountandActiveFilterChipscompute the same thing by two routes today and agree by coincidence. Under this design the count is the number of chips, so they cannot disagree.That changes three visible behaviours, recorded here rather than discovered later:
hasActiveFiltersis deleted. Its job — deciding whether an empty grid reads as "No items match these filters" with a way out, or as an empty shop — becomeschips.length > 0.Testing
chips()being pure is most of the point, and the frontend has no unit test runner at all, so today it could only be covered end-to-end, expensively and indirectly. A minimal vitest setup scoped to the dimensions is part of this work.Not in scope
The filter state itself.
ItemFilters, the URL serialisation and the parsing are untouched — dimensions render and describe, they never own the state.Design:
docs/superpowers/specs/2026-08-25-filter-dimensions-design.md.