studio-engine 0.88.0 → 0.89.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e16f65ea44055886fea1ffb0743a9415caa612f858caa0ff7dabb7c87beeeb15
4
- data.tar.gz: 9690ad40a9e766646995ddfa1c69c8c22191e3509bb944d5c184ea5c1a1b21e2
3
+ metadata.gz: eeb8af5c3521e31b4bda82538bc2053bf4ff364008f616454ce89aa267d30643
4
+ data.tar.gz: b4374bb37c27468e3b20df3656973a1818df2445aa7b0a0b6c242dc2d67f08fd
5
5
  SHA512:
6
- metadata.gz: ae4786abebc9f1d9dc86cc3992f4a6f4225c4fceaa20b73fca12ddd43afad1e4d6ef2807a8d3302bdf2d811792995fdac42961a02c6b70637f8923e841e61402
7
- data.tar.gz: 3ba6c43faf9e1f7a03596b70924fc099469d8c95ce967f63e4a2ba8f4f0da0112db6068e701e739fa7e3ce5435bd07ee456b2982fec91ebb9561a4bc3dd18b09
6
+ metadata.gz: c107ea4e17a719e1612a243550c0427bc5868e5ebc0219ab913e12312d4580fee715788afd80cdb6617f7ef4b8df799e67e8507fd3851db2d2f71e85fcf07bd0
7
+ data.tar.gz: f41f199936bd5bd48bf226b0df833679c956ca84671c86971f9a115b88c666885ebe4229814fd17b240cf8bed981da5660315dc75bdd8185ee9df4c51f185a1a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.89.0 — 2026-10-05
8
+
9
+ ### Changed
10
+
11
+ - **A survey opens on question 1.** The intro screen and its Start button are
12
+ gone: a respondent lands on "Question 1 of N" with the progress bar started,
13
+ and question 1 shows no Back button. The definition's `intro` becomes a quiet
14
+ lead line above question 1, and its `title` stays the page's `h1`; when the
15
+ title repeats question 1 (`Studio::Survey#title_repeats_first_question?`), the
16
+ stepper hides it visually so the same words are not painted twice. Number keys
17
+ and Enter work from the first paint. Returning respondents still resume on
18
+ their first unanswered question, and the no-JavaScript form shows the title
19
+ and intro at the top. No host change is needed.
20
+ - **The survey's "Press a number to choose" hint** no longer shows at phone width
21
+ (under 640px), as well as on touch screens.
22
+
7
23
  ## 0.88.0 — 2026-10-05
8
24
 
9
25
  ### Added
@@ -19,7 +19,9 @@
19
19
 
20
20
  var form = root.querySelector("[data-survey-form]");
21
21
  var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
22
- var total = steps.length - 1;
22
+ // Every step is a question: the survey opens on question 1, no intro screen.
23
+ var total = steps.length;
24
+ var head = root.querySelector("[data-survey-head]");
23
25
  var back = root.querySelector("[data-survey-back]");
24
26
  var next = root.querySelector("[data-survey-next]");
25
27
  var submit = root.querySelector("[data-survey-submit]");
@@ -128,21 +130,23 @@
128
130
  }
129
131
  });
130
132
  current = index;
131
- var onIntro = index === 0;
133
+ var first = index === 0;
132
134
  var last = index === steps.length - 1;
133
- back.hidden = onIntro;
135
+ // Nothing comes before question 1, so it has no Back. Next keeps the
136
+ // right edge on its own (margin-left: auto), so the row does not shift.
137
+ back.hidden = first;
134
138
  next.hidden = last;
135
- next.textContent = onIntro ? "Start" : "Next";
136
139
  submit.hidden = !last;
140
+ // The title and intro lead question 1 only; later screens are the question alone.
141
+ if (head) head.hidden = !first;
137
142
  // The number-key shortcut only means something on a choice or scale.
138
143
  var step = steps[index];
139
144
  hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
140
145
 
141
- var position = onIntro ? 0 : index;
146
+ var position = index + 1;
142
147
  fill.style.width = (total ? (position / total) * 100 : 0) + "%";
143
148
  bar.setAttribute("aria-valuenow", String(position));
144
- // The intro already says how many questions there are.
145
- count.textContent = onIntro ? "" : "Question " + index + " of " + total;
149
+ count.textContent = "Question " + position + " of " + total;
146
150
 
