studio-engine 0.60.0 → 0.60.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bf3942309a578282c3ed5d194123c773c659fde728ac53db036f29abee6d09f
4
- data.tar.gz: 96dcf654f30bbbc63d7682394b24460091acbc52d310b39cb065ae771e3108a0
3
+ metadata.gz: d48986ae997517eba2f2ae27edf0ef145069a69d09382ee2bdced6ff7839a630
4
+ data.tar.gz: e2e6b4b71af934b23536a94a03352e0340f47b8add6398f485f1f9c1262c240b
5
5
  SHA512:
6
- metadata.gz: 67264dd6f10ab6be519740067057f7e39c1d960976e7409b3524de3e61f094bc523172e5e1721dfd09c1bfe95c6bc935c5ccd133696bbdbc581e046b8c1da0f7
7
- data.tar.gz: a210c2bbd8591ea197438150788be7594dca951ce7405f975e91892376b739218e0f048ffec483ca702d23573b429b44f104a75b8ca8a67091522a4496b74209
6
+ metadata.gz: 96fce3596b86f2ea40f0cce0e8801cb57dc0d848e00d68f4b151b72885944fe99b346e49241d0ece5d79ae8a7293e4829ad5b055e38bddb12e3a8ee3730f8169
7
+ data.tar.gz: c5003ca60f6d27a5ea6c81b40ca50902f0ce6df4f11fc2a127392e1993af1147209f13a580c18006b69585b7ce1e8882b7da50dc30cfbd8c05ad4c7daf4edb50
data/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Breaking
8
+
9
+ - **The age-gate DOB modal is renamed, and the refusal moved to its own card.**
10
+ A host that renders these paths from the released gem must update them:
11
+
12
+ | Was | Is now |
13
+ |-----|--------|
14
+ | `studio/modals/blocks/_age_verify` | `studio/modals/blocks/_birthday` |
15
+ | `studio/_age_verify_assets` | `studio/_birthday_assets` |
16
+ | `window.ageVerifyModal` | `window.birthdayModal` |
17
+
18
+ Nothing in `mcritchie-studio`, `turf-monster`, `mcritchie-industries` or `rolio`
19
+ rendered the old paths at the time of this change (each app that shows an age
20
+ gate ships its own card and shares only `studio/fields/_date_of_birth`), so the
21
+ rename lands with no consumer edit — but the old paths are GONE, not aliased, so
22
+ a future adopter reaching for `blocks/age_verify` needs this table.
23
+
24
+ **`age-verified` is deliberately unchanged.** The success event and
25
+ `session.ageVerified` are what consuming apps already listen on, so the rename
26
+ stopped short of them and no host wiring moves.
27
+
28
+ - **`min_age` is now OPTIONAL on the birthday card** (was REQUIRED). Absent — or
29
+ `0` — means the app asks for a birthday and does not gate on it: the card drops
30
+ the age line, the eligibility wording and the refusal handoff. Existing callers
31
+ that pass a number are unaffected. There is still NO engine default, because 18
32
+ is itself a policy value.
33
+
34
+ - **An under-age date now SUBMITS.** It used to disable the card's own submit
35
+ button and turn the card red — the one screen state with nothing to press. The
36
+ factory routes on the RESPONSE instead: `body.underage === true`, or HTTP 403,
37
+ opens the age-gate card; anything else non-verified stays on the error line. An
38
+ app adopting this must make its verify endpoint answer "too young" distinctly
39
+ from "bad request", because a client that blocks submission never had to.
40
+
41
+ ### Added
42
+
43
+ - **`studio/modals/blocks/_age_gate` — the refusal card.** Where the birthday card
44
+ hands off when the date is under the app's bar. Headed "Easy, Young'un" under a
45
+ teddy bear rather than a red X, because a person who will be welcome later is
46
+ not an error state. Carries a LIVE COUNTDOWN to the day they qualify, built from
47
+ the date the birthday card passes across the store, plus the two ways out: a
48
+ primary CTA to watch instead (app-supplied `watch_url`, dropped entirely when
49
+ absent rather than rendered dead) and a back link that returns to the birthday
50
+ card. Displays `min_age` / `state`; computes no eligibility of its own.
51
+
7
52
  ### Added
