Skip to content

Make catalog browsing store-driven: native facets, server-side sort and cursor paging - #79

Merged
hta218 merged 49 commits into
mainfrom
feat/collection-route-filters
Sep 28, 2026
Merged

hta218 merged 49 commits into
mainfrom
feat/collection-route-filters

Conversation

@hta218

@hta218 hta218 commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Closes #78
Closes #80

Why

Two problems, one root cause.

/shop/<collectionHandle> parsed no query state at all, so nothing on a collection page held a filter. The filters /shop did have (Category and Activity) existed in no Shopify store: they were constants in a per-handle profile table the theme carried. Connecting Pilot to the same store showed what the store actually offers: Availability and Price, and nothing else.

That table was one of several. Menus had to match a hardcoded tree, the header's Shop panel had four fixed cards, pages and articles came from an allowlist with their presentation in a profile table, and whenever live data did not fit, the theme quietly served fixtures instead. So the theme could not run on another store.

What changed

The catalog is the store's. catalog-presentation.ts and the handle allowlists are deleted. category is productType, activities are the product's tags minus ownership and ops bookkeeping, the subtitle is its first forward.highlights entry, colorway ids derive from the published Color values, swatches are Shopify's native ones, and related products are the store's other items of the same type. Collections take their title, image and membership from Shopify, with an optional description and a forward.field_code metafield.

Facets are the Storefront API's. A collection is its own query with filters, sortKey, reverse and cursors, and it returns products.filters. A facet value's input is opaque Shopify JSON. It travels into the URL under the facet's own id and back into the query untouched, so a filter a merchant enables in Search & Discovery renders with no code change. Without credentials, the deterministic catalog synthesizes the same two default facets and ignores shapes it does not recognise, so both modes answer a route the same way.

Sort is Shopify's. Six options, each a real sort key rather than an ordering applied after the fact.

Menus, footer and the Shop panel are the store's. Menus keep whatever the merchant arranged: order, labels and size. Shopify paths map onto theme routes (/collections/x → /shop/x, /collections/all → /shop, any blog → /journal), and one shared mapper (shopify/theme-routes.ts) serves both menus and content links. A menu the store has not set up is empty, and a link to another origin or to a route the theme lacks is left out. The header's Shop panel is built from the merchant's Shop links, and each row takes its description, image and field code from its collection.

Content is the store's. Every page and every article across blogs is read, instead of seven aliased pages and one approved blog. content-presentation.ts is deleted. An article's plate counts from the oldest, its reading time comes from its word count, its image is its own, and location/coordinates are the optional forward.location / forward.coordinates metafields. A page, article or policy whose body the parser refuses is left out (its route 404s) instead of failing the whole read. The parser still refuses the markup.

No fallbacks. Shopify mode never reads fixtures: navigation, footer, collections and content fail closed, and every executor is required. A Weaverse page renders exactly as authored, so an empty template renders empty and a route with no page answers 404. No route renders its own copy of a section a template omits (/shop, PDP), a childless main-product renders only its shell, and an mp--* child outside it renders nothing.

Composition.

/shop                        ALL_PRODUCTS
  all-products
  ├─ ap--toolbar             count, order
  └─ ap--product-grid        grid, cursor paging

/shop/<collectionHandle>     COLLECTION
  collection-hero
  main-collection
  ├─ mc--toolbar             count, clear filters, order
  └─ mc--content
     ├─ mc--filters          the store's facets
     └─ mc--product-grid     grid, cursor paging

Query state stays theme-owned: the route validates the facet params, sort and cursor, reads one page, and hands the result down as browse. A section never parses a param, so reordering the tree cannot change which products a URL selects.

Both trees render through the same CatalogToolbar and CatalogGrid components, so each ap--* / mc--* section is a thin Weaverse wrapper that differs only in its data and empty state. Sort options are one table typed with Hydrogen's own ProductCollectionSortKeys / ProductSortKeys, and both paged reads share one executor.

