studio-engine 0.71.0 → 0.72.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: b84ed97df94f71d6135899621e982649f6c80b41d5821b12842ab5c0e6d47ce7
4
- data.tar.gz: 2b1af46db99092b30d9049d2afbbefa9cc810878bcbc376d9e04f780cc6e7a05
3
+ metadata.gz: a53d7cde61dcf497f1dee1542d64ad82714900227b0fb0c98c31df714ab67267
4
+ data.tar.gz: 16d9816dde34b609d14b57f0c526f60883a79641f485775362e396cf4ac040b1
5
5
  SHA512:
6
- metadata.gz: 294aa380598143d9f79592e2b0e62bab038dbb886b374ce765ff9fdf0da349e460049ae284e397f234647f3b9dfdea24263cd8ed31bf74bbe00d1e14e1bbf49f
7
- data.tar.gz: d119392c02adb20753485b0b6efcaf01e3c3f3ea0aa26f9b5e36fe1f3de3fb4df77ede836f0a79ec937bd556ad14a9317078fa25f028d890e27f531e74ad8f37
6
+ metadata.gz: 682057c50bc19db2ab91d46d54ddb730d273617c1814b61e7bedea16120187af757ea44fe554cdbf66bc8b26f8356cb40050532b3cca146f7af6c73607632b21
7
+ data.tar.gz: 59c7b9a8331368526b1561482cd86d117866a4e61ce2e07a499fb48a8c2eb4ecf6a588388347a3bb4c2d6973c9d599bc819c2eb4b6348212f38aaee753c20774
data/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
 
9
+ - **The first-name card now says WHICH path finished it, and can type its
10
+ placeholder.** Two adoption hooks on
11
+ `studio/modals/onboarding/_first_name`, aimed at turf-monster's own 240-line
12
+ copy of it.
13
+
14
+ **WHAT THIS DOES NOT DO.** It unblocks that adoption; it does not complete it,
15
+ and the copy cannot simply be deleted on this release. `required` is still
16
+ resolved at RENDER time, while turf drives it as a runtime prop from ONE
17
+ registered modal id — the post-auth chain opens that id skippable, the entry
18
+ gate opens the SAME id with `required: true` — so adopting still costs two
19
+ registered ids (pass a distinct `id:` to keep the field's DOM id unique) or a
20
+ runtime mode here. And the host still writes the bridge: this card touches no
21
+ session store and emits no `first-name-saved`, so turf's resume needs a
22
+ listener that does both when `saved` is true.
23
+
24
+ **THE OUTCOME (the blocker).** `finish()` is called by the save AND the skip,
25
+ and it dispatched the same detail either way — so a host could hear that the
26
+ step was done and could not hear whether a name had landed. Its `done_event`
27
+ detail now carries `{ next: [...], saved: true|false }`. turf's entry gate
28
+ resumes a contest entry the gate interrupted when the name saves; fired after
29
+ a SKIP that same signal resumes an entry that still has no name.
30
+
31
+ It is an extra detail key rather than an opt-in second event because
32
+ McRitchie Studio's listener wants BOTH paths (it retires the "ask" marker
33
+ whether the user answered or skipped), so the event that always fires has to
34
+ be the one carrying the answer — and an opt-in event would leave this one
35
+ ambiguous for every host that did not pass it, including the next one to add
36
+ a gate. `next` keeps its key and the event keeps its name, so the existing
37
+ listener does not move. The flag is written `!!saved`, so an unreported path
38
+ reports FALSE: a card claiming `saved: true` for a skip would be worse than
39
+ the ambiguity it replaced.
40
+
41
+ **THE TYPED PLACEHOLDER (optional, OFF by default).** A new
42
+ `placeholder_names:` local takes an array of first names and types one of
43
+ them into the placeholder a character at a time, so the card demonstrates the
44
+ answer instead of describing it. Absent or empty, NONE of it is emitted and
45
+ the static `placeholder` is used exactly as before — the local exists so
46
+ turf's adoption does not silently delete a flourish it has today, and it
47
+ comes out in one piece if the operator would rather drop it.
48
+
49
+ It carries the three things that make it bearable rather than a nuisance:
50
+ typing stops the moment the user types and NEVER restarts (a focus counts
51
+ only when it follows a blur, because the field is focused on mount);
52
+ `prefers-reduced-motion` gets the whole example statically, with no timer;
53
+ and there is a 420ms pre-roll so a short name does not finish underneath the
54
+ modal's own mount spring. The pool travels as an escaped `data-` attribute
55
+ rather than interpolated into the `x-data`, which is what keeps a name with
56
+ an apostrophe — or the JSON's own double quotes — from killing the component.
57
+
58
+ The default card is asserted **byte-for-byte against the 0.70.0 render**,
59
+ with the four outcome lines rewound and every other byte required to match;
60
+ a fifth change has to be added to that list deliberately. Mutation-checked
61
+ 6/6, and the two halves redden disjoint tests: reporting `true` on the skip,
62
+ dropping `saved` from the detail, and renaming `next` each hit an outcome
63
+ test, while forcing the typed mode on, un-guarding the refocus, and treating
64
+ an empty pool as present each hit a placeholder test.
65
+
9
66
  - **The living style guide grows a HOST SECTION seam.** `/admin/style` rendered