8
53
 
9
54
  - **A Geo row in the shared admin dropdown, with signage when it is off.** Every
@@ -1,27 +1,39 @@
1
1
  <%#
2
- ageVerifyModal factory — the Alpine data for the engine age-gate DOB modal
3
- (studio/modals/blocks/_age_verify). Ships here, at PAGE level, on purpose: the
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
4
  modal partial mounts inside a modal host's single-root template, and a script
5
5
  cloned out of a template never runs. So a consumer renders THIS once in its
6
6
  layout (like the shared alpine factories), and the modal partial anywhere below
7
- just references window.ageVerifyModal. Mirrors how studio/_cropper_assets homes
7
+ just references window.birthdayModal. Mirrors how studio/_cropper_assets homes
8
8
  the cropPhotoModal factory for the crop modal.
9
9
 
10
10
  The factory holds ZERO legal policy — no age default, no state table. minAge is
11
11
  whatever the app hands it (a required prop upstream); the client "too young"
12
12
  hint is UX only, and the server recompute at submit_url stays authoritative.
13
13
 
14
- opts: { minAge, state, url, store, demo }.
14
+ opts: { minAge, state, url, store, demo, demoUnderage, gateId }.
15
15
  minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
16
16
  state passive jurisdiction label (server-detected upstream; never editable).
17
17
  url app endpoint the DOB POSTs to (the app owns the authoritative check).
18
18
  store Alpine modal store name backing close() (default "modals").
19
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.
20
32
  %>
21
33
  <script>
