studio-engine 0.96.2 → 0.97.0.rc1

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -0,0 +1,172 @@
1
+ <%#
2
+ THE HOLD BUTTON'S GUARD: says so when a hold button cannot work.
3
+
4
+ The hold button runs on five modules (studio/stimulus, studio/lazy_controllers,
5
+ studio/controllers/hold_button_controller, studio/hold_button and
6
+ studio/hold_button_hooks). A browser keeps a failed module request for the life
7
+ of the document, so one failure among them at page load leaves every hold
8
+ button rendered, enabled and deaf until a reload. This script is what speaks
9
+ then, so it is INLINE and imports nothing: it shares no request with the
10
+ modules whose failure it reports.
11
+
12
+ The hold-button controller writes data-hold-button-connected on its
13
+ .hold-stack when it connects. A stack that still lacks it once the page's
14
+ boot is over is marked
15
+
16
+ <span class="hold-stack" data-studio-controller-failed="hold-button">
17
+
18
+ its button aria-disabled, and its .hold-notice (role="status") filled;
19
+ engine-motion.css styles all three. A press on a marked button says the notice
20
+ again. The controller clears the mark the moment it connects, however late.
21
+
22
+ WHEN THE BOOT IS OVER. The engine's modules are module scripts in the head, so
23
+ DOMContentLoaded waits for them, however slow, and Stimulus connects its
24
+ controllers as studio/stimulus runs, before that event. Measured in Chromium
25
+ on the e2e lab, the button connects ahead of DOMContentLoaded by 3 to 5 ms on
26
+ a cold load, by 110 to 230 ms at 20x CPU throttle, and by 1.1 s on a 400 ms,
27
+ 400 kbit/s link at 6x CPU (where DOMContentLoaded itself lands at 11.5 s). A
28
+ healthy page has therefore connected its buttons before the grace starts; the
29
+ 1.5 s below is margin for a host that boots its modules another way, not a
30
+ race the page has to win. Later stacks (a Turbo visit, a modal's x-if)
31
+ connect in the microtask after they arrive and are swept shortly after.
32
+
33
+ Locals:
34
+ nonce - the request's CSP nonce, or nil (layouts/studio/_head passes it)
35
+ %>
36
+ <%= tag.script(nonce: local_assigns[:nonce]) do %>
37
+ (function () {
38
+ if (window.studioHoldButtonGuard) return;
39
+
40
+ var STACK = '.hold-stack[data-studio-controller~="hold-button"]';
41
+ var CONNECTED = "data-hold-button-connected";
42
+ var FAILED = "data-studio-controller-failed";
43
+ var NOTICE = "This button could not load. Reload the page.";
44
+ var GRACE_MS = 1500;
45
+ var SETTLE_MS = 250;
46
+
47
+ var deadline = null;
48
+ var reported = false;
49
+ var serial = 0;
50
+ var lastPress = 0;
51
+
52
+ // What to do with one stack. `deadline` is null until the page's boot has
53
+ // an end, and nothing is marked before it.
54
+ function verdict(state) {
55
+ if (state.connected) return state.marked ? "clear" : "leave";
56
+ if (state.marked) return "leave";
57
+ if (state.deadline == null || state.now < state.deadline) return "wait";
58
+ return "mark";
59
+ }
60
+
61
+ function child(stack, name) {
62
+ for (var i = 0; i < stack.children.length; i++) {
63
+ if (stack.children[i].classList.contains(name)) return stack.children[i];
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function mark(stack) {
69
+ var button = child(stack, "hold-btn");
70
+ var notice = child(stack, "hold-notice");
71
+ if (!notice) {
72
+ notice = document.createElement("span");
73
+ notice.className = "hold-notice";
74
+ notice.setAttribute("role", "status");
75
+ stack.appendChild(notice);
76
+ }
77
+ if (!notice.id) notice.id = "hold-notice-" + (++serial);
78
+ notice.textContent = NOTICE;
79
+ stack.setAttribute(FAILED, "hold-button");
80
+ if (button) {
81
+ button.setAttribute("aria-disabled", "true");
82
+ button.setAttribute("aria-describedby", notice.id);
83
+ }
84
+ if (!reported) {
85
+ reported = true;
86
+ console.error("[studio] the hold-button controller failed to load and stays unavailable until the page is reloaded");
87
+ }
88
+ }
89
+
90
+ function clear(stack) {
91
+ var button = child(stack, "hold-btn");
92
+ var notice = child(stack, "hold-notice");
93
+ if (!stack.hasAttribute(FAILED)) return;
94
+ stack.removeAttribute(FAILED);
95
+ stack.removeAttribute("data-hold-notice-pressed");
96
+ if (notice) notice.textContent = "";
97
+ if (button) {
98
+ button.removeAttribute("aria-disabled");
99
+ button.removeAttribute("aria-describedby");
100
+ }
101
+ }
102
+
103
+ function judge(stack) {
104
+ var answer = verdict({
105
+ connected: stack.hasAttribute(CONNECTED),
106
+ marked: stack.hasAttribute(FAILED),
107
+ now: Date.now(),
108
+ deadline: deadline
109
+ });
110
+ if (answer === "mark") mark(stack);
111
+ if (answer === "clear") clear(stack);
112
+ return answer;
113
+ }
114
+
115
+ function sweep() {
116
+ var stacks = document.querySelectorAll(STACK);
117
+ for (var i = 0; i < stacks.length; i++) judge(stacks[i]);
118
+ }
119
+
120
+ // A press on a marked button: the notice again, to the eye and to a screen
121
+ // reader. The text has to change to be announced, so a trailing no-break
122
+ // space comes and goes.
123
+ function say(stack) {
124
+ var notice = child(stack, "hold-notice");
125
+ if (notice) notice.textContent = notice.textContent === NOTICE ? NOTICE + " " : NOTICE;
126
+ stack.removeAttribute("data-hold-notice-pressed");
127
+ void stack.offsetWidth;
128
+ stack.setAttribute("data-hold-notice-pressed", "");
129
+ }
130
+
131
+ function pressed(event) {
132
+ var target = event.target;
133
+ var button = target && target.closest ? target.closest(".hold-btn") : null;
134
+ var stack = button ? button.closest(STACK) : null;
135
+ if (!stack) return;
136
+ // A touch is followed by the mouse events the browser makes up for it.
137
+ if (event.type === "mousedown" && Date.now() - lastPress < 700) return;
138
+ lastPress = Date.now();
139
+ judge(stack);
140
+ if (stack.hasAttribute(FAILED)) say(stack);
141
+ }
142
+
143
+ function holds(node) {
144
+ return node.nodeType === 1 && (node.matches(STACK) || node.querySelector(STACK) !== null);
145
+ }
146
+
147
+ function booted() {
148
+ deadline = Date.now() + GRACE_MS;
149
+ setTimeout(sweep, GRACE_MS);
150
+ // Read off window under a local name: a host page that counts the
151
+ // observers its own inline scripts construct (turf-monster's report
152
+ // pages do, by the constructor's spelling) does not count this one.
153
+ var Observer = window.MutationObserver;
154
+ if (!Observer) return;
155
+ new Observer(function (records) {
156
+ for (var i = 0; i < records.length; i++) {
157
+ for (var j = 0; j < records[i].addedNodes.length; j++) {
158
+ if (holds(records[i].addedNodes[j])) return void setTimeout(sweep, SETTLE_MS);
159
+ }
160
+ }
161
+ }).observe(document.documentElement, { childList: true, subtree: true });
162
+ }
163
+
164
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", booted);
165
+ else booted();
166
+ document.addEventListener("turbo:load", function () { setTimeout(sweep, SETTLE_MS); });
167
+ document.addEventListener("mousedown", pressed, true);
168
+ document.addEventListener("touchstart", pressed, { capture: true, passive: true });
169
+
170
+ window.studioHoldButtonGuard = { verdict: verdict, clear: clear, sweep: sweep, graceMs: GRACE_MS };
171
+ })();
172
+ <% end %>
@@ -1,285 +1,11 @@
1
1
  <%#
2
- levelingActionModal factory — the Alpine data behind the engine leveling-activity
3
- modals (studio/modals/blocks/_leveling_activity and its _change_username
4
- specialization). Ships here, at PAGE level, on purpose: the modal partial mounts
5
- inside a modal host's single-root template, and a script cloned out of a template
6
- never runs. So a consumer renders THIS once in its layout (like the other shared
7
- Alpine factories), and the modal partial anywhere below just references
8
- window.levelingActionModal. Mirrors how studio/_birthday_assets homes the
9
- birthdayModal factory.
10
-
11
- CRITICAL BOUNDARY — this factory is UI ONLY. It POSTs to an app-supplied
12
- submitUrl and reacts to a NEUTRAL JSON contract; it holds ZERO on-chain logic,
13
- no wallet taxonomy, no signing. Any second step some apps need (e.g. a wallet
14
- approval) is delegated OPAQUELY: the engine hands the app-returned "challenge"
15
- blob to an app-supplied window[finalizeHook] and POSTs back whatever opaque
16
- "proof" the app returns. The engine never inspects, parses, or decodes either
17
- blob, so the whole wallet dance stays in the app.
18
-
19
- Wire contract (submitUrl + finalizeUrl responses are JSON):
20
- POST submitUrl { value } -> one of:
21
- { status: "saved", seeds_earned?, seeds_total? } done
22
- { status: "needs_step", challenge, token } app step required
23
- { status: "error", message } show message
24
- when needs_step and a finalizeHook is wired:
25
- proof = await window[finalizeHook](challenge, { token, onProgress })
26
- POST finalizeUrl { token, proof } -> { status: "saved" | "error", ... }
27
-
28
- opts: { hasInput, initialValue, minLength, submitUrl, finalizeUrl, finalizeHook,
29
- savedEvent, store, leveling, demo, seedsPerLevel }.
30
- hasInput true renders + validates a single-line input; false is a
31
- no-field activity (a plain "Complete" action).
32
- initialValue seed value for the input (a plain string; never a model).
33
- minLength client-only min length for the input (0 = none).
34
- submitUrl app endpoint the action POSTs to (the app owns persistence).
35
- finalizeUrl app endpoint for the opaque second step (omit if none).
36
- finalizeHook name of a window async fn the app supplies for the step (omit
37
- if none); it owns EVERY app-side detail of that step.
38
- savedEvent DOM event dispatched on success, carrying the app payload, so
39
- the app can run its own follow-on. Default "studio:activity-saved".
40
- A non-demo caller that supplies its OWN (non-default) saved_event
41
- is declaring it drives that follow-on: the engine then CLOSES on
42
- save (fires the event, stops) rather than rendering its own
43
- confirm/celebrate, so the app's follow-on never renders twice.
44
- store Alpine modal store name backing close(). Default "modals".
45
- leveling the render-time DEFAULT for the seeds celebration. The live
46
- value is read at RUNTIME from the modal store's current
47
- props.leveling (this.leveling getter), so one modal id flips
48
- TM (celebration) <-> MS (plain save) as a toggle moves; when
49
- props omits it (TM's live path) the default stands.
50
- hasConsent true renders a consent checkbox that gates the action until
51
- ticked (the newsletter join).
52
- demo style-guide preview: resolve locally, skip the network POST, and
53
- suppress the app-facing saved event (so a host app's follow-on
54
- handler can't stack a second modal over the demo's own state).
55
- seedsPerLevel seeds per level for the celebration bar. Default 100.
56
- demoSeedsEarned / demoSeedsTotal demo-only seed payload (defaults to a
57
- level-crossing 30 / seedsPerLevel) so each activity's
58
- celebration can showcase a chosen state.
2
+ Renders nothing. A layout or page that renders studio/leveling_activity_assets
3
+ keeps working; the render can be deleted.
4
+
5
+ The leveling-activity modals' behaviour is the studio/leveling_activity module
6
+ (its header carries the opts and the JSON contract). studio/alpine_shims
7
+ publishes its factory as window.levelingActionModal on every page, before
8
+ Alpine starts, so x-data="levelingActionModal({...})" on
9
+ studio/modals/blocks/_leveling_activity and _change_username resolves with no
10
+ page-level script.
59
11
  %>
60
- <script>
61
- (function () {
62
- if (window.levelingActionModal) return;
63
- // The default saved event. A non-demo caller that wires its OWN (non-default)
64
- // saved_event is declaring it drives the post-save follow-on itself — see the
65
- // appDrivenFollowOn getter, which then makes the engine close on save instead of
66
- // rendering its own confirm/celebrate.
67
- var DEFAULT_SAVED_EVENT = "studio:activity-saved";
68
- window.levelingActionModal = function (opts) {
69
- opts = opts || {};
70
- return {
71
- hasInput: !!opts.hasInput,
72
- value: opts.initialValue || "",
73
- original: opts.initialValue || "",
74
- minLength: parseInt(opts.minLength, 10) || 0,
75
- submitUrl: opts.submitUrl || "",
76
- finalizeUrl: opts.finalizeUrl || "",
77
- finalizeHook: opts.finalizeHook || "",
78
- savedEvent: opts.savedEvent || DEFAULT_SAVED_EVENT,
79
- store: opts.store || "modals",
80
- // leveling is a RUNTIME getter (below) reading props.leveling off the modal
81
- // store, so ONE modal id flips TM <-> MS shape live as a section toggle
82
- // moves. _levelingDefault is the render-time fallback (TM ships no
83
- // props.leveling and relies on its :leveling capability default).
84
- _levelingDefault: !!opts.leveling,
85
- hasConsent: !!opts.hasConsent,
86
- consent: false,
87
- demo: !!opts.demo,
88
- seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
89
- demoSeedsEarned: parseInt(opts.demoSeedsEarned, 10),
90
- demoSeedsTotal: parseInt(opts.demoSeedsTotal, 10),
91
- saving: false,
92
- error: "",
93
- saved: false,
94
- celebrate: false,
95
- progressLabel: "",
96
- seedsEarned: 0,
97
- seedsTotal: 0,
98
-
99
- // Open directly AT the updated/celebrate state — the specimen's "updated"
100
- // cards (Great Username / Subscribed!) open the same modal id pre-advanced,
101
- // exactly like the Auth cards open a given step. props.celebrate mounts the
102
- // modal already saved, with the seed payload the celebration animates from.
103
- // leveling (a getter) then decides at runtime whether that state renders the
104
- // seeds celebration (TM) or the plain confirmation (MS).
105
- init: function () {
106
- var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
107
- var cur = (s && typeof s.current === "function") ? s.current() : null;
108
- var p = (cur && cur.props) || {};
109
- if (p.celebrate) {
110
- this.seedsEarned = parseInt(p.seedsEarned, 10) || 0;
111
- this.seedsTotal = parseInt(p.seedsTotal, 10) || 0;
112
- this.saved = true;
113
- this.celebrate = true;
114
- }
115
- },
116
-
117
- // leveling — resolved at RUNTIME from the modal store's current props, so a
118
- // single modal id flips TM (seeds/level/quest) <-> MS (plain save) live as
119
- // the section's leveling toggle moves. Falls back to the render-time default
120
- // when props does not set it (TM's live path), so existing callers are
121
- // unaffected.
122
- get leveling() {
123
- var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
124
- var cur = (s && typeof s.current === "function") ? s.current() : null;
125
- var p = cur && cur.props;
126
- if (p && typeof p.leveling === "boolean") return p.leveling;
127
- return this._levelingDefault;
128
- },
129
-
130
- // Actionable while there is a real change to save (input activities) or
131
- // until the one-shot action has fired (no-field activities). A consent
132
- // checkbox (the newsletter join) additionally gates the action until ticked.
133
- get changed() {
134
- if (this.hasConsent && !this.consent) return false;
135
- if (!this.hasInput) return !this.saved;
136
- var v = (this.value || "").trim();
137
- if (v.length < this.minLength) return false;
138
- return v !== (this.original || "").trim();
139
- },
140
-
141
- // appDrivenFollowOn — a real (non-demo) consumer that wired its OWN saved_event
142
- // owns the after-save UI: its listener runs the follow-on (e.g. Turf Monster's
143
- // completeQuest / quest-success on 'studio:username-saved'), including whether
144
- // to close, swap, or advance. For those the engine must NOT advance to its own
145
- // confirm/celebrate on save — that double-renders the app's follow-on step
146
- // (once in this dialog, once in the app's own inline card: the strict-mode
147
- // double-render TM's e2e caught). The engine yields: it fires the saved event,
148
- // then STOPS (the 0.24 contract), letting the app drive. Demo previews (self-
149
- // contained) and standalone callers on the DEFAULT event keep the engine's own
150
- // confirm/celebrate.
151
- get appDrivenFollowOn() {
152
- if (this.demo) return false;
153
- return this.savedEvent !== DEFAULT_SAVED_EVENT;
154
- },
155
-
156
- _dispatch: function (payload) {
157
- // In demo mode (the /admin/style preview) do NOT fire the app-facing
158
- // saved event: the host app may listen for it and open its OWN follow-on
159
- // modal (e.g. Turf Monster swaps to 'quest-success' on
160
- // 'studio:username-saved'), which would stack a SECOND modal over the
161
- // demo's own celebrate view — the reported duplicate. The demo resolves
162
- // locally and is self-contained, so it must not drive real app follow-ons.
163
- if (this.demo) return;
164
- try {
165
- window.dispatchEvent(new CustomEvent(this.savedEvent, { detail: payload || {} }));
166
- } catch (e) {}
167
- },
168
-
169
- _finishSaved: function (payload) {
170
- payload = payload || {};
171
- this.saving = false;
172
- this.saved = true;
173
- this.progressLabel = "";
174
- if (this.hasInput) this.original = (this.value || "").trim();
175
- this._dispatch(payload);
176
- // App-driven follow-on: the consumer wired its own saved_event and owns
177
- // what comes next (its listener advances/closes/swaps). Dispatch fired
178
- // above; STOP here — the 0.24 contract — instead of advancing to the
179
- // engine's own confirm/celebrate, which would render the app's follow-on
180
- // step twice. The engine never force-closes here: the app's listener owns
181
- // close-vs-swap (TM closes on the contest page, swaps to quest-success on
182
- // /account), so a force-close would clobber that swap.
183
- if (this.appDrivenFollowOn) return;
184
- // Otherwise (a demo preview, or a standalone caller on the DEFAULT event
185
- // with no app follow-on): advance to the updated state; the view gate
186
- // (celebrate && leveling vs celebrate && !leveling) picks the seeds
187
- // celebration (TM shape) or the plain confirmation (MS shape). Seeds are
188
- // set either way — only the leveling view reads them.
189
- this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
190
- this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
191
- this.celebrate = true;
192
- // Reflect the advance onto the store's LIVE current props so the specimen
193
- // glow (which reads props.celebrate) transfers from the input card to the
194
- // updated card — exactly like the Auth glow follows props.step as its
195
- // machine advances. Without this, the glow stays on the input card.
196
- this._syncCelebrate(true);
197
- },
198
-
199
- // Mirror the component's celebrate onto the current modal store entry's
200
- // props, the reactive source the specimen glow_when reads.
201
- _syncCelebrate: function (value) {
202
- var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
203
- var cur = (s && typeof s.current === "function") ? s.current() : null;
204
- if (cur && cur.props) cur.props.celebrate = !!value;
205
- },
206
-
207
- _post: function (url, body) {
208
- var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
209
- var fetcher = window.authedFetch || window.fetch;
210
- return fetcher(url, {
211
- method: "POST",
212
- headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
213
- body: JSON.stringify(body || {})
214
- }).then(function (r) {
215
- return r.json().then(function (j) { return { ok: r.ok, body: j || {} }; });
216
- });
217
- },
218
-
219
- save: function () {
220
- if (this.saving || !this.changed) return;
221
- this.saving = true;
222
- this.error = "";
223
- var self = this;
224
- if (this.demo) {
225
- // Style-guide preview: no backend. Resolve to a per-activity seed
226
- // payload (defaulting to a level-crossing one) so the celebration
227
- // demonstrates its flourish — Username stays at Level 1 (+25), the
228
- // Newsletter crosses a level to reveal the Free Entry Token line.
229
- var de = isNaN(self.demoSeedsEarned) ? 30 : self.demoSeedsEarned;
230
- var dt = isNaN(self.demoSeedsTotal) ? self.seedsPerLevel : self.demoSeedsTotal;
231
- setTimeout(function () {
232
- self._finishSaved({ seeds_earned: de, seeds_total: dt });
233
- }, 600);
234
- return;
235
- }
236
- var body = this.hasInput ? { value: (this.value || "").trim() } : {};
237
- this._post(this.submitUrl, body)
238
- .then(function (res) { return self._handle(res); })
239
- .catch(function (e) {
240
- self.error = (e && e.message) || "Something went wrong. Please try again.";
241
- self.saving = false;
242
- });
243
- },
244
-
245
- _handle: function (res) {
246
- var body = (res && res.body) || {};
247
- var status = body.status || (res && res.ok ? "saved" : "error");
248
- if (status === "saved") { this._finishSaved(body); return; }
249
- if (status === "needs_step") { return this._step(body); }
250
- this.error = body.message || "Something went wrong. Please try again.";
251
- this.saving = false;
252
- },
253
-
254
- // The opaque second step. The engine hands the app's "challenge" blob to
255
- // the app-supplied hook and POSTs back the app's "proof" blob — it inspects
256
- // neither, so every app-side detail of the step stays in the app.
257
- _step: function (body) {
258
- var hook = this.finalizeHook && window[this.finalizeHook];
259
- var self = this;
260
- if (typeof hook !== "function") {
261
- this.error = "This step isn't available.";
262
- this.saving = false;
263
- return;
264
- }
265
- return Promise.resolve(
266
- hook(body.challenge, {
267
- token: body.token,
268
- onProgress: function (label) { self.progressLabel = label || ""; }
269
- })
270
- ).then(function (proof) {
271
- return self._post(self.finalizeUrl, { token: body.token, proof: proof });
272
- }).then(function (res) {
273
- var rb = (res && res.body) || {};
274
- var status = rb.status || (res && res.ok ? "saved" : "error");
275
- if (status === "saved") { self._finishSaved(rb); }
276
- else { self.error = rb.message || "Couldn't complete the step."; self.saving = false; }
277
- }).catch(function (e) {
278
- self.error = (e && e.message) || "Couldn't complete the step.";
279
- self.saving = false;
280
- });
281
- }
282
- };
283
- };
284
- })();
285
- </script>
@@ -3,10 +3,12 @@
3
3
  primitive (Phase D): the SortableJS drag-reorder, optimistic cross-column move →
4
4
  revert → toast, and count/live wiring that the three near-identical McRitchie
5
5
  Studio boards (tasks / news / content) and the depth chart each hand-rolled,
6
- lifted into one place. Renders a horizontal row of columns, each a drop zone the
7
- vendored SortableJS + window.studioBoard factory (studio/_board_assets, rendered
8
- once at layout level — a <script> inside this component template would never run)
9
- wires.
6
+ lifted into one place. Renders a horizontal row of columns, each a drop zone.
7
+ The section is two things at once: its x-data is the studioBoard scope
8
+ (studio/board, published as window.studioBoard by studio/alpine_shims), which
9
+ the cards and slots inside it bind to, and its data-studio-controller is the
10
+ board controller, registered lazily, which loads the vendored SortableJS and
11
+ makes the zones draggable. This partial carries no script.
10
12
 
11
13
  UI ONLY and NEUTRAL: no model, no routes, no domain. The app supplies its columns,
12
14
  its card partial, and its endpoints. Custom behavior attaches by LISTENING to the
@@ -164,9 +166,18 @@
164
166
  labels: all_columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
165
167
  }
166
168
  %>
167
- <section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
169
+ <section class="studio-board" data-test="studio-board" data-studio-controller="board" x-data="studioBoard(<%= board_opts.to_json %>)">
168
170
  <% if live_channel.present? %><%= turbo_stream_from live_channel %><% end %>
169
171
 
172
+ <%# Shown only when the board controller's module failed to load: the registry
173
+ marks this section data-studio-controller-failed="board", and engine.css
174
+ reveals the notice on that attribute (its inline display: none is the
175
+ default, so a host whose CSS lacks the rule shows nothing). The board still renders and its
176
+ cards stay readable; they do not drag until the page is reloaded. %>
177
+ <p role="status" data-test="studio-board-failed"
178
+ class="studio-board-failed mb-4 rounded-lg border bg-surface px-4 py-2.5 text-sm font-medium"
179
+ style="display: none; border-color: var(--color-danger); color: var(--color-danger-ink, var(--color-danger))">This board could not load its controls. Reload the page.</p>
180
+
170
181
  <% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
171
182
  <% if above_board_slot %><%= above_board_slot %><% end %>
172
183
 
@@ -1,6 +1,8 @@
1
- <%# Styles and scripts for the booking frame and the booking popup. Rendered
2
- once per page, by `studio_booking_assets` (the frame and the popup both ask).
3
- Scoped CSS for the reason studio/site_footer/_assets gives. %>
1
+ <%# Styles for the booking frame and the booking popup. Rendered once per page,
2
+ by `studio_booking_assets` (the frame and the popup both ask). Scoped CSS
3
+ for the reason studio/site_footer/_assets gives. The behaviour is
4
+ studio/booking, started by the booking controller the frame's wrapper and
5
+ the popup's dialog name. %>
4
6
  <style>
5
7
  /* The inline frame. Google's page cannot be themed, so the panel is white. */
6
8
  /* content-box, so the heights below are the WINDOW onto the frame whatever
@@ -58,139 +60,3 @@
58
60
  .booking-popup iframe { flex: 1; width: 100%; border: 0; background: #fff; }
59
61
  @media (prefers-reduced-motion: reduce) { .booking-frame, .booking-frame iframe { transition: none; } }
60
62
  </style>
61
-
62
- <script>
63
- // The inline frame: assign src after `load`, once the frame is near the
64
- // viewport, and open the crop when focus moves into the frame.
65
- //
66
- // THE GUARDS ARE THE ENGINE'S OWN NAMES, AND THE ELEMENTS ARE THE ENGINE'S OWN
67
- // (data-studio-booking), for the reason studio/site_footer/_assets gives for
68
- // the map: an app that still renders a local booking script guards it with
69
- // the unprefixed names and marks its frame, dialog and links with the same
70
- // data-booking-* attributes. Sharing either lets one script stand the other
71
- // down across a Turbo visit, or act on elements that are not its own.
72
- (function () {
73
- if (window.__studioBookingFramesArmed) return;
74
- window.__studioBookingFramesArmed = true;
75
-
76
- // ASK GOOGLE ONCE. Two things can ask for a frame: scrolling near it, and a
77
- // booking link that jumps to it. Assigning src a second time, even to the
78
- // same URL, is a second request, so whoever comes second finds it set.
79
- function load(frame) {
80
- if (!frame.getAttribute('src')) frame.src = frame.dataset.src;
81
- }
82
- window.__studioBookingLoad = load;
83
-
84
- function arm() {
85
- document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
86
- if (frame.__bookingArmed) return;
87
- frame.__bookingArmed = true;
88
- if (typeof IntersectionObserver !== 'function') { load(frame); return; }
89
- var watcher = new IntersectionObserver(function (entries) {
90
- if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
91
- watcher.disconnect();
92
- load(frame);
93
- }, { rootMargin: '200px' });
94
- watcher.observe(frame);
95
- });
96
- }
97
-
98
- function armAfterLoad() {
99
- if (document.readyState === 'complete') arm();
100
- else window.addEventListener('load', arm, { once: true });
101
- }
102
-
103
- // Focus moving into the frame is the one signal a cross-origin frame gives
104
- // its parent. It means the visitor has started using the calendar.
105
- function openActive() {
106
- var active = document.activeElement;
107
- if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
108
- var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
109
- if (wrap) wrap.classList.add('is-open');
110
- }
111
-
112
- // THE WINDOW BLURS ONCE. Focus that moves from one frame straight into
113
- // another never comes back through this window, so no second blur announces
114
- // the second frame, and on a page with two cropped frames it would stay
115
- // cropped under the visitor's cursor. So while focus is away in a frame and a
116
- // cropped wrapper is still shut, look again a few times a second. It stops
117
- // when focus returns here or nothing is left to open, and a page with one
118
- // frame never starts it: that frame is open by then.
119
- var watching = null;
120
- function shut() { return document.querySelector('[data-booking-wrap][data-studio-booking][data-booking-crop]:not(.is-open)'); }
121
- function stopWatching() { if (watching) { clearInterval(watching); watching = null; } }
122
-
123
- window.addEventListener('blur', function () {
124
- openActive();
125
- var active = document.activeElement;
126
- if (watching || !active || active.tagName !== 'IFRAME' || !shut()) return;
127
- watching = setInterval(function () {
128
- openActive();
129
- if (!shut()) stopWatching();
130
- }, 250);
131
- });
132
- window.addEventListener('focus', stopWatching);
133
-
134
- document.addEventListener('turbo:load', armAfterLoad);
135
- armAfterLoad();
136
- })();
137
- </script>
138
-
139
- <script>
140
- // Any a[data-booking-popup] link opens the booking dialog instead of leaving
141
- // the page. Delegated from document, so it survives Turbo body swaps. A
142
- // modified click (new tab, new window) is left alone, and so is everything
143
- // when the dialog is missing or <dialog> is unsupported: the link's own href
144
- // then takes the visitor to the booking page.
145
- //
146
- // ON A PAGE THAT ALREADY SHOWS THE INLINE FRAME the link goes to that frame
147
- // instead: it scrolls there, opens the crop and moves focus in. A popup would
148
- // be a second copy of the calendar on top of the first.
149
- (function () {
150
- if (window.__studioBookingPopupArmed) return;
151
- window.__studioBookingPopupArmed = true;
152
-
153
- document.addEventListener('click', function (event) {
154
- var link = event.target.closest && event.target.closest('a[data-booking-popup][data-studio-booking]');
155
- if (!link) return;
156
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
157
-
158
- var inline = document.querySelector('iframe[data-booking-frame][data-studio-booking]');
159
- if (inline) {
160
- event.preventDefault();
161
- var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
162
- if (wrap) wrap.classList.add('is-open');
163
- // Before `load` the frame keeps waiting: its own script assigns src then.
164
- if (document.readyState === 'complete' && window.__studioBookingLoad) window.__studioBookingLoad(inline);
165
- var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
166
- (wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
167
- inline.focus({ preventScroll: true });
168
- return;
169
- }
170
-
171
- var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
172
- if (!dialog || typeof dialog.showModal !== 'function') return;
173
-
174
- event.preventDefault();
175
- var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
176
- if (frame && !frame.getAttribute('src')) frame.src = frame.dataset.src;
177
- if (!dialog.open) dialog.showModal();
178
- });
179
-
180
- // A click on the backdrop lands on the dialog element itself.
181
- document.addEventListener('click', function (event) {
182
- if (event.target.matches && event.target.matches('dialog[data-booking-dialog][data-studio-booking]')) event.target.close();
183
- });
184
-
185
- // Never snapshot the dialog open: a restored page would show it stuck. And
186
- // never snapshot its frame loaded: a restored page would ask Google for a
187
- // popup nobody has opened. The src goes back to waiting in data-src.
188
- document.addEventListener('turbo:before-cache', function () {
189
- var dialog = document.querySelector('dialog[data-booking-dialog][data-studio-booking]');
190
- if (!dialog) return;
191
- if (dialog.open) dialog.close();
192
- var frame = dialog.querySelector('iframe[data-booking-popup-frame]');
193
- if (frame) frame.removeAttribute('src');
194
- });
195
- })();
196
- </script>