Skip to content

Collection page support

Diagnostic and troubleshooting reference for the collection / product-listing page (PLP) — elevated filter UX, dual-thumb price range, swatch facets, sub-collection chip row, distinct empty-state, three pagination modes, sticky pagination footer on mobile, self-canonical + rel=prev/next SEO, CollectionPage + ItemList JSON-LD, back-nav state restore, card polish (badges, OOS, swatch hover), list-view toggle, and per-preset template defaults.

FAQ

Filters are not auto-applying when I click checkboxes.

Filter changes auto-apply 150 ms after the last toggle (debounced — multiple rapid toggles batch into one fetch). Verify:

  1. Wait at least 200 ms between checkbox clicks. The first click starts a 150 ms timer; rapid clicks reset it. The fetch fires after the timer expires.
  2. Open DevTools → Network tab → toggle a checkbox; you should see a fetch to /collections/<handle>?filter.X=Y&sections=<id> after 150 ms (the filter params come first; section-renderer.js appends the sections= param last). If not, JS is not loading — check assets/collection.js returns 200.
  3. In the console, run !!customElements.get('collection-filters'). Expected: truefalse means assets/collection.js never registered the custom element.

If assets/collection.js is loading but auto-apply still does nothing, check the console for errors. The AbortController stack will surface any unhandled rejections.

The bottom-sheet filter dialog does not open on mobile.

The bottom-sheet opens at viewport widths < 1024 px (anything from 320 px to 1023 px). Verify:

  1. Confirm viewport is < 1024 px (DevTools device emulation).
  2. Confirm the filter trigger button is visible (it's hidden on desktop ≥ 1024 px because the sidebar is the canonical filter surface there).
  3. Confirm the trigger is the .collection__filter-toggle button with aria-controls='collection-filters-drawer'. Click it — the <dialog class='collection__filters-drawer'> should open with the [open] attribute set.

Run the diagnostic snippet — inspect filters.bottomSheet. Expected on mobile:

json
{
  "present": true,
  "open": false,
  "structure": "sibling-of-aside"
}

If structure is "nested-inside-aside", the markup has drifted — file a support ticket; this is a regression of the structural invariant.

Filters apply but the URL doesn't update.

The theme updates the URL via the History API without a page reload — history.replaceState for checkbox filter applies and filter-chip removals (no new history entry), history.pushState for sort changes, Clear all, and pagination. Verify:

  1. Toggle a checkbox and check the address bar after the apply. Expected: ?filter.v.option.color=red or similar.
  2. Reload the page from the bookmarked URL — filters should re-apply on load.
  3. In the console, run history.state after an apply. Expected: { plpFilter: true, source: 'filter-applied' } (a Load More append sets { plpPagination: true, mode: ... }).

Note the back button does NOT step through individual checkbox toggles — filter applies use replaceState, so back returns to the last pushed entry (a sort change, Clear all, or a pagination step) or the previous page. If the URL never updates at all, the History API may be blocked by a parent-document constraint (theme inside an iframe, custom-app overrides).

The price-range slider thumbs don't move.

The dual-thumb slider uses native <input type="range"> × 2 + 2 <input type="number"> inputs. Verify:

  1. Run the diagnostic snippet — inspect filters.priceRange. Expected: { "renders": true, "rangeInputs": 2, "numberInputs": 2, "minMax": [{ "min": "0", "max": <max>, "val": ... }, ...] }.
  2. Confirm the range thumbs are keyboard-focusable — tab to a thumb and use arrow keys. The focus ring rides the visible overlay knob (:focus-visible styles in assets/collection.css); the number inputs remain the precise-entry path.
  3. If thumbs render but dragging does nothing, the delegated input listeners (bound by _rebindFilterFormHandlers in assets/collection.js) may not be attached — check assets/collection.js is loading.

If you typed min=100, max=50 (inverted) and see a red border + aria-invalid="true" on the min input, that's correct — the validator catches min > max.

Swatches show neutral gray instead of the variant's color.

The swatch fallback chain (in priority order):

  1. Per-product metafield product.metafields.shopify.color-swatches (a list of { name, hex } objects per variant).
  2. CSS named-color allowlist — 20 common color names (Red, Blue, Green, etc.) recognized by browser CSS.
  3. Neutral gray #D1D5DB + console.warn.

Run the diagnostic snippet — inspect filters.swatches.fallbackCount. If > 0, that's how many swatches fell back to gray on the current page.

To fix:

  1. Admin → Products → choose a product → Variants → for each color variant, populate the shopify.color-swatches metafield with hex values.
  2. Reload the collection page; swatches should now render the merchant-set hex.

If the variant color name is in the CSS allowlist (e.g., "Red"), the swatch will render red from the allowlist even without the metafield. Custom names (e.g., "Sunset orange") need the metafield.

Sub-collection chips don't render above the grid.

The chip row has three sources, in priority order: (1) repeatable sub_collection_chip section blocks (typed label + URL — the primary source), (2) collection.metafields.uisce.sub_collection_chips as a metaobject list, (3) the same metafield as a json-typed list [{"label": "X", "url": "/collections/y"}, ...]. Blocks win over the metafield when present. Verify:

  1. Theme editor → Collection section → confirm sub-collection chip blocks exist with a label and URL each; OR
  2. Admin → Settings → Custom data → Collections → confirm the metafield definition uisce.sub_collection_chips exists and is set on the collection as a metaobject list or a JSON list. A list.collection_reference metafield renders nothing — only metaobject-list and json types are read.
  3. Reload the storefront collection page; the chip row should render above the grid.

If a source is populated but no chips render, check the URL safety guard — chips with https://, http://, //, javascript:, or data: URLs are filtered out (same-origin policy, Codex MEDIUM M4). Rejected chips are silently skipped — no console output and no DOM emission — so a list of all-external URLs renders nothing at all.

Empty state shows the same heading whether I have filters applied or not.

Phase 06.5 introduced distinct headings:

  • Truly empty (no filters, no products) — collections.empty_state.heading_empty
  • Filtered to zero (filters active, products filter to nothing) — collections.empty_state.heading_filtered

Verify:

  1. Apply a filter that returns 0 results (e.g., a price range that excludes all products); confirm the heading reads "No products match your filters" (or your locale's translation) + a "Clear all filters" button below + up to 3 suggested-collection links when collection.metafields.uisce.suggested_collections is populated.
  2. Visit a truly empty collection (no products at all); confirm the heading reads "No products in this collection yet" + no clear-all CTA and no suggested collections (the suggested-collections block renders only in the filtered branch).

If both states show the same heading, the locale file may have stale keys — confirm locales/en.default.json (or your default locale) has both heading_filtered and heading_empty keys distinct.

Pagination doesn't load more products on scroll.

Auto-load-on-scroll triggers at 80% scroll, not 100% — by design. Verify:

  1. Run the diagnostic snippet — inspect pagination.mode. Expected one of load-more, auto-load-on-scroll, numbered.
  2. If mode is auto-load-on-scroll, scroll the page slowly to ~80% of the grid height. The next page should fetch automatically.
  3. Open DevTools → Network → confirm a fetch fires when you cross the 80% threshold.

If mode is load-more or numbered, you need to click the button or page link — auto-load only triggers on the auto-load-on-scroll setting.

If the fetch fires but no products append, check the console for JSON-parse errors. The Section Rendering API response may have changed format; confirm assets/collection.js is at the latest commit.

Load More button shows "Retry" after I click it.

The Load More flow surfaces a Retry button on ANY fetch failure (network error, 4xx, 5xx, or an empty section response) — each retry is a manual click, nothing retries automatically. After 3 failed attempts the Retry button is replaced by a fallback link labeled "View page N" (<a href="?page=N">) that does a full-page navigation.

Verify:

  1. Confirm your Shopify storefront is responsive — curl -s https://YOURSTORE/collections/<handle>?page=2 should return 200.
  2. Check DevTools → Network → identify the failing request's status. 5xx is Shopify platform side; if 4xx, the request is malformed (more likely a theme bug — file a support ticket).

After the third failure, the user can click the "View page N" fallback link to navigate directly. They lose the in-place append UX but get the products.

The footer is position: sticky; inset-block-end: calc(var(--announcement-bar-height, 0px) + env(safe-area-inset-bottom, 0px)) (logical property; auto-flips in RTL). It should sit above the iOS safe-area inset and above the announcement bar.

If it covers the last card:

  1. Confirm nothing overrides the wrapper's own spacing — the sticky footer carries padding-block: var(--spacing-3) plus a safe-area-aware padding-block-end from assets/collection.css; spacing comes from theme spacing tokens, not a fixed pixel padding on the grid.
  2. Confirm --announcement-bar-height is set correctly when the announcement bar is visible. assets/announcement-bar.js publishes this variable (and broadcasts announcement-bar:visibility-changed, which collection.js also listens to); the sticky header publishes --header-height, not this one. If you removed the announcement bar, the variable defaults to 0px.
  3. iOS Safari sometimes mis-computes env(safe-area-inset-bottom) — try env(safe-area-inset-bottom, 16px) as a fallback.

CollectionPage JSON-LD is missing on my collection pages.

The CollectionPage JSON-LD shell emits from snippets/json-ld.liquid (head-scoped invariant fields, rendered by layout/theme.liquid) + snippets/plp-collection-itemlist-jsonld.liquid (paginate-scoped ItemList, rendered inside the collection section's paginate block). Verify:

bash
curl -s "https://YOURSTORE/collections/all" | grep -oE '"@type":[[:space:]]*"CollectionPage"'
curl -s "https://YOURSTORE/collections/all" | grep -oE '"@type":[[:space:]]*"ItemList"'

Expected: 1 hit each.

If missing:

  1. Confirm templates/collection.json (or the per-preset variant) renders the collection section type.
  2. Confirm layout/theme.liquid includes {% render 'json-ld' %} (the CollectionPage shell) and sections/collection.liquid renders plp-collection-itemlist-jsonld inside its paginate block.
  3. Push the latest theme — the split was introduced in Plan 06.5-15.

Back navigation from PDP doesn't restore filters / scroll.

The back-nav state restore reads from localStorage (via the UisceShopperState helper) under uisce.state.v1.plp-back-nav.<collection-handle>. Verify:

  1. Apply filters → click a product → in DevTools → Application → Local Storage; you should see a key uisce.state.v1.plp-back-nav.<handle> with JSON { filters, sort_by, pagination: { pages_loaded }, scroll_y, timestamp }.
  2. Click browser back; the grid should re-apply filters + sort in a single page-1 fetch and scroll back to scroll_y. Previously-loaded pages are NOT replayed — pagination.pages_loaded is persisted but not read on restore.
  3. An aria-live announcement should read "Restored to N products" via the [role=status][aria-live=polite] element.

If the state is missing or restore fails:

  • TTL: state expires 30 minutes after the PDP click (per RESEARCH Pitfall 11). Reload after a 30+ min coffee break and you get a fresh load. This is intentional.
  • Malformed state: if the JSON is corrupt (e.g., browser extension stomped on it), the theme silently falls back to a fresh load and dispatches a collection:back-nav-miss event — there is no console.warn.
  • Cross-collection navigation: state is keyed per collection.handle; navigating to a different collection won't restore the prior collection's state.

List view toggle is not visible on Gaoth / Talamh / Neart.

The view toggle is per-preset opt-in. Default:

  • Tine + Uisce — toggle visible (catalog-density value)
  • Gaoth + Talamh + Neart — toggle hidden (visual-first niches)

To enable on a Gaoth/Talamh/Neart store:

  1. Theme editor → Collection section → Settings → "Enable grid/list view toggle" → check the box.
  2. Reload the storefront; the toggle should appear above the grid.

If the toggle is enabled but doesn't render, check the console for JS errors. The toggle markup is server-rendered by the collection section's Liquid; assets/plp-analytics.js only binds the click/persist behavior (Plan 06.5-13 — extracted to keep collection.js under cap).

Search engines aren't indexing my paginated pages.

Phase 06.5 emits self-canonical URLs (NOT collapsing pages 2+ to page 1, which would have caused this exact issue). Verify:

bash
# The link tags span multiple lines (attribute per line), so print the following line too:
curl -s "https://YOURSTORE/collections/all?page=2" | grep -A1 "rel='canonical'"
curl -s "https://YOURSTORE/collections/all?page=2" | grep -A1 "rel='prev'"

Expected:

  • the href line after rel='canonical' contains .../collections/all?page=2 (self-canonical, NOT page 1)
  • the href line after rel='prev' on page 2 is the bare collection path — the link to page 1 carries NO ?page=1; from page 3 onward the prev href carries ?page=N-1
  • rel='next' with href='.../collections/all?page=3' (when more pages remain)

If the canonical href points to page 1 or omits the ?page=2, the theme is stale — push the latest. The fix landed in Plan 06.5-10.

If your collection has fewer products than products_per_page (e.g., 14 products with products_per_page=16), the entire collection fits on page 1 — page 2 will be empty AND won't have rel=next (because there's no next page). This is correct.

My color filter shows checkboxes only — no swatches.

The swatch facet style setting controls this. Verify:

  1. Theme editor → Collection section → "Color swatch style" → confirm circles (default) or inline.
  2. Confirm the filter's values carry swatch data. Swatches render whenever Shopify supplies value.swatch for at least one of the filter's values (driven by the color taxonomy / swatch metafields) — the theme does no filter-id comparison.
  3. If the filter is filter.p.tag (collection tag), swatches don't render — tag values carry no swatch data, so they always render as plain checkboxes.

If swatches still don't render, run the diagnostic snippet → check filters.swatches.renders and filters.swatches.style.

Decision tree: "Why is X not working?"

text
Symptom: filters don't auto-apply
  -> Is JS loading? (Network tab → assets/collection.js status)
    -> 4xx/5xx: theme not deployed; push latest
    -> 200: continue
  -> Is the custom element registered? (console → !!customElements.get('collection-filters'))
    -> false: collection.js never ran; push latest / check for JS errors
    -> true: continue
  -> AbortController error in console?
    -> yes: check fetch failed; verify storefront responsive
    -> no: check checkbox event listener; data-filter-form attr present?

Symptom: bottom-sheet doesn't open on mobile
  -> Is viewport < 1024 px? (DevTools device emulation)
    -> no: bottom-sheet hidden by design; sidebar is the desktop surface
    -> yes: continue
  -> Is filter trigger button visible? (.collection__filter-toggle, aria-controls='collection-filters-drawer')
    -> hidden: section setting may have hidden it; check enable_filtering=true
    -> visible: continue
  -> Click trigger; does <dialog> get [open] attribute?
    -> yes: dialog opens but invisible — check CSS for display:none or visibility:hidden
    -> no: <dialog> not in DOM; confirm collection-filters custom element mounted

Symptom: pagination doesn't load more products
  -> What is pagination_mode? (diagnostic snippet → pagination.mode)
    -> load-more: click the button (auto-load is not the default)
    -> auto-load-on-scroll: scroll to ~80% of grid height
    -> numbered: click a page number link
  -> Network tab: does the fetch fire?
    -> no: scroll-trigger may not be wired; check IntersectionObserver in console
    -> yes but fails (any error): error strip shows a manual Retry button (max 3 clicks),
       then a "View page N" fallback link replaces it
    -> yes 200: response parses; appends should follow; check console for parse errors

Symptom: swatches show neutral gray
  -> Is shopify.color-swatches metafield populated? (Admin → Products → variant)
    -> no: populate it; reload storefront
    -> yes: continue
  -> Is the option-value name in the CSS allowlist? (inspect the dot's data-swatch-hex attribute)
    -> #D1D5DB: fallback gray — not in allowlist; metafield is required
    -> any other hex: allowlist/metafield resolved; check the value is the intended color
  -> Console.warn "[c]swatch-fallback" present?
    -> yes: read the warning to identify the unmatched value
    -> no: theme stale; push latest

Symptom: back-nav doesn't restore state
  -> Did you navigate via SPA (single-page nav with no full reload)?
    -> yes: state may not have persisted; the persistence happens on full PDP nav
    -> no: continue
  -> Is localStorage uisce.state.v1.plp-back-nav.<handle> present?
    -> no: state never persisted; the pagehide handler in collection.js writes it — confirm
       collection.js loaded on the PLP before you navigated away
    -> yes: continue
  -> Is the TTL expired? (state.timestamp > 30 minutes ago)
    -> yes: fresh load is correct; this is intentional
    -> no: continue
  -> Is the JSON malformed? (paste localStorage value into JSON.parse)
    -> SyntaxError: corrupted; theme silently falls back to fresh load + collection:back-nav-miss event
    -> valid JSON: theme bug; file a ticket

Symptom: CollectionPage JSON-LD missing
  -> curl https://YOURSTORE/collections/all | grep CollectionPage
    -> 0 hits: shell not rendering; check layout/theme.liquid renders 'json-ld'
       (the CollectionPage shell lives there, not in the section)
    -> 1 hit: continue
  -> Google Rich Results Test (paste HTML) — CollectionPage enhancement green?
    -> red: schema malformed; check JSON-LD output for syntax errors
    -> green: correctly configured

Symptom: rel=prev/next missing
  -> Is the page paginated? (collection.products_count > products_per_page)
    -> no: rel=prev/next correctly absent — collection fits on page 1
    -> yes: continue
  -> curl ?page=2 | grep "rel='prev'"
    -> 0: theme stale; push latest (Plan 06.5-10)
    -> 1: prev OK; check rel=next on appropriate pages
  -> rel=next absent on the last page?
    -> correct: no next page after last; theme math expected

Symptom: PLP slow on mobile (Lighthouse perf < 0.6)
  -> Identify LCP element (DevTools → Performance → LCP flame)
    -> hero card image: optimize source size; first 4 cards have eager+fetchpriority=high
    -> sub-collection chips: server-rendered Liquid — there is no client-side metaobject
       fetch, so chips cannot be the network bottleneck
  -> Check images cards 5+ have loading=lazy (Plan 06.5-11)
  -> Check IntersectionObserver firing on impression (plp-analytics.js)

Validator interpretation

ValidatorOutput messageMeaningFix
Lighthouse"CLS > 0.1 on PLP"Card images load without width/height, causing layout shiftConfirm cards 1-4 have explicit width + height (Plan 06.5-11 sets them); if missing, push the latest theme
Lighthouse"Perf < 0.6 on mobile PLP"LCP card image > 2.5 s mobileIdentify the hero card image; ensure source ≤ 600 KB; Shopify CDN auto-resizes responsive variants but source dominates first request
Lighthouse"TBT > 300 ms mobile"Total Blocking Time exceeded mobile gateShopify platform baseline (4× CPU throttle lab artifact). Not a theme defect; informational only
Lighthouse"BP = 0.78"Best Practices score capped at 0.78Shopify platform cap (portable-wallets.en.js, bugsnag telemetry). Not a theme defect
axe"color-contrast on .footer__copyright"Footer copyright contrast ratio fails WCAG AA — pre-existing 06.4 issueFooter-scope work, outside PLP scope. Not introduced by the collection page
GRRT"CollectionPage.numberOfItems missing"JSON-LD shell emitted but ItemList missingPlan 06.5-15 split shell + paginate-scoped ItemList; push latest theme. Confirm snippets/plp-collection-itemlist-jsonld.liquid renders inside the paginate block
GRRT"ItemList.itemListElement[].url not absolute"URLs use relative paths instead of full https:// prefixPlan 06.5-15 prepends shop.url to product URLs; push latest theme
GRRT"BreadcrumbList.itemListElement.item must be a URL"Breadcrumb regression on PLPPlan 06.5 includes a 06.4 regression guard for BreadcrumbList. Push latest theme
Meta Sharing Debugger"URL cannot be scraped"Dev-store password protection blocks URL-only scrapersPlatform constraint — dev stores cannot disable password. Reachable post-Theme-Store-transfer. Use HTML-paste validators meanwhile

Diagnostic snippet

Paste into your browser DevTools Console on any collection page of your live store. Produces a single JSON blob describing current PLP state — safe, read-only, and adds zero theme code. The snippet runs one read-only traversal of the PLP DOM and writes nothing.

javascript
(() => {
  const state = {
    pageUrl: location.href,
    viewport: { w: innerWidth, h: innerHeight },
    theme: document.documentElement.getAttribute('data-theme') || 'light',
    dir: document.documentElement.getAttribute('dir') || 'ltr',
    lang: document.documentElement.getAttribute('lang'),
    template: /\/collections\//.test(location.pathname) ? 'collection' : 'other',
    preferences: {
      hover: matchMedia('(hover: hover)').matches,
      pointerCoarse: matchMedia('(pointer: coarse)').matches,
      reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
    },
    grid: {
      present: !!document.querySelector('.collection__grid'),
      cardCount: document.querySelectorAll('[data-product-card]').length,
      // Primary images only — hover images (.product-card__image--hover) are always lazy
      // on cards with 2+ media and would inflate a bare img[loading="lazy"] count.
      eagerCardCount: document.querySelectorAll(
        '[data-product-card] .product-card__image--primary img[loading="eager"]',
      ).length,
      lazyCardCount: document.querySelectorAll('[data-product-card] .product-card__image--primary img[loading="lazy"]')
        .length,
      aspectRatio: (() => {
        const grid = document.querySelector('.collection__grid');
        return grid ? getComputedStyle(grid).getPropertyValue('--product-card-aspect-ratio').trim() || null : null;
      })(),
      gridColumns: (() => {
        const grid = document.querySelector('.collection__grid');
        const m = grid && grid.className.match(/collection__grid--(\d+)/);
        return m ? m[1] : null;
      })(),
    },
    filters: {
      formPresent: !!document.querySelector('[data-filter-form]'),
      activeFiltersCount: document.querySelectorAll('[data-active-filters] [data-filter-remove]').length,
      announcement: !!document.querySelector('[data-filter-announcement]'),
      bottomSheet: (() => {
        const dialog = document.querySelector('dialog.collection__filters-drawer');
        if (!dialog) return { present: false };
        return {
          present: true,
          open: dialog.hasAttribute('open'),
          structure: dialog.closest('aside') ? 'nested-inside-aside' : 'sibling-of-aside',
        };
      })(),
      priceRange: (() => {
        const range = document.querySelector('.collection__filter-price-thumb, .collection__price-range-thumb');
        if (!range) return { renders: false };
        const inputs = document.querySelectorAll('.collection__filter-price input[type="range"]');
        return {
          renders: true,
          rangeInputs: inputs.length,
          numberInputs: document.querySelectorAll('.collection__filter-price input[type="number"]').length,
          minMax: Array.from(inputs).map((i) => ({ min: i.min, max: i.max, val: i.value })),
        };
      })(),
      swatches: (() => {
        const swatches = document.querySelectorAll('.collection__filter-swatch');
        if (!swatches.length) return { renders: false };
        const fallbackCount = document.querySelectorAll('[data-swatch-hex="#D1D5DB"]').length;
        const wrap = document.querySelector('.collection__filter-swatches');
        const styleMatch = wrap && wrap.className.match(/collection__filter-swatches--(circles|inline)/);
        return {
          renders: true,
          count: swatches.length,
          style: styleMatch ? styleMatch[1] : null,
          fallbackCount,
        };
      })(),
      showMoreLessSlots: document.querySelectorAll('[data-facet-show-more], [data-facet-search]').length,
      historyState: (() => {
        try {
          return history.state;
        } catch (_) {
          return null;
        }
      })(),
    },
    chips: {
      activeFilters: document.querySelectorAll('[data-filter-remove]').length,
      activeFilterOverflow: (() => {
        const row = document.querySelector('.collection__active-filters');
        return row ? row.scrollWidth > row.clientWidth : false;
      })(),
      subCollectionChips: (() => {
        const chips = document.querySelectorAll('[data-sub-chip-link], .collection__sub-chip');
        return {
          renders: chips.length > 0,
          count: chips.length,
          urls: Array.from(chips)
            .map((c) => c.getAttribute('href'))
            .slice(0, 5),
        };
      })(),
    },
    pagination: {
      mode: document.querySelector('[data-pagination-mode]')?.getAttribute('data-pagination-mode') || null,
      currentPage: Number(new URL(location.href).searchParams.get('page') || '1'),
      loadMoreButton: !!document.querySelector('[data-load-more]'),
      sticky: (() => {
        const footer = document.querySelector('.collection__pagination-wrapper, [data-pagination-wrapper]');
        if (!footer) return { renders: false };
        const cs = getComputedStyle(footer);
        return {
          renders: true,
          position: cs.position,
          insetBlockEnd: cs.insetBlockEnd,
        };
      })(),
      retryVisible: !!document.querySelector('[data-load-more-retry]:not([hidden])'),
      fallbackVisible: !!document.querySelector('[data-load-more-fallback]:not([hidden])'),
    },
    seo: {
      canonicalSelf: (() => {
        const el = document.querySelector('link[rel="canonical"]');
        return el ? el.href === location.href.split('#')[0] : false;
      })(),
      relPrev: !!document.querySelector('link[rel="prev"]'),
      relNext: !!document.querySelector('link[rel="next"]'),
      hreflangCount: document.querySelectorAll('link[rel="alternate"][hreflang]').length,
    },
    jsonLd: {
      collectionPagePresent: (() => {
        const scripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const s of scripts) {
          try {
            if (JSON.parse(s.textContent)['@type'] === 'CollectionPage') return true;
          } catch (_) {}
        }
        return false;
      })(),
      itemListPresent: (() => {
        const scripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const s of scripts) {
          try {
            const j = JSON.parse(s.textContent);
            if (j['@type'] === 'CollectionPage' && j.mainEntity?.['@type'] === 'ItemList') return true;
            if (j['@type'] === 'ItemList') return true;
          } catch (_) {}
        }
        return false;
      })(),
      breadcrumbPresent: (() => {
        const scripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const s of scripts) {
          try {
            const j = JSON.parse(s.textContent);
            if (j['@type'] === 'BreadcrumbList') return true;
          } catch (_) {}
        }
        return false;
      })(),
    },
    backNav: {
      // UisceShopperState is localStorage-backed
      stateKey: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return null;
        return `uisce.state.v1.plp-back-nav.${handle}`;
      })(),
      statePresent: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return false;
        try {
          return !!localStorage.getItem(`uisce.state.v1.plp-back-nav.${handle}`);
        } catch (_) {
          return false;
        }
      })(),
      stateAge: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return null;
        try {
          const raw = localStorage.getItem(`uisce.state.v1.plp-back-nav.${handle}`);
          if (!raw) return null;
          const parsed = JSON.parse(raw);
          return parsed.timestamp ? Math.round((Date.now() - parsed.timestamp) / 1000) + 's' : null;
        } catch (_) {
          return null;
        }
      })(),
    },
    viewToggle: {
      present: !!document.querySelector('[data-view-toggle]'),
      // data-view is set on [data-product-grid] by plp-analytics.js after a toggle
      // or a restored preference; absent = the default grid view.
      activeMode: document.querySelector('[data-product-grid]')?.getAttribute('data-view') || 'grid',
      // per-collection key; the stored value is JSON-encoded ("grid" / "list", with quotes)
      stateKey: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        return handle ? `uisce.state.v1.plp-view.${handle}` : null;
      })(),
      stateValue: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return null;
        try {
          return localStorage.getItem(`uisce.state.v1.plp-view.${handle}`);
        } catch (_) {
          return null;
        }
      })(),
    },
    cards: {
      withDataOos: document.querySelectorAll('[data-product-card][data-oos="true"]').length,
      withBadges: document.querySelectorAll('[data-product-card] .product-card__badge').length,
      withSwatches: document.querySelectorAll('[data-product-card] [data-swatch-hex]').length,
      withFocusVisible: document.querySelectorAll('[data-product-card]:focus-visible').length,
    },
    analytics: {
      // plp-analytics.js sets this guard flag when it loads. It dispatches
      // collection:card-impression / collection:card-click CustomEvents on window —
      // add a window.addEventListener for those to observe live events.
      loaded: window.__uisceP12Analytics === true,
    },
  };
  console.log('Uisce Theme — PLP diagnostic report');
  console.log(JSON.stringify(state, null, 2));
  return state;
})();

What to look for in the output:

  • grid.present === true and grid.cardCount >= 1 — grid is mounted with content.
  • grid.eagerCardCount === 4 — the first 4 cards' primary images have loading="eager" (Plan 06.5-11 LCP optimization).
  • grid.lazyCardCount === grid.cardCount - 4 — primary images on cards 5+ are loading="lazy" (the probe counts primary images only; hover images are always lazy and are excluded).
  • grid.aspectRatio matches the per-preset default (square / portrait / natural / landscape).
  • filters.formPresent === true — filter form mounted.
  • filters.bottomSheet.structure === "sibling-of-aside" — structural invariant intact (not nested).
  • filters.historyState reflects the last apply — { plpFilter: true, source: ... } (or plpPagination after a Load More).
  • filters.swatches.fallbackCount === 0 — all swatches have metafield-driven hex (or named color from allowlist).
  • chips.subCollectionChips.renders === true — sub-chip row mounted on collections with chip blocks or the metafield populated.
  • pagination.mode matches the section setting (load-more / auto-load-on-scroll / numbered).
  • pagination.sticky.position === "sticky" on mobile/tablet (< 1024 px).
  • seo.canonicalSelf === true — self-canonical (NOT collapsed).
  • seo.relPrev === true on ?page=N where N > 1.
  • seo.relNext === true on pages where there are more pages remaining.
  • seo.hreflangCount > 0 — hreflang alternates emitted (regression check from Phase 06.2).
  • jsonLd.collectionPagePresent === true — CollectionPage JSON-LD emitted.
  • jsonLd.itemListPresent === true — ItemList JSON-LD nested in CollectionPage OR standalone.
  • jsonLd.breadcrumbPresent === true — BreadcrumbList regression guard active.
  • backNav.statePresent === true after navigating back from PDP within 30 minutes; stateAge shows the age (the state lives in localStorage under backNav.stateKey).
  • viewToggle.present === true only on Tine + Uisce (or any preset where merchant explicitly enabled it).

Curl cheat-sheet

Use these one-liners to verify your live store's PLP. Replace YOURSTORE with your domain and <handle> with an actual collection.

If your store is password-protected (dev store), capture the auth cookie first:

bash
curl -sI "https://YOURSTORE/password" -d "form_type=storefront_password&password={password}" \
  -L -c cookies.txt | head -5
# Then add `-b cookies.txt` to all subsequent curl commands below

For live production stores (no password protection), no cookie is needed.

1. Verify the CollectionPage JSON-LD is emitted.

bash
curl -s https://YOURSTORE/collections/<handle> | grep -oE '"@type":[[:space:]]*"CollectionPage"'

Expected: 1 line.

2. Verify the ItemList JSON-LD has absolute URLs.

bash
curl -s https://YOURSTORE/collections/<handle> | grep -oE '"url":[[:space:]]*"https://[^"]+"' | head -5

Expected: at least 1 line per page-render.

3. Verify the BreadcrumbList JSON-LD emits on collection pages (06.4 regression guard).

bash
curl -s https://YOURSTORE/collections/<handle> | grep -c '"@type":[[:space:]]*"BreadcrumbList"'

Expected: 1.

4. Verify the self-canonical link tag preserves ?page=N.

bash
# The link tag spans multiple lines (attribute per line) — print the href line too:
curl -s "https://YOURSTORE/collections/<handle>?page=2" | grep -A1 "rel='canonical'"

Expected: 2 lines — the rel='canonical' line, then an href line containing ?page=2 (NOT collapsed to page 1).

5. Verify the rel=prev link tag emits on page > 1.

bash
curl -s "https://YOURSTORE/collections/<handle>?page=2" | grep -c "rel='prev'"

Expected: 1.

6. Verify the rel=next link tag emits when more pages exist.

bash
# Replace <handle> with a collection that has > 32 products (more than 2 pages worth at default products_per_page=16)
curl -s "https://YOURSTORE/collections/<handle>?page=1" | grep -c "rel='next'"

Expected: 1 (when the collection has more pages remaining).

7. Verify the data-pagination-mode attribute on the section.

bash
# The theme emits single-quoted attributes:
curl -s "https://YOURSTORE/collections/<handle>" | grep -oE "data-pagination-mode='[a-z-]+'" | head -1

Expected: 1 of load-more, auto-load-on-scroll, numbered.

8. Verify the filter form is present.

bash
curl -s "https://YOURSTORE/collections/<handle>" | grep -c 'data-filter-form'

Expected: ≥ 1.

9. Verify hreflang alternates (regression check from Phase 06.2).

bash
curl -s "https://YOURSTORE/collections/<handle>" | grep -c 'rel="alternate" hreflang='

Expected: ≥ 1 (one per published locale).

