studio-engine 0.96.2 → 0.97.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 (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
@@ -22,13 +22,13 @@
22
22
  THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
23
23
  party, and a frame that starts loading before the window's `load` event
24
24
  holds that event open for as long as Google takes to answer. So the URL
25
- waits in data-src and the script in studio/booking/_assets assigns it only
26
- AFTER `load`, once the frame is near the viewport.
25
+ waits in data-src and studio/booking (the booking controller on the
26
+ wrapper) assigns it only AFTER `load`, once the frame is near the viewport.
27
27
 
28
28
  WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
29
29
  empty frame and puts a plain link to Google's booking page in its place. %>
30
30
  <%= studio_booking_assets %>
31
- <div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
31
+ <div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking data-studio-controller="booking"<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
32
32
  <iframe data-booking-frame data-studio-booking
33
33
  data-src="<%= studio_booking_embed_url %>"
34
34
  title="<%= title %>"
@@ -1,7 +1,7 @@
1
1
  <%# The booking popup: Google's appointment page in a dialog, opened by any
2
- a[data-booking-popup] link on the page (studio/booking/_assets carries the
3
- script). Rendered once per page by `studio_booking_popup`; the site footer
4
- calls it.
2
+ a[data-booking-popup] link on the page (studio/booking, started by the
3
+ booking controller on the dialog). Rendered once per page by
4
+ `studio_booking_popup`; the site footer calls it.
5
5
 
6
6
  It does what Google's own scheduling-button script does, without loading
7
7
  that script, its stylesheet and its icon font on every public page: the
@@ -23,7 +23,7 @@
23
23
  reaches the dialog. The Close button and a click on the backdrop always
24
24
  work, which is why the button is a solid one. %>
25
25
  <%= studio_booking_assets %>
26
- <dialog class="booking-popup" data-booking-dialog data-studio-booking aria-label="<%= label %>">
26
+ <dialog class="booking-popup" data-booking-dialog data-studio-booking data-studio-controller="booking" aria-label="<%= label %>">
27
27
  <form method="dialog" class="booking-popup-bar">
28
28
  <span class="booking-popup-title"><%= label %></span>
29
29
  <button type="submit" class="booking-popup-close" data-booking-close>Close ✕</button>
@@ -1,28 +1,7 @@
1
- <%# The live banner editor: typing repaints the banner, and ONE Save button in
2
- the header reports whether there is anything to save.
3
-
4
- WHY THE PREVIEW IS PAINTED IN THE BROWSER AND NOT RE-RENDERED
5
- The banner is already server-rendered by the mailer's own partial, which is
6
- what makes it trustworthy. This does not replace it — it edits the text
7
- nodes, the logo and the tint of that same rendered banner in place. The
8
- shape, the typography and the scaling stay the server's. Re-rendering a
9
- second banner in JavaScript would be a second implementation of the email,
10
- and it would drift.
11
-
12
- WHY THE PLACEHOLDER RULE IS REPEATED HERE
13
- Studio::Banner fills {name} and {app} server-side. Repeating that in script
14
- is a real duplication and worth naming: the alternative is a round trip per
15
- keystroke. The rule is small and pinned on both sides — banner_copy_test.rb
16
- for Ruby, e2e/email_banner_editor.spec.js for the browser. If they ever
17
- disagree, the server is right.
18
-
19
- TOUCHED VS DIRTY — they are different questions and the button answers both.
20
- `touched` is "has anyone typed here", and it decides whether the button is
21
- SHOWN. `dirty` is "is anything actually different from what is saved", and it
22
- decides whether the button is ENABLED. Typing "A" into the subject and
23
- deleting it again leaves touched true and dirty false: the button stays
24
- visible and goes back to disabled, which is what tells the operator their
25
- edit was undone rather than silently swallowed.
1
+ <%# The live banner editor's styles. The editor itself is the
2
+ emailBannerEditor factory in studio/email_banner, which studio/alpine_shims
3
+ publishes on every page; this partial carries the tint slider's rules, and
4
+ studio/emails/show renders it once.
26
5
  %>
27
6
  <style>
28
7
  /* The tint slider, in the app's own palette.
@@ -70,130 +49,3 @@
70
49
  .studio-range::-moz-range-track { height: 6px; border-radius: 999px; background: transparent; }
71
50
  .studio-range:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary, #8E82FE) 35%, transparent); }
72
51
  </style>
73
- <script>
74
- (function () {
75
- function editor(config) {
76
- return {
77
- form: Object.assign({}, config.values),
78
- initial: Object.assign({}, config.values),
79
- targets: config.targets || [],
80
- targetId: config.targetId,
81
- touched: false,
82
- pickerOpen: false,
83
-
84
- init() {
85
- this.paint();
86
- this.$watch("form", () => { this.touched = true; this.paint(); });
87
- this.$watch("targetId", () => this.paint());
88
- },
89
-
90
- target() {
91
- return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
92
- },
93
-
94
- firstName() {
95
- return (this.target().name || "").trim().split(/\s+/)[0] || "";
96
- },
97
-
98
- // {app} is substituted on both paths because it does not depend on the
99
- // recipient — a header reading "Welcome to {app}" must not show a raw
100
- // brace here while the inbox shows the app's name.
101
- resolve(template, fallback) {
102
- var first = this.firstName();
103
- var app = config.appName || "";
104
- var text = (template || "").replace(/\{app\}/g, app);
105
- if (first) return text.replace(/\{name\}/g, first);
106
- if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
107
- return text;
108
- },
109
-
110
- headerText() { return this.resolve(this.form.header, this.form.header_fallback); },
111
- subjectText() { return this.resolve(this.form.subject, this.form.subject); },
112
-
113
- // Field by field rather than JSON.stringify, whose output depends on key
114
- // order — two equal objects can stringify differently.
115
- //
116
- // No type coercion here, deliberately. An earlier version normalised
117
- // booleans on the theory that a checkbox holds true while the server
118
- // echoes "1"; a mutation run showed removing it changed nothing, because
119
- // the payload sends real booleans for the two checkboxes and strings for
120
- // everything else. Unproven defensive code in a comparison that decides
121
- // whether a Save button appears is worse than none.
122
- dirty() {
123
- return Object.keys(this.initial).some(function (k) {
124
- return (this.form[k] ?? "") !== (this.initial[k] ?? "");
125
- }, this);
126
- },
127
-
128
- // The logo has THREE states, and a URL field alone can only express two.
129
- // "standard" inherits, "custom" uses what this app uploaded, "hidden" is
130
- // the deliberate no-logo answer that blank cannot say.
131
- logoMode() {
132
- if (this.form.hide_logo === true || this.form.hide_logo === "1") return "hidden";
133
- if (config.uploadedLogo) return "custom";
134
- return "standard";
135
- },
136
-
137
- setLogoMode(mode) {
138
- this.form.hide_logo = mode === "hidden";
139
- },
140
-
141
- currentLogo() {
142
- if (this.logoMode() === "hidden") return "";
143
- return config.uploadedLogo || config.inheritedLogo || "";
144
- },
145
-
146
- // The slider's own fill, as a percentage string for --range-fill.
147
- scrimFill() {
148
- var percent = parseInt(this.form.scrim_percent, 10);
149
- if (isNaN(percent)) percent = config.defaultScrim;
150
- return Math.min(Math.max(percent, 0), 100) + "%";
151
- },
152
-
153
- scrimCss() {
154
- var percent = parseInt(this.form.scrim_percent, 10);
155
- if (isNaN(percent)) percent = config.defaultScrim;
156
- percent = Math.min(Math.max(percent, 0), 100);
157
- return "rgba(24,16,64," + (percent / 100) + ")";
158
- },
159
-
160
- paint() {
161
- var root = this.$root;
162
- var header = root.querySelector("[data-banner-header]");
163
- var subtext = root.querySelector("[data-banner-subtext]");
164
- var logo = root.querySelector("[data-banner-logo]");
165
- var scrim = root.querySelector("[data-banner-scrim]");
166
-
167
- if (header) header.textContent = this.headerText();
168
- if (subtext) subtext.textContent = this.form.subtext || "";
169
- if (scrim) scrim.style.backgroundColor = this.scrimCss();
170
- if (logo) {
171
- var url = this.currentLogo();
172
- if (url) {
173
- if (logo.getAttribute("src") !== url) logo.setAttribute("src", url);
174
- logo.style.display = "block";
175
- } else {
176
- logo.style.display = "none";
177
- }
178
- }
179
- },
180
-
181
- // Every visible field is x-model bound and carries no name; the hidden
182
- // form below mirrors them. One submit writes the whole page.
183
- save() {
184
- if (!this.dirty()) return;
185
- this.$refs.saveForm.requestSubmit();
186
- }
187
- };
188
- }
189
-
190
- window.emailBannerEditor = editor;
191
- if (typeof Alpine !== "undefined") {
192
- Alpine.data("emailBannerEditor", editor);
193
- } else {
194
- document.addEventListener("alpine:init", function () {
195
- Alpine.data("emailBannerEditor", editor);
196
- });
197
- }
198
- })();
199
- </script>
@@ -1,56 +1,5 @@
1
- <%# Scales the 600px email banner down to whatever width its frame has.
2
-
3
- Why this needs script at all: the banner is the EMAIL's own markup, a fixed
4
- 600px table, and it must stay that way — an inbox has no responsive layout,
5
- so making it fluid here would mean previewing markup no one receives. The
6
- frame beside it is fluid. Nothing in CSS converts a fixed-width box into a
7
- fluid one without knowing the container's width, so the width is read and a
8
- transform applied.
9
-
10
- What it does NOT do: move anything. It sets a scale on a box whose height is
11
- already reserved by `aspect-ratio`, so no reflow follows and there is no
12
- paint-time jump — the failure mode of measuring layout in script.
13
-
14
- Degrades to an unscaled, clipped preview if script never runs. That is the
15
- behaviour this replaced, so a failure here costs the improvement, not the page.
16
- %>
17
- <script>
18
- (function () {
19
- function fit(preview) {
20
- var frame = preview.parentElement;
21
- if (!frame) return;
22
- var natural = parseFloat(preview.dataset.bannerWidth) || 600;
23
- var available = frame.clientWidth;
24
- if (!available) return;
25
- // Never scale UP past the real size: at 1:1 this preview is exactly the
26
- // 600px the recipient sees, and blowing it past that would show the
27
- // operator a banner larger than any inbox renders.
28
- var scale = Math.min(available / natural, 1);
29
- preview.style.transform = "scale(" + scale + ")";
30
- }
31
-
32
- function fitAll() {
33
- document.querySelectorAll("[data-email-banner-preview]").forEach(fit);
34
- }
35
-
36
- // ResizeObserver rather than a resize listener: the frame changes width when
37
- // a sidebar opens or the grid reflows, neither of which resizes the window.
38
- var observer = typeof ResizeObserver === "function" ? new ResizeObserver(fitAll) : null;
39
-
40
- function start() {
41
- fitAll();
42
- if (!observer) return;
43
- observer.disconnect();
44
- document.querySelectorAll("[data-email-banner-frame]").forEach(function (frame) {
45
- observer.observe(frame);
46
- });
47
- }
48
-
49
- // turbo:load as well as DOMContentLoaded — a Turbo visit swaps the body
50
- // without firing DOMContentLoaded, which would leave the preview at its
51
- // unscaled 600px until a full reload.
52
- document.addEventListener("DOMContentLoaded", start);
53
- document.addEventListener("turbo:load", start);
54
- if (document.readyState !== "loading") start();
55
- })();
56
- </script>
1
+ <%# Scales the 600px email banner down to whatever width its frame has. The
2
+ fit is studio/email_banner_scale, started by the email-banner-scale
3
+ controller on this anchor. Render it once per page that shows a banner
4
+ preview. Without script the preview is unscaled and clipped. %>
5
+ <template data-studio-controller="email-banner-scale"></template>
@@ -147,7 +147,6 @@
147
147
  </div>
148
148
 
149
149
  <%= render "studio/emails/banner_scale" %>
150
- <%= render "studio/emails/recipient_repaint" %>
151
150
 
152
151
  <p class="text-xs text-muted mt-4">
153
152
  Banners render full-bleed at 600px wide inside the email card. The crop is
@@ -52,7 +52,7 @@
52
52
  </style>
53
53
 
54
54
  <div class="max-w-3xl mx-auto"
55
- data-geo-page
55
+ data-geo-page data-studio-controller="geo-settings"
56
56
  data-geo-home="<%= home_country %>"
57
57
  data-geo-country="<%= geo_country %>"
58
58
  data-geo-subdivision="<%= geo_state %>"
@@ -264,102 +264,6 @@
264
264
  <% end %>
265
265
  </div>
266
266
 
267
- <script>
268
- // LIVE PREVIEW. Tick your own region and the navbar badge (and the Blocked?
269
- // tile) answer immediately, before anything is saved — so an operator can see
270
- // what a rule will do to a real visitor rather than saving to find out.
271
- //
272
- // This mirrors Studio::Geo.blocked? on the client, deliberately and narrowly:
273
- // same three rules, same order, same fail-closed narrowing. It decides nothing
274
- // — the server re-decides on save and on every request afterwards — but a
275
- // mirror can still drift, so turf-monster's browser lane asserts the painted
276
- // result against a policy the server also enforces.
277
- (function () {
278
- var page = document.querySelector("[data-geo-page]");
279
- if (!page) return;
280
-
281
- var home = page.dataset.geoHome;
282
- var country = page.dataset.geoCountry;
283
- var subdivision = page.dataset.geoSubdivision;
284
- var failClosed = page.dataset.geoFailClosed === "true";
285
- var enabledBox = page.querySelector('input[type="checkbox"][name="geo_setting[enabled]"]');
286
-
287
- function checkedValues(selector) {
288
- return Array.prototype.map.call(page.querySelectorAll(selector + ":checked"), function (el) {
289
- return el.value;
290
- });
291
- }
292
-
293
- function blocked() {
294
- if (!enabledBox || !enabledBox.checked) return false;
295
-
296
- var countries = checkedValues('input[name="geo_setting[banned_countries][]"]');
297
- if (country && countries.indexOf(country) !== -1) return true;
298
-
299
- var subdivisions = checkedValues('input[name="geo_setting[banned_subdivisions][]"]');
300
- if (subdivision && country === home && subdivisions.indexOf(subdivision) !== -1) return true;
301
-
302
- // Fail closed, narrowed exactly as the server narrows it: only a
303
- // home-country visitor with NO region, and only when region rules exist.
304
- if (!failClosed) return false;
305
- if (subdivision) return false;
306
- if (country !== home) return false;
307
- return subdivisions.length > 0;
308
- }
309
-
310
- // The summary chips are rebuilt from the editor's own squares, so they
311
- // describe the policy you are ABOUT to save rather than the one on disk —
312
- // and they are built from the square's own flag, so there is no second copy
313
- // of the artwork to keep in step.
314
- function refreshSummary(kind, selector) {
315
- var row = page.querySelector('[data-geo-summary="' + kind + '"]');
316
- var counts = page.querySelectorAll('[data-geo-summary-count="' + kind + '"]');
317
- if (!row) return;
318
-
319
- Array.prototype.forEach.call(row.querySelectorAll(".geo-chip"), function (chip) {
320
- chip.remove();
321
- });
322
-
323
- var boxes = Array.prototype.filter.call(page.querySelectorAll(selector), function (box) {
324
- return box.checked;
325
- });
326
- boxes.sort(function (a, b) { return a.value.localeCompare(b.value); });
327
-
328
- boxes.forEach(function (box) {
329
- var square = box.closest("label");
330
- var chip = document.createElement("span");
331
- chip.className = "geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono";
332
- var art = square && square.querySelector("img, span[role='img']");
333
- if (art) chip.appendChild(art.cloneNode(true));
334
- chip.appendChild(document.createTextNode(box.value));
335
- row.insertBefore(chip, row.querySelector(".geo-summary-empty"));
336
- });
337
-
338
- // The tab label and the summary heading carry the same number; a page that
339
- // showed two different counts for one list would be its own bug report.
340
- Array.prototype.forEach.call(counts, function (el) { el.textContent = boxes.length; });
341
- }
342
-
343
- function paint() {
344
- refreshSummary("states", 'input[name="geo_setting[banned_subdivisions][]"]');
345
- refreshSummary("countries", 'input[name="geo_setting[banned_countries][]"]');
346
-
347
- var value = blocked() ? "blocked" : "allowed";
348
- document.documentElement.setAttribute("data-geo-preview", value);
349
- Array.prototype.forEach.call(document.querySelectorAll("[data-geo-badge]"), function (badge) {
350
- // Only the badge showing THIS visitor's location previews; a specimen
351
- // badge rendered with other locals keeps whatever it was given.
352
- if (badge.dataset.country === country && (badge.dataset.subdivision || "") === (subdivision || "")) {
353
- badge.setAttribute("data-blocked", blocked() ? "true" : "false");
354
- }
355
- });
356
- }
357
-
358
- page.addEventListener("change", paint);
359
- paint();
360
- })();
361
- </script>
362
-
363
267
  <style>
364
268
  /* The verdict tile follows the same one attribute the badge does. */
365
269
  .geo-when-blocked, .geo-when-allowed { display: none; }
@@ -1,15 +1,17 @@
1
- <%# One live map of an address. Leaflet mounts on this element (see
2
- studio/site_footer/_map_assets, which this partial and nothing else pulls
3
- in); until it does, and if it never does, the link inside is the whole map:
4
- an address that opens directions. `classes` and `style` size and shape it.
1
+ <%# One live map of an address. Leaflet mounts on this element (studio/footer_map,
2
+ started by the footer-map controller it names); until it does, and if it
3
+ never does, the link inside is the whole map: an address that opens
4
+ directions. `classes` and `style` size and shape it. The styles are
5
+ studio/site_footer/_map_assets, which this partial and nothing else pulls in.
5
6
 
