Skip to content

Product page support

Diagnostic and troubleshooting reference for the product detail page (PDP) — gallery + lightbox, variant picker, sticky add-to-cart, product recommendations, back-in-stock signup, selling-plan subscriptions, combined-listing parent-child, breadcrumb structured data, and per-preset template ordering.

FAQ

Clicking or tapping a gallery image opens the fullscreen lightbox — a native <dialog> owned by media-gallery itself, with previous/next navigation; Escape or the close button closes it (verified live: 5 triggers on a 5-image product, dialog opens with prev/next, Escape closes). Diagnose in the console on a product page:

js
document.querySelectorAll('[data-lightbox-trigger]').length; // ≥ 1 — the clickable images
!!document.querySelector('[data-lightbox]'); // true — the dialog exists
!!customElements.get('media-gallery') && !!document.querySelector('media-gallery'); // must be true

If triggers exist but clicks do nothing, check for custom JS intercepting clicks or a CSS override with pointer-events: none on the gallery. (A hover-magnifier zoom mode exists in the codebase but is not wired into the composed page — tracked as issue #234; do not diagnose against it.)

Zooming does not work inside the fullscreen viewer on mobile.

Inside the open lightbox dialog, zooming uses your browser's own pinch gesture on the image — the theme adds no custom gesture handlers, so if pinch fails it is being intercepted before the browser sees it: PWA shell wrappers or custom additions binding their own touchstart with preventDefault, or an embedded webview with gestures disabled. Close the dialog with Escape or its close button.

The gallery parses the media query parameter from the page-load URL — ?media=<n>, 1-based — and activates that image (?media=2 → second image; verified live). Clicking thumbnails keeps the URL updated via replaceState, so copied links reopen the same image. If a deep link is not working:

  1. Confirm the syntax is the query parameter ?media=<n> — an older hash-based syntax is not read.
  2. Confirm the product has at least n media items — an out-of-range n is ignored (the first image stays active; nothing is logged).
  3. Confirm the page loaded the product template — deep-links only apply where the gallery renders.
  4. The parameter is read once, at page load — changing it in the address bar without reloading does nothing.

The variant picker does not show the combined-listing chip.

The combined-listing chip only renders when:

  • The product belongs to a combined-listing group (created with Shopify's Combined Listings app — a Shopify Plus feature); the render gate is simply whether Shopify exposes combined_listing on the product
  • The combined_listing block is added to the product section (Theme editor → Product section → Add block → Combined listing)

What renders is plain navigation: cross-product option pills (each a link to the sibling carrying that value) plus the "You're viewing X — See related options" chip linking to the parent. Nothing expands in place. Check in the console: !!document.querySelector('.product__combined-listing'). If false on a product you believe is grouped, the group or the block composition is missing. On dev stores, seed a demo group with node scripts/populate-combined-listing-demo.mjs --preset <preset>.

Sticky add-to-cart bar does not appear on scroll.

The sticky bar uses an IntersectionObserver on the in-page buy-button. When the buy-button scrolls out of viewport, the sticky bar is shown; when it scrolls back in, the sticky bar hides (avoiding double-render).

The surface is dual-track: two <sticky-atc> elements — data-mode='bar' (phones ≤ 749 px, with the optional quantity stepper) and data-mode='dock' (wide desktops ≥ 1400 px, no stepper). Between those widths, desktop relies on the sticky info column (CSS from ≥ 1024 px) and neither element shows — that is by design, not a failure.

If the sticky surface never shows on a width where it should:

  1. Confirm both elements are mounted and check the one for your viewport: [...document.querySelectorAll('sticky-atc')].map(e => e.dataset.mode) should return ['bar', 'dock'].
  2. Confirm the page is long enough to scroll — on very short product descriptions (no additional blocks below buy-button), the sticky bar never triggers because the buy-button stays visible.
  3. Confirm no custom CSS has overflow: hidden on a parent element that would break IntersectionObserver viewport detection.
  4. Confirm sticky-add-to-cart.css is loaded — check the Network tab; the asset should return 200.

Some merchants hide the sticky bar intentionally for minimalist PDPs. Custom CSS override:

css
sticky-atc {
  display: none !important;
}

Where do back-in-stock submissions go?

The back-in-stock form submits through Shopify's native contact form; Shopify emails each submission to your store's Sender email address (Shopify Admin → Settings → Notifications). The theme does not post to any external endpoint — there is no external-forwarding field of any kind, by design.

Verify:

  1. Open DevTools → Network tab on a sold-out PDP, submit the form, and confirm a POST /contact appears with a redirect back to the product page.
  2. Check the inbox for your store's Sender email address (Settings → Notifications). Each submission carries contact[form_type]=back_in_stock plus the variant id, product handle, variant title, and the shopper's email.

To get these signups into Klaviyo / Omnisend / Postscript, use that provider's own signup form or Shopify app instead of this block, or forward the notification emails from your mail provider — the theme deliberately keeps the shopper's email inside Shopify rather than transmitting it to a merchant-typed URL.

The save badge is not showing on my subscription plan.

The badge renders only when:

  • show_save_badge is enabled on the selling-plan block (default: on)
  • The selling plan has a non-zero discount configured in Shopify Admin → Subscriptions

If your plan is priced equal to one-time purchase (a zero adjustment), the badge intentionally does not render — a "Save 0%" badge would be misleading UX.

Verify in the DOM: the selling-plan block renders inside .product__selling-plan — a discounted plan shows its save badge there; a plan with a zero adjustment shows none. The badge wording follows the plan's price-adjustment type in Shopify Admin, not any theme setting: a percentage adjustment renders "Save X%", a fixed-amount adjustment renders the money-formatted amount (e.g. "Save €5.00").

Recommendations strip is showing sold-out products.

The exclude_sold_out setting filters client-side. Verify:

  1. Theme editor → Product recommendations section → Hide sold-out products — confirm enabled.
  2. Confirm the setting reached the storefront: the <product-recommendations> element carries data-exclude-oos="true" (console: document.querySelector('product-recommendations')?.dataset.excludeOos).
  3. With the filter on, no rendered card should show a Sold out badge. If sold-out cards still appear, push the latest theme — the client-side filter lives in assets/product-recommendations.js.

Check the deployed script is current: curl -s <your-store>/products/<any-product> | grep -oE '<script[^>]*product-recommendations[^>]*>' — the tag should reference product-recommendations.js with a version query.

The OOS cross-sell strip does not render on a sold-out PDP.

The cross-sell renders after the buy column, only when every variant is sold out — the disabled add-to-cart button stays in place above it. If one variant is still available, the cross-sell is not triggered (the shopper can still add-to-cart via the available variant).

The strip fetches the theme's product-recommendations section file through Shopify's recommendations endpoint and ships its own script and stylesheet tags, so it does not depend on a separate Product recommendations section being composed. The fetch pins the strip's own parameters — related intent, limit 4, sold-out cards always excluded, the fetched section heading hidden in favour of the strip's own — and everything else renders with the section file's schema defaults. A separately-composed Product recommendations section's instance settings (heading, intent, limit, columns, quick-buy, Hide sold-out products, Limit to a collection) apply only to that section, never to this strip.

If every variant is sold out but the strip still does not render:

  1. Confirm show_oos_recommendations is enabled on the product section (default: on).
  2. Confirm product-recommendations.js is loaded — check the Network tab (the strip emits its own <script> tag, so a missing script means a stale deployed theme).
  3. Confirm the recommendations API endpoint returns at least one related product: curl -s <your-store>/recommendations/products.json?product_id=<id>&limit=4 should return a non-empty products array.

If the recommendations API returns empty (Shopify's engine can return no results — for example on new or low-data stores), or every returned card is sold out, the recommendations element empties and hides itself rather than showing bare placeholder cards. Nothing is injected in its place. A back-in-stock signup only appears if the Back-in-stock alert block is separately composed on the product — the two are independent.

Restock-date banner shows but the countdown is wrong.

The countdown is computed client-side, in the visitor's browser: the restock banner renders a <countdown-timer> whose remaining time comes from Date.now() against the metafield date, ticking live (compact days : hours : minutes; hides itself when the date passes). Two consequences: a visitor whose device clock is wrong sees a wrong remaining time, and before scripts run the banner shows the localized date itself rather than a countdown.

If the countdown is wildly wrong (e.g., negative, or year-off):

  1. Confirm the metafield value is a valid ISO date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ).
  2. Confirm the metafield namespace.key matches the oos_restock_date_metafield section setting (default custom.restock_date).
  3. The theme falls back to the variant-level metafield if product-level is empty. If both are empty, no banner renders.

The theme's breadcrumb structured data is schema.org microdata inline in the breadcrumb markup (snippets/breadcrumbs.liquid: itemscope itemtype='https://schema.org/BreadcrumbList' with ListItem children). Shopify may additionally emit its own BreadcrumbList JSON-LD script at the platform level (observed live) — either form satisfies Google Rich Results. Verify:

bash
curl -s <your-store>/products/<any-product> | grep -c 'schema.org/BreadcrumbList'

Expected: 1 or more. The microdata's itemprop='item' links carry absolute URLs (each href is prefixed with the shop URL).

If the block is missing, the breadcrumb snippet is not rendered. Breadcrumbs render globally from layout/theme.liquid (before the page content) — they are not a product-template section, so there is nothing to remove in the theme editor's product view. If they are missing, diagnose the layout path: confirm layout/theme.liquid still renders the breadcrumbs snippet and that no customization removed that render.

Selecting a variant (click / change — there is no hover-preview path) fires a variant:change event; the gallery activates that variant's assigned featured image. If the variant has no image assigned in Shopify Admin, the gallery deliberately stays where it is. To fix:

  1. Shopify Admin → Products → <product> → Media → drag each image onto the matching variant.
  2. That assignment is the whole mechanism — admin-driven, no theme-editor configuration.

The PDP is slow to load on mobile.

Mobile Lighthouse performance below 0.6 on PDPs is typically caused by large product images, video heroes, or embedded iframes. Common causes:

  • Unoptimized product images (>1MB each) — Shopify auto-resizes for responsive loading but source file size matters for the first request
  • Video-as-hero products — consider an image placeholder with a play-button overlay
  • Large descriptions with embedded YouTube/Vimeo — lazy-load via native loading="lazy" on the iframe wrapper

Run Lighthouse per-page to identify the blocker. The shipped .lighthouserc.cjs config runs the desktop preset — for mobile numbers, run Lighthouse from DevTools with mobile emulation.

Decision tree: "Why is X not working?"

text
Symptom: gallery lightbox not opening on click
  -> Are the triggers present? (console: document.querySelectorAll('[data-lightbox-trigger]').length)
    -> 0: the gallery markup did not render; check media-gallery.js in the Network tab
    -> 1+: continue
  -> Is the dialog present? (console: !!document.querySelector('[data-lightbox]'))
    -> false: stale/customized gallery snippet; restore snippets/product-media-gallery.liquid
    -> true: check for custom click handlers or `pointer-events: none` overrides on the gallery
  (the pointer-mode magnifier in media-zoom.js is not wired into the composed page — issue #234; do not diagnose against it)

Symptom: sticky ATC never appears on scroll
  -> Is the viewport in a sticky-element range? (bar ≤749px, dock ≥1400px; between them the CSS info column is the sticky surface)
    -> no: by design — resize or test on a phone/wide desktop
    -> yes: continue
  -> Are both elements in the DOM? (console: [...document.querySelectorAll('sticky-atc')].map(e => e.dataset.mode))
    -> not ['bar','dock']: section removed or asset failed; re-add section + check Network
    -> present: continue
  -> Is the product page long enough to scroll past the buy-button?
    -> no: sticky bar stays hidden by design
    -> yes: continue
  -> IntersectionObserver support? (console: 'IntersectionObserver' in window)
    -> false: very old browser; polyfill not shipped in 06.4 (browser-matrix is modern evergreens)
    -> true: check for custom CSS `position: fixed` overrides on parent elements

Symptom: back-in-stock submission not appearing
  -> DevTools → Network → submit form; is there a POST /contact?
    -> no: check the block is enabled + the variant is actually sold out
    -> yes: continue
  -> Check the inbox for the store's Sender email address (Settings → Notifications)
    -> missing: confirm the Sender email (Settings → Notifications) is a monitored address
    -> present: working as designed (submissions stay inside Shopify — no external forwarding by design)

Symptom: combined-listing block missing
  -> Is the product in a combined-listing group? (Combined Listings app — Shopify Plus; the gate is product.combined_listing existing)
    -> no: create the group, or on dev stores run populate-combined-listing-demo.mjs
    -> yes: continue
  -> Is the combined-listing block added to the section? (Theme editor)
    -> no: add it (Theme editor → Product → Add block → Combined listing)
    -> yes: console check !!document.querySelector('.product__combined-listing') — false means the block did not render; re-check composition

Symptom: recommendations strip showing sold-out products
  -> Is `exclude_sold_out` enabled on the section? (Theme editor → Hide sold-out products)
    -> off: enable it
    -> on: continue
  -> Does the element carry data-exclude-oos="true"? (console: dataset.excludeOos)
    -> no: the deployed theme is stale — push latest
    -> yes: no rendered card should show a Sold out badge; if one does, push latest (filter: assets/product-recommendations.js)

Symptom: OOS cross-sell not rendering on sold-out PDP
  -> Is every variant sold out? (some variants still available triggers buy-button, not cross-sell)
    -> no: design-correct; fix sold-out status or expect buy-button
    -> yes: continue
  -> Is `show_oos_recommendations` enabled? (Theme editor → Product section)
    -> off: enable it
    -> on: continue
  -> Does `/recommendations/products.json?product_id=<id>&limit=4` return non-empty?
    -> empty: the recommendations element hides itself (no fallback is injected); compose the Back-in-stock alert block if you want a signup there
    -> non-empty but every returned product is sold out: same hide-by-design — the strip's always-on sold-out filter leaves zero cards to render
    -> non-empty with in-stock products: check product-recommendations.js is loaded + check console for JS errors

Symptom: breadcrumb structured data missing
  -> curl the PDP: grep 'schema.org/BreadcrumbList' — present? (microdata in the breadcrumb markup, not a JSON-LD script)
    -> no: breadcrumbs render globally from layout/theme.liquid (before page content) — confirm the layout still renders the breadcrumbs snippet; there is no product-template entry to check
    -> yes: continue
  -> Google Rich Results Test (paste HTML) — BreadcrumbList enhancement green?
    -> red: URLs relative instead of absolute — push latest theme (fixed in 06.4)
    -> green: correctly configured

Symptom: PDP slow on mobile (Lighthouse perf < 0.6)
  -> Identify LCP element (DevTools → Performance → LCP flame)
    -> Hero image: optimize source size; Shopify auto-resizes but source matters
    -> Video: swap to placeholder image with play-button overlay
    -> Description block: check for embedded iframes; add loading="lazy"
  -> Are fonts preloaded? (head → `<link rel=preload as=font>`)
    -> no: the theme always emits them (snippets/fonts.liquid) — a missing preload means a customized layout dropped the fonts snippet; restore it
    -> yes: continue checking

Validator interpretation

ValidatorOutput messageMeaningFix
Lighthouse"CLS > 0.1 on PDP"Gallery image loading caused layout shiftCheck gallery images have width + height attributes; theme emits them but custom edits may strip them
Lighthouse"Perf < 0.6 on mobile PDP"LCP element > 2.5s mobileSee decision tree "PDP slow on mobile"; check hero image size, fonts preload, video heroes
Lighthouse"TBT > 300 ms mobile"Total Blocking Time exceeded mobile gateShopify platform baseline (4× CPU throttle lab artifact). Not a theme defect. See Phase 06.2 baseline
Lighthouse"BP = 0.78"Best Practices score capped at 0.78Shopify platform cap (portable-wallets.en.js, bugsnag telemetry). Not a theme defect
Lighthouse"Perf < 0.6 on mobile PDP"LCP element > 2.5s mobile on a product with large mediaRe-export hero at ≤ 600 KB; Shopify CDN auto-resizes responsive variants but source size dominates first render
axe"color-contrast on .product__pill--selected"Variant pill selected state contrast ratio fails WCAG AA06.4 fix shipped but cached CSS may lag — clear CDN + re-push theme; if still failing, escalate (contrast math changed)
axe"aria-expanded invalid on pickup-availability"Drawer aria-expanded value not syncing on open/close06.4 fix shipped via capture-phase listener (F38 in audit). Clear CDN + re-push. If still failing, check for custom JS overriding handler
GRRT"Product.offers.itemCondition missing"product:condition hardcoded "new" in JSON-LD, no merchant settingDeferred to Phase 7
GRRT"BreadcrumbList.itemListElement.item must be a URL"Breadcrumb emitted relative URLsFixed in 06.4 (absolute URLs). 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
LinkedIn Post Inspector"Unable to retrieve preview"Same as Meta (dev-store password)Same as above
Shopify Theme Checkany offenseThe repo gate is zero offenses (npm run theme:check)An offense means a local edit introduced it — the shipped theme passes clean; diff your changes against the theme source

Diagnostic snippet

Paste into your browser DevTools Console on any product detail page of your live store. Produces a single JSON blob describing current PDP state — safe, read-only, and adds zero theme code. The snippet runs one read-only traversal of the PDP 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'),
    presetClass: [...document.body.classList].find((c) => c.startsWith('preset-')) || null,
    isProductPage: location.pathname.includes('/products/'),
    preferences: {
      pointerFine: matchMedia('(pointer: fine)').matches,
      reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
    },
    gallery: {
      present: !!document.querySelector('media-gallery'),
      activeIndex: document.querySelector('media-gallery')?._currentIndex ?? null,
      mediaCount: document.querySelector('media-gallery')?.items?.length ?? 0,
      lightboxTriggers: document.querySelectorAll('[data-lightbox-trigger]').length,
      deepLink: new URLSearchParams(location.search).get('media'),
      lightbox: {
        present: !!document.querySelector('[data-lightbox]'),
        open: document.querySelector('[data-lightbox]')?.open ?? false,
      },
    },
    variantPicker: {
      present: !!document.querySelector('variant-picker'),
      urlVariant: new URLSearchParams(location.search).get('variant'),
      pillCount: document.querySelectorAll('variant-picker .product__pill').length,
      swatchCount: document.querySelectorAll('variant-picker .product__swatch').length,
      dropdownCount: document.querySelectorAll('variant-picker .product__select').length,
    },
    combinedListing: {
      blockRendered: !!document.querySelector('.product__combined-listing'),
    },
    buyButton: {
      present: !!document.querySelector('.product__add-to-cart'),
      enabled: (() => {
        const btn = document.querySelector('.product__add-to-cart');
        return btn ? !btn.disabled : null;
      })(),
      unitPriceVisible: !!document.querySelector('.product__unit-price'),
    },
    stickyAtc: {
      modes: [...document.querySelectorAll('sticky-atc')].map((e) => e.dataset.mode),
      visibleModes: [...document.querySelectorAll('sticky-atc')]
        .filter((e) => getComputedStyle(e).display !== 'none' && getComputedStyle(e).visibility !== 'hidden')
        .map((e) => e.dataset.mode),
    },
    backInStock: {
      formPresent: !!document.querySelector('.product__back-in-stock-form'),
      successShown: (() => {
        const el = document.querySelector('.product__back-in-stock-success');
        return el ? !el.hidden : false;
      })(),
    },
    recommendations: {
      present: !!document.querySelector('product-recommendations'),
      excludeOos: (() => {
        const el = document.querySelector('product-recommendations');
        return el ? el.dataset.excludeOos === 'true' : null;
      })(),
      collectionScope: (() => {
        const el = document.querySelector('product-recommendations');
        return el ? el.dataset.collectionScope || null : null;
      })(),
      cardsCount: document.querySelectorAll('product-recommendations .product-card').length,
    },
    oosCrossSell: {
      present: !!document.querySelector('.product__oos-cross-sell'),
      restockBanner: !!document.querySelector('.product__oos-restock-banner'),
      countdownPresent: !!document.querySelector('.product__oos-restock-banner countdown-timer'),
    },
    sellingPlan: {
      present: !!document.querySelector('.product__selling-plan'),
    },
    breadcrumb: {
      microdataPresent: !!document.querySelector("[itemtype='https://schema.org/BreadcrumbList']"),
      absoluteUrls: (() => {
        const links = document.querySelectorAll("[itemtype='https://schema.org/BreadcrumbList'] [itemprop='item']");
        if (!links.length) return null;
        return [...links].every((a) => (a.href || '').startsWith('https://'));
      })(),
    },
    productJsonLd: (() => {
      /* Shopify's structured_data filter emits @type "Product" for
         single-variant products and "ProductGroup" for multi-variant ones. */
      for (const el of document.querySelectorAll("script[type='application/ld+json']")) {
        try {
          const parsed = JSON.parse(el.textContent);
          if (parsed['@type'] === 'Product' || parsed['@type'] === 'ProductGroup')
            return { present: true, type: parsed['@type'] };
        } catch (e) {
          /* skip malformed blocks */
        }
      }
      return { present: false, type: null };
    })(),
    shopperState: {
      available: typeof window.UisceShopperState === 'object',
      stateKeys: Object.keys(localStorage).filter((k) => k.startsWith('uisce.state.v1.')).length,
    },
  };
  console.log('Uisce Theme — PDP diagnostic report');
  console.log(JSON.stringify(state, null, 2));
  return state;
})();