Two API facts worth knowing

  • QueryRoot.products accepts no filters argument, only first, after, last, before, reverse, sortKey and query. Faceted browsing is a collection feature, so /shop is sort and paging only.
  • TITLE exists in both sort key enums, so name (A–Z) is a real server-side sort.

Breaking

  • ?colorway= values changed (charcoal → charcoal-moss). Old links still resolve to the product and fall back to its first colorway.
  • The live menus point "Shop" and "Shop all" at /collections/forward, which is now /shop/forward. Point them at /collections/all to reach /shop.
  • /shop renders only what its ALL_PRODUCTS template composes. Until that template has an all-products section, the page is blank.
  • Header links carry only sort into /shop/**, because facets belong to one collection.
  • repair is empty until it becomes a theme setting, because the store has no source for it.
  • Pages have no per-page eyebrow or hero image (the page hero uses its settings), and policies have no summary.
  • The store's data-sharing-opt-out page 404s, since its markup is not renderable.
  • collection-grid, catalog-facets.ts, FilterSidebar, product-results, collection-presentation.ts and content-presentation.ts are retired.

Before merging

  • Metafield definitions with Storefront access, plus values: collection forward.field_code, article forward.location and forward.coordinates
  • Give the ALL_PRODUCTS template an all-products section (bun run seed:weaverse --apply seeds it)
  • Optionally point the Shop menu links at /collections/all
  • Squash merge: two intermediate commits (ae20d9d, a2af484) do not build on their own

Notes for review

AGENTS.md now forbids handle allowlists and presentation profile tables, states that nothing falls back to fixtures in Shopify mode, and states that a Weaverse page renders exactly as authored. Only Pilot's file organization was adopted; no Pilot source is translated. Removing static mode entirely is tracked in #81.

Verification

bun install --frozen-lockfile and bun run check pass: 380 node tests, 189 DOM tests, check:graphql, build, check:theme and check:routes. bun run verify:shopify passes against the live store, including live menus, footer, collections, pages, articles and policies. bun run smoke:routes passes except /shop, which stays blank until its template is populated. bun run test:browser has not been run on this revision.

Specs: .weaverse/specs/2026-09-21--collection-route-filters/ and .weaverse/specs/2026-09-22--store-driven-catalog/.

/shop spelled its own param parsing, category list and href builder. Moving
them behind a single module gives the collection route the same validated
query semantics instead of a second convention, and adds match counts and a
facet that is omitted when it cannot change the result.
Both adapters run the same normalized narrowing listProducts already uses, so
a collection filtered live cannot drift from one filtered against fixtures.
Product count, a no-JavaScript sort form that carries every param it does not
own, and the mobile disclosure that is the only way to reach the facets below
the sidebar's breakpoint.
The results row that holds the facet sidebar beside the grid. Which side the
sidebar sits on is the order the merchant put the children in, not a setting.
Every facet row is an href the route already validated, so narrowing a
collection needs no JavaScript and every result is a shareable URL.
Paged results with an authored page size, plus the empty state that clears
only the facet params so anything else on the URL survives the reset.
The shell owns layout and nothing else, and renders the default composition
when it has no children so a collection URL never loses its grid.
collection-grid is retired; mc--product-grid supersedes it.
The collection route parsed no query state at all, so nothing on the page held
a filter. It now validates category, activity and sort against the collection's
own products, reads the narrowed result through the storefront seam, and hands
it down already resolved — a section never parses a param.

Closes #78
@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
forward Ready Ready Preview Sep 28, 2026 12:28pm UTC

Activity and Category were two near-identical twenty-line blocks differing
only in param name and label, and the category strings were spelled three
times over. One record now carries the display order, the labels and the set
a query param is validated against, and one dimension table builds both
groups. Internals that had no caller outside the module stop being exported.
Counts were gated three times: an option on the facet builder, an undefined
check on the link, and a prop on the sidebar. The builder always counts now —
it is a pass over an array the page already holds — and the sidebar's prop is
the single switch. It defaults off, so /shop keeps its current look and
mc--filters opts in through its own setting.
The grid was three components and a Suspense boundary. The boundary guarded a
static-render bailout that cannot happen on a route already reading
searchParams on the server, and its fallback reached useSearchParams through
the pagination it rendered, so it could never have been a non-suspending
fallback anyway. Also drops emptyHeading, which named a link label rather than
a heading and was a second free-text setting on an empty state.
Same bailout that cannot happen; the hooks move up into the toolbar and the
form takes what it needs as props.
mapProduct consulted a per-handle profile table for category, activities,
subtitle, repair copy, related handles, colorway ids and swatch colours, and
rejected any handle absent from a nine-item allowlist. None of that could run
on another store.

Category is now productType, activities are the product's own tags minus
ownership and ops bookkeeping, the subtitle is its first forward.highlights
entry, colorway ids are derived from the published Color values, and swatch
colours are Shopify's own — null when the merchant set none, which is the
common case, so selectors fall back to the colorway image. Related products
are the store's other items of the same type. ProductCategory widens from a
three-literal union to the merchant's own label.

The canonical assertions the profiles supplied go with them: approved Color
order, an exact variant count, canonical variant order and a required size
list are all the merchant's business. Structural integrity stays: one Color
option, no duplicate values, a media map covering exactly the published
colours, no unreferenced media, USD money, unique merchandise ids.
The static fixtures were generated from the same profiles the mapper used, so
deleting the table left static mode with no catalog. They are literal data
now, mirroring the connected store's product types, tags and option values,
and they run the same derivations the live mapper does — so the two modes
cannot drift. catalog-presentation.ts is deleted.
The navigation mapper checked every collection against a theme-side profile:
the exact four approved handles, their titles, and their product membership in
order. A store with different collections failed the read.

Title, image and membership now come from Shopify, and the query asks for the
description and a forward.field_code metafield too. Both are optional — a
store that sets neither renders the collection without them rather than
borrowing copy the theme invented, so heroImage is nullable and the hero and
index omit what is missing. A truncated collections page is still a hard
failure; a store simply having fewer collections is not.
Every suite that asserted the approved-handle list, canonical colour order,
canonical variant order or the profile table now asserts what has to hold for
any store: unique slug-shaped colorway ids, a product type and tags present,
a swatch only when the store published one, related products sharing a type,
and a media map that covers exactly the published colours. The catalog fixture
carries the connected store's real per-product tags.
A facet value carries Shopify's own ProductFilter JSON as an opaque string:
the theme never builds or reads it, so a filter a merchant enables later works
without a code change. Sort options become real sort keys — TITLE, PRICE,
BEST_SELLING, CREATED — rather than an ordering the theme applies afterwards,
and featured is the merchant's own collection order.

Without credentials there is no API to ask, so the deterministic catalog
synthesizes the two facets every Shopify store exposes by default and applies
them locally, matching ids, inputs and counts. An unrecognised filter shape
leaves the catalog alone rather than emptying it.
The Storefront API accepts no filters argument outside a collection, so the
catalog level is sort and paging only — which is exactly why Pilot's own
all-products route has no filters either. Both modes agree on that rather than
offering controls that could not be applied.
Shop was a hand-written page with theme-invented Category and Activity
filters. It is now the all-products tree — a toolbar and a grid — over the
page the store returned, and it renders that composition itself when the
project has no ALL_PRODUCTS template, so the route is never empty. The
invented facet module, its sidebar and the old results column go with it.
The suites assert what has to hold for any store: a facet's input round-trips
through the URL untouched, an applied value is marked and clearing it is
reachable, the order control carries everything it does not own but drops the
cursor, and paging is cursors rather than page numbers.
@hta218 hta218 changed the title Add filters, sort and pagination to the collection route Make catalog browsing store-driven: native facets, server-side sort and cursor paging Sep 22, 2026
@hta218
hta218 merged commit ab1898b into main Sep 28, 2026
2 checks passed

This branch was successfully deployed

1 active deployment
Preview — ae8d51bc Deployed Sep 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant