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>?section_id=<id>&filter.X=Y after 150 ms. If not, JS is not loading — check assets/collection.js returns 200.
  3. Run the diagnostic snippet below and inspect filter.debounceMs. Expected: 150.

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 has a [data-filter-trigger] or [aria-controls="filter-dialog"] attribute. Click it — the <dialog> should open with [open] attribute set.

Run the diagnostic snippet — inspect filter.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 uses history.pushState to update the URL without a page reload. Verify:

  1. Open DevTools → Application → Frames → check the URL after a filter apply. Expected: ?filter.v.option.color=red or similar.
  2. Reload the page from the bookmarked URL — filters should re-apply on load.
  3. Click browser back button — URL should revert to the prior state.

If URL never updates, history.pushState may be blocked by a parent-document constraint (theme inside an iframe, custom-app overrides). Run the diagnostic snippet and check filter.history.lastPush — should reflect the last filter apply.

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 filter.priceRange. Expected: { "renders": true, "min": 0, "max": <max>, "currentMin": 0, "currentMax": <max> }.
  2. Confirm the range inputs have tabindex="-1" (keyboard users tab into the number inputs for precise entry; the range inputs are pointer-only). This is intentional per RESEARCH UX decision (Plan 06.5-03).
  3. If thumbs render but dragging does nothing, the inline change event listener may be missing — 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 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 reads from collection.metafields.uisce.sub_collection_chips (a metafield on the collection — NOT on the product). Verify:

  1. Admin → Settings → Custom data → Collections → confirm the metafield definition uisce.sub_collection_chips exists.
  2. Admin → Collections → choose a collection → Metafields → set the value (either as a list.collection_reference linking to other collections OR as a JSON list [{"label": "X", "url": "/collections/y"}, ...]).
  3. Reload the storefront collection page; the chip row should render above the grid.

If the metafield 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). Look for a console.warn in DevTools indicating which chip was rejected.

The chip row also requires the section to render — confirm sub_collection_chips setting is empty in the section (the metafield is the canonical source) OR is populated with valid JSON. Inline section JSON overrides the metafield; if you want the metafield to win, leave the section setting empty.

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.
  2. Visit a truly empty collection (no products at all); confirm the heading reads "This collection is empty" + no clear-all CTA + optional 3 suggested-collection cards.

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 fetch retries up to 3 times on 5xx server errors (Plan 06.5-07 retry pattern). After 3 failed attempts, the button changes label to "View all products" + becomes a fallback <a href="?page=N"> link 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 → confirm the failing requests are 5xx (Shopify platform side). If 4xx, the request is malformed (more likely a theme bug — file a support ticket).

After 3 retries, the user can click the fallback link to navigate to page 2 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 the grid has bottom padding sufficient for the footer height. Default is padding-block-end: 80px; if your CSS overrides this, restore.
  2. Confirm --announcement-bar-height is set correctly when the announcement bar is visible. The header's StickyHeader custom element manages this; if you removed the announcement bar, the variable should default 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 emits in snippets/json-ld-collection.liquid (head-scoped invariant fields) + snippets/json-ld-collection-itemlist.liquid (paginate-scoped ItemList). 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 main-collection section type.
  2. Confirm sections/collection.liquid includes {% render 'json-ld-collection' %} near the head.
  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 sessionStorage under uisce.state.v1.plp-back-nav.<collection-handle>. Verify:

  1. Apply filters → click a product → in DevTools → Application → sessionStorage; you should see a key uisce.state.v1.plp-back-nav.<handle> with JSON { filters, sort, pages, scrollY, ts }.
  2. Click browser back; the grid should re-apply filters + sort + load all the previously-loaded pages + scroll to within 50 px of scrollY.
  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 falls back to fresh load + 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 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 is rendered by assets/plp-analytics.js (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
curl -s "https://YOURSTORE/collections/all?page=2" | grep -oE "rel='canonical'[^>]*"
curl -s "https://YOURSTORE/collections/all?page=2" | grep -oE "rel='prev'[^>]*"

Expected:

  • rel='canonical' href='.../collections/all?page=2' (self-canonical, NOT page=1)
  • rel='prev' href='.../collections/all?page=1'
  • rel='next' href='.../collections/all?page=3' (when the collection has more than 3 pages worth)

If the canonical 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 is for a color option (not just any checkbox). The swatch dispatch reads filter.id == 'filter.v.option.color' (or your store's color-option label, which Shopify auto-handles).
  3. If the filter is filter.p.tag (collection tag), swatches don't render — tag-filters always render as plain checkboxes.

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

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 timer firing? (DevTools console → diagnostic snippet → filter.debounceMs)
    -> not 150: theme stale, push latest
    -> 150: 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? (CSS .collection-filters__trigger or [data-filter-trigger])
    -> 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 5xx: server error; retry kicks in (max 3); fallback link appears
    -> 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? (diagnostic snippet → swatches.allowlistMatches)
    -> 0: not in allowlist; metafield is required
    -> > 0: allowlist working; check metafield is the canonical source
  -> Console.warn for swatch fallback?
    -> 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 sessionStorage uisce.state.v1.plp-back-nav.<handle> present?
    -> no: state never persisted; check pdp-link click handler in plp-analytics.js
    -> yes: continue
  -> Is the TTL expired? (state.ts > 30 minutes ago)
    -> yes: fresh load is correct; this is intentional
    -> no: continue
  -> Is the JSON malformed? (paste sessionStorage value into JSON.parse)
    -> SyntaxError: corrupted; theme falls back to fresh load + console.warn
    -> valid JSON: theme bug; file a ticket

Symptom: CollectionPage JSON-LD missing
  -> curl https://YOURSTORE/collections/all | grep CollectionPage
    -> 0 hits: snippet not rendering; check sections/collection.liquid render call
    -> 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-chip metaobject fetch: cache via paginate; should not be in critical path
  -> 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 issuePhase 7 polish — footer copyright contrast fix not yet shipped. Not introduced by 06.5
axe"tabindex on price-range input"Range input has tabindex="-1" so keyboard-only users skip itIntentional — keyboard users tab into the number inputs for precise entry; range inputs are pointer-only per RESEARCH UX decision (Plan 06.5-03)
GRRT"CollectionPage.numberOfItems missing"JSON-LD shell emitted but ItemList missingPlan 06.5-15 split shell + paginate-scoped ItemList; push latest theme. Confirm snippets/json-ld-collection-itemlist.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
Shopify Theme Check"AssetCount > 60"Theme file count exceeds D-22 capAsset count (~106) exceeds the original 60-file cap; Phase 7 renegotiation in progress. 06.5 added 1 net new file (plp-analytics.js) for Plan 12 cumulative-impressions extraction
Shopify Theme CheckMissingTemplateSuffix on collection.<preset>.jsonPer-preset template not registeredConfirm templates/collection.<preset>.json is committed for all 5 presets (Plan 06.5-14 matrix)

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: document.body.getAttribute('data-template') || 'unknown',
    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,
      eagerCardCount: document.querySelectorAll('[data-product-card] img[loading="eager"]').length,
      lazyCardCount: document.querySelectorAll('[data-product-card] 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');
        return grid ? grid.getAttribute('data-grid-cols') : null;
      })(),
    },
    filters: {
      formPresent: !!document.querySelector('[data-filter-form]'),
      activeFiltersCount: document.querySelectorAll('[data-active-filters] [data-active-filter]').length,
      announcement: !!document.querySelector('[data-filter-announcement]'),
      bottomSheet: (() => {
        const dialog = document.querySelector('snippet-collection-filters dialog, dialog[data-filter-dialog]');
        const aside = document.querySelector('snippet-collection-filters aside, aside.collection-filters');
        if (!dialog) return { present: false };
        return {
          present: true,
          open: dialog.hasAttribute('open'),
          structure: aside && dialog.parentElement === aside ? 'nested-inside-aside' : 'sibling-of-aside',
        };
      })(),
      debounceMs: window.uisceFilterDebug?.DEBOUNCE_MS ?? null,
      announceDebounceMs: window.uisceFilterDebug?.ANNOUNCE_DEBOUNCE_MS ?? null,
      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('[data-filter-swatch], .collection__filter-swatch');
        if (!swatches.length) return { renders: false };
        const fallbackCount = document.querySelectorAll('[data-swatch-fallback="true"], [style*="#D1D5DB"]').length;
        return {
          renders: true,
          count: swatches.length,
          style: document.querySelector('[data-swatch-facet-style]')?.dataset.swatchFacetStyle || null,
          fallbackCount,
        };
      })(),
      showMoreLessSlots: document.querySelectorAll('[data-show-more-trigger], [data-search-within]').length,
      history: {
        lastPush: window.uisceFilterDebug?.lastHistoryPush ?? null,
      },
    },
    chips: {
      activeFilters: document.querySelectorAll('[data-active-filter]').length,
      activeFilterOverflow: (() => {
        const row = document.querySelector('.collection__active-filters');
        return row ? row.scrollWidth > row.clientWidth : false;
      })(),
      subCollectionChips: (() => {
        const chips = document.querySelectorAll('[data-sub-chip], .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,
        };
      })(),
      retryAttempts: window.uisceFilterDebug?.lastRetryCount ?? null,
    },
    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: !!document.querySelector(
        'script[type="application/ld+json"][data-collection-jsonld], script[type="application/ld+json"]:not([data-product-jsonld])',
      ),
      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: {
      sessionStateKey: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return null;
        return `uisce.state.v1.plp-back-nav.${handle}`;
      })(),
      sessionStatePresent: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return false;
        try {
          return !!sessionStorage.getItem(`uisce.state.v1.plp-back-nav.${handle}`);
        } catch (_) {
          return false;
        }
      })(),
      sessionStateAge: (() => {
        const handle = (location.pathname.match(/\/collections\/([^\/?]+)/) || [])[1];
        if (!handle) return null;
        try {
          const raw = sessionStorage.getItem(`uisce.state.v1.plp-back-nav.${handle}`);
          if (!raw) return null;
          const parsed = JSON.parse(raw);
          return parsed.ts ? Math.round((Date.now() - parsed.ts) / 1000) + 's' : null;
        } catch (_) {
          return null;
        }
      })(),
    },
    viewToggle: {
      present: !!document.querySelector('[data-view-toggle]'),
      activeMode: document.querySelector('[data-view-mode]')?.getAttribute('data-view-mode') || null,
      shopperStateKey: 'uisce.state.v1.collection.view-mode',
      shopperStateValue: (() => {
        try {
          return localStorage.getItem('uisce.state.v1.collection.view-mode');
        } 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]').length,
      withFocusVisible: document.querySelectorAll('[data-product-card]:focus-visible').length,
    },
    analytics: {
      // The plp-analytics.js IIFE registers these listeners on window.
      cardImpressionEvents: window.__uisceCardImpressionsCount ?? null,
      cardClickEvents: window.__uisceCardClicksCount ?? null,
    },
  };
  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 — first 4 cards have loading="eager" (Plan 06.5-11 LCP optimization).
  • grid.lazyCardCount === grid.cardCount - 4 — cards 5+ have loading="lazy".
  • 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.debounceMs === 150 — auto-apply debounce window correct.
  • 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 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.sessionStatePresent === true after navigating back from PDP within 30 minutes; sessionStateAge shows the age.
  • 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 via scripts/verify-theme-deployment.mjs --preset YOURPRESET (the script handles password auth for you). For live production stores, 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