10
67
  four hard-coded engine sections (Theme, Modals, Tricks, Tasks) with no way for
11
68
  a consuming app to contribute one of its own. An app now defines
@@ -25,6 +25,18 @@
25
25
  talking to a different audience — a host that passes its own
26
26
  subtext still wins)
27
27
  placeholder — (default "Alex")
28
+ placeholder_names — OPTIONAL, and OFF unless passed: an array of first names,
29
+ one of which is TYPED into the placeholder a character at a
30
+ time, so the card demonstrates the answer instead of
31
+ describing it. Absent (or empty) NONE of it is emitted and the
32
+ static `placeholder` above is used exactly as before. Passed,
33
+ `placeholder` goes unused: the field carries no static
34
+ placeholder, because a value Alpine blanks a moment after
35
+ paint reads as a flicker rather than a hint. Honours
36
+ prefers-reduced-motion by showing the whole example
37
+ statically, with no timer. The typing stops FOR GOOD the
38
+ moment the user types — it never restarts, not even when they
39
+ leave the field and come back.
28
40
  max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
29
41
  real bound, and it REFUSES past that rather than truncating,
30
42
  so a host raising this makes the endpoint answer 422 rather
@@ -32,10 +44,25 @@
32
44
  progress — [current, total] to render the segmented pill, or nil for none
33
45
  modal_store — Alpine store name (default "modals"; the living style guide
34
46
  mounts its own page-scoped host and passes "dsModals")
35
- done_event — window event dispatched with { next: [...] } when this step is
36
- finished, saved or skipped (default "onboarding-step-done").
47
+ done_event — window event dispatched when this step is finished, saved or
48
+ skipped (default "onboarding-step-done"). Its detail carries
49
+ `{ next: [...], saved: true|false }` — see THE OUTCOME below.
37
50
  The HOST decides what happens next; this partial never knows.
38
51
 
52
+ THE OUTCOME RIDES THE DETAIL, NOT A SECOND EVENT. `saved` says which path
53
+ finished the step, because to a host the two are not interchangeable:
54
+ turf-monster's entry gate resumes a contest entry the gate interrupted once
55
+ the name lands, and resuming after a SKIP resumes an entry that still has no
56
+ name. It is an extra detail key rather than an opt-in `saved_event` local for
57
+ two reasons. McRitchie Studio's listener wants BOTH paths — it retires the
58
+ "ask" marker whether the user answered or skipped — so the event that always
59
+ fires has to be the one carrying the answer, and a second event would leave
60
+ this one ambiguous for every host that did not opt in, including the next one
61
+ to add a gate. `next` keeps its key and its meaning, so an existing listener
62
+ does not move. And the flag is written `!!saved`: an unreported path reports
63
+ FALSE, because a card that claimed `saved: true` for a skip would be worse
64
+ than the ambiguity it replaced.
65
+
39
66
  CONTRACT WITH THE SERVER. Both endpoints answer JSON `{ ok: true, next: [...] }`
