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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/app/javascript/studio/alpine_scopes.js +66 -0
- data/app/javascript/studio/alpine_shims.js +4 -0
- data/app/javascript/studio/application.js +4 -1
- data/app/javascript/studio/birthday.js +351 -0
- data/app/javascript/studio/confirm_interstitial.js +44 -0
- data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
- data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
- data/app/javascript/studio/cropper.js +281 -0
- data/app/javascript/studio/identity_mini.js +74 -0
- data/app/javascript/studio/image_upload.js +189 -0
- data/app/javascript/studio/profile_form.js +88 -0
- data/app/javascript/studio/stimulus.js +4 -0
- data/app/javascript/studio/style_guide.js +13 -0
- data/app/javascript/studio/style_modals.js +214 -0
- data/app/views/components/_avatar_cropper.html.erb +3 -38
- data/app/views/layouts/studio/_head.html.erb +12 -2
- data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
- data/app/views/studio/_birthday_assets.html.erb +6 -240
- data/app/views/studio/_confirm_interstitial.html.erb +31 -18
- data/app/views/studio/_cropper_assets.html.erb +9 -8
- data/app/views/studio/modals/_crop_photo.html.erb +1 -1
- data/app/views/studio/modals/_image_upload.html.erb +8 -321
- data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
- data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
- data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
- data/app/views/studio/profiles/_plain_save.html.erb +11 -0
- data/app/views/studio/profiles/edit.html.erb +1 -6
- data/app/views/style/_modals.html.erb +36 -420
- data/lib/studio/version.rb +1 -1
- metadata +14 -3
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
- data/app/views/studio/profiles/_form_script.html.erb +0 -65
|
@@ -1,243 +1,9 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
|
|
3
|
-
(studio/modals/blocks/_birthday)
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
47
|
-
margin-top:
|
|
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 {
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|