studio-engine 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ <%# One survey question as a fieldset (studio/surveys/show). It is a complete
2
+ form control without JavaScript: native radios, checkboxes and text fields
3
+ named answers[key], with `required` so the no-JS form validates in the
4
+ browser too. The script enhances it into one screen of the stepper. %>
5
+ <% key = question.key %>
6
+ <% dom = "studio-survey-#{key}" %>
7
+ <% value = response&.answer_value(key) %>
8
+ <% ends = %i[emoji_scale rating].include?(question.type) && (question.low_label || question.high_label) %>
9
+ <% described = [("#{dom}-help" if question.help), ("#{dom}-scale" if ends), "#{dom}-error"].compact.join(" ") %>
10
+ <fieldset class="studio-survey__step" id="<%= dom %>" data-survey-step
11
+ data-key="<%= key %>" data-type="<%= question.type %>" data-required="<%= question.required? %>"
12
+ aria-describedby="<%= described %>">
13
+ <legend class="studio-survey__question" id="<%= dom %>-label" tabindex="-1" data-survey-focus>
14
+ <span class="studio-survey__qnum">Question <%= index + 1 %> of <%= total %></span>
15
+ <%= question.label %><% if question.required? %><span class="studio-survey__req" aria-hidden="true">*</span><span class="studio-survey__sr"> (required)</span><% end %>
16
+ </legend>
17
+ <% if question.help %>
18
+ <p class="studio-survey__help" id="<%= dom %>-help"><%= question.help %></p>
19
+ <% end %>
20
+
21
+ <div class="studio-survey__body">
22
+ <% case question.type %>
23
+ <% when :emoji_scale, :rating %>
24
+ <div class="studio-survey__scale">
25
+ <% question.options.each_with_index do |opt, i| %>
26
+ <label class="studio-survey__tile" data-survey-choice>
27
+ <input type="radio" class="studio-survey__input" name="answers[<%= key %>]" value="<%= opt.value %>"
28
+ <%= "checked" if value.to_s == opt.value.to_s %> <%= "required" if question.required? %>
29
+ <% if opt.face %>aria-label="<%= opt.label %>"<% end %>>
30
+ <% if opt.face %>
31
+ <span class="studio-survey__face" aria-hidden="true"><%= opt.face %></span>
32
+ <span class="studio-survey__tile-label" aria-hidden="true"><%= opt.label %></span>
33
+ <% else %>
34
+ <span class="studio-survey__num"><%= opt.label %></span>
35
+ <% end %>
36
+ <span class="studio-survey__kbd" aria-hidden="true"><%= i + 1 %></span>
37
+ </label>
38
+ <% end %>
39
+ </div>
40
+ <% if ends %>
41
+ <div class="studio-survey__ends" aria-hidden="true">
42
+ <span>1 · <%= question.low_label %></span><span><%= question.high_label %> · 5</span>
43
+ </div>
44
+ <p class="studio-survey__sr" id="<%= dom %>-scale">1 means <%= question.low_label || "lowest" %>, 5 means <%= question.high_label || "highest" %>.</p>
45
+ <% end %>
46
+ <% when :choice, :multi_choice %>
47
+ <% multi = question.multi? %>
48
+ <% chosen = Array(value).map(&:to_s) %>
49
+ <div class="studio-survey__options">
50
+ <% if multi %><input type="hidden" name="answers[<%= key %>][]" value=""><% end %>
51
+ <% question.options.each_with_index do |opt, i| %>
52
+ <label class="studio-survey__option" data-survey-choice>
53
+ <input type="<%= multi ? "checkbox" : "radio" %>" class="studio-survey__input"
54
+ name="answers[<%= key %>]<%= "[]" if multi %>" value="<%= opt.value %>"
55
+ <%= "checked" if chosen.include?(opt.value.to_s) %> <%= "required" if question.required? && !multi %>>
56
+ <span class="studio-survey__mark<%= " studio-survey__mark--box" if multi %>" aria-hidden="true"></span>
57
+ <span class="studio-survey__option-label"><%= opt.label %></span>
58
+ <% if i < 9 %><span class="studio-survey__kbd" aria-hidden="true"><%= i + 1 %></span><% end %>
59
+ </label>
60
+ <% end %>
61
+ </div>
62
+ <% when :short_text %>
63
+ <input type="text" class="input-field studio-survey__text" id="<%= dom %>-input" name="answers[<%= key %>]"
64
+ value="<%= value %>" maxlength="<%= question.max_length %>" aria-labelledby="<%= dom %>-label"
65
+ placeholder="<%= question.placeholder || "Type your answer" %>" autocomplete="off"
66
+ <%= "required" if question.required? %> data-survey-text>
67
+ <% when :long_text %>
68
+ <textarea class="input-field studio-survey__text" id="<%= dom %>-input" name="answers[<%= key %>]" rows="5"
69
+ maxlength="<%= question.max_length %>" aria-labelledby="<%= dom %>-label"
70
+ placeholder="<%= question.placeholder || "Type your answer" %>"
71
+ <%= "required" if question.required? %> data-survey-text><%= value %></textarea>
72
+ <p class="studio-survey__counter" aria-hidden="true"><span data-survey-counter><%= value.to_s.length %></span> / <%= question.max_length %></p>
73
+ <% end %>
74
+ </div>
75
+
76
+ <p class="studio-survey__error" id="<%= dom %>-error" role="alert" data-survey-error <%= "hidden" unless error %>><%= error %></p>
77
+ </fieldset>
@@ -0,0 +1,288 @@
1
+ <%# The survey stepper: progressive enhancement over studio/surveys/show.
2
+ Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
3
+ render that re-runs it never double-binds. Bound roots are tracked in a
4
+ WeakSet, never in a DOM attribute: Turbo's snapshot cache clones the page,
5
+ and a clone keeps every attribute but none of the listeners, so a marker
6
+ in the DOM would make a restored page look set up while it is dead.
7
+ Autosave is best effort: the final Submit posts the whole form, so a
8
+ failed autosave loses nothing. %>
9
+ <%= javascript_tag nonce: true do %>
10
+ (function () {
11
+ "use strict";
12
+
13
+ // Shared across re-runs of this script (Turbo evaluates it on every visit).
14
+ var bound = window.__studioSurveyBound || (window.__studioSurveyBound = new WeakSet());
15
+
16
+ function init(root) {
17
+ if (bound.has(root)) return;
18
+ bound.add(root);
19
+
20
+ var form = root.querySelector("[data-survey-form]");
21
+ var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
22
+ var total = steps.length - 1;
23
+ var back = root.querySelector("[data-survey-back]");
24
+ var next = root.querySelector("[data-survey-next]");
25
+ var submit = root.querySelector("[data-survey-submit]");
26
+ var top = root.querySelector("[data-survey-top]");
27
+ var bar = root.querySelector("[data-survey-progress]");
28
+ var fill = root.querySelector("[data-survey-progress-fill]");
29
+ var count = root.querySelector("[data-survey-count]");
30
+ var status = root.querySelector("[data-survey-status]");
31
+ var hint = root.querySelector("[data-survey-hint]");
32
+ // A page restored from Turbo's cache comes back on the step it was left
33
+ // on; resume there rather than at the server's original resume point.
34
+ var restored = -1;
35
+ // The GLOBAL token, not the form's: with per_form_csrf_tokens on, the
36
+ // form's hidden token is bound to the POST and refused on the PATCH.
37
+ var csrf = root.dataset.csrf || "";
38
+ var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
39
+ var saved = {};
40
+ var timers = {};
41
+ var current = -1;
42
+ var pointerPick = false;
43
+ var submitting = false;
44
+
45
+ form.noValidate = true;
46
+ if (root.classList.contains("is-enhanced")) {
47
+ Array.prototype.forEach.call(root.querySelectorAll("[data-survey-step]"), function (step, i) {
48
+ if (step.classList.contains("is-active")) restored = i;
49
+ });
50
+ }
51
+ root.classList.add("is-enhanced");
52
+ top.hidden = false;
53
+
54
+ function typeOf(step) { return step.dataset.type || ""; }
55
+ function isChoice(step) { return /^(emoji_scale|rating|choice)$/.test(typeOf(step)); }
56
+ function isMulti(step) { return typeOf(step) === "multi_choice"; }
57
+ function inputs(step) { return Array.prototype.slice.call(step.querySelectorAll("input.studio-survey__input")); }
58
+
59
+ function valueOf(step) {
60
+ if (isMulti(step)) return inputs(step).filter(function (i) { return i.checked; }).map(function (i) { return i.value; });
61
+ if (isChoice(step)) {
62
+ var picked = inputs(step).filter(function (i) { return i.checked; })[0];
63
+ return picked ? picked.value : "";
64
+ }
65
+ var field = step.querySelector("[data-survey-text]");
66
+ return field ? field.value.trim() : "";
67
+ }
68
+
69
+ function answered(step) {
70
+ var v = valueOf(step);
71
+ return Array.isArray(v) ? v.length > 0 : v !== "";
72
+ }
73
+
74
+ function setError(step, message) {
75
+ var el = step.querySelector("[data-survey-error]");
76
+ if (!el) return;
77
+ el.textContent = message || "";
78
+ el.hidden = !message;
79
+ step.classList.toggle("is-invalid", !!message);
80
+ }
81
+
82
+ function save(step) {
83
+ var key = step.dataset.key;
84
+ if (!key || submitting) return Promise.resolve(true);
85
+ var value = valueOf(step);
86
+ var fingerprint = JSON.stringify(value);
87
+ if (saved[key] === fingerprint) return Promise.resolve(true);
88
+
89
+ var body = new FormData();
90
+ if (Array.isArray(value)) {
91
+ if (value.length === 0) body.append("value[]", "");
92
+ value.forEach(function (v) { body.append("value[]", v); });
93
+ } else {
94
+ body.append("value", value);
95
+ }
96
+ var url = root.dataset.answerBase + encodeURIComponent(key);
97
+ status.textContent = "Saving…";
98
+ return fetch(url, {
99
+ method: "PATCH",
100
+ body: body,
101
+ credentials: "same-origin",
102
+ headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
103
+ }).then(function (res) {
104
+ // fetch resolves on a 4xx/5xx — the status is the only signal.
105
+ if (!res.ok) {
106
+ return res.json().catch(function () { return {}; }).then(function (data) {
107
+ if (res.status === 422 && data.error) setError(step, data.error);
108
+ status.textContent = "Not saved yet — your answers still go in when you submit.";
109
+ return false;
110
+ });
111
+ }
112
+ saved[key] = fingerprint;
113
+ status.textContent = "Saved";
114
+ return true;
115
+ }).catch(function () {
116
+ status.textContent = "Offline? Your answers still go in when you submit.";
117
+ return false;
118
+ });
119
+ }
120
+
121
+ function render(index, direction) {
122
+ steps.forEach(function (step, i) {
123
+ var active = i === index;
124
+ step.hidden = !active;
125
+ step.classList.toggle("is-active", active);
126
+ if (active) {
127
+ if (direction && !reduce) step.dataset.dir = direction; else delete step.dataset.dir;
128
+ }
129
+ });
130
+ current = index;
131
+ var onIntro = index === 0;
132
+ var last = index === steps.length - 1;
133
+ back.hidden = onIntro;
134
+ next.hidden = last;
135
+ next.textContent = onIntro ? "Start" : "Next";
136
+ submit.hidden = !last;
137
+ // The number-key shortcut only means something on a choice or scale.
138
+ var step = steps[index];
139
+ hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
140
+
141
+ var position = onIntro ? 0 : index;
142
+ fill.style.width = (total ? (position / total) * 100 : 0) + "%";
143
+ bar.setAttribute("aria-valuenow", String(position));
144
+ // The intro already says how many questions there are.
145
+ count.textContent = onIntro ? "" : "Question " + index + " of " + total;
146
+
147
+ var focusTarget = steps[index].querySelector("[data-survey-focus]");
148
+ if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
149
+ if (direction) window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
150
+ }
151
+
152
+ function go(index) {
153
+ if (index < 0 || index >= steps.length || index === current) return;
154
+ render(index, index > current ? "fwd" : "back");
155
+ }
156
+
157
+ function forward() {
158
+ var step = steps[current];
159
+ if (step.dataset.key) {
160
+ if (step.dataset.required === "true" && !answered(step)) {
161
+ setError(step, "This one is required.");
162
+ var first = step.querySelector("input:not([type=hidden]), textarea");
163
+ if (first) first.focus();
164
+ return;
165
+ }
166
+ setError(step, "");
167
+ }
168
+ if (current === steps.length - 1) {
169
+ // No autosave here: the POST carries every answer, and a PATCH racing
170
+ // it would land after completion and be refused.
171
+ submitForm();
172
+ } else {
173
+ if (step.dataset.key) save(step);
174
+ go(current + 1);
175
+ }
176
+ }
177
+
178
+ function submitForm() {
179
+ if (form.requestSubmit) form.requestSubmit(submit); else form.submit();
180
+ }
181
+
182
+ // The first required question still missing an answer, for a Submit that
183
+ // jumps ahead (Enter on the last step, or a mid-survey reload).
184
+ form.addEventListener("submit", function (event) {
185
+ for (var i = 1; i < steps.length; i++) {
186
+ if (steps[i].dataset.required === "true" && !answered(steps[i])) {
187
+ event.preventDefault();
188
+ go(i);
189
+ setError(steps[i], "This one is required.");
190
+ return;
191
+ }
192
+ }
193
+ Object.keys(timers).forEach(function (key) { clearTimeout(timers[key]); });
194
+ submitting = true;
195
+ submit.disabled = true;
196
+ submit.textContent = "Sending…";
197
+ });
198
+
199
+ back.addEventListener("click", function () { go(current - 1); });
200
+ next.addEventListener("click", forward);
201
+
202
+ root.addEventListener("pointerdown", function (event) {
203
+ pointerPick = !!event.target.closest("[data-survey-choice]");
204
+ });
205
+
206
+ root.addEventListener("change", function (event) {
207
+ var step = event.target.closest("[data-survey-step]");
208
+ if (!step || !step.dataset.key || step.hidden) return;
209
+ setError(step, "");
210
+ if (isChoice(step)) {
211
+ save(step);
212
+ // Advance on a tap or a number key, never on arrow keys: arrows move
213
+ // through a radio group and must not jump the reader off the question.
214
+ if (pointerPick) {
215
+ pointerPick = false;
216
+ setTimeout(function () { if (steps[current] === step) forward(); }, reduce ? 0 : 260);
217
+ }
218
+ } else if (isMulti(step)) {
219
+ save(step);
220
+ }
221
+ });
222
+
223
+ root.addEventListener("input", function (event) {
224
+ var field = event.target.closest("[data-survey-text]");
225
+ if (!field) return;
226
+ var step = field.closest("[data-survey-step]");
227
+ var counter = step.querySelector("[data-survey-counter]");
228
+ if (counter) counter.textContent = String(field.value.length);
229
+ clearTimeout(timers[step.dataset.key]);
230
+ timers[step.dataset.key] = setTimeout(function () { save(step); }, 900);
231
+ });
232
+
233
+ root.addEventListener("keydown", function (event) {
234
+ if (event.defaultPrevented || event.altKey) return;
235
+ // A tap on an already-chosen option fires pointerdown but no change, so
236
+ // the flag would outlive it and make the next arrow key advance.
237
+ pointerPick = false;
238
+ var step = steps[current];
239
+ var target = event.target;
240
+ var inText = target.matches && target.matches("textarea, input[type=text]");
241
+
242
+ if (event.key === "Enter") {
243
+ if (target.tagName === "BUTTON" || target.tagName === "A") return;
244
+ if (target.tagName === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return;
245
+ event.preventDefault();
246
+ forward();
247
+ return;
248
+ }
249
+
250
+ if (inText || event.metaKey || event.ctrlKey || !/^[1-9]$/.test(event.key)) return;
251
+ if (!step.dataset.key || !(isChoice(step) || isMulti(step))) return;
252
+ var option = inputs(step)[parseInt(event.key, 10) - 1];
253
+ if (!option) return;
254
+ event.preventDefault();
255
+ option.checked = isMulti(step) ? !option.checked : true;
256
+ option.focus({ preventScroll: true });
257
+ pointerPick = isChoice(step);
258
+ option.dispatchEvent(new Event("change", { bubbles: true }));
259
+ });
260
+
261
+ var resume = root.dataset.resumeIndex;
262
+ var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10) + 1;
263
+ var hasErrors = root.dataset.hasErrors === "true";
264
+ // A fresh visitor sees the intro; a returning one lands on their next
265
+ // question, and a failed submit on the first question it flagged.
266
+ if (restored >= 0) start = restored;
267
+ render(start > 0 ? start : 0, null);
268
+ if (restored < 0 && (start > 0 || hasErrors)) {
269
+ var focusTarget = steps[current].querySelector("[data-survey-focus]");
270
+ if (focusTarget) focusTarget.focus({ preventScroll: true });
271
+ if (start > 0 && !hasErrors) status.textContent = "Welcome back — picking up where you left off.";
272
+ }
273
+ // Answers already on the page are already stored.
274
+ steps.forEach(function (step) { if (step.dataset.key) saved[step.dataset.key] = JSON.stringify(valueOf(step)); });
275
+ }
276
+
277
+ function boot() {
278
+ Array.prototype.forEach.call(document.querySelectorAll("[data-studio-survey]"), init);
279
+ }
280
+
281
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
282
+ // Turbo re-runs this script on every visit; bind the turbo:load hook once.
283
+ if (!window.__studioSurveyTurboHook) {
284
+ window.__studioSurveyTurboHook = true;
285
+ document.addEventListener("turbo:load", boot);
286
+ }
287
+ })();
288
+ <% end %>
@@ -0,0 +1,95 @@
1
+ <%# Survey styles, scoped under .studio-survey. Plain CSS on the theme's CSS
2
+ custom properties (--color-cta, --color-surface, ...) rather than Tailwind
3
+ utilities, so the page looks the same in every host whatever its Tailwind
4
+ scan picks up, and follows the host's dark and light palettes for free.
5
+ Motion is opt-out: prefers-reduced-motion drops every transition. %>
6
+ <style>
7
+ /* overflow-x: clip, not hidden: the slide-in must never widen the page, and
8
+ clip makes no scroll container, so the sticky nav still sticks. */
9
+ .studio-survey { --sv-accent: var(--color-cta, #8E82FE); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
10
+ .studio-survey [hidden] { display: none !important; }
11
+ .studio-survey__shell { width: 100%; max-width: 36rem; }
12
+ .studio-survey__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
13
+ .studio-survey__top { margin-bottom: 1.75rem; }
14
+ .studio-survey__progress { height: 6px; border-radius: 999px; background: var(--color-surface-alt); overflow: hidden; }
15
+ .studio-survey__progress-fill { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--sv-accent); transition: width .35s cubic-bezier(.2, .8, .2, 1); }
16
+ .studio-survey__count { min-height: 1.25rem; margin: .5rem 0 0; font-size: .8125rem; line-height: 1.25rem; color: var(--color-text-muted); }
17
+ .studio-survey__step { border: 0; margin: 0 0 2.25rem; padding: 0; min-width: 0; }
18
+ .studio-survey__title { margin: 0 0 .75rem; font-size: 1.75rem; line-height: 1.2; font-weight: 800; color: var(--color-text); outline: none; }
19
+ .studio-survey__intro-copy { margin: 0 0 1rem; font-size: 1.0625rem; line-height: 1.55; color: var(--color-text-body); white-space: pre-line; }
20
+ .studio-survey__meta { margin: 0; font-size: .8125rem; color: var(--color-text-muted); }
21
+ .studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
22
+ .studio-survey__qnum { display: block; margin-bottom: .375rem; font-size: .75rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--color-text-muted); }
23
+ .studio-survey.is-enhanced .studio-survey__qnum { display: none; }
24
+ .studio-survey__req { color: var(--sv-accent); margin-left: .125rem; }
25
+ .studio-survey__help { margin: 0 0 1rem; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
26
+ .studio-survey__body { margin-top: 1rem; }
27
+ .studio-survey__error { margin: .75rem 0 0; font-size: .875rem; font-weight: 600; color: var(--color-danger-ink, var(--color-danger)); }
28
+ .studio-survey__input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
29
+
30
+ .studio-survey__scale { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .5rem; }
31
+ .studio-survey__tile { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .3rem; min-height: 4.75rem; padding: .75rem .25rem; border: 2px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); color: var(--color-text-body); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: transform .12s ease, border-color .15s ease, background-color .15s ease; }
32
+ .studio-survey__tile:hover { border-color: var(--color-border-strong); }
33
+ .studio-survey__tile:active { transform: scale(.95); }
34
+ .studio-survey__face { font-size: 2.125rem; line-height: 1; transition: transform .18s cubic-bezier(.3, 1.6, .5, 1); }
35
+ .studio-survey__num { font-size: 1.5rem; line-height: 1; font-weight: 800; color: var(--color-text); }
36
+ .studio-survey__tile-label { font-size: .6875rem; line-height: 1.15; text-align: center; color: var(--color-text-secondary); }
37
+ .studio-survey__ends { display: flex; justify-content: space-between; gap: 1rem; margin-top: .5rem; font-size: .75rem; color: var(--color-text-muted); }
38
+
39
+ .studio-survey__options { display: flex; flex-direction: column; gap: .5rem; }
40
+ .studio-survey__option { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 3.25rem; padding: .75rem 1rem; border: 2px solid var(--color-border); border-radius: .875rem; background: var(--color-surface); color: var(--color-text-body); font-size: 1rem; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: border-color .15s ease, background-color .15s ease; }
41
+ .studio-survey__option:hover { border-color: var(--color-border-strong); }
42
+ .studio-survey__mark { flex: none; display: grid; place-items: center; width: 1.25rem; height: 1.25rem; border: 2px solid var(--color-text-muted); border-radius: 999px; background: var(--color-surface); }
43
+ .studio-survey__mark--box { border-radius: .375rem; }
44
+ .studio-survey__option-label { flex: 1 1 auto; min-width: 0; }
45
+
46
+ .studio-survey__tile:has(input:checked), .studio-survey__option:has(input:checked) { border-color: var(--sv-accent); background: color-mix(in srgb, var(--sv-accent) 14%, var(--color-surface)); color: var(--color-text); }
47
+ .studio-survey__tile:has(input:checked) .studio-survey__face { transform: scale(1.18); }
48
+ .studio-survey__tile:has(input:checked) .studio-survey__tile-label { color: var(--color-text); font-weight: 600; }
49
+ .studio-survey__option:has(input:checked) .studio-survey__mark { border-color: var(--sv-accent); background: var(--sv-accent); box-shadow: inset 0 0 0 3px var(--color-surface); }
50
+ .studio-survey__option:has(input:checked) .studio-survey__mark--box { box-shadow: none; }
51
+ .studio-survey__option:has(input:checked) .studio-survey__mark--box::after { content: ""; width: .35rem; height: .65rem; margin-top: -.15rem; border: solid var(--btn-primary-fg, #fff); border-width: 0 2px 2px 0; transform: rotate(45deg); }
52
+ .studio-survey__tile:has(input:focus-visible), .studio-survey__option:has(input:focus-visible) { outline: 3px solid color-mix(in srgb, var(--sv-accent) 70%, transparent); outline-offset: 2px; }
53
+
54
+ .studio-survey__kbd { display: none; font-size: .625rem; font-weight: 600; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: .25rem; padding: 0 .3rem; line-height: 1.4; }
55
+ .studio-survey__tile .studio-survey__kbd { position: absolute; top: .3rem; right: .35rem; }
56
+ .studio-survey__option .studio-survey__kbd { margin-left: auto; }
57
+ @media (hover: hover) and (pointer: fine) { .studio-survey.is-enhanced .studio-survey__option .studio-survey__kbd { display: inline-block; } }
58
+ /* On a scale tile the badge needs the room a phone-width tile does not have. */
59
+ @media (hover: hover) and (pointer: fine) and (min-width: 640px) { .studio-survey.is-enhanced .studio-survey__tile .studio-survey__kbd { display: inline-block; } }
60
+
61
+ .studio-survey__text { width: 100%; font-size: 1rem; }
62
+ textarea.studio-survey__text { min-height: 9rem; resize: vertical; line-height: 1.5; }
63
+ .studio-survey__counter { margin: .375rem 0 0; text-align: right; font-size: .75rem; color: var(--color-text-muted); }
64
+
65
+ .studio-survey__nav { display: flex; align-items: center; gap: .75rem; }
66
+ .studio-survey.is-enhanced .studio-survey__nav { position: sticky; bottom: 0; padding: .75rem 0 calc(.75rem + env(safe-area-inset-bottom, 0px)); background: linear-gradient(to top, var(--color-page) 72%, transparent); }
67
+ .studio-survey__nav .btn { min-height: 3rem; min-width: 6.5rem; }
68
+ .studio-survey__forward { margin-left: auto; }
69
+ .studio-survey__status { min-height: 1.25rem; margin: .5rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
70
+ .studio-survey__hint { margin: .25rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
71
+ @media (hover: none), (pointer: coarse) { .studio-survey__hint { display: none; } }
72
+
73
+ .studio-survey__card { padding: 2rem 1.5rem; text-align: center; border: 1px solid var(--color-border); border-radius: 1.25rem; background: var(--color-surface); }
74
+ .studio-survey__burst { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
75
+
76
+ @keyframes studio-survey-in-fwd { from { opacity: 0; transform: translateX(1.5rem); } to { opacity: 1; transform: none; } }
77
+ @keyframes studio-survey-in-back { from { opacity: 0; transform: translateX(-1.5rem); } to { opacity: 1; transform: none; } }
78
+ @keyframes studio-survey-shake { 0%, 100% { transform: none; } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
79
+ .studio-survey__step.is-active[data-dir="fwd"] { animation: studio-survey-in-fwd .3s cubic-bezier(.2, .8, .2, 1); }
80
+ .studio-survey__step.is-active[data-dir="back"] { animation: studio-survey-in-back .3s cubic-bezier(.2, .8, .2, 1); }
81
+ .studio-survey__step.is-invalid .studio-survey__body { animation: studio-survey-shake .3s ease; }
82
+ @media (prefers-reduced-motion: reduce) {
83
+ .studio-survey *, .studio-survey *::before, .studio-survey *::after { animation: none !important; transition: none !important; }
84
+ }
85
+
86
+ @media (min-width: 640px) {
87
+ .studio-survey { padding-top: 3rem; }
88
+ .studio-survey__question { font-size: 1.625rem; }
89
+ .studio-survey__title { font-size: 2.125rem; }
90
+ .studio-survey__scale { gap: .75rem; }
91
+ .studio-survey__tile { min-height: 5.5rem; }
92
+ .studio-survey__face { font-size: 2.5rem; }
93
+ .studio-survey__tile-label { font-size: .75rem; }
94
+ }
95
+ </style>
@@ -0,0 +1,11 @@
1
+ <%# An unknown survey slug. Says nothing about why. %>
2
+ <% content_for(:title, "Survey not found") %>
3
+ <%= render "studio/surveys/styles" %>
4
+ <main class="studio-survey" data-studio-survey-missing>
5
+ <div class="studio-survey__shell">
6
+ <div class="studio-survey__card">
7
+ <h1 class="studio-survey__title">Survey not found</h1>
8
+ <p class="studio-survey__intro-copy">This survey doesn't exist or has closed.</p>
9
+ </div>
10
+ </div>
11
+ </main>
@@ -0,0 +1,60 @@
1
+ <%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
2
+
3
+ ONE FORM, TWO PRESENTATIONS. Without JavaScript every question renders in
4
+ order and the Submit button posts the lot. The script below adds
5
+ .is-enhanced and turns the same markup into one question per screen, with a
6
+ progress bar, Back and Next, number-key and Enter shortcuts, and an
7
+ autosave on each answer. Submit is still the same POST either way, so the
8
+ server's validation is the one rule. %>
9
+ <% total = @survey.questions.size %>
10
+ <% minutes = [(total / 3.0).ceil, 1].max %>
11
+ <% content_for(:title, @survey.title) %>
12
+ <%= render "studio/surveys/styles" %>
13
+
14
+ <main class="studio-survey" data-studio-survey
15
+ data-answer-base="<%= studio_survey_path(@survey.slug) %>/answers/"
16
+ data-csrf="<%= form_authenticity_token %>"
17
+ data-resume-index="<%= @resume_index %>"
18
+ data-has-errors="<%= @errors.any? %>"
19
+ data-total="<%= total %>">
20
+ <div class="studio-survey__shell">
21
+ <div class="studio-survey__top" data-survey-top hidden>
22
+ <div class="studio-survey__progress" role="progressbar" aria-label="Survey progress"
23
+ aria-valuemin="0" aria-valuemax="<%= total %>" aria-valuenow="0" data-survey-progress>
24
+ <span class="studio-survey__progress-fill" data-survey-progress-fill></span>
25
+ </div>
26
+ <p class="studio-survey__count" aria-live="polite" data-survey-count></p>
27
+ </div>
28
+
29
+ <%= form_with url: studio_survey_path(@survey.slug), method: :post, local: true,
30
+ html: { class: "studio-survey__form", data: { survey_form: true } } do %>
31
+ <section class="studio-survey__step" data-survey-step data-intro aria-labelledby="studio-survey-title">
32
+ <h1 class="studio-survey__title" id="studio-survey-title" tabindex="-1" data-survey-focus><%= @survey.title %></h1>
33
+ <% if @survey.intro %>
34
+ <p class="studio-survey__intro-copy"><%= @survey.intro %></p>
35
+ <% end %>
36
+ <p class="studio-survey__meta">
37
+ <%= pluralize(total, "question") %> · about <%= pluralize(minutes, "minute") %><% if @survey.required_keys.any? %> · <span aria-hidden="true">*</span> required<% end %>
38
+ </p>
39
+ <% if @errors.any? %>
40
+ <p class="studio-survey__error" role="alert">A few answers need another look before this can be sent.</p>
41
+ <% end %>
42
+ </section>
43
+
44
+ <% @survey.questions.each_with_index do |question, index| %>
45
+ <%= render "studio/surveys/question", question: question, index: index, total: total,
46
+ response: @response, error: @errors[question.key] %>
47
+ <% end %>
48
+
49
+ <div class="studio-survey__nav">
50
+ <button type="button" class="btn btn-neutral" data-survey-back hidden>Back</button>
51
+ <button type="button" class="btn btn-primary studio-survey__forward" data-survey-next hidden>Start</button>
52
+ <button type="submit" class="btn btn-primary studio-survey__forward" data-survey-submit>Submit</button>
53
+ </div>
54
+ <p class="studio-survey__status" aria-live="polite" data-survey-status></p>
55
+ <p class="studio-survey__hint" data-survey-hint hidden>Press a number to choose · Enter to continue</p>
56
+ <% end %>
57
+ </div>
58
+ </main>
59
+
60
+ <%= render "studio/surveys/script" %>
@@ -0,0 +1,15 @@
1
+ <%# A survey without allow_anonymous, opened by a visitor who is neither
2
+ signed in nor arriving by an attributed email link. %>
3
+ <% content_for(:title, @survey.title) %>
4
+ <%= render "studio/surveys/styles" %>
5
+ <main class="studio-survey" data-studio-survey-sign-in>
6
+ <div class="studio-survey__shell">
7
+ <div class="studio-survey__card">
8
+ <h1 class="studio-survey__title"><%= @survey.title %></h1>
9
+ <p class="studio-survey__intro-copy">Sign in to answer this survey.</p>
10
+ <% if respond_to?(:login_path) %>
11
+ <a href="<%= login_path %>" class="btn btn-primary btn-lg">Sign in</a>
12
+ <% end %>
13
+ </div>
14
+ </div>
15
+ </main>
@@ -0,0 +1,16 @@
1
+ <%# /surveys/:slug/thanks — shown once a response completes. The next action is
2
+ the survey's own (next_action label:, url:), when it declares one. %>
3
+ <% content_for(:title, "Thank you") %>
4
+ <%= render "studio/surveys/styles" %>
5
+ <main class="studio-survey" data-studio-survey-thanks>
6
+ <div class="studio-survey__shell">
7
+ <div class="studio-survey__card">
8
+ <div class="studio-survey__burst" aria-hidden="true">&#x1F64C;</div>
9
+ <h1 class="studio-survey__title" tabindex="-1" autofocus>Thank you!</h1>
10
+ <p class="studio-survey__intro-copy"><%= @survey.thank_you %></p>
11
+ <% if @survey.next_action %>
12
+ <a href="<%= @survey.next_action[:url] %>" class="btn btn-primary btn-lg" data-survey-next-action><%= @survey.next_action[:label] %></a>
13
+ <% end %>
14
+ </div>
15
+ </div>
16
+ </main>
@@ -0,0 +1,47 @@
1
+ # One respondent's answers to one survey (Studio::SurveyResponse,
2
+ # docs/SURVEYS.md). Surveys themselves are defined in app code, not stored; the
3
+ # row keys on the survey's slug and stamps the definition version it was taken
4
+ # under.
5
+ #
6
+ # `answers` is { question_key => { "value", "label", "type", "display",
7
+ # "answered_at" } }: each answer carries the question label and option label it
8
+ # was given under, so a later edit to the definition never rewrites history.
9
+ #
10
+ # No raw IP and no raw user agent: user_agent_class is mobile / tablet /
11
+ # desktop / bot / unknown, which is all the results panel reads.
12
+ #
13
+ # The two partial unique indexes enforce ONE IN-PROGRESS response per signed-in
14
+ # user, and per anonymous session token, for each survey. Completed rows fall
15
+ # out of both, so a respondent's history is unconstrained.
16
+ class CreateStudioSurveyResponses < ActiveRecord::Migration[7.2]
17
+ def change
18
+ create_table :studio_survey_responses do |t|
19
+ t.string :survey_slug, null: false
20
+ t.string :survey_version
21
+ if connection.adapter_name.match?(/postg/i)
22
+ t.jsonb :answers, null: false, default: {}
23
+ else
24
+ t.json :answers, null: false, default: {}
25
+ end
26
+ t.bigint :user_id
27
+ t.string :email_ref
28
+ t.string :session_token
29
+ t.string :user_agent_class
30
+ t.string :current_key
31
+ t.datetime :started_at, null: false
32
+ t.datetime :completed_at
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ add_index :studio_survey_responses, %i[survey_slug completed_at]
38
+ add_index :studio_survey_responses, :user_id
39
+ add_index :studio_survey_responses, :email_ref
40
+ add_index :studio_survey_responses, %i[survey_slug user_id], unique: true,
41
+ where: "completed_at IS NULL AND user_id IS NOT NULL",
42
+ name: "index_studio_survey_responses_one_open_per_user"
43
+ add_index :studio_survey_responses, %i[survey_slug session_token], unique: true,
44
+ where: "completed_at IS NULL AND session_token IS NOT NULL",
45
+ name: "index_studio_survey_responses_one_open_per_session"
46
+ end
47
+ end
data/lib/studio/engine.rb CHANGED
@@ -170,6 +170,15 @@ module Studio
170
170
 
171
171
  public
172
172
 
173
+ # Survey definitions (docs/SURVEYS.md): every *.rb under
174
+ # Studio.survey_definitions_path, loaded on boot and again on each
175
+ # development reload so an edited definition takes effect without a restart.
176
+ # Surveys defined in an initializer instead stay registered either way.
177
+ config.to_prepare do
178
+ dir = Studio.survey_definitions_path
179
+ Studio::Survey.load_definitions!(Rails.root.join(dir)) if dir.present? && Rails.root
180
+ end
181
+
173
182
  config.after_initialize do
174
183
  # Configure the IP -> location provider for every app that has the gem,
175
184
  # so geo detection works the same way everywhere: HTTPS (without which