22
34
  (function () {
23
- if (window.ageVerifyModal) return;
24
- window.ageVerifyModal = function (opts) {
35
+ if (window.birthdayModal) return;
36
+ window.birthdayModal = function (opts) {
25
37
  opts = opts || {};
26
38
  var now = new Date();
27
39
  return {
@@ -34,6 +46,10 @@
34
46
  url: opts.url || "",
35
47
  store: opts.store || "modals",
36
48
  demo: !!opts.demo,
49
+ demoUnderage: !!opts.demoUnderage,
50
+ // Blank gateId => no second card registered yet; the refusal falls back
51
+ // to the error line so a host mid-adoption still SAYS something.
52
+ gateId: opts.gateId || "",
37
53
  months: [
38
54
  { n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
39
55
  { n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
@@ -83,17 +99,58 @@
83
99
  try { Alpine.store(this.store).close(); } catch (e) {}
84
100
  window.dispatchEvent(new CustomEvent("age-verified"));
85
101
  },
102
+ // Refusal path: hand off to the age-gate card, which owns the CTAs
103
+ // ("watch instead", "fix your birthday"). Deliberately NOT an error —
104
+ // the card that refuses is also the card that offers somewhere to go.
105
+ // swap(), not open(), so the back-link on that card returns HERE.
106
+ _reject(message) {
107
+ if (!this.gateId) {
108
+ // No gate registered (host mid-adoption): say it inline rather than
109
+ // closing silently, which would look like the date was accepted.
110
+ this.error = message || this._refusalLine();
111
+ return;
112
+ }
113
+ try {
114
+ Alpine.store(this.store).swap(this.gateId, {
115
+ minAge: this.minAge,
116
+ state: this.stateCode,
117
+ message: message || "",
118
+ // The DATE, so the gate card can say WHEN rather than only "no".
119
+ // Passed as parts rather than a Date: this crosses a store, and a
120
+ // Date through Alpine's reactive proxy is a class waiting to be
121
+ // stringified by something downstream.
122
+ dobYear: parseInt(this.year, 10) || null,
123
+ dobMonth: parseInt(this.month, 10) || null,
124
+ dobDay: parseInt(this.day, 10) || null
125
+ });
126
+ } catch (e) {
127
+ this.error = message || this._refusalLine();
128
+ }
129
+ },
130
+ _refusalLine() {
131
+ return "You must be " + this.minAge + "+" +
132
+ (this.stateCode ? " in " + this.stateCode : "") + ".";
133
+ },
134
+ get isUnderage() {
135
+ return this.computedAge !== null && !!this.minAge && this.computedAge < this.minAge;
136
+ },
86
137
  submit() {
87
138
  if (!this.complete || this.submitting) return;
88
139
  this.error = "";
89
- // Client courtesy gate only the server recompute is authoritative.
90
- if (this.computedAge !== null && this.minAge && this.computedAge < this.minAge) return;
140
+ // NO client-side refusal. This used to early-return on an under-age
141
+ // date, which is what left the card red with a disabled button. The
142
+ // date goes to the server like any other; the server recompute is and
143
+ // always was the authoritative check, and its verdict routes here.
91
144
  this.submitting = true;
92
145
  var self = this;
93
146
  if (this.demo) {
94
- // Style-guide preview: no backend, resolve locally so the modal
95
- // demonstrates the success handoff without an endpoint.
96
- setTimeout(function () { self.submitting = false; self._finish(); }, 450);
147
+ // Style-guide preview: no backend. demoUnderage picks which branch
148
+ // the local resolve takes, so the guide can walk BOTH outcomes.
149
+ setTimeout(function () {
150
+ self.submitting = false;
151
+ if (self.demoUnderage || self.isUnderage) self._reject();
152
+ else self._finish();
153
+ }, 450);
97
154
  return;
98
155
  }
99
156
  var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
@@ -102,11 +159,20 @@
102
159
  headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
103
160
  body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
104
161
  })
105
- .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
162
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
106
163
  .then(function (res) {
107
164
  self.submitting = false;
108
165
  if (res.ok && res.body && res.body.verified) { self._finish(); return; }
109
- self.error = (res.body && res.body.error) ||
166
+ // The app distinguishes "too young" from "bad request". An explicit
167
+ // underage verdict — body.underage, or a 403 — is a REFUSAL and
168
+ // routes to the gate card; anything else is a real error and stays
169
+ // on the red line here.
170
+ var body = res.body || {};
171
+ if (body.underage === true || res.status === 403) {
172
+ self._reject(body.error || body.message || "");
173
+ return;
174
+ }
175
+ self.error = body.error ||
110
176
  "We couldn't verify your age. Please check the date and try again.";
111
177
  })
112
178
  .catch(function () {
@@ -3,7 +3,7 @@
3
3
  (studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
4
4
  board component template would be cloned/re-parsed and never run, so a consumer
5
5
  renders THIS once in its layout (like studio/_leveling_activity_assets homes
6
- levelingActionModal and studio/_age_verify_assets homes ageVerifyModal), and the
6
+ levelingActionModal and studio/_birthday_assets homes birthdayModal), and the
7
7
  board anywhere below just references window.studioBoard. SortableJS is loaded
8
8
  globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
9
9
 
@@ -5,8 +5,8 @@
5
5
  inside a modal host's single-root template, and a script cloned out of a template
6
6
  never runs. So a consumer renders THIS once in its layout (like the other shared
7
7
  Alpine factories), and the modal partial anywhere below just references
8
- window.levelingActionModal. Mirrors how studio/_age_verify_assets homes the
9
- ageVerifyModal factory.
8
+ window.levelingActionModal. Mirrors how studio/_birthday_assets homes the
9
+ birthdayModal factory.
10
10
 
11
11
  CRITICAL BOUNDARY — this factory is UI ONLY. It POSTs to an app-supplied
12
12
  submitUrl and reacts to a NEUTRAL JSON contract; it holds ZERO on-chain logic,
@@ -6,7 +6,7 @@
6
6
  site can recompute whatever it derives. Defaults to the
7
7
  age-gate's onMonthChange(), which every current caller has.
8
8
 
9
- ONE HOOK FOR ALL THREE, where age_verify used onMonthChange()
9
+ ONE HOOK FOR ALL THREE, where the birthday modal used onMonthChange()
10
10
  for month/year and `error = ''` for day. That is not a
11
11
  behaviour change: onMonthChange() clears an out-of-range day
12
12
  and then clears the error, and a day just chosen FROM
@@ -24,8 +24,9 @@
24
24
  years — the year range this caller offers
25
25
 
26
26
  EXTRACTED BECAUSE IT WAS ALREADY DUPLICATED: these selects lived in
27
- studio/modals/blocks/_age_verify, and turf-monster forked a second copy into
28
- its own modals/_age_verify. Two copies of one field is how they drift.
27
+ studio/modals/blocks/_birthday (named _age_verify until 2026-08-24), and
28
+ turf-monster forked a second copy into its own modals/_age_verify. Two copies
29
+ of one field is how they drift.
29
30
 
30
31
  AND BECAUSE IT REPLACED SOMETHING WORSE. /profile/edit used a custom calendar
31
32
  popover, which shipped four defects in three days — its grid collapsed in
@@ -0,0 +1,225 @@
1
+ <%#
2
+ Age gate — the REFUSAL card. New 2026-08-24, and it exists because a refusal
3
+ needs somewhere to go.
4
+
5
+ WHAT IT REPLACED. blocks/_birthday (then blocks/_age_verify) used to carry the
6
+ refusal itself: enter an under-age date and the card turned red and disabled
7
+ its own submit button. That is the one screen state with nothing to press —
8
+ the person is told no and handed no next move, on a card whose only other
9
+ control closes it. This card is that next move.
10
+
11
+ THE TWO WAYS OUT, and both are real:
12
+ watch_url — the thing they CAN do. Being too young to enter is not being too
13
+ young to watch, so the primary CTA is a way to stay rather than
14
+ a way to leave. App-supplied: the engine has no idea what a
15
+ contest is.
16
+ the back link — the thing they might NEED. A wrong birthday is an ordinary
17
+ typo (a mis-picked year is one scroll away), and a gate with no
18
+ way back turns a typo into a locked door. swap()s back to the
19
+ birthday card, so this is a correction, not a restart.
20
+
21
+ DELIBERATELY NOT DISMISSIBLE-BY-DEFAULT-ONLY: the host still decides via
22
+ props.dismissible at open() time, exactly like every other card. What this
23
+ partial guarantees is that there is always a visible way forward even when the
24
+ host locks the backdrop.
25
+
26
+ NO LEGAL POLICY LIVES HERE, same rule as the birthday card. min_age and state
27
+ are passed in and only ever displayed; nothing here computes eligibility.
28
+
29
+ Locals:
30
+ min_age (Integer, optional) — for the headline. Falls back to props.minAge,
31
+ which _birthday's _reject passes, so a host that opens this card
32
+ through the handoff needs to supply nothing at all.
33
+ state (String, optional) — passive jurisdiction label. Same fallback.
34
+ watch_url (String, optional) — the primary CTA target. Omit and the CTA is
35
+ dropped rather than rendered dead.
36
+ watch_label (String, optional) — default "Watch the Contest".
37
+ birthday_modal_id (String, optional) — the card the back link returns to.
38
+ Default "birthday". Pass nil to drop the link.
39
+ back_label (String, optional) — default "Update your Birthday".
40
+ title (String, optional) — default is built from min_age.
41
+ countdown (Boolean, optional) — default true. FALSE drops the countdown line
42
+ entirely and falls back to the plain rule sentence. It does not
43
+ merely stop the clock: a frozen countdown reads as a rendering
44
+ artefact, which is the very thing this card exists not to be.
45
+ modal_store (String, optional) — Alpine store name. Default "modals".
46
+
47
+ Single root element (modal-host template mount rule).
48
+ %>
49
+ <%
50
+ min_age = local_assigns[:min_age]
51
+ state = local_assigns[:state].to_s.strip
52
+ countdown = local_assigns.fetch(:countdown, true)
53
+ watch_url = local_assigns[:watch_url].to_s
54
+ watch_label = local_assigns.fetch(:watch_label, "Watch the Contest")
55
+ birthday_modal_id = local_assigns.fetch(:birthday_modal_id, "birthday")
56
+ back_label = local_assigns.fetch(:back_label, "Update your Birthday")
57
+ title = local_assigns[:title]
58
+ modal_store = local_assigns.fetch(:modal_store, "modals")
59
+ %>
60
+ <div x-data="{
61
+ now: Date.now(),
62
+ _tick: null,
63
+ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
64
+ get minAge() { return <%= min_age.present? ? min_age.to_i : 0 %> || this.props.minAge || null; },
65
+ get stateCode() { return '<%= j state %>' || this.props.state || ''; },
66
+ get headline() {
67
+ // Single-quoted + j-escaped, NOT to_json: to_json emits DOUBLE quotes,
68
+ // which close this double-quoted x-data attribute and mount the
69
+ // component as a silent no-op. The markup still renders, so every
70
+ // assertion passes while the card is dead in a browser.
71
+ var t = '<%= j title.to_s %>';
72
+ return t ? t : 'Easy, Young’un';
73
+ },
74
+ // The date they become eligible: their birthday, plus the app's minimum
75
+ // age in years. Built from PARTS the birthday card handed across the
76
+ // store, because a Date through a reactive proxy is a class waiting to be
77
+ // stringified by something downstream.
78
+ get eligibleAt() {
79
+ var p = this.props, y = p.dobYear, m = p.dobMonth, d = p.dobDay;
80
+ if (!y || !m || !d || !this.minAge) return null;
81
+ return new Date(y + this.minAge, m - 1, d);
82
+ },
83
+ // Add n months and CLAMP to the end of the target month. Bare setMonth
84
+ // overflows: Jan 31 + 1 month is Feb 31, which JS rolls forward to Mar 3,
85
+ // and the countdown then reports one month less than it should for every
86
+ // person born on a 29th, 30th or 31st.
87
+ addMonths(d, n) {
88
+ var day = d.getDate();
89
+ var r = new Date(d.getTime());
90
+ r.setDate(1);
91
+ r.setMonth(r.getMonth() + n);
92
+ var lastDay = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
93
+ r.setDate(day < lastDay ? day : lastDay);
94
+ return r;
95
+ },
96
+ get remaining() {
97
+ var target = this.eligibleAt;
98
+ if (!target) return null;
99
+ if (target.getTime() - this.now <= 0) return null;
100
+
101
+ // Calendar months and days, not 30-day approximations — this number is
102
+ // read as a real date difference and a rounded one is wrong by days.
103
+ var from = new Date(this.now), months = 0;
104
+ while (this.addMonths(from, months + 1).getTime() <= target.getTime()) months++;
105
+ var probe = this.addMonths(from, months);
106
+
107
+ // Days walked with setDate, NOT by dividing milliseconds: a day across a
108
+ // DST boundary is 23 or 25 hours, and ms-division reports 27d 23h where
109
+ // the calendar says 28d.
110
+ var days = 0;
111
+ while (true) {
112
+ var next = new Date(probe.getTime());
113
+ next.setDate(next.getDate() + 1);
114
+ if (next.getTime() > target.getTime()) break;
115
+ probe = next; days++;
116
+ }
117
+
118
+ var rest = target.getTime() - probe.getTime();
119
+ var hours = Math.floor(rest / 3600000); rest -= hours * 3600000;
120
+ var mins = Math.floor(rest / 60000); rest -= mins * 60000;
121
+ var secs = Math.floor(rest / 1000);
122
+ return { months: months, days: days, hours: hours, minutes: mins, seconds: secs };
123
+ },
124
+ get countdownText() {
125
+ var r = this.remaining;
126
+ if (!r) return '';
127
+ var unit = function (n, one) { return n + ' ' + one + (n === 1 ? '' : 's'); };
128
+ // YEARS, because the whole point of this line is that nobody should have
129
+ // to divide. Ninety months — the reachable worst case for a 13-year-old
130
+ // at a 21 bar — is exactly the is-that-a-week-or-a-decade arithmetic the
131
+ // comment below claims to remove. Months carry the remainder.
132
+ // (No double quotes in here: this whole object is one HTML attribute.)
133
+ var years = Math.floor(r.months / 12), months = r.months % 12;
134
+ var parts = [];
135
+ if (years) parts.push(unit(years, 'year'));
136
+ if (months || years) parts.push(unit(months, 'month'));
137
+ if (r.days || months || years) parts.push(unit(r.days, 'day'));
138
+ return parts.join(' ') + (parts.length ? ', ' : '') +
139
+ unit(r.hours, 'hour') + ', ' + unit(r.minutes, 'minute') + ', ' + unit(r.seconds, 'second');
140
+ },
141
+ start() { var s = this; this._tick = setInterval(function () { s.now = Date.now(); }, 1000); },
142
+ stop() { if (this._tick) { clearInterval(this._tick); this._tick = null; } },
143
+ // Alpine 3 calls destroy() ON THE DATA OBJECT at teardown and dispatches
144
+ // no `destroy` DOM event, so the `@destroy` listener this used to carry
145
+ // never fired and the interval outlived every close. Same idiom as the
146
+ // sibling cards that poll: modals/_web3_step_up, blocks/_cta_redirect,
147
+ // blocks/_seeds_bar.
148
+ destroy() { this.stop(); },
149
+ back() { $store.<%= modal_store %>.swap('<%= j birthday_modal_id.to_s %>', {}); }
150
+ }"
151
+ <% if countdown %>x-init="start()"<% end %>
152
+ class="relative">
153
+ <%# A TEDDY BEAR, not a red X. The X pill is the engine's error icon and this
154
+ is not an error — it is a person who will be welcome later. The card that
155
+ turns someone away is the last place to reach for alarm styling; the copy
156
+ does the same work ("Easy, Young’un" rather than "You must be 21+"),
157
+ and the two have to agree or the card reads as scolding.
158
+
159
+ title_key, not title: the headline may arrive as a LOCAL or fall back to
160
+ the default, and the block below replaces the subtitle area. %>
161
+ <%= render "studio/modals/blocks/card_header",
162
+ size: :lg,
163
+ icon_emoji: "🧸",
164
+ title_key: "headline" do %>
165
+ <%# WHEN, not just no. A refusal that only says "not yet" leaves the person
166
+ to work out whether that means a week or a decade; the countdown answers
167
+ it in the units they actually feel. It ticks live because a frozen
168
+ number reads as a rendering artefact rather than a clock. %>
169
+ <template x-if="<%= countdown ? 'countdownText' : 'false' %>">
170
+ <p class="text-sm text-body">
171
+ You have <strong class="text-heading" x-text="countdownText"></strong>
172
+ before you can join your first contest.
173
+ <span x-show="stateCode" x-cloak>
174
+ The minimum age <span x-text="'in ' + stateCode"></span> is
175
+ <strong class="text-heading" x-text="minAge"></strong>.
176
+ </span>
177
+ </p>
178
+ </template>
179
+
180
+ <%# No date to count from (the card was opened directly, or the handoff
181
+ carried no DOB). Say the rule plainly rather than an empty sentence. %>
182
+ <template x-if="<%= countdown ? '!countdownText' : 'true' %>">
183
+ <p class="text-sm text-body">
184
+ <span x-show="stateCode" x-cloak>
185
+ The minimum age <span x-text="'in ' + stateCode"></span> is
186
+ <strong class="text-heading" x-text="minAge"></strong>.
187
+ </span>
188
+ <span x-show="!stateCode">You are not old enough to enter this contest yet.</span>
189
+ </p>
190
+ </template>
191
+
192
+ <%# A server-authored reason, when there is one. Never invented here. %>
193
+ <template x-if="props.message">
194
+ <p class="text-sm text-muted mt-2" x-text="props.message"></p>
195
+ </template>
196
+ <% end %>
197
+
198
+ <%# Breathing room before the CTA. card_header's block sits flush against
199
+ whatever follows it, and a full-width primary button landing directly on
200
+ the last line of body copy reads as one block rather than as a sentence
201
+ and then a choice. %>
202
+ <div class="mt-5"></div>
203
+
204
+ <% if watch_url.present? %>
205
+ <%# The primary action is the thing they CAN do. Too young to enter is not
206
+ too young to watch, so this keeps them in rather than showing them out. %>
207
+ <%= link_to watch_label, watch_url,
208
+ class: "btn btn-primary btn-lg w-full",
209
+ "@click": "$store.#{modal_store}.close()" %>
210
+ <% end %>
211
+
212
+ <% if birthday_modal_id.present? %>
213
+ <%# The correction path. A mis-picked year is one scroll away from a right
214
+ one, and without this the typo is indistinguishable from the verdict. %>
215
+ <button type="button" @click="back()"
216
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition underline-offset-2 hover:underline">
217
+ <%= back_label %>
218
+ </button>
219
+ <% end %>
220
+
221
+ <button type="button" @click="$store.<%= modal_store %>.close()"
222
+ class="block mx-auto mt-3 text-sm text-muted hover:text-secondary transition">
223
+ Close
224
+ </button>
225
+ </div>
@@ -0,0 +1,134 @@
1
+ <%#
2
+ Birthday modal — engine primitive. Collects a real date of birth
3
+ (Month / Day / Year) and submits it. Homed in the engine as the heavier sibling
4
+ of studio/modals/shared/_age_attestation (the lighter one-checkbox legal
5
+ attestation). RUN ONE OR THE OTHER, not both: the attestation is a signup-time
6
+ "I confirm I'm of legal age" checkbox; this is an entry-time DOB gate that a
7
+ server can recompute and stamp.
8
+
9
+ RENAMED 2026-08-24, and the rename carries a behaviour change. This partial was
10
+ `blocks/_age_verify` and it owned BOTH halves of the question: it collected the
11
+ date AND, when the date failed, it turned red and disabled its own button. That
12
+ is a dead end — the one screen state with nothing to press and nowhere to go.
13
+ The two halves are now two cards:
14
+
15
+ THIS ONE (birthday) — asks. Always submittable.
16
+ blocks/_age_gate — answers, when the answer is no. Carries the CTAs
17
+ (watch instead / fix the date), so the refusal is a
18
+ card with somewhere to go rather than red text.
19
+
20
+ So an under-age date no longer disables the button; it SUBMITS, and the app's
21
+ verdict opens the age-gate card. The inline red text that used to carry the
22
+ refusal is gone. What remains red here is a genuine ERROR — an invalid date, a
23
+ network failure — which is a different thing from "you are too young", and
24
+ conflating the two is what made the old state a dead end.
25
+
26
+ CRITICAL — the engine hardcodes NO legal policy. It renders the modal UI, the
27
+ DOB fields, and the submit; everything legal is app-supplied:
28
+
29
+ min_age (Integer, OPTIONAL) — the app's minimum age. There is NO engine
30
+ default (18 is itself a policy value); the app resolves it from
31
+ its own policy against its own server-detected jurisdiction.
32
+ ABSENT (or 0) = this app asks for a birthday and does not gate on
33
+ it. That is a first-class mode, not a degraded one: a hub app may
34
+ want the date for a greeting and have no bar at all, and the card
35
+ then drops the age line, the eligibility wording and the refusal
36
+ handoff rather than rendering "You must be 0+".
37
+ submit_url (String, REQUIRED) — the app endpoint the DOB POSTs to. The
38
+ app owns the authoritative recompute + DOB persistence; the modal
39
+ only shows the response.
40
+ state (String, optional) — a PASSIVE jurisdiction label for the copy
41
+ (e.g. "CA"). Server-detected upstream; the modal never detects
42
+ geography and never offers an editable state field (a spoofable
43
+ client state must not be able to lower the bar). Blank => the
44
+ jurisdiction clause is dropped.
45
+ title (String, optional) — modal title. Default "Your birthday"
46
+ (it was "Verify your age" under the old _age_verify name).
47
+ intro (String, optional) — the lead sentence after the age line.
48
+ fine_print (String, optional) — the app's legal / jurisdiction copy (e.g.
49
+ its per-state age table). The engine ships only a neutral,
50
+ policy-free default; the app passes its real copy here.
51
+ modal_store (String, optional) — Alpine store backing close(). Default "modals".
52
+ age_gate_modal_id (String, optional) — the id opened when the app’s verdict is
53
+ UNDER age. Default "age-gate". Pass nil to keep the old
54
+ inline-refusal behaviour (the refusal then falls back to the
55
+ error line), which exists only so a host mid-adoption is not
56
+ forced to register a second modal on the same deploy.
57
+ demo (Boolean, optional) — style-guide preview: resolve locally, no POST.
58
+ demo_underage (Boolean, optional) — style-guide preview ONLY: make the local
59
+ resolve take the REFUSED branch, so the guide can show the
60
+ handoff without a backend. Ignored unless demo.
61
+
62
+ Requires window.birthdayModal — render studio/_birthday_assets once at
63
+ layout level (the factory can’t ship inside this template; a cloned script
64
+ never runs). Single root element (modal-host template mount rule).
65
+ %>
66
+ <%
67
+ # min_age is OPTIONAL now. Absent (or 0) means this app asks for a birthday and
68
+ # does not gate on it — McRitchie Studio wants the date for a greeting, not for
69
+ # eligibility. Present means the app validates, and the shape of that validation
70
+ # is the APP's: turf-monster's varies by jurisdiction, which is exactly why the
71
+ # engine takes a resolved number and a passive label rather than a rule.
72
+ min_age = local_assigns[:min_age]
73
+ validates = min_age.to_i.positive?
74
+ submit_url = local_assigns.fetch(:submit_url)
75
+ state = local_assigns[:state].to_s.strip
76
+ title = local_assigns.fetch(:title, "Your birthday")
77
+ intro = local_assigns.fetch(:intro,
78
+ validates ? "Enter your date of birth to confirm you're eligible — we only ask once."
79
+ : "Enter your date of birth — we only ask once.")
80
+ fine_print = local_assigns.fetch(:fine_print,
81
+ validates ? "We use your date of birth only to confirm eligibility."
82
+ : "We use your date of birth only to know when to wish you a happy birthday.")
83
+ modal_store = local_assigns.fetch(:modal_store, "modals")
84
+ demo = local_assigns.fetch(:demo, false)
85
+ demo_underage = local_assigns.fetch(:demo_underage, false)
86
+ age_gate_modal_id = local_assigns.fetch(:age_gate_modal_id, "age-gate")
87
+ %>
88
+ <div x-data="birthdayModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %>, demoUnderage: <%= demo_underage ? 'true' : 'false' %>, gateId: '<%= j age_gate_modal_id.to_s %>' })">
89
+ <%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
90
+ <%# The age line only exists when the app HAS a bar. A card that says
91
+ "You must be 0+" is what an unconditional line produces for a host that
92
+ just wants the date. %>
93
+ <p class="text-sm text-muted mb-4">
94
+ <% if validates %>
95
+ You must be <span class="text-heading font-semibold"><%= min_age %>+</span><%= state.present? ? " in #{state}" : "" %>.
96
+ <% end %>
97
+ <%= intro %>
98
+ </p>
99
+
100
+ <%# Month / Day / Year — the engine's one DOB field, shared with the profile
101
+ page's birthday row. It lived here first and turf-monster forked a second
102
+ copy; extracting it is what stops the two drifting. %>
103
+ <div class="mb-4">
104
+ <%= render "studio/fields/date_of_birth" %>
105
+ </div>
106
+
107
+ <%# NO inline too-young hint. It used to live here, and turning the card red
108
+ while disabling its own button is the dead end this rename removed — the
109
+ refusal is now its own card (blocks/_age_gate) with somewhere to go.
110
+
111
+ This line stays for genuine ERRORS: an invalid date, a network failure.
112
+ A refusal is not an error, and showing them in the same red paragraph is
113
+ what let the dead end hide. %>
114
+ <template x-if="error">
115
+ <p class="text-red-400 text-sm mb-3" x-text="error"></p>
116
+ </template>
117
+
118
+ <%# Disabled ONLY for an incomplete date or an in-flight submit. It used to be
119
+ disabled for an under-age date too, which is precisely the dead end: the
120
+ one state where the card had nothing to press. An under-age date now
121
+ submits like any other and the app’s verdict opens the age-gate card. %>
122
+ <button type="button" @click="submit()"
123
+ class="btn btn-primary btn-lg w-full"
124
+ :disabled="!complete || submitting">
125
+ <span x-show="!submitting">Confirm &amp; Continue</span>
126
+ <span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
127
+ <span class="cta-spinner" aria-hidden="true"></span>
128
+ Verifying…
129
+ </span>
130
+ </button>
131
+
132
+ <p class="text-[11px] text-muted text-center mt-3 leading-snug"><%= fine_print %></p>
133
+ <% end %>
134
+ </div>
@@ -10,7 +10,7 @@
10
10
  second one badly.
11
11
 
12
12
  THE SAME FIELD THE AGE GATE USES (operator's call, 2026-08-15). It renders
13
- studio/fields/_date_of_birth, which studio/modals/blocks/_age_verify also
13
+ studio/fields/_date_of_birth, which studio/modals/blocks/_birthday also
14
14
  renders — one field, two surfaces. It replaced a custom calendar popover that
15
15
  shipped four defects in three days; see the component script for the list.
16
16