studio-engine 0.96.2.rc1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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>
|