What to look for in the output:

  • gallery.present === true and gallery.mediaCount >= 1 — gallery is mounted with content.
  • gallery.lightboxTriggers >= 1 and gallery.lightbox.present === true — clicking an image opens the fullscreen viewer.
  • variantPicker.present === true — picker mounted; exactly one of pillCount / swatchCount / dropdownCount is non-zero per option.
  • combinedListing.blockRendered === true only on combined-listing children with the block composed.
  • buyButton.enabled === true on an in-stock product, false on a sold-out product; unitPriceVisible === true on variants with a unit-price measurement.
  • stickyAtc.modes is ['bar', 'dock'] when the section is composed; visibleModes depends on viewport width (bar ≤ 749 px, dock ≥ 1400 px, neither in between) and scroll position.
  • backInStock.formPresent === true on sold-out variants; the form submits to Shopify via POST /contact (no external endpoint).
  • recommendations.present === true; with excludeOos === true, no rendered card should show a Sold out badge.
  • oosCrossSell.present === true on fully-sold-out PDPs; restockBanner + countdownPresent when the restock metafield is populated.
  • sellingPlan.present === true when the block is composed; a discounted plan renders its Save badge inside it.
  • breadcrumb.microdataPresent === true and absoluteUrls === true — the microdata's itemprop='item' links all start with https://.
  • productJsonLd.present === true — Shopify's structured_data filter emits Product (single-variant) or ProductGroup (multi-variant); verified live as ProductGroup on a multi-variant PDP.
  • shopperState.available === true — shopper-state primitive loaded (its keys are namespaced uisce.state.v1.*).