147
151
  var focusTarget = steps[index].querySelector("[data-survey-focus]");
148
152
  if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
@@ -182,7 +186,7 @@
182
186
  // The first required question still missing an answer, for a Submit that
183
187
  // jumps ahead (Enter on the last step, or a mid-survey reload).
184
188
  form.addEventListener("submit", function (event) {
185
- for (var i = 1; i < steps.length; i++) {
189
+ for (var i = 0; i < steps.length; i++) {
186
190
  if (steps[i].dataset.required === "true" && !answered(steps[i])) {
187
191
  event.preventDefault();
188
192
  go(i);
@@ -230,7 +234,7 @@
230
234
  timers[step.dataset.key] = setTimeout(function () { save(step); }, 900);
231
235
  });
232
236
 
233
- root.addEventListener("keydown", function (event) {
237
+ function onKey(event) {
234
238
  if (event.defaultPrevented || event.altKey) return;
235
239
  // A tap on an already-chosen option fires pointerdown but no change, so
236
240
  // the flag would outlive it and make the next arrow key advance.
@@ -256,15 +260,29 @@
256
260
  option.focus({ preventScroll: true });
257
261
  pointerPick = isChoice(step);
258
262
  option.dispatchEvent(new Event("change", { bubbles: true }));
259
- });
263
+ }
264
+
265
+ root.addEventListener("keydown", onKey);
266
+ // The survey opens on question 1 with nothing focused, so a first key
267
+ // lands on <body>, outside the root. Take those keys too, and only those:
268
+ // a key aimed at any other control on the page is not the survey's. The
269
+ // listener retires itself once a Turbo visit has taken this root away.
270
+ function onPageKey(event) {
271
+ if (!root.isConnected) { document.removeEventListener("keydown", onPageKey); return; }
272
+ var target = event.target;
273
+ if (target === document.body || target === document.documentElement) onKey(event);
274
+ }
275
+ document.addEventListener("keydown", onPageKey);
260
276
 
261
277
  var resume = root.dataset.resumeIndex;
262
- var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10) + 1;
278
+ // resume-index is the question's own index, and every step is a question.
279
+ var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10);
280
+ if (!(start >= 0 && start < steps.length)) start = 0;
263
281
  var hasErrors = root.dataset.hasErrors === "true";
264
- // A fresh visitor sees the intro; a returning one lands on their next
282
+ // A fresh visitor lands on question 1; a returning one on their next
265
283
  // question, and a failed submit on the first question it flagged.
266
284
  if (restored >= 0) start = restored;
267
- render(start > 0 ? start : 0, null);
285
+ render(start, null);
268
286
  if (restored < 0 && (start > 0 || hasErrors)) {
269
287
  var focusTarget = steps[current].querySelector("[data-survey-focus]");
270
288
  if (focusTarget) focusTarget.focus({ preventScroll: true });
@@ -18,6 +18,15 @@
18
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
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
20
  .studio-survey__meta { margin: 0; font-size: .8125rem; color: var(--color-text-muted); }
21
+ .studio-survey__head { margin: 0 0 2.25rem; }
22
+ /* Stepped, the head leads question 1 quietly: a small title (or none, when it
23
+ repeats question 1, kept for screen readers), the intro as one lead line,
24
+ and no meta, since the count above already says "Question 1 of N". */
25
+ .studio-survey.is-enhanced .studio-survey__head { margin-bottom: 1.25rem; }
26
+ .studio-survey.is-enhanced .studio-survey__title { margin: 0 0 .375rem; font-size: 1rem; line-height: 1.4; font-weight: 700; color: var(--color-text-secondary); }
27
+ .studio-survey.is-enhanced .studio-survey__head--echo .studio-survey__title { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
28
+ .studio-survey.is-enhanced .studio-survey__intro-copy { margin: 0; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
29
+ .studio-survey.is-enhanced .studio-survey__meta { display: none; }
21
30
  .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
31
  .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
32
  .studio-survey.is-enhanced .studio-survey__qnum { display: none; }
@@ -68,7 +77,9 @@
68
77
  .studio-survey__forward { margin-left: auto; }
69
78
  .studio-survey__status { min-height: 1.25rem; margin: .5rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
70
79
  .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; } }
80
+ /* The hint speaks to a keyboard. A touch screen has none, and a phone-width
81
+ window is a phone more often than not, so neither shows it. */
82
+ @media (hover: none), (pointer: coarse), (max-width: 639px) { .studio-survey__hint { display: none; } }
72
83
 
73
84
  .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
85
  .studio-survey__burst { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
@@ -86,7 +97,7 @@
86
97
  @media (min-width: 640px) {
87
98
  .studio-survey { padding-top: 3rem; }
88
99
  .studio-survey__question { font-size: 1.625rem; }
89
- .studio-survey__title { font-size: 2.125rem; }
100
+ .studio-survey:not(.is-enhanced) .studio-survey__title { font-size: 2.125rem; }
90
101
  .studio-survey__scale { gap: .75rem; }
91
102
  .studio-survey__tile { min-height: 5.5rem; }
92
103
  .studio-survey__face { font-size: 2.5rem; }
@@ -1,9 +1,10 @@
1
1
  <%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
2
2
 
3
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
4
+ order under the title and intro, and the Submit button posts the lot. The
5
+ script below adds .is-enhanced and turns the same markup into one question
6
+ per screen, opening on question 1 with no intro screen, with a progress
7
+ bar, Back (from question 2 on) and Next, number-key and Enter shortcuts, and an
7
8
  autosave on each answer. Submit is still the same POST either way, so the
8
9
  server's validation is the one rule. %>
9
10
  <% total = @survey.questions.size %>
@@ -28,10 +29,14 @@
28
29
 
29
30
  <%= form_with url: studio_survey_path(@survey.slug), method: :post, local: true,
30
31
  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>
32
+ <%# The head is not a step: the survey opens straight on question 1. The
33
+ title stays the page's h1 for a screen reader; when it says what
34
+ question 1 asks, the stepper hides it visually rather than paint the
35
+ same words twice. The intro is a quiet lead line over question 1. %>
36
+ <header class="studio-survey__head<%= " studio-survey__head--echo" if @survey.title_repeats_first_question? %>" data-survey-head>
37
+ <h1 class="studio-survey__title" id="studio-survey-title"><%= @survey.title %></h1>
33
38
  <% if @survey.intro %>
34
- <p class="studio-survey__intro-copy"><%= @survey.intro %></p>
39
+ <p class="studio-survey__intro-copy" data-survey-intro><%= @survey.intro %></p>
35
40
  <% end %>
36
41
  <p class="studio-survey__meta">
37
42
  <%= pluralize(total, "question") %> · about <%= pluralize(minutes, "minute") %><% if @survey.required_keys.any? %> · <span aria-hidden="true">*</span> required<% end %>
@@ -39,7 +44,7 @@
39
44
  <% if @errors.any? %>
40
45
  <p class="studio-survey__error" role="alert">A few answers need another look before this can be sent.</p>
41
46
  <% end %>
42
- </section>
47
+ </header>
43
48
 
44
49
  <% @survey.questions.each_with_index do |question, index| %>
45
50
  <%= render "studio/surveys/question", question: question, index: index, total: total,
@@ -48,7 +53,7 @@
48
53
 
49
54
  <div class="studio-survey__nav">
50
55
  <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>
56
+ <button type="button" class="btn btn-primary studio-survey__forward" data-survey-next hidden>Next</button>
52
57
  <button type="submit" class="btn btn-primary studio-survey__forward" data-survey-submit>Submit</button>
53
58
  </div>
54
59
  <p class="studio-survey__status" aria-live="polite" data-survey-status></p>
data/lib/studio/survey.rb CHANGED
@@ -241,6 +241,15 @@ module Studio
241
241
  def keys = questions.map(&:key)
242
242
  def required_keys = questions.select(&:required?).map(&:key)
243
243
 
244
+ # True when the title says what the first question asks ("How was your
245
+ # first game?" twice). The survey opens on that question, so the stepper
246
+ # keeps the title as the page's heading for a screen reader but does not
247
+ # paint it twice. Compared on letters and digits alone, ignoring case.
248
+ def title_repeats_first_question?
249
+ squash = ->(text) { text.to_s.downcase.gsub(/[^[:alnum:]]/, "") }
250
+ squash.call(title) == squash.call(questions.first.label)
251
+ end
252
+
244
253
  private
245
254
 
246
255
  def validate!
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.88.0"
2
+ VERSION = "0.89.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.88.0
4
+ version: 0.89.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails