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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7bff4e07706e3ad5a053dba887b51fc012504416243c90a0d31d4b04352fd760
4
- data.tar.gz: 2019507efae14483adf943de92b61eede3e3ad7de386156e9cbd7859d76f6ef7
3
+ metadata.gz: 96463f400e143dc8688f6803440f724745f9064ec1921e55e58faf9be91edca2
4
+ data.tar.gz: 93e5d60b3dff0cc99c33d951c5326f8b74b192dd7f2ddc44d9f76adc0d047a2f
5
5
  SHA512:
6
- metadata.gz: 50ab3087714740f73542e5a5ccc0fdfdc40a601601e30db2a2c7ebd237fa7693ce62eb3e68b0b99b1de7809c314d6019b69bfdd9c4b146a6975938c6d2ca3066
7
- data.tar.gz: 44fbd55745216110988f1494caa8caa40835f157b56c7233024fa68c1577c4bd49c59df3b8c6d80382c3707ed99f7135faa4c8ff0f40f283e3a86743440c691e
6
+ metadata.gz: 953aedd6855e26a05484615332176e44b59ed4a890c635b0f6042215876afea856504b43d1b5e99bf16ec845a4dfb7aaefefcf8fc2caa33031780fce108a5f5a
7
+ data.tar.gz: 2e1000ccd5b1b15c16c7ce911cd5c5387730d7b2672186d1f46f1a8b4c82b6919f926a13e16f71b9a6a833bcf4ecec8d63b1aa185ed5136ad186de0eb6eb8653
data/CHANGELOG.md CHANGED
@@ -4,6 +4,95 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.98.0 — 2026-10-09
8
+
9
+ ### Changed
10
+
11
+ - **The modal blocks and the profile scripts move to ES modules.** The crop
12
+ photo modal, the image upload hosts, the avatar cropper, the birthday card,
13
+ the profile form and its birthday row, the compact identity bar, the sign-in
14
+ interstitial and the style guide's modal demos render markup only; nine
15
+ inline scripts are gone. Rendered markup and styling are unchanged apart from
16
+ the attributes and the interstitial's fallback named here, and every `x-data`
17
+ a page writes still resolves.
18
+ - **The factories.** `cropPhotoModal` is `studio/cropper`; `imageUploadHost`,
19
+ `avatarCropperHost` and `submitFormWithProgress` are `studio/image_upload`;
20
+ `birthdayModal` and `studioBirthdayFields` are `studio/birthday`;
21
+ `studioProfileForm` is `studio/profile_form`. `studio/alpine_scopes`
22
+ publishes each on `window`, where a host has not defined the name first.
23
+ They are in the preloaded boot graph, not lazy: Alpine evaluates an `x-data`
24
+ as it meets the element, and a factory that is not there leaves a photo
25
+ upload, the birthday gate or the profile form dead. `layouts/studio/_head`
26
+ also imports `studio/alpine_scopes` by its own nonced module tag, so a boot
27
+ that fails to load keeps a page its factories.
28
+ - **Partials.** `studio/modals/_image_upload` and `studio/_birthday_assets`
29
+ render nothing and stay, so a layout that renders either by name keeps
30
+ working; the renders can be deleted. `studio/_cropper_assets` loads
31
+ Cropper.js (from cdnjs, as before) and nothing else.
32
+ - **Compact identity bar.** `studio/profiles/_identity_mini` carries
33
+ `data-studio-controller="identity-mini"`; when the bar shows is
34
+ `studio/identity_mini`. The controller is registered statically, because on
35
+ the edit page the bar carries Save and Discard.
36
+ - **Sign-in interstitial.** `studio/_confirm_interstitial` posts its form from
37
+ `studio/confirm_interstitial`, loaded by its own nonced module tag (the page
38
+ has no import map). The fallback button shows itself four seconds in two
39
+ ways: by CSS alone, so a page whose script never arrives still has something
40
+ to press, and by the module's own timer, for an engine that holds a CSS
41
+ animation while the post is pending (WebKit). It is hidden by `max-height`
42
+ and `visibility` in place of `display: none`.
43
+ - **Style guide.** The Modals section's demo drivers, its enter/leave
44
+ simulator and the wallet stubs are `studio/style_modals`, on a
45
+ `style-modals` controller the section's own module tag registers
46
+ (`studio/style_guide`); its demo buttons carry `data-studio-action` in place
47
+ of `onclick`. `$store.dsModals` is the engine's own modal stack: the overlay
48
+ declares `data-studio-controller="modal-host"` and
49
+ `data-modal-host-store-value="dsModals"`, and the guide's copy of the stack
50
+ is deleted. The card carries no `max-w-*` of its own and takes its width
51
+ from `StudioModals.CARD_WIDTHS` by id, as an app's cards do.
52
+
53
+ ### Added
54
+
55
+ - **A page whose x-data factories failed to load says so, and its profile form
56
+ can still be saved.** One failed request among `studio/alpine_scopes`,
57
+ `studio/cropper`, `studio/image_upload`, `studio/birthday` and
58
+ `studio/profile_form` leaves all six factories undefined until a reload. A
59
+ nonced inline guard in `layouts/studio/_head` (`studio/_alpine_scopes_guard`,
60
+ beside the hold button's) marks each element that binds one of them
61
+ `data-studio-scope-failed="<factory>"` once the page's boot is over and puts
62
+ `<p class="studio-scope-notice" role="status">This did not load. Reload the
63
+ page.</p>` first inside it, on a card a modal mounts later too. Inside a
64
+ marked element every `[data-studio-scope-fallback]` is shown and a text field
65
+ left blank goes back to the value the server rendered: the profile form's
66
+ plain Save (`studio/profiles/_plain_save`) is such a fallback, so a person can
67
+ still save. A healthy or slow page is never marked. The line carries its own
68
+ styles, so a host changes nothing; `.studio-scope-notice` is there to restyle
69
+ it.
70
+ - **The crop photo modal says when Cropper.js did not load.** It waits up to
71
+ three seconds for the library (the script is deferred, and on a Turbo visit
72
+ can land after the card opens), then shows "The photo cropper did not load.
73
+ Reload the page and try again." on its error line and leaves the modal
74
+ dismissible. It used to show the image with no cropper and a Crop & Save
75
+ button that did nothing.
76
+
77
+ ### Breaking
78
+
79
+ - **`studio/profiles/_form_script` and `studio/profiles/_birthday_picker_script`
80
+ are deleted.** `studio/profiles/edit` and `studio/profiles/_birthday_fields`
81
+ no longer render them; an app that renders either by name deletes the render.
82
+ - **The factories are no longer in a page's HTML.** A test that looked for
83
+ `window.birthdayModal = function`, `window.imageUploadHost`,
84
+ `window.studioProfileForm` or `cropPhotoModal` source in a rendered page
85
+ looks for the name in `app/javascript/studio/alpine_scopes.js` and for
86
+ `import "studio/alpine_scopes"` in the page's head.
87
+ `Alpine.data("cropPhotoModal")` is no longer registered; the `x-data` resolves
88
+ through `window.cropPhotoModal`.
89
+ - **`window.dsModalDemos`, `window.dsWalletConnectDemo` and the stub
90
+ `window.walletProvider` exist only on the style guide, once its section's
91
+ controller has connected.** `registerDsModals` and
92
+ `window.__dsAnimControlsBound` are gone.
93
+ - **`#magic-fallback` on the sign-in interstitial is no longer `display: none`.**
94
+ A host stylesheet or test that read its `display` reads `visibility`.
95
+
7
96
  ## 0.97.0 — 2026-10-09
8
97
 
9
98
  ### Changed
@@ -0,0 +1,66 @@
1
+ // studio/alpine_scopes: the x-data factories the engine's partials and its
2
+ // hosts' pages bind, each published on window from the module that owns it.
3
+ //
4
+ // x-data="cropPhotoModal({ store: 'modals' })" studio/cropper
5
+ // x-data="imageUploadHost({ aspectRatio: 5 })" studio/image_upload
6
+ // x-data="avatarCropperHost()" studio/image_upload
7
+ // x-data="birthdayModal({ minAge, url })" studio/birthday
8
+ // x-data="studioBirthdayFields('1991-01-31')" studio/birthday
9
+ // x-data="studioProfileForm({ first_name: 'Pat' })" studio/profile_form
10
+ // submitFormWithProgress(form, opts) studio/image_upload
11
+ //
12
+ // WHY THESE ARRIVE WITH THE PAGE AND ARE NOT LAZY. Alpine evaluates an x-data
13
+ // expression when it meets the element: at start for a page's own markup, at
14
+ // mount for a modal card. A factory that is not defined by then leaves its
15
+ // element dead, and the element here is a photo upload, the birthday gate or
16
+ // the profile form. A dynamic import cannot promise to have resolved, and one
17
+ // that fails is final for the document (studio/lazy_controllers). So the
18
+ // factories are imported statically and preloaded on every page.
19
+ //
20
+ // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
21
+ // its own nonced module tag as well as through the boot graph
22
+ // (studio/alpine_shims imports it), like studio/alpine_stores. Its only imports
23
+ // are the modules that own the factories, and those import nothing, so a throw
24
+ // or a missing file elsewhere in studio/application's graph still leaves a
25
+ // page its uploads, its birthday gate and its profile form. It installs itself
26
+ // on evaluation, once.
27
+ //
28
+ // WHEN ONE OF THESE FILES FAILS TO LOAD, every name here is missing: a failed
29
+ // static import stops this module, by either door, for the life of the
30
+ // document. The inline guard in the head (studio/_alpine_scopes_guard) says so
31
+ // on each element that binds one and shows the profile form's plain Save. A
32
+ // factory added to SCOPES is added to that guard's list
33
+ // (test/javascript/alpine_scopes_guard.test.mjs holds the two together).
34
+ //
35
+ // A name a host defined first is left alone.
36
+ import { cropPhotoModal } from "studio/cropper"
37
+ import { imageUploadHost, avatarCropperHost, submitFormWithProgress } from "studio/image_upload"
38
+ import { birthdayModal, studioBirthdayFields } from "studio/birthday"
39
+ import { studioProfileForm } from "studio/profile_form"
40
+
41
+ // name -> the factory Alpine calls. Alpine passes the expression's own
42
+ // arguments and nothing else.
43
+ export const SCOPES = {
44
+ cropPhotoModal: (opts) => cropPhotoModal(opts),
45
+ imageUploadHost: (opts) => imageUploadHost(opts),
46
+ avatarCropperHost: () => avatarCropperHost(),
47
+ birthdayModal: (opts) => birthdayModal(opts),
48
+ studioBirthdayFields: (initial) => studioBirthdayFields(initial),
49
+ studioProfileForm: (initial) => studioProfileForm(initial)
50
+ }
51
+
52
+ // Plain functions a page calls by name.
53
+ export const FUNCTIONS = {
54
+ submitFormWithProgress: (form, opts) => submitFormWithProgress(form, opts)
55
+ }
56
+
57
+ export function installAlpineScopes(win) {
58
+ for (const name of Object.keys(SCOPES)) {
59
+ if (!win[name]) win[name] = SCOPES[name]
60
+ }
61
+ for (const name of Object.keys(FUNCTIONS)) {
62
+ if (!win[name]) win[name] = FUNCTIONS[name]
63
+ }
64
+ }
65
+
66
+ if (typeof window !== "undefined") installAlpineScopes(window)
@@ -15,6 +15,10 @@
15
15
  // host's body binding survives a boot that failed to load. Importing it here
16
16
  // keeps it in the boot graph, preloaded; it installs itself on evaluation.
17
17
  import "studio/alpine_stores"
18
+ // The x-data factories pages bind (cropPhotoModal, imageUploadHost, ...) are
19
+ // studio/alpine_scopes, which the head also loads by its own module tag and
20
+ // which installs itself the same way.
21
+ import "studio/alpine_scopes"
18
22
  import { NavCollapse } from "studio/nav_collapse"
19
23
  import {
20
24
  showNavSpinner, hideNavSpinner, installSpinnerReset, fireSuccessConfetti
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // The Stimulus application itself, its attribute schema, the hold button and
6
6
  // the lazily registered page-specific controllers are studio/stimulus. This
7
- // file registers the every-page chrome on it.
7
+ // file registers the every-page chrome on it, and the profile page's compact
8
+ // identity bar, which carries Save once the full card has scrolled away.
8
9
  //
9
10
  // Everything this file imports statically is preloaded on every page
10
11
  // (Studio::Engine.javascript_boot_graph), so the boot costs one round trip.
@@ -13,6 +14,7 @@ import NavCollapseController from "studio/controllers/nav_collapse_controller"
13
14
  import ModalHostController from "studio/controllers/modal_host_controller"
14
15
  import ToastController from "studio/controllers/toast_controller"
15
16
  import LinkSidebarController from "studio/controllers/link_sidebar_controller"
17
+ import IdentityMiniController from "studio/controllers/identity_mini_controller"
16
18
  import { startPinnedStack } from "studio/pinned_stack"
17
19
  import { installAlpineShims } from "studio/alpine_shims"
18
20
 
@@ -23,5 +25,6 @@ application.register("nav-collapse", NavCollapseController)
23
25
  application.register("modal-host", ModalHostController)
24
26
  application.register("toast", ToastController)
25
27
  application.register("link-sidebar", LinkSidebarController)
28
+ application.register("identity-mini", IdentityMiniController)
26
29
 
27
30
  export { application, schema }
@@ -0,0 +1,351 @@
1
+ // studio/birthday: the two places a person picks a date of birth from three
2
+ // selects (studio/fields/_date_of_birth), and the date arithmetic both share.
3
+ //
4
+ // x-data="birthdayModal({ minAge, state, url, store, gateId })"
5
+ // the birthday card (studio/modals/blocks/_birthday): asks for the date
6
+ // and posts it to the app, which decides.
7
+ // x-data="studioBirthdayFields('1991-01-31')"
8
+ // the profile page's birthday row (studio/profiles/_birthday_fields):
9
+ // writes the picked date into the enclosing profile form.
10
+ //
11
+ // Both bind month, day, year, months, dayOptions, years and onMonthChange().
12
+ //
13
+ // studio/alpine_scopes publishes both on window, statically: the birthday card
14
+ // gates an entry, and a card whose factory is missing has nothing to press.
15
+ // It imports nothing; test/javascript/birthday.test.mjs loads it as a data:
16
+ // module.
17
+
18
+ export const MONTHS = [
19
+ { n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
20
+ { n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
21
+ { n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
22
+ { n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
23
+ ]
24
+
25
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
26
+
27
+ // How long the style guide's demo card takes to answer.
28
+ export const DEMO_RESOLVE_MS = 450
29
+
30
+ export const VERIFY_FAILED = "We couldn't verify your age. Please check the date and try again."
31
+ export const NETWORK_ERROR = "Network error — please try again."
32
+
33
+ // The days a month has: 30 in April, 29 in a February only in a leap year. Day
34
+ // 0 of the next month is the last day of this one. With no year picked the
35
+ // year is 2000, a leap year, so February offers 29. With no month, 31.
36
+ export function daysInMonth(month, year) {
37
+ const m = parseInt(month, 10)
38
+ if (!m) return 31
39
+ return new Date(parseInt(year, 10) || 2000, m, 0).getDate()
40
+ }
41
+
42
+ // [1, 2, ... n] for the day select.
43
+ export function dayOptions(month, year) {
44
+ return Array.from({ length: daysInMonth(month, year) }, (_, index) => index + 1)
45
+ }
46
+
47
+ // Whether a picked day still exists once the month or year changes (the 31st
48
+ // does not survive a move to February).
49
+ export function dayExists(day, month, year) {
50
+ return !!day && parseInt(day, 10) <= daysInMonth(month, year)
51
+ }
52
+
53
+ // The years counted down from `newest` years ago to `oldest` years ago.
54
+ export function yearsBack(now, newest, oldest) {
55
+ const years = []
56
+ for (let y = now.getFullYear() - newest; y >= now.getFullYear() - oldest; y--) years.push(y)
57
+ return years
58
+ }
59
+
60
+ // Whole years between a date of birth and `now`: a birthday later this year
61
+ // has not happened yet.
62
+ export function ageOn(year, month, day, now) {
63
+ const dob = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10))
64
+ let age = now.getFullYear() - dob.getFullYear()
65
+ const hadBirthday = (now.getMonth() > dob.getMonth()) ||
66
+ (now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate())
67
+ if (!hadBirthday) age -= 1
68
+ return age
69
+ }
70
+
71
+ // "1991-01-31" as the three strings the selects hold ("1991", "1", "31"), or
72
+ // three blanks for anything that is not a full date. Strings, because that is
73
+ // what a pick produces: an option renders :value="m.n", the DOM stringifies it
74
+ // and x-model writes the string back.
75
+ export function partsOfIso(value) {
76
+ const match = ISO_DATE.exec(String(value || ""))
77
+ if (!match) return { year: "", month: "", day: "" }
78
+ return { year: String(+match[1]), month: String(+match[2]), day: String(+match[3]) }
79
+ }
80
+
81
+ // The three parts as "1991-01-31", or "" until all three are picked.
82
+ export function isoOfParts(year, month, day) {
83
+ if (!(year && month && day)) return ""
84
+ return year + "-" + String(month).padStart(2, "0") + "-" + String(day).padStart(2, "0")
85
+ }
86
+
87
+ // What the profile form's dirty check compares. A complete date is its ISO
88
+ // string, so a finished edit compares against the value the page loaded with.
89
+ // An untouched empty field is "", the same as a person with no birthday on
90
+ // file. A partial pick is the three parts joined, so choosing only a month
91
+ // reads as a change.
92
+ export function dateSignature(year, month, day) {
93
+ if (year && month && day) return isoOfParts(year, month, day)
94
+ if (!year && !month && !day) return ""
95
+ return [year, month, day].join("-")
96
+ }
97
+
98
+ // The date a modal entry's props carry back from the age-gate card
99
+ // (dobYear, dobMonth, dobDay), as select strings, or null when any part is
100
+ // missing. A day the restored month does not have comes back blank: a select
101
+ // holding a value its options do not contain reads as complete and would
102
+ // submit a date nobody picked.
103
+ export function returnedDob(props) {
104
+ props = props || {}
105
+ const y = parseInt(props.dobYear, 10)
106
+ const m = parseInt(props.dobMonth, 10)
107
+ const d = parseInt(props.dobDay, 10)
108
+ if (!y || !m || !d) return null
109
+ return { year: String(y), month: String(m), day: d <= daysInMonth(m, y) ? String(d) : "" }
110
+ }
111
+
112
+ // The line a refusal reads when no age-gate card is registered.
113
+ export function refusalLine(minAge, stateCode) {
114
+ return "You must be " + minAge + "+" + (stateCode ? " in " + stateCode : "") + "."
115
+ }
116
+
117
+ // What the app's answer means. The app tells "too young" from "bad request":
118
+ // body.underage or a 403 is a refusal, which goes to the age-gate card; any
119
+ // other failure is an error, which stays on the card's own line.
120
+ export function verdictOf(response) {
121
+ const body = (response && response.body) || {}
122
+ if (response && response.ok && body.verified) return { verdict: "verified" }
123
+ if (body.underage === true || (response && response.status === 403)) {
124
+ return { verdict: "refused", message: body.error || body.message || "" }
125
+ }
126
+ return { verdict: "error", message: body.error || VERIFY_FAILED }
127
+ }
128
+
129
+ // x-data for the birthday card. The factory holds no policy: minAge is the
130
+ // app's (0 means the app supplied none), the age it computes is a hint, and
131
+ // the app's answer at `url` is the verdict.
132
+ //
133
+ // opts:
134
+ // minAge the app's minimum age.
135
+ // state a jurisdiction label, shown and never edited.
136
+ // url the app endpoint the date posts to.
137
+ // store the Alpine store the card is mounted in (default "modals").
138
+ // gateId the modal a refusal opens (the age-gate card). Blank, the
139
+ // refusal is the card's own error line.
140
+ // demo the style guide: answer locally, post nothing.
141
+ // demoUnderage the style guide: take the refused branch.
142
+ //
143
+ // env: { win, now } for the tests.
144
+ export function birthdayModal(opts, env) {
145
+ opts = opts || {}
146
+ const win = (env && env.win) || window
147
+ const now = (env && env.now) || new Date()
148
+
149
+ return {
150
+ month: "", day: "", year: "",
151
+ submitting: false,
152
+ error: "",
153
+ minAge: parseInt(opts.minAge, 10) || 0,
154
+ stateCode: opts.state || "",
155
+ url: opts.url || "",
156
+ store: opts.store || "modals",
157
+ demo: !!opts.demo,
158
+ demoUnderage: !!opts.demoUnderage,
159
+ gateId: opts.gateId || "",
160
+ months: MONTHS,
161
+ // From 13 years ago back 100: a sane range to pick from, not an age bar.
162
+ years: yearsBack(now, 13, 100),
163
+
164
+ get dayOptions() {
165
+ return dayOptions(this.month, this.year)
166
+ },
167
+ get complete() {
168
+ return !!(this.month && this.day && this.year)
169
+ },
170
+ get computedAge() {
171
+ if (!this.complete) return null
172
+ return ageOn(this.year, this.month, this.day, now)
173
+ },
174
+ get isUnderage() {
175
+ return this.computedAge !== null && !!this.minAge && this.computedAge < this.minAge
176
+ },
177
+
178
+ onMonthChange() {
179
+ if (this.day && !dayExists(this.day, this.month, this.year)) this.day = ""
180
+ this.error = ""
181
+ },
182
+
183
+ // THE RETURN TRIP. _reject writes the date onto the store on its way to the
184
+ // age-gate card, and that card's back() hands the same three parts back, so
185
+ // a card mounted from a return trip finds the person's date on its own
186
+ // entry. Read off the store, where this factory already meets it.
187
+ _returnedDob() {
188
+ try {
189
+ const entry = win.Alpine.store(this.store).current()
190
+ return returnedDob(entry && entry.props)
191
+ } catch (_) {
192
+ return null
193
+ }
194
+ },
195
+
196
+ // Alpine calls init() at mount, before it walks the three selects, so the
197
+ // restored date is their first state. The DATE is restored, never the
198
+ // verdict: the app decides again on the next submit.
199
+ init() {
200
+ const dob = this._returnedDob()
201
+ if (!dob) return
202
+ this.year = dob.year
203
+ this.month = dob.month
204
+ this.day = dob.day
205
+ },
206
+
207
+ // Accepted: flip the session flag if the page has one, close, and let the
208
+ // app resume what it was gating (it listens for `age-verified`).
209
+ _finish() {
210
+ try {
211
+ const session = win.Alpine && win.Alpine.store && win.Alpine.store("session")
212
+ if (session) session.ageVerified = true
213
+ } catch (_) {}
214
+ try { win.Alpine.store(this.store).close() } catch (_) {}
215
+ win.dispatchEvent(new win.CustomEvent("age-verified"))
216
+ },
217
+
218
+ // Refused: hand off to the age-gate card, which owns what to do next. A
219
+ // handoff and not an error, so the person is never left on a red card with
220
+ // nothing to press. swap(), so that card's back link returns here. The date
221
+ // crosses the store as three numbers, which a reactive proxy carries intact.
222
+ _reject(message) {
223
+ if (!this.gateId) {
224
+ this.error = message || this._refusalLine()
225
+ return
226
+ }
227
+ try {
228
+ win.Alpine.store(this.store).swap(this.gateId, {
229
+ minAge: this.minAge,
230
+ state: this.stateCode,
231
+ message: message || "",
232
+ dobYear: parseInt(this.year, 10) || null,
233
+ dobMonth: parseInt(this.month, 10) || null,
234
+ dobDay: parseInt(this.day, 10) || null
235
+ })
236
+ } catch (_) {
237
+ this.error = message || this._refusalLine()
238
+ }
239
+ },
240
+
241
+ _refusalLine() {
242
+ return refusalLine(this.minAge, this.stateCode)
243
+ },
244
+
245
+ // No refusal on the client: every complete date goes to the app, and its
246
+ // answer routes here.
247
+ submit() {
248
+ if (!this.complete || this.submitting) return
249
+ this.error = ""
250
+ this.submitting = true
251
+ const self = this
252
+
253
+ if (this.demo) {
254
+ win.setTimeout(function () {
255
+ self.submitting = false
256
+ if (self.demoUnderage || self.isUnderage) self._reject()
257
+ else self._finish()
258
+ }, DEMO_RESOLVE_MS)
259
+ return
260
+ }
261
+
262
+ const csrf = (win.document.querySelector('meta[name="csrf-token"]') || {}).content || ""
263
+ win.fetch(this.url, {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
266
+ body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
267
+ })
268
+ .then(function (response) {
269
+ return response.json().then(function (body) { return { ok: response.ok, status: response.status, body } })
270
+ })
271
+ .then(function (response) {
272
+ self.submitting = false
273
+ const answer = verdictOf(response)
274
+ if (answer.verdict === "verified") self._finish()
275
+ else if (answer.verdict === "refused") self._reject(answer.message)
276
+ else self.error = answer.message
277
+ })
278
+ .catch(function () {
279
+ self.submitting = false
280
+ self.error = NETWORK_ERROR
281
+ })
282
+ }
283
+ }
284
+ }
285
+
286
+ // x-data for the profile page's birthday row: three selects in place of a date
287
+ // picker, so there is no popover to place and none to restore open. `initial`
288
+ // is the ISO date on file, or blank.
289
+ //
290
+ // It sits inside the profile form's scope (studio/profile_form), and reaches
291
+ // that scope's `fields` through Alpine's scope chain: publishing the picked
292
+ // date into fields.birthday is what raises the save bar.
293
+ export function studioBirthdayFields(initial, env) {
294
+ const today = () => (env && env.now) || new Date()
295
+ const parts = partsOfIso(initial)
296
+
297
+ return {
298
+ year: parts.year,
299
+ month: parts.month,
300
+ day: parts.day,
301
+ months: MONTHS,
302
+
303
+ // This year back 120: a birthday is not in the future, and 120 covers
304
+ // every living person.
305
+ get years() {
306
+ return yearsBack(today(), 0, 120)
307
+ },
308
+ get dayOptions() {
309
+ return dayOptions(this.month, this.year)
310
+ },
311
+ get complete() {
312
+ return !!(this.year && this.month && this.day)
313
+ },
314
+ get isoValue() {
315
+ return isoOfParts(this.year, this.month, this.day)
316
+ },
317
+ get signature() {
318
+ return dateSignature(this.year, this.month, this.day)
319
+ },
320
+
321
+ // Named for what the shared field binds. Clears a day the new month or
322
+ // year does not have, then publishes.
323
+ onMonthChange() {
324
+ if (this.day && !dayExists(this.day, this.month, this.year)) this.day = ""
325
+ this.publish()
326
+ },
327
+
328
+ publish() {
329
+ if (this.fields) this.fields.birthday = this.signature
330
+ },
331
+
332
+ // DISCARD REACHES THE SELECTS through this watch. The form's discard
333
+ // reassigns its `fields`, which restores every input bound with x-model;
334
+ // the selects are bound to this scope's own parts, so they follow
335
+ // fields.birthday instead. The early return when the value already matches
336
+ // is what stops publish and watch from looping.
337
+ init() {
338
+ const self = this
339
+ this.publish()
340
+ this.$watch("fields.birthday", function (value) {
341
+ if (value === self.signature) return
342
+ self.adopt(value)
343
+ })
344
+ },
345
+
346
+ // Puts the selects on an ISO date; anything else empties all three.
347
+ adopt(value) {
348
+ Object.assign(this, partsOfIso(value))
349
+ }
350
+ }
351
+ }
@@ -0,0 +1,44 @@
1
+ // studio/confirm_interstitial: the scanner-safe sign-in page
2
+ // (studio/_confirm_interstitial) posts its own form. The GET that renders the
3
+ // page is inert; this POST is what burns the single-use token and signs in.
4
+ //
5
+ // The page is a whole document with no layout, no import map and no Stimulus
6
+ // application, so it loads this module by its own tag and the module runs on
7
+ // evaluation. It posts once: the form is stamped, so a second evaluation (a
8
+ // restored page) does not post again.
9
+ //
10
+ // THE FALLBACK BUTTON HAS TWO WAYS TO APPEAR, four seconds in. The page shows
11
+ // it by CSS alone, so a page whose script never arrives still has something to
12
+ // press. This module shows it too, by a timer, because an engine may hold a
13
+ // CSS animation while the post's navigation is pending (WebKit does, and the
14
+ // post is pending for exactly as long as the button is wanted).
15
+ //
16
+ // It imports nothing; test/javascript/confirm_interstitial.test.mjs loads it
17
+ // as a data: module.
18
+
19
+ export const FORM_ID = "magic-consume-form"
20
+ export const FALLBACK_ID = "magic-fallback"
21
+ // The class that shows the fallback (the page's own stylesheet).
22
+ export const SHOWN_CLASS = "is-shown"
23
+ export const FALLBACK_AFTER_MS = 4000
24
+
25
+ // Submits the form unless it has been submitted already. Answers whether it
26
+ // submitted.
27
+ export function autoSubmit(form) {
28
+ if (!form || form.dataset.autoSubmitted) return false
29
+ form.dataset.autoSubmitted = "1"
30
+ if (typeof form.requestSubmit === "function") form.requestSubmit()
31
+ else form.submit()
32
+ return true
33
+ }
34
+
35
+ // Shows the fallback block once `ms` have passed. Returns the timer.
36
+ export function revealFallbackLater(fallback, later, ms) {
37
+ if (!fallback) return null
38
+ return later(function () { fallback.classList.add(SHOWN_CLASS) }, ms == null ? FALLBACK_AFTER_MS : ms)
39
+ }
40
+
41
+ if (typeof document !== "undefined") {
42
+ autoSubmit(document.getElementById(FORM_ID))
43
+ revealFallbackLater(document.getElementById(FALLBACK_ID), setTimeout)
44
+ }
@@ -0,0 +1,32 @@
1
+ // identity-mini: the compact identity bar's lifetime, on the engine's Stimulus
2
+ // application (studio/application, which reads data-studio-controller). It sits
3
+ // on the bar studio/profiles/_identity_mini renders:
4
+ //
5
+ // <div data-studio-identity-mini data-studio-controller="identity-mini"
6
+ // class="studio-identity-mini" inert>
7
+ //
8
+ // and watches the page's full card ([data-studio-identity-full]). When the bar
9
+ // shows is studio/identity_mini. connect() starts the watch, on a full load
10
+ // and on a Turbo visit alike; the bar goes back to hidden and inert before
11
+ // Turbo caches the page and when it leaves it.
12
+ //
13
+ // Registered statically by studio/application: on the edit page the bar
14
+ // carries Save and Discard once the card has scrolled away, so it arrives with
15
+ // the page.
16
+ import { Controller } from "@hotwired/stimulus"
17
+ import { IdentityMini, FULL_SELECTOR } from "studio/identity_mini"
18
+
19
+ export default class extends Controller {
20
+ connect() {
21
+ this.bar = new IdentityMini(this.element, document.querySelector(FULL_SELECTOR), window.IntersectionObserver)
22
+ this.bar.start()
23
+ this.onBeforeCache = () => this.bar.reset()
24
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
25
+ }
26
+
27
+ disconnect() {
28
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
29
+ if (this.bar) this.bar.reset()
30
+ this.bar = null
31
+ }
32
+ }