Curl cheat-sheet

Use these one-liners to verify your live store's PDP. Replace YOURSTORE with your domain and <product-handle> with an actual product. If your store is password-protected (dev store), a bare curl gets the password page — authenticate in a browser and reuse its cookies (Shopify's storefront session rides the _shopify_essential cookie), or use a browser-driven check instead. For live production stores, no cookie is needed.

1. Verify the Product JSON-LD is emitted and valid.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '"@type":"Product'

Expected: 1 or more — Shopify's structured_data filter emits "Product" for single-variant products and "ProductGroup" for multi-variant ones (the prefix grep matches both; verified live).

2. Verify the BreadcrumbList microdata is present with absolute URLs.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c 'schema.org/BreadcrumbList'

Expected: 1. The breadcrumb's itemprop='item' links carry absolute URLs (each href is prefixed with the shop URL).

3. Verify hreflang alternates in the head (regression check from Phase 06.2).

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<link rel=.alternate. hreflang='

Expected: 1 or more if you have published locales.

4. Verify the media-gallery custom element is present.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<media-gallery'

Expected: 1.

5. Verify variant-picker custom element is present and has variants.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<variant-picker[^>]*>' | head -1

Expected: one line containing <variant-picker.

6. Verify the sticky add-to-cart elements are mounted.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<sticky-atc'

