studio-engine 0.51.0 → 0.52.0

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: '047185d5f84476c97f8c3ecbc62c8392b4ab2b13f4124c1088ee3c92d99748ba'
4
- data.tar.gz: c2c5406a1892ef48e20fd2cd6c93409697cd8e1419e1b47799e86d913106c18f
3
+ metadata.gz: 2a59868c458671e78335fa67057b5914b85fa9ec7f237852dc4d3a543262dcc1
4
+ data.tar.gz: 580857e88f928bbf76882d984cee10bd2bd86471d1a649f0d2b8e9228832622e
5
5
  SHA512:
6
- metadata.gz: e11dfb9c11733764b8267f3e447f6af65f5778e252357bb6da1aa8daeb464a988361671ebdfd096b2477d78e40cc5561f0d643dc036e8ce210ba32e406ff739c
7
- data.tar.gz: 1a2f0d7e26663e7afcffd87888eb06f3c6b1bd4345b4148990b1f07d1da3f6fae24cf1c00a1e68232d1b30ab79ad386c98af78927c544bff9892e51c6d761ca5
6
+ metadata.gz: 2b3dad1a4bb20643705d263a3edaaa3ec97f9fb3cea437d12833bda1dc30766450cfdeb021cd01759df3334e23721a05bb26f0af3c55df324d4bfc1f54e53223
7
+ data.tar.gz: 48c6b729890c548c00c5b1f6881ffda6a636acde4303914e9bf243fbbf3065f82613a970630554880ca9ccbdaa1cf4f2f4b74618d55f458556a42fc98a768da8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,114 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Changed
8
+
9
+ - **The edit page's avatar is now the control; its corner badge is gone.**
10
+ Hovering the identity card fades a **Change photo** label over the picture, and
11
+ clicking anywhere on the picture opens the file picker and then the cropper. A
12
+ 128px target replaces a 28px one, and a label replaces a glyph that had to be
13
+ interpreted. The reveal also fires on **focus**, because a keyboard never
14
+ hovers and this is the only route to changing a photo; on touch the label stays
15
+ visible over a thinner scrim, so the affordance exists without hiding the photo
16
+ it describes.
17
+
18
+ The label is the button's **accessible name**, so it is hidden by `opacity`
19
+ and never by `display`/`visibility` — either of those would drop it out of the
20
+ accessibility tree and leave the button silently unnamed at rest.
21
+
22
+ **The read page is unchanged**: its whole card is a link to `/profile/edit` and
23
+ its badge stays a decorative `<span>` (an `<a>` inside an `<a>` is invalid, and
24
+ browsers repair it by closing the outer link early).
25
+
26
+ - **Birthday is entered through a calendar rather than an open field.** The
27
+ popover carries month and year **selects** rather than only step arrows —
28
+ stepping from today to 1985 is about four hundred clicks, which is why
29
+ turf-monster's contest picker could not simply be reused. Future dates are
30
+ disabled per-day (the boundary month is half valid), today is still selectable,
31
+ and a set birthday can be cleared.
32
+
33
+ It is `position: fixed`, placed from the trigger's rect, for two reasons: an
34
+ absolutely-positioned popover is clipped the day a consumer's card carries
35
+ `position: relative` alongside `overflow-hidden`, and viewport coordinates only
36
+ stay meaningful under `fixed` once the page scrolls.
37
+
38
+ **Without JavaScript the native `<input type="date">` renders instead**, and
39
+ the two branches are `<template x-if>` rather than `x-show` — only one is ever
40
+ in the DOM, so the form can never submit two `profile[birthday]` fields.
41
+
42
+ The three integer columns are unchanged; the UI joins them for entry and
43
+ `ProfilesController#update` splits them again.
44
+
45
+
46
+ ### Added
47
+
48
+ - **`/profile` gains the Newsletter row.** Lifted from turf-monster's `/account`
49
+ card, which has run this flow in production, and stripped of everything
50
+ turf-specific on the way: its 25-seed on-chain welcome bonus, its quest state,
51
+ its seeds level-up payload. What the engine takes is the part every app needs.
52
+
53
+ New routes: `POST /profile/newsletter` (`profile_newsletter_path`) joins,
54
+ `DELETE` on the same path leaves.
55
+
56
+ **TWO TIMESTAMPS, NOT A BOOLEAN.** `joined_email_list_at` and
57
+ `left_email_list_at`, matching turf, because the pair carries three states a
58
+ flag cannot: never asked (both nil), subscribed (joined after left, *including
59
+ a rejoin where both are set*), and unsubscribed. `Studio::Newsletter` holds the
60
+ rules — `subscribed?`, `ever_joined?`, `needs_email?` — pure and duck-typed
61
+ like `Studio::OauthIdentity`.
62
+
63
+ `ever_joined?` is deliberately a different question from `subscribed?`: leaving
64
+ stamps a date and never clears the join, so a consumer paying a once-ever
65
+ welcome bonus cannot have it re-earned by cycling.
66
+
67
+ **ASYMMETRIC ON PURPOSE.** Joining is one click; leaving asks for confirmation
68
+ in a modal. Joining is reversible from the same card, so a confirm step would
69
+ be friction protecting nothing — a mis-click on leave is silent until the next
70
+ send that never arrives. An account with **no address on file** (a wallet-only
71
+ sign-in) is asked for one in a modal rather than allowed to submit and fail;
72
+ the address is written but **not** marked verified, because typing an address
73
+ is not the same as holding it.
74
+
75
+ Gated on `requires:` like every other row, so a host without the columns gets
76
+ silence rather than a 500. The columns ship consumer-first under
77
+ *Roll Out Standard Profile Columns*.
78
+
79
+ ### Fixed
80
+
81
+ - **A page-scoped modal host rendered outside an Alpine scope was inert, and
82
+ `/profile/edit` was in exactly that state.** `studio/modals/_scoped_host`
83
+ declares no `x-data` of its own, and Alpine 3 only initialises trees rooted at
84
+ one — so its outer `<template x-if>` never runs. The store registers, `open()`
85
+ pushes onto the stack, `current()` returns the right modal id, and **no dialog
86
+ reaches the document**. Every symptom points at the modal id or the store;
87
+ none of them is the cause.
88
+
89
+ `/profile/edit` mounts its crop-photo host after the `studioProfileForm` div
90
+ closes, with no scope above it, so the avatar cropper could not open. The
91
+ engine's other live call site (`studio/emails/index.html.erb`) happens to
92
+ render inside `x-data="emailRecipients(...)"`, which is the only reason this
93
+ pattern has worked anywhere.
94
+
95
+ Both pages now wrap the host in `<div x-data>`. Found by the newsletter row's
96
+ first browser spec — the view suite was green on the `@click` attribute
97
+ throughout.
98
+
99
+ ### Changed
100
+
101
+ - **The read page mounts a modal host only when a row asks for one.** This is the
102
+ registry's `modals:` key finally doing the job it was documented for — until
103
+ the newsletter row, nothing on `/profile` opened a modal and mounting a host
104
+ would have been furniture for nobody.
105
+
106
+ `modals:` is now **a partial path rather than a boolean**. A host row that
107
+ declares modals keeps its partial in the host's own app, so any convention like
108
+ `"studio/profiles/#{key}_modals"` would resolve to a path that does not exist
109
+ there. It costs one string and works for everyone.
110
+
111
+ The host is **not** the cropper: `/profile` mounts `studio/modals/_scoped_host`
112
+ and never `studio/cropper_assets`, because the avatar is read-only on that page.
113
+
114
+
7
115
  ### Added
8
116
 
