studio-engine 0.51.0 → 0.52.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,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 %>
data/lib/studio/engine.rb CHANGED
@@ -43,6 +43,7 @@ module Studio
43
43
  app.config.assets.precompile += %w[
44
44
  studio/sticky_table_header.css
45
45
  studio/sticky_table_header.js
46
+ studio/alpine.js
46
47
  studio/canvas_confetti.js
47
48
  studio/studio_confetti.js
48
49
  studio/sortable.js