6
7
  Leaflet's two files ride the engine's asset pipeline (studio/leaflet.js,
7
- studio/leaflet.css). Their URLs travel on the element, so the script fetches
8
+ studio/leaflet.css). Their URLs travel on the element, so the module fetches
8
9
  them only on a page that has a map. %>
9
10
  <%= studio_footer_map_assets %>
10
11
  <div class="ftr-map <%= local_assigns[:classes] %>"
11
12
  <% if local_assigns[:style].present? %>style="<%= style %>"<% end %>
12
13
  data-footer-map
14
+ data-studio-controller="footer-map"
13
15
  data-lat="<%= address[:lat] %>" data-lng="<%= address[:lng] %>"
14
16
  data-zoom="<%= local_assigns[:zoom] || address[:zoom] %>"
15
17
  data-controls="<%= local_assigns.fetch(:controls, true) %>"
@@ -1,7 +1,8 @@
1
- <%# The map's styles and its mount script. Rendered once per page, by
2
- `studio_footer_map_assets`, which only studio/site_footer/_map calls: a page
3
- with no map (a footer with no address) carries none of this and names no
4
- Leaflet. The footer's own styles are studio/site_footer/_assets. %>
1
+ <%# The map's styles. Rendered once per page, by `studio_footer_map_assets`,
2
+ which only studio/site_footer/_map calls: a page with no map (a footer with
3
+ no address) carries none of this and names no Leaflet. The mount is
4
+ studio/footer_map, started by the footer-map controller on the map element.
5
+ The footer's own styles are studio/site_footer/_assets. %>
5
6
  <style>
6
7
  /* The map. isolation keeps Leaflet's internal z-indexes (up to 1000) under
7
8
  the pinned navbar and every modal. It reads the same properties outside
@@ -30,128 +31,3 @@
30
31
  @keyframes ftr-ping { 75%, 100% { transform: scale(3.2); opacity: 0; } }
31
32
  @media (prefers-reduced-motion: reduce) { .ftr-pin::before { animation: none; } }
32
33
  </style>
33
-
34
- <script>
35
- // Mounts a Leaflet map on every [data-footer-map]. Leaflet (script and
36
- // stylesheet) is fetched once, on demand, from the URLs the element carries,
37
- // so a page with no map pays nothing for it. The basemap follows the site
38
- // theme in CSS (see .dark .leaflet-tile-pane above). Tiles are OpenStreetMap's
39
- // keyless ones; a basemap that needs an API key does not belong in a gem.
40
- //
41
- // NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
42
- // THE VIEWPORT. The tiles are a third party's, and a request that starts
43
- // before `load` holds that event open for as long as the third party takes to
44
- // answer. The map is also at the bottom of every public page, where most
45
- // visits never reach: those pay for neither Leaflet nor a single tile.
46
- //
47
- // THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
48
- // app that still renders a local footer script (mcritchie-studio, until it
49
- // adopts this one) guards it with the unprefixed name and marks its map
50
- // [data-footer-map] too. Sharing either would let whichever script ran first
51
- // in a Turbo session stand the other down and then fail on its elements: that
52
- // app's map carries no data-leaflet-js, so there is nothing here to fetch.
53
- (function () {
54
- if (window.__studioFooterMapsArmed) return;
55
- window.__studioFooterMapsArmed = true;
56
-
57
- var TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
58
- var ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
59
- var maps = [];
60
- var loading = null;
61
-
62
- // Checked on every mount, not once: a page visit that rebuilds the head
63
- // must not leave a mounted map without its stylesheet.
64
- function ensureStylesheet(href) {
65
- if (!href || document.querySelector('link[data-studio-leaflet]')) return;
66
- var link = document.createElement('link');
67
- link.rel = 'stylesheet';
68
- link.href = href;
69
- link.setAttribute('data-studio-leaflet', '');
70
- document.head.appendChild(link);
71
- }
72
-
73
- function loadLeaflet(src) {
74
- if (window.L) return Promise.resolve();
75
- if (loading) return loading;
76
- loading = new Promise(function (resolve, reject) {
77
- var s = document.createElement('script');
78
- s.src = src;
79
- s.onload = resolve;
80
- // A failed fetch must not be remembered: the next visit tries again.
81
- s.onerror = function () { loading = null; s.remove(); reject(new Error('leaflet failed to load')); };
82
- document.head.appendChild(s);
83
- });
84
- return loading;
85
- }
86
-
87
- function mount(el) {
88
- if (el.__footerMap) return;
89
- var at = [parseFloat(el.dataset.lat), parseFloat(el.dataset.lng)];
90
- if (isNaN(at[0]) || isNaN(at[1])) return;
91
- // NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
92
- // phone a swipe that starts on it would pan the map instead of scrolling
93
- // the page, and there is no gutter to scroll by. With dragging off Leaflet
94
- // leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
95
- // pinch still zooms the map (touchZoom stays on, and moves it under the
96
- // fingers). The zoom buttons and the directions link do the rest.
97
- var map = L.map(el, { center: at, zoom: parseInt(el.dataset.zoom, 10) || 15,
98
- scrollWheelZoom: false, attributionControl: true,
99
- dragging: !L.Browser.mobile, touchZoom: true,
100
- zoomControl: el.dataset.controls !== 'false' });
101
- map.attributionControl.setPrefix(false);
102
- L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map);
103
- L.marker(at, { icon: L.divIcon({ className: '', html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
104
- keyboard: false }).addTo(map);
105
- // Page scroll stays page scroll until the visitor commits to the map.
106
- map.on('click', function () { map.scrollWheelZoom.enable(); });
107
- map.on('mouseout', function () { map.scrollWheelZoom.disable(); });
108
- var fallback = el.querySelector('.ftr-map-fallback');
109
- if (fallback) { el.__footerFallback = fallback; fallback.remove(); }
110
- el.__footerMap = map;
111
- maps.push(el);
112
- }
113
-
114
- function fetchAndMount(el) {
115
- ensureStylesheet(el.dataset.leafletCss);
116
- loadLeaflet(el.dataset.leafletJs)
117
- .then(function () { if (el.isConnected) mount(el); })
118
- .catch(function () { /* the fallback link stays */ });
119
- }
120
-
121
- // One observer per map: it fires once, when the map comes within 400px of
122
- // the viewport, and is then dropped.
123
- function watch(el) {
124
- if (el.__footerMap || el.__footerWatch) return;
125
- if (typeof IntersectionObserver !== 'function') { fetchAndMount(el); return; }
126
- var watcher = new IntersectionObserver(function (entries) {
127
- if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
128
- watcher.disconnect();
129
- el.__footerWatch = null;
130
- fetchAndMount(el);
131
- }, { rootMargin: '400px' });
132
- el.__footerWatch = watcher;
133
- watcher.observe(el);
134
- }
135
-
136
- function armAll() { document.querySelectorAll('[data-footer-map][data-leaflet-js]').forEach(watch); }
137
-
138
- function armAfterLoad() {
139
- if (document.readyState === 'complete') armAll();
140
- else window.addEventListener('load', armAll, { once: true });
141
- }
142
-
143
- // A cached Turbo snapshot would restore Leaflet's DOM without its state, so
144
- // the map is taken down and its fallback link put back before the snapshot.
145
- document.addEventListener('turbo:before-cache', function () {
146
- maps.forEach(function (el) {
147
- var fallback = el.__footerFallback;
148
- el.__footerMap.remove();
149
- el.__footerMap = null;
150
- if (fallback && !el.querySelector('.ftr-map-fallback')) el.appendChild(fallback);
151
- });
152
- maps = [];
153
- });
154
- document.addEventListener('turbo:load', armAfterLoad);
155
- armAfterLoad();
156
- })();
157
- </script>
@@ -59,7 +59,7 @@
59
59
  Alpine scope of its own, so without one here the crop modal's store opens
60
60
  and its dialog never appears in a host whose body carries no x-data
61
61
  (e2e/site_identity.spec.js). %>
62
- <div class="max-w-3xl mx-auto" x-data data-link-preview-page
62
+ <div class="max-w-3xl mx-auto" x-data data-link-preview-page data-studio-controller="link-preview-card"
63
63
  data-fallback-title="<%= fallback_title %>"
64
64
  data-fallback-description="<%= fallback_desc %>">
65
65
  <h1 class="text-2xl font-extrabold text-heading mb-2">Link Preview</h1>
@@ -208,26 +208,3 @@
208
208
  <% end %>
209
209
  <% end %>
210
210
  </div>
211
-
212
- <script>
213
- // LIVE CARD. Typing in the title or description repaints the card before
214
- // anything is saved, falling back to what a blank field would really send.
215
- (function () {
216
- var page = document.querySelector("[data-link-preview-page]");
217
- if (!page) return;
218
- var fallbacks = {
219
- title: page.dataset.fallbackTitle || "",
220
- description: page.dataset.fallbackDescription || ""
221
- };
222
- function paint(input) {
223
- var key = input.dataset.linkPreviewInput;
224
- var target = page.querySelector("[data-link-preview-card-" + key + "]");
225
- if (!target) return;
226
- var value = input.value.trim();
227
- target.textContent = value || fallbacks[key];
228
- }
229
- page.querySelectorAll("[data-link-preview-input]").forEach(function (input) {
230
- input.addEventListener("input", function () { paint(input); });
231
- });
232
- })();
233
- </script>
@@ -1,8 +1,9 @@
1
1
  <%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
2
2
 
3
3
  ONE FORM, TWO PRESENTATIONS. Without JavaScript every question renders in
4
- order under the title and intro, and the Submit button posts the lot. The
5
- script below adds .is-enhanced and turns the same markup into one question
4
+ order under the title and intro, and the Submit button posts the lot.
5
+ studio/survey (the survey controller on the root) adds .is-enhanced and
6
+ turns the same markup into one question
6
7
  per screen, opening on question 1 with no intro screen, with a progress
7
8
  bar, Back (from question 2 on) and Next, number-key and Enter shortcuts, and an
8
9
  autosave on each answer. Submit is still the same POST either way, so the
@@ -12,7 +13,7 @@
12
13
  <% content_for(:title, @survey.title) %>
13
14
  <%= render "studio/surveys/styles" %>
14
15
 
15
- <main class="studio-survey" data-studio-survey
16
+ <main class="studio-survey" data-studio-survey data-studio-controller="survey"
16
17
  data-answer-base="<%= studio_survey_path(@survey.slug) %>/answers/"
17
18
  data-csrf="<%= form_authenticity_token %>"
18
19
  data-resume-index="<%= @resume_index %>"
@@ -61,5 +62,3 @@
61
62
  <% end %>
62
63
  </div>
63
64
  </main>
64
-
65
- <%= render "studio/surveys/script" %>
@@ -3,7 +3,7 @@
3
3
  The shared board primitive, LIVE. Phase D lifted the columns / cards / drag +
4
4
  optimistic move / reorder that the three near-identical McRitchie Studio boards
5
5
  (tasks / news / content) each hand-rolled into one engine primitive
6
- (studio/board/_board + the window.studioBoard factory + vendored SortableJS).
6
+ (studio/board/_board + the studio/board module + vendored SortableJS).
7
7
  The first block below renders that real primitive with `demo: true` — you can
8
8
  DRAG a card between columns and the move animates + toasts, but nothing POSTs.
9
9
  The blocks under it stay a static reference: the stage-* badge palette
@@ -32,10 +32,7 @@
32
32
  # the last expression of an ERB scriptlet trips Erubi, and literal ERB tags in the
33
33
  # body would close the tag. Plain render() calls read clearly for an agent.
34
34
  board_usage_snippet = [
35
- "# once at layout level (a <script> in the board template never runs):",
36
- 'render "studio/board_assets"',
37
- "",
38
- "# then the board itself:",
35
+ "# the board; it needs nothing else on the page:",
39
36
  'render "studio/board/board",',
40
37
  " columns: @columns, # [{ key:, label:, cards:, badge_class: }]",
41
38
  ' card_partial: "tasks/task_card",',
@@ -80,9 +77,7 @@
80
77
  </div>
81
78
 
82
79
  <%# ---- The LIVE primitive (demo mode) --------------------------------- %>
83
- <%# Render the factory once (a <script> in the board template would never run), then
84
- the board. demo: true → drag animates + toasts locally, nothing POSTs. %>
85
- <%= render "studio/board_assets" %>
80
+ <%# demo: true → drag animates + toasts locally, nothing POSTs. %>
86
81
  <div class="space-y-3">
87
82
  <div class="flex items-center gap-2">
88
83
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>