40
67
  — `next` being whatever steps the host says remain. A non-ok response must carry
41
68
  `{ error: "…" }`, which is rendered inline. The step reports `next` upward and
@@ -59,6 +86,12 @@
59
86
  end
60
87
  subtext = local_assigns.fetch(:subtext, default_subtext)
61
88
  placeholder = local_assigns.fetch(:placeholder, "Alex")
89
+ # OFF unless a host passes a non-empty array. Normalised to nil so that an
90
+ # empty list behaves exactly like an absent local rather than emitting the
91
+ # whole typing machine for a pool it can never sample from.
92
+ placeholder_names = local_assigns.fetch(:placeholder_names, nil)
93
+ placeholder_names = nil if placeholder_names.nil? || placeholder_names.empty?
94
+ typed_placeholder = !placeholder_names.nil?
62
95
  max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
63
96
  progress = local_assigns.fetch(:progress, nil)
64
97
  modal_store = local_assigns.fetch(:modal_store, "modals")
@@ -77,6 +110,68 @@
77
110
  firstName: '',
78
111
  submitting: false,
79
112
  error: '',
113
+ <% if typed_placeholder %>
114
+ // --- the typed placeholder (opt-in: placeholder_names) ---------------
115
+ // Types ONE sampled name into the field's PLACEHOLDER, character by
116
+ // character. The field itself stays genuinely empty — nothing is ever
117
+ // submitted that the user did not type.
118
+ //
119
+ // NO fake caret. A blinking block after the text is the obvious way to
120
+ // sell a typewriter and it is wrong here: the field is focused on mount,
121
+ // so the browser is already drawing a real caret at position 0, and a
122
+ // second one two characters away reads as a rendering bug.
123
+ placeholderText: '',
124
+ _phPhrase: '',
125
+ _phTimer: null,
126
+ _phDelay: null,
127
+ _phBlurred: false,
128
+ startPlaceholder(names) {
129
+ if (!names || !names.length) return;
130
+ this._phPhrase = names[Math.floor(Math.random() * names.length)];
131
+ // Reduced motion: show the whole example, statically, no timer. The
132
+ // hint is the point; the animation is decoration.
133
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
134
+ this.placeholderText = this._phPhrase;
135
+ return;
136
+ }
137
+ var self = this, i = 0;
138
+ this.placeholderText = '';
139
+ // WAIT FOR THE CARD TO LAND. The modal mounts with a 320ms spring and
140
+ // x-init fires at the START of it, so typing that begins immediately
141
+ // runs entirely underneath the entrance. A SHORT name is the common
142
+ // case and the worst one: a two-letter name finishes before the card
143
+ // has finished appearing, so the field simply arrives with a name
144
+ // already in it and the effect is invisible.
145
+ this._phDelay = setTimeout(function () {
146
+ self._phDelay = null;
147
+ self._phTimer = setInterval(function () {
148
+ i += 1;
149
+ self.placeholderText = self._phPhrase.slice(0, i);
150
+ if (i >= self._phPhrase.length) self.stopPlaceholder();
151
+ }, 110);
152
+ }, 420);
153
+ },
154
+ // Stop the timer, leaving whatever is on screen. Also kills the pre-roll,
155
+ // or dismissing DURING the delay lets the animation start after the user
156
+ // has already begun typing.
157
+ stopPlaceholder() {
158
+ if (this._phTimer) { clearInterval(this._phTimer); this._phTimer = null; }
159
+ if (this._phDelay) { clearTimeout(this._phDelay); this._phDelay = null; }
160
+ },
161
+ // The user is writing their own name — get out of the way. Clearing
162
+ // rather than completing is deliberate: a half-typed name frozen under
163
+ // their cursor looks broken. Nothing restarts it afterwards.
164
+ dismissPlaceholder() {
165
+ this.stopPlaceholder();
166
+ this.placeholderText = '';
167
+ },
168
+ // The mount focus is NOT engagement. This card focuses the field the
169
+ // moment it mounts, so treating ANY focus as the user choosing the field
170
+ // would kill the animation before it drew a character. Only a focus that
171
+ // FOLLOWS a blur is the user deliberately coming back.
172
+ markPlaceholderBlurred() { this._phBlurred = true; },
173
+ refocusPlaceholder() { if (this._phBlurred) this.dismissPlaceholder(); },
174
+ <% end %>
80
175
  async save() {
81
176
  if (this.submitting) return;
82
177
  var value = (this.firstName || '').trim();
@@ -88,14 +183,14 @@
88
183
  this.error = (data && data.error) || 'Could not save that — try again.';
89
184
  return;
90
185
  }
