studio-engine 0.97.0 → 0.98.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/app/javascript/studio/alpine_scopes.js +66 -0
  4. data/app/javascript/studio/alpine_shims.js +4 -0
  5. data/app/javascript/studio/application.js +4 -1
  6. data/app/javascript/studio/birthday.js +351 -0
  7. data/app/javascript/studio/confirm_interstitial.js +44 -0
  8. data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
  9. data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
  10. data/app/javascript/studio/cropper.js +281 -0
  11. data/app/javascript/studio/identity_mini.js +74 -0
  12. data/app/javascript/studio/image_upload.js +189 -0
  13. data/app/javascript/studio/profile_form.js +88 -0
  14. data/app/javascript/studio/stimulus.js +4 -0
  15. data/app/javascript/studio/style_guide.js +13 -0
  16. data/app/javascript/studio/style_modals.js +214 -0
  17. data/app/views/components/_avatar_cropper.html.erb +3 -38
  18. data/app/views/layouts/studio/_head.html.erb +12 -2
  19. data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
  20. data/app/views/studio/_birthday_assets.html.erb +6 -240
  21. data/app/views/studio/_confirm_interstitial.html.erb +31 -18
  22. data/app/views/studio/_cropper_assets.html.erb +9 -8
  23. data/app/views/studio/modals/_crop_photo.html.erb +1 -1
  24. data/app/views/studio/modals/_image_upload.html.erb +8 -321
  25. data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
  26. data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
  27. data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
  28. data/app/views/studio/profiles/_plain_save.html.erb +11 -0
  29. data/app/views/studio/profiles/edit.html.erb +1 -6
  30. data/app/views/style/_modals.html.erb +36 -420
  31. data/lib/studio/version.rb +1 -1
  32. metadata +14 -3
  33. data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
  34. data/app/views/studio/profiles/_form_script.html.erb +0 -65
@@ -0,0 +1,13 @@
1
+ // studio/style_guide: the living style guide's own controllers, registered on
2
+ // the engine's Stimulus application. style/_modals loads this module by its
3
+ // own nonced module tag, so only the style guide fetches it and it stays out of
4
+ // the every-page boot graph.
5
+ //
6
+ // A module tag and not an entry in studio/stimulus's LAZY table: the tag runs
7
+ // with the page, in document order, so the section's controller is connected
8
+ // by the time the document has loaded, and a script or a spec that drives the
9
+ // demos the moment the page is up finds them there.
10
+ import { application } from "studio/stimulus"
11
+ import StyleModalsController from "studio/controllers/style_modals_controller"
12
+
13
+ application.register("style-modals", StyleModalsController)
@@ -0,0 +1,214 @@
1
+ // studio/style_modals: what drives the living style guide's Modals section
2
+ // (style/_modals): the stack-behaviour demos, the enter/leave simulator whose
3
+ // controls are built from the live animation registry, and the stubs the
4
+ // wallet specimens need to run with no wallet.
5
+ //
6
+ // The section's own modal host is the engine's stack under the name dsModals
7
+ // (studio/modal_host registers it from the overlay's data attributes), so what
8
+ // the guide shows is the store an app runs. The style-modals controller binds
9
+ // this module to the section.
10
+ //
11
+ // It imports nothing; test/javascript/style_modals.test.mjs loads it as a
12
+ // data: module.
13
+
14
+ // The card the stack demos drive: style/modals/_ds_stack_demo, which belongs
15
+ // to the guide and to nothing that ships.
16
+ export const VEHICLE = "ds-stack-demo"
17
+
18
+ // The card the enter/leave simulator opens, so only the motion varies.
19
+ export const ANIM_DEMO_ID = "email-change-pending"
20
+ export const ANIM_DEMO_PROPS = { currentEmail: "you@example.com", newEmail: "new@example.com" }
21
+
22
+ // The four containers the simulator fills.
23
+ export const ANIM_CONTROL_IDS = {
24
+ enterButtons: "modal-anim-enter-buttons",
25
+ exitButtons: "modal-anim-exit-buttons",
26
+ enterSelect: "modal-anim-enter-select",
27
+ exitSelect: "modal-anim-exit-select"
28
+ }
29
+
30
+ // A pending operation, as the vehicle opens: not dismissible, like a real one.
31
+ export const PENDING = {
32
+ state: "processing",
33
+ title: "Submitting entry",
34
+ message: "Awaiting confirmation",
35
+ dismissible: false
36
+ }
37
+
38
+ export const SUCCESS = {
39
+ state: "success",
40
+ title: "Entry confirmed",
41
+ message: "Resolved in place — this is the same stack entry you opened.",
42
+ dismissible: true
43
+ }
44
+
45
+ export const FAILURE = {
46
+ state: "error",
47
+ title: "Submission failed",
48
+ message: "The error face of that same entry, patched in by advance().",
49
+ dismissible: true
50
+ }
51
+
52
+ // How long the wallet stub takes to answer, so the picker's connecting state
53
+ // is visible.
54
+ export const WALLET_CONNECT_MS = 1100
55
+
56
+ // "Pop (default)", "Shake": a registry key as a control's label.
57
+ export function animationLabel(key) {
58
+ return key.charAt(0).toUpperCase() + key.slice(1) + (key === "pop" ? " (default)" : "")
59
+ }
60
+
61
+ // The registry the page holds, read when asked and never copied: a key an app
62
+ // registers later grows a control on the next build.
63
+ export function animationRegistry(win) {
64
+ const registry = win.ModalAnimations || {}
65
+ return { enter: registry.enter || {}, exit: registry.exit || {} }
66
+ }
67
+
68
+ // How long a round trip holds the card before closing it: its entrance's own
69
+ // registry duration plus a beat.
70
+ export function roundTripHoldMs(win, enterAnim) {
71
+ const entrance = animationRegistry(win).enter[enterAnim] || {}
72
+ return (entrance.ms || 320) + 700
73
+ }
74
+
75
+ // The stubs solana-studio's wallet picker needs on a page with no wallet:
76
+ // a connect that performs none and answers success after a beat, and a
77
+ // provider that lists two wallets and has neither. A page that ships a real
78
+ // one keeps it.
79
+ export function installWalletStubs(win) {
80
+ win.dsWalletConnectDemo = win.dsWalletConnectDemo || function () {
81
+ return new Promise(function (resolve) {
82
+ win.setTimeout(function () { resolve({ success: true }) }, WALLET_CONNECT_MS)
83
+ })
84
+ }
85
+ win.walletProvider = win.walletProvider || {
86
+ available: function () { return [{ name: "Phantom" }, { name: "Solflare" }] },
87
+ isMobile: function () { return false },
88
+ isAvailable: function () { return false }
89
+ }
90
+ }
91
+
92
+ // The demo drivers. env: { win, doc, store } — the window, the document the
93
+ // simulator's controls live in, and the name of the section's modal store.
94
+ export function createModalDemos(env) {
95
+ const win = env.win
96
+ const doc = env.doc
97
+ const storeName = env.store || "dsModals"
98
+ const store = () => win.Alpine.store(storeName)
99
+ const element = (id) => doc.getElementById(id)
100
+
101
+ function openDemo(props) {
102
+ store().open(VEHICLE, Object.assign({}, PENDING, props || {}))
103
+ }
104
+
105
+ // A state change on the card already open. advance() patches props without
106
+ // replacing the stack entry, so the vehicle's scope survives. Only a
107
+ // still-open vehicle is patched: a timer that fires after the card closed
108
+ // does nothing to whatever is on top now.
109
+ function advanceDemo(patch) {
110
+ const current = store().current()
111
+ if (!current || current.id !== VEHICLE || current._closing) return
112
+ store().advance(patch)
113
+ }
114
+
115
+ function animOpen(enterAnim, exitAnim) {
116
+ store().open(ANIM_DEMO_ID, Object.assign({}, ANIM_DEMO_PROPS, { enterAnim, exitAnim }))
117
+ }
118
+
119
+ // Open, let the entrance settle, then close, so the chosen exit is what
120
+ // plays.
121
+ function animRoundTrip(enterAnim, exitAnim) {
122
+ animOpen(enterAnim, exitAnim)
123
+ win.setTimeout(function () { store().close() }, roundTripHoldMs(win, enterAnim))
124
+ }
125
+
126
+ // Builds the quick buttons and the select options from the live registry.
127
+ // Each container is emptied first, so building again doubles nothing.
128
+ function buildAnimControls() {
129
+ const registry = animationRegistry(win)
130
+ const enterButtons = element(ANIM_CONTROL_IDS.enterButtons)
131
+ const exitButtons = element(ANIM_CONTROL_IDS.exitButtons)
132
+ const enterSelect = element(ANIM_CONTROL_IDS.enterSelect)
133
+ const exitSelect = element(ANIM_CONTROL_IDS.exitSelect)
134
+
135
+ const addOption = function (select, key) {
136
+ if (!select) return
137
+ const option = doc.createElement("option")
138
+ option.value = key
139
+ option.textContent = animationLabel(key)
140
+ if (key === "pop") option.selected = true
141
+ select.appendChild(option)
142
+ }
143
+ const addButton = function (container, key, onClick, suffix) {
144
+ if (!container) return
145
+ const button = doc.createElement("button")
146
+ button.type = "button"
147
+ button.className = "btn btn-outline btn-sm"
148
+ button.textContent = animationLabel(key) + (suffix || "")
149
+ button.onclick = onClick
150
+ container.appendChild(button)
151
+ }
152
+
153
+ ;[enterButtons, exitButtons, enterSelect, exitSelect].forEach(function (node) {
154
+ if (node) node.innerHTML = ""
155
+ })
156
+
157
+ Object.keys(registry.enter).forEach(function (key) {
158
+ addButton(enterButtons, key, function () { animOpen(key, "pop") }, " ↗")
159
+ addOption(enterSelect, key)
160
+ })
161
+ Object.keys(registry.exit).forEach(function (key) {
162
+ addButton(exitButtons, key, function () { animRoundTrip("pop", key) })
163
+ addOption(exitSelect, key)
164
+ })
165
+ }
166
+
167
+ return {
168
+ buildAnimControls,
169
+
170
+ // Stack behaviour
171
+ processing: function () { openDemo({}) },
172
+ success: function () { openDemo(SUCCESS) },
173
+ error: function () { openDemo(FAILURE) },
174
+ processThenSuccess: function () {
175
+ openDemo({ message: "Awaiting RPC confirmation" })
176
+ win.setTimeout(function () { advanceDemo(SUCCESS) }, 3000)
177
+ },
178
+ processThenError: function () {
179
+ openDemo({ message: "Awaiting RPC confirmation" })
180
+ win.setTimeout(function () { advanceDemo(FAILURE) }, 3000)
181
+ },
182
+ dismissible: function () {
183
+ openDemo({
184
+ dismissible: true,
185
+ title: "Dismissible processing",
186
+ message: "Escape and click-outside both close this one."
187
+ })
188
+ },
189
+ fastWithHold: function () {
190
+ openDemo({ title: "Fast operation", message: "Holding the spinner for at least 1500ms" })
191
+ win.StudioModals.holdAtLeast(1500).then(function () { advanceDemo(SUCCESS) })
192
+ },
193
+ fastNoHold: function () {
194
+ openDemo({ title: "Fast operation", message: "No hold, so this flashes past" })
195
+ win.setTimeout(function () { advanceDemo(SUCCESS) }, 50)
196
+ },
197
+ stackTwo: function () {
198
+ openDemo({
199
+ dismissible: true,
200
+ title: "Bottom of the stack",
201
+ message: "A second card is about to push in front of this one."
202
+ })
203
+ win.setTimeout(function () { store().open(ANIM_DEMO_ID, ANIM_DEMO_PROPS) }, 700)
204
+ },
205
+
206
+ // Enter / leave simulator
207
+ animOpenSelected: function () {
208
+ animOpen(element(ANIM_CONTROL_IDS.enterSelect).value, element(ANIM_CONTROL_IDS.exitSelect).value)
209
+ },
210
+ animRoundTripSelected: function () {
211
+ animRoundTrip(element(ANIM_CONTROL_IDS.enterSelect).value, element(ANIM_CONTROL_IDS.exitSelect).value)
212
+ }
213
+ }
214
+ }
@@ -8,6 +8,9 @@
8
8
  Distinct from imageUploadHost (immediate-save): this STAGES the file and
9
9
  lets the enclosing form own the submit (signup / profile edit).
10
10
 
11
+ The scope, avatarCropperHost(), is studio/image_upload, published on every
12
+ page by studio/alpine_scopes.
13
+
11
14
  Locals: form_field_name (e.g. "user[avatar]"). %>
12
15
 
13
16
  <%= render "studio/cropper_assets" %>
@@ -41,41 +44,3 @@
41
44
  <input type="file" x-ref="filePicker" @change="onFileSelected($event)" accept="image/*" class="hidden">
42
45
  </div>
43
46
 
44
- <script>
45
- function avatarCropperHost() {
46
- return {
47
- croppedUrl: null,
48
-
49
- onFileSelected(event) {
50
- var file = event.target.files[0];
51
- if (!file) return;
52
- var self = this;
53
- var reader = new FileReader();
54
- reader.onload = function(e) {
55
- // Hand the raw image to the shared modal host; the crop-photo modal
56
- // owns the cropper UI and posts back via 'crop-photo-confirmed'.
57
- Alpine.store('modals').open('crop-photo', { imageUrl: e.target.result });
58
- };
59
- reader.readAsDataURL(file);
60
- event.target.value = ''; // allow re-selecting the same file
61
- },
62
-
63
- applyCrop(blob) {
64
- if (!blob) return;
65
- if (this.croppedUrl) URL.revokeObjectURL(this.croppedUrl);
66
- this.croppedUrl = URL.createObjectURL(blob);
67
- var file = new File([blob], 'avatar.png', { type: 'image/png' });
68
- var dt = new DataTransfer();
69
- dt.items.add(file);
70
- this.$refs.hiddenFileInput.files = dt.files;
71
- },
72
-
73
- removePhoto() {
74
- if (this.croppedUrl) URL.revokeObjectURL(this.croppedUrl);
75
- this.croppedUrl = null;
76
- var dt = new DataTransfer();
77
- this.$refs.hiddenFileInput.files = dt.files;
78
- }
79
- };
80
- }
81
- </script>
@@ -1,4 +1,4 @@
1
- <%# The request's CSP nonce, for the two inline scripts left in this head and
1
+ <%# The request's CSP nonce, for the three inline scripts left in this head and
2
2
  for javascript_import_module_tag below (importmap-rails nonces its own tags).
3
3
  nil when the host sets no nonce generator, and then no attribute renders. %>
4
4
  <% studio_script_nonce = (content_security_policy_nonce if respond_to?(:content_security_policy_nonce)) %>
@@ -12,10 +12,14 @@
12
12
  }
13
13
  })();
14
14
  <% end %>
15
- <%# THE HOLD BUTTON'S GUARD. Inline, and the other inline script this head keeps:
15
+ <%# THE HOLD BUTTON'S GUARD. Inline, one of the two guards this head keeps:
16
16
  it reports a hold button whose modules failed to load, so it can share no
17
17
  request with them (studio/_hold_button_guard). %>
18
18
  <%= render "studio/hold_button_guard", nonce: studio_script_nonce %>
19
+ <%# THE X-DATA FACTORIES' GUARD. Inline for the same reason: it reports a profile
20
+ form, a birthday card or an upload whose factory failed to load
21
+ (studio/_alpine_scopes_guard), and shows the form's plain Save. %>
22
+ <%= render "studio/alpine_scopes_guard", nonce: studio_script_nonce %>
19
23
 
20
24
  <meta name="viewport" content="width=device-width,initial-scale=1">
21
25
  <%# Studio.nav_spinner_min_ms, read by the nav spinner in studio/head_chrome. %>
@@ -198,6 +202,12 @@
198
202
  the document click and Escape handlers. It imports nothing, and the
199
203
  link-sidebar controller imports it. %>
200
204
  <%= javascript_import_module_tag "studio/link_sidebar" %>
205
+ <%# THE X-DATA FACTORIES, BY THEIR OWN DOOR, for the same reason.
206
+ studio/alpine_scopes publishes the factories a page binds by name
207
+ (cropPhotoModal, imageUploadHost, submitFormWithProgress), each from the
208
+ module that owns it. Its imports import nothing, and studio/alpine_shims
209
+ imports it, so a failed boot leaves a page its uploads. %>
210
+ <%= javascript_import_module_tag "studio/alpine_scopes" %>
201
211
  <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
202
212
  classic scripts and module scripts execute in document order, so by the time
203
213
  Alpine starts, studio/application has defined the shims it evaluates
@@ -0,0 +1,178 @@
1
+ <%#
2
+ THE X-DATA FACTORIES' GUARD: says so when a page's factories did not load.
3
+
4
+ The photo crop, the upload hosts, the birthday card and row and the profile
5
+ form bind an x-data factory by name (cropPhotoModal, imageUploadHost,
6
+ avatarCropperHost, birthdayModal, studioBirthdayFields, studioProfileForm),
7
+ and all six are published by studio/alpine_scopes from four modules
8
+ (studio/cropper, studio/image_upload, studio/birthday, studio/profile_form).
9
+ A browser keeps a failed module request for the life of the document, so one
10
+ failure among the five at page load leaves every name undefined until a
11
+ reload: the profile page with no Save, the birthday card with nothing to
12
+ press. This script is what speaks then, so it is INLINE and imports nothing:
13
+ it shares no request with the modules whose failure it reports.
14
+
15
+ Once the page's boot is over, an element whose x-data names a factory that is
16
+ not on window is marked, and a line is put first inside it:
17
+
18
+ <div x-data="studioProfileForm({...})" data-studio-scope-failed="studioProfileForm">
19
+ <p class="studio-scope-notice" role="status">This did not load. Reload the page.</p>
20
+
21
+ The line carries its own few styles (the danger ink, a border, weight), set
22
+ from here, so it reads as a notice in a host whose stylesheet has never heard
23
+ of it; .studio-scope-notice is there for a host that wants to restyle it. A
24
+ card a modal mounts later is marked when it arrives. A factory that turns up
25
+ afterwards takes the mark and the line away again.
26
+
27
+ A FORM IS NOT LEFT WITH NO WAY TO SUBMIT. Inside a marked element:
28
+ - every [data-studio-scope-fallback] is shown (its x-cloak and hidden come
29
+ off): the profile form's plain Save button is one, so a person can still
30
+ save;
31
+ - a text field Alpine emptied goes back to the value the server rendered,
32
+ unless the person is in it, so that Save sends what was on file and not
33
+ blanks.
34
+ The birthday card has no plain path (its submit is a fetch the factory
35
+ makes), so its line is the whole of what it offers: reload.
36
+
37
+ WHEN THE BOOT IS OVER. The engine's modules are module scripts in the head,
38
+ so DOMContentLoaded waits for them, however slow, and each has run or failed
39
+ by then. The 1.5 s after it is margin for a host that defines a factory of
40
+ the same name from a script of its own, not a race the page has to win.
41
+
42
+ Locals:
43
+ nonce - the request's CSP nonce, or nil (layouts/studio/_head passes it)
44
+ %>
45
+ <%= tag.script(nonce: local_assigns[:nonce]) do %>
46
+ (function () {
47
+ if (window.studioScopesGuard) return;
48
+
49
+ var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm"];
50
+ var BOUND = "[x-data]";
51
+ var FAILED = "data-studio-scope-failed";
52
+ var FALLBACK = "data-studio-scope-fallback";
53
+ var NOTICE_CLASS = "studio-scope-notice";
54
+ var NOTICE = "This did not load. Reload the page.";
55
+ // The danger ink is the theme's, held to AA on every surface; a host with
56
+ // no such token keeps the text colour around the line.
57
+ var NOTICE_STYLE = "display:block;margin:0 0 0.75rem;padding:0.5rem 0.75rem;" +
58
+ "border:1px solid currentColor;border-radius:0.5rem;font-size:0.875rem;font-weight:600;" +
59
+ "line-height:1.35;text-align:center;color:var(--color-danger-ink, var(--color-danger, inherit))";
60
+ var CALL = /^\s*([A-Za-z_$][\w$]*)\s*\(/;
61
+ var GRACE_MS = 1500;
62
+ var SETTLE_MS = 250;
63
+
64
+ var deadline = null;
65
+ var reported = false;
66
+
67
+ // The factory an x-data expression calls, when it is one of ours.
68
+ function scopeName(expression) {
69
+ var match = CALL.exec(expression || "");
70
+ return match && NAMES.indexOf(match[1]) !== -1 ? match[1] : null;
71
+ }
72
+
73
+ // What to do with one element. `deadline` is null until the page's boot has
74
+ // an end, and nothing is marked before it.
75
+ function verdict(state) {
76
+ if (!state.name) return "leave";
77
+ if (state.defined) return state.marked ? "clear" : "leave";
78
+ if (state.marked) return "leave";
79
+ if (state.deadline == null || state.now < state.deadline) return "wait";
80
+ return "mark";
81
+ }
82
+
83
+ function notice(element) {
84
+ for (var i = 0; i < element.children.length; i++) {
85
+ if (element.children[i].classList.contains(NOTICE_CLASS)) return element.children[i];
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function each(element, selector, visit) {
91
+ var found = element.querySelectorAll(selector);
92
+ for (var i = 0; i < found.length; i++) visit(found[i]);
93
+ }
94
+
95
+ function mark(element, name) {
96
+ element.setAttribute(FAILED, name);
97
+ if (!notice(element)) {
98
+ var line = document.createElement("p");
99
+ line.className = NOTICE_CLASS;
100
+ line.setAttribute("role", "status");
101
+ line.style.cssText = NOTICE_STYLE;
102
+ line.textContent = NOTICE;
103
+ element.insertBefore(line, element.firstChild);
104
+ }
105
+ each(element, "[" + FALLBACK + "]", function (fallback) {
106
+ fallback.removeAttribute("x-cloak");
107
+ fallback.hidden = false;
108
+ fallback.style.display = "";
109
+ fallback.setAttribute(FALLBACK, "shown");
110
+ });
111
+ each(element, "input, textarea", function (field) {
112
+ if (field === document.activeElement) return;
113
+ if (field.type === "file" || field.type === "password" || field.type === "hidden") return;
114
+ if (field.type === "checkbox" || field.type === "radio") return;
115
+ if (field.value === "" && field.defaultValue !== "") field.value = field.defaultValue;
116
+ });
117
+ if (!reported) {
118
+ reported = true;
119
+ console.error("[studio] the page's x-data factories failed to load and stay unavailable until the page is reloaded");
120
+ }
121
+ }
122
+
123
+ function clear(element) {
124
+ var line = notice(element);
125
+ element.removeAttribute(FAILED);
126
+ if (line) element.removeChild(line);
127
+ each(element, "[" + FALLBACK + '="shown"]', function (fallback) {
128
+ fallback.setAttribute(FALLBACK, "");
129
+ fallback.setAttribute("x-cloak", "");
130
+ });
131
+ }
132
+
133
+ function judge(element) {
134
+ var name = scopeName(element.getAttribute("x-data"));
135
+ var answer = verdict({
136
+ name: name,
137
+ defined: !!name && typeof window[name] === "function",
138
+ marked: element.hasAttribute(FAILED),
139
+ now: Date.now(),
140
+ deadline: deadline
141
+ });
142
+ if (answer === "mark") mark(element, name);
143
+ if (answer === "clear") clear(element);
144
+ return answer;
145
+ }
146
+
147
+ function sweep() {
148
+ var bound = document.querySelectorAll(BOUND);
149
+ for (var i = 0; i < bound.length; i++) judge(bound[i]);
150
+ }
151
+
152
+ function binds(node) {
153
+ return node.nodeType === 1 && (node.matches(BOUND) || node.querySelector(BOUND) !== null);
154
+ }
155
+
156
+ function booted() {
157
+ deadline = Date.now() + GRACE_MS;
158
+ setTimeout(sweep, GRACE_MS);
159
+ // Read off window under a local name: a host page that counts the
160
+ // observers its own inline scripts construct does not count this one.
161
+ var Observer = window.MutationObserver;
162
+ if (!Observer) return;
163
+ new Observer(function (records) {
164
+ for (var i = 0; i < records.length; i++) {
165
+ for (var j = 0; j < records[i].addedNodes.length; j++) {
166
+ if (binds(records[i].addedNodes[j])) return void setTimeout(sweep, SETTLE_MS);
167
+ }
168
+ }
169
+ }).observe(document.documentElement, { childList: true, subtree: true });
170
+ }
171
+
172
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", booted);
173
+ else booted();
174
+ document.addEventListener("turbo:load", function () { setTimeout(sweep, SETTLE_MS); });
175
+
176
+ window.studioScopesGuard = { names: NAMES, scopeName: scopeName, verdict: verdict, sweep: sweep, graceMs: GRACE_MS };
177
+ })();
178
+ <% end %>