Expected: 2 — the dual-track surface always mounts both the bar (≤ 749 px) and the dock (≥ 1400 px); the viewport decides which of the two (if either) is visible, not which is in the markup.

7. Verify product-recommendations.js is loaded with defer.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<script[^>]*product-recommendations[^>]*>'

Expected: one line containing defer.

8. Verify the back-in-stock block is present when the CURRENT variant is sold out.

bash
curl -s 'https://YOURSTORE/products/<handle>?variant=<sold-out-variant-id>' | grep -c 'product__back-in-stock-form'

Expected: 1. The block renders when the selected variant (or the first available one, when nothing is selected) is unavailable and the block is composed — target an actually-unavailable variant with ?variant=; other variants being sold out is not the trigger.

9. Verify commerce OG tags for social sharing.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<meta[^>]*property="og:(type|price:amount|price:currency|availability)"[^>]*>'

Expected: 4 lines (og:type=product, og:price:amount, og:price:currency, og:availability).

10. Verify the preset cascade reached the page — regression check for per-preset packaging.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE "class='preset-[a-z]+'" | head -1

Expected: class='preset-<your-preset>' on the <body> (e.g. preset-tine).

11. Verify the combined-listing block renders on a grouped child product.

bash
curl -s https://YOURSTORE/products/<combined-listing-child-handle> | grep -c 'product__combined-listing'

