NewLink Testing catches survey errors before fieldwork.Try it free
Draft — this post is not published to production
Learn

How to build a shelf testing question in Decipher

Build a Decipher shelf test step by step, placing design loading, native answers, CSS and JavaScript in an XML scaffold. Includes explanations and complete final code.

Six granola packs arranged on two grocery shelves in Decipher, with Coconut and Chia selected and its price shown below.

A shelf test asks respondents to choose a product in the context of the products around it. This tutorial builds a two-tier grocery shelf in Decipher, with six fictional granolas, visible prices and a selection that lifts the chosen pack from the shelf. Respondents can change their choice before continuing.

The shelf-testing example on GitHub uses a design file to control placement, prices and availability. A native single-select question stores the answer. The visual shelf is a way to interact with that question.

You will assemble the same complete XML shown at the end. Start with the document scaffold, replace each named insertion point, and follow the explanation beside each section. The screenshot above shows the finished element.

1. Prepare the survey and files

Create a new Decipher survey in testing mode with XML editing and system/static upload access. Download the shelf example files.

UploadLocationPurpose
shelf-design-v1.datRoot/system filesProduct, position, price and availability assignments.
assets/granola-atlas.png as granola-atlas.pngStatic/public filesOne three-column, two-row image containing all six packs.

The server opens the design from the project root; the browser loads the atlas from the public survey asset path. See Forsta's static-file guide. The atlas replaces six separate pack uploads. This synthetic example demonstrates controlled placement; it is not a statistically optimized conjoint design.

2. Start with the XML structure

Open the XML editor of your new test survey. Replace its contents with this scaffold; do not nest it inside another <survey> element.

The outer <style name="respview.client.css"> is a Decipher XML hook. The inner <style> is the HTML stylesheet it emits. Paste CSS rules between that inner pair. <![CDATA[ and ]]> protect characters such as < and & from XML parsing. Keep the backslash in \@media and \@keyframes; these examples escape those rules for Decipher's style processing.

respview.client.js is the JavaScript hook. Its exact wrapper is already in the scaffold below. Both hooks are children of <survey>, alongside the questions. Python belongs in <exec> or <validate> inside the XML, never in the JavaScript hook. Forsta describes the embedding rules in its XML style-system guide.

The BUILD: comments are temporary insertion points. Replace the entire named comment line with its matching code block, once. Keep the surrounding opening and closing tags. Each block below is part of the finished document, with no omitted rows or helper functions. Complete all replacements before saving and running the survey; an unfinished script can refer to functions or variables you have not inserted yet. The final example is an alternative one-paste installation, not another block to append.

Here, mode="after" appends to the normal hook. wrap="ready" makes Decipher supply the JavaScript script tag and document-ready wrapper. Paste the JavaScript inside its CDATA; do not add another HTML <script> there.

Part of the documentJob
CSS and JavaScript hooksRender the shelf and connect it to native inputs.
Initialization and assignmentLoad the design and retain version/session.
Offer matrix and metadata HTMLSave the six-slot assignment and pass it to the browser.
ShelfPick, page break, derived fieldsSave the selected product, then derive its price and location.
<survey
  name="Shelf testing"
  alt="Shelf testing"
  compat="144"
  fir="on"
  html:showNumber="0"
  builder:wizardCompleted="1"
  state="testing"
  ss:enableNavigation="0"
  extraVariables="source"
  autosave="1"
  displayOnError="all"
>
  <style name="respview.client.css" mode="after"><![CDATA[
    <style>
      /* BUILD: SHELF_PAGE */
      /* BUILD: SHELF_RACK */
      /* BUILD: SHELF_TICKETS */
      /* BUILD: SHELF_SUMMARY */
      /* BUILD: SHELF_MEDIUM */
      /* BUILD: SHELF_SMALL */
    </style>
  ]]></style>
  <style name="respview.client.js" mode="after" wrap="ready"><![CDATA[
    (function () {
      'use strict';
      /* BUILD: SHELF_DISCOVERY */
      /* BUILD: SHELF_HELPERS */
      /* BUILD: SHELF_BUILD */
      /* BUILD: SHELF_CHOOSE */
      /* BUILD: SHELF_ACTIVATE */
    })();
  ]]></style>
  <!-- BUILD: SHELF_LOADER -->
  <!-- BUILD: SHELF_ASSIGNMENT -->
  <!-- BUILD: SHELF_OFFERS -->
  <!-- BUILD: SHELF_META -->
  <!-- BUILD: SHELF_QUESTION -->
  <!-- BUILD: SHELF_DERIVED -->
</survey>

Read the opening <survey> settings before adding the component:

SettingWhy it is here
name, altThe survey's name and alternate title. They do not determine answer codes.
compat="144"Retains the source example's compatibility level. Changing it can change the generated markup the script reads.
fir="on"Enables Decipher's form input replacement presentation; the underlying native inputs still exist.
html:showNumber="0"Suppresses displayed question numbers.
builder:wizardCompleted="1"Retains the example's completed setup-wizard flag.
state="testing"Keeps the example in its testing lifecycle.
ss:enableNavigation="0"Does not add Decipher's Back navigation button.
extraVariables="source"Includes the URL variable source in the survey's extra variables.

These are survey settings, separate from the component's own logic. See Forsta's survey-tag reference and compatibility-level reference.

This example also sets autosave="1" to retain partial progress. displayOnError="all" keeps all questions on a page when validation redisplays it.

Read the syntax used in the build

In XML, label gives an element its programmatic identity; value declares an exported answer code. <title> and <comment> provide question text and instructions. <suspend /> ends a page. A where="execute" field runs on the server without being shown to the respondent. optional="0" requires an answer; optional="1" allows a field to be empty. A numeric field's size is its input width, not an allowed-value list.

In JavaScript, querySelector finds one matching element and querySelectorAll finds all matches. A Map or object connects a stable identifier to its native input. data-* HTML attributes become element.dataset properties. forEach visits every entry; map produces a new list; find returns the first match; some and every test whether any or all entries qualify. A guard followed by return stops that function when its prerequisites are absent.

The immediately invoked function, (function () { ... })();, keeps local variables out of the global scope. Its opening and closing punctuation is already in the scaffold; the dots in this sentence are explanatory, not a code block to paste. 'use strict' enables stricter JavaScript checks. Event listeners connect named actions to clicks, keys or pointer movement. textContent inserts plain text; innerHTML builds the example's HTML elements. aria-*, role and tabIndex describe controls and keyboard focus to assistive technology. They supplement the native inputs rather than creating exported answers.

For CSS, read each selector as “which elements” and the declarations inside its braces as “how they look.” margin, padding and gap control space outside, inside and between elements. width, height, min-*, max-* and box-sizing control dimensions. Flex/grid rules arrange children. position, edge offsets and z-index place layers; transform moves or scales them; opacity changes transparency. Fonts, colors, borders, radii and shadows provide the visual treatment. transition animates a property change; animation runs named keyframes. Attribute selectors such as [aria-pressed='true'] style a state the script sets. Media queries apply only at the specified width or motion preference.

3. Build the native survey and data flow

These blocks belong in the XML part of the scaffold, outside the client CSS and JavaScript hooks. Keep Python indentation exactly as shown; Python uses indentation to define its blocks.

1. Load and check the shelf design

Replace the entire BUILD: SHELF_LOADER comment line in the XML scaffold with this block.

Inside the survey-level initialization <exec>, open the file in the survey's system directory, remove blank lines and split remaining lines on tabs. Check the exact eight-column header. For each data record, require eight values, convert them to integers and reject a duplicate version/task/concept key before storing the final five attributes.

shelfOffers() reads slots 1–6 for task 1 of the assigned version. Each dictionary gives the physical slot, stable product ID with a p prefix, shelf, position, price in cents and availability flag. The design has six versions of this single task. Product identity is separate from position: moving product 101 to another slot does not turn it into product 102. 449 is $4.49, and availability is 0 or 1.

  <exec when="init"><![CDATA[
def shelfLoadDesign(fname):
    f = open("%s/%s" % (gv.survey.path, fname))
    records = [line.strip("\r\n").split("\t") for line in f.readlines() if line.strip()]
    f.close()
    if records[0] != ["Version", "Task", "Concept", "Product", "Shelf", "Position", "Price", "Available"]:
        raise ValueError("Unexpected shelf design header")
    design = {}
    for row in records[1:]:
        if len(row) != 8:
            raise ValueError("Invalid shelf design row")
        values = [int(n) for n in row]
        key = "v%s_t%s_c%s" % (values[0],values[1],values[2])
        if key in design:
            raise ValueError("Duplicate shelf design key")
        design[key] = values[3:]
    return design
shelfDesign = shelfLoadDesign("shelf-design-v1.dat")
def shelfOffers(version):
    offers = []
    for slot in range(1,7):
        a = shelfDesign["v%s_t1_c%s" % (version,slot)]
        offers.append({"slot":slot, "product":"p%s" % a[0], "shelf":a[1], "position":a[2], "price":a[3], "available":a[4]})
    return offers
]]></exec>

2. Assign the version and persist the initialization page

Replace the entire BUILD: SHELF_ASSIGNMENT comment line in the XML scaffold with this block.

The three server-only fields store the randomly assigned version 1–6, immutable design ID and random anonymous cache key. Each random assignment is guarded by is None, so revisiting the server flow does not intentionally draw another assignment. size controls field width; the Python ranges define the generated numbers.

The loading HTML carries the native question label in data-label. Its following <suspend /> creates a page boundary. The browser script advances that page with the native Continue button, allowing the assignment to be submitted before shelf selection. autosave="1" retains partial survey progress; displayOnError="all" retains the page's questions when validation redisplays it.

  <number label="ShelfPickDesignVersion" where="execute" optional="1" size="1">
    <title>Assigned shelf design version</title>
    <exec>
if ShelfPickDesignVersion.val is None:
    ShelfPickDesignVersion.val = random.randint(1,6)
</exec>
  </number>
  <text label="ShelfPickDesignID" where="execute" optional="1">
    <title>Immutable shelf design ID</title>
    <exec>ShelfPickDesignID.val = "shelf-20261019-v1"</exec>
  </text>
  <number label="ShelfPickSessionKey" where="execute" optional="1" size="12">
    <title>Anonymous draft cache key</title>
    <exec>
if ShelfPickSessionKey.val is None:
    ShelfPickSessionKey.val = random.randint(100000000,999999999)
</exec>
  </number>
  <html label="ShelfPickStart" where="survey"><div id="shelf-start" data-label="ShelfPick">Loading…</div></html>
  <suspend />

3. Save all six assigned slots in native fields

Replace the entire BUILD: SHELF_OFFERS comment line in the XML scaffold with this block.

The standalone <exec> obtains the selected version's offers and filters available product IDs into p.ShelfPickAvailable. The task field records the fixed task number, 1.

ShelfPickOffers is a six-row number matrix evaluated on the server. Its rows name the physical top/bottom, left/center/right positions, while columns record slot, stable product code, shelf, position, cents and availability. For each offer, subtract one from its slot to index the row list. Assign every column; remove the product's p prefix and convert it to an integer for the numeric product column. This exports the complete assignment, including products the respondent did not choose.

  <exec>