curl -s "https://YOURSTORE/collections/<handle>?page=2" | grep -oE "rel='canonical'[^>]*"

Expected: 1 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
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
# Should match `</aside>...<dialog` not `<aside>...<dialog>...</aside>`
curl -s "https://YOURSTORE/collections/<handle>" | grep -oE '</aside>[[:space:]]*<dialog' | head -1

Expected: 1 line.

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: total grid card count − 4.

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 collection has 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
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 count cap D-22 breach (carry-over)Asset count (~106) exceeds the original 60-file cap; Phase 7 renegotiation in progress. 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 necessary. Phase 7 renegotiates with fresh evidence (HTTP/2 reality, Shopify CDN amortization)Deferred to Phase 7 renegotiation
Codex HIGH H2 cumulative replay rework (Plan 06.5-08 deferral)Plan 08 shipped a minimal back-nav that restores filters + sort + scroll + visible page count. 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. v1.1 polish considers cost/value: 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-scopePhase 7 polish — not yet shipped
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
Phase 6.5 canary test bodies remain test.skip(true, 'RED:')All 9 specs (62 tests) shipped Wave 0 as RED scaffolds; Plans 01-15 landed unit-test contracts to GREEN but never un-skipped the live-storefront canary bodies. Plan 17 audit empirically verifies via verify-theme-deployment.mjs (85/85 markers PASS) — this is sufficient for closure but the canary tests remain a future-test-strengthen taskDeferred to Phase 7 review
collection.js ~31 KB vs Dawn ~8 KB (3.5× larger)Bundle is at the Plan 07a/08/15 raised cap of 31,500 B. Phase 7 considers extraction to plp-back-nav.js + plp-analytics.js as separate files contingent on retiring ≥ 2 existing assets firstDeferred to Phase 7 renegotiation
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.