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 +4 -4
- data/CHANGELOG.md +108 -0
- data/app/controllers/studio/profiles_controller.rb +53 -0
- data/app/views/studio/profiles/_birthday_fields.html.erb +210 -12
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +144 -0
- data/app/views/studio/profiles/_identity.html.erb +22 -1
- data/app/views/studio/profiles/_identity_body.html.erb +52 -23
- data/app/views/studio/profiles/_identity_styles.html.erb +76 -15
- data/app/views/studio/profiles/_newsletter_modals.html.erb +61 -0
- data/app/views/studio/profiles/_newsletter_section.html.erb +65 -0
- data/app/views/studio/profiles/edit.html.erb +26 -9
- data/app/views/studio/profiles/show.html.erb +35 -0
- data/lib/studio/newsletter.rb +70 -0
- data/lib/studio/profile_sections.rb +33 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +9 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a59868c458671e78335fa67057b5914b85fa9ec7f237852dc4d3a543262dcc1
|
|
4
|
+
data.tar.gz: 580857e88f928bbf76882d984cee10bd2bd86471d1a649f0d2b8e9228832622e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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="
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
/*
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
<%#
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
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 —
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# the engine's shared host
|
|
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
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|