10. Verify the bottom-sheet <dialog> is rendered as a sibling of the aside (not nested).

bash
# The dialog renders BEFORE the aside; join lines so the cross-line adjacency is greppable:
curl -s "https://YOURSTORE/collections/<handle>" | tr -d '\n\r' | grep -c '</dialog>[[:space:]]*<aside'

Expected: 1 (</dialog> immediately followed by <aside — the dialog is a preceding sibling, never nested inside the aside).

11. Verify product cards 1-4 have loading="eager" and fetchpriority="high".

bash
curl -s "https://YOURSTORE/collections/<handle>" | grep -c 'loading="eager"[^>]*fetchpriority="high"'

Expected: ≥ 4 (the first 4 grid cards).

12. Verify product cards 5+ have loading="lazy".

bash
curl -s "https://YOURSTORE/collections/<handle>" | grep -c 'loading="lazy"'

Expected: at least (grid card count − 4). Hover images on cards with 2+ media are also lazy, so the raw page count typically exceeds the primary-image count — use the diagnostic snippet's grid.lazyCardCount for the exact primary-image figure.

13. Verify the sub-collection chip row renders (when metafield populated).

bash
curl -s "https://YOURSTORE/collections/<handle>" | grep -c 'collection__sub-chip\|data-sub-chip'

Expected: ≥ 1 if the section has sub_collection_chip blocks or the collection has the uisce.sub_collection_chips metafield populated; 0 otherwise.

14. Verify the active-filter chip row is present after filtering.

bash
curl -s "https://YOURSTORE/collections/<handle>?filter.v.option.size=Medium" | grep -c 'collection__active-filters\|data-active-filter'

Expected: ≥ 1.

15. Verify the OOS treatment is rendering with the expected attribute.

bash
# Pick a handle known to have at least one sold-out product. The theme emits single-quoted attributes:
curl -s "https://YOURSTORE/collections/<handle>" | grep -oE "data-oos='(true|false)'" | sort | uniq -c

Expected: a mix of data-oos='true' (sold-out cards) and data-oos='false' (available cards).

16. Verify the meta name='plp-rel-next-authoritative' belt-and-suspenders cross-check.

bash
curl -s "https://YOURSTORE/collections/<handle>?page=2" | grep -c 'plp-rel-next-authoritative\|plp-rel-prev-authoritative'

Expected: 1 or 2 (depending on the page's prev/next state).

Deferred items (Phase 07 / v1.1)

These items were surfaced during the Phase 06.5 audit but explicitly deferred to a later phase. All have seed files tracking the revisit criteria.

FindingRationaleStatus
Asset file count (carry-over)assets/ holds 181 files. No file-count gate is enforced (D-B1) — bundle quality is guarded by per-file byte caps, per-template composed caps, and the Lighthouse performance score instead. Phase 06.5 added 1 net new file (plp-analytics.js) for Plan 12 cumulative-impressions extraction; the size-cap pressure on collection.js made splitting necessarySuperseded — no file-count gate
Codex HIGH H2 cumulative replay rework (Plan 06.5-08 deferral)Plan 08 shipped a minimal back-nav that restores filters + sort + scroll via a single page-1 fetch; pagination.pages_loaded is persisted but not replayed. Cumulative replay (the deferred Codex H2) would re-fetch every previously-loaded page on restore for byte-perfect grid replay. Scope-deferred per bundle-cap pressure; on a 14-product collection, single-page restore is sufficientDeferred to Phase 7 review
.footer__copyright color-contrast (carry-over from 06.4)5/5 axe violations on collection page — same .footer__copyright selector flagged in 06.4 PDP audit. Single CSS rule, no theme regression; resolution is footer-scope work, not PLP-scopeFooter-scope work
all collection handle missing on every dev storeDemo-data script targets a /collections/all Shopify smart collection, but Shopify auto-serves that path WITHOUT exposing it as a manageable handle. The chip-row metafield's "all" target unresolved. Either (a) merchants create a real all collection, or (b) script targets per-preset main handle. Soft data-state issue, not deploy failureDeferred to Phase 7 renegotiation
Neart 7/14 wellness handles missing on dev storePlan 16's CSV updates were never re-imported on Neart's dev store; populate script idempotently skipped 7 handles. Routed to audit report as a data-state finding; affects only the canary baseline product count on NeartDeferred to Phase 7 review
collection.js size vs Dawn (~8 KB)collection.js minifies to ~18.8 KB against an enforced 23,000 B minified cap (tests/unit/bundle-size.test.js); the old 31,500 B raised source-cap exemption is retired. plp-back-nav/plp-analytics extraction remains a lever if the minified size approaches the capEnforced at 23,000 B minified
Embedded PLP diagnostic tool (same pattern as 06.3 / 06.4)Console snippet provides ~80% of value at ~5% of cost; v1 ships without an embedded UIDeferred to Milestone 2

Built for the Shopify Theme Store.