p.ShelfPickOffers = shelfOffers(ShelfPickDesignVersion.val)
p.ShelfPickAvailable = [a['product'] for a in p.ShelfPickOffers if a['available'] == 1]
</exec>
  <number label="ShelfPickTask" where="execute" optional="1" size="1">
    <title>Shelf design task</title>
    <exec>ShelfPickTask.val = 1</exec>
  </number>
  <number label="ShelfPickOffers" where="execute" optional="1" size="5">
    <title>Assigned shelf layout by physical slot; price in USD cents; availability 0 or 1</title>
    <row label="r1">Top shelf, left</row>
    <row label="r2">Top shelf, center</row>
    <row label="r3">Top shelf, right</row>
    <row label="r4">Bottom shelf, left</row>
    <row label="r5">Bottom shelf, center</row>
    <row label="r6">Bottom shelf, right</row>
    <col label="slot">Physical slot</col>
    <col label="product">Stable product code</col>
    <col label="shelf">Shelf number</col>
    <col label="position">Position within shelf</col>
    <col label="price">Price in USD cents</col>
    <col label="available">Available to choose</col>
    <exec>
for a in p.ShelfPickOffers:
    r = ShelfPickOffers.rows[a['slot']-1]
    r.slot.val = a['slot']
    r.product.val = int(a['product'][1:])
    r.shelf.val = a['shelf']
    r.position.val = a['position']
    r.price.val = a['price']
    r.available.val = a['available']
</exec>
  </number>

4. Pass the saved layout to the browser

Replace the entire BUILD: SHELF_META comment line in the XML scaffold with this block.

This <html> element is a sibling of the choice question. The outer metadata element carries the native label, version, design, session and survey asset base. Its six empty spans carry one slot's product, position, cents and availability each.

Every ${...} expression reads a saved native field on the server. Prefixing a numeric product with p reconstructs the browser's inventory key. The attributes are configuration, not visible question text. JavaScript reads them through dataset; it does not generate a different layout. Copy all six spans even when one product is unavailable.

  <html label="ShelfPickMeta" where="survey"><div id="shelf-meta" data-label="ShelfPick" data-version="${ShelfPickDesignVersion.val}" data-design="${ShelfPickDesignID.val}" data-session="${ShelfPickSessionKey.val}" data-assets="/survey/${gv.survey.path}/"><span data-slot="1" data-product="p${ShelfPickOffers.r1.product.val}" data-shelf="${ShelfPickOffers.r1.shelf.val}" data-position="${ShelfPickOffers.r1.position.val}" data-price="${ShelfPickOffers.r1.price.val}" data-available="${ShelfPickOffers.r1.available.val}"></span><span data-slot="2" data-product="p${ShelfPickOffers.r2.product.val}" data-shelf="${ShelfPickOffers.r2.shelf.val}" data-position="${ShelfPickOffers.r2.position.val}" data-price="${ShelfPickOffers.r2.price.val}" data-available="${ShelfPickOffers.r2.available.val}"></span><span data-slot="3" data-product="p${ShelfPickOffers.r3.product.val}" data-shelf="${ShelfPickOffers.r3.shelf.val}" data-position="${ShelfPickOffers.r3.position.val}" data-price="${ShelfPickOffers.r3.price.val}" data-available="${ShelfPickOffers.r3.available.val}"></span><span data-slot="4" data-product="p${ShelfPickOffers.r4.product.val}" data-shelf="${ShelfPickOffers.r4.shelf.val}" data-position="${ShelfPickOffers.r4.position.val}" data-price="${ShelfPickOffers.r4.price.val}" data-available="${ShelfPickOffers.r4.available.val}"></span><span data-slot="5" data-product="p${ShelfPickOffers.r5.product.val}" data-shelf="${ShelfPickOffers.r5.shelf.val}" data-position="${ShelfPickOffers.r5.position.val}" data-price="${ShelfPickOffers.r5.price.val}" data-available="${ShelfPickOffers.r5.available.val}"></span><span data-slot="6" data-product="p${ShelfPickOffers.r6.product.val}" data-shelf="${ShelfPickOffers.r6.shelf.val}" data-position="${ShelfPickOffers.r6.position.val}" data-price="${ShelfPickOffers.r6.price.val}" data-available="${ShelfPickOffers.r6.available.val}"></span></div></html>

5. Create the native product choice and validation

Replace the entire BUILD: SHELF_QUESTION comment line in the XML scaffold with this block.

ShelfPick is a required single-select question. Its six rows have stable labels p101–p106 and exported values 101–106. Each cond includes the row only if that product is in the available list. alt supplies plain row text, and the span's data-product gives the browser a direct product identifier. &amp; is XML's escaped ampersand.

The <validate> block checks that the selected row's label is still available. optional="0" handles a missing answer; this custom check handles an invalid offered choice. The page break is deliberately after the question: the derived fields in the next section need its submitted answer.

  <radio label="ShelfPick" optional="0" ss:questionClassNames="shelf-native">
    <title>Which granola would you choose?</title>
    <comment>Choose one product from the shelf.</comment>
    <row
      label="p101"
      value="101"
      cond="'p101' in p.ShelfPickAvailable"
      alt="Oatfield Honey &amp; Almond"
    ><span data-product="p101">Oatfield Honey &amp; Almond</span></row>
    <row
      label="p102"
      value="102"
      cond="'p102' in p.ShelfPickAvailable"
      alt="Oatfield Maple &amp; Pecan"
    ><span data-product="p102">Oatfield Maple &amp; Pecan</span></row>
    <row
      label="p103"
      value="103"
      cond="'p103' in p.ShelfPickAvailable"
      alt="Oatfield Berry &amp; Seed"
    ><span data-product="p103">Oatfield Berry &amp; Seed</span></row>
    <row
      label="p104"
      value="104"
      cond="'p104' in p.ShelfPickAvailable"
      alt="Morning Folk Cocoa &amp; Hazelnut"
    ><span data-product="p104">Morning Folk Cocoa &amp; Hazelnut</span></row>
    <row
      label="p105"
      value="105"
      cond="'p105' in p.ShelfPickAvailable"
      alt="Morning Folk Coconut &amp; Chia"
    ><span data-product="p105">Morning Folk Coconut &amp; Chia</span></row>
    <row
      label="p106"
      value="106"
      cond="'p106' in p.ShelfPickAvailable"
      alt="Morning Folk Apple &amp; Cinnamon"
    ><span data-product="p106">Morning Folk Apple &amp; Cinnamon</span></row>
    <validate>
if ShelfPick.val is not None:
    if ShelfPick.selected.label not in p.ShelfPickAvailable:
        error("Please choose an available product from the shelf.")
</validate>
  </radio>
  <suspend />

6. Derive the selected price and position after the page break

Replace the entire BUILD: SHELF_DERIVED comment line in the XML scaffold with this block.

ShelfPickProductID saves the selected native row label, such as p105. Each list comprehension scans the same offers used to display the shelf, filters by that label, and takes the matching value with [0]. The price field divides cents by 100.0 to store dollars as a float. The remaining number fields save slot, shelf and position.

This relies on a valid selected product and a design with one matching offer. The final samplesources block retains the repository's open-survey source: list zero is the default, completed supplies the already-completed message, and the qualified exit supplies the recorded-response message. These messages do not derive data or select a product.

  <text label="ShelfPickProductID" where="execute" optional="1">
    <title>Chosen stable product ID</title>
    <exec>ShelfPickProductID.val = ShelfPick.selected.label</exec>
  </text>
  <float label="ShelfPickPrice" where="execute" optional="1" size="6">
    <title>Chosen product price in USD</title>
    <exec>ShelfPickPrice.val = [a['price'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0] / 100.0</exec>
  </float>
  <number label="ShelfPickSlot" where="execute" optional="1" size="1">
    <title>Chosen product physical slot</title>
    <exec>ShelfPickSlot.val = [a['slot'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <number label="ShelfPickShelf" where="execute" optional="1" size="1">
    <title>Chosen product shelf</title>
    <exec>ShelfPickShelf.val = [a['shelf'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <number label="ShelfPickPosition" where="execute" optional="1" size="1">
    <title>Chosen product position within shelf</title>
    <exec>ShelfPickPosition.val = [a['position'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <samplesources default="0">
    <samplesource list="0">
      <title>Open survey</title>
      <completed>You have already completed this study.</completed>
      <exit cond="qualified">Your responses have been recorded.</exit>
    </samplesource>
  </samplesources>

4. Fill the styling hook

Fill the named markers inside respview.client.css in the scaffold. CSS blocks replace comments between the inner HTML <style> tags. Where this example includes a head script, its marker is explicitly inside <script>.

1. Prepare the custom page and its heading

Replace the entire BUILD: SHELF_PAGE comment line in the CSS hook’s inner <style> with this block.

Rules scoped to shelf-ready remove the native page framing and hide its original presentation only after JavaScript adds that class. The custom .sh-ui has its own sizing, typography and button focus treatment. Heading and instruction rules set the text above the shelf.

The metadata wrapper is hidden along with the native shelf question, original buttons and survey-level error banner. The script later copies native question errors into its custom alert. The underlying inputs remain in the form for native validation and submission.

      body.shelf-ready {
        margin: 0;
        background: #f5f3ed;
        color: #243c31;
        font-family: Arial, Helvetica, sans-serif;
        -webkit-font-smoothing: antialiased;
      }
      .shelf-ready #survey,
      .shelf-ready .survey-section,
      .shelf-ready .survey-body {
        max-width: none;
        width: auto;
        margin: 0;
        padding: 0;
        border: 0;
        border-radius: 0;
        background: transparent;
        box-shadow: none;
      }
      .shelf-ready .footer,
      .shelf-ready .survey-error,
      .shelf-ready .survey-header,
      .shelf-ready .page-header,
      .shelf-ready .survey-footer,
      .shelf-ready .survey-buttons,
      .shelf-ready .survey-info,
      .shelf-ready .shelf-native,
      .shelf-ready .shelf-meta-wrap {
        display: none !important;
      }
      .sh-ui {
        max-width: 1110px;
        margin: auto;
        padding: 30px 40px 26px;
        box-sizing: border-box;
      }
      .sh-ui * {
        box-sizing: border-box;
      }
      .sh-ui button {
        font: inherit;
        -webkit-tap-highlight-color: transparent;
      }
      .sh-ui button:focus-visible {
        outline: 3px solid #265d48;
        outline-offset: 4px;
      }
      .sh-heading {
        margin-bottom: 25px;
      }
      .sh-heading h1 {
        font-size: clamp(25px, 3vw, 34px);
        line-height: 1.18;
        letter-spacing: -1.1px;
        font-weight: 500;
        margin: 0 0 10px;
      }
      .sh-heading p {
        font-size: 13px;
        line-height: 1.5;
        color: #6d766e;
        margin: 0;
      }

2. Draw the rack and crop the product atlas

Replace the entire BUILD: SHELF_RACK comment line in the CSS hook’s inner <style> with this block.

The rack uses gradients, borders and shadows to suggest the shelf cabinet. Its pseudo-element supplies a background detail. Each tier is a three-column grid with a pseudo-element for the shelf edge. Slots contain a button, floor shadow and two decorative pack layers.

.sh-pack uses the shared atlas URL and background-size: 300% 200%: the image contains three columns and two rows. JavaScript sets background-position for the product's fixed sprite cell. The back/front transforms create depth; hover and [aria-checked='true'] bring the selected front pack forward. A separate check marker appears only for that checked state. Atlas coordinates describe artwork, independently of the assigned physical shelf slot.

      .sh-rack {
        position: relative;
        padding: 11px 18px 0;
        background: linear-gradient(
          90deg,
          #c3b8a4 0%,
          #ebe6da 1.1%,
          #e8e1d4 2%,
          #eee8db 98%,
          #c9beab 99%,
          #b9ad99 100%
        );
        border: 1px solid #c7bead;
        border-radius: 9px 9px 4px 4px;
        box-shadow:
          0 16px 26px -20px #41352555,
          0 2px 3px #4b3c2810;
        isolation: isolate;
      }
      .sh-rack:before {
        content: '';
        position: absolute;
        inset: 9px 16px 0;
        background:
          radial-gradient(circle, #b8ae9b80 0.7px, transparent 1.1px) 2px 2px/13px 13px,
          linear-gradient(90deg, #d3c8b6, #e8decd 18%, #e8decd 82%, #d1c5b1);
        z-index: -1;
        box-shadow: inset 0 15px 25px #9e8f7226;
      }
      .sh-tier {
        position: relative;
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 0;
        padding-top: 10px;
      }
      .sh-tier:after {
        content: '';
        position: absolute;
        bottom: 0;
        left: -10px;
        right: -10px;
        height: 47px;
        background: linear-gradient(
          #ddd5c5 0,
          #fffdf5 3px,
          #efeadf 6px,
          #d9cfbc 8px,
          #f7f4ed 9px,
          #e9e3d6 96%,
          #b8ab94
        );
        border-top: 1px solid #c6b99f;
        border-bottom: 2px solid #b8aa93;
        box-shadow: 0 9px 12px #5c4a2e26;
        z-index: 1;
        pointer-events: none;
      }
      .sh-slot {
        position: relative;
        min-width: 0;
        height: 246px;
      }
      .sh-product {
        position: relative;
        width: 100%;
        height: 199px;
        display: block;
        border: 0;
        background: none;
        cursor: pointer;
        padding: 0;
        z-index: 2;
        color: #233c31;
        touch-action: manipulation;
      }
      .sh-product:focus-visible {
        outline-offset: -6px;
        border-radius: 8px;
      }
      .sh-floor-shadow {
        position: absolute;
        left: 13%;
        right: 13%;
        bottom: 1px;
        height: 17px;
        background: radial-gradient(ellipse, #4d3c2770, transparent 70%);
        filter: blur(3px);
        transition: opacity 0.4s;
      }
      .sh-pack {
        position: absolute;
        width: 208px;
        height: 208px;
        bottom: 0;
        left: calc(50% - 132px);
        background-image: var(--sh-atlas);
        background-size: 300% 200%;
        background-repeat: no-repeat;
        pointer-events: none;
        transform-origin: 50% 92%;
        filter: drop-shadow(2px 4px 2px #41362133);
        transition:
          transform 0.55s cubic-bezier(0.2, 0.85, 0.25, 1.25),
          filter 0.4s,
          opacity 0.4s;
        will-change: transform;
      }
      .sh-pack.back {
        left: calc(50% - 67px);
        transform: translateY(-3px) scale(0.96);
        filter: brightness(0.89) drop-shadow(5px 3px 2px #41362135);
        z-index: 0;
      }
      .sh-pack.front {
        z-index: 1;
      }
      .sh-product:hover .sh-pack.front {
        transform: translateY(-8px) rotate(-1deg);
        filter: drop-shadow(4px 12px 6px #41362133);
      }
      .sh-product[aria-checked='true'] {
        z-index: 4;
      }
      .sh-product[aria-checked='true'] .sh-pack.front {
        transform: translate(-5px, -19px) scale(1.11) rotate(-3deg);
        filter: drop-shadow(7px 17px 8px #4136214a);
      }
      .sh-product[aria-checked='true'] .sh-pack.back {
        transform: translate(5px, -2px) scale(0.96);
      }
      .sh-check {
        position: absolute;
        right: calc(50% - 92px);
        top: 1px;
        width: 27px;
        height: 27px;
        border-radius: 50%;
        display: grid;
        place-items: center;
        background: #274e3d;
        color: white;
        box-shadow: 0 2px 0 #fff9;
        font-size: 15px;
        opacity: 0;
        transform: scale(0.5);
        transition:
          opacity 0.25s,
          transform 0.35s cubic-bezier(0.2, 0.85, 0.25, 1.4);
        z-index: 5;
      }
      .sh-product[aria-checked='true'] .sh-check {
        opacity: 1;
        transform: scale(1);
      }

3. Add price tickets and unavailable products

Replace the entire BUILD: SHELF_TICKETS comment line in the CSS hook’s inner <style> with this block.

Ticket rules position a compact label beneath each pack, divide name/size from price, and draw a decorative barcode with a repeating gradient. Separate price and currency rules establish hierarchy. The ticket is hidden from assistive technology because the button's accessible name already includes product, size and price.

Unavailable slots use .sh-empty, dim their decorative packs and display an Unavailable badge. They are not radio buttons, so neither keyboard navigation nor click selection can choose them. Their metadata still appears in the saved offer matrix.

      .sh-ticket {
        position: absolute;
        bottom: 6px;
        left: 12%;
        right: 12%;
        height: 33px;
        border: 1px solid #d2cdc1;
        box-shadow: 0 1px 2px #55493115;
        background: #fffef9;
        border-radius: 2px;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 3px 9px;
        z-index: 2;
        color: #303931;
      }
      .sh-ticket-copy {
        min-width: 0;
        padding-right: 4px;
      }
      .sh-ticket-name {
        font-size: 9px;
        font-weight: 600;
        line-height: 1.1;
        white-space: nowrap;
        text-transform: uppercase;
        letter-spacing: 0.35px;
      }
      .sh-ticket-size {
        font-size: 7px;
        color: #7a7c73;
        line-height: 1.4;
        margin-top: 2px;
      }
      .sh-barcode {
        display: inline-block;
        width: 28px;
        height: 7px;
        margin-left: 6px;
        background: repeating-linear-gradient(
          90deg,
          #353b32 0,
          #353b32 1px,
          transparent 1px,
          transparent 2px,
          #353b32 2px,
          #353b32 4px,
          transparent 4px,
          transparent 6px
        );
        vertical-align: middle;
        opacity: 0.7;
      }
      .sh-price {
        font-size: 20px;
        font-weight: 600;
        letter-spacing: -0.8px;
        white-space: nowrap;
        line-height: 1;
      }
      .sh-price small {
        font-size: 11px;
        vertical-align: top;
        line-height: 1.4;
        letter-spacing: 0;
        margin-right: 1px;
      }
      .sh-empty {
        height: 199px;
        position: relative;
      }
      .sh-empty .sh-pack {
        opacity: 0.14;
        filter: grayscale(1);
        transform: scale(0.92);
        left: calc(50% - 104px);
      }
      .sh-unavailable {
        position: absolute;
        left: 50%;
        top: 51%;
        transform: translate(-50%, -50%);
        font-size: 10px;
        line-height: 1.3;
        white-space: nowrap;
        color: #786e5a;
        padding: 7px 9px;
        border: 1px solid #b9ae99;
        background: #e5deceee;
        border-radius: 3px;
        z-index: 2;
      }

4. Style selection feedback, Continue and loading

Replace the entire BUILD: SHELF_SUMMARY comment line in the CSS hook’s inner <style> with this block.

The bottom flex layout joins a selected-product summary to Continue. The summary's thumbnail uses the same atlas crop; has-choice reveals the chosen treatment. Its title/detail typography communicates product and price. Continue has hover, pressed and disabled states.

The error paragraph disappears only when empty, while .sh-sr retains live announcements in a clipped box. shelf-boot and #shelf-start style the short initialization page; that state is separate from shelf-ready on the choice page.

      .sh-bottom {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 20px;
        margin-top: 22px;
        min-height: 56px;
      }
      .sh-selection {
        display: flex;
        align-items: center;
        gap: 11px;
        min-height: 51px;
        min-width: 0;
      }
      .sh-selection-thumb {
        width: 47px;
        height: 47px;
        flex: none;
        background-image: var(--sh-atlas);
        background-size: 300% 200%;
        background-repeat: no-repeat;
        opacity: 0;
        transform: translateY(4px);
        transition:
          opacity 0.2s,
          transform 0.3s;
      }
      .sh-selection.has-choice .sh-selection-thumb {
        opacity: 1;
        transform: translateY(0);
      }
      .sh-selection-title {
        font-size: 13px;
        line-height: 1.4;
        font-weight: 500;
        margin: 0;
        color: #2c4437;
      }
      .sh-selection-detail {
        font-size: 11px;
        line-height: 1.5;
        color: #7b8075;
        margin: 2px 0 0;
      }
      .sh-selection:not(.has-choice) .sh-selection-thumb {
        display: none;
      }
      .sh-next {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 34px;
        border: 1px solid #294735;
        border-radius: 7px;
        padding: 16px 20px;
        background: #294735;
        color: #fff;
        cursor: pointer;
        min-width: 181px;
        min-height: 52px;
        font-size: 13px !important;
        transition:
          background 0.2s,
          transform 0.2s;
        flex: none;
      }
      .sh-next:hover {
        background: #3b5a46;
      }
      .sh-next:active {
        transform: scale(0.985);
      }
      .sh-next:disabled {
        opacity: 0.6;
        cursor: wait;
      }
      .sh-next-arrow {
        font-size: 20px;
        line-height: 1;
      }
      .sh-error {
        color: #a13d2d;
        font-size: 12px;
        line-height: 1.5;
        margin: 12px 0 0;
      }
      .sh-error:empty {
        display: none;
      }
      .sh-sr {
        position: absolute;
        width: 1px;
        height: 1px;
        margin: -1px;
        padding: 0;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
      body.shelf-boot {
        background: #f5f3ed;
      }
      .shelf-boot #survey {
        visibility: hidden;
      }
      #shelf-start {
        visibility: visible;
        padding: 30px;
        color: #52634e;
        font:
          14px Arial,
          sans-serif;
      }

5. Resize the rack for wide and medium displays

Replace the entire BUILD: SHELF_MEDIUM comment line in the CSS hook’s inner <style> with this block.

At 1400 pixels and above, the rack and pack dimensions expand. At 760 pixels and below, the rack, tiers, slots, pack sizes, tickets, prices, summary and Continue contract together. The three-column physical shelf layout remains intact.

These are coordinated overrides of the same selectors, so retain their order after the base rules. Do not change only the pack height without checking the ticket and shelf-edge positions that accompany it.

      \@media (min-width: 1400px) {
        .sh-ui {
          padding-top: 36px;
        }
        .sh-slot {
          height: 273px;
        }
        .sh-product,
        .sh-empty {
          height: 226px;
        }
        .sh-pack {
          width: 235px;
          height: 235px;
          left: calc(50% - 147px);
        }
        .sh-pack.back {
          left: calc(50% - 80px);
        }
        .sh-check {
          top: 0;
          right: calc(50% - 104px);
        }
      }
      \@media (max-width: 760px) {
        .sh-ui {
          padding: 23px 20px 25px;
        }
        .sh-heading {
          margin-bottom: 23px;
        }
        .sh-heading h1 {
          font-size: 28px;
          letter-spacing: -0.8px;
        }
        .sh-heading p {
          font-size: 12px;
        }
        .sh-rack {
          padding: 10px 10px 0;
        }
        .sh-rack:before {
          inset: 8px 9px 0;
        }
        .sh-tier {
          padding-top: 16px;
        }
        .sh-slot {
          height: 202px;
        }
        .sh-product,
        .sh-empty {
          height: 159px;
        }
        .sh-pack {
          width: 174px;
          height: 174px;
          left: calc(50% - 108px);
        }
        .sh-pack.back {
          left: calc(50% - 60px);
        }
        .sh-tier:after {
          height: 43px;
          left: -5px;
          right: -5px;
        }
        .sh-ticket {
          left: 5%;
          right: 5%;
          height: 30px;
          bottom: 5px;
          padding: 3px 6px;
        }
        .sh-ticket-name {
          font-size: 7px;
          letter-spacing: 0;
        }
        .sh-ticket-size {
          font-size: 6px;
        }
        .sh-price {
          font-size: 18px;
        }
        .sh-price small {
          font-size: 9px;
        }
        .sh-check {
          right: calc(50% - 68px);
          width: 23px;
          height: 23px;
          font-size: 13px;
          top: 0;
        }
        .sh-bottom {
          margin-top: 18px;
          gap: 12px;
        }
        .sh-next {
          min-width: 155px;
          padding: 15px 17px;
        }
        .sh-selection-title {
          font-size: 12px;
        }
        .sh-selection-detail {
          font-size: 10px;
        }
      }

6. Keep all six slots usable on small screens

Replace the entire BUILD: SHELF_SMALL comment line in the CSS hook’s inner <style> with this block.

The 500-pixel query further adjusts heading size, cabinet padding, product layers, tickets and the bottom controls. The 360-pixel query tightens the smallest layout again. Each block changes dimensions and spacing without changing which products are offered.

The final reduced-motion query removes animations, transitions and smooth scrolling. It removes the hover transform and keeps a smaller static selected transform of translateY(-5px) scale(1.04). The check and native state still identify the answer without animated movement.

      \@media (max-width: 500px) {
        .sh-ui {
          padding: 23px 14px 24px;
        }
        .sh-heading {
          margin-bottom: 24px;
        }
        .sh-heading h1 {
          font-size: 27px;
          max-width: 320px;
          line-height: 1.15;
        }
        .sh-heading p {
          font-size: 11px;
          line-height: 1.6;
          max-width: 340px;
        }
        .sh-rack {
          padding: 9px 6px 0;
        }
        .sh-rack:before {
          inset: 8px 5px 0;
          background-size:
            11px 11px,
            auto;
        }
        .sh-tier {
          padding-top: 21px;
        }
        .sh-slot {
          height: 181px;
        }
        .sh-product,
        .sh-empty {
          height: 140px;
        }
        .sh-pack {
          width: 151px;
          height: 151px;
          left: calc(50% - 75.5px);
        }
        .sh-pack.back {
          display: none;
        }
        .sh-product[aria-checked='true'] .sh-pack.front {
          transform: translateY(-12px) scale(1.1) rotate(-3deg);
        }
        .sh-product:hover .sh-pack.front {
          transform: translateY(-4px);
        }
        .sh-product[aria-checked='true']:hover .sh-pack.front {
          transform: translateY(-12px) scale(1.1) rotate(-3deg);
        }
        .sh-ticket {
          left: 3px;
          right: 3px;
          padding: 3px 5px;
          height: 30px;
        }
        .sh-ticket-name {
          font-size: 6px;
          white-space: normal;
          line-height: 1.2;
          max-width: 58px;
        }
        .sh-price {
          font-size: 17px;
        }
        .sh-price small {
          font-size: 8px;
        }
        .sh-ticket-size {
          font-size: 6px;
        }
        .sh-barcode {
          display: none;
        }
        .sh-tier:after {
          height: 41px;
          left: -3px;
          right: -3px;
        }
        .sh-floor-shadow {
          left: 5%;
          right: 5%;
          bottom: 0;
        }
        .sh-check {
          width: 21px;
          height: 21px;
          font-size: 12px;
          right: 5px;
          top: -5px;
        }
        .sh-unavailable {
          font-size: 8px;
          padding: 5px;
        }
        .sh-bottom {
          margin-top: 15px;
          align-items: stretch;
          flex-direction: column;
          gap: 13px;
        }
        .sh-selection {
          min-height: 44px;
          gap: 8px;
        }
        .sh-selection-thumb {
          width: 43px;
          height: 43px;
        }
        .sh-selection-title {
          font-size: 12px;
        }
        .sh-selection-detail {
          font-size: 10px;
        }
        .sh-next {
          width: 100%;
          min-height: 51px;
          padding: 15px 17px;
          font-size: 12px !important;
        }
        .sh-error {
          font-size: 11px;
          margin-top: 10px;
        }
        .sh-empty .sh-pack {
          left: calc(50% - 75.5px);
        }
      }
      \@media (max-width: 360px) {
        .sh-ui {
          padding-left: 9px;
          padding-right: 9px;
        }
        .sh-slot {
          height: 164px;
        }
        .sh-product,
        .sh-empty {
          height: 123px;
        }
        .sh-pack {
          width: 124px;
          height: 124px;
          left: calc(50% - 62px);
        }
        .sh-empty .sh-pack {
          left: calc(50% - 62px);
        }
        .sh-ticket-name {
          font-size: 5.5px;
        }
        .sh-price {
          font-size: 15px;
        }
        .sh-heading h1 {
          font-size: 25px;
        }
      }
      \@media (prefers-reduced-motion: reduce) {
        .sh-ui * {
          animation: none !important;
          transition: none !important;
          scroll-behavior: auto !important;
        }
        .sh-product:hover .sh-pack.front {
          transform: none;
        }
        .sh-product[aria-checked='true'] .sh-pack.front {
          transform: translateY(-5px) scale(1.04);
        }
      }

5. Fill the JavaScript hook

Fill the named markers inside respview.client.js. Keep the scaffold’s function openings, closing braces and ready wrapper. The sections share variables in those existing scopes; do not wrap each pasted section in a new function.

1. Read the catalogue, offers and native inputs

Replace the entire BUILD: SHELF_DISCOVERY comment line in the JavaScript hook with this block.

If this is the loading page, add shelf-boot, schedule a native Continue click after 30 milliseconds and return. Otherwise find the metadata, Continue form and native question using its label, with .shelf-native as a fallback.

The six inventory objects associate stable IDs with names, brands, flavors and fixed sprite indices 0–5. Build an ID lookup, then convert the six metadata spans' numeric strings into numbers. Map each native input by its label's data-product; matching its exact text is a fallback. Before constructing the shelf, require six offers, known products, integer slots/prices, and a native input for every available offer. An unavailable product need not have a selectable native row.

      var boot = document.getElementById('shelf-start');
      if (boot) {
        document.body.classList.add('shelf-boot');
        var initial = document.getElementById('btn_continue');
        if (initial)
          setTimeout(function () {
            initial.click();
          }, 30);
        return;
      }
      var meta = document.getElementById('shelf-meta');
      if (!meta) return;
      var next = document.getElementById('btn_continue'),
        form = next && next.form;
      var question =
        document.getElementById('question_' + meta.dataset.label) ||
        document.querySelector('.shelf-native');
      if (!next || !form || !question) return;
      var inventory = [
          {
            id: 'p101',
            name: 'Oatfield Honey & Almond',
            brand: 'Oatfield',
            flavor: 'Honey & Almond',
            sprite: 0
          },
          {
            id: 'p102',
            name: 'Oatfield Maple & Pecan',
            brand: 'Oatfield',
            flavor: 'Maple & Pecan',
            sprite: 1
          },
          {
            id: 'p103',
            name: 'Oatfield Berry & Seed',
            brand: 'Oatfield',
            flavor: 'Berry & Seed',
            sprite: 2
          },
          {
            id: 'p104',
            name: 'Morning Folk Cocoa & Hazelnut',
            brand: 'Morning Folk',
            flavor: 'Cocoa & Hazelnut',
            sprite: 3
          },
          {
            id: 'p105',
            name: 'Morning Folk Coconut & Chia',
            brand: 'Morning Folk',
            flavor: 'Coconut & Chia',
            sprite: 4
          },
          {
            id: 'p106',
            name: 'Morning Folk Apple & Cinnamon',
            brand: 'Morning Folk',
            flavor: 'Apple & Cinnamon',
            sprite: 5
          }
        ],
        items = {},
        inputs = {},
        offers;
      inventory.forEach(function (p) {
        items[p.id] = p;
      });
      offers = Array.from(meta.querySelectorAll('[data-slot]')).map(function (e) {
        return {
          slot: Number(e.dataset.slot),
          product: e.dataset.product,
          shelf: Number(e.dataset.shelf),
          position: Number(e.dataset.position),
          price: Number(e.dataset.price),
          available: Number(e.dataset.available)
        };
      });
      Array.from(question.querySelectorAll('input[type="radio"]')).forEach(function (input) {
        var label = question.querySelector('label[for="' + input.id + '"]'),
          tag = label && label.querySelector('[data-product]');
        var product = tag ? tag.dataset.product : null;
        if (!product && label) {
          var known = inventory.find(function (p) {
            return p.name === label.textContent.trim();
          });
          if (known) product = known.id;
        }
        if (product) inputs[product] = input;
      });
      if (
        !Array.isArray(offers) ||
        offers.length !== 6 ||
        offers.some(function (o) {
          return (
            !items[o.product] ||
            !Number.isInteger(o.slot) ||
            !Number.isInteger(o.price) ||
            (o.available === 1 && !inputs[o.product])
          );
        })
      )
        return;

2. Create the UI shell and formatting helpers

Replace the entire BUILD: SHELF_HELPERS comment line in the JavaScript hook with this block.

The draft key combines survey path, component label, response session, design and version, preventing a choice from another assignment from being reused. selected starts empty and submitted guards subsequent interaction. money() converts cents to a two-decimal dollar string.

position() converts a product's fixed sprite number to atlas percentages: indices 0, 1, 2 use horizontal 0%, 50%, 100% on the top row; 3, 4, 5 use the same horizontal positions on the bottom row. esc() replaces five HTML-sensitive characters before inserting names into generated markup.

Set the --sh-atlas CSS variable to the survey asset URL. The generated main element contains a labeled radio group, choice summary, Continue, an alert and a polite status. Store references to those elements for later updates.

      var key =
        'shelf:' +
        location.pathname +
        ':' +
        meta.dataset.label +
        ':' +
        meta.dataset.session +
        ':' +
        meta.dataset.design +
        ':' +
        meta.dataset.version;
      var selected = null,
        submitted = false,
        ui = document.createElement('main');
      var money = function (cents) {
        return '$' + (cents / 100).toFixed(2);
      };
      var position = function (product) {
        var s = items[product].sprite;
        return (s % 3) * 50 + '% ' + (s < 3 ? 0 : 100) + '%';
      };
      var esc = function (s) {
        return String(s).replace(/[&<>"']/g, function (c) {
          return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
        });
      };
      ui.className = 'sh-ui';
      ui.setAttribute('aria-label', 'Shelf selection');
      ui.style.setProperty(
        '--sh-atlas',
        'url("' + meta.dataset.assets + 'granola-atlas.png")'
      );
      ui.innerHTML =
        '<header class="sh-heading"><h1>Which granola would you choose?</h1><p>Select one product from the shelf. You can change your choice before continuing.</p></header><div class="sh-rack" role="radiogroup" aria-label="Granola products" aria-required="true" aria-describedby="sh-error"></div><div class="sh-bottom"><div class="sh-selection"><div class="sh-selection-thumb" aria-hidden="true"></div><div><p class="sh-selection-title">Choose a product from the shelf</p><p class="sh-selection-detail">All packs 350 g · Prices in USD</p></div></div><button type="button" class="sh-next"><span>Continue</span><span class="sh-next-arrow" aria-hidden="true">→</span></button></div><p class="sh-error" id="sh-error" role="alert"></p><span class="sh-sr" role="status" aria-live="polite"></span>';
      var rack = ui.querySelector('.sh-rack'),
        error = ui.querySelector('.sh-error'),
        status = ui.querySelector('[role="status"]'),
        submit = ui.querySelector('.sh-next');

3. Render two tiers in design order

Replace the entire BUILD: SHELF_BUILD comment line in the JavaScript hook with this block.

Create tiers for shelf numbers 1 and 2. Filter offers to the current shelf and sort by position before creating each slot. Store both slot and product IDs on it. Build the back/front pack layers with the product's atlas crop.

Available offers get a type="button" control with radio role, unchecked state, product ID and an escaped accessible name containing product, grams and price. It initially has tabindex="-1"; update() chooses the tabbable item later. Unavailable offers get a noninteractive element instead. Finally add the visible price ticket and append the slot to its tier. All six assigned slots are rendered, including unavailable ones.

      [1, 2].forEach(function (level) {
        var tier = document.createElement('div');
        tier.className = 'sh-tier';
        tier.setAttribute('role', 'presentation');
        rack.appendChild(tier);
        offers
          .filter(function (o) {
            return o.shelf === level;
          })
          .sort(function (a, b) {
            return a.position - b.position;
          })
          .forEach(function (o) {
            var p = items[o.product],
              slot = document.createElement('div');
            slot.className = 'sh-slot';
            slot.dataset.slot = o.slot;
            slot.dataset.product = o.product;
            var packs =
              '<i class="sh-floor-shadow" aria-hidden="true"></i><i class="sh-pack back" aria-hidden="true" style="background-position:' +
              position(p.id) +
              '"></i><i class="sh-pack front" aria-hidden="true" style="background-position:' +
              position(p.id) +
              '"></i>';
            if (o.available === 1) {
              slot.innerHTML =
                '<button class="sh-product" type="button" role="radio" aria-checked="false" data-product="' +
                p.id +
                '" aria-label="' +
                esc(p.name + ', 350 grams, ' + money(o.price)) +
                '" tabindex="-1">' +
                packs +
                '<span class="sh-check" aria-hidden="true">✓</span></button>';
            } else {
              slot.innerHTML =
                '<div class="sh-empty" aria-label="' +
                esc(p.name + ' unavailable') +
                '">' +
                packs +
                '<span class="sh-unavailable">Unavailable</span></div>';
            }
            slot.innerHTML +=
              '<div class="sh-ticket" aria-hidden="true"><div class="sh-ticket-copy"><div class="sh-ticket-name">' +
              esc(p.flavor) +
              '</div><div class="sh-ticket-size">350 g<span class="sh-barcode"></span></div></div><div class="sh-price"><small>$</small>' +
              (o.price / 100).toFixed(2) +
              '</div></div>';
            tier.appendChild(slot);
          });
      });

4. Synchronize native selection, display and keyboard controls

Replace the entire BUILD: SHELF_CHOOSE comment line in the JavaScript hook with this block.

update() sets every button's aria-checked and gives only the selected button—or initially the first—tab index zero. It updates the summary text, price and thumbnail; optional live announcements describe the selection.

choose() rejects interaction after submission, a missing native input or an unavailable product. It clicks the native input, records the selected ID, clears the validation display, saves a same-tab draft if storage works, and redraws. A keyboard choice also focuses the chosen product.

Click handlers choose their product. Left/Right move one position in the available-button list; Up/Down move three, wrapping with modulo arithmetic. Home and End choose the first/last available button. Because unavailable slots are absent from that button list, this is list-based navigation, not a geometric search for the nearest pack.

      var buttons = Array.from(ui.querySelectorAll('.sh-product'));
      function update(announce) {
        buttons.forEach(function (b, i) {
          var checked = b.dataset.product === selected;
          b.setAttribute('aria-checked', checked ? 'true' : 'false');
          b.tabIndex = selected ? (checked ? 0 : -1) : i === 0 ? 0 : -1;
        });
        var p = selected && items[selected],
          o = offers.find(function (x) {
            return x.product === selected;
          });
        var summary = ui.querySelector('.sh-selection');
        summary.classList.toggle('has-choice', !!p);
        ui.querySelector('.sh-selection-title').textContent = p
          ? p.name
          : 'Choose a product from the shelf';
        ui.querySelector('.sh-selection-detail').textContent = p
          ? money(o.price) + ' · 350 g'
          : 'All packs 350 g · Prices in USD';
        if (p)
          ui.querySelector('.sh-selection-thumb').style.backgroundPosition = position(p.id);
        if (announce && p)
          status.textContent = p.name + ', ' + money(o.price) + ', selected.';
      }
      function choose(id, focus) {
        if (
          submitted ||
          !inputs[id] ||
          !offers.some(function (o) {
            return o.product === id && o.available === 1;
          })
        )
          return;
        inputs[id].click();
        selected = id;
        error.textContent = '';
        rack.removeAttribute('aria-invalid');
        try {
          sessionStorage.setItem(key, id);
        } catch (e) {}
        update(true);
        if (focus)
          buttons
            .find(function (b) {
              return b.dataset.product === id;
            })
            .focus();
      }
      buttons.forEach(function (b) {
        b.addEventListener('click', function () {
          choose(b.dataset.product, false);
        });
        b.addEventListener('keydown', function (e) {
          var direction = { ArrowLeft: -1, ArrowUp: -3, ArrowRight: 1, ArrowDown: 3 }[e.key],
            index = buttons.indexOf(b),
            target;
          if (direction) {
            e.preventDefault();
            target = (index + direction + buttons.length * 2) % buttons.length;
            choose(buttons[target].dataset.product, true);
          } else if (e.key === 'Home' || e.key === 'End') {
            e.preventDefault();
            choose(buttons[e.key === 'Home' ? 0 : buttons.length - 1].dataset.product, true);
          }
        });
      });

5. Restore a choice and submit through Decipher

Replace the entire BUILD: SHELF_ACTIVATE comment line in the JavaScript hook with this block.

Prefer a checked native input; otherwise read the cache. Accept a restored ID only if a native input exists for it, click it and redraw. Insert the finished UI before metadata, mark its wrapper, then add shelf-ready to hide the original presentation. Read native question errors from a redisplayed page and show them when no choice is restored.

The custom Continue handler guards a second click, disables itself and product controls, and clicks the original native Continue. The form's submit event records submission too. Native required-answer and availability validation remain responsible for accepting the response. In your test survey, explicitly check that a validation redisplay restores usable controls; the script does not implement its own server submission endpoint or a separate answer store.

      var restored = Object.keys(inputs).find(function (id) {
        return inputs[id].checked;
      });
      if (!restored) {
        try {
          restored = sessionStorage.getItem(key);
        } catch (e) {}
      }
      if (restored && inputs[restored]) {
        inputs[restored].click();
        selected = restored;
      }
      update(false);
      // Only hide the native form after every offered item has a native input and the UI is ready.
      meta.closest('.html').insertAdjacentElement('beforebegin', ui);
      meta.closest('.html').classList.add('shelf-meta-wrap');
      document.body.classList.add('shelf-ready');
      var nativeErrors = Array.from(document.querySelectorAll('.question-error'))
        .map(function (e) {
          return e.textContent.trim();
        })
        .filter(Boolean);
      if (nativeErrors.length && !selected) {
        error.textContent = nativeErrors.join(' ');
        rack.setAttribute('aria-invalid', 'true');
      }
      submit.addEventListener('click', function () {
        if (submitted) return;
        submitted = true;
        submit.disabled = true;
        buttons.forEach(function (b) {
          b.disabled = true;
        });
        next.click();
      });
      form.addEventListener('submit', function () {
        submitted = true;
      });

6. Verify the assembled survey

Search your XML for BUILD:. There should be no insertion markers left. Check that there is one outer <survey>, one CSS hook and one JavaScript hook, then save in Decipher and resolve any XML errors before opening the test link. The complete example below is the reference for comparing your assembled document.

Save the XML and open your survey's test link. Select a product, change it, then Continue. In a separate test, try Continue without a choice and confirm that the validation message leaves the shelf usable.

Export fieldWhat to verify
ShelfPickThe chosen product's code, 101–106
ShelfPickProductIDThe same product with its p prefix, such as p105
ShelfPickDesignVersionThe version whose rows you compare in the .dat
ShelfPickOffers...All six slots match that version's products, positions, prices and availability
ShelfPickPriceThe selected offer's cents divided by 100
ShelfPickSlot, ShelfPickShelf, ShelfPickPositionThe selected product's assigned location

Check all six versions as part of your survey's QA, including the unavailable products in versions 5 and 6. Do not assume there is a URL override for version selection. Use your team's controlled testing process to cover the assignments without changing the fielded allocation logic.

Also check phone-width layout, keyboard selection, same-tab refresh and repeated Continue clicks. The repository reports native completion checks for versions 3 and 5; the remaining versions, physical phones and additional browsers still deserve checks in your own account.

If you replace the granolas, update the design codes, native row labels, embedded inventory, image atlas and price copy together. Keep one shelf instance per page because the interface uses fixed DOM IDs.

Reuse the component with Questra Fine Tuning

Add the component to a Questra Fine Tuning template so Questra can create shelf testing elements for you and your team. The bundled template export already includes the shared CSS/JavaScript, ShelfTesting component, design file and atlas image.

Import it from Fine Tuning and select the template when programming. Request ShelfTesting in main with {"label":"ShelfPick"}. Use a unique valid native label for each instance and retain the one-shelf-per-page rule. The label prefixes the native metadata and selected-product fields, keeping the generated survey's data understandable for the whole team.

Complete example XML

After uploading the files from step 1, you can paste this entire document into a new test survey instead of assembling the sections individually. Replacing every scaffold marker with its matching block produces this exact XML. Do not append it to the document you have already assembled.

This is the formatted complete source at revision 109fa76. The component-examples repository contains the matching assets, design files where applicable, and Fine Tuning templates.

<survey
  name="Shelf testing"
  alt="Shelf testing"
  compat="144"
  fir="on"
  html:showNumber="0"
  builder:wizardCompleted="1"
  state="testing"
  ss:enableNavigation="0"
  extraVariables="source"
  autosave="1"
  displayOnError="all"
>
  <style name="respview.client.css" mode="after"><![CDATA[
    <style>
      body.shelf-ready {
        margin: 0;
        background: #f5f3ed;
        color: #243c31;
        font-family: Arial, Helvetica, sans-serif;
        -webkit-font-smoothing: antialiased;
      }
      .shelf-ready #survey,
      .shelf-ready .survey-section,
      .shelf-ready .survey-body {
        max-width: none;
        width: auto;
        margin: 0;
        padding: 0;
        border: 0;
        border-radius: 0;
        background: transparent;
        box-shadow: none;
      }
      .shelf-ready .footer,
      .shelf-ready .survey-error,
      .shelf-ready .survey-header,
      .shelf-ready .page-header,
      .shelf-ready .survey-footer,
      .shelf-ready .survey-buttons,
      .shelf-ready .survey-info,
      .shelf-ready .shelf-native,
      .shelf-ready .shelf-meta-wrap {
        display: none !important;
      }
      .sh-ui {
        max-width: 1110px;
        margin: auto;
        padding: 30px 40px 26px;
        box-sizing: border-box;
      }
      .sh-ui * {
        box-sizing: border-box;
      }
      .sh-ui button {
        font: inherit;
        -webkit-tap-highlight-color: transparent;
      }
      .sh-ui button:focus-visible {
        outline: 3px solid #265d48;
        outline-offset: 4px;
      }
      .sh-heading {
        margin-bottom: 25px;
      }
      .sh-heading h1 {
        font-size: clamp(25px, 3vw, 34px);
        line-height: 1.18;
        letter-spacing: -1.1px;
        font-weight: 500;
        margin: 0 0 10px;
      }
      .sh-heading p {
        font-size: 13px;
        line-height: 1.5;
        color: #6d766e;
        margin: 0;
      }
      .sh-rack {
        position: relative;
        padding: 11px 18px 0;
        background: linear-gradient(
          90deg,
          #c3b8a4 0%,
          #ebe6da 1.1%,
          #e8e1d4 2%,
          #eee8db 98%,
          #c9beab 99%,
          #b9ad99 100%
        );
        border: 1px solid #c7bead;
        border-radius: 9px 9px 4px 4px;
        box-shadow:
          0 16px 26px -20px #41352555,
          0 2px 3px #4b3c2810;
        isolation: isolate;
      }
      .sh-rack:before {
        content: '';
        position: absolute;
        inset: 9px 16px 0;
        background:
          radial-gradient(circle, #b8ae9b80 0.7px, transparent 1.1px) 2px 2px/13px 13px,
          linear-gradient(90deg, #d3c8b6, #e8decd 18%, #e8decd 82%, #d1c5b1);
        z-index: -1;
        box-shadow: inset 0 15px 25px #9e8f7226;
      }
      .sh-tier {
        position: relative;
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 0;
        padding-top: 10px;
      }
      .sh-tier:after {
        content: '';
        position: absolute;
        bottom: 0;
        left: -10px;
        right: -10px;
        height: 47px;
        background: linear-gradient(
          #ddd5c5 0,
          #fffdf5 3px,
          #efeadf 6px,
          #d9cfbc 8px,
          #f7f4ed 9px,
          #e9e3d6 96%,
          #b8ab94
        );
        border-top: 1px solid #c6b99f;
        border-bottom: 2px solid #b8aa93;
        box-shadow: 0 9px 12px #5c4a2e26;
        z-index: 1;
        pointer-events: none;
      }
      .sh-slot {
        position: relative;
        min-width: 0;
        height: 246px;
      }
      .sh-product {
        position: relative;
        width: 100%;
        height: 199px;
        display: block;
        border: 0;
        background: none;
        cursor: pointer;
        padding: 0;
        z-index: 2;
        color: #233c31;
        touch-action: manipulation;
      }
      .sh-product:focus-visible {
        outline-offset: -6px;
        border-radius: 8px;
      }
      .sh-floor-shadow {
        position: absolute;
        left: 13%;
        right: 13%;
        bottom: 1px;
        height: 17px;
        background: radial-gradient(ellipse, #4d3c2770, transparent 70%);
        filter: blur(3px);
        transition: opacity 0.4s;
      }
      .sh-pack {
        position: absolute;
        width: 208px;
        height: 208px;
        bottom: 0;
        left: calc(50% - 132px);
        background-image: var(--sh-atlas);
        background-size: 300% 200%;
        background-repeat: no-repeat;
        pointer-events: none;
        transform-origin: 50% 92%;
        filter: drop-shadow(2px 4px 2px #41362133);
        transition:
          transform 0.55s cubic-bezier(0.2, 0.85, 0.25, 1.25),
          filter 0.4s,
          opacity 0.4s;
        will-change: transform;
      }
      .sh-pack.back {
        left: calc(50% - 67px);
        transform: translateY(-3px) scale(0.96);
        filter: brightness(0.89) drop-shadow(5px 3px 2px #41362135);
        z-index: 0;
      }
      .sh-pack.front {
        z-index: 1;
      }
      .sh-product:hover .sh-pack.front {
        transform: translateY(-8px) rotate(-1deg);
        filter: drop-shadow(4px 12px 6px #41362133);
      }
      .sh-product[aria-checked='true'] {
        z-index: 4;
      }
      .sh-product[aria-checked='true'] .sh-pack.front {
        transform: translate(-5px, -19px) scale(1.11) rotate(-3deg);
        filter: drop-shadow(7px 17px 8px #4136214a);
      }
      .sh-product[aria-checked='true'] .sh-pack.back {
        transform: translate(5px, -2px) scale(0.96);
      }
      .sh-check {
        position: absolute;
        right: calc(50% - 92px);
        top: 1px;
        width: 27px;
        height: 27px;
        border-radius: 50%;
        display: grid;
        place-items: center;
        background: #274e3d;
        color: white;
        box-shadow: 0 2px 0 #fff9;
        font-size: 15px;
        opacity: 0;
        transform: scale(0.5);
        transition:
          opacity 0.25s,
          transform 0.35s cubic-bezier(0.2, 0.85, 0.25, 1.4);
        z-index: 5;
      }
      .sh-product[aria-checked='true'] .sh-check {
        opacity: 1;
        transform: scale(1);
      }
      .sh-ticket {
        position: absolute;
        bottom: 6px;
        left: 12%;
        right: 12%;
        height: 33px;
        border: 1px solid #d2cdc1;
        box-shadow: 0 1px 2px #55493115;
        background: #fffef9;
        border-radius: 2px;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 3px 9px;
        z-index: 2;
        color: #303931;
      }
      .sh-ticket-copy {
        min-width: 0;
        padding-right: 4px;
      }
      .sh-ticket-name {
        font-size: 9px;
        font-weight: 600;
        line-height: 1.1;
        white-space: nowrap;
        text-transform: uppercase;
        letter-spacing: 0.35px;
      }
      .sh-ticket-size {
        font-size: 7px;
        color: #7a7c73;
        line-height: 1.4;
        margin-top: 2px;
      }
      .sh-barcode {
        display: inline-block;
        width: 28px;
        height: 7px;
        margin-left: 6px;
        background: repeating-linear-gradient(
          90deg,
          #353b32 0,
          #353b32 1px,
          transparent 1px,
          transparent 2px,
          #353b32 2px,
          #353b32 4px,
          transparent 4px,
          transparent 6px
        );
        vertical-align: middle;
        opacity: 0.7;
      }
      .sh-price {
        font-size: 20px;
        font-weight: 600;
        letter-spacing: -0.8px;
        white-space: nowrap;
        line-height: 1;
      }
      .sh-price small {
        font-size: 11px;
        vertical-align: top;
        line-height: 1.4;
        letter-spacing: 0;
        margin-right: 1px;
      }
      .sh-empty {
        height: 199px;
        position: relative;
      }
      .sh-empty .sh-pack {
        opacity: 0.14;
        filter: grayscale(1);
        transform: scale(0.92);
        left: calc(50% - 104px);
      }
      .sh-unavailable {
        position: absolute;
        left: 50%;
        top: 51%;
        transform: translate(-50%, -50%);
        font-size: 10px;
        line-height: 1.3;
        white-space: nowrap;
        color: #786e5a;
        padding: 7px 9px;
        border: 1px solid #b9ae99;
        background: #e5deceee;
        border-radius: 3px;
        z-index: 2;
      }
      .sh-bottom {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 20px;
        margin-top: 22px;
        min-height: 56px;
      }
      .sh-selection {
        display: flex;
        align-items: center;
        gap: 11px;
        min-height: 51px;
        min-width: 0;
      }
      .sh-selection-thumb {
        width: 47px;
        height: 47px;
        flex: none;
        background-image: var(--sh-atlas);
        background-size: 300% 200%;
        background-repeat: no-repeat;
        opacity: 0;
        transform: translateY(4px);
        transition:
          opacity 0.2s,
          transform 0.3s;
      }
      .sh-selection.has-choice .sh-selection-thumb {
        opacity: 1;
        transform: translateY(0);
      }
      .sh-selection-title {
        font-size: 13px;
        line-height: 1.4;
        font-weight: 500;
        margin: 0;
        color: #2c4437;
      }
      .sh-selection-detail {
        font-size: 11px;
        line-height: 1.5;
        color: #7b8075;
        margin: 2px 0 0;
      }
      .sh-selection:not(.has-choice) .sh-selection-thumb {
        display: none;
      }
      .sh-next {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 34px;
        border: 1px solid #294735;
        border-radius: 7px;
        padding: 16px 20px;
        background: #294735;
        color: #fff;
        cursor: pointer;
        min-width: 181px;
        min-height: 52px;
        font-size: 13px !important;
        transition:
          background 0.2s,
          transform 0.2s;
        flex: none;
      }
      .sh-next:hover {
        background: #3b5a46;
      }
      .sh-next:active {
        transform: scale(0.985);
      }
      .sh-next:disabled {
        opacity: 0.6;
        cursor: wait;
      }
      .sh-next-arrow {
        font-size: 20px;
        line-height: 1;
      }
      .sh-error {
        color: #a13d2d;
        font-size: 12px;
        line-height: 1.5;
        margin: 12px 0 0;
      }
      .sh-error:empty {
        display: none;
      }
      .sh-sr {
        position: absolute;
        width: 1px;
        height: 1px;
        margin: -1px;
        padding: 0;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
      body.shelf-boot {
        background: #f5f3ed;
      }
      .shelf-boot #survey {
        visibility: hidden;
      }
      #shelf-start {
        visibility: visible;
        padding: 30px;
        color: #52634e;
        font:
          14px Arial,
          sans-serif;
      }
      \@media (min-width: 1400px) {
        .sh-ui {
          padding-top: 36px;
        }
        .sh-slot {
          height: 273px;
        }
        .sh-product,
        .sh-empty {
          height: 226px;
        }
        .sh-pack {
          width: 235px;
          height: 235px;
          left: calc(50% - 147px);
        }
        .sh-pack.back {
          left: calc(50% - 80px);
        }
        .sh-check {
          top: 0;
          right: calc(50% - 104px);
        }
      }
      \@media (max-width: 760px) {
        .sh-ui {
          padding: 23px 20px 25px;
        }
        .sh-heading {
          margin-bottom: 23px;
        }
        .sh-heading h1 {
          font-size: 28px;
          letter-spacing: -0.8px;
        }
        .sh-heading p {
          font-size: 12px;
        }
        .sh-rack {
          padding: 10px 10px 0;
        }
        .sh-rack:before {
          inset: 8px 9px 0;
        }
        .sh-tier {
          padding-top: 16px;
        }
        .sh-slot {
          height: 202px;
        }
        .sh-product,
        .sh-empty {
          height: 159px;
        }
        .sh-pack {
          width: 174px;
          height: 174px;
          left: calc(50% - 108px);
        }
        .sh-pack.back {
          left: calc(50% - 60px);
        }
        .sh-tier:after {
          height: 43px;
          left: -5px;
          right: -5px;
        }
        .sh-ticket {
          left: 5%;
          right: 5%;
          height: 30px;
          bottom: 5px;
          padding: 3px 6px;
        }
        .sh-ticket-name {
          font-size: 7px;
          letter-spacing: 0;
        }
        .sh-ticket-size {
          font-size: 6px;
        }
        .sh-price {
          font-size: 18px;
        }
        .sh-price small {
          font-size: 9px;
        }
        .sh-check {
          right: calc(50% - 68px);
          width: 23px;
          height: 23px;
          font-size: 13px;
          top: 0;
        }
        .sh-bottom {
          margin-top: 18px;
          gap: 12px;
        }
        .sh-next {
          min-width: 155px;
          padding: 15px 17px;
        }
        .sh-selection-title {
          font-size: 12px;
        }
        .sh-selection-detail {
          font-size: 10px;
        }
      }
      \@media (max-width: 500px) {
        .sh-ui {
          padding: 23px 14px 24px;
        }
        .sh-heading {
          margin-bottom: 24px;
        }
        .sh-heading h1 {
          font-size: 27px;
          max-width: 320px;
          line-height: 1.15;
        }
        .sh-heading p {
          font-size: 11px;
          line-height: 1.6;
          max-width: 340px;
        }
        .sh-rack {
          padding: 9px 6px 0;
        }
        .sh-rack:before {
          inset: 8px 5px 0;
          background-size:
            11px 11px,
            auto;
        }
        .sh-tier {
          padding-top: 21px;
        }
        .sh-slot {
          height: 181px;
        }
        .sh-product,
        .sh-empty {
          height: 140px;
        }
        .sh-pack {
          width: 151px;
          height: 151px;
          left: calc(50% - 75.5px);
        }
        .sh-pack.back {
          display: none;
        }
        .sh-product[aria-checked='true'] .sh-pack.front {
          transform: translateY(-12px) scale(1.1) rotate(-3deg);
        }
        .sh-product:hover .sh-pack.front {
          transform: translateY(-4px);
        }
        .sh-product[aria-checked='true']:hover .sh-pack.front {
          transform: translateY(-12px) scale(1.1) rotate(-3deg);
        }
        .sh-ticket {
          left: 3px;
          right: 3px;
          padding: 3px 5px;
          height: 30px;
        }
        .sh-ticket-name {
          font-size: 6px;
          white-space: normal;
          line-height: 1.2;
          max-width: 58px;
        }
        .sh-price {
          font-size: 17px;
        }
        .sh-price small {
          font-size: 8px;
        }
        .sh-ticket-size {
          font-size: 6px;
        }
        .sh-barcode {
          display: none;
        }
        .sh-tier:after {
          height: 41px;
          left: -3px;
          right: -3px;
        }
        .sh-floor-shadow {
          left: 5%;
          right: 5%;
          bottom: 0;
        }
        .sh-check {
          width: 21px;
          height: 21px;
          font-size: 12px;
          right: 5px;
          top: -5px;
        }
        .sh-unavailable {
          font-size: 8px;
          padding: 5px;
        }
        .sh-bottom {
          margin-top: 15px;
          align-items: stretch;
          flex-direction: column;
          gap: 13px;
        }
        .sh-selection {
          min-height: 44px;
          gap: 8px;
        }
        .sh-selection-thumb {
          width: 43px;
          height: 43px;
        }
        .sh-selection-title {
          font-size: 12px;
        }
        .sh-selection-detail {
          font-size: 10px;
        }
        .sh-next {
          width: 100%;
          min-height: 51px;
          padding: 15px 17px;
          font-size: 12px !important;
        }
        .sh-error {
          font-size: 11px;
          margin-top: 10px;
        }
        .sh-empty .sh-pack {
          left: calc(50% - 75.5px);
        }
      }
      \@media (max-width: 360px) {
        .sh-ui {
          padding-left: 9px;
          padding-right: 9px;
        }
        .sh-slot {
          height: 164px;
        }
        .sh-product,
        .sh-empty {
          height: 123px;
        }
        .sh-pack {
          width: 124px;
          height: 124px;
          left: calc(50% - 62px);
        }
        .sh-empty .sh-pack {
          left: calc(50% - 62px);
        }
        .sh-ticket-name {
          font-size: 5.5px;
        }
        .sh-price {
          font-size: 15px;
        }
        .sh-heading h1 {
          font-size: 25px;
        }
      }
      \@media (prefers-reduced-motion: reduce) {
        .sh-ui * {
          animation: none !important;
          transition: none !important;
          scroll-behavior: auto !important;
        }
        .sh-product:hover .sh-pack.front {
          transform: none;
        }
        .sh-product[aria-checked='true'] .sh-pack.front {
          transform: translateY(-5px) scale(1.04);
        }
      }
    </style>
  ]]></style>
  <style name="respview.client.js" mode="after" wrap="ready"><![CDATA[
    (function () {
      'use strict';
      var boot = document.getElementById('shelf-start');
      if (boot) {
        document.body.classList.add('shelf-boot');
        var initial = document.getElementById('btn_continue');
        if (initial)
          setTimeout(function () {
            initial.click();
          }, 30);
        return;
      }
      var meta = document.getElementById('shelf-meta');
      if (!meta) return;
      var next = document.getElementById('btn_continue'),
        form = next && next.form;
      var question =
        document.getElementById('question_' + meta.dataset.label) ||
        document.querySelector('.shelf-native');
      if (!next || !form || !question) return;
      var inventory = [
          {
            id: 'p101',
            name: 'Oatfield Honey & Almond',
            brand: 'Oatfield',
            flavor: 'Honey & Almond',
            sprite: 0
          },
          {
            id: 'p102',
            name: 'Oatfield Maple & Pecan',
            brand: 'Oatfield',
            flavor: 'Maple & Pecan',
            sprite: 1
          },
          {
            id: 'p103',
            name: 'Oatfield Berry & Seed',
            brand: 'Oatfield',
            flavor: 'Berry & Seed',
            sprite: 2
          },
          {
            id: 'p104',
            name: 'Morning Folk Cocoa & Hazelnut',
            brand: 'Morning Folk',
            flavor: 'Cocoa & Hazelnut',
            sprite: 3
          },
          {
            id: 'p105',
            name: 'Morning Folk Coconut & Chia',
            brand: 'Morning Folk',
            flavor: 'Coconut & Chia',
            sprite: 4
          },
          {
            id: 'p106',
            name: 'Morning Folk Apple & Cinnamon',
            brand: 'Morning Folk',
            flavor: 'Apple & Cinnamon',
            sprite: 5
          }
        ],
        items = {},
        inputs = {},
        offers;
      inventory.forEach(function (p) {
        items[p.id] = p;
      });
      offers = Array.from(meta.querySelectorAll('[data-slot]')).map(function (e) {
        return {
          slot: Number(e.dataset.slot),
          product: e.dataset.product,
          shelf: Number(e.dataset.shelf),
          position: Number(e.dataset.position),
          price: Number(e.dataset.price),
          available: Number(e.dataset.available)
        };
      });
      Array.from(question.querySelectorAll('input[type="radio"]')).forEach(function (input) {
        var label = question.querySelector('label[for="' + input.id + '"]'),
          tag = label && label.querySelector('[data-product]');
        var product = tag ? tag.dataset.product : null;
        if (!product && label) {
          var known = inventory.find(function (p) {
            return p.name === label.textContent.trim();
          });
          if (known) product = known.id;
        }
        if (product) inputs[product] = input;
      });
      if (
        !Array.isArray(offers) ||
        offers.length !== 6 ||
        offers.some(function (o) {
          return (
            !items[o.product] ||
            !Number.isInteger(o.slot) ||
            !Number.isInteger(o.price) ||
            (o.available === 1 && !inputs[o.product])
          );
        })
      )
        return;
      var key =
        'shelf:' +
        location.pathname +
        ':' +
        meta.dataset.label +
        ':' +
        meta.dataset.session +
        ':' +
        meta.dataset.design +
        ':' +
        meta.dataset.version;
      var selected = null,
        submitted = false,
        ui = document.createElement('main');
      var money = function (cents) {
        return '$' + (cents / 100).toFixed(2);
      };
      var position = function (product) {
        var s = items[product].sprite;
        return (s % 3) * 50 + '% ' + (s < 3 ? 0 : 100) + '%';
      };
      var esc = function (s) {
        return String(s).replace(/[&<>"']/g, function (c) {
          return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
        });
      };
      ui.className = 'sh-ui';
      ui.setAttribute('aria-label', 'Shelf selection');
      ui.style.setProperty(
        '--sh-atlas',
        'url("' + meta.dataset.assets + 'granola-atlas.png")'
      );
      ui.innerHTML =
        '<header class="sh-heading"><h1>Which granola would you choose?</h1><p>Select one product from the shelf. You can change your choice before continuing.</p></header><div class="sh-rack" role="radiogroup" aria-label="Granola products" aria-required="true" aria-describedby="sh-error"></div><div class="sh-bottom"><div class="sh-selection"><div class="sh-selection-thumb" aria-hidden="true"></div><div><p class="sh-selection-title">Choose a product from the shelf</p><p class="sh-selection-detail">All packs 350 g · Prices in USD</p></div></div><button type="button" class="sh-next"><span>Continue</span><span class="sh-next-arrow" aria-hidden="true">→</span></button></div><p class="sh-error" id="sh-error" role="alert"></p><span class="sh-sr" role="status" aria-live="polite"></span>';
      var rack = ui.querySelector('.sh-rack'),
        error = ui.querySelector('.sh-error'),
        status = ui.querySelector('[role="status"]'),
        submit = ui.querySelector('.sh-next');
      [1, 2].forEach(function (level) {
        var tier = document.createElement('div');
        tier.className = 'sh-tier';
        tier.setAttribute('role', 'presentation');
        rack.appendChild(tier);
        offers
          .filter(function (o) {
            return o.shelf === level;
          })
          .sort(function (a, b) {
            return a.position - b.position;
          })
          .forEach(function (o) {
            var p = items[o.product],
              slot = document.createElement('div');
            slot.className = 'sh-slot';
            slot.dataset.slot = o.slot;
            slot.dataset.product = o.product;
            var packs =
              '<i class="sh-floor-shadow" aria-hidden="true"></i><i class="sh-pack back" aria-hidden="true" style="background-position:' +
              position(p.id) +
              '"></i><i class="sh-pack front" aria-hidden="true" style="background-position:' +
              position(p.id) +
              '"></i>';
            if (o.available === 1) {
              slot.innerHTML =
                '<button class="sh-product" type="button" role="radio" aria-checked="false" data-product="' +
                p.id +
                '" aria-label="' +
                esc(p.name + ', 350 grams, ' + money(o.price)) +
                '" tabindex="-1">' +
                packs +
                '<span class="sh-check" aria-hidden="true">✓</span></button>';
            } else {
              slot.innerHTML =
                '<div class="sh-empty" aria-label="' +
                esc(p.name + ' unavailable') +
                '">' +
                packs +
                '<span class="sh-unavailable">Unavailable</span></div>';
            }
            slot.innerHTML +=
              '<div class="sh-ticket" aria-hidden="true"><div class="sh-ticket-copy"><div class="sh-ticket-name">' +
              esc(p.flavor) +
              '</div><div class="sh-ticket-size">350 g<span class="sh-barcode"></span></div></div><div class="sh-price"><small>$</small>' +
              (o.price / 100).toFixed(2) +
              '</div></div>';
            tier.appendChild(slot);
          });
      });
      var buttons = Array.from(ui.querySelectorAll('.sh-product'));
      function update(announce) {
        buttons.forEach(function (b, i) {
          var checked = b.dataset.product === selected;
          b.setAttribute('aria-checked', checked ? 'true' : 'false');
          b.tabIndex = selected ? (checked ? 0 : -1) : i === 0 ? 0 : -1;
        });
        var p = selected && items[selected],
          o = offers.find(function (x) {
            return x.product === selected;
          });
        var summary = ui.querySelector('.sh-selection');
        summary.classList.toggle('has-choice', !!p);
        ui.querySelector('.sh-selection-title').textContent = p
          ? p.name
          : 'Choose a product from the shelf';
        ui.querySelector('.sh-selection-detail').textContent = p
          ? money(o.price) + ' · 350 g'
          : 'All packs 350 g · Prices in USD';
        if (p)
          ui.querySelector('.sh-selection-thumb').style.backgroundPosition = position(p.id);
        if (announce && p)
          status.textContent = p.name + ', ' + money(o.price) + ', selected.';
      }
      function choose(id, focus) {
        if (
          submitted ||
          !inputs[id] ||
          !offers.some(function (o) {
            return o.product === id && o.available === 1;
          })
        )
          return;
        inputs[id].click();
        selected = id;
        error.textContent = '';
        rack.removeAttribute('aria-invalid');
        try {
          sessionStorage.setItem(key, id);
        } catch (e) {}
        update(true);
        if (focus)
          buttons
            .find(function (b) {
              return b.dataset.product === id;
            })
            .focus();
      }
      buttons.forEach(function (b) {
        b.addEventListener('click', function () {
          choose(b.dataset.product, false);
        });
        b.addEventListener('keydown', function (e) {
          var direction = { ArrowLeft: -1, ArrowUp: -3, ArrowRight: 1, ArrowDown: 3 }[e.key],
            index = buttons.indexOf(b),
            target;
          if (direction) {
            e.preventDefault();
            target = (index + direction + buttons.length * 2) % buttons.length;
            choose(buttons[target].dataset.product, true);
          } else if (e.key === 'Home' || e.key === 'End') {
            e.preventDefault();
            choose(buttons[e.key === 'Home' ? 0 : buttons.length - 1].dataset.product, true);
          }
        });
      });
      var restored = Object.keys(inputs).find(function (id) {
        return inputs[id].checked;
      });
      if (!restored) {
        try {
          restored = sessionStorage.getItem(key);
        } catch (e) {}
      }
      if (restored && inputs[restored]) {
        inputs[restored].click();
        selected = restored;
      }
      update(false);
      // Only hide the native form after every offered item has a native input and the UI is ready.
      meta.closest('.html').insertAdjacentElement('beforebegin', ui);
      meta.closest('.html').classList.add('shelf-meta-wrap');
      document.body.classList.add('shelf-ready');
      var nativeErrors = Array.from(document.querySelectorAll('.question-error'))
        .map(function (e) {
          return e.textContent.trim();
        })
        .filter(Boolean);
      if (nativeErrors.length && !selected) {
        error.textContent = nativeErrors.join(' ');
        rack.setAttribute('aria-invalid', 'true');
      }
      submit.addEventListener('click', function () {
        if (submitted) return;
        submitted = true;
        submit.disabled = true;
        buttons.forEach(function (b) {
          b.disabled = true;
        });
        next.click();
      });
      form.addEventListener('submit', function () {
        submitted = true;
      });
    })();
  ]]></style>
  <exec when="init"><![CDATA[
def shelfLoadDesign(fname):
    f = open("%s/%s" % (gv.survey.path, fname))
    records = [line.strip("\r\n").split("\t") for line in f.readlines() if line.strip()]
    f.close()
    if records[0] != ["Version", "Task", "Concept", "Product", "Shelf", "Position", "Price", "Available"]:
        raise ValueError("Unexpected shelf design header")
    design = {}
    for row in records[1:]:
        if len(row) != 8:
            raise ValueError("Invalid shelf design row")
        values = [int(n) for n in row]
        key = "v%s_t%s_c%s" % (values[0],values[1],values[2])
        if key in design:
            raise ValueError("Duplicate shelf design key")
        design[key] = values[3:]
    return design
shelfDesign = shelfLoadDesign("shelf-design-v1.dat")
def shelfOffers(version):
    offers = []
    for slot in range(1,7):
        a = shelfDesign["v%s_t1_c%s" % (version,slot)]
        offers.append({"slot":slot, "product":"p%s" % a[0], "shelf":a[1], "position":a[2], "price":a[3], "available":a[4]})
    return offers
]]></exec>
  <number label="ShelfPickDesignVersion" where="execute" optional="1" size="1">
    <title>Assigned shelf design version</title>
    <exec>
if ShelfPickDesignVersion.val is None:
    ShelfPickDesignVersion.val = random.randint(1,6)
</exec>
  </number>
  <text label="ShelfPickDesignID" where="execute" optional="1">
    <title>Immutable shelf design ID</title>
    <exec>ShelfPickDesignID.val = "shelf-20261019-v1"</exec>
  </text>
  <number label="ShelfPickSessionKey" where="execute" optional="1" size="12">
    <title>Anonymous draft cache key</title>
    <exec>
if ShelfPickSessionKey.val is None:
    ShelfPickSessionKey.val = random.randint(100000000,999999999)
</exec>
  </number>
  <html label="ShelfPickStart" where="survey"><div id="shelf-start" data-label="ShelfPick">Loading…</div></html>
  <suspend />
  <exec>
p.ShelfPickOffers = shelfOffers(ShelfPickDesignVersion.val)
p.ShelfPickAvailable = [a['product'] for a in p.ShelfPickOffers if a['available'] == 1]
</exec>
  <number label="ShelfPickTask" where="execute" optional="1" size="1">
    <title>Shelf design task</title>
    <exec>ShelfPickTask.val = 1</exec>
  </number>
  <number label="ShelfPickOffers" where="execute" optional="1" size="5">
    <title>Assigned shelf layout by physical slot; price in USD cents; availability 0 or 1</title>
    <row label="r1">Top shelf, left</row>
    <row label="r2">Top shelf, center</row>
    <row label="r3">Top shelf, right</row>
    <row label="r4">Bottom shelf, left</row>
    <row label="r5">Bottom shelf, center</row>
    <row label="r6">Bottom shelf, right</row>
    <col label="slot">Physical slot</col>
    <col label="product">Stable product code</col>
    <col label="shelf">Shelf number</col>
    <col label="position">Position within shelf</col>
    <col label="price">Price in USD cents</col>
    <col label="available">Available to choose</col>
    <exec>
for a in p.ShelfPickOffers:
    r = ShelfPickOffers.rows[a['slot']-1]
    r.slot.val = a['slot']
    r.product.val = int(a['product'][1:])
    r.shelf.val = a['shelf']
    r.position.val = a['position']
    r.price.val = a['price']
    r.available.val = a['available']
</exec>
  </number>
  <html label="ShelfPickMeta" where="survey"><div id="shelf-meta" data-label="ShelfPick" data-version="${ShelfPickDesignVersion.val}" data-design="${ShelfPickDesignID.val}" data-session="${ShelfPickSessionKey.val}" data-assets="/survey/${gv.survey.path}/"><span data-slot="1" data-product="p${ShelfPickOffers.r1.product.val}" data-shelf="${ShelfPickOffers.r1.shelf.val}" data-position="${ShelfPickOffers.r1.position.val}" data-price="${ShelfPickOffers.r1.price.val}" data-available="${ShelfPickOffers.r1.available.val}"></span><span data-slot="2" data-product="p${ShelfPickOffers.r2.product.val}" data-shelf="${ShelfPickOffers.r2.shelf.val}" data-position="${ShelfPickOffers.r2.position.val}" data-price="${ShelfPickOffers.r2.price.val}" data-available="${ShelfPickOffers.r2.available.val}"></span><span data-slot="3" data-product="p${ShelfPickOffers.r3.product.val}" data-shelf="${ShelfPickOffers.r3.shelf.val}" data-position="${ShelfPickOffers.r3.position.val}" data-price="${ShelfPickOffers.r3.price.val}" data-available="${ShelfPickOffers.r3.available.val}"></span><span data-slot="4" data-product="p${ShelfPickOffers.r4.product.val}" data-shelf="${ShelfPickOffers.r4.shelf.val}" data-position="${ShelfPickOffers.r4.position.val}" data-price="${ShelfPickOffers.r4.price.val}" data-available="${ShelfPickOffers.r4.available.val}"></span><span data-slot="5" data-product="p${ShelfPickOffers.r5.product.val}" data-shelf="${ShelfPickOffers.r5.shelf.val}" data-position="${ShelfPickOffers.r5.position.val}" data-price="${ShelfPickOffers.r5.price.val}" data-available="${ShelfPickOffers.r5.available.val}"></span><span data-slot="6" data-product="p${ShelfPickOffers.r6.product.val}" data-shelf="${ShelfPickOffers.r6.shelf.val}" data-position="${ShelfPickOffers.r6.position.val}" data-price="${ShelfPickOffers.r6.price.val}" data-available="${ShelfPickOffers.r6.available.val}"></span></div></html>
  <radio label="ShelfPick" optional="0" ss:questionClassNames="shelf-native">
    <title>Which granola would you choose?</title>
    <comment>Choose one product from the shelf.</comment>
    <row
      label="p101"
      value="101"
      cond="'p101' in p.ShelfPickAvailable"
      alt="Oatfield Honey &amp; Almond"
    ><span data-product="p101">Oatfield Honey &amp; Almond</span></row>
    <row
      label="p102"
      value="102"
      cond="'p102' in p.ShelfPickAvailable"
      alt="Oatfield Maple &amp; Pecan"
    ><span data-product="p102">Oatfield Maple &amp; Pecan</span></row>
    <row
      label="p103"
      value="103"
      cond="'p103' in p.ShelfPickAvailable"
      alt="Oatfield Berry &amp; Seed"
    ><span data-product="p103">Oatfield Berry &amp; Seed</span></row>
    <row
      label="p104"
      value="104"
      cond="'p104' in p.ShelfPickAvailable"
      alt="Morning Folk Cocoa &amp; Hazelnut"
    ><span data-product="p104">Morning Folk Cocoa &amp; Hazelnut</span></row>
    <row
      label="p105"
      value="105"
      cond="'p105' in p.ShelfPickAvailable"
      alt="Morning Folk Coconut &amp; Chia"
    ><span data-product="p105">Morning Folk Coconut &amp; Chia</span></row>
    <row
      label="p106"
      value="106"
      cond="'p106' in p.ShelfPickAvailable"
      alt="Morning Folk Apple &amp; Cinnamon"
    ><span data-product="p106">Morning Folk Apple &amp; Cinnamon</span></row>
    <validate>
if ShelfPick.val is not None:
    if ShelfPick.selected.label not in p.ShelfPickAvailable:
        error("Please choose an available product from the shelf.")
</validate>
  </radio>
  <suspend />
  <text label="ShelfPickProductID" where="execute" optional="1">
    <title>Chosen stable product ID</title>
    <exec>ShelfPickProductID.val = ShelfPick.selected.label</exec>
  </text>
  <float label="ShelfPickPrice" where="execute" optional="1" size="6">
    <title>Chosen product price in USD</title>
    <exec>ShelfPickPrice.val = [a['price'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0] / 100.0</exec>
  </float>
  <number label="ShelfPickSlot" where="execute" optional="1" size="1">
    <title>Chosen product physical slot</title>
    <exec>ShelfPickSlot.val = [a['slot'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <number label="ShelfPickShelf" where="execute" optional="1" size="1">
    <title>Chosen product shelf</title>
    <exec>ShelfPickShelf.val = [a['shelf'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <number label="ShelfPickPosition" where="execute" optional="1" size="1">
    <title>Chosen product position within shelf</title>
    <exec>ShelfPickPosition.val = [a['position'] for a in p.ShelfPickOffers if a['product'] == ShelfPick.selected.label][0]</exec>
  </number>
  <samplesources default="0">
    <samplesource list="0">
      <title>Open survey</title>
      <completed>You have already completed this study.</completed>
      <exit cond="qualified">Your responses have been recorded.</exit>
    </samplesource>
  </samplesources>
</survey>

See how Questra fits your survey workflow.

Book a demo