9
117
  - **The link sidebar leads with a Profile link, shipped by the engine.** The
@@ -142,6 +142,59 @@ module Studio
142
142
  end
143
143
  end
144
144
 
145
+ # POST /profile/newsletter — join the mailing list.
146
+ #
147
+ # DIRECT, no confirmation. Joining is reversible in one click from the same
148
+ # card, so a confirm step would be friction protecting nothing. LEAVING is the
149
+ # one that asks, because a mis-click there is silent until the next send that
150
+ # never arrives.
151
+ #
152
+ # An account with no address on file supplies one here — a wallet-only sign-in
153
+ # has no email, and a newsletter needs somewhere to send. It is written as the
154
+ # account email but NOT marked verified: this proves the person can type an
155
+ # address, not that they hold it, and treating it as verified would turn a
156
+ # mailing-list form into an account-recovery path.
157
+ def subscribe_newsletter
158
+ return unsupported("newsletter") unless row_rendered?(:newsletter)
159
+
160
+ if Studio::Newsletter.needs_email?(current_user)
161
+ value = params.dig(:profile, :email).to_s.strip
162
+ unless value.match?(URI::MailTo::EMAIL_REGEXP)
163
+ return redirect_to profile_path, status: :see_other,
164
+ alert: "Enter an email address to subscribe."
165
+ end
166
+ current_user.email = value
167
+ end
168
+
169
+ rescue_and_log(target: current_user) do
170
+ # left_email_list_at is CLEARED rather than left in place. `subscribed?`
171
+ # compares the two dates, so a stale leave date in the future of the join
172
+ # would read as unsubscribed the moment the clock disagreed.
173
+ current_user.update!(joined_email_list_at: Time.current, left_email_list_at: nil)
174
+ redirect_to profile_path, notice: "You're on the list."
175
+ end
176
+ end
177
+
178
+ # DELETE /profile/newsletter — leave it.
179
+ #
180
+ # STAMPS A DATE, never clears the join. "Have they ever joined" is a different
181
+ # question from "are they on the list", and a consumer that pays a once-ever
182
+ # welcome bonus (turf-monster does, on-chain) needs the first one to survive
183
+ # every leave and rejoin. Clearing joined_email_list_at here would let someone
184
+ # re-earn it by cycling.
185
+ def unsubscribe_newsletter
186
+ return unsupported("newsletter") unless row_rendered?(:newsletter)
187
+
188
+ unless Studio::Newsletter.subscribed?(current_user)
189
+ return redirect_to profile_path, alert: "You're not subscribed.", status: :see_other
190
+ end
191
+
192
+ rescue_and_log(target: current_user) do
193
+ current_user.update!(left_email_list_at: Time.current)
194
+ redirect_to profile_path, notice: "You've been unsubscribed."
195
+ end
196
+ end
197
+
145
198
  private
146
199
 
147
200
  # Would the page render this row for this viewer?
@@ -1,4 +1,4 @@
1
- <%# Birthday — ONE date input over THREE integer columns.
1
+ <%# Birthday — ONE date over THREE integer columns, entered through a calendar.
2
2
 
3
3
  Locals: user (required).
4
4
 
@@ -9,23 +9,221 @@
9
9
  (needs month and day, and no year at all). A single date column answers the
10
10
  second one badly.
11
11
 
12
- So the UI joins them and ProfilesController#update splits them again. A
13
- native date input is used rather than three selects because a date is one
14
- idea to the person entering it, and the browser already knows how to enter
15
- one on every platform.
12
+ So the UI joins them and ProfilesController#update splits them again. The row
13
+ renders only when the host has all three columns, so nothing here re-checks.
16
14
 
17
- The row renders only when the host has all three columns, so nothing here
18
- re-checks.
15
+ TWO INPUTS, ONE NAME, AND ONLY EVER ONE IN THE DOM. `<template x-if>` is what
16
+ makes that true rather than a claim: the branch Alpine does not take is never
17
+ instantiated, so the form can never carry two `profile[birthday]` fields and
18
+ the server can never receive the wrong one. Toggling with x-show would leave
19
+ BOTH present and submit the hidden one's value too — the last duplicate wins
20
+ in Rack's params, which is precisely the bug this shape avoids.
21
+
22
+ no JS — the native `<input type="date">`. It is not a downgrade; it is the
23
+ control every platform already knows how to render, and the reason
24
+ a JS-less page can still set a birthday at all.
25
+ JS — the calendar below. `alpine` comes from the enclosing
26
+ studioProfileForm and is true only once that component has booted.
27
+
28
+ The operator asked for a calendar rather than an open field (2026-08-15).
19
29
  %>
30
+ <label class="block text-sm text-secondary mb-2 font-medium" for="profile_birthday">Date of birth</label>
31
+
20
32
  <%
21
33
  y = user.birth_year
22
34
  m = user.birth_month
23
35
  d = user.birth_day
24
36
  value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
25
37
  %>
26
- <label class="block text-sm text-secondary mb-2 font-medium" for="profile_birthday">Date of birth</label>
27
- <input type="date" name="profile[birthday]" id="profile_birthday"
28
- value="<%= value %>" class="input-field"
29
- autocomplete="bday" max="<%= Date.current.iso8601 %>"
30
- x-model="fields.birthday">
38
+
39
+ <div x-data="studioBirthdayPicker('<%= value %>')">
40
+ <%# --- the JS-less path ------------------------------------------------- %>
41
+ <template x-if="!alpine">
42
+ <input type="date" name="profile[birthday]" id="profile_birthday"
43
+ value="<%= value %>" class="input-field"
44
+ autocomplete="bday" max="<%= Date.current.iso8601 %>">
45
+ </template>
46
+
47
+ <%# --- the calendar ------------------------------------------------------ %>
48
+ <template x-if="alpine">
49
+ <div>
50
+ <%# The value the form actually submits. The calendar writes here; nothing
51
+ types into it. %>
52
+ <input type="hidden" name="profile[birthday]" :value="value">
53
+
54
+ <button type="button" x-ref="trigger" @click="toggle()"
55
+ @keydown.escape="open = false"
56
+ :aria-expanded="open ? 'true' : 'false'"
57
+ aria-haspopup="dialog"
58
+ class="input-field w-full text-left flex items-center justify-between gap-2">
59
+ <span :class="displayValue ? 'text-heading' : 'text-muted'"
60
+ x-text="displayValue || 'Select your date of birth'"></span>
61
+ <svg class="w-4 h-4 text-muted flex-shrink-0" fill="none" stroke="currentColor"
62
+ stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
63
+ <path stroke-linecap="round" stroke-linejoin="round"
64
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/>
65
+ </svg>
66
+ </button>
67
+
68
+ <div x-show="open" x-cloak
69
+ @click.outside="open = false"
70
+ @keydown.escape.window="open = false"
71
+ @scroll.window="place()" @resize.window="place()"
72
+ role="dialog" aria-label="Choose your date of birth"
73
+ class="studio-birthday-popover"
74
+ :style="'top:' + top + 'px; left:' + left + 'px; min-width:' + width + 'px;'">
75
+
76
+ <%# Month and year JUMP rather than step. Stepping from today to 1985 is
77
+ about four hundred clicks, which is the whole reason this is not the
78
+ contest picker. %>
79
+ <div class="studio-birthday-nav">
80
+ <select x-model.number="viewMonth" class="input-field studio-birthday-month"
81
+ aria-label="Month">
82
+ <template x-for="(name, i) in months" :key="name">
83
+ <option :value="i" x-text="name"></option>
84
+ </template>
85
+ </select>
86
+ <select x-model.number="viewYear" class="input-field studio-birthday-year"
87
+ aria-label="Year">
88
+ <template x-for="y in years" :key="y">
89
+ <option :value="y" x-text="y"></option>
90
+ </template>
91
+ </select>
92
+ </div>
93
+
94
+ <div class="studio-birthday-grid studio-birthday-weekdays">
95
+ <template x-for="(wd, i) in weekdays" :key="i">
96
+ <div x-text="wd"></div>
97
+ </template>
98
+ </div>
99
+
100
+ <div class="studio-birthday-grid">
101
+ <template x-for="(cell, i) in calDays()" :key="i">
102
+ <div>
103
+ <template x-if="cell">
104
+ <button type="button" @click="pick(cell)" :disabled="isFuture(cell)"
105
+ :class="isSelected(cell) && 'is-selected'"
106
+ class="studio-birthday-day"
107
+ x-text="cell"></button>
108
+ </template>
109
+ </div>
110
+ </template>
111
+ </div>
112
+
113
+ <%# A birthday already set is the only case where clearing is meaningful,
114
+ and without this the calendar is a one-way door — every day cell sets
115
+ a value and none of them unset one. %>
116
+ <div class="mt-3 flex justify-end" x-show="value">
117
+ <button type="button" @click="clear()"
118
+ class="text-xs text-secondary hover:text-heading transition">
119
+ Clear
120
+ </button>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </template>
125
+ </div>
126
+
31
127
  <p class="text-muted text-xs mt-2">Stored as day, month and year — we use it for birthdays and age checks.</p>