Expected: 1 or more on a child product in a group with the block composed.

12. Verify the selling-plan block renders on a subscription product.

bash
curl -s https://YOURSTORE/products/<subscription-product-handle> | grep -c 'product__selling-plan'

Expected: 1 or more when the product has selling plans and the block is composed.

13. Verify the OOS restock banner renders when the metafield is populated.

bash
curl -s https://YOURSTORE/products/<oos-product-handle> | grep -c 'product__oos-restock-banner'

Expected: 1 when the product is fully sold out AND has the restock-date metafield populated.

Deferred items (v1.1 / Phase 06.5 / Phase 07)

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

FindingRationaleStatus
Rating count CLDR fan-out (F24)Most locales only have one / other forms; 50-locale CLDR rollover belongs in Phase 06.5 PLP audit where rating is more prominent on collection cardsDeferred to Phase 06.5
Asset count cap D-22 breach (F41)Pre-existing breach (99→106 files vs cap of 60); Phase 7 renegotiates with fresh evidence (HTTP/2, Shopify CDN amortization, Lighthouse before/after)Deferred to Phase 7 renegotiation
@app block dispatch via content_for (F33)Retrofitting PDP sections from legacy {% for block in section.blocks %} to content_for 'blocks' was a Theme Store review gateShipped — the PDP dispatches via content_for 'blocks'; Theme Check runs clean
product:condition merchant settingHardcoded "new" in JSON-LD; merchants selling refurbished / used need a per-product override settingDeferred to Phase 7
Embedded PDP diagnostic tool (same as 06.3)Console snippet provides ~80% of the value at ~5% of the cost; v1 ships without an embedded UIDeferred to Milestone 2

Built for the Shopify Theme Store.