Summary
The public web storefront (store/, shop.scout-patches.com) sells apparel —
hats, knit beanies, tees, long-sleeve shirts, sweatshirts and branded swag —
through a tag-driven catalogue read straight from Shopify. Two tags decide
everything: shop opts a product into the web store at all, and
shop:<category> places it on one or more of six category pages
(store/lib/store/shop.ts:1-18). The wall that used to hide the whole
shopfront behind a "coming soon" page is now open for this apparel store —
home, /shop, /shop/[category], /products/[handle] and /cart are all
reachable — while patch and pack commerce stays walled off
(store/lib/coming-soon.ts:29-41). The tagging pass HAS since been run: 27
products carry shop as of 2026-09-16 — 14 hats, 12 knit beanies, a tee and a
snapback (verified against the live Admin API, not inferred).
Status
Shipped to develop. The catalogue is now populated: 27 Shopify products
carry shop (see Summary), so /shop, /shop/hats and /shop/knit-hats
render real grids. /shop/tees, /shop/long-sleeve, /shop/sweaters and
/shop/swag are still thin or empty and render the empty state until more
products are tagged — a manual Shopify step (see CLAUDE.md's "Content
publishing" section for why that stays Alan's job, not an agent's).
Stock is the separate question from tagging: only the Saint Louis Zoo trail hat has inventory (10 units across three colourways). Every leather-patch hat and beanie is tagged, visible and at zero stock.
User-facing surfaces
/— home. Hero placeholder ("Scout Shop", a deliberate stand-in the repo owner will write) followed by one shelf per category flaggedhomeShelf: true, each showing up to 4 products with a "See all" link to its category page (store/app/(store)/page.tsx:36-77)./shop— the whole merch catalogue, unfiltered (store/app/(store)/shop/page.tsx)./shop/[category]— one statically-generated page per category:/shop/hats,/shop/knit-hats,/shop/tees,/shop/long-sleeve,/shop/sweaters,/shop/swag(store/app/(store)/shop/[category]/page.tsx:27-29)./products/[handle]— the product detail page. The earned-not-bought gate that applies to patches (patchProductGate) and collections (collectionProductGate) does not apply to apparel product types, so a merch PDP has no unlock requirement (store/app/(store)/products/[handle]/page.tsx:40-52). A merch PDP shows:- Colour swatches naming the selected value ("COLOR · Moss/Charcoal"),
which double as the image picker —
VariantSwatches, shared with the full-screen viewer. - An angle strip under the large photo, listing that colourway's other
views (front / side / back) when it has more than one. Present only for
products whose photos are alt-text-tagged per colour (today: the Saint
Louis Zoo trail hat). A product without the convention shows one photo
and no strip, exactly as before (
store/components/store/MerchImageStage.tsx). - A full-screen viewer. Tapping the photo opens
MerchLightbox: close button top-left, the photo as large as the screen allows, and the angle strip, colour swatches and buy button along the bottom. It renders the page's OWN buy area node, not a lightbox-only copy, so it can never offer a control the page itself does not. Restores the shape ofHatLightbox, deleted in55cf2123with the Hat Studio it was built for, but driven by real Shopify variants instead of a hardcoded hat catalogue. - A fixed square image stage. The photo sits in an
aspect-squareframe with the image absolutely positioned andobject-contain. Without it the frame takes its height from the photo, and a hat's front shot (1.16:1) vs its side shot (2.03:1) made the whole column — thumbnails included — jump on every switch. - A sold-out colour that is still selectable — dimmed and announced as "<value> — sold out", but clickable, so a shopper can look at it and land on the disabled "Sold out" control rather than being unable to click it at all. The shelf card's own picker behaves the same way.
- Colour swatches naming the selected value ("COLOR · Moss/Charcoal"),
which double as the image picker —
- Shelf and grid cards (
GalleryPiece,variant="field") lay out as: art, swatch row, the chosen value named in text, product name, one full-width action. Price and rating ride ON the art — a brass tag top right, a review stamp bottom left — rather than sitting in the text block. That is a layout decision, not decoration: only 2 of ~30 apparel products carry a review, so a rating ROW made two cards taller than the rest and knocked their buttons off a shared baseline, and a price RANGE ("$29.99 – $30.99") was most of a 161px mobile card when it shared the name's line. As overlays neither costs any layout. Every slot below the art is then a fixed height — name clamped to two lines, colour line 18px — so a row of cards is a single height (measured: 37 cards, one distinct height, every button at the same offset).- On hover the product turns. The card has no panel to rotate, so the
OBJECT tilts — up to 9° tracking the cursor across the whole card, lifted
36px, with a ground shadow that stays flat beneath it. Nine degrees rather
than the
gallerycard's thirteen: with no frame to read the rotation against, a steeper angle stops looking like an object turning and starts looking like the photo skewing. Off entirely on touch (hover: hover and pointer: fine) and underprefers-reduced-motion, both checked per event — a phone fires synthetic mouse events on tap and, ungated, would tilt a card and leave it stuck with nomouseleaveto follow. - The art is inset to 90% of the card rather than full-bleed, and that is
load-bearing: a transform's overflow counts toward an ancestor's
scrollable area, so a full-bleed lift pushed past the card and turned any
overflow-x: autoshelf into a vertically scrollable box. This is why the rule previously readtransform: none. Insetting keeps the lift inside the card's own box. Verified by measuringscrollHeightagainstclientHeightduring hover, on every scroll container on the page. - The perspective is declared on the pedestal, not the card:
perspectiveapplies only to an element's DIRECT children, and the art sits severaltransform-style: flatlevels below the card — so a perspective further up leaves the rotation rendering as a flat 2D skew rather than a turn. They carry their own colour picker: a row of swatches BELOW the photo, above the name. It used to float over the photo's right edge; that sat on top of the product and could only be as wide as the gutter beside it. Picking one is local card state: it swaps the card's image and which variant "Add to cart" adds, without leaving the shelf. - A
Colorvalue paints a real colour chip; every other option keeps that variant's photo. A contained photo at 28px could not tell "Black/White" from "Moss/Charcoal" — both read as the same dark smudge. A value naming two tones paints two hard-edged bands rather than a blend, because the shopper is choosing two distinct panels and a gradient would show a grey found nowhere on the product. - The chip colour never comes from a hand-kept name→hex map. These
colourways belong to the supplier, so a map would go stale the moment they
add one — and a stale map shows a confidently WRONG colour. Resolution
order (
store/lib/store/swatch-color.ts): Shopify's ownProductOptionValue.swatch.color, then a colour sampled offline from that variant's photograph, then nothing — and nothing means the swatch renders the photo, exactly as every swatch did before chips existed. So a colourway added after the last generator run degrades; it never lies. - Shopify's swatch field is read but unpopulated: 0 of 223 non-size option values carry one (audited 2026-09-17). Every chip on the site today is therefore a derived colour. A value the merchant fills in later overrides the derived one with no code change.
- Derived colours are generated by
store/scripts/derive-swatches.pyintostore/lib/store/derived-swatches.json(48 products, 202 colourways). Re-run it when the supplier adds or re-shoots a colourway. It masks background by alpha, not brightness — a brightness cutoff erases the white tee and the white half of "Black/White" — and samples only the outer band of the garment silhouette, because every render carries a centred leather patch or graphic big enough to win a tone slot outright (whole-image sampling handed "Brown/Khaki" the patch's#fafaf9as its second tone). - Known limit: 12 of the 49 values whose name implies more than one tone resolve to a single tone, because the second colour is not visible head-on — "Moss/Charcoal" is the clearest case, its charcoal being the mesh back. The chip shows one honest colour rather than an invented second.
- The product detail page still shows photo swatches, not chips
(
VariantSwatches): its query does not selectoptions, and its swatches are 56px where the card's are 28px, so the legibility problem that motivated chips is much weaker there. This is a known inconsistency between the grid and the PDP, not a decision that they should differ.
- On hover the product turns. The card has no panel to rotate, so the
OBJECT tilts — up to 9° tracking the cursor across the whole card, lifted
36px, with a ground shadow that stays flat beneath it. Nine degrees rather
than the
/products— 301-redirects to/shop. The old patch catalogue page still exists as code but is no longer routed to, so there is no second grid to keep in sync (store/app/(store)/products/page.tsx).- Nav — the header's primary links are the six categories, generated from
the registry in nav order so a new category appears automatically
(
store/components/store/Header.tsx:32-42).
How it works
- A category page (or the home shelf, or
/shop) callsgetShopProducts(category?), which builds a Shopify Storefrontproductsearch query viabuildShopQueryand fetches through the sharedgetShelfProductshelper used by every other shelf (swag, promo) — same never-throws contract: a Shopify error yields an empty list rather than a broken page (store/lib/shopify/apparel-catalog.ts:63-70,:75-97). buildShopQueryalways prependstag:shopand-tag:internal; a category argument appendstag:shop:<category>(store/lib/store/shop.ts:111-119).tag:shopis the opt-in gate — a product with noshoptag can never appear in the web store, however else it is tagged.- Category pages are pre-rendered at build via
generateStaticParams(one per entry inSHOP_CATEGORIES) and revalidate every 300s (ISR), matching every other catalogue route in the store (store/app/(store)/shop/[category]/page.tsx:16,:27-29). - When a category currently has zero products, the page renders
CategoryEmptyStateinstead of the grid, andgenerateMetadatasetsrobots: { index: false }for that response — an empty category is thin content and should not be crawled (store/app/(store)/shop/[category]/page.tsx:44,:66-70). - The result feeds the same product card (
GalleryPiece, viatoSwagGalleryItem) used everywhere else in the store, so apparel gets no bespoke card that could drift from the patch/swag cards (store/lib/store/swag-gallery.ts:1-13). Each card carries a star row (★★★★★ 5.0 (1)) when that product has published Judge.me reviews. Ratings are fetched ONCE per page withgetRatingsByProduct()and looked up per card withratingFor— never one request per card, which would be two Judge.me round trips per product.ShopGridtakesSwagItems and runs the adapter itself precisely so a call site cannot hand it cards whoseproductId(the lookup key) has already been adapted away. - On a product page,
MerchVariantProviderowns BOTH the colour selection and the angle index, and the swatches, the image stage, the angle strip and the full-screen viewer all read it — four views of one state rather than four copies. Changing colour resets the angle to that colour's first photo, derived during render rather than reset in an effect so the strip and the image can never be one paint out of step. Which photos belong to which colour is read from image alt text, formatted"<title> — <Colour> — <angle>"and matched per whole em-dash-separated segment (store/lib/store/variant-media.ts). This is a Shopify content convention, not a schema change: tagging a product's photos is an alt-text edit, and a product that does not use it degrades to one photo per colour. Alt text rather than Shopify variant media because the Storefront API exposes only ONE image per variant — a variant's full media list exists in the Admin API and is simply not readable by the storefront. - The mobile buy bar (
position: fixedbelowsm) measures itself, andMobilePurchaseBarSpacerreserves exactly that height at the end of the page (store/components/store/MobilePurchaseBar.tsx). It replaced two hardcoded spacer heights that were 144px short once a colour picker went into the bar. - Whether a category is open to crawlers moves with the wall
(
isOpenPath) and the same "has at least one product" count that drives the empty state —sitemapPathsfilters candidate URLs throughisOpenPathand drops any category slug not inpopulatedCategories(store/lib/sitemap-entries.ts:43-60; the count is computed once per category instore/app/sitemap.ts).
Data model
No new Prisma models. The registry is a static, in-repo array — not a database table:
// store/lib/store/shop.ts:45-90
SHOP_CATEGORIES: {
slug: string; // URL segment and tag suffix, e.g. 'hats'
navLabel: string; // nav bar label
title: string; // <h1> / <title>
description: string; // meta description / page subhead
homeShelf: boolean; // whether home renders a shelf for it
}[]
The six entries, in nav order: hats, knit-hats, tees, long-sleeve,
sweaters, swag (store/lib/store/shop.ts:45-90; order and slugs are
asserted by store/lib/store/shop.test.ts). swag is the only entry with
homeShelf: false, because every swag item also carries a garment tag and
would otherwise double up with another shelf on the same screen
(store/lib/store/shop.ts:78-84).
The actual products live in Shopify, identified purely by tag — there is no
Scout database row per apparel product the way there is for a patch.
Category membership is not exclusive: a Scout-branded snapback can carry
shop, shop:hats and shop:swag at once and appear on both category pages
(store/lib/store/shop.ts:10-13).
API surface
No new backend endpoints. This project is entirely store/ (the Next.js
storefront) talking directly to the Shopify Storefront GraphQL API
(GET_APPAREL_LISTING, called from getShelfProducts,
store/lib/shopify/apparel-catalog.ts:75). No Scout backend route was added
or changed.
Key files
store/lib/store/shop.ts— the category registry andbuildShopQuery.store/lib/store/variant-media.ts— the alt-text convention that maps a product's photos onto its colour option values.store/lib/store/swatch-color.ts— where a colour chip gets its colour: Shopify's swatch, then the derived colour, then nothing (render the photo). Rejects any value that is not a 6-digit hex, because these strings land in an inlinestyleattribute.store/lib/store/derived-swatches.json— generated colours, keyed by product handle then option value. Regenerate, never hand-edit.store/scripts/derive-swatches.py— the generator. Run it when the supplier adds or re-shoots a colourway.store/lib/shopify/queries.test.ts— the drift guard holding the two product queries (queries.tsandcollections.ts) to the same field selections. Without it, a field added to one silently produces a lesser card on the surfaces fed by the other.store/lib/judgeme-shape.ts—ratingFor, the shelf rating lookup shared byShopGridandSwagShelf.store/components/store/shop/ShopGrid.tsx,store/components/store/shop/ShopSection.tsx— the grid and its titled sections; both thread the page's ratings map down to the cards.store/components/store/MerchVariantProvider.tsx— shared selection state;initialVariantIdlets a caller open on a specific variant.store/components/store/VariantSwatches.tsx— the colour swatches, shared with the full-screen viewer.store/components/store/MerchImageStage.tsx— the fixed-square stage and the full-screen trigger.store/components/store/AngleStrip.tsx— the selected colour's other views, shared by the stage and the viewer.store/components/store/MerchLightbox.tsx— the full-screen viewer.store/components/store/TiltImage.tsx—frameClassNametakes the fixed box; its doc records why the image must be absolutely positioned.store/lib/store/buy-area.ts— which buy control a PDP renders, and the order the cases are tried in.store/components/store/MobilePurchaseBar.tsx— the fixed mobile buy bar and the self-measuring spacer that reserves its height.store/lib/shopify/apparel-catalog.ts:63-70—getShopProducts, the shared fetch used by every shop surface.store/lib/store/swag-gallery.ts— adapts aSwagItemto the shared product card.store/app/(store)/page.tsx:36-77— home page shelves.store/app/(store)/shop/page.tsx—/shopindex.store/app/(store)/shop/[category]/page.tsx— category pages, static params, metadata, empty-state branch.store/components/store/shop/CategoryEmptyState.tsx— the empty-category render.store/components/store/shop/ShopGrid.tsx— the grid layout wrappingGalleryPiece.store/components/store/Header.tsx:32-42— nav generated from the registry.store/lib/coming-soon.ts:29-60— the open/walled allowlist;/shop,/products,/cart,/accountare open, patch commerce is not.store/lib/sitemap-entries.ts— sitemap filtering, including the populated-category rule.store/lib/sitemap-pagination.ts—collectAll, the generic cursor walk over a Shopify connection. Extracted fromapp/sitemap.tsso it is reachable by thelib/**test runner; takes a page-fetcher so a test can drive it with a fake catalogue of any size.store/lib/seo-text.ts—stripHtmlandtruncateAtWord, the single definition behind every meta description and the Product JSON-LD description. It exists because there were two copies ofstripHtmlthat disagreed on whether a tag becomes' 'or''.store/lib/store/collection-tree.ts— pure tree building, path resolution and the redundancy rule.store/lib/shopify/collections.ts— the Shopify reads.store/app/(store)/shop/[...slug]/page.tsx— every browse page at any depth.store/lib/store/legacy-shop-routes.ts— 301s from the flat theme URLs.backend/src/scripts/create-shop-collections.ts— creates the structure.store/lib/merchant-feed.ts+store/app/feed.xml/route.ts— the Google Merchant Center product feed, one row per buyable variant. Shopify's own Google & YouTube app cannot produce it: that syncs from the Online Store channel, which Scout publishes to nothing on by design.store/lib/store/indexable.ts—isIndexableProduct/indexableHandles: the single rule deciding whether a product page is a search surface. Defined in terms ofbuyAreaFor, so indexability and the buy affordance cannot disagree. Used by the sitemap, the PDP'srobotsmetadata, and the PDP's Product JSON-LD.store/app/sitemap.ts— computespopulatedCategories(one query per category) and wires it intositemapPaths.store/lib/store/policy.ts— the shipping and returns policy, andMERCHANT_CENTER: a written record of exactly what was declared to Google. Google re-reviews the policy published on the SITE against that declaration, and a mismatch is a suspension risk rather than a warning; the declaration previously lived only in a Google web form, so nothing here could notice the site drifting from it.store/lib/store/policy.test.tsasserts they agree.store/lib/seo.ts—OG_CARDand the root social card, plushasRedundantBrandand the sharedSITE_NAME. The root declaredtwitter:card: summary_large_image— a promise of a large preview image — and supplied NOog:image, so every share of the storefront home rendered an empty box.productMetadatanow falls back to the same card instead of an empty array: a Shopify product can legitimately have nofeaturedImage(a freshly exported Printify one has zero for 15+ minutes), and that shipped the identical blank-card bug per product. The card itself isstore/public/og-card.png, generated with the landing page's bybackend/scripts/make-og-cards.py.store/lib/seo-title.test.tsscans everyapp/**/page.tsxfor a title that repeats the brandtitle.templatealready appends.store/app/(store)/products/page.tsx—/products→/shopredirect.store/lib/store/shop.test.ts,store/lib/coming-soon.test.ts,store/lib/sitemap-entries.test.ts,store/lib/standalone-routes.test.ts— tests, see below.
Google Merchant Center
/feed.xml is an RSS 2.0 feed in the http://base.google.com/ns/1.0 namespace,
revalidated hourly. It is live in Merchant Center account 5856267263
(US / English, daily fetch), with all 480 rows ingested and none disapproved.
Shipping is declared free, returns 30 days by mail with return postage at the
customer's cost.
Why self-hosted rather than Shopify's Google & YouTube app: that app syncs from
the Online Store channel, and Scout deliberately publishes to none of the
browsable publications. BROWSABLE_PUBLICATION_IDS in
backend/src/scripts/lib/shopify-publications.ts actively guards against it,
because the Liquid theme has never heard of the internal tag — publishing
there makes Hat Studio blanks publicly buyable. The standard path is closed by
design, not merely unconfigured.
Feed membership uses isIndexableProduct, the same predicate as the sitemap and
the PDP robots tag, so the three cannot drift. Advertising an earned-only
patch would be a Merchant Center policy violation, not merely a poor listing.
Against the live catalogue: 2,431 products in, 480 rows out (one per buyable variant of the 48 buyable products), zero patches, zero missing images, zero unescaped ampersands.
Apparel attributes. Merchant Center flagged all 480 rows with "Missing age
group / color / gender / size" — required attributes for apparel. The data was
in Shopify all along; the feed simply did not emit it. optionValue() reads
variants.selectedOptions case-insensitively on the option NAME, because those
names are merchant-entered (Color, color, Colour). age_group and
gender are stated flat as adult / unisex: nothing in the catalogue is
sized by gender.
A missing option is OMITTED rather than emitted empty — Merchant Center reads a
blank <g:size></g:size> as an invalid value, but a missing one as absent.
Live: 480/480 carry colour, age group and gender; 444 carry size.
Known wrinkle — g:size on the leather-patch beanies. 345 tee rows carry
real garment sizes (S…4XL), which is where Google requires size. But 96
beanie rows emit <g:size>Small Circle</g:size>, because those products use a
Shopify option literally named Size to describe the patch, not the hat.
A further 12 zoo trail hats emit no size at all, having only a Color option
where every other hat carries Size: One size.
Both are catalogue-naming problems, not feed bugs, and the fix belongs in
Shopify: rename the beanie option to Patch size, and add Size: One size to
the 12 trail hats. Size is only a REQUIRED attribute for
Apparel & Accessories > Clothing and > Shoes; hats are Clothing Accessories, so neither risks disapproval — they cost filter accuracy.
Configuration and flags
No new environment variable or feature flag. The existing STORE_COMING_SOON
flag (read by isComingSoonEnabled, store/lib/coming-soon.ts:105-108)
still governs whether the wall is up at all; when it is, OPEN_PREFIXES
decides what stays reachable, and the merch store's prefixes (/shop,
/products, /cart, /account) are in that allowlist
(store/lib/coming-soon.ts:50-60).
Edge cases and known limits
-
Every page title carried the brand TWICE. The root layout sets
title.template = '%s | Scout Patches', so Next appends the brand itself. Two pages wrote it in as well, and production servedShipping & Returns | Scout Patches | Scout Patches. Nothing caught it because the existing tests asserted metadata OBJECTS, and the doubling only happens when Next composes the template, at render.store/lib/seo-title.test.tsnow scans the source of everyapp/**/page.tsxinstead, so it catches the class rather than the two instances. -
The Shipping & Returns page described products the store does not sell. It was written about "patches" — which are EARNED, excluded from the feed by
isIndexableProduct, and unbuyable by a stranger — while the public catalogue is hats and tees. It also never said who pays return postage, though Merchant Center had been told "customer's responsibility", and promised delivery "within a week" against a declared 9–10 days. Google re-reviews the published policy against the declaration within ~10 days of setup, so all three were live mismatches. The page now renders fromlib/store/policy.ts. -
Meta descriptions are cut at a word boundary, and block elements are separated. Both were bugs visible in live SERP snippets until 2026-09-17:
lib/seo.tsstripped tags to'', so<p>…on a beanie.</p><p>The patch…</p>became "on a beanie.The patch"; and.slice(0, 160)cut mid-word, turning "Printed, not engraved" into "Printed, not ".lib/product-jsonld.tshad a second, correct copy of the same helper, which is why only one of the two surfaces was wrong. -
Earned-only patch PDPs are
noindex, followand absent from the sitemap. There are two stores behind one/products/<handle>route and only one is a search surface: 37 buyable apparel SKUs, and 2,383 "earned, not bought" patch pages whose own copy tells the visitor to go and earn the patch in person. The patch pages carry ~880 characters each and differ only by name and city, so indexed they are near-duplicate thin content that buries the 37 pages somebody can actually buy.followstays on deliberately — the sibling-patch and/shoplinks should still be walked. The rule isisIndexableProduct, expressed viabuyAreaForso it tracks the buy affordance automatically (store/lib/store/indexable.ts). -
A page with no public offer emits no Product JSON-LD. Earned-only patches used to emit
availability: InStockat $8.00 on a page reading "Earned, not bought — sign in to order it": schema asserting a purchase a stranger cannot make. That is grounds for Google dropping the rich result and a flat Merchant Center policy violation once Shopping is on. Same predicate as thenoindex, so the two cannot drift. -
In
limited_inventory_modethe sitemap carries no product URLs at all.visiblePatchHandlesis patch handles by definition, and every patch PDP is nownoindex; submitting them would fill Search Console with "Submitted URL marked noindex"./and/shopare still submitted. -
The catalogue walk has a ceiling of 100 pages (25,000 products), and it throws rather than truncating. Until 2026-09-17 it stopped after 5 pages with the comment "Cap at 5 pages of 250 = 1250 handles, plenty for our catalog". The catalogue had since grown to 2,420 products, so the sitemap silently shipped 1,190 URLs and dropped 1,230 — including all 37 buyable apparel SKUs, which sit at pagination positions 1700-2420 behind the "earned, not bought" patch pages. The sitemap therefore advertised 1,190 pages a stranger cannot buy from and zero that they can. The ceiling that replaced it is a runaway guard, not a size limit, and reaching it raises an error so a truncated sitemap can never ship silently again (
store/lib/sitemap-pagination.ts). -
Nothing is in the store until Shopify tagging is run. The code is live; the catalogue is not. Every category renders its empty state today.
-
An empty category is
noindexand absent from the sitemap — both driven by the same "count of matching products" check, so a category becomes indexable and sitemap-eligible automatically the moment one product carries its tag; no redeploy needed (store/app/(store)/shop/[category]/page.tsx:44,store/lib/sitemap-entries.ts:29-34). -
Category tags are not mutually exclusive by design. A product with two category tags appears on both pages and (if either category has
homeShelf: true) can appear twice on the home page, once per shelf. This is accepted, not a bug. -
The browse structure lives in SHOPIFY, not in code. Collections plus a
custom.parentmetafield define a tree of arbitrary depth; the storefront reads it at runtime (lib/shopify/collections.ts→lib/store/collection-tree.ts) and/shop/[...slug]serves every level. Adding a category is an admin action, not a deploy. The registry inlib/store/shop.tssurvives only for the home shelves and as the nav fallback. -
Hero banners come from Shopify's native collection
imagefield. A browse page with an image rendersShopHero— a full-bleed 3:1 banner carrying the breadcrumb,<h1>and description overlaid on the art (store/components/store/shop/ShopHero.tsx). A collection with no image renders the plain text header instead, so the field is optional and no page depends on art existing. Same reasoning as the tree itself: adding a banner is an admin action, not a deploy, and the alt text is editable beside the image. All 9 browse collections carry one as of 2026-09-17;knit-hatsreuses the beanies banner because it holds the identical 12 products. -
The heading is never baked into the banner art. It stays HTML text over the image, which keeps one indexable
<h1>per page, keeps it crisp at every DPR, and makes a copy change an edit rather than a regenerated image. Contrast is guaranteed by two scrim gradients in the component, NOT by the artwork — per-subject banners vary in left-hand luminance, so a new banner needs no contrast testing. -
custom.parentneedsstorefront: PUBLIC_READon its DEFINITION. A metafield definition defaults tostorefront: NONE, in which case the Storefront API returns null for every parent, the tree silently flattens to a list of roots, and nothing errors anywhere. -
A URL must follow the real parent chain.
/shop/tees/national-parks404s rather than rendering national-park hats at a tees URL — a duplicate of a real page, at a URL that lies about its own hierarchy. -
Tree building is defensive about admin mistakes: a missing parent makes the child a root (deleting a parent must not make its children vanish silently), a parent CYCLE is broken rather than recursing until the stack dies, and a self-parent is a root.
-
Redundancy is decided on product COUNT, not shape. A child is suppressed only when it is an only child AND holds exactly as many products as its parent. "Is it an only child" was tried first and is wrong:
teeshas one child,route-66, but 11 products to route-66's 10 — suppressing it would have thrown away a page targeting a query worth having. -
Categories cut on two axes: garment and theme.
hats,knit-hats,tees,long-sleeve,sweaters,swagdescribe what a product is;national-parks,zoos,route-66describe what it is of. The theme categories were added 2026-09-17 because/shop/hatshad become 12 zoo Trail Hats plus 12 national-park trucker hats under the single title "Trucker Hats", which was wrong for half the shelf — and because the keyword research found "national park hats" and "zoo hats" are queries people type, while "rope hat" and "snapback" describe construction nobody searches Scout by. -
A theme category selects an EXISTING catalogue tag via
ShopCategory.tag(National Parks,zoo hat,route 66), rather than a newshop:tag. Requiringshop:national-parkswould have meant re-tagging 46 live products in Shopify to encode something the catalogue already knows. Garment categories leavetagundefined and keep owning theshop:namespace; two categories claiming one tag would double-list. -
There are deliberately NO per-park category pages. Every national park has exactly two products, a beanie and a trucker hat, so
/shop/parks/yosemiteand friends would be twelve pages of two items each — the thin-content shape removed from the sitemap inlib/store/indexable.ts. The per-park tags (Yosemite,Zion, …) remain useful for filtering. -
swagnever gets a home shelf, because every swag product also carries a garment tag; giving it one would show the same product twice above the fold on one screen (store/lib/store/shop.ts:78-84). -
A product with no
shoptag is invisible on the web store, full stop — even if it carries ashop:hats-style category tag.tag:shopis required in every query the store issues (store/lib/store/shop.ts:115, asserted bystore/lib/store/shop.test.ts's "every product query is gated on the opt-in tag"). -
/products/[handle]is one route for two very different product shapes. A patch or collection handle still runs the earned-not-bought / completed-set gates; an apparel handle does not, because those gates only trigger forproductType === 'Patch Collection'or a patch context (store/app/(store)/products/[handle]/page.tsx:40-52). This doc's parent feature, patch/collection commerce, is documented separately incommerce-and-store.md. -
Merch beats a swag
productType, and the order is tested. The Saint Louis Zoo trail hat's ShopifyproductTypeis literally"Hats", which is inSWAG_PRODUCT_TYPES. With swag checked first its three COLOUR variants rendered throughApparelBuyAreaas chips under a "SIZE" heading — on a product whose card and image stage were both showing colour swatches. It was invisible while the hat had one variant and appeared the moment it gained colourways.buyAreaForfixes the precedence;MerchBuyAreastill defers toApparelBuyAreawhen a product has no aesthetic picker, so sized tees and one-size caps are unaffected. -
A fixed-ratio image frame needs the image OUT OF FLOW.
aspect-squareplus a normal-flowh-fullchild does not work: the child sizes the box by its own content,h-fullresolves toauto, and a portrait photo grows the box straight past the ratio (a patch PDP measured 541x631 inside a "square").overflow-hiddenonly trades the jump for a crop. The frame isrelativeand the imageabsolute inset-0. -
The angle strip is opt-in per product, via alt text. A product whose photos do not name a colour value in an em-dash-separated alt segment gets no strip — not an error, just the previous single-photo behaviour. Matching is per whole segment, so "Blackberry" never counts as the colour "Black" (
store/lib/store/variant-media.test.ts). -
A star row appears only for a product with published reviews. Today that is exactly one product store-wide (the Saint Louis Zoo trail hat, one 5-star review) — every other card renders without a row rather than with an empty one or "(0)". A rating entry whose count is zero is treated as no rating at all (
ratingFor). -
getRatingsByProductreads the 100 most recent reviews. Past that volume it would need paging before the map is complete; the failure mode is a missing star row on an older product, never a wrong one. -
The home shelves take the first 4 products per category, in Shopify/group order — NOT by rating.
SwagShelfreorders by social proof (orderByProof), butShopGriddeliberately does not: a grid shows everything at once, so reordering it only moves cards around. The consequence today is that the one reviewed, in-stock hat is not in the home page's 4-card Hats shelf at all, becausegroup:ropesorts aftergroup:leather-patch. -
A half-tagged product is handled, not half-broken. If only some colourways have tagged photos, the untagged ones fall back to their single variant image rather than rendering an empty image stage.
-
The home hero is a placeholder ("Scout Shop", no positioning copy) — the repo owner is writing the real hero; do not invent brand language there (
store/app/(store)/page.tsx:52-58).
What this feature does NOT do
- Does not sell patches or packs on the web.
/collections,/my-patches,/open-patches,/featured,/store-exclusives,/packs, and the old public shelves/gear,/display,/field-recordsall still exist as code, but every one of them is walled byOPEN_PREFIXES(store/lib/coming-soon.ts:29-41). The only place a scout can still buy a patch with the earned-not-bought rule enforced is the in-app store at/app(docs/features/mobile-store.md) — this project did not touch that tree. - Does not change the in-app store at
/app. The signed-in WebView store the mobile app opens is a separate route tree and was not modified; it still sells patches under the earned-not-bought gate exactly as before. - Does not change Shopify publications. Products remain published to the Scout Headless channel only, same as before this project. Nothing here moved a product onto the Online Store channel or changed who can query it.
- A product with no
shoptag does not appear anywhere on the web store, by design — this is the safe default the opt-in tag exists to guarantee, not an oversight to fix. - Does not populate itself. Six category pages and a home shelf exist,
but they render empty states until a human tags products
shop/shop:<category>in Shopify admin — that tagging step has not been run as of this doc. - Empty categories are excluded from search. They render
noindexand are dropped from the sitemap until they have at least one product (store/app/(store)/shop/[category]/page.tsx:44,store/lib/sitemap-entries.ts:29-34) — this is not a bug to file, it is the intended handling of thin content. - Does not add a new backend endpoint, database table, or feature flag.
Everything here is
store/-side code reading Shopify directly. /launchis gone. The drop page was deleted on 2026-09-16 when the desktop store opened; it 404s, with no redirect. The selection layer it shared with this feature (MerchVariantProvider,VariantSwatches,MerchImageStage) stayed and is now used only here. Its scarcity bar and direct-to-checkout button were dropped rather than ported, and its MetaViewContentfiring moved to/products/[handle](ProductViewContent) — seecommerce-and-store.md.- The angle strip is not a general product gallery. It shows only the
SELECTED colour's photos. A patch's detail/scale shots still use
ProductImageGallery; merch never gets a product-wide thumbnail strip, because on a hat that is just a second, worse colour picker. - Alt text is not validated anywhere. Nothing fails a build, a test or a publish if a photo's alt text names the wrong colour or misspells one. The symptom is a silently missing angle strip, and the only check is looking at the page.
Tests that cover it
-
store/lib/store/policy.test.ts— the cross-system contract with Merchant Center: the site's return window equals the declared one, the site states who pays return postage and agrees with the declaration, the site never promises delivery faster than Google was told, the refund processing time is a real number, and the policy copy never calls the catalogue "patches". Each guard was verified by restoring the original bug — the old "patches" noun, the "within a week" transit claim, a copy line that omits the payer, and a window drifting from the declaration — and confirming exactly that test failed. -
store/lib/seo-og.test.ts— the social-card contract: the root declares anog:image, anything promisingsummary_large_imagesupplies one, the URL is absolute, the file is exactly 1200x630 (read from the PNG's IHDR chunk, no image library), a PDP keeps its OWN product image, and an imageless product falls back to the card rather than to nothing. Verified by deleting the root image and by reverting the product fallback, each time confirming exactly the matching tests failed. -
store/lib/seo-title.test.ts—hasRedundantBrand, plus a source scan of everyapp/**/page.tsxfor a title that repeats the brand the template appends. Verified by putting| Scout Patchesback into the /shipping-returns title and confirming the scan named that exact file. -
store/lib/merchant-feed.test.ts— 17 tests: earned-only patches never reach the feed, one row per variant grouped byitem_group_id, per-variant availability, the variant→product image fallback, price formatting, absolute links, entity-decoded plain-text descriptions, XML escaping (the raw&that invalidates the WHOLE document), theg:namespace,identifier_exists, the empty catalogue, and the apparel attributes — colour and size fromselectedOptions, flatadult/unisex, case-insensitive option NAME matching, and omitting rather than emitting an empty element. -
store/lib/store/buy-area.test.ts—buyAreaFor's four cases and, above all, that merch beats a swag productType. Verified by flipping the two lines back to the buggy order and confirming exactly that test failed. -
store/lib/judgeme-shape.test.ts—ratingFor: lookup by GID, the no-reviews and no-map cases, an unparseable id, and the zero-count entry that must not render an empty star row. -
store/lib/store/variant-media.test.ts— the alt-text convention: segment splitting, case-insensitive whole-segment matching, the "Blackberry is not Black" substring case, values containing a slash (Black/White), grouping order, the untagged-product empty map, and the half-tagged fallback. The substring case was verified by mutatingimageOptionValueto a substring match and confirming exactly that test failed. -
store/lib/store/collection-tree.test.ts— 20 tests over the tree: building from a flat list, the URL segment stripping the parent-handle prefix (and working without it), SEO fallbacks,frontpageexclusion, a missing parent, a parent CYCLE, a self-parent, the empty catalogue, path resolution following the real parent chain, case-insensitivity, the hero image surviving tree building (and an absent one being an explicitnull, notundefined— both verified by deleting the passthrough and confirming exactly those two failed), and count-based redundancy including the unknown-count case. -
store/lib/store/legacy-shop-routes.test.ts— the flat theme URLs redirect, trailing slashes redirect,/shop/knit-hatsdoes NOT (still a real collection), and no live path ever redirects. -
store/lib/store/shop.test.ts— also covers the theme categories: that each resolves to its catalogue tag rather than inventing ashop:one, that they still requiretag:shop(without it/shop/national-parkswould list the 2,383 earned-only patches, every one of which carries a park name), that garment categories are unchanged, that an unknown slug falls back rather than throwing, and thathatsno longer titles itself "Trucker Hats". PlusbuildShopQuery's opt-in gate, the-tag:internalexclusion, category narrowing, the "no category tag" case for the whole-store query, the dual-category (snapback) case, the registry slugs/order, and thatswaghashomeShelf: false. -
store/lib/coming-soon.test.ts—'the merch store is open: home, grid, categories, PDPs, cart'and'patch commerce stays walled'/'the superseded shelves are walled now /shop replaces them'assert the open/walled split this doc describes. -
store/lib/seo.test.ts—productMetadata: block-element separation, the word-boundary cut, a short description left verbatim, the empty and markup-only fallbacks, and the canonical. Both bugs were confirmed failing against the unfixed code first. The truncation assertion deliberately compares against the source's word set rather thansource.includes(lastWord)— the substring check silently passed, because the buggy cut ended in "lin", which is a substring of "line". -
store/lib/store/indexable.test.ts— that an earned-only patch and a patch collection are not indexable, that all four buyable garment types are, that theshoptag rather than product type is what decides (Beanies and Trucker Hats are NOT inSWAG_PRODUCT_TYPES, so a type-only rule would silently noindex 24 of the 37 buyable SKUs), theshop-tagged Trail Hat, case- and whitespace-insensitive tag matching, the fail-closed unclassifiable product, andindexableHandlesorder/empty cases. Mutation-verified twice: making the predicate always true failed 5 tests, and ignoring theshoptag failed 4 including'every buyable garment stays indexable'. -
store/lib/sitemap-pagination.test.ts— the full catalogue walk, that the buyable apparel beyond page 5 survives, cursor threading (a pager that ignoredendCursorwould return the right count from the wrong pages), the empty catalogue, the single short page, and that a server which never stops paginating throws instead of truncating. Verified by restoring the originalpage < 5loop and confirming the first two failed withgot 1250andapparel 0 !== 721. -
store/lib/sitemap-entries.test.ts—'a category with products is in the sitemap'and'an empty category is NOT in the sitemap'cover the populated-category rule end to end. -
store/lib/standalone-routes.test.ts— asserts/review/<handle>stays outside the(store)route group so it does not inherit the new open nav chrome; adjacent to this project though not testing the merch store directly.
No Playwright/Maestro end-to-end coverage exists for the shop pages; the
tests above are unit-level (node:test) against the pure query/routing
functions, matching this codebase's pattern for store/lib/**.
Open questions
- Whether/when the Shopify tagging pass will run, and which products get which category tags, was not determined by this change — that is a content decision, not a code one.
- No production verification was performed for this doc (no live fetch
against
shop.scout-patches.com): the doc's claims come from reading the code and its tests, not from confirming a deployed, tagged catalogue.