128
+
129
+ <style>
130
+ /* EVERY RULE BELOW IS OWNED CSS, NOT TAILWIND UTILITIES, and that is not a
131
+ style preference — it is the bug the operator reported.
132
+
133
+ The engine ships a PREBUILT bundle to consumers. A Tailwind utility exists in
134
+ a consuming app only if that app's OWN views already emitted it, because its
135
+ build scans its own source, not the gem's. This calendar first shipped using
136
+ `grid grid-cols-7`, and `grid-cols-7` is rare enough that NO consumer had
137
+ ever emitted it: measured in mcritchie-studio's compiled bundle, zero
138
+ occurrences. With no grid, the seven weekday letters stacked into a single
139
+ vertical column and the popover grew to the height of the page.
140
+ `hover:bg-surface-alt` was missing for the same reason.
141
+
142
+ The same note is on studio/profiles/_identity_styles for the same reason.
143
+
144
+ AND THE BROWSER LANE COULD NOT SEE IT. e2e/tailwind_input.css carries
145
+ `@source "../app/views"`, so the lane compiles the engine's OWN views and
146
+ emits grid-cols-7 — every spec passed against a page no consumer renders.
147
+ That blindness is recorded in docs/E2E_LANE.md; the fix here is to stop
148
+ depending on the utility at all. */
149
+ .studio-birthday-nav {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 0.5rem;
153
+ margin-bottom: 0.75rem;
154
+ }
155
+
156
+ .studio-birthday-month { flex: 1 1 auto; }
157
+
158
+ .studio-birthday-month,
159
+ .studio-birthday-year {
160
+ font-size: 0.875rem;
161
+ padding-top: 0.25rem;
162
+ padding-bottom: 0.25rem;
163
+ }
164
+
165
+ /* THE ONE THAT BROKE. Seven columns, owned outright. */
166
+ .studio-birthday-grid {
167
+ display: grid;
168
+ grid-template-columns: repeat(7, minmax(0, 1fr));
169
+ text-align: center;
170
+ }
171
+
172
+ .studio-birthday-weekdays {
173
+ margin-bottom: 0.25rem;
174
+ font-size: 0.75rem;
175
+ font-weight: 500;
176
+ color: var(--color-text-muted, #9ca3af);
177
+ }
178
+
179
+ .studio-birthday-weekdays > div { padding: 0.25rem 0; }
180
+
181
+ .studio-birthday-day {
182
+ width: 2rem;
183
+ height: 2rem;
184
+ border-radius: 9999px;
185
+ font-size: 0.875rem;
186
+ color: var(--color-text-body, inherit);
187
+ transition: background-color 150ms ease, color 150ms ease;
188
+ }
189
+
190
+ .studio-birthday-day:hover:not(:disabled) {
191
+ background: var(--color-surface-alt, rgba(127, 127, 127, 0.15));
192
+ }
193
+
194
+ .studio-birthday-day:disabled {
195
+ opacity: 0.4;
196
+ cursor: not-allowed;
197
+ }
198
+
199
+ .studio-birthday-day.is-selected {
200
+ background: var(--color-primary);
201
+ color: #fff;
202
+ font-weight: 700;
203
+ }
204
+
205
+ /* FIXED, not absolute, for TWO reasons — and the second is the one a spec can
206
+ actually catch.
207
+
208
+ 1. Clipping. The edit page's rows sit inside `card p-0 overflow-hidden`. An
209
+ absolutely-positioned popover only escapes that clip while NO ancestor is
210
+ positioned; the day a consumer's card grows a `position: relative` it is
211
+ clipped out of sight, with perfectly correct markup.
212
+ 2. Anchoring. place() writes VIEWPORT coordinates from the trigger's rect.
213
+ Fixed keeps them meaningful as the page scrolls; absolute reinterprets
214
+ them as document coordinates and the popover drifts by the scroll offset.
215
+
216
+ At scroll 0 the two are indistinguishable, which is why the spec that proves
217
+ this scrolls first. */
218
+ .studio-birthday-popover {
219
+ position: fixed;
220
+ z-index: 40;
221
+ background: var(--color-surface);
222
+ border: 1px solid var(--color-border-subtle);
223
+ border-radius: 0.75rem;
224
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.25);
225
+ padding: 0.75rem;
226
+ }
227
+ </style>
228
+
229
+ <%= render "studio/profiles/birthday_picker_script" %>
@@ -0,0 +1,144 @@
1
+ <%# The birthday calendar's Alpine component.
2
+
3
+ Its own partial so the field markup stays readable and this can be reasoned
4
+ about as behaviour.
5
+
6
+ WHY NOT REUSE turf-monster's contest_lock_picker. That component is the
7
+ nearest thing in the ecosystem and its GRID is where this one's shape came
8
+ from, but its navigation is built for the opposite problem: picking a lock
9
+ time a few weeks OUT, by stepping months from today. Stepping to 1985 that
10
+ way is roughly four hundred clicks. A birthday needs to JUMP — hence the year
11
+ and month selects in the header — and it needs the future closed off, which a
12
+ contest picker specifically must not do.
13
+
14
+ WHY `position: fixed` AND NOT `absolute`. The edit page renders its rows
15
+ inside `card p-0 overflow-hidden`, which CLIPS an absolutely-positioned child
16
+ — the popover would open and be invisible below the row. Fixed takes it out
17
+ of the clipping context entirely, at the cost of having to place it by hand
18
+ from the trigger's rect and re-place it while it is open. The compact identity
19
+ header is fixed for a related reason, so this is the file's second instance of
20
+ the same trade rather than a one-off.
21
+ %>
22
+ <script>
23
+ window.studioBirthdayPicker = function (initial) {
24
+ return {
25
+ open: false,
26
+ value: initial || "",
27
+ viewYear: 0,
28
+ viewMonth: 0,
29
+ top: 0,
30
+ left: 0,
31
+ width: 0,
32
+
33
+ // Oldest year offered. 120 covers every living person with room to spare;
34
+ // the point is a bounded list, because a select has to end somewhere.
35
+ get years() {
36
+ var now = new Date().getFullYear();
37
+ var out = [];
38
+ for (var y = now; y >= now - 120; y--) out.push(y);
39
+ return out;
40
+ },
41
+
42
+ months: ["January", "February", "March", "April", "May", "June",
43
+ "July", "August", "September", "October", "November", "December"],
44
+
45
+ weekdays: ["S", "M", "T", "W", "T", "F", "S"],
46
+
47
+ init() {
48
+ this.syncViewFromValue();
49
+ },
50
+
51
+ // The month the grid opens on: the chosen birthday when there is one, else
52
+ // a sensible landing spot. NOT today — opening a birthday picker on the
53
+ // current year means everyone starts 30+ years from where they are going.
54
+ syncViewFromValue() {
55
+ var parts = this.parse(this.value);
56
+ var now = new Date();
57
+ this.viewYear = parts ? parts.y : now.getFullYear() - 30;
58
+ this.viewMonth = parts ? parts.m - 1 : 0;
59
+ },
60
+
61
+ parse(iso) {
62
+ var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ""));
63
+ if (!m) return null;
64
+ return { y: +m[1], m: +m[2], d: +m[3] };
65
+ },
66
+
67
+ get displayValue() {
68
+ var p = this.parse(this.value);
69
+ if (!p) return "";
70
+ return this.months[p.m - 1] + " " + p.d + ", " + p.y;
71
+ },
72
+
73
+ // Leading blanks so the 1st lands under its real weekday, then the days.
74
+ calDays() {
75
+ var first = new Date(this.viewYear, this.viewMonth, 1).getDay();
76
+ var count = new Date(this.viewYear, this.viewMonth + 1, 0).getDate();
77
+ var cells = [];
78
+ for (var i = 0; i < first; i++) cells.push(null);
79
+ for (var d = 1; d <= count; d++) cells.push(d);
80
+ return cells;
81
+ },
82
+
83
+ // A birthday cannot be in the future. Checked per-DAY rather than by
84
+ // clamping the year, because the boundary month is half valid.
85
+ isFuture(day) {
86
+ var candidate = new Date(this.viewYear, this.viewMonth, day);
87
+ var today = new Date();
88
+ today.setHours(0, 0, 0, 0);
89
+ return candidate > today;
90
+ },
91
+
92
+ isSelected(day) {
93
+ var p = this.parse(this.value);
94
+ return !!p && p.y === this.viewYear && p.m === this.viewMonth + 1 && p.d === day;
95
+ },
96
+
97
+ pick(day) {
98
+ if (this.isFuture(day)) return;
99
+ var mm = String(this.viewMonth + 1).padStart(2, "0");
100
+ var dd = String(day).padStart(2, "0");
101
+ this.value = this.viewYear + "-" + mm + "-" + dd;
102
+
103
+ // `fields` belongs to the enclosing studioProfileForm component. Alpine's
104
+ // scope chain makes it reachable from here, and it is the SAME object, so
105
+ // assigning through it is what raises the save bar. Without this line the
106
+ // picker would set a value the dirty check never notices.
107
+ if (this.fields) this.fields.birthday = this.value;
108
+
109
+ this.open = false;
110
+ },
111
+
112
+ clear() {
113
+ this.value = "";
114
+ if (this.fields) this.fields.birthday = "";
115
+ this.syncViewFromValue();
116
+ },
117
+
118
+ toggle() {
119
+ this.open = !this.open;
120
+ if (this.open) {
121
+ this.syncViewFromValue();
122
+ this.place();
123
+ }
124
+ },
125
+
126
+ // Placed from the trigger's rect because the popover is fixed. Flips above
127
+ // the trigger when there is not room below, so it never opens off-screen on
128
+ // a short viewport.
129
+ place() {
130
+ var el = this.$refs.trigger;
131
+ if (!el) return;
132
+ var r = el.getBoundingClientRect();
133
+ var estimatedHeight = 340;
134
+ var below = window.innerHeight - r.bottom;
135
+
136
+ this.left = r.left;
137
+ this.width = r.width;
138
+ this.top = below < estimatedHeight && r.top > estimatedHeight
139
+ ? r.top - estimatedHeight - 4
140
+ : r.bottom + 4;
141
+ }
142
+ };
143
+ };
144
+ </script>
@@ -28,7 +28,28 @@
28
28
  <%= render "studio/profiles/identity_mini", user: user %>
29
29
 
30
30
  <% if editable %>
31
- <div data-studio-identity-full class="card p-6 mb-6 text-center">
31
+ <%# `studio-identity-card-editable` is the hover hook: the operator asked for the
32
+ overlay to reveal on hovering the CARD, not only the picture, so the card
33
+ needs a class the stylesheet can hang that off. Deliberately NOT the read
34
+ page's `studio-identity-card`, which also paints a primary border to say
35
+ "this whole thing is a link" — a claim that is false here. %>
36
+ <%# THE WHOLE CARD IS THE TRIGGER (operator's call, 2026-08-15). The hover
37
+ already revealed the label across the whole card, so the click had to match
38
+ it — a surface that lights up on hover and does nothing when clicked reads
39
+ as broken.
40
+
41
+ `@click.self` is NOT used: the click must count anywhere on the card,
42
+ including on the name and the address. The inner avatar button keeps its own
43
+ handler with `.stop`, so clicking the picture fires the picker ONCE rather
44
+ than twice — the button and the card would otherwise both handle it and open
45
+ two file dialogs.
46
+
47
+ The button stays for keyboard and assistive tech: a div with a click handler
48
+ is unreachable by either, and this is still the only route to changing the
49
+ photo. %>
50
+ <div data-studio-identity-full
51
+ @click="$refs.filePicker.click()"
52
+ class="studio-identity-card-editable studio-identity-card-clickable card p-6 mb-6 text-center">
32
53
  <%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
33
54
  </div>
34
55
  <% else %>
@@ -5,38 +5,67 @@
5
5
 
6
6
  Locals: user, editable, attachable.
7
7
  %>
8
- <div class="relative mb-3 studio-avatar mx-auto" style="width: 128px; height: 128px;">
8
+ <%# THE PICTURE ITSELF IS THE CONTROL ON THE EDIT PAGE (operator's call). The
9
+ 28px pencil badge that used to sit in the corner is gone there: hovering the
10
+ card fades a label over the avatar, and clicking anywhere on it opens the
11
+ picker and then the cropper. A 128px target beats a 28px one, and the label
12
+ says what will happen instead of leaving a glyph to be interpreted.
13
+
14
+ The badge REMAINS on the read page, where it is decorative and the whole card
15
+ is the link. %>
16
+ <% avatar_style = "width: 128px; height: 128px;" %>
17
+
18
+ <% avatar_inner = capture do %>
9
19
  <% if attachable && user.avatar.attached? %>
10
20
  <%= image_tag user.avatar, class: "rounded-full object-cover",
11
- style: "width: 128px; height: 128px;", alt: user.display_name %>
21
+ style: avatar_style, alt: user.display_name %>
12
22
  <% else %>
13
23
  <div class="rounded-full flex items-center justify-center font-bold text-white text-4xl"
14
- style="width: 128px; height: 128px; background-color: <%= user.avatar_color %>">
24
+ style="<%= avatar_style %> background-color: <%= user.avatar_color %>">
15
25
  <%= user.avatar_initials %>
16
26
  </div>
17
27
  <% end %>
28
+ <% end %>
29
+
30
+ <% if editable && attachable %>
31
+ <%# A BUTTON, not a div with a click handler: this is the only route to
32
+ changing the photo, and a div is unreachable by keyboard and invisible to
33
+ assistive tech.
18
34
 
19
- <% badge_class = "studio-avatar-badge absolute bottom-0 right-0 flex items-center justify-center rounded-full border-2 border-page bg-primary text-white transition" %>
20
- <% badge_style = "width: 28px; height: 28px;" %>
21
-
22
- <% if editable && attachable %>
23
- <%# A real button: on the edit page this opens the cropper. %>
24
- <button type="button" @click="$refs.filePicker.click()"
25
- aria-label="Change your profile photo"
26
- class="<%= badge_class %>" style="<%= badge_style %>">
27
- <%= render "studio/profiles/pencil_icon" %>
28
- </button>
29
- <% elsif !editable %>
30
- <%# DECORATIVE on the read page, and it has to be: the whole card is a link
31
- now, and an <a> inside an <a> is invalid HTML that browsers repair by
32
- closing the outer one early which would silently cut the card's
33
- clickable area down to whatever came before the badge. The card carries
34
- the destination and the accessible name; this is just the glyph. %>
35
- <span class="<%= badge_class %>" style="<%= badge_style %>" aria-hidden="true">
36
- <%= render "studio/profiles/pencil_icon" %>
35
+ The visible label doubles as the ACCESSIBLE NAME, which is why it lives
36
+ inside the button rather than beside it and why there is no aria-label to
37
+ drift from it. It is hidden by opacity, never by `display:none` or
38
+ `visibility:hidden` those two remove an element from the accessibility
39
+ tree, which would leave this button silently unnamed for a screen reader
40
+ at rest. %>
41
+ <%# `.stop` because the CARD now carries the same handler. Without it the click
42
+ bubbles and the picker opens twice. %>
43
+ <button type="button" @click.stop="$refs.filePicker.click()"
44
+ class="studio-avatar studio-avatar-trigger relative mb-3 mx-auto block rounded-full overflow-hidden"
45
+ style="<%= avatar_style %>">
46
+ <%= avatar_inner %>
47
+
48
+ <span class="studio-avatar-overlay absolute inset-0 flex items-center justify-center rounded-full text-white font-semibold text-sm px-2 text-center">
49
+ Change photo
37
50
  </span>
38
- <% end %>
39
- </div>
51
+ </button>
52
+ <% else %>
53
+ <div class="relative mb-3 studio-avatar mx-auto" style="<%= avatar_style %>">
54
+ <%= avatar_inner %>
55
+
56
+ <% if !editable %>
57
+ <%# DECORATIVE on the read page, and it has to be: the whole card is a link
58
+ now, and an <a> inside an <a> is invalid HTML that browsers repair by
59
+ closing the outer one early — which would silently cut the card's
60
+ clickable area down to whatever came before the badge. The card carries
61
+ the destination and the accessible name; this is just the glyph. %>
62
+ <span class="studio-avatar-badge absolute bottom-0 right-0 flex items-center justify-center rounded-full border-2 border-page bg-primary text-white transition"
63
+ style="width: 28px; height: 28px;" aria-hidden="true">
64
+ <%= render "studio/profiles/pencil_icon" %>
65
+ </span>
66
+ <% end %>
67
+ </div>
68
+ <% end %>
40
69
 
41
70
  <p class="text-3xl font-extrabold text-heading"><%= user.display_name %></p>
42
71
  <% if user.respond_to?(:email) && user.email.present? %>
@@ -1,7 +1,14 @@
1
- <%# The avatar badge's reveal.
1
+ <%# The identity header's styles — the read page's badge, the edit page's avatar
2
+ control, and the compact header that takes over on scroll.
2
3
 
3
- The badge sits empty until you hover the picture, then the pencil fades in —
4
- the operator's call, and it keeps the resting state quiet.
4
+ TWO DIFFERENT AFFORDANCES, because the two pages ask different questions.
5
+
6
+ READ — the whole card is a link to /profile/edit. A 28px badge sits in the
7
+ avatar's corner, empty until you hover the card, and then the pencil
8
+ fades in. Decorative: the card carries the destination.
9
+ EDIT — there is no badge. The PICTURE is the button (operator's call,
10
+ 2026-08-15), and hovering the card fades a "Change photo" label over
11
+ it. A 128px target beats a 28px one, and a label beats a glyph.
5
12
 
6
13
  WRITTEN AS CSS RATHER THAN TAILWIND UTILITIES on purpose. The engine ships a
7
14
  prebuilt bundle to consumers, so a utility only exists if something already
@@ -21,7 +28,8 @@
21
28
  visible there rather than never appearing
22
29
 
23
30
  The badge circle itself is always visible in every case; it is only the
24
- glyph inside it that waits.
31
+ glyph inside it that waits. The edit page's overlay follows the same three
32
+ paths for the same reasons.
25
33
  %>
26
34
  <style>
27
35
  .studio-avatar-badge-icon {
@@ -29,19 +37,51 @@
29
37
  transition: opacity 200ms ease;
30
38
  }
31
39
 
32
- /* The read page: the whole card is the link, so hovering ANYWHERE on it
33
- reveals the glyph. The avatar and badge selectors stay for the edit page,
34
- where the badge is its own button. */
40
+ /* The READ page only. The badge is decorative there and the whole card is the
41
+ link, so hovering anywhere on it reveals the glyph.
42
+
43
+ The edit page's badge-button selectors used to live here and are GONE with
44
+ the button — the picture itself is the control there now, and its reveal is
45
+ the overlay block below. A selector for an element that no longer renders is
46
+ not harmless: it reads as coverage. */
35
47
  .studio-identity-card:hover .studio-avatar-badge-icon,
36
48
  .studio-identity-card:focus .studio-avatar-badge-icon,
37
49
  .studio-identity-card:focus-visible .studio-avatar-badge-icon,
