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

How to build photo swipe cards in Decipher

Build Decipher photo swipe cards from an XML scaffold, with complete copyable sections for native answers, CSS and JavaScript, detailed explanations and final XML.

A Decipher drink concept card with a honey oat latte photograph and Not interested, Not sure and Interested answer buttons.

A swipe-card question gives respondents one concept at a time. They swipe left or right, or use an answer button, and the next card appears. In this guide, you will build four photo cards for a fictional café menu and save one response per drink in a native Decipher radio matrix.

The example comes from Questra's component-examples repository. The card interface reads and selects Decipher's existing radio inputs. That connection lets you change the presentation while keeping native answer codes and validation.

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 survey in testing mode with access to the XML editor and static-file uploads. Download the drink photographs, then upload these four files to the survey's static/public files, retaining their names:

  • honey-oat-latte.jpg
  • peach-iced-tea.jpg
  • vanilla-cold-brew.jpg
  • strawberry-matcha.jpg

[rel filename.jpg] resolves a file in that survey's static directory. See Forsta's static-file guide. This example needs no design file or server-side Python loader.

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 hook, including the theme's small head scriptPage appearance, photo card, button states and loading reveal.
JavaScript hookMap native radio inputs, render a card, handle answers and continue safely.
QSwipe and its page breakStore four native answers, one per drink.
Completion/footer overridesRetain the example's closing presentation and logo.
<survey
  name="Café menu preferences"
  alt="Café menu preferences"
  compat="144"
  fir="on"
  html:showNumber="0"
  builder:wizardCompleted="1"
  state="testing"
  ss:enableNavigation="0"
  extraVariables="source"
>
  <style name="respview.client.css" mode="after"><![CDATA[
    <!-- questra-theme:start -->
    <style>
      /* BUILD: THEME_PAGE */
      /* BUILD: THEME_CONTROLS */
      /* BUILD: THEME_GATE_CSS */
    </style>
 
    <script>
      /* BUILD: THEME_GATE_JS */
    </script>
    <!-- questra-theme:end -->
    <style>
      /* BUILD: SWIPE_FRAME */
      /* BUILD: SWIPE_CONTROLS */
      /* BUILD: SWIPE_PHOTO */
    </style>
  ]]></style>
  <style name="respview.client.js" mode="after" wrap="ready"><![CDATA[
    /* qsw-shared-js: one definition per respondent page; native radios are the only answer model. */
    (function () {
      'use strict';
      /* BUILD: SWIPE_COORDINATOR */
        /* BUILD: SWIPE_FORM_EVENTS */
      function initialize(root, form, coordinator) {
        /* BUILD: SWIPE_INPUTS */
        /* BUILD: SWIPE_UI */
        /* BUILD: SWIPE_PHASES */
        /* BUILD: SWIPE_MOTION */
        /* BUILD: SWIPE_RENDER */
        /* BUILD: SWIPE_CHOOSE */
        /* BUILD: SWIPE_GESTURES */
        /* BUILD: SWIPE_ACTIVATE */
      }
    })();
 
    if (window.QuestraTheme) window.QuestraTheme.ready();
  ]]></style>
  <!-- BUILD: SWIPE_QUESTION -->
  <!-- BUILD: SWIPE_FOOTER -->
</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.

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.

1. Create all four native rows

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

This complete question is a sibling of the two style hooks. type="rating" supplies a row-by-column radio matrix, and optional="0" requires one answer for each drink. ss:questionClassNames="qswipe" adds the class the script searches for. Each <row> has a unique label and a span containing its image filename and text.

The three columns have stable labels c1, c2, c3 and exported codes 1, 2, 3. “Not sure” is a completed answer with code 2. The script will identify columns from table headers, not assume the browser's zero-based input value equals the export code. Keep the — separator between the title and description; the card renderer splits on it. [rel filename] resolves each uploaded photo. The final <suspend /> submits the four-row page before the survey finishes.

  <radio label="QSwipe" type="rating" optional="0" ss:questionClassNames="qswipe">
    <title>Which of these drinks would you consider ordering?</title>
    <comment>Think about your next café visit. Swipe left for Not interested, right for Interested, or choose an answer below.</comment>
    <row label="r1"><span data-qsw-image="[rel honey-oat-latte.jpg]">Honey oat latte — espresso with oat milk and a touch of honey</span></row>
    <row label="r2"><span data-qsw-image="[rel peach-iced-tea.jpg]">Peach iced tea — black tea with peach and a squeeze of lemon</span></row>
    <row label="r3"><span data-qsw-image="[rel vanilla-cold-brew.jpg]">Vanilla cold brew — slow-steeped coffee with vanilla cream</span></row>
    <row label="r4"><span data-qsw-image="[rel strawberry-matcha.jpg]">Strawberry matcha — green tea with milk and strawberry purée</span></row>
    <col label="c1" value="1">Not interested</col>
    <col label="c2" value="2">Not sure</col>
    <col label="c3" value="3">Interested</col>
  </radio>
  <suspend />

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

The empty survey.completion override retains the source's completion presentation. survey.respview.footer adds the Questra footer after the standard footer. The long data:image/svg+xml;base64,... value is an encoded logo image, not a function or survey logic; copying it intact avoids another asset upload. Its alt text names the logo. The surrounding theme comments only mark the shared styling section.

  <!-- questra-theme:start -->
  <style name="survey.completion"><![CDATA[]]></style>
  <style name="survey.respview.footer" mode="after"><![CDATA[<footer class="qt-footer"><img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDE2MDAgNDgwIj48cGF0aCBkPSJNMTE1Ni4wNSwzMDAuNDFjLTM0LjgyLDQwLjQtNzIuNzUsNzIuNjYtMTE1LjM4LDk4LjU0LTg2Ljk0LDUyLjc3LTE5My42OCw3OC40Mi0zMjYuMTcsNzguNDJzLTIzOS4yOC0yNS42NS0zMjYuMjItNzguNDJjLTQ0LjQtMjYuOTQtODMuNjYtNjAuNzMtMTE5LjY4LTEwMy41MywxNC4yNS00LjkzLDI3LjMyLTExLjk2LDM4Ljg1LTIwLjc5LDc3LjM4LDg4LjQyLDE5NS4zMywxNTkuNTgsNDA3LjA1LDE1OS41OCwxOTYuOTUsMCwzMTIuNzgtNjEuNiwzOTAuMjMtMTQxLjM3LDExLjExLDUuMzUsMjQuMzIsOC4yNywzOS4xLDguMjcsNC4yMSwwLDguMjktLjIzLDEyLjIxLS43WiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik0zOTIuNDMsMjA2LjY1Vjc4LjQyaDQ0LjE5djExNS4yMWMwLDI4LjgsMTguNTQsNDguMTQsNDYuOTUsNDguMTQsMjkuOTksMCw1MC4xMS0xOS43Myw1MC4xMS00OC45M3YtMTE0LjQyaDQ0LjE5djE0MC4wN2MwLDExLjQ0LDcuMSwxOC41NCwxOC41NCwxOC41NGgzLjk0djQyLjYxaC0xMy4wMmMtMjYuNDMsMC00My40LTEwLjI2LTUwLjExLTI5LjItMTUuMzksMjIuMS0zOC42NywzMy45My02Ny44NywzMy45My00Ni4xNiwwLTc2Ljk0LTMwLjc4LTc2Ljk0LTc3LjczWiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik02MTguMjIsMTc5LjAzYzAtNTkuOTcsNDYuMTYtMTA1LjM1LDEwNy4zMy0xMDUuMzVzMTA3LjcyLDQ1LjM4LDEwNy43MiwxMDUuNzVjMCw1LjUyLS40LDExLjQ0LTEuMTksMTYuNTdoLTE2Ni41MWM2LjcxLDI5LjIsMzAuMzgsNDguNTMsNjEuOTUsNDguNTMsMjMuMjgsMCw0My40LTEwLjI2LDUzLjY2LTI1LjY1aDQ4LjkzYy0xNi4xOCwzOS4wNi01NS42Myw2NS41LTEwMy4zOCw2NS41LTYyLjM0LDAtMTA4LjUxLTQ1LjM3LTEwOC41MS0xMDUuMzVaTTY2NS45NiwxNTkuN2gxMTkuOTVjLTYuNzEtMjcuNjItMzAuMzgtNDYuMTYtNjAuMzctNDYuMTZzLTUyLjQ4LDE4LjE1LTU5LjU4LDQ2LjE2WiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik04NjAuMDYsMjIwLjQ2aDQ0Ljk4YzAsMTUuMzksMTQuNiwyNS42NSwzNS45MSwyNS42NSwxOS43MywwLDMyLjc0LTkuMDgsMzIuNzQtMjEuNywwLTM2LjMtMTEwLjQ3LTE2LjE4LTExMC40Ny04Ny41OSwwLTM5LjA2LDMxLjE3LTYzLjEzLDc1Ljc2LTYzLjEzczc2LjU1LDI0Ljg2LDc2LjU1LDYxLjE2aC00NC45OGMwLTEzLjgxLTEzLjAyLTIzLjI4LTMxLjk2LTIzLjI4cy0zMC43Nyw4LjI5LTMwLjc3LDIwLjUyYzAsMzUuMTEsMTEwLjg3LDE1Ljc4LDExMC44Nyw4Ny41OSwwLDM5LjA2LTMxLjE3LDY0LjcxLTc4LjEyLDY0Ljcxcy04MC40OS0yNi4wNC04MC40OS02My45MloiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMTIzOC41Nyw3OC40Mmg0NC4xOXYzMy41NGMxNS43OS0yMi44OCwzOS4wNi0zNi42OSw2Ny40Ny0zNi42OWg3LjF2NDUuMzdoLTcuMWMtNDIuNjIsMC02Ny40NywyNC4wNy02Ny40Nyw2MS4xNnY5Ny44NWgtNDQuMTlWNzguNDJaIiBmaWxsPSIjMTAzNzQzIi8+PHBhdGggZD0iTTEzNjQuMDUsMTc5LjAzYzAtNTkuOTcsNDIuMjItMTA1LjM1LDk5LjAzLTEwNS4zNSwyOS42LDAsNTQuMDYsMTIuNjMsNzAuNjMsMzMuNTR2LTI4LjhoNDQuMTl2MTQwLjA3YzAsMTEuNDQsNi43MSwxOC41NCwxOC41NCwxOC41NGgzLjU1djQyLjYxaC0xMi42M2MtMjcuNjIsMC00NC45OC0xMS4wNS01MC45LTMxLjk2LTE2Ljk3LDIyLjg5LTQyLjYyLDM2LjctNzMuNCwzNi43LTU2LjgxLDAtOTkuMDMtNDUuMzctOTkuMDMtMTA1LjM1Wk0xNTMzLjcxLDE3OS4wM2MwLTM1LjUxLTI2LjQzLTYyLjc0LTYxLjk0LTYyLjc0cy02Mi4zNCwyNy4yMi02Mi4zNCw2Mi43NCwyNi40NCw2Mi43NCw2Mi4zNCw2Mi43NCw2MS45NC0yNy4yMiw2MS45NC02Mi43NFoiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMTQ0LjI2LDgxLjg3Yy01Ljk4LDcuMzQtMTAuOTEsMTUuNi0xNC40MywyNC42Nkg0OS4zN3YtMjQuNjZoOTQuODlaIiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeT0iODEuODUiIHdpZHRoPSIyNC42NyIgaGVpZ2h0PSIyNC42NyIgZmlsbD0iI2ZmNTA1ZCIvPjxwYXRoIGQ9Ik0xMjIuOTIsMTQzLjUyYzAsNC4xOS4yNSw4LjMyLjc0LDEyLjMzaC00OS42M3YtMjQuNjZoNDkuNjNjLS40OSw0LjAxLS43NCw4LjE0LS43NCwxMi4zM1oiIGZpbGw9IiNmZjUwNWQiLz48cGF0aCBkPSJNMTQ0LjI2LDIwNS4xOEg0OS4zN3YtMjQuNjZoODAuNDZjMy41MSw5LjA2LDguNDUsMTcuMzMsMTQuNDMsMjQuNjZaIiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeD0iMjQuNjciIHk9IjEzMS4xOSIgd2lkdGg9IjI0LjY3IiBoZWlnaHQ9IjI0LjY3IiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeT0iMTgwLjUzIiB3aWR0aD0iMjQuNjciIGhlaWdodD0iMjQuNjciIGZpbGw9IiNmZjUwNWQiLz48cGF0aCBkPSJNMTA3Ni43NSwyMjEuMjV2LTEwMi41OWgtMzMuOTN2LTQwLjI0aDIwLjUxYzEwLjI2LDAsMTUuNzktNS41MiwxNS43OS0xNS43OFYyMy45N2g0MS44MnY1NC40NWg2NS44OXY0MC4yNGgtNjUuODl2OTguMjVjMCwxMy40MSw5LjA4LDIyLjEsMjIuODksMjIuMXMyMi44OS05LjQ3LDIyLjg5LTI1LjI1aDM5LjQ2YzAsNDAuMjQtMjUuMjUsNjUuODktNjUuNSw2NS44OXMtNjMuOTItMjMuMjgtNjMuOTItNTguNFoiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMzYxLjA0LDE0Mi43MmMwLDgwLjA5LTYxLjU0LDE0MC4wMi0xNDMuMjMsMTQwLjAyLTQ2Ljk4LDAtODcuNDktMjAuMjktMTEzLjMyLTUyLjloNzEuNThjMTIuNDUsNS45MiwyNi42NCw5LjE4LDQxLjc0LDkuMTgsMS45OSwwLDMuOTYtLjA2LDUuOTEtLjE3LTcuMDgtMTAuNjctMTMuOTktMjEuNzItMjEuMDYtMzMuNjhsMzcuMTgtMjIuMDFjOC42NywxNC42NywxNy40OSwyOC43MSwyNy4yNCw0Mi44NiwyNy44Ni0xNi4yNyw0NS44Ny00Ni40NSw0NS44Ny04Mi41LDAtNTQuNDQtNDEuMDctOTUuNTEtOTUuMTQtOTUuNTEtMTUuMSwwLTI5LjI5LDMuMjctNDEuNzQsOS4xOWgtNzMuNjhDMTI4LjEsMjMuNDcsMTY5LjUzLDIuNjMsMjE3LjgxLDIuNjNjODEuNjksMCwxNDMuMjMsNjAsMTQzLjIzLDE0MC4wOVoiIGZpbGw9IiMxMDM3NDMiLz48L3N2Zz4=" data-brand-source="https://www.questra.ai/logo/primary/full-color.svg" alt="Questra" width="120" height="36" decoding="async" /></footer>]]></style>
  <!-- questra-theme:end -->

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. Set the page colors, column and native fields

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

The --qt-* custom properties name the palette, surface and radius so later rules can refer to them with var(...). color-scheme: light requests light browser controls. The HTML/body rules remove the default margin and set the background, text, font and minimum viewport height.

#survey becomes a centered, padded column, capped at 1240 pixels. The section/body and question selectors remove the default panels while keeping full available width. Question headings scale with clamp; instructions retain a generous line height. .answers permits horizontal overflow for a native matrix. Text controls inherit the font and receive padding and borders; radio/checkbox inputs receive an accent color and minimum dimensions. These rules style the native fallback as well as the finished activity.

      /* Questra brand: questra-workspace/brand/motion/motion.js, BRAND (lines 51–52).
         Shared shell and native controls only. No font or stylesheet network dependency. */
      :root {
        --qt-chalk: #f5f1ec;
        --qt-coral: #ff505d;
        --qt-slate: #103743;
        --qt-line: #10374333;
        --qt-surface: #fff;
        --qt-radius: 24px;
        color-scheme: light;
      }
      html {
        background: var(--qt-chalk);
      }
      html body,
      html body.survey-page {
        margin: 0;
        background: var(--qt-chalk);
        color: var(--qt-slate);
        font:
          16px/1.5 Arial,
          Helvetica,
          sans-serif;
        min-height: 100vh;
      }
      body #survey {
        box-sizing: border-box;
        width: 100%;
        max-width: 1240px;
        margin: 0 auto;
        padding: 28px 32px 48px;
        background: transparent;
        border: 0;
        box-shadow: none;
        border-radius: 0;
      }
      body .survey-section,
      body .survey-body {
        box-sizing: border-box;
        max-width: none;
        width: 100%;
        margin: 0;
        padding: 0;
        background: transparent;
        border: 0;
        box-shadow: none;
      }
      body .survey-header,
      body .survey-footer {
        background: transparent;
        color: var(--qt-slate);
      }
      body .question {
        box-sizing: border-box;
        margin: 24px 0;
        padding: 0;
        background: transparent;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        color: var(--qt-slate);
      }
      body .question-text {
        font-size: clamp(22px, 3vw, 34px);
        line-height: 1.2;
        letter-spacing: -0.6px;
        color: var(--qt-slate);
      }
      body .instruction-text {
        color: var(--qt-slate);
        line-height: 1.6;
      }
      body .answers {
        max-width: 100%;
        overflow-x: auto;
      }
      body .answers input:not([type='radio']):not([type='checkbox']):not([type='hidden']),
      body .answers textarea,
      body .answers select {
        box-sizing: border-box;
        max-width: 100%;
        font: inherit;
        color: var(--qt-slate);
        background: white;
        border: 1px solid var(--qt-slate);
        border-radius: 8px;
        padding: 10px;
      }
      body input[type='radio'],
      body input[type='checkbox'] {
        accent-color: var(--qt-slate);
        min-width: 20px;
        min-height: 20px;
      }

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

The button container wraps controls with a gap and aligns them to the right. Continue, Finish and Back share a minimum touch height, pill border and typeface; Back then overrides the fill and shadow. :focus-visible adds a keyboard outline. Error selectors preserve readable messages with a colored border. The example hides the progress bar. .qt-footer centers its image, and object-fit: contain keeps the logo's proportions. These declarations affect presentation, not answer selection or submission.

      body .survey-buttons {
        display: flex;
        flex-wrap: wrap;
        gap: 12px;
        justify-content: flex-end;
        padding: 24px 0;
      }
      body #btn_continue,
      body #btn_finish,
      body #btn_back {
        font:
          700 16px/1.4 Arial,
          sans-serif;
        min-height: 48px;
        padding: 13px 26px;
        border: 2px solid var(--qt-slate);
        border-radius: 999px;
        background: var(--qt-slate);
        color: var(--qt-chalk);
        cursor: pointer;
        box-shadow: 4px 4px 0 var(--qt-coral);
      }
      body #btn_back {
        background: transparent;
        color: var(--qt-slate);
        box-shadow: none;
      }
      body :focus-visible {
        outline: 3px solid var(--qt-slate);
        outline-offset: 4px;
      }
      body .survey-error,
      body .question-error,
      body .error-text {
        color: var(--qt-slate);
        background: var(--qt-chalk);
        border-left: 4px solid var(--qt-coral);
        padding: 12px;
      }
      body .progress-bar,
      body [role='progressbar'] {
        display: none !important;
      }
      .qt-footer {
        display: flex;
        justify-content: center;
        padding: 32px 20px 40px;
        background: transparent;
      }
      .qt-footer img {
        display: block;
        width: 120px;
        height: 36px;
        object-fit: contain;
      }

3. Give loading a deadline

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

While html has qt-pending, questions and the named custom interfaces are temporarily invisible. qt-reveal makes them visible after 1.8 seconds even if later JavaScript fails. The matching pseudo-element displays “Preparing your activity…” and qt-dismiss removes it at the same deadline. forwards retains the keyframe's ending state.

The 650-pixel media query reduces page spacing. The reduced-motion query prevents smooth page scrolling. These loading rules never hide the form, navigation or validation container itself.

      /* The optional early gate never hides the form, navigation or validation messages.
         CSS provides an independent fail-open deadline even if a later script throws. */
      html.qt-pending .question,
      html.qt-pending .outfit-ui,
      html.qt-pending .sh-ui {
        visibility: hidden;
        animation: qt-reveal 0s 1.8s forwards;
      }
      html.qt-pending .survey-body:before {
        content: 'Preparing your activity…';
        display: block;
        padding: 24px;
        border-left: 4px solid var(--qt-coral);
        animation: qt-dismiss 0s 1.8s forwards;
      }
      \@keyframes qt-reveal {
        to {
          visibility: visible;
        }
      }
      \@keyframes qt-dismiss {
        to {
          display: none;
          visibility: hidden;
          height: 0;
          padding: 0;
        }
      }
      \@media (max-width: 650px) {
        body #survey {
          padding: 16px 12px 32px;
        }
        body .question {
          padding: 0;
          border-radius: 0;
          margin: 16px 0;
        }
      }
      \@media (prefers-reduced-motion: reduce) {
        body {
          scroll-behavior: auto;
        }
      }

4. Release the loading state

Replace the entire BUILD: THEME_GATE_JS comment line in the CSS hook’s existing <script> with this block.

This script is intentionally inside the CSS hook's HTML payload: it runs in the head, before the body is parsed. It adds qt-pending, then exposes two small methods as window.QuestraTheme. ready() reveals the page after setup; canEnhance() says whether the cosmetic loading window is still open.

reveal(true) also sets expired, so a late component does not replace controls after the respondent starts using them. The 1800-millisecond timer, first key press, first pointer press and restored-page event all trigger that fallback. once removes an interaction listener after its first call; capture lets it see the event early. The component's final ready() call releases the gate on a successful build.

      /* Runs inline in the head, before body parsing; no jQuery dependency. */
      (function () {
        'use strict';
        var root = document.documentElement;
        var expired = false;
        root.classList.add('qt-pending');
        function reveal(fallback) {
          if (fallback) expired = true;
          root.classList.remove('qt-pending');
        }
        window.QuestraTheme = {
          canEnhance: function () {
            return !expired;
          },
          ready: function () {
            reveal(false);
          }
        };
        setTimeout(function () {
          reveal(true);
        }, 1800);
        // Keyboard/pointer intent takes precedence over cosmetic loading.
        document.addEventListener(
          'keydown',
          function () {
            reveal(true);
          },
          { once: true, capture: true }
        );
        document.addEventListener(
          'pointerdown',
          function () {
            reveal(true);
          },
          { once: true, capture: true }
        );
        window.addEventListener('pageshow', function (event) {
          if (event.persisted) reveal(true);
        });
      })();

5. Build the card container and text

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

The .question.qswipe prefix limits these rules to this component. The question is capped at 580 pixels and the UI initially at 480 pixels. Universal box-sizing keeps padding inside declared sizes. The card establishes a layered, clipped surface; will-change hints at its upcoming transform and fade. touch-action: pan-y permits normal vertical scrolling while the JavaScript interprets horizontal movement. user-select: none prevents a drag from selecting the card text.

The copy container uses a vertical flex layout. Eyebrow, title and description rules set their spacing and hierarchy. Later photo rules deliberately override some of these base values; keep both sections in this order.

      /* qsw-shared-css */
      .question.qswipe {
        max-width: 580px;
        margin-left: auto;
        margin-right: auto;
        --qsw-ink: var(--qt-slate);
        --qsw-green: var(--qt-slate);
      }
      .question.qswipe .qsw-ui {
        overflow: clip;
        padding: 12px 10px;
        max-width: 480px;
        margin: 24px auto 12px;
        color: var(--qsw-ink);
        font:
          16px/1.5 system-ui,
          sans-serif;
      }
      .question.qswipe .qsw-ui * {
        box-sizing: border-box;
      }
      .question.qswipe .qsw-card {
        position: relative;
        z-index: 1;
        overflow: hidden;
        will-change: transform, opacity;
        border: 1px solid #10374340;
        border-radius: 24px;
        box-shadow: none;
        background: var(--qt-chalk);
        touch-action: pan-y;
        user-select: none;
      }
      .question.qswipe .qsw-card-copy {
        padding: 36px 28px;
        min-height: 220px;
        display: flex;
        flex-direction: column;
        justify-content: center;
      }
      .question.qswipe .qsw-eyebrow {
        margin: 0 0 18px;
        font-size: 12px;
        font-weight: 650;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }
      .question.qswipe .qsw-title {
        font:
          650 27px/1.2 system-ui,
          sans-serif;
        letter-spacing: -0.04em;
        margin: 0;
        color: var(--qsw-ink);
      }
      .question.qswipe .qsw-description {
        margin: 16px 0 0;
        font-size: 18px;
        line-height: 1.5;
      }

6. Style answer states, swipe cues and completion

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

The action row is flex-based and its buttons share space. data-answer colors the negative and positive choices; aria-pressed marks a chosen answer and aria-disabled dims controls during a transition. A visible focus ring remains available to keyboard users. [hidden] is enforced with !important so an explicit hidden state wins over layout styles.

The reduced-motion rule removes transitions. .qsw-sr uses a tiny clipped box to retain announcements for screen readers. The stack allows the cue to sit above the card; its opacity starts at zero and its data-direction selects the response color. .qsw-continue-hidden hides native navigation only when the coordinator adds that class. The completion paragraph has its own readable status treatment.

      .question.qswipe .qsw-actions {
        display: flex;
        gap: 10px;
        margin: 18px 0 10px;
      }
      .question.qswipe .qsw-ui button {
        cursor: pointer;
        font:
          600 14px/1.3 system-ui,
          sans-serif;
        border: 1px solid #ccd8cf;
        border-radius: 13px;
        background: white;
        color: var(--qsw-ink);
        min-height: 44px;
        padding: 10px 12px;
      }
      .question.qswipe .qsw-actions button {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
      }
      .question.qswipe .qsw-actions button span {
        font-size: 24px;
        font-weight: 400;
        line-height: 1;
      }
      .question.qswipe .qsw-actions [data-answer='c1'] {
        background: #fff2ed;
        border-color: #e6cfc4;
      }
      .question.qswipe .qsw-actions [data-answer='c3'] {
        background: var(--qsw-green);
        color: white;
        border-color: var(--qsw-green);
      }
      .question.qswipe .qsw-actions [aria-pressed='true'] {
        outline: 3px solid #b08037;
        outline-offset: 3px;
      }
      .question.qswipe .qsw-ui button[aria-disabled='true'] {
        opacity: 0.45;
        cursor: default;
      }
      .question.qswipe .qsw-ui :focus-visible {
        outline: 3px solid #2257a7;
        outline-offset: 4px;
      }
      .question.qswipe [hidden] {
        display: none !important;
      }
      \@media (prefers-reduced-motion: reduce) {
        .question.qswipe .qsw-card {
          transform: none !important;
          transition: none !important;
        }
      }
      .question.qswipe .qsw-sr {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
      }
 
      .question.qswipe .qsw-stack {
        position: relative;
      }
      .question.qswipe .qsw-cue {
        position: absolute;
        top: 24px;
        left: 24px;
        padding: 5px 12px;
        border: 3px solid currentColor;
        border-radius: 8px;
        color: #16624b;
        background: #ffffffed;
        font:
          750 23px/1.3 system-ui,
          sans-serif;
        opacity: 0;
        transform: rotate(-10deg);
      }
      .question.qswipe .qsw-cue[data-direction='c1'] {
        color: #a34432;
        left: auto;
        right: 24px;
        transform: rotate(10deg);
      }
      .question.qswipe .qsw-cue[data-direction='c2'] {
        color: #66548a;
      }
 
      form.qsw-form .qsw-continue-hidden {
        display: none !important;
      }
      .question.qswipe .qsw-completion {
        padding: 30px 12px;
        margin: 0;
        text-align: center;
        font:
          600 18px/1.5 system-ui,
          sans-serif;
      }
 
      /* Photo cards: image is decorative; native text remains the answer label. */

7. Apply the photo treatment and phone layout

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

These later rules narrow the UI to 440 pixels and give the card a photo background. The copy area has a 440-pixel fallback minimum height, overridden by clamp(350px, 58svh, 520px) where supported: 58% of the small viewport height, bounded between 350 and 520 pixels. The absolutely positioned image fills it with object-fit: cover; object-position controls the crop. Negative layer positions put the photo behind the text and the ::after gradient between them. pointer-events: none makes both decorative layers transparent to gestures.

The copy aligns to the bottom over the dark gradient. White type and adjusted spacing keep it legible. Answer buttons become at least 64 pixels tall and stack their symbol above their label. The coral focus outline sits three pixels outside the card, and the cue moves to an 18-pixel top/left inset. At 480 pixels and below, the UI padding, card minimum height, copy padding and title size shrink together. These are the final overrides of the earlier base rules.

      .question.qswipe .qsw-ui {
        max-width: 440px;
        margin: 12px auto;
        padding: 8px;
      }
      .question.qswipe .qsw-card {
        background: #103743;
        border: 0;
        border-radius: 24px;
        isolation: isolate;
      }
      .question.qswipe .qsw-photo {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
        object-position: 50% 40%;
        pointer-events: none;
        z-index: -2;
      }
      .question.qswipe .qsw-card::after {
        content: '';
        position: absolute;
        inset: 0;
        z-index: -1;
        pointer-events: none;
        background: linear-gradient(
          180deg,
          transparent 25%,
          rgba(5, 18, 23, 0.12) 40%,
          rgba(5, 18, 23, 0.82) 70%,
          #051217 100%
        );
      }
      .question.qswipe .qsw-card-copy {
        min-height: 440px;
        min-height: clamp(350px, 58svh, 520px);
        padding: 150px 24px 26px;
        justify-content: flex-end;
        color: #fff;
      }
      .question.qswipe .qsw-eyebrow {
        font-size: 11px;
        letter-spacing: 0.12em;
        margin: 0 0 9px;
        color: #fff;
      }
      .question.qswipe .qsw-title {
        font-size: 32px;
        font-weight: 750;
        line-height: 1.08;
        color: #fff;
        letter-spacing: -0.035em;
      }
      .question.qswipe .qsw-description {
        font-size: 16px;
        line-height: 1.4;
        margin: 12px 0 0;
        color: #fff;
      }
      .question.qswipe .qsw-actions {
        gap: 8px;
        margin: 12px 0 0;
      }
      .question.qswipe .qsw-actions button {
        flex-direction: column;
        gap: 5px;
        min-height: 64px;
        padding: 8px 4px;
        font-size: 12px;
        border-radius: 16px;
      }
      .question.qswipe .qsw-actions button span {
        font-size: 27px;
      }
      .question.qswipe .qsw-card:focus-visible {
        outline: 3px solid #ff505d;
        outline-offset: 3px;
      }
      .question.qswipe .qsw-cue {
        font-size: 17px;
        top: 18px;
        left: 18px;
        max-width: 85%;
      }
      \@media (max-width: 480px) {
        .question.qswipe .qsw-ui {
          padding: 5px;
        }
        .question.qswipe .qsw-card-copy {
          padding: 125px 20px 24px;
          min-height: clamp(280px, 48svh, 480px);
        }
        .question.qswipe .qsw-title {
          font-size: 30px;
        }
      }

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. Coordinate native Continue across the page

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

Inside the outer function, stop if the theme's loading window expired. Visit each form once, locate its .qswipe roots and native Continue/Finish button, and stop when either is missing or the button is disabled. The flags separate an automatic attempt, a pending request, a seen submit event and a recovery state; recoveryTimer holds the timeout ID.

autoEligible() checks every currently visible question against registered swipe instances. That prevents automatic continuation from assuming an unrelated question is complete. recover() clears the timeout, restores each instance's native answers, and makes Continue usable. beginRequest() switches instances to their saving state and starts a 12-second recovery timer.

coordinator.complete() exposes Continue on mixed-question pages. On a page containing only managed cards, it waits for every instance to be both done and fully answered, permits one automatic attempt, checks that the button still exists, and clicks that native button. A caught click failure falls back to recovery. The surrounding form loop is opened here and closed in the next insertion.

      if (window.QuestraTheme && !window.QuestraTheme.canEnhance()) return;
      document.querySelectorAll('form').forEach(function (form) {
        if (form.dataset.qswSharedReady) return;
        var roots = Array.from(form.querySelectorAll('.question.qswipe'));
        var button = form.querySelector('#btn_continue, #btn_finish');
        if (!roots.length || !button || button.disabled) return;
        var instances = [],
          attempted = false,
          pending = false,
          submitSeen = false,
          recovering = false,
          recoveryTimer = null;
        // Never infer another question type's completion or bypass its validation.
        function autoEligible() {
          var visible = Array.from(form.querySelectorAll('.question')).filter(function (q) {
            return q.getClientRects().length > 0;
          });
          return (
            visible.length > 0 &&
            visible.every(function (q) {
              return instances.some(function (i) {
                return i.root === q;
              });
            })
          );
        }
        function recover(message) {
          window.clearTimeout(recoveryTimer);
          recovering = true;
          pending = false;
          submitSeen = false;
          instances.forEach(function (i) {
            i.recover(message || 'Please review your selected answers, then use Continue.');
          });
          button.classList.remove('qsw-continue-hidden');
          button.disabled = false;
        }
        function beginRequest() {
          pending = true;
          submitSeen = false;
          instances.forEach(function (i) {
            i.saving();
          });
          recoveryTimer = window.setTimeout(function () {
            recover(
              'This is taking longer than expected. Your answers are still selected. Use Continue to try again.'
            );
          }, 12000);
        }
        var coordinator = {
          complete: function () {
            if (!autoEligible()) {
              button.classList.remove('qsw-continue-hidden');
              return;
            }
            if (
              attempted ||
              pending ||
              recovering ||
              !instances.every(function (i) {
                return i.done() && i.allAnswered();
              })
            )
              return;
            attempted = true;
            if (button.disabled || !button.isConnected) {
              recover();
              return;
            }
            try {
              button.click();
            } catch (error) {
              recover();
            }
          }
        };

2. Initialize instances and guard duplicate submissions

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

For each root, call initialize, collecting only successful instances. A failed setup restores that question's native answer area and removes any partial custom UI. Without a working instance the form is left alone. Once registered, the form receives its ready marker and styling class; Continue is hidden only if autoEligible() allows it.

The capturing click handler blocks a second request while one is pending. The submit handler blocks repeated submit events and starts the saving state if submission arrived another way. Native invalid events restore the answers with an instruction to review them. A pageshow from the browser's page cache also recovers. Finally, an already-answered page is shown for review rather than immediately submitted again. This block closes the forEach from the previous block.

        roots.forEach(function (root) {
          try {
            var instance = initialize(root, form, coordinator);
            if (instance) instances.push(instance);
          } catch (error) {
            // A failed question keeps native fields available; it also prevents automatic submission.
            var native = root.querySelector('.answers');
            if (native) native.hidden = false;
            var partial = root.querySelector('.qsw-ui');
            if (partial) partial.remove();
            delete root.dataset.swipeReady;
          }
        });
        if (!instances.length) return;
        form.dataset.qswSharedReady = 'true';
        form.classList.add('qsw-form');
        if (autoEligible()) button.classList.add('qsw-continue-hidden');
        button.addEventListener(
          'click',
          function (event) {
            if (pending) {
              event.preventDefault();
              event.stopImmediatePropagation();
              return;
            }
            beginRequest();
          },
          true
        );
        form.addEventListener(
          'submit',
          function (event) {
            if (submitSeen) {
              event.preventDefault();
              event.stopImmediatePropagation();
              return;
            }
            if (!pending) beginRequest();
            submitSeen = true;
          },
          true
        );
        form.addEventListener(
          'invalid',
          function () {
            recover('Please check the highlighted answers, then use Continue.');
          },
          true
        );
        window.addEventListener('pageshow', function (event) {
          if (event.persisted) recover();
        });
        // Server redisplay of restored values must never cause a submission loop.
        if (
          instances.every(function (i) {
            return i.allAnswered();
          })
        )
          recover();
      });

3. Discover the native inputs inside initialize

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

This section goes inside the scaffold's initialize(root, form, coordinator) function. Stop if the root was enhanced already or has no .answers. Group radio inputs by their generated name, which corresponds to one native row.

For each group, find its table row and .row-legend. Read the cell's headers tokens and match suffixes _c1, _c2, _c3 to build byCode. Extract plain row text and data-qsw-image. The final guard requires a title and exactly three mapped inputs for every row. If discovery fails, return before hiding anything.

        if (root.dataset.swipeReady) return null;
        var native = root.querySelector('.answers');
        var groups = new Map();
        if (!native) return null;
        native.querySelectorAll('input[type="radio"]').forEach(function (input) {
          if (!groups.has(input.name)) groups.set(input.name, []);
          groups.get(input.name).push(input);
        });
        var rows = Array.from(groups.values()).map(function (inputs) {
          var tr = inputs[0].closest('tr');
          var legend = tr && tr.querySelector('.row-legend');
          var byCode = {};
          inputs.forEach(function (input) {
            var cell = input.closest('td');
            var headers = cell && cell.getAttribute('headers');
            ['c1', 'c2', 'c3'].forEach(function (code) {
              if (
                headers &&
                headers.split(/\s+/).some(function (h) {
                  return h.endsWith('_' + code);
                })
              )
                byCode[code] = input;
            });
          });
          var media = legend && legend.querySelector('[data-qsw-image]');
          return {
            inputs: inputs,
            byCode: byCode,
            title: legend && legend.textContent.trim(),
            image: media && media.getAttribute('data-qsw-image')
          };
        });
        // Unsupported native markup leaves the matrix fully usable.
        if (
          !rows.length ||
          rows.some(function (r) {
            return (
              !r.title ||
              r.inputs.length !== 3 ||
              !r.byCode.c1 ||
              !r.byCode.c2 ||
              !r.byCode.c3
            );
          })
        )
          return;

4. Create the card, instructions and local state

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

Create a labeled section containing a live status, keyboard instructions, one focusable card, a decorative image, title/description elements, a hidden swipe cue and three buttons. type="button" prevents an answer button from submitting the form. Each button's data-answer matches a native column label.

The separate completion paragraph can receive programmatic focus with tabindex="-1". Store references to frequently updated elements. A failed photograph is hidden; preloading the row images reduces waits between cards. matchMedia tracks reduced motion. busy, timer and pointer represent an active transition, its scheduled callback and the current gesture; phase starts as answering.

        var ui = document.createElement('section');
        ui.className = 'qsw-ui';
        ui.setAttribute('aria-label', 'Swipe cards');
        var helpId = root.id + '-swipe-help';
        ui.innerHTML =
          '<p class="qsw-sr qsw-status" role="status" aria-live="polite" aria-atomic="true"></p>' +
          '<p class="qsw-sr qsw-help">Swipe left for Not interested or right for Interested. On the focused card, press Left Arrow for Not interested, S for Not sure, or Right Arrow for Interested. Not sure is an explicit answer.</p>' +
          '<div class="qsw-stack"><article class="qsw-card" tabindex="0" aria-roledescription="swipe card"><img class="qsw-photo" alt="" draggable="false" decoding="async" /><div class="qsw-card-copy"><p class="qsw-eyebrow">Seasonal café menu</p><h2 class="qsw-title"></h2><p class="qsw-description"></p></div><span class="qsw-cue" aria-hidden="true"></span></article></div>' +
          '<div class="qsw-actions" role="group" aria-label="Choose an answer"><button type="button" data-answer="c1"><span aria-hidden="true">−</span>Not interested</button><button type="button" data-answer="c2"><span aria-hidden="true">→</span>Not sure</button><button type="button" data-answer="c3"><span aria-hidden="true">+</span>Interested</button></div>';
        var completion = document.createElement('p');
        completion.className = 'qsw-completion';
        completion.setAttribute('role', 'status');
        completion.setAttribute('tabindex', '-1');
        completion.hidden = true;
        ui.appendChild(completion);
        var stack = ui.querySelector('.qsw-stack');
        var actions = ui.querySelector('.qsw-actions');
        var card = ui.querySelector('.qsw-card');
        var photo = card.querySelector('.qsw-photo');
        photo.addEventListener('error', function () {
          photo.hidden = true;
        });
        rows.forEach(function (row) {
          if (row.image) {
            var preload = new Image();
            preload.src = row.image;
          }
        });
        var status = ui.querySelector('.qsw-status');
        var cue = card.querySelector('.qsw-cue');
        var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
        var busy = false,
          timer = null,
          pointer = null;
        var phase = 'answering';

5. Define answering, completion and recovery

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

leaveDeck() locks answers, clears the gesture, hides the stack/actions/help and focuses a completion status. recover() cancels the card timer, changes phase and reveals the native matrix. completeDeck() only runs from the answering phase and only when every row has a native selection; it then tells the form coordinator.

allAnswered() checks the rows through selected(), defined below. lock() updates the busy flag and the answer buttons' accessible disabled state. Event handlers also consult the busy flag: aria-disabled alone does not stop clicks. resetMotion() clears the transform, opacity and cue after an animation.

        function leaveDeck(message) {
          lock(true);
          pointer = null;
          stack.hidden = true;
          actions.hidden = true;
          ui.querySelector('.qsw-help').hidden = true;
          status.textContent = '';
          completion.textContent = message;
          completion.hidden = false;
          completion.focus();
        }
        function recover(message) {
          window.clearTimeout(timer);
          phase = 'recovery';
          leaveDeck(message);
          native.hidden = false;
        }
        function completeDeck() {
          if (phase !== 'answering' || !allAnswered()) return;
          phase = 'done';
          leaveDeck('Done.');
          coordinator.complete();
        }
        function allAnswered() {
          return rows.every(function (row) {
            return selected(row);
          });
        }
        function lock(value) {
          busy = value;
          ui.querySelectorAll('[data-answer]').forEach(function (b) {
            b.setAttribute('aria-disabled', String(value));
          });
        }
        function resetMotion() {
          card.style.transition = 'none';
          card.style.transform = '';
          card.style.opacity = '';
          cue.style.opacity = '0';
        }

6. Turn horizontal movement into a deliberate answer

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

threshold() uses 15% of card width, bounded between 36 and 72 pixels. commits() requires horizontal movement to exceed vertical movement by a factor of 1.2. A drag that crossed the threshold may pull back to 65% of it and still count, provided its direction is unchanged; peak records that earlier travel.

move() translates the card, damps its vertical movement, caps rotation at 14 degrees and fades it with distance. The cue announces whether release will commit and identifies the direction. snapBack() locks interaction, resets the visual position, then unlocks after 280 milliseconds. With reduced motion, the delay is zero. None of these functions writes an answer; that happens in choose().

        function threshold() {
          return Math.max(36, Math.min(72, card.clientWidth * 0.15));
        }
        // Directional intent, not a drop target. Small pullback after crossing is allowed.
        function commits(dx, dy, peak) {
          var distance = Math.abs(dx),
            required = threshold();
          return (
            distance > Math.abs(dy) * 1.2 &&
            (distance >= required ||
              (peak * dx > 0 && Math.abs(peak) >= required && distance >= required * 0.65))
          );
        }
        function move(dx, dy) {
          var fraction = Math.min(1, Math.abs(dx) / threshold());
          card.style.transition = 'none';
          card.style.transform =
            'translate(' +
            dx +
            'px,' +
            dy * 0.12 +
            'px) rotate(' +
            Math.max(-14, Math.min(14, dx / 18)) +
            'deg)';
          card.style.opacity = String(
            1 - Math.min(0.78, Math.abs(dx) / (card.clientWidth * 0.85))
          );
          cue.textContent =
            (commits(dx, dy, pointer ? pointer.peak : dx) ? 'Release to ' : '') +
            (dx < 0 ? 'Not interested' : 'Interested');
          cue.dataset.direction = dx < 0 ? 'c1' : 'c3';
          cue.style.opacity = String(fraction);
        }
        function snapBack() {
          lock(true);
          card.style.transition = reduced.matches
            ? 'none'
            : 'transform 280ms cubic-bezier(.2,1.4,.4,1), opacity 220ms ease';
          card.style.transform = '';
          card.style.opacity = '';
          cue.style.opacity = '0';
          timer = window.setTimeout(
            function () {
              resetMotion();
              lock(false);
            },
            reduced.matches ? 0 : 280
          );
        }

7. Read a selected row and render its card

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

Connect the help paragraph to the card with aria-describedby. The labels object supplies display text for each column. Start at the first unanswered row, falling back to index zero if all are answered.

selected() finds a checked native input and returns its column label or null. show() reads that selection, counts answered rows, assigns or removes the photo source, and splits the row text into title and description. It updates the card's accessible label, tone identifier, buttons' aria-pressed states and live progress message. Text comes from the native row, so changing a row's wording updates the card too.

        ui.querySelector('.qsw-help').id = helpId;
        card.setAttribute('aria-describedby', helpId);
        var labels = { c1: 'Not interested', c2: 'Not sure', c3: 'Interested' };
        var current = rows.findIndex(function (r) {
          return !selected(r);
        });
        if (current < 0) current = 0;
        function selected(row) {
          return (
            Object.keys(row.byCode).find(function (code) {
              return row.byCode[code].checked;
            }) || null
          );
        }
        function show(message) {
          var row = rows[current];
          var chosen = selected(row);
          var count = rows.filter(function (r) {
            return selected(r);
          }).length;
          photo.hidden = !row.image;
          if (row.image) photo.src = row.image;
          else photo.removeAttribute('src');
          var parts = row.title.split(' — ');
          card.querySelector('.qsw-title').textContent = parts.shift();
          card.querySelector('.qsw-description').textContent = parts.join(' — ');
          card.querySelector('.qsw-description').hidden = !parts.length;
          card.setAttribute(
            'aria-label',
            row.title +
              '. ' +
              (chosen ? 'Current answer: ' + labels[chosen] : 'Not yet answered')
          );
          card.dataset.tone = String(current % 4);
          ui.querySelectorAll('[data-answer]').forEach(function (button) {
            button.setAttribute('aria-pressed', String(button.dataset.answer === chosen));
          });
          status.textContent =
            (message ? message + ' ' : '') +
            'Card ' +
            (current + 1) +
            ' of ' +
            rows.length +
            ': ' +
            row.title +
            '. ' +
            count +
            ' of ' +
            rows.length +
            ' answered.';
        }

8. Commit the native answer before animating

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

Reject choices while busy, outside the answering phase, or when the target native input is disabled. Lock the current row and direction, clear the gesture, click the requested input and confirm it became checked. A rejected native click unlocks and returns the card to its position.

After a successful selection, prepare the announcement and cue. Negative/positive answers move left/right; Not sure moves upward and shrinks. The 310-millisecond callback waits for the exit animation, checks that the component is still answering, then either completes the deck or displays the next unanswered row. Reduced motion uses a zero delay. The saved answer exists before this cosmetic transition starts.

        function choose(code) {
          if (busy || phase !== 'answering') return;
          var row = rows[current];
          if (row.byCode[code].disabled) return;
          lock(true); // Direction and row are fixed until this one transition finishes.
          pointer = null;
          if (!row.byCode[code].checked) row.byCode[code].click();
          if (!row.byCode[code].checked) {
            lock(false);
            snapBack();
            return;
          }
          var message = row.title + ': ' + labels[code] + '.';
          var direction = code === 'c1' ? -1 : 1;
          cue.textContent = labels[code];
          cue.dataset.direction = code;
          cue.style.opacity = '1';
          card.style.transition = reduced.matches
            ? 'none'
            : 'transform 300ms cubic-bezier(.25,.65,.4,1), opacity 260ms ease';
          card.style.transform =
            code === 'c2'
              ? 'translateY(-35px) scale(.96)'
              : 'translateX(' +
                direction * (card.clientWidth + 120) +
                'px) rotate(' +
                direction * 18 +
                'deg)';
          card.style.opacity = '0';
          timer = window.setTimeout(
            function () {
              if (phase !== 'answering') return;
              if (
                rows.every(function (row) {
                  return selected(row);
                })
              ) {
                completeDeck();
                return;
              }
              current = rows.findIndex(function (row) {
                return !selected(row);
              });
              resetMotion();
              show(message);
              lock(false);
            },
            reduced.matches ? 0 : 310
          );
        }

9. Connect buttons, keys and pointer gestures

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

Buttons pass their data-answer to choose(). Left Arrow selects c1, Right Arrow selects c3, and S selects c2. Repeated or modified key presses are ignored; preventDefault stops the matching key's usual browser behavior.

Pointer down accepts only the primary left-button/touch contact, stores its ID and origin, and captures it. Pointer movement waits for eight pixels before deciding whether the gesture is mainly horizontal or vertical; only horizontal movement moves the card. It also updates the furthest horizontal point for the pullback rule.

endGesture() clears the tracked pointer and commits only on a qualifying pointerup; cancellation snaps back. A lost capture also resets an unfinished gesture. Every input route eventually uses the same native-answer function.

        ui.querySelectorAll('[data-answer]').forEach(function (button) {
          button.addEventListener('click', function () {
            choose(button.dataset.answer);
          });
        });
        card.addEventListener('keydown', function (event) {
          if (event.repeat || event.altKey || event.ctrlKey || event.metaKey) return;
          var code = { ArrowLeft: 'c1', ArrowRight: 'c3', s: 'c2', S: 'c2' }[event.key];
          if (code) {
            event.preventDefault();
            choose(code);
          }
        });
        card.addEventListener('pointerdown', function (event) {
          if (busy || !event.isPrimary || event.button !== 0) return;
          pointer = {
            id: event.pointerId,
            x: event.clientX,
            y: event.clientY,
            axis: null,
            peak: 0
          };
          card.setPointerCapture(event.pointerId);
        });
        card.addEventListener('pointermove', function (event) {
          if (!pointer || pointer.id !== event.pointerId || busy) return;
          var dx = event.clientX - pointer.x,
            dy = event.clientY - pointer.y;
          if (!pointer.axis && Math.max(Math.abs(dx), Math.abs(dy)) > 8) {
            if (Math.abs(dx) > Math.abs(dy) * 1.2) pointer.axis = 'x';
            else if (Math.abs(dy) > Math.abs(dx) * 1.2) pointer.axis = 'y';
          }
          if (pointer.axis === 'x') {
            if (dx * pointer.peak < 0 || Math.abs(dx) > Math.abs(pointer.peak))
              pointer.peak = dx;
            move(dx, dy);
          }
        });
        function endGesture(event) {
          if (!pointer || pointer.id !== event.pointerId || busy) return;
          var dx = event.clientX - pointer.x,
            dy = event.clientY - pointer.y,
            axis = pointer.axis,
            peak = pointer.peak;
          pointer = null;
          if (event.type === 'pointerup' && axis === 'x' && commits(dx, dy, peak))
            choose(dx < 0 ? 'c1' : 'c3');
          else snapBack();
        }
        card.addEventListener('pointerup', endGesture);
        card.addEventListener('pointercancel', endGesture);
        card.addEventListener('lostpointercapture', function () {
          if (pointer && !busy) {
            pointer = null;
            snapBack();
          }
        });

10. Expose the completed enhancement and its coordinator API

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

A native change redraws the current card when no transition is active. Then call show(), insert the UI before the native answers, hide the matrix, and mark the question ready—in that order. If all answers were restored, show Done without advancing again.

The testing-notice filter hides only two exact known test messages; it does not broadly remove survey warnings. The returned object gives the page coordinator the root, completion checks, a saving callback and recovery callback. Saving cancels pending motion, changes the phase and announces that answers are being saved. The scaffold closes initialize and the outer function, then calls the theme's ready() method.

        native.addEventListener('change', function () {
          if (!busy && phase === 'answering') show();
        });
        // Commit the enhancement only after successful discovery and setup.
        show();
        native.before(ui);
        native.hidden = true;
        root.dataset.swipeReady = 'true';
        if (allAnswered()) {
          phase = 'done';
          leaveDeck('Done.');
        }
        // Remove only observed test-only notices, never validation or other warnings.
        var page = root.closest('.survey-body');
        if (page) {
          page.classList.add('qsw-page');
          page.querySelectorAll('.survey-info, .survey-warning').forEach(function (notice) {
            var text = notice.textContent.replace(/\s+/g, ' ').trim();
            if (
              text === 'Please login to see additional testing features' ||
              text ===
                "I've detected that your browser has already completed this survey but I'll let you in anyway for testing"
            ) {
              notice.classList.add('qsw-test-notice');
              notice.hidden = true;
            }
          });
        }
        return {
          root: root,
          allAnswered: allAnswered,
          done: function () {
            return phase === 'done';
          },
          saving: function () {
            window.clearTimeout(timer);
            phase = 'submitting';
            leaveDeck('Saving your answers…');
          },
          recover: recover
        };

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 a test link from your own survey. Check the following before changing the artwork or questionnaire text:

  1. All four photos load and the title and ingredients are readable at desktop and phone widths.
  2. Answer the first three cards with Interested, Not interested and Not sure. Complete the fourth card and confirm that the survey reaches completion.
  3. Inspect the exported response: QSwiper1, QSwiper2 and QSwiper3 should contain 3, 1 and 2, respectively. The fourth row should contain the code you selected.
  4. In another test response, exercise left and right drags, the three buttons, and Left Arrow, Right Arrow and S while the card has focus. Try a short drag and a vertical drag; neither should answer a card.
  5. Check rapid repeated input, a missing image, and the native fallback with JavaScript disabled. Test actual phone touch and the browsers your study supports.

The repository records checks of the photo revision in hosted Decipher, but its notes explicitly leave physical phones, screen readers, Safari/Firefox and a fresh export comparison unverified for that revision. Treat your own export check as part of setup, not something the screenshot proves.

Reuse the component with Questra Fine Tuning

Once you have a version your team wants to reuse, put it in a Questra Fine Tuning template so Questra can create these elements for you and your team. This example already includes an importable template.

Import questra-template.json from Fine Tuning and select it when programming. The SwipeCards component accepts a native label, plain-text title and instructions, and one to twenty rows with unique labels, text and image filenames. Its shared base contains the CSS and JavaScript, and the four photos are bundled with the template.

You can ask Questra to create a swipe-card question for a new set of concepts using this template. Upload any new images as survey static files, keep the three answer columns consistent, and review the generated result in Decipher before fielding.

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="Café menu preferences"
  alt="Café menu preferences"
  compat="144"
  fir="on"
  html:showNumber="0"
  builder:wizardCompleted="1"
  state="testing"
  ss:enableNavigation="0"
  extraVariables="source"
>
  <style name="respview.client.css" mode="after"><![CDATA[
    <!-- questra-theme:start -->
    <style>
      /* Questra brand: questra-workspace/brand/motion/motion.js, BRAND (lines 51–52).
         Shared shell and native controls only. No font or stylesheet network dependency. */
      :root {
        --qt-chalk: #f5f1ec;
        --qt-coral: #ff505d;
        --qt-slate: #103743;
        --qt-line: #10374333;
        --qt-surface: #fff;
        --qt-radius: 24px;
        color-scheme: light;
      }
      html {
        background: var(--qt-chalk);
      }
      html body,
      html body.survey-page {
        margin: 0;
        background: var(--qt-chalk);
        color: var(--qt-slate);
        font:
          16px/1.5 Arial,
          Helvetica,
          sans-serif;
        min-height: 100vh;
      }
      body #survey {
        box-sizing: border-box;
        width: 100%;
        max-width: 1240px;
        margin: 0 auto;
        padding: 28px 32px 48px;
        background: transparent;
        border: 0;
        box-shadow: none;
        border-radius: 0;
      }
      body .survey-section,
      body .survey-body {
        box-sizing: border-box;
        max-width: none;
        width: 100%;
        margin: 0;
        padding: 0;
        background: transparent;
        border: 0;
        box-shadow: none;
      }
      body .survey-header,
      body .survey-footer {
        background: transparent;
        color: var(--qt-slate);
      }
      body .question {
        box-sizing: border-box;
        margin: 24px 0;
        padding: 0;
        background: transparent;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        color: var(--qt-slate);
      }
      body .question-text {
        font-size: clamp(22px, 3vw, 34px);
        line-height: 1.2;
        letter-spacing: -0.6px;
        color: var(--qt-slate);
      }
      body .instruction-text {
        color: var(--qt-slate);
        line-height: 1.6;
      }
      body .answers {
        max-width: 100%;
        overflow-x: auto;
      }
      body .answers input:not([type='radio']):not([type='checkbox']):not([type='hidden']),
      body .answers textarea,
      body .answers select {
        box-sizing: border-box;
        max-width: 100%;
        font: inherit;
        color: var(--qt-slate);
        background: white;
        border: 1px solid var(--qt-slate);
        border-radius: 8px;
        padding: 10px;
      }
      body input[type='radio'],
      body input[type='checkbox'] {
        accent-color: var(--qt-slate);
        min-width: 20px;
        min-height: 20px;
      }
      body .survey-buttons {
        display: flex;
        flex-wrap: wrap;
        gap: 12px;
        justify-content: flex-end;
        padding: 24px 0;
      }
      body #btn_continue,
      body #btn_finish,
      body #btn_back {
        font:
          700 16px/1.4 Arial,
          sans-serif;
        min-height: 48px;
        padding: 13px 26px;
        border: 2px solid var(--qt-slate);
        border-radius: 999px;
        background: var(--qt-slate);
        color: var(--qt-chalk);
        cursor: pointer;
        box-shadow: 4px 4px 0 var(--qt-coral);
      }
      body #btn_back {
        background: transparent;
        color: var(--qt-slate);
        box-shadow: none;
      }
      body :focus-visible {
        outline: 3px solid var(--qt-slate);
        outline-offset: 4px;
      }
      body .survey-error,
      body .question-error,
      body .error-text {
        color: var(--qt-slate);
        background: var(--qt-chalk);
        border-left: 4px solid var(--qt-coral);
        padding: 12px;
      }
      body .progress-bar,
      body [role='progressbar'] {
        display: none !important;
      }
      .qt-footer {
        display: flex;
        justify-content: center;
        padding: 32px 20px 40px;
        background: transparent;
      }
      .qt-footer img {
        display: block;
        width: 120px;
        height: 36px;
        object-fit: contain;
      }
      /* The optional early gate never hides the form, navigation or validation messages.
         CSS provides an independent fail-open deadline even if a later script throws. */
      html.qt-pending .question,
      html.qt-pending .outfit-ui,
      html.qt-pending .sh-ui {
        visibility: hidden;
        animation: qt-reveal 0s 1.8s forwards;
      }
      html.qt-pending .survey-body:before {
        content: 'Preparing your activity…';
        display: block;
        padding: 24px;
        border-left: 4px solid var(--qt-coral);
        animation: qt-dismiss 0s 1.8s forwards;
      }
      \@keyframes qt-reveal {
        to {
          visibility: visible;
        }
      }
      \@keyframes qt-dismiss {
        to {
          display: none;
          visibility: hidden;
          height: 0;
          padding: 0;
        }
      }
      \@media (max-width: 650px) {
        body #survey {
          padding: 16px 12px 32px;
        }
        body .question {
          padding: 0;
          border-radius: 0;
          margin: 16px 0;
        }
      }
      \@media (prefers-reduced-motion: reduce) {
        body {
          scroll-behavior: auto;
        }
      }
    </style>
 
    <script>
      /* Runs inline in the head, before body parsing; no jQuery dependency. */
      (function () {
        'use strict';
        var root = document.documentElement;
        var expired = false;
        root.classList.add('qt-pending');
        function reveal(fallback) {
          if (fallback) expired = true;
          root.classList.remove('qt-pending');
        }
        window.QuestraTheme = {
          canEnhance: function () {
            return !expired;
          },
          ready: function () {
            reveal(false);
          }
        };
        setTimeout(function () {
          reveal(true);
        }, 1800);
        // Keyboard/pointer intent takes precedence over cosmetic loading.
        document.addEventListener(
          'keydown',
          function () {
            reveal(true);
          },
          { once: true, capture: true }
        );
        document.addEventListener(
          'pointerdown',
          function () {
            reveal(true);
          },
          { once: true, capture: true }
        );
        window.addEventListener('pageshow', function (event) {
          if (event.persisted) reveal(true);
        });
      })();
    </script>
    <!-- questra-theme:end -->
    <style>
      /* qsw-shared-css */
      .question.qswipe {
        max-width: 580px;
        margin-left: auto;
        margin-right: auto;
        --qsw-ink: var(--qt-slate);
        --qsw-green: var(--qt-slate);
      }
      .question.qswipe .qsw-ui {
        overflow: clip;
        padding: 12px 10px;
        max-width: 480px;
        margin: 24px auto 12px;
        color: var(--qsw-ink);
        font:
          16px/1.5 system-ui,
          sans-serif;
      }
      .question.qswipe .qsw-ui * {
        box-sizing: border-box;
      }
      .question.qswipe .qsw-card {
        position: relative;
        z-index: 1;
        overflow: hidden;
        will-change: transform, opacity;
        border: 1px solid #10374340;
        border-radius: 24px;
        box-shadow: none;
        background: var(--qt-chalk);
        touch-action: pan-y;
        user-select: none;
      }
      .question.qswipe .qsw-card-copy {
        padding: 36px 28px;
        min-height: 220px;
        display: flex;
        flex-direction: column;
        justify-content: center;
      }
      .question.qswipe .qsw-eyebrow {
        margin: 0 0 18px;
        font-size: 12px;
        font-weight: 650;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }
      .question.qswipe .qsw-title {
        font:
          650 27px/1.2 system-ui,
          sans-serif;
        letter-spacing: -0.04em;
        margin: 0;
        color: var(--qsw-ink);
      }
      .question.qswipe .qsw-description {
        margin: 16px 0 0;
        font-size: 18px;
        line-height: 1.5;
      }
      .question.qswipe .qsw-actions {
        display: flex;
        gap: 10px;
        margin: 18px 0 10px;
      }
      .question.qswipe .qsw-ui button {
        cursor: pointer;
        font:
          600 14px/1.3 system-ui,
          sans-serif;
        border: 1px solid #ccd8cf;
        border-radius: 13px;
        background: white;
        color: var(--qsw-ink);
        min-height: 44px;
        padding: 10px 12px;
      }
      .question.qswipe .qsw-actions button {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
      }
      .question.qswipe .qsw-actions button span {
        font-size: 24px;
        font-weight: 400;
        line-height: 1;
      }
      .question.qswipe .qsw-actions [data-answer='c1'] {
        background: #fff2ed;
        border-color: #e6cfc4;
      }
      .question.qswipe .qsw-actions [data-answer='c3'] {
        background: var(--qsw-green);
        color: white;
        border-color: var(--qsw-green);
      }
      .question.qswipe .qsw-actions [aria-pressed='true'] {
        outline: 3px solid #b08037;
        outline-offset: 3px;
      }
      .question.qswipe .qsw-ui button[aria-disabled='true'] {
        opacity: 0.45;
        cursor: default;
      }
      .question.qswipe .qsw-ui :focus-visible {
        outline: 3px solid #2257a7;
        outline-offset: 4px;
      }
      .question.qswipe [hidden] {
        display: none !important;
      }
      \@media (prefers-reduced-motion: reduce) {
        .question.qswipe .qsw-card {
          transform: none !important;
          transition: none !important;
        }
      }
      .question.qswipe .qsw-sr {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
      }
 
      .question.qswipe .qsw-stack {
        position: relative;
      }
      .question.qswipe .qsw-cue {
        position: absolute;
        top: 24px;
        left: 24px;
        padding: 5px 12px;
        border: 3px solid currentColor;
        border-radius: 8px;
        color: #16624b;
        background: #ffffffed;
        font:
          750 23px/1.3 system-ui,
          sans-serif;
        opacity: 0;
        transform: rotate(-10deg);
      }
      .question.qswipe .qsw-cue[data-direction='c1'] {
        color: #a34432;
        left: auto;
        right: 24px;
        transform: rotate(10deg);
      }
      .question.qswipe .qsw-cue[data-direction='c2'] {
        color: #66548a;
      }
 
      form.qsw-form .qsw-continue-hidden {
        display: none !important;
      }
      .question.qswipe .qsw-completion {
        padding: 30px 12px;
        margin: 0;
        text-align: center;
        font:
          600 18px/1.5 system-ui,
          sans-serif;
      }
 
      /* Photo cards: image is decorative; native text remains the answer label. */
      .question.qswipe .qsw-ui {
        max-width: 440px;
        margin: 12px auto;
        padding: 8px;
      }
      .question.qswipe .qsw-card {
        background: #103743;
        border: 0;
        border-radius: 24px;
        isolation: isolate;
      }
      .question.qswipe .qsw-photo {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
        object-position: 50% 40%;
        pointer-events: none;
        z-index: -2;
      }
      .question.qswipe .qsw-card::after {
        content: '';
        position: absolute;
        inset: 0;
        z-index: -1;
        pointer-events: none;
        background: linear-gradient(
          180deg,
          transparent 25%,
          rgba(5, 18, 23, 0.12) 40%,
          rgba(5, 18, 23, 0.82) 70%,
          #051217 100%
        );
      }
      .question.qswipe .qsw-card-copy {
        min-height: 440px;
        min-height: clamp(350px, 58svh, 520px);
        padding: 150px 24px 26px;
        justify-content: flex-end;
        color: #fff;
      }
      .question.qswipe .qsw-eyebrow {
        font-size: 11px;
        letter-spacing: 0.12em;
        margin: 0 0 9px;
        color: #fff;
      }
      .question.qswipe .qsw-title {
        font-size: 32px;
        font-weight: 750;
        line-height: 1.08;
        color: #fff;
        letter-spacing: -0.035em;
      }
      .question.qswipe .qsw-description {
        font-size: 16px;
        line-height: 1.4;
        margin: 12px 0 0;
        color: #fff;
      }
      .question.qswipe .qsw-actions {
        gap: 8px;
        margin: 12px 0 0;
      }
      .question.qswipe .qsw-actions button {
        flex-direction: column;
        gap: 5px;
        min-height: 64px;
        padding: 8px 4px;
        font-size: 12px;
        border-radius: 16px;
      }
      .question.qswipe .qsw-actions button span {
        font-size: 27px;
      }
      .question.qswipe .qsw-card:focus-visible {
        outline: 3px solid #ff505d;
        outline-offset: 3px;
      }
      .question.qswipe .qsw-cue {
        font-size: 17px;
        top: 18px;
        left: 18px;
        max-width: 85%;
      }
      \@media (max-width: 480px) {
        .question.qswipe .qsw-ui {
          padding: 5px;
        }
        .question.qswipe .qsw-card-copy {
          padding: 125px 20px 24px;
          min-height: clamp(280px, 48svh, 480px);
        }
        .question.qswipe .qsw-title {
          font-size: 30px;
        }
      }
    </style>
  ]]></style>
  <style name="respview.client.js" mode="after" wrap="ready"><![CDATA[
    /* qsw-shared-js: one definition per respondent page; native radios are the only answer model. */
    (function () {
      'use strict';
      if (window.QuestraTheme && !window.QuestraTheme.canEnhance()) return;
      document.querySelectorAll('form').forEach(function (form) {
        if (form.dataset.qswSharedReady) return;
        var roots = Array.from(form.querySelectorAll('.question.qswipe'));
        var button = form.querySelector('#btn_continue, #btn_finish');
        if (!roots.length || !button || button.disabled) return;
        var instances = [],
          attempted = false,
          pending = false,
          submitSeen = false,
          recovering = false,
          recoveryTimer = null;
        // Never infer another question type's completion or bypass its validation.
        function autoEligible() {
          var visible = Array.from(form.querySelectorAll('.question')).filter(function (q) {
            return q.getClientRects().length > 0;
          });
          return (
            visible.length > 0 &&
            visible.every(function (q) {
              return instances.some(function (i) {
                return i.root === q;
              });
            })
          );
        }
        function recover(message) {
          window.clearTimeout(recoveryTimer);
          recovering = true;
          pending = false;
          submitSeen = false;
          instances.forEach(function (i) {
            i.recover(message || 'Please review your selected answers, then use Continue.');
          });
          button.classList.remove('qsw-continue-hidden');
          button.disabled = false;
        }
        function beginRequest() {
          pending = true;
          submitSeen = false;
          instances.forEach(function (i) {
            i.saving();
          });
          recoveryTimer = window.setTimeout(function () {
            recover(
              'This is taking longer than expected. Your answers are still selected. Use Continue to try again.'
            );
          }, 12000);
        }
        var coordinator = {
          complete: function () {
            if (!autoEligible()) {
              button.classList.remove('qsw-continue-hidden');
              return;
            }
            if (
              attempted ||
              pending ||
              recovering ||
              !instances.every(function (i) {
                return i.done() && i.allAnswered();
              })
            )
              return;
            attempted = true;
            if (button.disabled || !button.isConnected) {
              recover();
              return;
            }
            try {
              button.click();
            } catch (error) {
              recover();
            }
          }
        };
        roots.forEach(function (root) {
          try {
            var instance = initialize(root, form, coordinator);
            if (instance) instances.push(instance);
          } catch (error) {
            // A failed question keeps native fields available; it also prevents automatic submission.
            var native = root.querySelector('.answers');
            if (native) native.hidden = false;
            var partial = root.querySelector('.qsw-ui');
            if (partial) partial.remove();
            delete root.dataset.swipeReady;
          }
        });
        if (!instances.length) return;
        form.dataset.qswSharedReady = 'true';
        form.classList.add('qsw-form');
        if (autoEligible()) button.classList.add('qsw-continue-hidden');
        button.addEventListener(
          'click',
          function (event) {
            if (pending) {
              event.preventDefault();
              event.stopImmediatePropagation();
              return;
            }
            beginRequest();
          },
          true
        );
        form.addEventListener(
          'submit',
          function (event) {
            if (submitSeen) {
              event.preventDefault();
              event.stopImmediatePropagation();
              return;
            }
            if (!pending) beginRequest();
            submitSeen = true;
          },
          true
        );
        form.addEventListener(
          'invalid',
          function () {
            recover('Please check the highlighted answers, then use Continue.');
          },
          true
        );
        window.addEventListener('pageshow', function (event) {
          if (event.persisted) recover();
        });
        // Server redisplay of restored values must never cause a submission loop.
        if (
          instances.every(function (i) {
            return i.allAnswered();
          })
        )
          recover();
      });
      function initialize(root, form, coordinator) {
        if (root.dataset.swipeReady) return null;
        var native = root.querySelector('.answers');
        var groups = new Map();
        if (!native) return null;
        native.querySelectorAll('input[type="radio"]').forEach(function (input) {
          if (!groups.has(input.name)) groups.set(input.name, []);
          groups.get(input.name).push(input);
        });
        var rows = Array.from(groups.values()).map(function (inputs) {
          var tr = inputs[0].closest('tr');
          var legend = tr && tr.querySelector('.row-legend');
          var byCode = {};
          inputs.forEach(function (input) {
            var cell = input.closest('td');
            var headers = cell && cell.getAttribute('headers');
            ['c1', 'c2', 'c3'].forEach(function (code) {
              if (
                headers &&
                headers.split(/\s+/).some(function (h) {
                  return h.endsWith('_' + code);
                })
              )
                byCode[code] = input;
            });
          });
          var media = legend && legend.querySelector('[data-qsw-image]');
          return {
            inputs: inputs,
            byCode: byCode,
            title: legend && legend.textContent.trim(),
            image: media && media.getAttribute('data-qsw-image')
          };
        });
        // Unsupported native markup leaves the matrix fully usable.
        if (
          !rows.length ||
          rows.some(function (r) {
            return (
              !r.title ||
              r.inputs.length !== 3 ||
              !r.byCode.c1 ||
              !r.byCode.c2 ||
              !r.byCode.c3
            );
          })
        )
          return;
        var ui = document.createElement('section');
        ui.className = 'qsw-ui';
        ui.setAttribute('aria-label', 'Swipe cards');
        var helpId = root.id + '-swipe-help';
        ui.innerHTML =
          '<p class="qsw-sr qsw-status" role="status" aria-live="polite" aria-atomic="true"></p>' +
          '<p class="qsw-sr qsw-help">Swipe left for Not interested or right for Interested. On the focused card, press Left Arrow for Not interested, S for Not sure, or Right Arrow for Interested. Not sure is an explicit answer.</p>' +
          '<div class="qsw-stack"><article class="qsw-card" tabindex="0" aria-roledescription="swipe card"><img class="qsw-photo" alt="" draggable="false" decoding="async" /><div class="qsw-card-copy"><p class="qsw-eyebrow">Seasonal café menu</p><h2 class="qsw-title"></h2><p class="qsw-description"></p></div><span class="qsw-cue" aria-hidden="true"></span></article></div>' +
          '<div class="qsw-actions" role="group" aria-label="Choose an answer"><button type="button" data-answer="c1"><span aria-hidden="true">−</span>Not interested</button><button type="button" data-answer="c2"><span aria-hidden="true">→</span>Not sure</button><button type="button" data-answer="c3"><span aria-hidden="true">+</span>Interested</button></div>';
        var completion = document.createElement('p');
        completion.className = 'qsw-completion';
        completion.setAttribute('role', 'status');
        completion.setAttribute('tabindex', '-1');
        completion.hidden = true;
        ui.appendChild(completion);
        var stack = ui.querySelector('.qsw-stack');
        var actions = ui.querySelector('.qsw-actions');
        var card = ui.querySelector('.qsw-card');
        var photo = card.querySelector('.qsw-photo');
        photo.addEventListener('error', function () {
          photo.hidden = true;
        });
        rows.forEach(function (row) {
          if (row.image) {
            var preload = new Image();
            preload.src = row.image;
          }
        });
        var status = ui.querySelector('.qsw-status');
        var cue = card.querySelector('.qsw-cue');
        var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
        var busy = false,
          timer = null,
          pointer = null;
        var phase = 'answering';
        function leaveDeck(message) {
          lock(true);
          pointer = null;
          stack.hidden = true;
          actions.hidden = true;
          ui.querySelector('.qsw-help').hidden = true;
          status.textContent = '';
          completion.textContent = message;
          completion.hidden = false;
          completion.focus();
        }
        function recover(message) {
          window.clearTimeout(timer);
          phase = 'recovery';
          leaveDeck(message);
          native.hidden = false;
        }
        function completeDeck() {
          if (phase !== 'answering' || !allAnswered()) return;
          phase = 'done';
          leaveDeck('Done.');
          coordinator.complete();
        }
        function allAnswered() {
          return rows.every(function (row) {
            return selected(row);
          });
        }
        function lock(value) {
          busy = value;
          ui.querySelectorAll('[data-answer]').forEach(function (b) {
            b.setAttribute('aria-disabled', String(value));
          });
        }
        function resetMotion() {
          card.style.transition = 'none';
          card.style.transform = '';
          card.style.opacity = '';
          cue.style.opacity = '0';
        }
        function threshold() {
          return Math.max(36, Math.min(72, card.clientWidth * 0.15));
        }
        // Directional intent, not a drop target. Small pullback after crossing is allowed.
        function commits(dx, dy, peak) {
          var distance = Math.abs(dx),
            required = threshold();
          return (
            distance > Math.abs(dy) * 1.2 &&
            (distance >= required ||
              (peak * dx > 0 && Math.abs(peak) >= required && distance >= required * 0.65))
          );
        }
        function move(dx, dy) {
          var fraction = Math.min(1, Math.abs(dx) / threshold());
          card.style.transition = 'none';
          card.style.transform =
            'translate(' +
            dx +
            'px,' +
            dy * 0.12 +
            'px) rotate(' +
            Math.max(-14, Math.min(14, dx / 18)) +
            'deg)';
          card.style.opacity = String(
            1 - Math.min(0.78, Math.abs(dx) / (card.clientWidth * 0.85))
          );
          cue.textContent =
            (commits(dx, dy, pointer ? pointer.peak : dx) ? 'Release to ' : '') +
            (dx < 0 ? 'Not interested' : 'Interested');
          cue.dataset.direction = dx < 0 ? 'c1' : 'c3';
          cue.style.opacity = String(fraction);
        }
        function snapBack() {
          lock(true);
          card.style.transition = reduced.matches
            ? 'none'
            : 'transform 280ms cubic-bezier(.2,1.4,.4,1), opacity 220ms ease';
          card.style.transform = '';
          card.style.opacity = '';
          cue.style.opacity = '0';
          timer = window.setTimeout(
            function () {
              resetMotion();
              lock(false);
            },
            reduced.matches ? 0 : 280
          );
        }
        ui.querySelector('.qsw-help').id = helpId;
        card.setAttribute('aria-describedby', helpId);
        var labels = { c1: 'Not interested', c2: 'Not sure', c3: 'Interested' };
        var current = rows.findIndex(function (r) {
          return !selected(r);
        });
        if (current < 0) current = 0;
        function selected(row) {
          return (
            Object.keys(row.byCode).find(function (code) {
              return row.byCode[code].checked;
            }) || null
          );
        }
        function show(message) {
          var row = rows[current];
          var chosen = selected(row);
          var count = rows.filter(function (r) {
            return selected(r);
          }).length;
          photo.hidden = !row.image;
          if (row.image) photo.src = row.image;
          else photo.removeAttribute('src');
          var parts = row.title.split(' — ');
          card.querySelector('.qsw-title').textContent = parts.shift();
          card.querySelector('.qsw-description').textContent = parts.join(' — ');
          card.querySelector('.qsw-description').hidden = !parts.length;
          card.setAttribute(
            'aria-label',
            row.title +
              '. ' +
              (chosen ? 'Current answer: ' + labels[chosen] : 'Not yet answered')
          );
          card.dataset.tone = String(current % 4);
          ui.querySelectorAll('[data-answer]').forEach(function (button) {
            button.setAttribute('aria-pressed', String(button.dataset.answer === chosen));
          });
          status.textContent =
            (message ? message + ' ' : '') +
            'Card ' +
            (current + 1) +
            ' of ' +
            rows.length +
            ': ' +
            row.title +
            '. ' +
            count +
            ' of ' +
            rows.length +
            ' answered.';
        }
        function choose(code) {
          if (busy || phase !== 'answering') return;
          var row = rows[current];
          if (row.byCode[code].disabled) return;
          lock(true); // Direction and row are fixed until this one transition finishes.
          pointer = null;
          if (!row.byCode[code].checked) row.byCode[code].click();
          if (!row.byCode[code].checked) {
            lock(false);
            snapBack();
            return;
          }
          var message = row.title + ': ' + labels[code] + '.';
          var direction = code === 'c1' ? -1 : 1;
          cue.textContent = labels[code];
          cue.dataset.direction = code;
          cue.style.opacity = '1';
          card.style.transition = reduced.matches
            ? 'none'
            : 'transform 300ms cubic-bezier(.25,.65,.4,1), opacity 260ms ease';
          card.style.transform =
            code === 'c2'
              ? 'translateY(-35px) scale(.96)'
              : 'translateX(' +
                direction * (card.clientWidth + 120) +
                'px) rotate(' +
                direction * 18 +
                'deg)';
          card.style.opacity = '0';
          timer = window.setTimeout(
            function () {
              if (phase !== 'answering') return;
              if (
                rows.every(function (row) {
                  return selected(row);
                })
              ) {
                completeDeck();
                return;
              }
              current = rows.findIndex(function (row) {
                return !selected(row);
              });
              resetMotion();
              show(message);
              lock(false);
            },
            reduced.matches ? 0 : 310
          );
        }
        ui.querySelectorAll('[data-answer]').forEach(function (button) {
          button.addEventListener('click', function () {
            choose(button.dataset.answer);
          });
        });
        card.addEventListener('keydown', function (event) {
          if (event.repeat || event.altKey || event.ctrlKey || event.metaKey) return;
          var code = { ArrowLeft: 'c1', ArrowRight: 'c3', s: 'c2', S: 'c2' }[event.key];
          if (code) {
            event.preventDefault();
            choose(code);
          }
        });
        card.addEventListener('pointerdown', function (event) {
          if (busy || !event.isPrimary || event.button !== 0) return;
          pointer = {
            id: event.pointerId,
            x: event.clientX,
            y: event.clientY,
            axis: null,
            peak: 0
          };
          card.setPointerCapture(event.pointerId);
        });
        card.addEventListener('pointermove', function (event) {
          if (!pointer || pointer.id !== event.pointerId || busy) return;
          var dx = event.clientX - pointer.x,
            dy = event.clientY - pointer.y;
          if (!pointer.axis && Math.max(Math.abs(dx), Math.abs(dy)) > 8) {
            if (Math.abs(dx) > Math.abs(dy) * 1.2) pointer.axis = 'x';
            else if (Math.abs(dy) > Math.abs(dx) * 1.2) pointer.axis = 'y';
          }
          if (pointer.axis === 'x') {
            if (dx * pointer.peak < 0 || Math.abs(dx) > Math.abs(pointer.peak))
              pointer.peak = dx;
            move(dx, dy);
          }
        });
        function endGesture(event) {
          if (!pointer || pointer.id !== event.pointerId || busy) return;
          var dx = event.clientX - pointer.x,
            dy = event.clientY - pointer.y,
            axis = pointer.axis,
            peak = pointer.peak;
          pointer = null;
          if (event.type === 'pointerup' && axis === 'x' && commits(dx, dy, peak))
            choose(dx < 0 ? 'c1' : 'c3');
          else snapBack();
        }
        card.addEventListener('pointerup', endGesture);
        card.addEventListener('pointercancel', endGesture);
        card.addEventListener('lostpointercapture', function () {
          if (pointer && !busy) {
            pointer = null;
            snapBack();
          }
        });
        native.addEventListener('change', function () {
          if (!busy && phase === 'answering') show();
        });
        // Commit the enhancement only after successful discovery and setup.
        show();
        native.before(ui);
        native.hidden = true;
        root.dataset.swipeReady = 'true';
        if (allAnswered()) {
          phase = 'done';
          leaveDeck('Done.');
        }
        // Remove only observed test-only notices, never validation or other warnings.
        var page = root.closest('.survey-body');
        if (page) {
          page.classList.add('qsw-page');
          page.querySelectorAll('.survey-info, .survey-warning').forEach(function (notice) {
            var text = notice.textContent.replace(/\s+/g, ' ').trim();
            if (
              text === 'Please login to see additional testing features' ||
              text ===
                "I've detected that your browser has already completed this survey but I'll let you in anyway for testing"
            ) {
              notice.classList.add('qsw-test-notice');
              notice.hidden = true;
            }
          });
        }
        return {
          root: root,
          allAnswered: allAnswered,
          done: function () {
            return phase === 'done';
          },
          saving: function () {
            window.clearTimeout(timer);
            phase = 'submitting';
            leaveDeck('Saving your answers…');
          },
          recover: recover
        };
      }
    })();
 
    if (window.QuestraTheme) window.QuestraTheme.ready();
  ]]></style>
  <radio label="QSwipe" type="rating" optional="0" ss:questionClassNames="qswipe">
    <title>Which of these drinks would you consider ordering?</title>
    <comment>Think about your next café visit. Swipe left for Not interested, right for Interested, or choose an answer below.</comment>
    <row label="r1"><span data-qsw-image="[rel honey-oat-latte.jpg]">Honey oat latte — espresso with oat milk and a touch of honey</span></row>
    <row label="r2"><span data-qsw-image="[rel peach-iced-tea.jpg]">Peach iced tea — black tea with peach and a squeeze of lemon</span></row>
    <row label="r3"><span data-qsw-image="[rel vanilla-cold-brew.jpg]">Vanilla cold brew — slow-steeped coffee with vanilla cream</span></row>
    <row label="r4"><span data-qsw-image="[rel strawberry-matcha.jpg]">Strawberry matcha — green tea with milk and strawberry purée</span></row>
    <col label="c1" value="1">Not interested</col>
    <col label="c2" value="2">Not sure</col>
    <col label="c3" value="3">Interested</col>
  </radio>
  <suspend />
  <!-- questra-theme:start -->
  <style name="survey.completion"><![CDATA[]]></style>
  <style name="survey.respview.footer" mode="after"><![CDATA[<footer class="qt-footer"><img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDE2MDAgNDgwIj48cGF0aCBkPSJNMTE1Ni4wNSwzMDAuNDFjLTM0LjgyLDQwLjQtNzIuNzUsNzIuNjYtMTE1LjM4LDk4LjU0LTg2Ljk0LDUyLjc3LTE5My42OCw3OC40Mi0zMjYuMTcsNzguNDJzLTIzOS4yOC0yNS42NS0zMjYuMjItNzguNDJjLTQ0LjQtMjYuOTQtODMuNjYtNjAuNzMtMTE5LjY4LTEwMy41MywxNC4yNS00LjkzLDI3LjMyLTExLjk2LDM4Ljg1LTIwLjc5LDc3LjM4LDg4LjQyLDE5NS4zMywxNTkuNTgsNDA3LjA1LDE1OS41OCwxOTYuOTUsMCwzMTIuNzgtNjEuNiwzOTAuMjMtMTQxLjM3LDExLjExLDUuMzUsMjQuMzIsOC4yNywzOS4xLDguMjcsNC4yMSwwLDguMjktLjIzLDEyLjIxLS43WiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik0zOTIuNDMsMjA2LjY1Vjc4LjQyaDQ0LjE5djExNS4yMWMwLDI4LjgsMTguNTQsNDguMTQsNDYuOTUsNDguMTQsMjkuOTksMCw1MC4xMS0xOS43Myw1MC4xMS00OC45M3YtMTE0LjQyaDQ0LjE5djE0MC4wN2MwLDExLjQ0LDcuMSwxOC41NCwxOC41NCwxOC41NGgzLjk0djQyLjYxaC0xMy4wMmMtMjYuNDMsMC00My40LTEwLjI2LTUwLjExLTI5LjItMTUuMzksMjIuMS0zOC42NywzMy45My02Ny44NywzMy45My00Ni4xNiwwLTc2Ljk0LTMwLjc4LTc2Ljk0LTc3LjczWiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik02MTguMjIsMTc5LjAzYzAtNTkuOTcsNDYuMTYtMTA1LjM1LDEwNy4zMy0xMDUuMzVzMTA3LjcyLDQ1LjM4LDEwNy43MiwxMDUuNzVjMCw1LjUyLS40LDExLjQ0LTEuMTksMTYuNTdoLTE2Ni41MWM2LjcxLDI5LjIsMzAuMzgsNDguNTMsNjEuOTUsNDguNTMsMjMuMjgsMCw0My40LTEwLjI2LDUzLjY2LTI1LjY1aDQ4LjkzYy0xNi4xOCwzOS4wNi01NS42Myw2NS41LTEwMy4zOCw2NS41LTYyLjM0LDAtMTA4LjUxLTQ1LjM3LTEwOC41MS0xMDUuMzVaTTY2NS45NiwxNTkuN2gxMTkuOTVjLTYuNzEtMjcuNjItMzAuMzgtNDYuMTYtNjAuMzctNDYuMTZzLTUyLjQ4LDE4LjE1LTU5LjU4LDQ2LjE2WiIgZmlsbD0iIzEwMzc0MyIvPjxwYXRoIGQ9Ik04NjAuMDYsMjIwLjQ2aDQ0Ljk4YzAsMTUuMzksMTQuNiwyNS42NSwzNS45MSwyNS42NSwxOS43MywwLDMyLjc0LTkuMDgsMzIuNzQtMjEuNywwLTM2LjMtMTEwLjQ3LTE2LjE4LTExMC40Ny04Ny41OSwwLTM5LjA2LDMxLjE3LTYzLjEzLDc1Ljc2LTYzLjEzczc2LjU1LDI0Ljg2LDc2LjU1LDYxLjE2aC00NC45OGMwLTEzLjgxLTEzLjAyLTIzLjI4LTMxLjk2LTIzLjI4cy0zMC43Nyw4LjI5LTMwLjc3LDIwLjUyYzAsMzUuMTEsMTEwLjg3LDE1Ljc4LDExMC44Nyw4Ny41OSwwLDM5LjA2LTMxLjE3LDY0LjcxLTc4LjEyLDY0Ljcxcy04MC40OS0yNi4wNC04MC40OS02My45MloiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMTIzOC41Nyw3OC40Mmg0NC4xOXYzMy41NGMxNS43OS0yMi44OCwzOS4wNi0zNi42OSw2Ny40Ny0zNi42OWg3LjF2NDUuMzdoLTcuMWMtNDIuNjIsMC02Ny40NywyNC4wNy02Ny40Nyw2MS4xNnY5Ny44NWgtNDQuMTlWNzguNDJaIiBmaWxsPSIjMTAzNzQzIi8+PHBhdGggZD0iTTEzNjQuMDUsMTc5LjAzYzAtNTkuOTcsNDIuMjItMTA1LjM1LDk5LjAzLTEwNS4zNSwyOS42LDAsNTQuMDYsMTIuNjMsNzAuNjMsMzMuNTR2LTI4LjhoNDQuMTl2MTQwLjA3YzAsMTEuNDQsNi43MSwxOC41NCwxOC41NCwxOC41NGgzLjU1djQyLjYxaC0xMi42M2MtMjcuNjIsMC00NC45OC0xMS4wNS01MC45LTMxLjk2LTE2Ljk3LDIyLjg5LTQyLjYyLDM2LjctNzMuNCwzNi43LTU2LjgxLDAtOTkuMDMtNDUuMzctOTkuMDMtMTA1LjM1Wk0xNTMzLjcxLDE3OS4wM2MwLTM1LjUxLTI2LjQzLTYyLjc0LTYxLjk0LTYyLjc0cy02Mi4zNCwyNy4yMi02Mi4zNCw2Mi43NCwyNi40NCw2Mi43NCw2Mi4zNCw2Mi43NCw2MS45NC0yNy4yMiw2MS45NC02Mi43NFoiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMTQ0LjI2LDgxLjg3Yy01Ljk4LDcuMzQtMTAuOTEsMTUuNi0xNC40MywyNC42Nkg0OS4zN3YtMjQuNjZoOTQuODlaIiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeT0iODEuODUiIHdpZHRoPSIyNC42NyIgaGVpZ2h0PSIyNC42NyIgZmlsbD0iI2ZmNTA1ZCIvPjxwYXRoIGQ9Ik0xMjIuOTIsMTQzLjUyYzAsNC4xOS4yNSw4LjMyLjc0LDEyLjMzaC00OS42M3YtMjQuNjZoNDkuNjNjLS40OSw0LjAxLS43NCw4LjE0LS43NCwxMi4zM1oiIGZpbGw9IiNmZjUwNWQiLz48cGF0aCBkPSJNMTQ0LjI2LDIwNS4xOEg0OS4zN3YtMjQuNjZoODAuNDZjMy41MSw5LjA2LDguNDUsMTcuMzMsMTQuNDMsMjQuNjZaIiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeD0iMjQuNjciIHk9IjEzMS4xOSIgd2lkdGg9IjI0LjY3IiBoZWlnaHQ9IjI0LjY3IiBmaWxsPSIjZmY1MDVkIi8+PHJlY3QgeT0iMTgwLjUzIiB3aWR0aD0iMjQuNjciIGhlaWdodD0iMjQuNjciIGZpbGw9IiNmZjUwNWQiLz48cGF0aCBkPSJNMTA3Ni43NSwyMjEuMjV2LTEwMi41OWgtMzMuOTN2LTQwLjI0aDIwLjUxYzEwLjI2LDAsMTUuNzktNS41MiwxNS43OS0xNS43OFYyMy45N2g0MS44MnY1NC40NWg2NS44OXY0MC4yNGgtNjUuODl2OTguMjVjMCwxMy40MSw5LjA4LDIyLjEsMjIuODksMjIuMXMyMi44OS05LjQ3LDIyLjg5LTI1LjI1aDM5LjQ2YzAsNDAuMjQtMjUuMjUsNjUuODktNjUuNSw2NS44OXMtNjMuOTItMjMuMjgtNjMuOTItNTguNFoiIGZpbGw9IiMxMDM3NDMiLz48cGF0aCBkPSJNMzYxLjA0LDE0Mi43MmMwLDgwLjA5LTYxLjU0LDE0MC4wMi0xNDMuMjMsMTQwLjAyLTQ2Ljk4LDAtODcuNDktMjAuMjktMTEzLjMyLTUyLjloNzEuNThjMTIuNDUsNS45MiwyNi42NCw5LjE4LDQxLjc0LDkuMTgsMS45OSwwLDMuOTYtLjA2LDUuOTEtLjE3LTcuMDgtMTAuNjctMTMuOTktMjEuNzItMjEuMDYtMzMuNjhsMzcuMTgtMjIuMDFjOC42NywxNC42NywxNy40OSwyOC43MSwyNy4yNCw0Mi44NiwyNy44Ni0xNi4yNyw0NS44Ny00Ni40NSw0NS44Ny04Mi41LDAtNTQuNDQtNDEuMDctOTUuNTEtOTUuMTQtOTUuNTEtMTUuMSwwLTI5LjI5LDMuMjctNDEuNzQsOS4xOWgtNzMuNjhDMTI4LjEsMjMuNDcsMTY5LjUzLDIuNjMsMjE3LjgxLDIuNjNjODEuNjksMCwxNDMuMjMsNjAsMTQzLjIzLDE0MC4wOVoiIGZpbGw9IiMxMDM3NDMiLz48L3N2Zz4=" data-brand-source="https://www.questra.ai/logo/primary/full-color.svg" alt="Questra" width="120" height="36" decoding="async" /></footer>]]></style>
  <!-- questra-theme:end -->
</survey>

See how Questra fits your survey workflow.

Book a demo