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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -0,0 +1,316 @@
1
+ // studio/survey: the survey stepper, a progressive enhancement over
2
+ // studio/surveys/show. It adds .is-enhanced and turns the page's own markup
3
+ // into one question at a time, with autosave. Plain DOM, no Alpine.
4
+ //
5
+ // IDEMPOTENT PER ROOT, so a second connect never double-binds. Bound roots are
6
+ // tracked in a WeakSet, never in a DOM attribute: Turbo's snapshot cache clones
7
+ // the page, and a clone keeps every attribute but none of the listeners, so a
8
+ // marker in the DOM would make a restored page look set up while it is dead.
9
+ //
10
+ // AUTOSAVE IS BEST EFFORT: the final Submit posts the whole form, so a failed
11
+ // autosave loses nothing. Without this module the page is the plain form, every
12
+ // question on one page, and it submits the same answers.
13
+ //
14
+ // It imports nothing; test/javascript/survey.test.mjs loads it as a data:
15
+ // module. Bound to the page by the survey controller.
16
+
17
+ var bound = new WeakSet();
18
+
19
+ export function typeOf(step) { return step.dataset.type || ""; }
20
+ export function isChoice(step) { return /^(emoji_scale|rating|choice)$/.test(typeOf(step)); }
21
+ export function isMulti(step) { return typeOf(step) === "multi_choice"; }
22
+ export function inputs(step) { return Array.prototype.slice.call(step.querySelectorAll("input.studio-survey__input")); }
23
+
24
+ // A step's answer: the checked values of a multi-choice, the checked value of
25
+ // a choice or scale, or the trimmed text. Blank is "" (or [] for a
26
+ // multi-choice).
27
+ export function valueOf(step) {
28
+ if (isMulti(step)) return inputs(step).filter(function (i) { return i.checked; }).map(function (i) { return i.value; });
29
+ if (isChoice(step)) {
30
+ var picked = inputs(step).filter(function (i) { return i.checked; })[0];
31
+ return picked ? picked.value : "";
32
+ }
33
+ var field = step.querySelector("[data-survey-text]");
34
+ return field ? field.value.trim() : "";
35
+ }
36
+
37
+ export function answered(step) {
38
+ var v = valueOf(step);
39
+ return Array.isArray(v) ? v.length > 0 : v !== "";
40
+ }
41
+
42
+ // The progress bar's width for question `position` of `total`.
43
+ export function progressWidth(position, total) {
44
+ return (total ? (position / total) * 100 : 0) + "%";
45
+ }
46
+
47
+ // Where the stepper opens. A fresh visitor lands on question 1; a returning
48
+ // one on their next question (resume-index is the question's own index, and
49
+ // every step is a question); a page restored from Turbo's cache on the step it
50
+ // was left on (`restored`, -1 when it was not restored).
51
+ export function startIndex(resume, total, restored) {
52
+ var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10);
53
+ if (!(start >= 0 && start < total)) start = 0;
54
+ if (restored >= 0) start = restored;
55
+ return start;
56
+ }
57
+
58
+ // Enhances one [data-studio-survey] root. Answers false for a root it has
59
+ // already bound.
60
+ export function enhanceSurvey(root, win = window, doc = document) {
61
+ if (bound.has(root)) return false;
62
+ bound.add(root);
63
+
64
+ var form = root.querySelector("[data-survey-form]");
65
+ var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
66
+ // Every step is a question: the survey opens on question 1, no intro screen.
67
+ var total = steps.length;
68
+ var head = root.querySelector("[data-survey-head]");
69
+ var back = root.querySelector("[data-survey-back]");
70
+ var next = root.querySelector("[data-survey-next]");
71
+ var submit = root.querySelector("[data-survey-submit]");
72
+ var top = root.querySelector("[data-survey-top]");
73
+ var bar = root.querySelector("[data-survey-progress]");
74
+ var fill = root.querySelector("[data-survey-progress-fill]");
75
+ var count = root.querySelector("[data-survey-count]");
76
+ var status = root.querySelector("[data-survey-status]");
77
+ var hint = root.querySelector("[data-survey-hint]");
78
+ // A page restored from Turbo's cache comes back on the step it was left
79
+ // on; resume there rather than at the server's original resume point.
80
+ var restored = -1;
81
+ // The GLOBAL token, not the form's: with per_form_csrf_tokens on, the
82
+ // form's hidden token is bound to the POST and refused on the PATCH.
83
+ var csrf = root.dataset.csrf || "";
84
+ var reduce = win.matchMedia && win.matchMedia("(prefers-reduced-motion: reduce)").matches;
85
+ var saved = {};
86
+ var timers = {};
87
+ var current = -1;
88
+ var pointerPick = false;
89
+ var submitting = false;
90
+
91
+ form.noValidate = true;
92
+ if (root.classList.contains("is-enhanced")) {
93
+ Array.prototype.forEach.call(root.querySelectorAll("[data-survey-step]"), function (step, i) {
94
+ if (step.classList.contains("is-active")) restored = i;
95
+ });
96
+ }
97
+ root.classList.add("is-enhanced");
98
+ top.hidden = false;
99
+
100
+ function setError(step, message) {
101
+ var el = step.querySelector("[data-survey-error]");
102
+ if (!el) return;
103
+ el.textContent = message || "";
104
+ el.hidden = !message;
105
+ step.classList.toggle("is-invalid", !!message);
106
+ }
107
+
108
+ function save(step) {
109
+ var key = step.dataset.key;
110
+ if (!key || submitting) return Promise.resolve(true);
111
+ var value = valueOf(step);
112
+ var fingerprint = JSON.stringify(value);
113
+ if (saved[key] === fingerprint) return Promise.resolve(true);
114
+
115
+ var body = new win.FormData();
116
+ if (Array.isArray(value)) {
117
+ if (value.length === 0) body.append("value[]", "");
118
+ value.forEach(function (v) { body.append("value[]", v); });
119
+ } else {
120
+ body.append("value", value);
121
+ }
122
+ var url = root.dataset.answerBase + encodeURIComponent(key);
123
+ status.textContent = "Saving…";
124
+ return win.fetch(url, {
125
+ method: "PATCH",
126
+ body: body,
127
+ credentials: "same-origin",
128
+ headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
129
+ }).then(function (res) {
130
+ // fetch resolves on a 4xx/5xx — the status is the only signal.
131
+ if (!res.ok) {
132
+ return res.json().catch(function () { return {}; }).then(function (data) {
133
+ if (res.status === 422 && data.error) setError(step, data.error);
134
+ status.textContent = "Not saved yet — your answers still go in when you submit.";
135
+ return false;
136
+ });
137
+ }
138
+ saved[key] = fingerprint;
139
+ status.textContent = "Saved";
140
+ return true;
141
+ }).catch(function () {
142
+ status.textContent = "Offline? Your answers still go in when you submit.";
143
+ return false;
144
+ });
145
+ }
146
+
147
+ function render(index, direction) {
148
+ steps.forEach(function (step, i) {
149
+ var active = i === index;
150
+ step.hidden = !active;
151
+ step.classList.toggle("is-active", active);
152
+ if (active) {
153
+ if (direction && !reduce) step.dataset.dir = direction; else delete step.dataset.dir;
154
+ }
155
+ });
156
+ current = index;
157
+ var first = index === 0;
158
+ var last = index === steps.length - 1;
159
+ // Nothing comes before question 1, so it has no Back. Next keeps the
160
+ // right edge on its own (margin-left: auto), so the row does not shift.
161
+ back.hidden = first;
162
+ next.hidden = last;
163
+ submit.hidden = !last;
164
+ // The title and intro lead question 1 only; later screens show the
165
+ // question alone. The head steps into a visual clip rather than
166
+ // `hidden`, so the page keeps its h1 for a screen reader on every step.
167
+ if (head) head.classList.toggle("studio-survey__head--later", !first);
168
+ // The number-key shortcut only means something on a choice or scale.
169
+ var step = steps[index];
170
+ hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
171
+
172
+ var position = index + 1;
173
+ fill.style.width = progressWidth(position, total);
174
+ bar.setAttribute("aria-valuenow", String(position));
175
+ count.textContent = "Question " + position + " of " + total;
176
+
177
+ var focusTarget = steps[index].querySelector("[data-survey-focus]");
178
+ if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
179
+ if (direction) win.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
180
+ }
181
+
182
+ function go(index) {
183
+ if (index < 0 || index >= steps.length || index === current) return;
184
+ render(index, index > current ? "fwd" : "back");
185
+ }
186
+
187
+ function forward() {
188
+ var step = steps[current];
189
+ if (step.dataset.key) {
190
+ if (step.dataset.required === "true" && !answered(step)) {
191
+ setError(step, "This one is required.");
192
+ var first = step.querySelector("input:not([type=hidden]), textarea");
193
+ if (first) first.focus();
194
+ return;
195
+ }
196
+ setError(step, "");
197
+ }
198
+ if (current === steps.length - 1) {
199
+ // No autosave here: the POST carries every answer, and a PATCH racing
200
+ // it would land after completion and be refused.
201
+ submitForm();
202
+ } else {
203
+ if (step.dataset.key) save(step);
204
+ go(current + 1);
205
+ }
206
+ }
207
+
208
+ function submitForm() {
209
+ if (form.requestSubmit) form.requestSubmit(submit); else form.submit();
210
+ }
211
+
212
+ // The first required question still missing an answer, for a Submit that
213
+ // jumps ahead (Enter on the last step, or a mid-survey reload).
214
+ form.addEventListener("submit", function (event) {
215
+ for (var i = 0; i < steps.length; i++) {
216
+ if (steps[i].dataset.required === "true" && !answered(steps[i])) {
217
+ event.preventDefault();
218
+ go(i);
219
+ setError(steps[i], "This one is required.");
220
+ return;
221
+ }
222
+ }
223
+ Object.keys(timers).forEach(function (key) { win.clearTimeout(timers[key]); });
224
+ submitting = true;
225
+ submit.disabled = true;
226
+ submit.textContent = "Sending…";
227
+ });
228
+
229
+ back.addEventListener("click", function () { go(current - 1); });
230
+ next.addEventListener("click", forward);
231
+
232
+ root.addEventListener("pointerdown", function (event) {
233
+ pointerPick = !!event.target.closest("[data-survey-choice]");
234
+ });
235
+
236
+ root.addEventListener("change", function (event) {
237
+ var step = event.target.closest("[data-survey-step]");
238
+ if (!step || !step.dataset.key || step.hidden) return;
239
+ setError(step, "");
240
+ if (isChoice(step)) {
241
+ save(step);
242
+ // Advance on a tap or a number key, never on arrow keys: arrows move
243
+ // through a radio group and must not jump the reader off the question.
244
+ if (pointerPick) {
245
+ pointerPick = false;
246
+ win.setTimeout(function () { if (steps[current] === step) forward(); }, reduce ? 0 : 260);
247
+ }
248
+ } else if (isMulti(step)) {
249
+ save(step);
250
+ }
251
+ });
252
+
253
+ root.addEventListener("input", function (event) {
254
+ var field = event.target.closest("[data-survey-text]");
255
+ if (!field) return;
256
+ var step = field.closest("[data-survey-step]");
257
+ var counter = step.querySelector("[data-survey-counter]");
258
+ if (counter) counter.textContent = String(field.value.length);
259
+ win.clearTimeout(timers[step.dataset.key]);
260
+ timers[step.dataset.key] = win.setTimeout(function () { save(step); }, 900);
261
+ });
262
+
263
+ function onKey(event) {
264
+ if (event.defaultPrevented || event.altKey) return;
265
+ // A tap on an already-chosen option fires pointerdown but no change, so
266
+ // the flag would outlive it and make the next arrow key advance.
267
+ pointerPick = false;
268
+ var step = steps[current];
269
+ var target = event.target;
270
+ var inText = target.matches && target.matches("textarea, input[type=text]");
271
+
272
+ if (event.key === "Enter") {
273
+ if (target.tagName === "BUTTON" || target.tagName === "A") return;
274
+ if (target.tagName === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return;
275
+ event.preventDefault();
276
+ forward();
277
+ return;
278
+ }
279
+
280
+ if (inText || event.metaKey || event.ctrlKey || !/^[1-9]$/.test(event.key)) return;
281
+ if (!step.dataset.key || !(isChoice(step) || isMulti(step))) return;
282
+ var option = inputs(step)[parseInt(event.key, 10) - 1];
283
+ if (!option) return;
284
+ event.preventDefault();
285
+ option.checked = isMulti(step) ? !option.checked : true;
286
+ option.focus({ preventScroll: true });
287
+ pointerPick = isChoice(step);
288
+ option.dispatchEvent(new win.Event("change", { bubbles: true }));
289
+ }
290
+
291
+ root.addEventListener("keydown", onKey);
292
+ // The survey opens on question 1 with nothing focused, so a first key
293
+ // lands on <body>, outside the root. Take those keys too, and only those:
294
+ // a key aimed at any other control on the page is not the survey's. The
295
+ // listener retires itself once a Turbo visit has taken this root away.
296
+ function onPageKey(event) {
297
+ if (!root.isConnected) { doc.removeEventListener("keydown", onPageKey); return; }
298
+ var target = event.target;
299
+ if (target === doc.body || target === doc.documentElement) onKey(event);
300
+ }
301
+ doc.addEventListener("keydown", onPageKey);
302
+
303
+ var hasErrors = root.dataset.hasErrors === "true";
304
+ // A failed submit lands on the first question it flagged: the server sets
305
+ // resume-index to it.
306
+ var start = startIndex(root.dataset.resumeIndex, steps.length, restored);
307
+ render(start, null);
308
+ if (restored < 0 && (start > 0 || hasErrors)) {
309
+ var focusTarget = steps[current].querySelector("[data-survey-focus]");
310
+ if (focusTarget) focusTarget.focus({ preventScroll: true });
311
+ if (start > 0 && !hasErrors) status.textContent = "Welcome back — picking up where you left off.";
312
+ }
313
+ // Answers already on the page are already stored.
314
+ steps.forEach(function (step) { if (step.dataset.key) saved[step.dataset.key] = JSON.stringify(valueOf(step)); });
315
+ return true;
316
+ }
@@ -0,0 +1,197 @@
1
+ // studio/theme_editor: the two live-preview theme editors, as the Alpine
2
+ // factories their templates bind.
3
+ //
4
+ // themeEditor(colors) theme_settings/edit, the standalone /admin/theme page
5
+ // dsThemeEditor(seed) style/_theme, the Theme section of the style guide
6
+ //
7
+ // Both are seeded with the server-resolved role colours and write the themeable
8
+ // role variables on the document as the operator types, so every specimen on
9
+ // the page restyles at once. The inputs still carry the real values.
10
+ //
11
+ // studio/alpine_shims publishes both as window globals before Alpine starts,
12
+ // because x-data="themeEditor(...)" is evaluated the moment Alpine does.
13
+ //
14
+ // It imports nothing; test/javascript/theme_editor.test.mjs loads it as a
15
+ // data: module.
16
+
17
+ // The seven roles the standalone editor edits, in the order it lists them.
18
+ export const ROLES = ["primary", "success", "accent", "warning", "danger", "dark", "light"]
19
+
20
+ // A typed colour as #rrggbb: a missing # is added and a 3-digit hex is doubled.
21
+ // Answers null for anything that is not a 3- or 6-digit hex.
22
+ export function normalizeHex(value) {
23
+ let hex = String(value == null ? "" : value).trim()
24
+ if (!hex.startsWith("#")) hex = "#" + hex
25
+ if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
26
+ hex = "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
27
+ }
28
+ return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : null
29
+ }
30
+
31
+ // `hex` (#rrggbb) moved `amount` (0..1) of the way to white.
32
+ export function lightenColor(hex, amount) {
33
+ hex = hex.replace("#", "")
34
+ const r = parseInt(hex.substring(0, 2), 16)
35
+ const g = parseInt(hex.substring(2, 4), 16)
36
+ const b = parseInt(hex.substring(4, 6), 16)
37
+ const nr = Math.round(r + (255 - r) * amount)
38
+ const ng = Math.round(g + (255 - g) * amount)
39
+ const nb = Math.round(b + (255 - b) * amount)
40
+ return "#" + [nr, ng, nb].map((c) => c.toString(16).padStart(2, "0")).join("")
41
+ }
42
+
43
+ // The standalone editor's colours: every role present, blank when the server
44
+ // resolved none.
45
+ export function seededColors(seed) {
46
+ const colors = {}
47
+ ROLES.forEach((role) => { colors[role] = (seed && seed[role]) || "" })
48
+ return colors
49
+ }
50
+
51
+ // What the standalone editor writes on the root for `colors`, as
52
+ // [property, value] pairs; a null value removes the property.
53
+ //
54
+ // btn-warning and btn-danger paint --color-<role>-fill first (the resolver
55
+ // emits it for the engine defaults), so a typed colour sets it too or the
56
+ // specimens keep the default fill. A cleared colour drops the override.
57
+ export function previewProperties(colors) {
58
+ const properties = [
59
+ ["--color-cta", colors.primary],
60
+ ["--color-success", colors.success],
61
+ ["--color-warning", colors.warning || "#FF7C47"],
62
+ ["--color-danger", colors.danger || "#EF4444"]
63
+ ]
64
+ for (const role of ["warning", "danger"]) {
65
+ properties.push([`--color-${role}-fill`, colors[role] ? colors[role] : null])
66
+ }
67
+ return properties
68
+ }
69
+
70
+ // What the style guide's editor writes on the root for `colors`. It sets only
71
+ // what has a value and removes nothing.
72
+ export function stylePreviewProperties(colors) {
73
+ const properties = [
74
+ ["--color-cta", colors.primary],
75
+ ["--color-cta-fill", colors.primary]
76
+ ]
77
+ if (colors.success) properties.push(["--color-success", colors.success])
78
+ if (colors.warning) properties.push(["--color-warning", colors.warning], ["--color-warning-fill", colors.warning])
79
+ if (colors.danger) properties.push(["--color-danger", colors.danger], ["--color-danger-fill", colors.danger])
80
+ return properties
81
+ }
82
+
83
+ function applyProperties(style, properties) {
84
+ for (const [name, value] of properties) {
85
+ if (value === null) style.removeProperty(name)
86
+ else style.setProperty(name, value)
87
+ }
88
+ }
89
+
90
+ // x-data="themeEditor({...})": theme_settings/edit.
91
+ export function themeEditor(seed) {
92
+ return {
93
+ colors: seededColors(seed),
94
+
95
+ resetColor(role, defaultVal) {
96
+ this.colors[role] = defaultVal
97
+ this.updatePreview()
98
+ },
99
+
100
+ normalizeHex(role) {
101
+ const hex = normalizeHex(this.colors[role])
102
+ if (hex) this.colors[role] = hex
103
+ this.updatePreview()
104
+ },
105
+
106
+ // Live CSS variables on the document, for immediate feedback.
107
+ updatePreview() {
108
+ applyProperties(document.documentElement.style, previewProperties(this.colors))
109
+ },
110
+
111
+ darkPreviewStyle() {
112
+ return { backgroundColor: this.colors.dark, borderRadius: "0" }
113
+ },
114
+
115
+ darkSurfaceStyle() {
116
+ return {
117
+ backgroundColor: this.lightenColor(this.colors.dark, 0.3),
118
+ border: "1px solid rgba(255,255,255,0.1)"
119
+ }
120
+ },
121
+
122
+ lightPreviewStyle() {
123
+ return { backgroundColor: this.colors.light, borderRadius: "0" }
124
+ },
125
+
126
+ lightSurfaceStyle() {
127
+ return { backgroundColor: "#ffffff", border: "1px solid #e2e8f0" }
128
+ },
129
+
130
+ lightenColor(hex, amount) {
131
+ return lightenColor(hex, amount)
132
+ }
133
+ }
134
+ }
135
+
136
+ // The method a form really sends, and its body without Rails' _method override.
137
+ // The editor's form carries _method=patch, and sending the real method means
138
+ // nothing depends on Rack::MethodOverride parsing a multipart fetch body.
139
+ //
140
+ // THE REDIRECT IS NOT FOLLOWED. The controller answers a save with a 302 back
141
+ // to /admin/theme, and a fetch that follows a 302 keeps a PATCH a PATCH: it
142
+ // sends the save again on every hop until the browser gives up.
143
+ export function formRequest(data) {
144
+ const override = data.get("_method")
145
+ if (override) data.delete("_method")
146
+ return { method: override ? String(override).toUpperCase() : "POST", body: data, redirect: "manual" }
147
+ }
148
+
149
+ // Whether the server took the save: a 2xx, or its own redirect (which a fetch
150
+ // told not to follow reports as an opaque redirect). A failed save is rendered
151
+ // in place with a 422.
152
+ export function saved(response) {
153
+ return response.ok || response.type === "opaqueredirect"
154
+ }
155
+
156
+ // x-data="dsThemeEditor({...})": style/_theme.
157
+ //
158
+ // SAVE IN PLACE. Save and Regenerate submit by fetch and stay on the style
159
+ // guide: the form's @submit.prevent stops the browser navigating to
160
+ // /admin/theme, the live preview persists, and the outcome is a toast (the
161
+ // engine's `toast` window event). The form's own POST still works when Alpine
162
+ // never loads.
163
+ export function dsThemeEditor(seed) {
164
+ return {
165
+ colors: Object.assign({
166
+ primary: "#000", dark: "#0f172a", light: "#ffffff",
167
+ success: "#367E3A", warning: "#FF7C47", danger: "#EF4444",
168
+ accent: "#6366F1"
169
+ }, seed || {}),
170
+ saving: false,
171
+
172
+ updatePreview() {
173
+ applyProperties(document.documentElement.style, stylePreviewProperties(this.colors))
174
+ },
175
+
176
+ persist(form, label) {
177
+ if (this.saving) return
178
+ this.saving = true
179
+ const request = formRequest(new FormData(form))
180
+ fetch(form.action, request)
181
+ .then((res) => {
182
+ if (!saved(res)) throw new Error("HTTP " + res.status)
183
+ return res
184
+ })
185
+ .then(() => { this.toast("notice", label, "Saved in place — the page stayed put.") })
186
+ .catch((err) => { this.toast("alert", "Save failed", String((err && err.message) || err)) })
187
+ .finally(() => { this.saving = false })
188
+ },
189
+
190
+ toast(type, title, message) {
191
+ window.dispatchEvent(new CustomEvent("toast", { detail: { type: type, title: title, message: message } }))
192
+ },
193
+
194
+ saveTheme(e) { this.persist(e.target, "Theme saved") },
195
+ regenerate(e) { this.persist(e.target, "Theme cache cleared") }
196
+ }
197
+ }