91
- this.finish(data.next || []);
186
+ this.finish(data.next || [], true);
92
187
  },
93
188
  async skip() {
94
189
  if (this.submitting) return;
95
190
  this.submitting = true; this.error = '';
96
191
  var data = await this.post('<%= skip_path %>', {});
97
192
  this.submitting = false;
98
- this.finish((data && data.next) || []);
193
+ this.finish((data && data.next) || [], false);
99
194
  },
100
195
  async post(url, body) {
101
196
  var meta = document.querySelector('meta[name=csrf-token]');
@@ -112,11 +207,18 @@
112
207
  return await res.json();
113
208
  } catch (e) { return null; }
114
209
  },
115
- finish(next) {
116
- window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next } }));
210
+ finish(next, saved) {
211
+ <% if typed_placeholder %>
212
+ this.stopPlaceholder();
213
+ <% end %>
214
+ window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next, saved: !!saved } }));
117
215
  $store.<%= modal_store %>.close();
118
216
  }
119
217
  }"
218
+ <% if typed_placeholder %>
219
+ data-placeholder-names="<%= placeholder_names.to_json %>"
220
+ x-init="startPlaceholder(JSON.parse($el.dataset.placeholderNames || '[]'))"
221
+ <% end %>
120
222
  class="relative">
121
223
 
122
224
  <div class="relative mb-3 -mt-2">
@@ -144,9 +246,22 @@
144
246
  <%# Focused on open. The HTML autofocus attribute is not enough for a modal
145
247
  mounted from a <template x-if> after the document parsed, so Alpine does
146
248
  it; preventScroll keeps the mount animation from being yanked. %>
249
+ <%# The placeholder is STATIC unless the host passed placeholder_names, in
250
+ which case it is bound and typed. The three extra handlers are the
251
+ difference between a hint and a nuisance: real typing dismisses it, a
252
+ blur is recorded, and a focus AFTER that blur — the user deliberately
253
+ returning — dismisses it too. A bare focus must not, because the
254
+ x-init on this input fires one immediately. %>
147
255
  <input id="<%= field_id %>" type="text" x-model="firstName"
148
256
  maxlength="<%= max_length %>" autocomplete="given-name" enterkeyhint="done"
257
+ <% if typed_placeholder %>
258
+ :placeholder="placeholderText"
259
+ @input="dismissPlaceholder()"
260
+ @blur="markPlaceholderBlurred()"
261
+ @focus="refocusPlaceholder()"
262
+ <% else %>
149
263
  placeholder="<%= placeholder %>"
264
+ <% end %>
150
265
  :disabled="submitting"
151
266
  x-init="$nextTick(() => $el.focus({ preventScroll: true }))"
152
267
  class="input-field">
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.71.0"
2
+ VERSION = "0.72.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.71.0
4
+ version: 0.72.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie