In this tutorial, you will build an outfit selection activity in Decipher. Respondents choose a shirt, pants and shoes, then decide whether to add a hat. They can tap an item or drag it into the outfit, and repeat the activity across five tasks with different offered items.
The outfit-studio example on GitHub combines a design file, native radio questions and a visual clothing selector. The design file determines the available items; Decipher records the choices; JavaScript displays and animates the outfit.
You will assemble the same complete XML shown at the end. Start with the document scaffold, replace each named insertion point, and follow the explanation beside each section. The screenshot above shows the finished element.
1. Prepare the survey and files
Create a new Decipher survey in testing mode with XML editing and both system/static upload access. Download the outfit example files.
| Upload | Location | Purpose |
|---|---|---|
outfit-design-v2.dat | Root/system files | Python reads the version/task offers from this file. |
All 24 SVGs in assets/ | Static/public files | Six illustrations each for shirts, pants, shoes and hats. |
Keep names such as shirt1.svg and hat6.svg unchanged. Upload the individual assets, not an enclosing folder that changes their URL. A system file and a public static file serve different purposes; see Forsta's file-reference guide. The design is a synthetic demonstration; use a design appropriate to your research before fielding.
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.
This source defines both hooks directly, without mode="after". Its JavaScript hook explicitly contains <script> and jQuery(function () { ... }), which waits for the document. Keep that pair; do not add a second wrap="ready" around it.
| Part of the document | Job |
|---|---|
| CSS and JavaScript hooks | Outfit presentation and native-input interaction. |
Initialization <exec> | Read the design and define the offer lookup. |
| Assignment fields and loading page | Retain version/design/session before the tasks. |
OutfitRounds loop | Render five pages with task-specific offers and four native questions. |
<survey
name="Outfit selection"
alt="Outfit selection"
compat="144"
fir="on"
html:showNumber="0"
builder:wizardCompleted="1"
state="testing"
ss:enableNavigation="0"
extraVariables="source"
autosave="1"
>
<style name="respview.client.css"><![CDATA[
<style>
/* BUILD: OUTFIT_PAGE */
/* BUILD: OUTFIT_STAGE */
/* BUILD: OUTFIT_OPTIONS */
/* BUILD: OUTFIT_FEEDBACK */
/* BUILD: OUTFIT_RESPONSIVE */
/* BUILD: OUTFIT_MOTION_BOOT */
</style>
]]></style>
<style name="respview.client.js"><![CDATA[
<script>
jQuery(function () {
/* BUILD: OUTFIT_CATALOGUE */
(function () {
'use strict';
/* BUILD: OUTFIT_DISCOVERY */
/* BUILD: OUTFIT_RESTORE */
/* BUILD: OUTFIT_RENDER */
/* BUILD: OUTFIT_CATEGORY */
/* BUILD: OUTFIT_CHOOSE */
/* BUILD: OUTFIT_DRAG */
/* BUILD: OUTFIT_SUBMIT */
})();
});
</script>
]]></style>
<!-- BUILD: OUTFIT_LOADER -->
<!-- BUILD: OUTFIT_ASSIGNMENT -->
<!-- BUILD: OUTFIT_LOOP -->
<!-- BUILD: OUTFIT_SHIRT -->
<!-- BUILD: OUTFIT_PANTS -->
<!-- BUILD: OUTFIT_SHOES -->
<!-- BUILD: OUTFIT_HAT_AND_CLOSE -->
</survey>Read the opening <survey> settings before adding the component:
| Setting | Why it is here |
|---|---|
name, alt | The survey's name and alternate title. They do not determine answer codes. |
compat="144" | Retains the source example's compatibility level. Changing it can change the generated markup the script reads. |
fir="on" | Enables Decipher's form input replacement presentation; the underlying native inputs still exist. |
html:showNumber="0" | Suppresses displayed question numbers. |
builder:wizardCompleted="1" | Retains the example's completed setup-wizard flag. |
state="testing" | Keeps the example in its testing lifecycle. |
ss:enableNavigation="0" | Does not add Decipher's Back navigation button. |
extraVariables="source" | Includes the URL variable source in the survey's extra variables. |
These are survey settings, separate from the component's own logic. See Forsta's survey-tag reference and compatibility-level reference.
This example also sets autosave="1" to retain partial progress. The initialization page below submits assignment before the first outfit task.
Read the syntax used in the build
In XML, label gives an element its programmatic identity; value declares an exported answer code. <title> and <comment> provide question text and instructions. <suspend /> ends a page. A where="execute" field runs on the server without being shown to the respondent. optional="0" requires an answer; optional="1" allows a field to be empty. A numeric field's size is its input width, not an allowed-value list.
In JavaScript, querySelector finds one matching element and querySelectorAll finds all matches. A Map or object connects a stable identifier to its native input. data-* HTML attributes become element.dataset properties. forEach visits every entry; map produces a new list; find returns the first match; some and every test whether any or all entries qualify. A guard followed by return stops that function when its prerequisites are absent.
The immediately invoked function, (function () { ... })();, keeps local variables out of the global scope. Its opening and closing punctuation is already in the scaffold; the dots in this sentence are explanatory, not a code block to paste. 'use strict' enables stricter JavaScript checks. Event listeners connect named actions to clicks, keys or pointer movement. textContent inserts plain text; innerHTML builds the example's HTML elements. aria-*, role and tabIndex describe controls and keyboard focus to assistive technology. They supplement the native inputs rather than creating exported answers.
For CSS, read each selector as “which elements” and the declarations inside its braces as “how they look.” margin, padding and gap control space outside, inside and between elements. width, height, min-*, max-* and box-sizing control dimensions. Flex/grid rules arrange children. position, edge offsets and z-index place layers; transform moves or scales them; opacity changes transparency. Fonts, colors, borders, radii and shadows provide the visual treatment. transition animates a property change; animation runs named keyframes. Attribute selectors such as [aria-pressed='true'] style a state the script sets. Media queries apply only at the specified width or motion preference.
3. Build the native survey and data flow
These blocks belong in the XML part of the scaffold, outside the client CSS and JavaScript hooks. Keep Python indentation exactly as shown; Python uses indentation to define its blocks.
1. Load the design before the question flow
Replace the entire BUILD: OUTFIT_LOADER comment line in the XML scaffold with this block.
<exec when="init"> defines the server-side helpers. Open the .dat relative to gv.survey.path, strip line endings, split on tabs, close the file and skip the header with rows[1:]. Each seven-column record becomes a dictionary entry keyed by version, task and concept slot; row[3:] retains the four clothing levels.
outfitOffers() reads slots 1, 2 and 3 for a version/task and returns integers from the requested attribute: 0 shirt, 1 pants, 2 shoes, 3 hat. range(1,4) excludes its upper bound. The supplied design has 12 versions × five tasks × three slots, or 180 records. For version 1/task 1, shirts are 5, 1, 6; pants 3, 4, 2; shoes 4, 5, 6; hats 4, 3, 6. These are independent category offers, not three fixed outfits. Keep the file's column order unchanged.
<exec when="init"><![CDATA[
def setupDCMFile(fname, fileDelimiter="\t"):
f = open("%s/%s" % (gv.survey.path, fname))
rows = [line.strip("\r\n").split(fileDelimiter) for line in f.readlines()]
f.close()
d = {}
for row in rows[1:]:
if len(row) == 7:
d["v%s_t%s_c%s" % (row[0],row[1],row[2])] = row[3:]
return d
outfitDesign = setupDCMFile("outfit-design-v2.dat")
def outfitOffers(version, task, attribute):
return [int(outfitDesign["v%s_t%s_c%s" % (version,task,c)][attribute]) for c in range(1,4)]
]]></exec>2. Retain a version and session before task one
Replace the entire BUILD: OUTFIT_ASSIGNMENT comment line in the XML scaffold with this block.
DesignVersion is a server-only number. The is None guard assigns 1–12 only when it has no value. DesignID records which immutable design produced the offers. The standalone <exec> creates a response-specific cache identifier by concatenating two random eight-digit strings, retaining it in p.outfitSession.
OutfitStart renders a loading element. The following <suspend /> creates an initialization page; the browser script clicks its native Continue button so the assignment is submitted before task one. The survey's autosave="1" supports retaining submitted partial progress. It does not replace the browser's separate draft-choice cache.
<number label="DesignVersion" size="3" where="execute" optional="1">
<title>Assigned design version</title>
<exec>
if DesignVersion.val is None:
DesignVersion.val = random.randint(1,12)
</exec>
</number>
<text label="DesignID" where="execute" optional="1">
<title>Immutable design ID</title>
<exec>DesignID.val = "outfit-20261019-v2"</exec>
</text>
<exec>
if not p.get('outfitSession'):
p.outfitSession = str(random.randint(10000000,99999999)) + str(random.randint(10000000,99999999))
</exec>
<html label="OutfitStart" where="survey"><div id="outfit-start">Loading…</div></html>
<suspend />3. Open the five-task loop and record the offers
Replace the entire BUILD: OUTFIT_LOOP comment line in the XML scaffold with this block.
This block opens <loop> and its <block>; leave them open for the following question blocks and the closing block supplied below. vars="task" names the loop variable. randomizeChildren="0" and randomize="0" retain task order.
At each iteration, convert [loopvar: task] to an integer and ask the loader for the four offered lists. Save the task number and comma-separated offered IDs in native fields. For example, the shirt list joins shirt with each level number. Those fields preserve design slot order; the radio rows below appear in their declared level order after filtering, so do not mistake that metadata for randomized screen order.
OutfitMeta supplies the browser with task, version, response cache ID and the current survey asset base. ${...} is evaluated by Decipher before the browser receives the page. The underscore and loop variable in native labels give each task distinct fields; the HTML ID can remain fixed because only one task appears on a page.
<loop label="OutfitRounds" vars="task" randomizeChildren="0">
<block label="OutfitRound" randomize="0">
<exec>
p.outfitTask = int("[loopvar: task]")
p.shirtOffers = outfitOffers(DesignVersion.val, p.outfitTask, 0)
p.pantsOffers = outfitOffers(DesignVersion.val, p.outfitTask, 1)
p.shoesOffers = outfitOffers(DesignVersion.val, p.outfitTask, 2)
p.hatOffers = outfitOffers(DesignVersion.val, p.outfitTask, 3)
</exec>
<number label="Task_[loopvar: task]" where="execute" optional="1" size="3">
<title>Design task</title>
<exec>Task_[loopvar: task].val = p.outfitTask</exec>
</number>
<text label="Offered_shirt_[loopvar: task]" where="execute" optional="1">
<title>Offered shirt IDs in design slot order</title>
<exec>Offered_shirt_[loopvar: task].val = ",".join(["shirt%s" % x for x in p.shirtOffers])</exec>
</text>
<text label="Offered_pants_[loopvar: task]" where="execute" optional="1">
<title>Offered pants IDs in design slot order</title>
<exec>Offered_pants_[loopvar: task].val = ",".join(["pants%s" % x for x in p.pantsOffers])</exec>
</text>
<text label="Offered_shoes_[loopvar: task]" where="execute" optional="1">
<title>Offered shoes IDs in design slot order</title>
<exec>Offered_shoes_[loopvar: task].val = ",".join(["shoes%s" % x for x in p.shoesOffers])</exec>
</text>
<text label="Offered_hat_[loopvar: task]" where="execute" optional="1">
<title>Offered hat IDs in design slot order</title>
<exec>Offered_hat_[loopvar: task].val = ",".join(["hat%s" % x for x in p.hatOffers])</exec>
</text>
<html label="OutfitMeta_[loopvar: task]" where="survey"><div id="outfit-meta" data-round="[loopvar: task]" data-version="${DesignVersion.val}" data-session="${p.outfitSession}" data-assets="/survey/${gv.survey.path}/"></div></html>4. Add the required shirt question
Replace the entire BUILD: OUTFIT_SHIRT comment line in the XML scaffold with this block.
optional="0" makes shirt selection required. outfit-native marks a field for the custom presentation; outfit-shirt identifies its category. All six possible rows are supplied, with explicit export values 1–6. Each cond displays a row only when its level is in this task's offered list.
The validation block runs on the server. After checking that a value exists, it reads the selected row's label, removes the leading r with [1:], converts the remainder to an integer and checks membership again. error() reports a choice outside the offered set. Keep these exact row names aligned with the JavaScript catalogue, because the browser matches native label text to catalogue names.
<radio
label="Shirt_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-shirt"
>
<title>Shirt</title>
<row label="r1" value="1" cond="1 in p.shirtOffers">Cloud cotton tee</row>
<row label="r2" value="2" cond="2 in p.shirtOffers">Ink knit polo</row>
<row label="r3" value="3" cond="3 in p.shirtOffers">Clay camp shirt</row>
<row label="r4" value="4" cond="4 in p.shirtOffers">Sage overshirt</row>
<row label="r5" value="5" cond="5 in p.shirtOffers">Breton stripe</row>
<row label="r6" value="6" cond="6 in p.shirtOffers">Sunwashed linen</row>
<validate>
if Shirt_[loopvar: task].val is not None:
if int(Shirt_[loopvar: task].selected.label[1:]) not in p.shirtOffers:
error("Please choose an available option.")
</validate>
</radio>5. Add the required pants question
Replace the entire BUILD: OUTFIT_PANTS comment line in the XML scaffold with this block.
Pants uses the same complete structure with its own outfit-pants class, names and p.pantsOffers list. The repeated row conditions filter the six catalogue levels; the validation checks the selected level against the pants offers. Pants_[loopvar: task] makes the answer independent of Shirt and independent of other tasks. No choice in this block selects shoes or a shirt automatically.
<radio
label="Pants_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-pants"
>
<title>Pants</title>
<row label="r1" value="1" cond="1 in p.pantsOffers">Indigo straight denim</row>
<row label="r2" value="2" cond="2 in p.pantsOffers">Sand chinos</row>
<row label="r3" value="3" cond="3 in p.pantsOffers">Olive cargo</row>
<row label="r4" value="4" cond="4 in p.pantsOffers">Charcoal pleats</row>
<row label="r5" value="5" cond="5 in p.pantsOffers">Ecru wide leg</row>
<row label="r6" value="6" cond="6 in p.pantsOffers">Washed blue denim</row>
<validate>
if Pants_[loopvar: task].val is not None:
if int(Pants_[loopvar: task].selected.label[1:]) not in p.pantsOffers:
error("Please choose an available option.")
</validate>
</radio>6. Add the required shoes question
Replace the entire BUILD: OUTFIT_SHOES comment line in the XML scaffold with this block.
Shoes repeats the required single-select pattern using p.shoesOffers and outfit-shoes. Each value remains the catalogue level even when only three of the six rows are shown. The browser's radio index is not the exported level: retain the explicit value attributes and read native row identities when validating.
<radio
label="Shoes_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-shoes"
>
<title>Shoes</title>
<row label="r1" value="1" cond="1 in p.shoesOffers">Chalk court sneaker</row>
<row label="r2" value="2" cond="2 in p.shoesOffers">Forest runner</row>
<row label="r3" value="3" cond="3 in p.shoesOffers">Cocoa loafer</row>
<row label="r4" value="4" cond="4 in p.shoesOffers">Ink high top</row>
<row label="r5" value="5" cond="5 in p.shoesOffers">Sand desert boot</row>
<row label="r6" value="6" cond="6 in p.shoesOffers">Clay trail sneaker</row>
<validate>
if Shoes_[loopvar: task].val is not None:
if int(Shoes_[loopvar: task].selected.label[1:]) not in p.shoesOffers:
error("Please choose an available option.")
</validate>
</radio>7. Add No hat and close the loop
Replace the entire BUILD: OUTFIT_HAT_AND_CLOSE comment line in the XML scaffold with this block.
The hat question offers three design-selected hats plus an unconditional r0 with exported value 0. Its <exec> selects r0.index only when no native value exists. That .index is the internal row position; value="0" determines the export. The validation accepts either a design-offered level or zero.
After Hat, <suspend /> places the four category questions on one task page. Close the block, then declare five <looprow> entries. Each assigns its number to the task variable, causing the block to execute five times with distinct labels. The final </loop> closes the element opened in the loop step. A respondent can finish a task without choosing a hat because No hat is an explicit default choice.
<radio
label="Hat_[loopvar: task]"
optional="1"
ss:questionClassNames="outfit-native outfit-hat"
>
<title>Hat</title>
<row label="r1" value="1" cond="1 in p.hatOffers">Ink baseball cap</row>
<row label="r2" value="2" cond="2 in p.hatOffers">Oat bucket hat</row>
<row label="r3" value="3" cond="3 in p.hatOffers">Rust beanie</row>
<row label="r4" value="4" cond="4 in p.hatOffers">Sage five panel</row>
<row label="r5" value="5" cond="5 in p.hatOffers">Cream baseball cap</row>
<row label="r6" value="6" cond="6 in p.hatOffers">Charcoal bucket hat</row>
<row label="r0" value="0">No hat</row>
<exec>
if Hat_[loopvar: task].val is None:
Hat_[loopvar: task].val = Hat_[loopvar: task].r0.index
</exec>
<validate>
if Hat_[loopvar: task].val is not None:
if int(Hat_[loopvar: task].selected.label[1:]) not in p.hatOffers + [0]:
error("Please choose an available option.")
</validate>
</radio>
<suspend />
</block>
<looprow label="1">
<loopvar name="task">1</loopvar>
</looprow>
<looprow label="2">
<loopvar name="task">2</loopvar>
</looprow>
<looprow label="3">
<loopvar name="task">3</loopvar>
</looprow>
<looprow label="4">
<loopvar name="task">4</loopvar>
</looprow>
<looprow label="5">
<loopvar name="task">5</loopvar>
</looprow>
</loop>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. Style the enhanced page and task heading
Replace the entire BUILD: OUTFIT_PAGE comment line in the CSS hook’s inner <style> with this block.
All page replacement rules depend on body.outfit-ready, which the script adds after discovering the native options. They reset the survey container and hide the original header, footer, buttons, native outfit questions and metadata presentation. The .outfit-ui rules establish the custom column, consistent sizing and button focus outline.
The progress strip contains five dots: done and current have separate fills. Heading rules set the main title and instructions. These elements report the task number; they do not change the loop variable or its order.
body.outfit-ready {
margin: 0;
background: #f6f4ed;
color: #263c34;
font-family: Arial, Helvetica, sans-serif;
}
.outfit-ready #survey,
.outfit-ready .survey-section,
.outfit-ready .survey-body {
width: auto;
max-width: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.outfit-ready .page-header,
.outfit-ready .survey-header,
.outfit-ready .survey-footer,
.outfit-ready .survey-buttons,
.outfit-ready .outfit-native,
.outfit-ready [id^='comment_OutfitMeta'] {
display: none !important;
}
.outfit-ready .survey-info {
display: none !important;
}
.outfit-ui {
max-width: 1320px;
margin: 0 auto;
padding: 30px 56px 28px;
box-sizing: border-box;
}
.outfit-ui * {
box-sizing: border-box;
}
.outfit-ui button {
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.outfit-ui button:focus-visible {
outline: 3px solid #bf623d;
outline-offset: 5px;
}
.os-progress {
display: flex;
align-items: center;
gap: 15px;
font-size: 12px;
color: #6b756a;
margin-bottom: 18px;
}
.os-dots {
display: flex;
gap: 6px;
}
.os-dot {
width: 25px;
height: 4px;
border-radius: 4px;
background: #d7ddcf;
}
.os-dot.done {
background: #7d9277;
}
.os-dot.current {
background: #263c34;
}
.os-heading {
margin: 0 0 24px;
}
.os-heading h1 {
font:
600 clamp(24px, 2.5vw, 32px)/1.25 Arial,
Helvetica,
sans-serif;
letter-spacing: -0.6px;
margin: 0;
color: #263c34;
}
.os-heading p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
color: #6b756a;
}2. Position the outfit layers
Replace the entire BUILD: OUTFIT_STAGE comment line in the CSS hook’s inner <style> with this block.
The workspace grid separates the outfit stage from the wardrobe. The stage is a positioned container, and its pseudo-element provides a decorative backdrop. Each .os-slot uses pixel dimensions and vertical offsets, percentage-based horizontal centering, and a category-specific stacking order: hat above shirt, shirt above pants, shoes at the bottom.
Images fit inside their slots without distortion. An empty slot shows a faint placeholder and its data-prompt through content: attr(...); the optional hat gets a subtler empty treatment. active-drop and drag-over indicate a valid drag target. The wardrobe rules supply the second panel's spacing.
.os-workspace {
display: grid;
grid-template-columns: 1fr 1.08fr;
gap: 34px;
align-items: stretch;
}
.os-stage {
position: relative;
height: 518px;
border-radius: 24px;
background: radial-gradient(ellipse at 50% 30%, #eef1e3 0%, #dfe6d2 72%);
overflow: hidden;
isolation: isolate;
border: 1px solid #d3dcc7;
transition: box-shadow 0.2s;
}
.os-stage:after {
content: '';
position: absolute;
left: 16%;
right: 16%;
bottom: 27px;
height: 19px;
background: #5c6b4420;
filter: blur(15px);
border-radius: 50%;
z-index: -1;
}
.os-slot {
position: absolute;
display: grid;
place-items: center;
left: 50%;
transform: translateX(-50%);
transition: filter 0.2s;
}
.os-slot[data-cat='hat'] {
top: 8px;
width: 115px;
height: 123px;
z-index: 4;
left: 52%;
}
.os-slot[data-cat='shirt'] {
top: 93px;
width: 245px;
height: 261px;
z-index: 3;
}
.os-slot[data-cat='pants'] {
top: 266px;
width: 188px;
height: 201px;
z-index: 2;
}
.os-slot[data-cat='shoes'] {
top: 405px;
width: 120px;
height: 128px;
z-index: 4;
}
.os-slot img {
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
pointer-events: none;
filter: drop-shadow(0 10px 8px #32442f10);
}
.os-slot.empty img {
opacity: 0.1;
filter: grayscale(1);
}
.os-slot.empty:after {
content: attr(data-prompt);
position: absolute;
font-size: 10px;
letter-spacing: 1.2px;
text-transform: uppercase;
background: #eef2e8cc;
padding: 7px 11px;
border: 1px dashed #9daa8c;
border-radius: 20px;
color: #617354;
}
.os-slot[data-cat='hat'].empty {
opacity: 0.5;
}
.os-slot[data-cat='hat'].empty:after {
display: none;
}
.os-slot.active-drop {
filter: drop-shadow(0 0 16px #fff);
}
.os-stage.drag-over {
box-shadow: inset 0 0 0 3px #81976c;
}
.os-wardrobe {
padding-top: 3px;
display: flex;
flex-direction: column;
min-width: 0;
}3. Style tabs, offered items and No hat
Replace the entire BUILD: OUTFIT_OPTIONS comment line in the CSS hook’s inner <style> with this block.
The tab row distributes category buttons. active highlights the current category; chosen reveals its check mark. The section heading separates a category title from “Choose one” or “Optional.” The three-column card grid presents only the server-offered items.
Hover rules lift a card; [aria-pressed='true'] supplies selected border, background and check marker. Each card image has a fixed fitting area and a touch-action rule for dragging, while the name remains visible text. No hat is a separate text button with its own selected check. None of these visual states writes data; the JavaScript sets them after selecting a native input.
.os-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7px;
margin-bottom: 26px;
}
.os-tab {
position: relative;
border: 1px solid #dce1d5;
border-radius: 50px;
background: transparent;
color: #6c7868;
padding: 12px 5px;
font-size: 12px !important;
transition:
background 0.2s,
color 0.2s,
border 0.2s;
}
.os-tab.active {
background: #263c34;
color: #fff;
border-color: #263c34;
}
.os-tab .os-tick {
display: none;
margin-left: 5px;
}
.os-tab.chosen .os-tick {
display: inline;
}
.os-section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 17px;
}
.os-section-head h2 {
font:
500 18px Arial,
Helvetica,
sans-serif;
margin: 0;
color: #263c34;
}
.os-section-head span {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.4px;
color: #7b8275;
}
.os-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.os-card {
position: relative;
padding: 10px 8px 14px;
border: 1px solid #dfe3d8;
border-radius: 16px;
background: #fbfaf5;
text-align: left;
color: #34483d;
min-width: 0;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s,
transform 0.25s;
}
.os-card:hover {
transform: translateY(-4px);
border-color: #a8b49b;
box-shadow: 0 10px 20px #263c3409;
}
.os-card[aria-pressed='true'] {
border-color: #788d65;
background: #ecf0e4;
box-shadow: inset 0 0 0 1px #788d65;
}
.os-card img {
display: block;
width: 100%;
height: 166px;
object-fit: contain;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
}
.os-card-name {
display: block;
font-size: 11px;
line-height: 1.45;
min-height: 32px;
margin: 9px 3px 0;
}
.os-card-marker {
position: absolute;
top: 10px;
right: 10px;
width: 19px;
height: 19px;
border-radius: 50%;
background: #fff9;
border: 1px solid #cbd4bf;
display: grid;
place-items: center;
font-size: 11px;
color: transparent;
}
.os-card[aria-pressed='true'] .os-card-marker {
background: #667e56;
color: white;
border-color: #667e56;
}
.os-nohat {
display: block;
border: 1px solid #dfe3d8;
border-radius: 12px;
background: transparent;
color: #54654b;
padding: 10px 14px;
width: 100%;
margin-top: 10px;
font-size: 12px !important;
text-align: left;
}
.os-nohat[aria-pressed='true'] {
border-color: #788d65;
background: #ecf0e4;
}
.os-nohat span {
float: right;
}
.os-nohat:not([aria-pressed='true']) span {
visibility: hidden;
}
4. Style Continue, status and animation layers
Replace the entire BUILD: OUTFIT_FEEDBACK comment line in the CSS hook’s inner <style> with this block.
The bottom area contains the custom Continue control and status text. Hover/active/disabled rules describe its interaction states, and .error makes validation feedback visible in the error color. The fixed .os-ghost image can travel over the page without capturing pointer events.
os-leaving fades/moves the activity during submission. os-enter runs the os-in keyframes when category cards change. The screen-reader utility clips text without removing it from the accessibility tree.
.os-bottom {
margin-top: auto;
padding-top: 23px;
}
.os-submit {
border: 0;
border-radius: 13px;
background: #263c34;
color: white;
padding: 17px 20px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
transition:
background 0.2s,
transform 0.2s;
min-height: 52px;
font-size: 13px !important;
}
.os-submit:hover {
background: #3c5647;
}
.os-submit:active {
transform: scale(0.985);
}
.os-submit:disabled {
cursor: wait;
opacity: 0.65;
}
.os-submit span:last-child {
font-size: 19px;
}
.os-status {
font-size: 11px;
color: #65765b;
margin: 10px 0 0;
min-height: 16px;
line-height: 1.4;
}
.os-status.error {
color: #a7462f;
}
.os-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
object-fit: contain;
filter: drop-shadow(0 15px 15px #263c342b);
will-change: transform, width, height;
}
.outfit-ui.os-leaving {
opacity: 0;
transform: translateY(-8px);
transition:
opacity 0.18s,
transform 0.18s;
}
.os-cards.os-enter {
animation: os-in 0.35s both;
}
\@keyframes os-in {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.os-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}5. Adapt the two panels to smaller screens
Replace the entire BUILD: OUTFIT_RESPONSIVE comment line in the CSS hook’s inner <style> with this block.
The 1500-pixel query increases the large-screen layout. At 900 pixels the workspace and stage proportions tighten. At 650 pixels the layout becomes a vertical mobile composition: the stage, slot positions, tabs, cards, heading and Continue spacing all resize together.
Read each media block as overrides of the selectors defined above. Keeping its declarations together matters because the garment positions were designed for the stage dimensions in that same breakpoint. The three offered items remain available; this is a layout change, not a design change.
\@media (min-width: 1500px) {
.outfit-ui {
padding-top: 40px;
}
.os-stage {
height: 560px;
}
.os-slot[data-cat='shirt'] {
top: 108px;
}
.os-slot[data-cat='pants'] {
top: 281px;
}
.os-slot[data-cat='shoes'] {
top: 420px;
}
.os-slot[data-cat='hat'] {
top: 23px;
}
}
\@media (max-width: 900px) {
.outfit-ui {
padding: 23px 25px;
}
.os-workspace {
gap: 22px;
grid-template-columns: 0.95fr 1.05fr;
}
.os-card img {
height: 138px;
}
.os-tabs {
gap: 4px;
}
.os-tab {
font-size: 11px !important;
}
.os-stage {
height: 500px;
}
.os-heading p {
font-size: 12px;
}
.os-slot[data-cat='shoes'] {
top: 389px;
}
.os-slot[data-cat='pants'] {
top: 250px;
}
.os-slot[data-cat='shirt'] {
top: 77px;
}
.os-slot[data-cat='hat'] {
top: -8px;
}
}
\@media (max-width: 650px) {
.outfit-ui {
padding: 17px 17px 22px;
}
.os-progress {
gap: 9px;
font-size: 10px;
}
.os-dot {
width: 15px;
}
.os-heading {
margin: 0 0 18px;
}
.os-heading h1 {
font-size: 25px;
letter-spacing: -0.5px;
}
.os-heading p {
margin: 8px 0 0;
max-width: none;
font-size: 11px;
}
.os-workspace {
display: flex;
flex-direction: column;
gap: 19px;
}
.os-stage {
height: 270px;
border-radius: 20px;
}
.os-slot[data-cat='hat'] {
width: 67px;
height: 72px;
top: -3px;
left: 51%;
}
.os-slot[data-cat='shirt'] {
width: 143px;
height: 153px;
top: 39px;
}
.os-slot[data-cat='pants'] {
width: 110px;
height: 117px;
top: 125px;
}
.os-slot[data-cat='shoes'] {
width: 70px;
height: 75px;
top: 200px;
}
.os-slot.empty:after {
font-size: 8px;
padding: 4px 7px;
}
.os-tabs {
margin-bottom: 17px;
gap: 7px;
}
.os-tab {
padding: 10px 5px;
}
.os-section-head {
margin-bottom: 12px;
}
.os-section-head h2 {
font-size: 17px;
}
.os-section-head span {
font-size: 9px;
}
.os-card {
padding: 6px 6px 10px;
border-radius: 13px;
}
.os-card img {
height: 114px;
}
.os-card-name {
font-size: 10px;
margin-top: 3px;
min-height: 29px;
}
.os-card-marker {
width: 17px;
height: 17px;
top: 7px;
right: 7px;
}
.os-bottom {
padding-top: 15px;
}
.os-submit {
padding: 14px 17px;
min-height: 48px;
}
.os-status {
font-size: 10px;
margin-top: 8px;
}
}6. Handle reduced motion and the loading page
Replace the entire BUILD: OUTFIT_MOTION_BOOT comment line in the CSS hook’s inner <style> with this block.
The reduced-motion block removes CSS animations/transitions, disables smooth scrolling and removes the card hover transform. The JavaScript separately skips the flying-image animation and submission delay. outfit-boot styles the initialization page and hides its original survey container while the script advances it. #outfit-start is the loading element from the assignment step.
The last .os-status:not(.error) rule visually clips ordinary announcements but keeps them available to screen readers. Error messages are excluded from that selector and remain visible. Keep these final overrides after the earlier status rules.
\@media (prefers-reduced-motion: reduce) {
.outfit-ui *,
.os-ghost {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
.os-card:hover {
transform: none;
}
}
body.outfit-boot {
background: #f6f4ed;
color: #263c34;
}
.outfit-boot #survey {
visibility: hidden;
}
#outfit-start {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: #f6f4ed;
font:
28px Georgia,
serif;
visibility: visible;
z-index: 100;
}
.os-status:not(.error) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
margin: 0;
}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. Declare the full clothing catalogue
Replace the entire BUILD: OUTFIT_CATALOGUE comment line in the JavaScript hook with this block.
Inside the scaffold's jQuery(function () { ... }), this array defines all 24 items. Each object has an id formed from category and level, a category matching a native class suffix, a numeric code, the exact native row name, and a static SVG src filename.
The six entries per category repeat that same schema. The code does not offer all 24 items on every page: it intersects this catalogue with the native rows Decipher actually rendered. No hat is handled separately as code zero with no SVG. If you rename an item, update both its native row text and this catalogue name.
window.OUTFIT_DATASET = [
{
'id': 'shirt1',
'category': 'shirt',
'code': 1,
'name': 'Cloud cotton tee',
'src': 'shirt1.svg'
},
{
'id': 'shirt2',
'category': 'shirt',
'code': 2,
'name': 'Ink knit polo',
'src': 'shirt2.svg'
},
{
'id': 'shirt3',
'category': 'shirt',
'code': 3,
'name': 'Clay camp shirt',
'src': 'shirt3.svg'
},
{
'id': 'shirt4',
'category': 'shirt',
'code': 4,
'name': 'Sage overshirt',
'src': 'shirt4.svg'
},
{
'id': 'shirt5',
'category': 'shirt',
'code': 5,
'name': 'Breton stripe',
'src': 'shirt5.svg'
},
{
'id': 'shirt6',
'category': 'shirt',
'code': 6,
'name': 'Sunwashed linen',
'src': 'shirt6.svg'
},
{
'id': 'pants1',
'category': 'pants',
'code': 1,
'name': 'Indigo straight denim',
'src': 'pants1.svg'
},
{
'id': 'pants2',
'category': 'pants',
'code': 2,
'name': 'Sand chinos',
'src': 'pants2.svg'
},
{
'id': 'pants3',
'category': 'pants',
'code': 3,
'name': 'Olive cargo',
'src': 'pants3.svg'
},
{
'id': 'pants4',
'category': 'pants',
'code': 4,
'name': 'Charcoal pleats',
'src': 'pants4.svg'
},
{
'id': 'pants5',
'category': 'pants',
'code': 5,
'name': 'Ecru wide leg',
'src': 'pants5.svg'
},
{
'id': 'pants6',
'category': 'pants',
'code': 6,
'name': 'Washed blue denim',
'src': 'pants6.svg'
},
{
'id': 'shoes1',
'category': 'shoes',
'code': 1,
'name': 'Chalk court sneaker',
'src': 'shoes1.svg'
},
{
'id': 'shoes2',
'category': 'shoes',
'code': 2,
'name': 'Forest runner',
'src': 'shoes2.svg'
},
{
'id': 'shoes3',
'category': 'shoes',
'code': 3,
'name': 'Cocoa loafer',
'src': 'shoes3.svg'
},
{
'id': 'shoes4',
'category': 'shoes',
'code': 4,
'name': 'Ink high top',
'src': 'shoes4.svg'
},
{
'id': 'shoes5',
'category': 'shoes',
'code': 5,
'name': 'Sand desert boot',
'src': 'shoes5.svg'
},
{
'id': 'shoes6',
'category': 'shoes',
'code': 6,
'name': 'Clay trail sneaker',
'src': 'shoes6.svg'
},
{
'id': 'hat1',
'category': 'hat',
'code': 1,
'name': 'Ink baseball cap',
'src': 'hat1.svg'
},
{
'id': 'hat2',
'category': 'hat',
'code': 2,
'name': 'Oat bucket hat',
'src': 'hat2.svg'
},
{
'id': 'hat3',
'category': 'hat',
'code': 3,
'name': 'Rust beanie',
'src': 'hat3.svg'
},
{
'id': 'hat4',
'category': 'hat',
'code': 4,
'name': 'Sage five panel',
'src': 'hat4.svg'
},
{
'id': 'hat5',
'category': 'hat',
'code': 5,
'name': 'Cream baseball cap',
'src': 'hat5.svg'
},
{
'id': 'hat6',
'category': 'hat',
'code': 6,
'name': 'Charcoal bucket hat',
'src': 'hat6.svg'
}
];2. Find the initialization page or the four native groups
Replace the entire BUILD: OUTFIT_DISCOVERY comment line in the JavaScript hook with this block.
On outfit-start, add the boot class, click the native Continue button after 30 milliseconds and stop. Otherwise find outfit-meta, read the task/version, find the native Continue form and create a catalogue lookup keyed by ID.
For each category, inspect its native radio inputs and their associated <label>. Match the exact label text to a catalogue item, with a special record for No hat. Keep both the item and native input together. Abort enhancement unless shirts, pants and shoes each have three options and hats have four. The browser never randomizes or invents offers.
var boot = document.getElementById('outfit-start');
if (boot) {
document.body.classList.add('outfit-boot');
var startButton = document.getElementById('btn_continue');
if (startButton)
setTimeout(function () {
startButton.click();
}, 30);
return;
}
var meta = document.getElementById('outfit-meta');
if (!meta) return;
var cats = ['shirt', 'pants', 'shoes', 'hat'],
groups = {},
data = window.OUTFIT_DATASET,
round = Number(meta.dataset.round),
version = meta.dataset.version;
var next = document.getElementById('btn_continue'),
form = next && next.form;
if (!next || !form) return;
var dataset = {};
data.forEach(function (d) {
dataset[d.id] = d;
});
// All offered values come from server-rendered native rows; JS never randomizes a design.
for (var ci = 0; ci < cats.length; ci++) {
var cat = cats[ci],
question = document.querySelector('.outfit-' + cat);
if (!question) return;
var options = [];
Array.prototype.forEach.call(
question.querySelectorAll('input[type="radio"]'),
function (input) {
var label = question.querySelector('label[for="' + input.id + '"]');
var name = label && label.textContent.trim();
var item = data.find(function (d) {
return d.category === cat && d.name === name;
});
if (cat === 'hat' && name === 'No hat')
item = { id: 'hat0', category: 'hat', code: 0, name: 'No hat', src: null };
if (item) options.push({ item: item, input: input });
}
);
if (options.length !== (cat === 'hat' ? 4 : 3)) return;
groups[cat] = options;
}3. Restore only choices available in this task
Replace the entire BUILD: OUTFIT_RESTORE comment line in the JavaScript hook with this block.
The draft key combines response session, version and task. selected holds item IDs; the flags track animation, submission, active category, pointer drag, click suppression and category-transition identity. Read sessionStorage inside try/catch so unavailable storage does not stop answering.
For each category, prefer an already checked native value. Otherwise accept a cached ID only if it exists in the currently offered group. If Hat still has no selection, use No hat. Click each restored native input and mirror its ID locally. Start with the first unanswered category, or Hat if everything is already answered.
var savedKey = 'outfit:' + meta.dataset.session + ':' + version + ':' + round,
selected = {},
busy = false,
submitted = false,
active = 'shirt',
drag = null,
suppressClick = 0,
transitionToken = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var saved = {};
try {
saved = JSON.parse(sessionStorage.getItem(savedKey) || '{}');
} catch (e) {}
cats.forEach(function (cat) {
var existing = groups[cat].find(function (x) {
return x.input.checked;
});
// Native restored values take precedence; local drafts only target offered native inputs.
var restore =
existing ||
groups[cat].find(function (x) {
return x.item.id === saved[cat];
});
if (!restore && cat === 'hat')
restore = groups.hat.find(function (x) {
return x.item.code === 0;
});
if (restore) {
restore.input.click();
selected[cat] = restore.item.id;
}
});
active =
cats.find(function (c) {
return !selected[c];
}) || 'hat';4. Build the stage, tabs and shared rendering functions
Replace the entire BUILD: OUTFIT_RENDER comment line in the JavaScript hook with this block.
The generated <main> contains task progress, instructions, four outfit slots, category tabs, a tab panel, option containers, Continue and a live status. The stage slot order controls layering in combination with CSS. Tabs use stable IDs, aria-controls and a shared panel.
Insert the UI before the metadata element and add outfit-ready. src() accepts an absolute HTTP/data image or prefixes a filename with the survey asset base. itemFor() supplies a synthetic No hat record; draft() stores current IDs with a nonfatal storage fallback.
update() toggles each slot's empty state, uses the first offered image as a faint empty placeholder, and sets alt text only for an actual choice. It updates tab completion/active classes, aria-selected, and one tabbable tab. It then refreshes option aria-pressed states and clears ordinary status text while preserving errors.
var ui = document.createElement('main');
ui.className = 'outfit-ui';
ui.setAttribute('aria-label', 'Outfit selection');
ui.innerHTML =
'<div class="os-progress" aria-label="Task ' +
round +
' of 5"><span>Task ' +
round +
' of 5</span><div class="os-dots" aria-hidden="true">' +
[1, 2, 3, 4, 5]
.map(function (n) {
return (
'<i class="os-dot ' +
(n < round ? 'done' : n === round ? 'current' : '') +
'"></i>'
);
})
.join('') +
'</div></div><div class="os-heading"><h1>Which outfit would you choose?</h1><p>Choose a shirt, pants and shoes. Hat is optional. Drag or tap to select.</p></div><div class="os-workspace"><section class="os-stage" aria-label="Your outfit. Drop an item here.">' +
['hat', 'shirt', 'pants', 'shoes']
.map(function (c) {
return (
'<div class="os-slot empty" data-cat="' +
c +
'" data-prompt="+ ' +
c +
'"><img alt="" draggable="false"></div>'
);
})
.join('') +
'</section><section class="os-wardrobe" aria-label="Clothing choices"><div class="os-tabs" role="tablist" aria-label="Clothing category">' +
cats
.map(function (c, i) {
return (
'<button type="button" class="os-tab" id="tab-' +
c +
'" role="tab" aria-controls="os-panel" data-cat="' +
c +
'">' +
c.charAt(0).toUpperCase() +
c.slice(1) +
'<span class="os-tick" aria-hidden="true">✓</span></button>'
);
})
.join('') +
'</div><div id="os-panel" role="tabpanel"><div class="os-section-head"><h2></h2><span></span></div><div class="os-cards" role="group"></div><div class="os-none-wrap"></div></div><div class="os-bottom"><button type="button" class="os-submit"><span>Continue</span><span aria-hidden="true">→</span></button><p class="os-status" role="status" aria-live="polite"></p></div></section></div>';
meta.closest('.html').insertAdjacentElement('beforebegin', ui);
document.body.classList.add('outfit-ready');
var stage = ui.querySelector('.os-stage'),
cards = ui.querySelector('.os-cards'),
status = ui.querySelector('.os-status'),
submit = ui.querySelector('.os-submit');
function src(item) {
return /^(https?:|data:)/.test(item.src)
? item.src
: meta.dataset.assets + item.src;
}
function itemFor(id) {
return id === 'hat0'
? { id: 'hat0', name: 'No hat', code: 0, category: 'hat' }
: dataset[id];
}
function draft() {
try {
sessionStorage.setItem(savedKey, JSON.stringify(selected));
} catch (e) {}
}
function update() {
cats.forEach(function (c) {
var slot = ui.querySelector('.os-slot[data-cat="' + c + '"]'),
item = selected[c] && itemFor(selected[c]),
empty = !item || !item.code;
slot.classList.toggle('empty', empty);
slot.querySelector('img').src = src(empty ? groups[c][0].item : item);
slot.querySelector('img').alt = empty ? '' : item.name;
var tab = ui.querySelector('.os-tab[data-cat="' + c + '"]');
tab.classList.toggle('chosen', !!selected[c]);
tab.classList.toggle('active', c === active);
tab.setAttribute('aria-selected', c === active ? 'true' : 'false');
tab.tabIndex = c === active ? 0 : -1;
});
ui.querySelectorAll('[data-option]').forEach(function (b) {
b.setAttribute(
'aria-pressed',
selected[active] === b.dataset.option ? 'true' : 'false'
);
});
if (!status.classList.contains('error')) status.textContent = '';
}5. Render the current category and its controls
Replace the entire BUILD: OUTFIT_CATEGORY comment line in the JavaScript hook with this block.
showCategory() sets the active category and increments transitionToken, invalidating an old automatic category advance. It updates the heading and accessible panel label, clears the previous options, and builds a button for every offered native item. No hat goes in its own text-control container; other options get an image, name and selection marker.
Click handlers ignore a synthetic click shortly after a drag, then call the shared choose() function. e.detail === 0 identifies keyboard-style activation so a subsequent category change can move focus appropriately. Arrow keys move focus between offered buttons with wraparound. Nonzero items attach pointer-down handlers to their images. Finally, reflow restarts the entry animation, update() synchronizes state, and an explicit focus request focuses the active tab.
function showCategory(c, focus) {
active = c;
transitionToken++;
var title = {
shirt: 'Select a shirt',
pants: 'Select pants',
shoes: 'Select shoes',
hat: 'Select a hat'
};
ui.querySelector('.os-section-head h2').textContent = title[c];
ui.querySelector('.os-section-head span').textContent =
c === 'hat' ? 'Optional' : 'Choose one';
ui.querySelector('#os-panel').setAttribute('aria-labelledby', 'tab-' + c);
cards.setAttribute('aria-label', c + ' options');
cards.innerHTML = '';
ui.querySelector('.os-none-wrap').innerHTML = '';
groups[c].forEach(function (o) {
var b = document.createElement('button');
b.type = 'button';
b.dataset.option = o.item.id;
b.setAttribute('aria-label', o.item.name);
b.setAttribute('aria-pressed', selected[c] === o.item.id ? 'true' : 'false');
if (o.item.code === 0) {
b.className = 'os-nohat';
b.innerHTML = 'No hat <span aria-hidden="true">✓</span>';
ui.querySelector('.os-none-wrap').appendChild(b);
} else {
b.className = 'os-card';
b.innerHTML =
'<span class="os-card-marker" aria-hidden="true">✓</span><img src="' +
src(o.item) +
'" alt="" draggable="false"><span class="os-card-name">' +
o.item.name +
'</span>';
cards.appendChild(b);
}
b.addEventListener('click', function (e) {
if (Date.now() < suppressClick) return;
choose(o, b.querySelector('img'), true, null, null, e.detail === 0);
});
b.addEventListener('keydown', function (e) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].indexOf(e.key) < 0)
return;
e.preventDefault();
var all = Array.from(ui.querySelectorAll('[data-option]')),
idx = all.indexOf(b),
dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
all[(idx + dir + all.length) % all.length].focus();
});
if (o.item.code)
b.querySelector('img').addEventListener('pointerdown', function (e) {
startDrag(e, o, b);
});
});
cards.classList.remove('os-enter');
void cards.offsetWidth;
cards.classList.add('os-enter');
update();
if (focus) ui.querySelector('.os-tab[data-cat="' + c + '"]').focus();
}6. Animate a copy after committing the native selection
Replace the entire BUILD: OUTFIT_CHOOSE comment line in the JavaScript hook with this block.
ghostFor() clones the item image, marks it decorative, fixes it to the image's viewport rectangle and appends it to the body. fly() measures the destination slot and animates that clone's bounds for 440 milliseconds. Reduced motion or an absent image resolves immediately. Both completed and canceled animations remove the temporary image.
choose() rejects busy/submitted states and objects outside the offered group. It remembers the active category and transition token, clicks the native input, and confirms it became checked before recording or animating anything. Then it saves the draft and clears an error. When the animation settles, it updates the outfit, unlocks input and announces the choice. It advances to the next category only if the user has not switched categories during the animation; keyboard activation requests focus there.
function ghostFor(image, rect) {
var g = image.cloneNode();
g.className = 'os-ghost';
g.setAttribute('aria-hidden', 'true');
Object.assign(g.style, {
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px'
});
document.body.appendChild(g);
return g;
}
function fly(o, image, existing, start) {
var target = ui.querySelector('.os-slot[data-cat="' + o.item.category + '"] img'),
dest = target.getBoundingClientRect();
if (!image || reduce.matches) {
if (existing) existing.remove();
return Promise.resolve();
}
var rect = start || image.getBoundingClientRect(),
g = existing || ghostFor(image, rect);
g.style.transform = 'none';
var a = g.animate(
[
{
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
opacity: 1
},
{
left: dest.left + 'px',
top: dest.top + 'px',
width: dest.width + 'px',
height: dest.height + 'px',
opacity: 1
}
],
{ duration: 440, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'forwards' }
);
return a.finished
.catch(function () {})
.then(function () {
g.remove();
});
}
function choose(o, image, advance, ghost, start, keyboard) {
if (busy || submitted) {
if (ghost) ghost.remove();
return;
}
if (
!groups[o.item.category].some(function (x) {
return x === o;
})
) {
if (ghost) ghost.remove();
return;
}
busy = true;
var originalCategory = active,
token = transitionToken;
o.input.click();
if (!o.input.checked) {
busy = false;
if (ghost) ghost.remove();
return;
}
selected[o.item.category] = o.item.id;
draft();
status.classList.remove('error');
// Native choice is committed synchronously; only the presentation waits for the snap.
fly(o, image, ghost, start).then(function () {
update();
busy = false;
status.textContent = o.item.name + ' selected.';
if (
advance &&
token === transitionToken &&
originalCategory === o.item.category
) {
var n = cats.indexOf(originalCategory);
if (n < 3) showCategory(cats[n + 1], !!keyboard);
}
});
}7. Track a drag and decide whether it lands on the outfit
Replace the entire BUILD: OUTFIT_DRAG comment line in the JavaScript hook with this block.
startDrag() saves the pointer ID, coordinates, source image/button, item and starting rectangle, then captures the pointer. Movement below seven pixels is still a tap. Beyond that, create a ghost, dim the source and translate/rotate the ghost with the pointer. Comparing the pointer to the stage rectangle drives the drop highlights.
endDrag() clears the drag state and highlights. A real drag suppresses a follow-up click for 500 milliseconds. Calculate the ghost's current rectangle from its last movement. Dropping inside the stage calls choose() with that ghost; canceling or dropping outside removes it immediately under reduced motion, or animates it back for 230 milliseconds before removal. Both pointer-up and pointer-cancel use this same cleanup.
function startDrag(e, o, button) {
if (busy || submitted || e.button !== 0) return;
var image = button.querySelector('img'),
r = image.getBoundingClientRect();
drag = {
pointer: e.pointerId,
x: e.clientX,
y: e.clientY,
lastX: e.clientX,
lastY: e.clientY,
o: o,
image: image,
button: button,
rect: r,
ghost: null,
moved: false
};
image.setPointerCapture(e.pointerId);
}
document.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.pointer) return;
var dx = e.clientX - drag.x,
dy = e.clientY - drag.y;
drag.lastX = e.clientX;
drag.lastY = e.clientY;
if (!drag.moved && Math.hypot(dx, dy) < 7) return;
if (!drag.moved) {
drag.moved = true;
drag.ghost = ghostFor(drag.image, drag.rect);
drag.button.style.opacity = '.45';
}
drag.ghost.style.transform =
'translate(' +
dx +
'px,' +
dy +
'px) rotate(' +
Math.max(-7, Math.min(7, dx / 25)) +
'deg)';
var r = stage.getBoundingClientRect(),
inside =
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
stage.classList.toggle('drag-over', inside);
ui.querySelector(
'.os-slot[data-cat="' + drag.o.item.category + '"]'
).classList.toggle('active-drop', inside);
});
function endDrag(e, cancel) {
if (!drag || e.pointerId !== drag.pointer) return;
var d = drag;
drag = null;
d.button.style.opacity = '';
stage.classList.remove('drag-over');
ui.querySelectorAll('.active-drop').forEach(function (x) {
x.classList.remove('active-drop');
});
if (!d.moved) return;
suppressClick = Date.now() + 500;
var r = stage.getBoundingClientRect(),
inside =
!cancel &&
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
var start = {
left: d.rect.left + d.lastX - d.x,
top: d.rect.top + d.lastY - d.y,
width: d.rect.width,
height: d.rect.height
};
if (inside) {
choose(d.o, d.image, true, d.ghost, start);
} else {
if (reduce.matches) {
d.ghost.remove();
return;
}
var a = d.ghost.animate(
[
{ transform: d.ghost.style.transform, opacity: 1 },
{ transform: 'translate(0,0) rotate(0)', opacity: 0.3 }
],
{ duration: 230, easing: 'ease-out' }
);
a.finished
.catch(function () {})
.then(function () {
d.ghost.remove();
});
}
}
document.addEventListener('pointerup', function (e) {
endDrag(e, false);
});
document.addEventListener('pointercancel', function (e) {
endDrag(e, true);
});8. Wire category navigation and native submission
Replace the entire BUILD: OUTFIT_SUBMIT comment line in the JavaScript hook with this block.
Tab clicks change category unless submission has begun. Left/Right Arrow cycles the four tabs and moves focus. Continue checks the first three category groups for checked native inputs; a missing choice opens its category and displays an error. Hat is excluded because No hat is valid.
A valid submission locks Continue, announces submission and applies the leaving style. After 180 milliseconds—or immediately for reduced motion—it clicks Decipher's original Continue. A 12-second timer unlocks the custom control if the page is still present. Native validation messages from a redisplayed page are collected and shown in the status area. Finally, showCategory(active, false) renders the restored or first unanswered category. The scaffold closes the inner function, jQuery-ready callback, HTML script and XML hook.
ui.querySelectorAll('.os-tab').forEach(function (b) {
b.addEventListener('click', function () {
if (!submitted) showCategory(b.dataset.cat, false);
});
b.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var idx = cats.indexOf(b.dataset.cat),
delta = e.key === 'ArrowRight' ? 1 : -1;
showCategory(cats[(idx + delta + 4) % 4], true);
});
});
submit.addEventListener('click', function () {
if (busy || submitted) return;
var missing = cats.slice(0, 3).filter(function (c) {
return !groups[c].some(function (o) {
return o.input.checked;
});
});
if (missing.length) {
showCategory(missing[0], true);
status.classList.add('error');
status.textContent = 'Please select ' + missing.join(', ') + '.';
return;
}
submitted = true;
submit.disabled = true;
status.classList.remove('error');
status.textContent = 'Submitting…';
ui.classList.add('os-leaving');
setTimeout(
function () {
next.click();
},
reduce.matches ? 0 : 180
);
setTimeout(function () {
if (document.contains(ui)) {
submitted = false;
submit.disabled = false;
ui.classList.remove('os-leaving');
status.classList.add('error');
status.textContent = 'Please try Continue again.';
}
}, 12000);
});
// Let native validation and the survey's own navigation perform all submissions.
var nativeErrors = Array.from(document.querySelectorAll('.error-text,.error'))
.map(function (e) {
return e.textContent.trim();
})
.filter(Boolean);
showCategory(active, false);
if (nativeErrors.length) {
status.classList.add('error');
status.textContent = nativeErrors.join(' ');
}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 complete XML and open your test survey. Walk through all five tasks, selecting different combinations and testing both No hat and a selected hat.
| Check | Expected result |
|---|---|
| Leave a required category unanswered | You cannot complete the task without a shirt, pants and shoes |
| Change an item before Continue | The outfit and native answer both reflect the replacement |
| Refresh during a task | The version stays the same; an available same-tab draft can be restored |
Compare offered items with the .dat | Every menu contains the assigned subset for that version and task |
Inspect Shirt_1, Pants_1, Shoes_1 and later tasks | Export codes identify the chosen levels, 1 through 6 |
Inspect Hat_1 and later tasks | 0 means No hat; 1 through 6 identify a hat |
Inspect Offered_shirt_1 and the other offered fields | IDs match the design's slot order |
Try keyboard selection, repeated clicks and a phone-sized layout as well. The repository reports native five-task and export checks, but physical phones, Safari/Firefox, screen readers and interrupted network submission still need verification for your deployment.
This is a single-instance example. Changing the loop label does not rename its fixed child fields or DOM IDs. Do not paste a second copy into the same survey. Changing task counts or inventory also requires coordinated updates to the design, XML, dataset and interface.
Reuse the component with Questra Fine Tuning
Put the finished component in a Questra Fine Tuning template so Questra can create outfit selection elements for you and your team. The repository includes an importable questra-template.json with the shared base, the OutfitSelection component, the design file and all 24 illustrations.
Import it from Fine Tuning, select it when programming, and request one OutfitSelection component in the main slot with {"label":"OutfitRounds"}. Keep the single-instance rule. The template gives your team a reusable starting point; changes to the inventory or study design still need the matching files and a native preview/export check.
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="Outfit selection"
alt="Outfit selection"
compat="144"
fir="on"
html:showNumber="0"
builder:wizardCompleted="1"
state="testing"
ss:enableNavigation="0"
extraVariables="source"
autosave="1"
>
<style name="respview.client.css"><![CDATA[
<style>
body.outfit-ready {
margin: 0;
background: #f6f4ed;
color: #263c34;
font-family: Arial, Helvetica, sans-serif;
}
.outfit-ready #survey,
.outfit-ready .survey-section,
.outfit-ready .survey-body {
width: auto;
max-width: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.outfit-ready .page-header,
.outfit-ready .survey-header,
.outfit-ready .survey-footer,
.outfit-ready .survey-buttons,
.outfit-ready .outfit-native,
.outfit-ready [id^='comment_OutfitMeta'] {
display: none !important;
}
.outfit-ready .survey-info {
display: none !important;
}
.outfit-ui {
max-width: 1320px;
margin: 0 auto;
padding: 30px 56px 28px;
box-sizing: border-box;
}
.outfit-ui * {
box-sizing: border-box;
}
.outfit-ui button {
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.outfit-ui button:focus-visible {
outline: 3px solid #bf623d;
outline-offset: 5px;
}
.os-progress {
display: flex;
align-items: center;
gap: 15px;
font-size: 12px;
color: #6b756a;
margin-bottom: 18px;
}
.os-dots {
display: flex;
gap: 6px;
}
.os-dot {
width: 25px;
height: 4px;
border-radius: 4px;
background: #d7ddcf;
}
.os-dot.done {
background: #7d9277;
}
.os-dot.current {
background: #263c34;
}
.os-heading {
margin: 0 0 24px;
}
.os-heading h1 {
font:
600 clamp(24px, 2.5vw, 32px)/1.25 Arial,
Helvetica,
sans-serif;
letter-spacing: -0.6px;
margin: 0;
color: #263c34;
}
.os-heading p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
color: #6b756a;
}
.os-workspace {
display: grid;
grid-template-columns: 1fr 1.08fr;
gap: 34px;
align-items: stretch;
}
.os-stage {
position: relative;
height: 518px;
border-radius: 24px;
background: radial-gradient(ellipse at 50% 30%, #eef1e3 0%, #dfe6d2 72%);
overflow: hidden;
isolation: isolate;
border: 1px solid #d3dcc7;
transition: box-shadow 0.2s;
}
.os-stage:after {
content: '';
position: absolute;
left: 16%;
right: 16%;
bottom: 27px;
height: 19px;
background: #5c6b4420;
filter: blur(15px);
border-radius: 50%;
z-index: -1;
}
.os-slot {
position: absolute;
display: grid;
place-items: center;
left: 50%;
transform: translateX(-50%);
transition: filter 0.2s;
}
.os-slot[data-cat='hat'] {
top: 8px;
width: 115px;
height: 123px;
z-index: 4;
left: 52%;
}
.os-slot[data-cat='shirt'] {
top: 93px;
width: 245px;
height: 261px;
z-index: 3;
}
.os-slot[data-cat='pants'] {
top: 266px;
width: 188px;
height: 201px;
z-index: 2;
}
.os-slot[data-cat='shoes'] {
top: 405px;
width: 120px;
height: 128px;
z-index: 4;
}
.os-slot img {
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
pointer-events: none;
filter: drop-shadow(0 10px 8px #32442f10);
}
.os-slot.empty img {
opacity: 0.1;
filter: grayscale(1);
}
.os-slot.empty:after {
content: attr(data-prompt);
position: absolute;
font-size: 10px;
letter-spacing: 1.2px;
text-transform: uppercase;
background: #eef2e8cc;
padding: 7px 11px;
border: 1px dashed #9daa8c;
border-radius: 20px;
color: #617354;
}
.os-slot[data-cat='hat'].empty {
opacity: 0.5;
}
.os-slot[data-cat='hat'].empty:after {
display: none;
}
.os-slot.active-drop {
filter: drop-shadow(0 0 16px #fff);
}
.os-stage.drag-over {
box-shadow: inset 0 0 0 3px #81976c;
}
.os-wardrobe {
padding-top: 3px;
display: flex;
flex-direction: column;
min-width: 0;
}
.os-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7px;
margin-bottom: 26px;
}
.os-tab {
position: relative;
border: 1px solid #dce1d5;
border-radius: 50px;
background: transparent;
color: #6c7868;
padding: 12px 5px;
font-size: 12px !important;
transition:
background 0.2s,
color 0.2s,
border 0.2s;
}
.os-tab.active {
background: #263c34;
color: #fff;
border-color: #263c34;
}
.os-tab .os-tick {
display: none;
margin-left: 5px;
}
.os-tab.chosen .os-tick {
display: inline;
}
.os-section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 17px;
}
.os-section-head h2 {
font:
500 18px Arial,
Helvetica,
sans-serif;
margin: 0;
color: #263c34;
}
.os-section-head span {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.4px;
color: #7b8275;
}
.os-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.os-card {
position: relative;
padding: 10px 8px 14px;
border: 1px solid #dfe3d8;
border-radius: 16px;
background: #fbfaf5;
text-align: left;
color: #34483d;
min-width: 0;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s,
transform 0.25s;
}
.os-card:hover {
transform: translateY(-4px);
border-color: #a8b49b;
box-shadow: 0 10px 20px #263c3409;
}
.os-card[aria-pressed='true'] {
border-color: #788d65;
background: #ecf0e4;
box-shadow: inset 0 0 0 1px #788d65;
}
.os-card img {
display: block;
width: 100%;
height: 166px;
object-fit: contain;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
}
.os-card-name {
display: block;
font-size: 11px;
line-height: 1.45;
min-height: 32px;
margin: 9px 3px 0;
}
.os-card-marker {
position: absolute;
top: 10px;
right: 10px;
width: 19px;
height: 19px;
border-radius: 50%;
background: #fff9;
border: 1px solid #cbd4bf;
display: grid;
place-items: center;
font-size: 11px;
color: transparent;
}
.os-card[aria-pressed='true'] .os-card-marker {
background: #667e56;
color: white;
border-color: #667e56;
}
.os-nohat {
display: block;
border: 1px solid #dfe3d8;
border-radius: 12px;
background: transparent;
color: #54654b;
padding: 10px 14px;
width: 100%;
margin-top: 10px;
font-size: 12px !important;
text-align: left;
}
.os-nohat[aria-pressed='true'] {
border-color: #788d65;
background: #ecf0e4;
}
.os-nohat span {
float: right;
}
.os-nohat:not([aria-pressed='true']) span {
visibility: hidden;
}
.os-bottom {
margin-top: auto;
padding-top: 23px;
}
.os-submit {
border: 0;
border-radius: 13px;
background: #263c34;
color: white;
padding: 17px 20px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
transition:
background 0.2s,
transform 0.2s;
min-height: 52px;
font-size: 13px !important;
}
.os-submit:hover {
background: #3c5647;
}
.os-submit:active {
transform: scale(0.985);
}
.os-submit:disabled {
cursor: wait;
opacity: 0.65;
}
.os-submit span:last-child {
font-size: 19px;
}
.os-status {
font-size: 11px;
color: #65765b;
margin: 10px 0 0;
min-height: 16px;
line-height: 1.4;
}
.os-status.error {
color: #a7462f;
}
.os-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
object-fit: contain;
filter: drop-shadow(0 15px 15px #263c342b);
will-change: transform, width, height;
}
.outfit-ui.os-leaving {
opacity: 0;
transform: translateY(-8px);
transition:
opacity 0.18s,
transform 0.18s;
}
.os-cards.os-enter {
animation: os-in 0.35s both;
}
\@keyframes os-in {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.os-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
\@media (min-width: 1500px) {
.outfit-ui {
padding-top: 40px;
}
.os-stage {
height: 560px;
}
.os-slot[data-cat='shirt'] {
top: 108px;
}
.os-slot[data-cat='pants'] {
top: 281px;
}
.os-slot[data-cat='shoes'] {
top: 420px;
}
.os-slot[data-cat='hat'] {
top: 23px;
}
}
\@media (max-width: 900px) {
.outfit-ui {
padding: 23px 25px;
}
.os-workspace {
gap: 22px;
grid-template-columns: 0.95fr 1.05fr;
}
.os-card img {
height: 138px;
}
.os-tabs {
gap: 4px;
}
.os-tab {
font-size: 11px !important;
}
.os-stage {
height: 500px;
}
.os-heading p {
font-size: 12px;
}
.os-slot[data-cat='shoes'] {
top: 389px;
}
.os-slot[data-cat='pants'] {
top: 250px;
}
.os-slot[data-cat='shirt'] {
top: 77px;
}
.os-slot[data-cat='hat'] {
top: -8px;
}
}
\@media (max-width: 650px) {
.outfit-ui {
padding: 17px 17px 22px;
}
.os-progress {
gap: 9px;
font-size: 10px;
}
.os-dot {
width: 15px;
}
.os-heading {
margin: 0 0 18px;
}
.os-heading h1 {
font-size: 25px;
letter-spacing: -0.5px;
}
.os-heading p {
margin: 8px 0 0;
max-width: none;
font-size: 11px;
}
.os-workspace {
display: flex;
flex-direction: column;
gap: 19px;
}
.os-stage {
height: 270px;
border-radius: 20px;
}
.os-slot[data-cat='hat'] {
width: 67px;
height: 72px;
top: -3px;
left: 51%;
}
.os-slot[data-cat='shirt'] {
width: 143px;
height: 153px;
top: 39px;
}
.os-slot[data-cat='pants'] {
width: 110px;
height: 117px;
top: 125px;
}
.os-slot[data-cat='shoes'] {
width: 70px;
height: 75px;
top: 200px;
}
.os-slot.empty:after {
font-size: 8px;
padding: 4px 7px;
}
.os-tabs {
margin-bottom: 17px;
gap: 7px;
}
.os-tab {
padding: 10px 5px;
}
.os-section-head {
margin-bottom: 12px;
}
.os-section-head h2 {
font-size: 17px;
}
.os-section-head span {
font-size: 9px;
}
.os-card {
padding: 6px 6px 10px;
border-radius: 13px;
}
.os-card img {
height: 114px;
}
.os-card-name {
font-size: 10px;
margin-top: 3px;
min-height: 29px;
}
.os-card-marker {
width: 17px;
height: 17px;
top: 7px;
right: 7px;
}
.os-bottom {
padding-top: 15px;
}
.os-submit {
padding: 14px 17px;
min-height: 48px;
}
.os-status {
font-size: 10px;
margin-top: 8px;
}
}
\@media (prefers-reduced-motion: reduce) {
.outfit-ui *,
.os-ghost {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
.os-card:hover {
transform: none;
}
}
body.outfit-boot {
background: #f6f4ed;
color: #263c34;
}
.outfit-boot #survey {
visibility: hidden;
}
#outfit-start {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: #f6f4ed;
font:
28px Georgia,
serif;
visibility: visible;
z-index: 100;
}
.os-status:not(.error) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
margin: 0;
}
</style>
]]></style>
<style name="respview.client.js"><![CDATA[
<script>
jQuery(function () {
window.OUTFIT_DATASET = [
{
'id': 'shirt1',
'category': 'shirt',
'code': 1,
'name': 'Cloud cotton tee',
'src': 'shirt1.svg'
},
{
'id': 'shirt2',
'category': 'shirt',
'code': 2,
'name': 'Ink knit polo',
'src': 'shirt2.svg'
},
{
'id': 'shirt3',
'category': 'shirt',
'code': 3,
'name': 'Clay camp shirt',
'src': 'shirt3.svg'
},
{
'id': 'shirt4',
'category': 'shirt',
'code': 4,
'name': 'Sage overshirt',
'src': 'shirt4.svg'
},
{
'id': 'shirt5',
'category': 'shirt',
'code': 5,
'name': 'Breton stripe',
'src': 'shirt5.svg'
},
{
'id': 'shirt6',
'category': 'shirt',
'code': 6,
'name': 'Sunwashed linen',
'src': 'shirt6.svg'
},
{
'id': 'pants1',
'category': 'pants',
'code': 1,
'name': 'Indigo straight denim',
'src': 'pants1.svg'
},
{
'id': 'pants2',
'category': 'pants',
'code': 2,
'name': 'Sand chinos',
'src': 'pants2.svg'
},
{
'id': 'pants3',
'category': 'pants',
'code': 3,
'name': 'Olive cargo',
'src': 'pants3.svg'
},
{
'id': 'pants4',
'category': 'pants',
'code': 4,
'name': 'Charcoal pleats',
'src': 'pants4.svg'
},
{
'id': 'pants5',
'category': 'pants',
'code': 5,
'name': 'Ecru wide leg',
'src': 'pants5.svg'
},
{
'id': 'pants6',
'category': 'pants',
'code': 6,
'name': 'Washed blue denim',
'src': 'pants6.svg'
},
{
'id': 'shoes1',
'category': 'shoes',
'code': 1,
'name': 'Chalk court sneaker',
'src': 'shoes1.svg'
},
{
'id': 'shoes2',
'category': 'shoes',
'code': 2,
'name': 'Forest runner',
'src': 'shoes2.svg'
},
{
'id': 'shoes3',
'category': 'shoes',
'code': 3,
'name': 'Cocoa loafer',
'src': 'shoes3.svg'
},
{
'id': 'shoes4',
'category': 'shoes',
'code': 4,
'name': 'Ink high top',
'src': 'shoes4.svg'
},
{
'id': 'shoes5',
'category': 'shoes',
'code': 5,
'name': 'Sand desert boot',
'src': 'shoes5.svg'
},
{
'id': 'shoes6',
'category': 'shoes',
'code': 6,
'name': 'Clay trail sneaker',
'src': 'shoes6.svg'
},
{
'id': 'hat1',
'category': 'hat',
'code': 1,
'name': 'Ink baseball cap',
'src': 'hat1.svg'
},
{
'id': 'hat2',
'category': 'hat',
'code': 2,
'name': 'Oat bucket hat',
'src': 'hat2.svg'
},
{
'id': 'hat3',
'category': 'hat',
'code': 3,
'name': 'Rust beanie',
'src': 'hat3.svg'
},
{
'id': 'hat4',
'category': 'hat',
'code': 4,
'name': 'Sage five panel',
'src': 'hat4.svg'
},
{
'id': 'hat5',
'category': 'hat',
'code': 5,
'name': 'Cream baseball cap',
'src': 'hat5.svg'
},
{
'id': 'hat6',
'category': 'hat',
'code': 6,
'name': 'Charcoal bucket hat',
'src': 'hat6.svg'
}
];
(function () {
'use strict';
var boot = document.getElementById('outfit-start');
if (boot) {
document.body.classList.add('outfit-boot');
var startButton = document.getElementById('btn_continue');
if (startButton)
setTimeout(function () {
startButton.click();
}, 30);
return;
}
var meta = document.getElementById('outfit-meta');
if (!meta) return;
var cats = ['shirt', 'pants', 'shoes', 'hat'],
groups = {},
data = window.OUTFIT_DATASET,
round = Number(meta.dataset.round),
version = meta.dataset.version;
var next = document.getElementById('btn_continue'),
form = next && next.form;
if (!next || !form) return;
var dataset = {};
data.forEach(function (d) {
dataset[d.id] = d;
});
// All offered values come from server-rendered native rows; JS never randomizes a design.
for (var ci = 0; ci < cats.length; ci++) {
var cat = cats[ci],
question = document.querySelector('.outfit-' + cat);
if (!question) return;
var options = [];
Array.prototype.forEach.call(
question.querySelectorAll('input[type="radio"]'),
function (input) {
var label = question.querySelector('label[for="' + input.id + '"]');
var name = label && label.textContent.trim();
var item = data.find(function (d) {
return d.category === cat && d.name === name;
});
if (cat === 'hat' && name === 'No hat')
item = { id: 'hat0', category: 'hat', code: 0, name: 'No hat', src: null };
if (item) options.push({ item: item, input: input });
}
);
if (options.length !== (cat === 'hat' ? 4 : 3)) return;
groups[cat] = options;
}
var savedKey = 'outfit:' + meta.dataset.session + ':' + version + ':' + round,
selected = {},
busy = false,
submitted = false,
active = 'shirt',
drag = null,
suppressClick = 0,
transitionToken = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var saved = {};
try {
saved = JSON.parse(sessionStorage.getItem(savedKey) || '{}');
} catch (e) {}
cats.forEach(function (cat) {
var existing = groups[cat].find(function (x) {
return x.input.checked;
});
// Native restored values take precedence; local drafts only target offered native inputs.
var restore =
existing ||
groups[cat].find(function (x) {
return x.item.id === saved[cat];
});
if (!restore && cat === 'hat')
restore = groups.hat.find(function (x) {
return x.item.code === 0;
});
if (restore) {
restore.input.click();
selected[cat] = restore.item.id;
}
});
active =
cats.find(function (c) {
return !selected[c];
}) || 'hat';
var ui = document.createElement('main');
ui.className = 'outfit-ui';
ui.setAttribute('aria-label', 'Outfit selection');
ui.innerHTML =
'<div class="os-progress" aria-label="Task ' +
round +
' of 5"><span>Task ' +
round +
' of 5</span><div class="os-dots" aria-hidden="true">' +
[1, 2, 3, 4, 5]
.map(function (n) {
return (
'<i class="os-dot ' +
(n < round ? 'done' : n === round ? 'current' : '') +
'"></i>'
);
})
.join('') +
'</div></div><div class="os-heading"><h1>Which outfit would you choose?</h1><p>Choose a shirt, pants and shoes. Hat is optional. Drag or tap to select.</p></div><div class="os-workspace"><section class="os-stage" aria-label="Your outfit. Drop an item here.">' +
['hat', 'shirt', 'pants', 'shoes']
.map(function (c) {
return (
'<div class="os-slot empty" data-cat="' +
c +
'" data-prompt="+ ' +
c +
'"><img alt="" draggable="false"></div>'
);
})
.join('') +
'</section><section class="os-wardrobe" aria-label="Clothing choices"><div class="os-tabs" role="tablist" aria-label="Clothing category">' +
cats
.map(function (c, i) {
return (
'<button type="button" class="os-tab" id="tab-' +
c +
'" role="tab" aria-controls="os-panel" data-cat="' +
c +
'">' +
c.charAt(0).toUpperCase() +
c.slice(1) +
'<span class="os-tick" aria-hidden="true">✓</span></button>'
);
})
.join('') +
'</div><div id="os-panel" role="tabpanel"><div class="os-section-head"><h2></h2><span></span></div><div class="os-cards" role="group"></div><div class="os-none-wrap"></div></div><div class="os-bottom"><button type="button" class="os-submit"><span>Continue</span><span aria-hidden="true">→</span></button><p class="os-status" role="status" aria-live="polite"></p></div></section></div>';
meta.closest('.html').insertAdjacentElement('beforebegin', ui);
document.body.classList.add('outfit-ready');
var stage = ui.querySelector('.os-stage'),
cards = ui.querySelector('.os-cards'),
status = ui.querySelector('.os-status'),
submit = ui.querySelector('.os-submit');
function src(item) {
return /^(https?:|data:)/.test(item.src)
? item.src
: meta.dataset.assets + item.src;
}
function itemFor(id) {
return id === 'hat0'
? { id: 'hat0', name: 'No hat', code: 0, category: 'hat' }
: dataset[id];
}
function draft() {
try {
sessionStorage.setItem(savedKey, JSON.stringify(selected));
} catch (e) {}
}
function update() {
cats.forEach(function (c) {
var slot = ui.querySelector('.os-slot[data-cat="' + c + '"]'),
item = selected[c] && itemFor(selected[c]),
empty = !item || !item.code;
slot.classList.toggle('empty', empty);
slot.querySelector('img').src = src(empty ? groups[c][0].item : item);
slot.querySelector('img').alt = empty ? '' : item.name;
var tab = ui.querySelector('.os-tab[data-cat="' + c + '"]');
tab.classList.toggle('chosen', !!selected[c]);
tab.classList.toggle('active', c === active);
tab.setAttribute('aria-selected', c === active ? 'true' : 'false');
tab.tabIndex = c === active ? 0 : -1;
});
ui.querySelectorAll('[data-option]').forEach(function (b) {
b.setAttribute(
'aria-pressed',
selected[active] === b.dataset.option ? 'true' : 'false'
);
});
if (!status.classList.contains('error')) status.textContent = '';
}
function showCategory(c, focus) {
active = c;
transitionToken++;
var title = {
shirt: 'Select a shirt',
pants: 'Select pants',
shoes: 'Select shoes',
hat: 'Select a hat'
};
ui.querySelector('.os-section-head h2').textContent = title[c];
ui.querySelector('.os-section-head span').textContent =
c === 'hat' ? 'Optional' : 'Choose one';
ui.querySelector('#os-panel').setAttribute('aria-labelledby', 'tab-' + c);
cards.setAttribute('aria-label', c + ' options');
cards.innerHTML = '';
ui.querySelector('.os-none-wrap').innerHTML = '';
groups[c].forEach(function (o) {
var b = document.createElement('button');
b.type = 'button';
b.dataset.option = o.item.id;
b.setAttribute('aria-label', o.item.name);
b.setAttribute('aria-pressed', selected[c] === o.item.id ? 'true' : 'false');
if (o.item.code === 0) {
b.className = 'os-nohat';
b.innerHTML = 'No hat <span aria-hidden="true">✓</span>';
ui.querySelector('.os-none-wrap').appendChild(b);
} else {
b.className = 'os-card';
b.innerHTML =
'<span class="os-card-marker" aria-hidden="true">✓</span><img src="' +
src(o.item) +
'" alt="" draggable="false"><span class="os-card-name">' +
o.item.name +
'</span>';
cards.appendChild(b);
}
b.addEventListener('click', function (e) {
if (Date.now() < suppressClick) return;
choose(o, b.querySelector('img'), true, null, null, e.detail === 0);
});
b.addEventListener('keydown', function (e) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].indexOf(e.key) < 0)
return;
e.preventDefault();
var all = Array.from(ui.querySelectorAll('[data-option]')),
idx = all.indexOf(b),
dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
all[(idx + dir + all.length) % all.length].focus();
});
if (o.item.code)
b.querySelector('img').addEventListener('pointerdown', function (e) {
startDrag(e, o, b);
});
});
cards.classList.remove('os-enter');
void cards.offsetWidth;
cards.classList.add('os-enter');
update();
if (focus) ui.querySelector('.os-tab[data-cat="' + c + '"]').focus();
}
function ghostFor(image, rect) {
var g = image.cloneNode();
g.className = 'os-ghost';
g.setAttribute('aria-hidden', 'true');
Object.assign(g.style, {
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px'
});
document.body.appendChild(g);
return g;
}
function fly(o, image, existing, start) {
var target = ui.querySelector('.os-slot[data-cat="' + o.item.category + '"] img'),
dest = target.getBoundingClientRect();
if (!image || reduce.matches) {
if (existing) existing.remove();
return Promise.resolve();
}
var rect = start || image.getBoundingClientRect(),
g = existing || ghostFor(image, rect);
g.style.transform = 'none';
var a = g.animate(
[
{
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
opacity: 1
},
{
left: dest.left + 'px',
top: dest.top + 'px',
width: dest.width + 'px',
height: dest.height + 'px',
opacity: 1
}
],
{ duration: 440, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'forwards' }
);
return a.finished
.catch(function () {})
.then(function () {
g.remove();
});
}
function choose(o, image, advance, ghost, start, keyboard) {
if (busy || submitted) {
if (ghost) ghost.remove();
return;
}
if (
!groups[o.item.category].some(function (x) {
return x === o;
})
) {
if (ghost) ghost.remove();
return;
}
busy = true;
var originalCategory = active,
token = transitionToken;
o.input.click();
if (!o.input.checked) {
busy = false;
if (ghost) ghost.remove();
return;
}
selected[o.item.category] = o.item.id;
draft();
status.classList.remove('error');
// Native choice is committed synchronously; only the presentation waits for the snap.
fly(o, image, ghost, start).then(function () {
update();
busy = false;
status.textContent = o.item.name + ' selected.';
if (
advance &&
token === transitionToken &&
originalCategory === o.item.category
) {
var n = cats.indexOf(originalCategory);
if (n < 3) showCategory(cats[n + 1], !!keyboard);
}
});
}
function startDrag(e, o, button) {
if (busy || submitted || e.button !== 0) return;
var image = button.querySelector('img'),
r = image.getBoundingClientRect();
drag = {
pointer: e.pointerId,
x: e.clientX,
y: e.clientY,
lastX: e.clientX,
lastY: e.clientY,
o: o,
image: image,
button: button,
rect: r,
ghost: null,
moved: false
};
image.setPointerCapture(e.pointerId);
}
document.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.pointer) return;
var dx = e.clientX - drag.x,
dy = e.clientY - drag.y;
drag.lastX = e.clientX;
drag.lastY = e.clientY;
if (!drag.moved && Math.hypot(dx, dy) < 7) return;
if (!drag.moved) {
drag.moved = true;
drag.ghost = ghostFor(drag.image, drag.rect);
drag.button.style.opacity = '.45';
}
drag.ghost.style.transform =
'translate(' +
dx +
'px,' +
dy +
'px) rotate(' +
Math.max(-7, Math.min(7, dx / 25)) +
'deg)';
var r = stage.getBoundingClientRect(),
inside =
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
stage.classList.toggle('drag-over', inside);
ui.querySelector(
'.os-slot[data-cat="' + drag.o.item.category + '"]'
).classList.toggle('active-drop', inside);
});
function endDrag(e, cancel) {
if (!drag || e.pointerId !== drag.pointer) return;
var d = drag;
drag = null;
d.button.style.opacity = '';
stage.classList.remove('drag-over');
ui.querySelectorAll('.active-drop').forEach(function (x) {
x.classList.remove('active-drop');
});
if (!d.moved) return;
suppressClick = Date.now() + 500;
var r = stage.getBoundingClientRect(),
inside =
!cancel &&
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
var start = {
left: d.rect.left + d.lastX - d.x,
top: d.rect.top + d.lastY - d.y,
width: d.rect.width,
height: d.rect.height
};
if (inside) {
choose(d.o, d.image, true, d.ghost, start);
} else {
if (reduce.matches) {
d.ghost.remove();
return;
}
var a = d.ghost.animate(
[
{ transform: d.ghost.style.transform, opacity: 1 },
{ transform: 'translate(0,0) rotate(0)', opacity: 0.3 }
],
{ duration: 230, easing: 'ease-out' }
);
a.finished
.catch(function () {})
.then(function () {
d.ghost.remove();
});
}
}
document.addEventListener('pointerup', function (e) {
endDrag(e, false);
});
document.addEventListener('pointercancel', function (e) {
endDrag(e, true);
});
ui.querySelectorAll('.os-tab').forEach(function (b) {
b.addEventListener('click', function () {
if (!submitted) showCategory(b.dataset.cat, false);
});
b.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var idx = cats.indexOf(b.dataset.cat),
delta = e.key === 'ArrowRight' ? 1 : -1;
showCategory(cats[(idx + delta + 4) % 4], true);
});
});
submit.addEventListener('click', function () {
if (busy || submitted) return;
var missing = cats.slice(0, 3).filter(function (c) {
return !groups[c].some(function (o) {
return o.input.checked;
});
});
if (missing.length) {
showCategory(missing[0], true);
status.classList.add('error');
status.textContent = 'Please select ' + missing.join(', ') + '.';
return;
}
submitted = true;
submit.disabled = true;
status.classList.remove('error');
status.textContent = 'Submitting…';
ui.classList.add('os-leaving');
setTimeout(
function () {
next.click();
},
reduce.matches ? 0 : 180
);
setTimeout(function () {
if (document.contains(ui)) {
submitted = false;
submit.disabled = false;
ui.classList.remove('os-leaving');
status.classList.add('error');
status.textContent = 'Please try Continue again.';
}
}, 12000);
});
// Let native validation and the survey's own navigation perform all submissions.
var nativeErrors = Array.from(document.querySelectorAll('.error-text,.error'))
.map(function (e) {
return e.textContent.trim();
})
.filter(Boolean);
showCategory(active, false);
if (nativeErrors.length) {
status.classList.add('error');
status.textContent = nativeErrors.join(' ');
}
})();
});
</script>
]]></style>
<exec when="init"><![CDATA[
def setupDCMFile(fname, fileDelimiter="\t"):
f = open("%s/%s" % (gv.survey.path, fname))
rows = [line.strip("\r\n").split(fileDelimiter) for line in f.readlines()]
f.close()
d = {}
for row in rows[1:]:
if len(row) == 7:
d["v%s_t%s_c%s" % (row[0],row[1],row[2])] = row[3:]
return d
outfitDesign = setupDCMFile("outfit-design-v2.dat")
def outfitOffers(version, task, attribute):
return [int(outfitDesign["v%s_t%s_c%s" % (version,task,c)][attribute]) for c in range(1,4)]
]]></exec>
<number label="DesignVersion" size="3" where="execute" optional="1">
<title>Assigned design version</title>
<exec>
if DesignVersion.val is None:
DesignVersion.val = random.randint(1,12)
</exec>
</number>
<text label="DesignID" where="execute" optional="1">
<title>Immutable design ID</title>
<exec>DesignID.val = "outfit-20261019-v2"</exec>
</text>
<exec>
if not p.get('outfitSession'):
p.outfitSession = str(random.randint(10000000,99999999)) + str(random.randint(10000000,99999999))
</exec>
<html label="OutfitStart" where="survey"><div id="outfit-start">Loading…</div></html>
<suspend />
<loop label="OutfitRounds" vars="task" randomizeChildren="0">
<block label="OutfitRound" randomize="0">
<exec>
p.outfitTask = int("[loopvar: task]")
p.shirtOffers = outfitOffers(DesignVersion.val, p.outfitTask, 0)
p.pantsOffers = outfitOffers(DesignVersion.val, p.outfitTask, 1)
p.shoesOffers = outfitOffers(DesignVersion.val, p.outfitTask, 2)
p.hatOffers = outfitOffers(DesignVersion.val, p.outfitTask, 3)
</exec>
<number label="Task_[loopvar: task]" where="execute" optional="1" size="3">
<title>Design task</title>
<exec>Task_[loopvar: task].val = p.outfitTask</exec>
</number>
<text label="Offered_shirt_[loopvar: task]" where="execute" optional="1">
<title>Offered shirt IDs in design slot order</title>
<exec>Offered_shirt_[loopvar: task].val = ",".join(["shirt%s" % x for x in p.shirtOffers])</exec>
</text>
<text label="Offered_pants_[loopvar: task]" where="execute" optional="1">
<title>Offered pants IDs in design slot order</title>
<exec>Offered_pants_[loopvar: task].val = ",".join(["pants%s" % x for x in p.pantsOffers])</exec>
</text>
<text label="Offered_shoes_[loopvar: task]" where="execute" optional="1">
<title>Offered shoes IDs in design slot order</title>
<exec>Offered_shoes_[loopvar: task].val = ",".join(["shoes%s" % x for x in p.shoesOffers])</exec>
</text>
<text label="Offered_hat_[loopvar: task]" where="execute" optional="1">
<title>Offered hat IDs in design slot order</title>
<exec>Offered_hat_[loopvar: task].val = ",".join(["hat%s" % x for x in p.hatOffers])</exec>
</text>
<html label="OutfitMeta_[loopvar: task]" where="survey"><div id="outfit-meta" data-round="[loopvar: task]" data-version="${DesignVersion.val}" data-session="${p.outfitSession}" data-assets="/survey/${gv.survey.path}/"></div></html>
<radio
label="Shirt_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-shirt"
>
<title>Shirt</title>
<row label="r1" value="1" cond="1 in p.shirtOffers">Cloud cotton tee</row>
<row label="r2" value="2" cond="2 in p.shirtOffers">Ink knit polo</row>
<row label="r3" value="3" cond="3 in p.shirtOffers">Clay camp shirt</row>
<row label="r4" value="4" cond="4 in p.shirtOffers">Sage overshirt</row>
<row label="r5" value="5" cond="5 in p.shirtOffers">Breton stripe</row>
<row label="r6" value="6" cond="6 in p.shirtOffers">Sunwashed linen</row>
<validate>
if Shirt_[loopvar: task].val is not None:
if int(Shirt_[loopvar: task].selected.label[1:]) not in p.shirtOffers:
error("Please choose an available option.")
</validate>
</radio>
<radio
label="Pants_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-pants"
>
<title>Pants</title>
<row label="r1" value="1" cond="1 in p.pantsOffers">Indigo straight denim</row>
<row label="r2" value="2" cond="2 in p.pantsOffers">Sand chinos</row>
<row label="r3" value="3" cond="3 in p.pantsOffers">Olive cargo</row>
<row label="r4" value="4" cond="4 in p.pantsOffers">Charcoal pleats</row>
<row label="r5" value="5" cond="5 in p.pantsOffers">Ecru wide leg</row>
<row label="r6" value="6" cond="6 in p.pantsOffers">Washed blue denim</row>
<validate>
if Pants_[loopvar: task].val is not None:
if int(Pants_[loopvar: task].selected.label[1:]) not in p.pantsOffers:
error("Please choose an available option.")
</validate>
</radio>
<radio
label="Shoes_[loopvar: task]"
optional="0"
ss:questionClassNames="outfit-native outfit-shoes"
>
<title>Shoes</title>
<row label="r1" value="1" cond="1 in p.shoesOffers">Chalk court sneaker</row>
<row label="r2" value="2" cond="2 in p.shoesOffers">Forest runner</row>
<row label="r3" value="3" cond="3 in p.shoesOffers">Cocoa loafer</row>
<row label="r4" value="4" cond="4 in p.shoesOffers">Ink high top</row>
<row label="r5" value="5" cond="5 in p.shoesOffers">Sand desert boot</row>
<row label="r6" value="6" cond="6 in p.shoesOffers">Clay trail sneaker</row>
<validate>
if Shoes_[loopvar: task].val is not None:
if int(Shoes_[loopvar: task].selected.label[1:]) not in p.shoesOffers:
error("Please choose an available option.")
</validate>
</radio>
<radio
label="Hat_[loopvar: task]"
optional="1"
ss:questionClassNames="outfit-native outfit-hat"
>
<title>Hat</title>
<row label="r1" value="1" cond="1 in p.hatOffers">Ink baseball cap</row>
<row label="r2" value="2" cond="2 in p.hatOffers">Oat bucket hat</row>
<row label="r3" value="3" cond="3 in p.hatOffers">Rust beanie</row>
<row label="r4" value="4" cond="4 in p.hatOffers">Sage five panel</row>
<row label="r5" value="5" cond="5 in p.hatOffers">Cream baseball cap</row>
<row label="r6" value="6" cond="6 in p.hatOffers">Charcoal bucket hat</row>
<row label="r0" value="0">No hat</row>
<exec>
if Hat_[loopvar: task].val is None:
Hat_[loopvar: task].val = Hat_[loopvar: task].r0.index
</exec>
<validate>
if Hat_[loopvar: task].val is not None:
if int(Hat_[loopvar: task].selected.label[1:]) not in p.hatOffers + [0]:
error("Please choose an available option.")
</validate>
</radio>
<suspend />
</block>
<looprow label="1">
<loopvar name="task">1</loopvar>
</looprow>
<looprow label="2">
<loopvar name="task">2</loopvar>
</looprow>
<looprow label="3">
<loopvar name="task">3</loopvar>
</looprow>
<looprow label="4">
<loopvar name="task">4</loopvar>
</looprow>
<looprow label="5">
<loopvar name="task">5</loopvar>
</looprow>
</loop>
</survey>See how Questra fits your survey workflow.
Book a demo