Info Cards and Product Data
SportRx Product Page Info cards 7 Oct 2026

How info cards will use product information

Cards keep their design and wording. Where a card needs a product detail, its text names the product field, and the page fills in that product's value.

What we landed on

One card, many products, each showing its own details

How it connects

A placeholder in the card points at a product field

In a card's Card Data field, a product field's name between three colons on each side is a placeholder. When the page shows the card, it replaces the placeholder with that product's value for the field. Card Data without placeholders works exactly like the description does today.

Card Data, as typed

Lens curve: :::sportrx.base_curve::: base.

The product's field

Base curve: 6

What shoppers see

Lens curve: 6 base.

The card is written once. Every product that has it shows its own number, and if a product's base curve is corrected, its card updates on its own.

An example

The same card on three products

Card wording below is illustrative.

A sport sunglass
Base curve 8 · card on its list
Advanced Base Lens Lens curve: 8 base. Shaped for clear vision from edge to edge.
An everyday eyeglass
Base curve 4 · card on its list
Advanced Base Lens Lens curve: 4 base. Shaped for clear vision from edge to edge.
A ski goggle
No base curve · card not on its list
No base curve card

The goggle never shows a half-filled “Lens curve: base.” card, because it was never given the card. Products without a base curve simply aren't assigned it.

It works the same for any field. If activities ever move into a card, Card Data reading “Perfect for: :::product.list_activities:::” would show each product's own list, e.g. “Perfect for: Biking, Running, Fishing”. We tested exactly this on the staging store.

One card or several

One base curve card can serve every base curve product

Only the placeholder changes from product to product. So one card works for all base curve products as long as everything else on it is the same: the wording around the placeholder, the image, the pop-up and the colours. If any of that needs to differ for some products, duplicate the card, make the change, and give the copy to just those products.

Advanced Base LensInfo Card
Heading
Advanced Base Lens
Card Data
Lens curve: :::sportrx.base_curve::: base. Shaped for clear vision from edge to edge.
Image
lens-curve.jpg
Pop-up Content
How base curve affects fit and vision…
Used on: every product with a base curve, about 2,100 models. Each shows its own number.
Advanced Base Lens – Sport WrapDuplicate
Heading
Advanced Base Lens
Card Data
Lens curve: :::sportrx.base_curve::: base. Wraps the face for wider coverage in motion.
Image
wrap-lens-curve.jpg
Pop-up Content
How base curve affects fit and vision…
Used on: only the wraparound sport frames, in place of the standard card. Highlighted lines are what changed.

Both cards still fill in each product's own base curve. The duplicate exists only because its wording and image are different. Make as few copies as the differences require, since each copy is one more card to keep up to date. Card names and wording here are illustrative.

What decides what shows

Two checks, in order

SituationWhat the shopper sees
The card isn't on the product's card listNot shown
The card list always decides first.
The card is on the list and the product has the valueShown
With the product's value filled in.
The card is on the list, but the product's field is emptyNot shown
A backup. Normally this can't happen, because a product without the detail isn't given the card. It covers a value being removed later.
The card has no placeholders, e.g. Free ShippingShown
Whenever it's on the list, exactly as written.

A question for you: when a card is on a product but that product's field is empty, should the card show general default wording instead of hiding? We've planned for it to hide, because these cards exist to give details specific to that product, and a general card without the product's own detail says little. If there are cards where general wording would still help, tell us which.

Getting started

The import sets up every product once

1. Agree the standard cards

Together we define the set of cards used across many products, with their wording, images and placeholders.

2. Agree who gets which card

Rules by product type, brand or a product detail. For example: every sunglass and eyeglass gets the base curve card, and only products with a base curve.

3. The import places them

The import adds the right cards to every product at once, so nobody assigns cards by hand for the existing catalog.

4. Then it's yours

After launch, the team adds or removes cards on any product freely, and can create one-off cards for unique situations alongside the standard set.

Day to day

Who changes what

Correct a product detail

Edit the product's field, e.g. its base curve. Every card on that product that uses it updates.

Reword a card

Edit the card once. Every product that has it changes.

Add a new kind of card

Create the card, with placeholders for any product details, then add it to the products it belongs on. That can be done in bulk from a spreadsheet.

Add a product

Fill in its fields and give it its cards, typically the same standard cards as similar products.

Handle a special case

Create a one-off card and add it to just the products that need it.

The Card Data field will carry short instructions right below it in the admin, on how to write a placeholder, with an example. Placeholders work the same in the card's pop-up.

For SLTWTR · scoping notes

Technical notes

What this decision commits us to build or change. The one item still marked “confirm” needs checking with the Shopify toolkit during the build.

  1. Token syntax and parsing Tokens are :::namespace.key::: inside the Card Data field (key description) and the pop-up content (item 7). The heading stays plain text. Split the text on :::: odd-indexed parts are tokens, even-indexed parts are literal text. Split each token on . into namespace and key.
    • Tested with the Shopify Liquid gem (5.14.0): tokens at the start, middle or end of the text, the whole text, and back-to-back tokens all keep tokens at odd indexes. A leading delimiter yields an empty first part; a trailing one drops the empty last part. Confirm once on the storefront.
    • Before replacing, count delimiters: (text.size − (text | remove: ':::').size) ÷ 3. An odd count means an unclosed token, so treat the text as malformed. Otherwise Advanced :::sportrx.base_curve Base Lens would read the rest of the sentence as one token.
    • Check each token's shape: exactly one ., no spaces, no :. That catches stray extra colons (::::a:::: splits into :a and :).
    • It has to be our own syntax. Test 1 showed Liquid typed into a metaobject field is never evaluated.
    • The owner object (product, page) comes from context, not from the token, because the namespace product already exists (product.activities, product.list_activities). A token like product.list_activities means namespace product, key list_activities, read from the context object.
    • Malformed tokens (no dot, more than one dot, unclosed :::) are treated as unresolved.
  2. One subject per block: which cards, and whose values The repeater has two inputs that can each be set directly or by dynamic source: product (whose fields placeholders read) and card_list (which cards show). If they point at different things, cards get filled with the wrong product's values, and Liquid can't detect that. Resolve one subject per block and use it for both.
    • Subject:
      • Page templates: the page setting (new), then closest.page, then page.
      • Everything else: the product setting, then closest.product, then product.
      • An explicit setting, dynamic source included, beats closest, so a static pick wins everywhere, including inside product grids. Say so in the setting's help text.
    • Card list: card_list if set, otherwise the subject's pdp_extended_content.info_cards.
    • Values: always the subject's fields.
    • Fixes a mismatch in the prototype: the original repeater resolves the product closest-first (closest, then setting) for the card list, while the prototype reads values setting-first. With product set on a product page, cards come from the current product and values from the chosen one.
    SetupCardsValuesRight?
    Product page, nothing setCurrent product's listCurrent productYes
    Product page, card_list picked by hand (e.g. service cards)The picked cardsCurrent productYes. The cards are templates the product fills in
    product set to a related product (e.g. dynamic source “the frame for this lens”)That product's listThat productYes, because both use the subject
    card_list dynamic-sourced from another object's field, product blankThat object's cardsCurrent productPossibly wrong. Liquid can't see where a dynamic source came from
    Page template, card_list from a page fieldPage's cardsPageYes, with the new page setting
    Page template, a card whose placeholder reads a product fieldPage's cardsPage, which lacks that fieldCard hides, with an editor note
    Repeater inside a product grid or recommendationsEach item's listEach itemYes, via closest.product, unless a static product pick overrides it
    • Guards for the case code can't catch:
      • Convention: when card_list is dynamic-sourced from an object's field, set product or page to that same object.
      • Help text on card_list saying so.
      • Editor label: in design mode, show “Values from: product handle” under the block, so anyone can see which object placeholders read. Shoppers never see it.
    • Pop-up IDs: add the loop index to the pop-up element ID. Block ID plus card handle collides when the same card is listed twice in one block.
    • Other page types: collection and article pages only resolve a subject inside product items for now. closest.collection or closest.metaobject extend the same chain later; request.page_type picks the chain.
    {% liquid
      if request.page_type == 'page'
        assign subject = block.settings.page | default: closest.page | default: page
      else
        assign subject = block.settings.product | default: closest.product | default: product
      endif
      assign info_cards = block.settings.card_list | default: subject.metafields.pdp_extended_content.info_cards.value
      assign owner = subject
      assign parts = text | split: ':::'
      assign out = ''
      assign unresolved = false
      for part in parts
        assign odd = forloop.index0 | modulo: 2
        if odd == 1
          assign nk = part | strip | split: '.'
          assign field = owner.metafields[nk[0]][nk[1]]
          if nk.size != 2 or field == blank
            assign unresolved = true
          endif
          # format field by type (item 3), append to out
        else
          assign out = out | append: part
        endif
      endfor
    %}
  3. Value formatting by field type One snippet resolves and formats a token, called by every slot. It needs to handle:
    • Single-line and multi-line text.
    • Numbers: convert to a whole number (| round) before inserting. Any other wording goes around the placeholder in Card Data. This covers sportrx.base_curve, a number_decimal field whose catalog values are all whole numbers (2, 4, 6, 8). It's the starting rule; revisit if a field ever needs decimals.
    • Lists of text, joined with “, ”.
    • Metaobject references and lists of them, showing a chosen display field (e.g. label), as with filters.frame_materials.
    • Rich text: render with metafield_tag before inserting.
  4. Info card repeater edits (theme/blocks/info-card-repeater.liquid, 95 lines today)
    • Run token replacement on Card Data (description, rich text: render to HTML, then replace) and the pop-up content. The heading is output as is.
    • Skip the card when any token is unresolved: the “field empty” safety net.
    • Keep the existing colour and style handling.
    • Card list: the block's card_list setting (set directly or by dynamic source; page templates such as track-order, prescription-replacement-lenses and Test Run already use it), otherwise the subject's pdp_extended_content.info_cards (item 2). Same order as today, but read from the shared subject.
  5. Editor warnings When request.design_mode is true (theme editor only; confirmed in Shopify's theme editor docs), print a small note under any card with a malformed token, or a token that resolved to nothing on the product being previewed.
    • Shoppers never see it.
    • It can't tell a typo from an empty value, but a typo shows on every product.
    • The preview link (?preview_theme_id=) isn't design mode.
  6. Info Card metaobject definition
    • Rename the description field's display name to Card Data, which is true whether or not it uses placeholders. Keep the key description, so the repeater, existing cards and imports keep working.
    • Put the placeholder instructions in the help text of the Card Data field itself, so they show right below that field in the admin: the :::namespace.key::: format, the base curve example, numbers shown as whole numbers, and that an empty value hides the card.
    • Add a rich text Pop-up Content field with the same help text (item 7).
    • Deprecate and then remove modal_topic (item 7).
    • The allowed-values list idea doesn't apply now that tokens live inside free text. The editor warning and the optional lint (item 9) replace it.
  7. Pop-up with dynamic content: one shared modal, on Neptune's modal and x-html Today the feature cards point at modal topic popup-example, and no modal with that topic exists (repo sections/modals.json and preview theme 166153421036), so the + button opens nothing. Plan: one site-wide modal that every card's pop-up is sent to, built only from existing Neptune pieces.
    • The modal: add one Modal section to the site-wide modals group (sections/modals.json), e.g. topic info-card. Turn on its existing Use X-HTML setting (remote_frame, from the RemoteFrame schema in components/@schemas/remote-frame.js). That wraps the modal's content in an <x-html> frame (id="remote-frame-{section.id}"). Set its Document events (remote_document_events) to a dedicated event, e.g. info-card:popup.
    • The content stays on the card: the repeater renders each card's Pop-up Content, placeholders already resolved by Liquid for that product, into a hidden element with a unique id, e.g. <div hidden id="info-card-popup-{{ block.id }}-{{ entry.system.handle }}">.
    • The + button sends it: on click, dispatch document.dispatchEvent(new CustomEvent('info-card:popup', { detail: { sourceSelector: '#info-card-popup-…' } })), then Modal.open('info-card').
      • The frame's document-event listener passes detail straight into its refresh. With a sourceSelector, x-html runs in proxy mode (renderFromProxySource in assets/x-html.js): it renders that on-page element into the modal, with no network request.
      • A selector that matches nothing fires x-html:error, and the frame refuses to point at itself or anything containing it.
    • Why this shape:
      • One modal for every card on every template.
      • The pop-up gets the same product-resolved placeholders as Card Data, because Liquid resolved them when it rendered the card.
      • No extra fetch.
      • Modal.open already handles history, focus return, Escape and click-outside.
    • Network alternative, if ever needed: the same frame can fetch instead of proxying. Pass src plus sectionId in the event detail, and it loads one section of that URL through Shopify's section rendering, as remote-product-items and recommendations already do with <x-html shopify section-id=…>.
    • To confirm in the build:
      • Whether proxy mode copies a hidden div's children as expected. Avoid <template>, whose content isn't in the live DOM.
      • Whether to open the modal on x-html:load rather than immediately, so the shopper never sees the previous card's content flash.
    • Deprecate modal_topic: the shared modal is the only pop-up path, so one way to make a pop-up avoids confusion.
      • The repeater stops reading the field. The + button shows only when a card has Pop-up Content; an unresolved placeholder in the pop-up drops the pop-up and its button.
      • Every feature card on staging has modal_topic = popup-example, a topic that doesn't exist, so nothing visible is lost.
      • Clear the values in the card import.
      • Then delete the field from the Info Card definition. Deleting it removes its stored values, so do it only after confirming no card still needs one.
      • Unrelated: the replacement-lens-form block has its own modal_topic block setting. That's not this field, and it stays.
  8. Card placement via the migration Card presence is the main control, so the import assigns the standard cards at import time. Ownership passes to the team afterwards.
    • Inputs we need from the client: the static (standard) card set, and assignment rules by product type, brand and data presence.
    • Example: base curve card for sunglasses and eyeglasses with a base_curve value; skip products without one.
    • Add it as an srx-migrate products job. The pdp_extended_content.info_cards column replaces the whole list, so plan from a fresh export and merge any hand-placed cards.
    • Ongoing: new products and later data changes need cards added by hand or by a re-run. The empty-field safety net only covers removal.
  9. Field definitions and access
    • Every field a token reads needs storefront access on its definition, or it resolves empty with no error.
    • Optional lint: compare tokens across all Info Cards against altera ref metafield-definitions products, plus the admin API for storefront access, on demand or on a schedule.
  10. Open decisions
    • The master set of card types the client wants.
    • Card order and Jacob's cap.
    • Empty-field behaviour: hide the card (current plan; card presence is the first control and this is the backup), or show default wording. Asked of the client on the page. Defaults would need a default-text slot per field plus fallback logic, so don't build them unless the client asks.
    • Per-value images: one card per value placed by the migration, e.g. four base-curve cards, if the image must differ by value.
  11. Spike cleanup on staging
    • Info cards spike-liquid-heading, activity-biking and spike-list-activities-source.
    • The product.list_activities definition and its value on 100-blake-test-logan, plus that product's 7th card.
    • The spike block on theme 166153421036.