studio-engine 0.59.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.
@@ -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