studio-engine 0.97.0 → 0.98.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 (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
@@ -1,243 +1,9 @@
1
1
  <%#
2
- birthdayModal factory — the Alpine data for the engine birthday DOB modal
3
- (studio/modals/blocks/_birthday). Ships here, at PAGE level, on purpose: the
4
- modal partial mounts inside a modal host's single-root template, and a script
5
- cloned out of a template never runs. So a consumer renders THIS once in its
6
- layout (like the shared alpine factories), and the modal partial anywhere below
7
- just references window.birthdayModal. Mirrors how studio/_cropper_assets homes
8
- the cropPhotoModal factory for the crop modal.
2
+ The birthday card's scope, birthdayModal(), is studio/birthday, published on
3
+ every page by studio/alpine_scopes. The card (studio/modals/blocks/_birthday)
4
+ mounts inside a modal host's template, so its factory has to be on the page
5
+ before the card is.
9
6
 
10
- The factory holds ZERO legal policy — no age default, no state table. minAge is
11
- whatever the app hands it (a required prop upstream); the client "too young"
12
- hint is UX only, and the server recompute at submit_url stays authoritative.
13
-
14
- opts: { minAge, state, url, store, demo, demoUnderage, gateId }.
15
- minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
16
- state passive jurisdiction label (server-detected upstream; never editable).
17
- url app endpoint the DOB POSTs to (the app owns the authoritative check).
18
- store Alpine modal store name backing close() (default "modals").
19
- demo style-guide preview: resolve locally, skip the network POST.
20
- demoUnderage demo only — take the REFUSED branch, so the style guide can
21
- walk the handoff with no backend.
22
- gateId modal id opened on a refusal (default "age-gate"). Blank falls back
23
- to the inline error line, for a host mid-adoption that has not
24
- registered the second card yet.
25
-
26
- THE REFUSAL IS A HANDOFF, NOT AN ERROR (2026-08-24). This factory used to
27
- early-return on an under-age date, leaving the modal red with its own button
28
- disabled — the one state with nothing to press. `_reject` now opens the
29
- age-gate card instead. `_finish` is unchanged: the success path still flips
30
- session.ageVerified and dispatches `age-verified`, which is the hook consuming
31
- apps already listen on, so nothing downstream had to change.
32
-
33
- AND THE HANDOFF RETURNS (2026-08-26). `_reject` sends dobYear/dobMonth/dobDay
34
- across the store; the gate card's back() hands the same three parts back; and
35
- `init` re-picks the three selects from them, so the correction the gate card
36
- offers costs one scroll rather than all three fields. The date is restored,
37
- never the VERDICT: the card comes back submittable and the app's endpoint
38
- re-decides on the next submit exactly as it did on the first.
7
+ THIS PARTIAL RENDERS NOTHING. It stays so a layout that renders it by name
8
+ gets no missing-template error; the render can be deleted.
39
9
  %>
40
- <script>
41
- (function () {
42
- if (window.birthdayModal) return;
43
- window.birthdayModal = function (opts) {
44
- opts = opts || {};
45
- var now = new Date();
46
- return {
47
- month: "", day: "", year: "",
48
- submitting: false,
49
- error: "",
50
- // No engine age default: 18 is itself a policy value. The app supplies it.
51
- minAge: parseInt(opts.minAge, 10) || 0,
52
- stateCode: opts.state || "",
53
- url: opts.url || "",
54
- store: opts.store || "modals",
55
- demo: !!opts.demo,
56
- demoUnderage: !!opts.demoUnderage,
57
- // Blank gateId => no second card registered yet; the refusal falls back
58
- // to the error line so a host mid-adoption still SAYS something.
59
- gateId: opts.gateId || "",
60
- months: [
61
- { n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
62
- { n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
63
- { n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
64
- { n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
65
- ],
66
- // Year range: 13 years ago (youngest plausible) back 100 years. This is a
67
- // sane input range, not an age bar — the app's minAge decides eligibility.
68
- years: (function () {
69
- var ys = [], top = now.getFullYear() - 13, bottom = now.getFullYear() - 100;
70
- for (var y = top; y >= bottom; y--) ys.push(y);
71
- return ys;
72
- })(),
73
- // Days available for the chosen month/year (handles 28/29/30/31).
74
- get dayOptions() {
75
- var m = parseInt(this.month, 10), y = parseInt(this.year, 10);
76
- var max = 31;
77
- if (m) max = new Date(y || 2000, m, 0).getDate();
78
- var ds = [];
79
- for (var d = 1; d <= max; d++) ds.push(d);
80
- return ds;
81
- },
82
- get complete() {
83
- return !!(this.month && this.day && this.year);
84
- },
85
- get computedAge() {
86
- if (!this.complete) return null;
87
- var dob = new Date(parseInt(this.year, 10), parseInt(this.month, 10) - 1, parseInt(this.day, 10));
88
- var age = now.getFullYear() - dob.getFullYear();
89
- var hadBirthday = (now.getMonth() > dob.getMonth()) ||
90
- (now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate());
91
- if (!hadBirthday) age -= 1;
92
- return age;
93
- },
94
- // Clamp an out-of-range day when the month changes (e.g. Feb after 31).
95
- onMonthChange() {
96
- if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
97
- this.error = "";
98
- },
99
- // THE RETURN TRIP. _reject writes dobYear/dobMonth/dobDay onto the store
100
- // on the way out to the age-gate card, and that card's back() hands the
101
- // same three parts straight back — so a card mounted from a return trip
102
- // finds the person's date on its own modal entry and re-picks the three
103
- // selects. Without this the factory started at "" whatever it was handed,
104
- // and a mistyped year cost all three fields (fixed 2026-08-26).
105
- //
106
- // Read off the STORE rather than taken as an opt: that is where this
107
- // factory already meets the store (_reject writes to the same object
108
- // through the same guard), the ERB locals in blocks/_birthday are
109
- // server-side and cannot carry a client value anyway, and it means a
110
- // consuming app that renders the card needs no change at all.
111
- //
112
- // STRINGS, because that is what the selects produce: the options render
113
- // :value="m.n" — a number stringified by the DOM — and x-model writes
114
- // that string back. Seeding a Number would leave the field holding a type
115
- // the person's own pick never produces.
116
- _returnedDob() {
117
- var props;
118
- try {
119
- var entry = Alpine.store(this.store).current();
120
- props = (entry && entry.props) || {};
121
- } catch (e) {
122
- return null;
123
- }
124
- var y = parseInt(props.dobYear, 10),
125
- m = parseInt(props.dobMonth, 10),
126
- d = parseInt(props.dobDay, 10);
127
- if (!y || !m || !d) return null;
128
- return { year: String(y), month: String(m), day: String(d) };
129
- },
130
- // Alpine calls init() on the data object at MOUNT, synchronously, inside
131
- // the x-data directive and before it walks the three <select>s below
132
- // (Alpine 3.16.1 as vendored: `a.init && T(t, a.init)`). So this is the
133
- // fields' initial state rather than a later mutation of them, and x-model
134
- // paints the selects from it. Measured in chromium: all three hold the
135
- // restored values on the FIRST frame the card exists, six runs out of six
136
- // — no deferral needed, and e2e/birthday_gate.spec.js is what watches it.
137
- init() {
138
- var dob = this._returnedDob();
139
- if (!dob) return;
140
- this.year = dob.year;
141
- this.month = dob.month;
142
- // Day LAST, and clamped: dayOptions is only the restored month's list
143
- // once month and year are set, and an impossible pair from a hand-built
144
- // props bag (Feb 30) must land empty rather than leave the select
145
- // holding a value its own options do not contain — which would read as
146
- // complete and submit a date nobody picked.
147
- this.day = parseInt(dob.day, 10) <= this.dayOptions.length ? dob.day : "";
148
- },
149
- // Success path: flip a session flag if present, close via the app's store,
150
- // and let the app resume its own flow (the entry gate listens for this).
151
- _finish() {
152
- try {
153
- var sess = window.Alpine && Alpine.store && Alpine.store("session");
154
- if (sess) sess.ageVerified = true;
155
- } catch (e) {}
156
- try { Alpine.store(this.store).close(); } catch (e) {}
157
- window.dispatchEvent(new CustomEvent("age-verified"));
158
- },
159
- // Refusal path: hand off to the age-gate card, which owns the CTAs
160
- // ("watch instead", "fix your birthday"). Deliberately NOT an error —
161
- // the card that refuses is also the card that offers somewhere to go.
162
- // swap(), not open(), so the back-link on that card returns HERE.
163
- _reject(message) {
164
- if (!this.gateId) {
165
- // No gate registered (host mid-adoption): say it inline rather than
166
- // closing silently, which would look like the date was accepted.
167
- this.error = message || this._refusalLine();
168
- return;
169
- }
170
- try {
171
- Alpine.store(this.store).swap(this.gateId, {
172
- minAge: this.minAge,
173
- state: this.stateCode,
174
- message: message || "",
175
- // The DATE, so the gate card can say WHEN rather than only "no".
176
- // Passed as parts rather than a Date: this crosses a store, and a
177
- // Date through Alpine's reactive proxy is a class waiting to be
178
- // stringified by something downstream.
179
- dobYear: parseInt(this.year, 10) || null,
180
- dobMonth: parseInt(this.month, 10) || null,
181
- dobDay: parseInt(this.day, 10) || null
182
- });
183
- } catch (e) {
184
- this.error = message || this._refusalLine();
185
- }
186
- },
187
- _refusalLine() {
188
- return "You must be " + this.minAge + "+" +
189
- (this.stateCode ? " in " + this.stateCode : "") + ".";
190
- },
191
- get isUnderage() {
192
- return this.computedAge !== null && !!this.minAge && this.computedAge < this.minAge;
193
- },
194
- submit() {
195
- if (!this.complete || this.submitting) return;
196
- this.error = "";
197
- // NO client-side refusal. This used to early-return on an under-age
198
- // date, which is what left the card red with a disabled button. The
199
- // date goes to the server like any other; the server recompute is and
200
- // always was the authoritative check, and its verdict routes here.
201
- this.submitting = true;
202
- var self = this;
203
- if (this.demo) {
204
- // Style-guide preview: no backend. demoUnderage picks which branch
205
- // the local resolve takes, so the guide can walk BOTH outcomes.
206
- setTimeout(function () {
207
- self.submitting = false;
208
- if (self.demoUnderage || self.isUnderage) self._reject();
209
- else self._finish();
210
- }, 450);
211
- return;
212
- }
213
- var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
214
- fetch(this.url, {
215
- method: "POST",
216
- headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
217
- body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
218
- })
219
- .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
220
- .then(function (res) {
221
- self.submitting = false;
222
- if (res.ok && res.body && res.body.verified) { self._finish(); return; }
223
- // The app distinguishes "too young" from "bad request". An explicit
224
- // underage verdict — body.underage, or a 403 — is a REFUSAL and
225
- // routes to the gate card; anything else is a real error and stays
226
- // on the red line here.
227
- var body = res.body || {};
228
- if (body.underage === true || res.status === 403) {
229
- self._reject(body.error || body.message || "");
230
- return;
231
- }
232
- self.error = body.error ||
233
- "We couldn't verify your age. Please check the date and try again.";
234
- })
235
- .catch(function () {
236
- self.submitting = false;
237
- self.error = "Network error — please try again.";
238
- });
239
- }
240
- };
241
- };
242
- })();
243
- </script>
@@ -7,6 +7,10 @@
7
7
  (full document).
8
8
 
9
9
  Local: consume_path — the POST target that burns the token + signs in.
10
+
11
+ Two ways through, and neither depends on the other: the page posts its own
12
+ form (studio/confirm_interstitial), and the button below shows itself after
13
+ four seconds, by CSS alone and by the module's timer as well.
10
14
  %>
11
15
  <!DOCTYPE html>
12
16
  <html lang="en">
@@ -42,10 +46,30 @@
42
46
  animation: magic-spin 0.8s linear infinite;
43
47
  }
44
48
 
49
+ /* THE FALLBACK SHOWS ITSELF, four seconds in, with no script: the button
50
+ is the way through when the auto-post stalls, and also when the script
51
+ that posts never arrives. Held at no height until then, so the spinner
52
+ sits where it would with the block absent. */
53
+ @keyframes magic-fallback-in {
54
+ to { max-height: 20rem; margin-top: 2rem; visibility: visible; }
55
+ }
56
+
45
57
  .magic-fallback {
46
- display: none;
47
- margin-top: 2rem;
58
+ max-height: 0;
59
+ margin-top: 0;
60
+ overflow: hidden;
61
+ visibility: hidden;
48
62
  color: rgba(248, 250, 252, 0.8);
63
+ animation: magic-fallback-in 1ms linear 4s forwards;
64
+ }
65
+
66
+ /* The module's own timer, for an engine that holds the animation while
67
+ the post is pending. */
68
+ .magic-fallback.is-shown {
69
+ max-height: 20rem;
70
+ margin-top: 2rem;
71
+ visibility: visible;
72
+ animation: none;
49
73
  }
50
74
 
51
75
  .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
@@ -82,25 +106,14 @@
82
106
 
83
107
  <noscript>
84
108
  <style>
85
- #magic-fallback { display: block !important; }
109
+ #magic-fallback { animation: none; max-height: none; margin-top: 2rem; visibility: visible; }
86
110
  .magic-spinner { display: none; }
87
111
  </style>
88
112
  </noscript>
89
113
 
90
- <script>
91
- (function () {
92
- var form = document.getElementById("magic-consume-form");
93
- if (form && !form.dataset.autoSubmitted) {
94
- form.dataset.autoSubmitted = "1";
95
- if (typeof form.requestSubmit === "function") form.requestSubmit();
96
- else form.submit();
97
- }
98
-
99
- setTimeout(function () {
100
- var fallback = document.getElementById("magic-fallback");
101
- if (fallback) fallback.style.display = "block";
102
- }, 4000);
103
- })();
104
- </script>
114
+ <%# THE AUTO-POST is studio/confirm_interstitial, a module with no imports,
115
+ loaded by its own tag: this document has no import map. The tag carries
116
+ the request's CSP nonce when the host sets one. %>
117
+ <script type="module" src="<%= asset_path("studio/confirm_interstitial.js") %>"<%= tag.attributes(nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))) %>></script>
105
118
  </body>
106
119
  </html>
@@ -1,11 +1,12 @@
1
- <%# Cropper.js + the image-upload factories for the shared crop-photo modal.
2
- Render on pages that can open the cropper (an avatar field, a banner editor,
3
- etc.) — this loads cropper.js (~40 KB) AND defines imageUploadHost /
4
- cropPhotoModal / submitFormWithProgress inline, so both the library and the
5
- behavior only load where an upload trigger exists rather than globally.
1
+ <%# Cropper.js, for the shared crop-photo modal. Render on a page that can open
2
+ the cropper (an avatar field, a banner editor), so the library (~40 KB)
3
+ loads only where an upload trigger exists.
6
4
 
7
- Pair with: the crop-photo + saving modal registrations in your modal-host
8
- block (see the README / CHANGELOG), and x-data="imageUploadHost({...})". %>
5
+ The behaviour around it (cropPhotoModal, imageUploadHost,
6
+ submitFormWithProgress) is studio/cropper and studio/image_upload, on every
7
+ page through studio/alpine_scopes; this partial loads the library alone.
8
+
9
+ Pair with: the crop-photo and saving modal registrations in your modal-host
10
+ block (see the README and CHANGELOG), and x-data="imageUploadHost({...})". %>
9
11
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
10
12
  <script defer src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
11
- <%= render "studio/modals/image_upload" %>
@@ -16,7 +16,7 @@
16
16
  lets the user choose a file inside the modal, then crops (e.g. a
17
17
  banner editor's open()).
18
18
 
19
- The cropPhotoModal() factory ships in studio/modals/_image_upload; cropper.js
19
+ The cropPhotoModal() factory is studio/cropper (studio/alpine_scopes); cropper.js
20
20
  loads via studio/_cropper_assets on the pages that can open this modal.
21
21
 
22
22
  Single root: lives inside the host's <template x-if>, so only the outer