38
- .studio-avatar:hover .studio-avatar-badge-icon,
39
- .studio-avatar-badge:hover .studio-avatar-badge-icon,
40
- .studio-avatar-badge:focus .studio-avatar-badge-icon,
41
- .studio-avatar-badge:focus-visible .studio-avatar-badge-icon {
50
+ .studio-avatar:hover .studio-avatar-badge-icon {
51
+ opacity: 1;
52
+ }
53
+
54
+ /* ---- THE EDIT PAGE'S AVATAR CONTROL -------------------------------------
55
+ The picture is the button. A scrim + label fades in over it on hover, so the
56
+ resting state is just the photo. */
57
+ .studio-avatar-overlay {
58
+ opacity: 0;
59
+ background: rgba(0, 0, 0, 0.55);
60
+ transition: opacity 200ms ease;
61
+ /* The label must never be the reason the button changes size. */
62
+ pointer-events: none;
63
+ }
64
+
65
+ /* Hovering the CARD reveals it (operator's call — not only the picture), and
66
+ so does focusing the button, because a keyboard never hovers and this is the
67
+ only route to changing the photo. */
68
+ .studio-identity-card-editable:hover .studio-avatar-overlay,
69
+ .studio-avatar-trigger:hover .studio-avatar-overlay,
70
+ .studio-avatar-trigger:focus .studio-avatar-overlay,
71
+ .studio-avatar-trigger:focus-visible .studio-avatar-overlay {
42
72
  opacity: 1;
43
73
  }
44
74
 
75
+ /* The whole card is the trigger, so it has to say so. */
76
+ .studio-identity-card-clickable { cursor: pointer; }
77
+
78
+ /* The button is a bare circle; give focus something to land on that is visible
79
+ against both a photo and a flat initials colour. */
80
+ .studio-avatar-trigger:focus-visible {
81
+ outline: 2px solid var(--color-primary);
82
+ outline-offset: 3px;
83
+ }
84
+
45
85
  /* The card reads as clickable before you click it. Kept subtle — this is a
46
86
  whole card lighting up, not a button. */
47
87
  .studio-identity-card {
@@ -53,18 +93,38 @@
53
93
  border-color: var(--color-primary);
54
94
  }
55
95
 
56
- /* No hover to give: show it and be done. */
96
+ /* No hover to give: show it and be done.
97
+
98
+ The overlay is a JUDGEMENT CALL rather than the same rule. Showing the full
99
+ scrim permanently on touch would hide the photo it is describing, so the
100
+ label stays but the scrim thins to where the picture still reads through.
101
+ The affordance exists; the picture is not lost to it. */
57
102
  @media (hover: none) {
58
103
  .studio-avatar-badge-icon { opacity: 1; }
104
+
105
+ .studio-avatar-overlay {
106
+ opacity: 1;
107
+ background: rgba(0, 0, 0, 0.35);
108
+ }
59
109
  }
60
110
 
61
111
  /* The compact header that takes over on scroll. FIXED so it costs no layout
62
112
  height while hidden; pinned to the navbar height the host publishes. */
63
113
  .studio-identity-mini {
64
114
  position: fixed;
65
- /* Sits just under the host navbar, with a little air so it reads as a card
66
- floating over the page rather than a second navbar welded to the first. */
67
- top: calc(var(--nav-h, 0px) + 0.5rem);
115
+ /* --nav-bottom, NOT --nav-h, and the engine already learned this once: the
116
+ two differ by exactly the height of any chrome an app stacks ABOVE the
117
+ navbar, and mcritchie-industries' 47px environment banner is exactly such
118
+ chrome. test/integration/sidebar_navbar_render_test.rb refuses `--nav-h`
119
+ for the sidebar panel for this reason; this bar had the same bug.
120
+ A `fixed` element's `top` is a viewport coordinate, so it must be the
121
+ header's BOTTOM EDGE.
122
+
123
+ FLUSH, with no added gap. It first carried `+ 0.5rem` "so it reads as a
124
+ card floating over the page"; the operator saw that as a strip of dead
125
+ space between the navbar and the bar (2026-08-15). The bar is a
126
+ continuation of the chrome, not a card hovering under it. */
127
+ top: var(--nav-bottom, var(--nav-h, 0px));
68
128
  /* CARD WIDTH, not full bleed. 42rem is max-w-2xl — the page container's
69
129
  width — and the calc keeps it inside the viewport's gutters on a narrow
70
130
  screen, where a fixed 42rem would run off the edge. Centred with the same
@@ -90,6 +150,7 @@
90
150
 
91
151
  @media (prefers-reduced-motion: reduce) {
92
152
  .studio-avatar-badge-icon,
153
+ .studio-avatar-overlay,
93
154
  .studio-identity-card,
94
155
  .studio-identity-mini { transition: none; }
95
156
  }
@@ -0,0 +1,61 @@
1
+ <%# The newsletter row's two modals, registered into the page's scoped host.
2
+
3
+ Locals: user (required).
4
+
5
+ BOTH CARRY THE FORM THEY SUBMIT, so there is exactly one place each verb is
6
+ issued from. A confirmation whose button posts a form elsewhere on the page
7
+ drifts the moment that form moves.
8
+
9
+ newsletter-unsubscribe — the confirmation. DELETE.
10
+ newsletter-email — the address capture, for an account with none on
11
+ file. POST, with the address in the same request.
12
+
13
+ The email modal exists because a wallet-only account (turf-monster has many)
14
+ has no address, and a newsletter needs somewhere to send. It is written as the
15
+ account email but NOT marked verified — see the controller for why that
16
+ distinction is load-bearing.
17
+ %>
18
+
19
+ <%# --- leaving asks --------------------------------------------------------- %>
20
+ <template x-if="$store.profileModals.current()?.id === 'newsletter-unsubscribe'">
21
+ <div>
22
+ <%= render "studio/modals/blocks/card_header",
23
+ size: :lg,
24
+ icon: :error,
25
+ title: "Unsubscribe?",
26
+ subtitle: "You'll stop receiving updates. You can join again from this page any time." %>
27
+
28
+ <%= form_with url: profile_newsletter_path, method: :delete, data: { turbo: false } do %>
29
+ <button type="submit" class="btn btn-danger btn-lg w-full">Unsubscribe</button>
30
+ <% end %>
31
+
32
+ <button type="button" @click="$store.profileModals.close()"
33
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
34
+ Cancel
35
+ </button>
36
+ </div>
37
+ </template>
38
+
39
+ <%# --- joining without an address on file ----------------------------------- %>
40
+ <template x-if="$store.profileModals.current()?.id === 'newsletter-email'">
41
+ <div>
42
+ <%= render "studio/modals/blocks/card_header",
43
+ size: :lg,
44
+ icon_color: :primary,
45
+ title: "Where should we send it?",
46
+ subtitle: "There's no email on this account yet. We'll use this for the newsletter." %>
47
+
48
+ <%= form_with url: profile_newsletter_path, method: :post, scope: :profile,
49
+ data: { turbo: false } do |f| %>
50
+ <%= f.email_field :email, required: true, autocomplete: "email",
51
+ placeholder: "you@example.com",
52
+ class: "input-field w-full mb-3" %>
53
+ <button type="submit" class="btn btn-primary btn-lg w-full">Subscribe</button>
54
+ <% end %>
55
+
56
+ <button type="button" @click="$store.profileModals.close()"
57
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
58
+ Cancel
59
+ </button>
60
+ </div>
61
+ </template>
@@ -0,0 +1,65 @@
1
+ <%# The newsletter row — subscribe, and unsubscribe behind a confirmation.
2
+
3
+ Locals: user (required).
4
+
5
+ LIFTED FROM turf-monster's /account card, which has run this flow in
6
+ production, and deliberately stripped of everything turf-specific on the way:
7
+ its 25-seed on-chain welcome bonus, its quest state, its seeds level-up
8
+ payload. What survives is the part every app needs — two states, a join, a
9
+ leave, and a confirmation before leaving.
10
+
11
+ SERVER-RENDERED, not JSON. turf's version POSTs and patches the card in place
12
+ because it sits beside a live seeds counter that must tick in the same
13
+ breath. Nothing on /profile is live, so a form and a redirect is the simpler
14
+ mechanism and the one that still works with no JavaScript at all. The only
15
+ JavaScript here is the confirmation.
16
+
17
+ ASYMMETRIC ON PURPOSE: joining is one click, leaving asks. Joining is
18
+ reversible from this same card in one more click, so a confirm step would be
19
+ friction protecting nothing; a mis-click on LEAVE is silent until the next
20
+ send that never arrives.
21
+ %>
22
+ <% subscribed = Studio::Newsletter.subscribed?(user) %>
23
+
24
+ <% if subscribed %>
25
+ <div class="flex items-center justify-between gap-3 flex-wrap">
26
+ <div class="flex items-center gap-2 min-w-0">
27
+ <svg class="w-5 h-5 text-primary flex-shrink-0" fill="none" stroke="currentColor"
28
+ stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
29
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
30
+ </svg>
31
+ <span class="text-body">Subscribed</span>
32
+ </div>
33
+
34
+ <%# Opens the confirmation rather than submitting. The FORM it submits lives
35
+ in the modal, so there is exactly one place the DELETE is issued from. %>
36
+ <button type="button" @click="$store.profileModals.open('newsletter-unsubscribe')"
37
+ class="text-sm text-secondary hover:text-heading transition underline-offset-2 hover:underline">
38
+ Unsubscribe
39
+ </button>
40
+ </div>
41
+ <% else %>
42
+ <div class="flex items-center justify-between gap-3 flex-wrap">
43
+ <p class="text-secondary text-sm min-w-0">
44
+ <% if Studio::Newsletter.ever_joined?(user) %>
45
+ You're not on the list. Join again any time.
46
+ <% else %>
47
+ Occasional updates. No spam, and one click to leave.
48
+ <% end %>
49
+ </p>
50
+
51
+ <%# NO EMAIL ON FILE — a wallet-only account. Asking in a modal rather than
52
+ growing a second inline form keeps the resting card one line, and it is
53
+ the same shape the operator asked for: the flow uses the modals. %>
54
+ <% if Studio::Newsletter.needs_email?(user) %>
55
+ <button type="button" @click="$store.profileModals.open('newsletter-email')"
56
+ class="btn btn-primary btn-sm flex-shrink-0">
57
+ Subscribe
58
+ </button>
59
+ <% else %>
60
+ <%= button_to "Subscribe", profile_newsletter_path, method: :post,
61
+ class: "btn btn-primary btn-sm flex-shrink-0",
62
+ form: { class: "flex-shrink-0" } %>
63
+ <% end %>
64
+ </div>
65
+ <% end %>
@@ -98,14 +98,31 @@
98
98
  <%= render "studio/profiles/form_script" %>
99
99
 
100
100
  <% if avatar_editable %>
101
- <%# Mounted ONCE per page. Two hosts sharing a store name would register the
101
+ <%# THE `x-data` IS LOAD-BEARING, and this page was missing it. Alpine 3 only
102
+ initialises trees rooted at an x-data, and studio/modals/_scoped_host
103
+ declares none of its own — so its outer `<template x-if>` sits inert, the
104
+ store registers fine, `open()` pushes onto the stack, and NOTHING RENDERS.
105
+ Every symptom points at the modal id or the store; none of them is the
106
+ cause.
107
+
108
+ This host sits AFTER the studioProfileForm div closes, so it had no Alpine
109
+ scope above it at all. Found while wiring the newsletter row's confirmation
110
+ on /profile, where the identical shape produced an identical silent
111
+ failure — current() returning the right id with no dialog in the document.
112
+ The engine's other live call site (studio/emails/index.html.erb) renders
113
+ inside `x-data="emailRecipients(...)"`, which is the only reason this
114
+ pattern has ever worked anywhere.
115
+
116
+ Mounted ONCE per page. Two hosts sharing a store name would register the
102
117
  same modal ids twice and render duplicate cards. %>
103
- <%= render "studio/modals/scoped_host", store: "profileModals" do %>
104
- <template x-if="$store.profileModals.current()?.id === 'crop-photo'">
105
- <div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
106
- </template>
107
- <template x-if="$store.profileModals.current()?.id === 'saving'">
108
- <div><%= render "studio/modals/saving", store: "profileModals" %></div>
109
- </template>
110
- <% end %>
118
+ <div x-data>
119
+ <%= render "studio/modals/scoped_host", store: "profileModals" do %>
120
+ <template x-if="$store.profileModals.current()?.id === 'crop-photo'">
121
+ <div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
122
+ </template>
123
+ <template x-if="$store.profileModals.current()?.id === 'saving'">
124
+ <div><%= render "studio/modals/saving", store: "profileModals" %></div>
125
+ </template>
126
+ <% end %>
127
+ </div>
111
128
  <% end %>
@@ -6,9 +6,21 @@
6
6
 
7
7
  Editing lives at /profile/edit. The avatar's badge links there, so the same
8
8
  control in the same place means "change this" on both pages.
9
+
10
+ THE MODAL HOST IS CONDITIONAL, and that is the registry's `modals:` key doing
11
+ the job it was documented for. Until the newsletter row shipped, NOTHING on
12
+ this page opened a modal, so mounting a host would have been furniture for
13
+ nobody. Now one row asks and the host appears; a host that declares the row
14
+ away gets no host again, automatically.
15
+
16
+ NOT studio/cropper_assets. That is ~40 KB of cropper.js for an upload
17
+ affordance this page does not have — the avatar is read-only here and the
18
+ picker lives on /profile/edit. A host is not a cropper.
9
19
  %>
10
20
  <% content_for(:title, "Profile") %>
11
21
 
22
+ <% modal_rows = @profile_sections.to_a.select { |section| section[:modals] } %>
23
+
12
24
  <div class="max-w-2xl mx-auto py-8">
13
25
  <%= render "studio/profiles/identity", user: current_user, editable: false %>
14
26
 
@@ -26,3 +38,26 @@
26
38
  </div>
27
39
  <% end %>
28
40
  </div>
41
+
42
+ <% if modal_rows.any? %>
43
+ <%# THE `x-data` IS LOAD-BEARING, and its absence is silent. Alpine 3 only
44
+ initialises trees rooted at an x-data, and _scoped_host declares none of its
45
+ own — its outer `<template x-if>` is inert without an Alpine scope above it.
46
+ The store still registers, `open()` still pushes onto the stack, and NOTHING
47
+ RENDERS. Measured on this very page: current() returned the right modal id
48
+ while the document contained no dialog at all.
49
+
50
+ The engine's own live call site (studio/emails/index.html.erb) happens to
51
+ render the host inside `x-data="emailRecipients(...)"`, which is the only
52
+ reason this has ever worked anywhere.
53
+
54
+ Mounted ONCE per page, on the page's own store — two hosts sharing a store
55
+ name would register the same ids twice and render duplicate cards. %>
56
+ <div x-data>
57
+ <%= render "studio/modals/scoped_host", store: "profileModals" do %>
58
+ <% modal_rows.each do |section| %>
59
+ <%= render section[:modals], user: current_user %>
60
+ <% end %>
61
+ <% end %>
62
+ </div>
63
+ <% end %>
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The newsletter subscription's rules. Pure Ruby and duck-typed, like
4
+ # Studio::OauthIdentity and Studio::ProfileImage — it loads without Rails, takes
5
+ # any object that answers the readers, and the unit suite exercises it without
6
+ # booting the dummy app.
7
+ #
8
+ # TWO TIMESTAMPS, NOT A BOOLEAN, and this is the decision the rest of the file
9
+ # follows from. `joined_email_list_at` and `left_email_list_at` are lifted from
10
+ # turf-monster, which has run this shape in production, and the pair carries
11
+ # three states a boolean cannot:
12
+ #
13
+ # never asked both nil — distinct from "unsubscribed"
14
+ # subscribed joined after left — including a REJOIN, where both are set
15
+ # unsubscribed left after joined — and the leave date survives the rejoin
16
+ #
17
+ # The third row is why turf keeps them: its 25-seed welcome bonus is once-ever,
18
+ # guarded on-chain, and "have they EVER joined" is a different question from "are
19
+ # they on the list today". A boolean answers neither. None of turf's seed
20
+ # machinery belongs in the engine — but the state shape does, because a consumer
21
+ # that adopts this and later wants that rule must not have to migrate away from a
22
+ # flag first.
23
+ module Studio
24
+ module Newsletter
25
+ module_function
26
+
27
+ # The columns a host must have for any of this to mean anything. The profile
28
+ # row declares the same pair in `requires:`, so a host without them silently
29
+ # gets no newsletter card rather than a NoMethodError on every page load.
30
+ COLUMNS = %i[joined_email_list_at left_email_list_at].freeze
31
+
32
+ def serves?(user)
33
+ return false if user.nil?
34
+
35
+ COLUMNS.all? { |column| user.respond_to?(column) }
36
+ end
37
+
38
+ # On the list right now.
39
+ #
40
+ # `joined > left` rather than `left.nil?`, because a REJOIN leaves both set
41
+ # and the later date wins. Reading only `left_email_list_at.nil?` would call
42
+ # every rejoined account unsubscribed forever.
43
+ def subscribed?(user)
44
+ return false unless serves?(user)
45
+
46
+ joined = user.joined_email_list_at
47
+ return false if joined.nil?
48
+
49
+ left = user.left_email_list_at
50
+ left.nil? || joined > left
51
+ end
52
+
53
+ # Has this account EVER been on the list? Distinct from `subscribed?`, and the
54
+ # question a once-ever welcome bonus asks. The engine does not pay bonuses; it
55
+ # answers the question so a consumer that does never has to reach past this
56
+ # module into the columns.
57
+ def ever_joined?(user)
58
+ serves?(user) && !user.joined_email_list_at.nil?
59
+ end
60
+
61
+ # A newsletter needs somewhere to send. An account with no address on file —
62
+ # a wallet-only sign-in, which turf-monster has plenty of — has to supply one
63
+ # before it can subscribe, so the UI asks rather than failing the submit.
64
+ def needs_email?(user)
65
+ return false unless serves?(user)
66
+
67
+ !user.respond_to?(:email) || user.email.to_s.strip.empty?
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # REQUIRED HERE, not left to lib/studio.rb's ordering. DEFAULTS names
4
+ # Studio::Newsletter::COLUMNS at LOAD time, and profile_sections is required
5
+ # BEFORE newsletter in that file — so without this line the constant is undefined
6
+ # and the engine fails to boot. Declaring the dependency where it is used makes
7
+ # the pair immune to a future reorder of that require list.
8
+ require_relative "newsletter"
9
+
3
10
  # Resolves Studio.profile_sections — the rows that make up the shared /profile
4
11
  # page — for a given view context. Pure Ruby (no Rails dependency) so the unit
5
12
  # suite exercises the resolution rules without booting the dummy app.
@@ -33,12 +40,19 @@
33
40
  # AND `auth_methods = %i[magic_link]` with no omniauth gem at all,
34
41
  # so it would have rendered a "Link Google Account" button leading
35
42
  # nowhere. Having a column is not the same as offering the feature.
36
- # modals — the row opens the shared crop/saving modals. The PAGE mounts them
37
- # once (studio/modals/_scoped_host on a "profileModals" store) when
38
- # any resolved row asks for them, so a row never has to know whether
39
- # the host app renders a modal host of its own. Two of the five
40
- # consumers render none at all, and the two that do ship a FORK of
41
- # the engine's shared host that would shadow it.
43
+ # modals — a partial of modal registrations this row needs, or nil. The PAGE
44
+ # mounts studio/modals/_scoped_host on a "profileModals" store when
45
+ # ANY resolved row declares one, and renders each declared partial
46
+ # inside it so a row never has to know whether the host app renders
47
+ # a modal host of its own. Two of the five consumers render none at
48
+ # all, and the two that do ship a FORK of the engine's shared host
49
+ # that would shadow it.
50
+ #
51
+ # A PATH, not a boolean, and not derived from `key`. A host row
52
+ # declaring modals keeps its partial in the host's own app, so any
53
+ # convention like "studio/profiles/#{key}_modals" would resolve to a
54
+ # path that does not exist there. Saying it outright costs one string
55
+ # and works for everyone.
42
56
  #
43
57
  # ON `requires` — this is the whole reason the registry exists rather than a
44
58
  # hardcoded page. The consuming apps do NOT agree on their users table:
@@ -73,6 +87,19 @@ module Studio
73
87
  partial: "studio/profiles/google_section", requires: %i[provider uid],
74
88
  if: -> { Studio.auth_method?(:google) } },
75
89
 
90
+ # The mailing list. READ-level for the same reason Google is: it is a state
91
+ # you look at and occasionally flip, not a field you type into.
92
+ #
93
+ # `modals: true` is what makes the page mount a host — leaving asks for
94
+ # confirmation, and an account with no address on file is asked for one.
95
+ # This is the FIRST row to declare the flag, which the registry has
96
+ # documented since it shipped; before this the read page mounted no host at
97
+ # all, correctly, because nothing asked.
98
+ { key: :newsletter, title: "Newsletter", page: :show,
99
+ partial: "studio/profiles/newsletter_section",
100
+ requires: Studio::Newsletter::COLUMNS,
101
+ modals: "studio/profiles/newsletter_modals" },
102
+
76
103
  # --- the edit page ------------------------------------------------------
77
104
  # These are FIELDS in one form with one Save, so they carry no buttons of
78
105
  # their own. Email included: it needed a separate action only while it was
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.51.0"
2
+ VERSION = "0.52.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -10,6 +10,7 @@ require "studio/sidebar_sections"
10
10
  require "studio/profile_sections"
11
11
  require "studio/profile_image"
12
12
  require "studio/oauth_identity"
13
+ require "studio/newsletter"
13
14
  require "studio/username_generator"
14
15
  require "studio/s3"
15
16
  require "studio/image_cache"
@@ -690,6 +691,14 @@ module Studio
690
691
  # here: it is OmniAuth's own /auth/:provider, which the middleware owns.
691
692
  delete "profile/google", to: "studio/profiles#unlink_google",
692
693
  as: :profile_unlink_google
694
+
695
+ # POST joins, DELETE leaves — the verbs the two actions actually are, on
696
+ # one path. Not a PATCH on the profile: subscribing is its own decision
697
+ # with its own confirmation, and folding it into the bulk field save would
698
+ # mean every name change re-asserted a mailing-list preference.
699
+ post "profile/newsletter", to: "studio/profiles#subscribe_newsletter",
700
+ as: :profile_newsletter
701
+ delete "profile/newsletter", to: "studio/profiles#unsubscribe_newsletter"
693
702
  end
694
703
 
695
704
  resources :error_logs, only: [:index, :show]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.51.0
4
+ version: 0.52.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -339,6 +339,7 @@ files:
339
339
  - app/views/studio/profile_mailer/email_change_notification.html.erb
340
340
  - app/views/studio/profile_mailer/email_change_notification.text.erb
341
341
  - app/views/studio/profiles/_birthday_fields.html.erb
342
+ - app/views/studio/profiles/_birthday_picker_script.html.erb
342
343
  - app/views/studio/profiles/_editable_identity.html.erb
343
344
  - app/views/studio/profiles/_email_fields.html.erb
344
345
  - app/views/studio/profiles/_form_script.html.erb
@@ -348,6 +349,8 @@ files:
348
349
  - app/views/studio/profiles/_identity_mini.html.erb
349
350
  - app/views/studio/profiles/_identity_styles.html.erb
350
351
  - app/views/studio/profiles/_name_fields.html.erb
352
+ - app/views/studio/profiles/_newsletter_modals.html.erb
353
+ - app/views/studio/profiles/_newsletter_section.html.erb
351
354
  - app/views/studio/profiles/_pencil_icon.html.erb
352
355
  - app/views/studio/profiles/_save_bar.html.erb
353
356
  - app/views/studio/profiles/edit.html.erb
@@ -392,6 +395,7 @@ files:
392
395
  - lib/studio/link_token.rb
393
396
  - lib/studio/log_rotation.rb
394
397
  - lib/studio/mail_transport.rb
398
+ - lib/studio/newsletter.rb
395
399
  - lib/studio/oauth_identity.rb
396
400
  - lib/studio/profile_image.rb
397
401
  - lib/studio/profile_sections.rb