studio-engine 0.55.0 → 0.56.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 +47 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +558 -0
- data/app/helpers/studio/fizz_helper.rb +125 -0
- data/app/views/studio/_fizz_layer.html.erb +16 -0
- data/app/views/studio/_hold_button.html.erb +405 -0
- data/app/views/studio/profiles/_identity.html.erb +23 -2
- data/app/views/studio/profiles/_identity_mini.html.erb +74 -23
- data/app/views/studio/profiles/_identity_styles.html.erb +144 -0
- data/app/views/studio/profiles/_save_controls.html.erb +79 -0
- data/app/views/studio/profiles/edit.html.erb +51 -25
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/version.rb +1 -1
- metadata +5 -2
- data/app/views/studio/profiles/_save_bar.html.erb +0 -39
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
<%# The compact identity that takes over when the full card scrolls away.
|
|
2
2
|
|
|
3
|
-
Locals: user (required).
|
|
4
|
-
|
|
5
3
|
WHY IT EXISTS: on a long profile the header is the thing that tells you whose
|
|
6
4
|
page you are on, and it is the first thing to disappear. This keeps that
|
|
7
5
|
answer on screen — the same move the navbar makes when it shrinks on scroll.
|
|
@@ -16,33 +14,82 @@
|
|
|
16
14
|
app that publishes nothing gets a bar at the top of the viewport rather than
|
|
17
15
|
a broken one.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
the page and already reachable: the full card above is the
|
|
21
|
-
A second link here would be a duplicate tab stop and a
|
|
22
|
-
the same name for no gain — this is an orientation aid
|
|
23
|
-
the screen, so it says so.
|
|
17
|
+
THE IDENTITY HALF IS aria-hidden, and only that half. It duplicates content
|
|
18
|
+
that is already on the page and already reachable: the full card above is the
|
|
19
|
+
link to editing. A second link here would be a duplicate tab stop and a
|
|
20
|
+
second announcement of the same name for no gain — this is an orientation aid
|
|
21
|
+
for people who can see the screen, so it says so.
|
|
22
|
+
|
|
23
|
+
THE aria-hidden USED TO SIT ON THE WHOLE BAR, and it had to come down one
|
|
24
|
+
level when the save controls moved in (operator's call, 2026-08-15). A
|
|
25
|
+
focusable control inside an aria-hidden subtree is a WCAG failure and a
|
|
26
|
+
practical one: screen-reader users get a button they can tab to and cannot
|
|
27
|
+
hear. The duplicated name and picture are still hidden; the controls are not.
|
|
28
|
+
|
|
29
|
+
Locals:
|
|
30
|
+
user (required)
|
|
31
|
+
editable (Boolean) — true on the edit page, where this bar carries the
|
|
32
|
+
save controls once the full card has scrolled away. On the read
|
|
33
|
+
page there is nothing to save and the bar stays as it was.
|
|
24
34
|
%>
|
|
35
|
+
<% editable = local_assigns.fetch(:editable, false) %>
|
|
36
|
+
|
|
37
|
+
<%# `inert` SHIPS ON, and comes off only when the bar is actually showing.
|
|
38
|
+
|
|
39
|
+
THE BUG IT CLOSES. This bar hides itself with opacity: 0 and
|
|
40
|
+
pointer-events: none, and NEITHER of those takes an element out of the tab
|
|
41
|
+
order or the accessibility tree. That was harmless for as long as the bar
|
|
42
|
+
held a picture and a name — this change is the first to put BUTTONS in it.
|
|
43
|
+
The result: on /profile/edit with unsaved changes and the page NOT scrolled,
|
|
44
|
+
the first two tab stops were an invisible Discard and Save, because this
|
|
45
|
+
partial renders before the card. Tab, Enter, and the edits were gone with
|
|
46
|
+
nothing on screen to explain it.
|
|
47
|
+
|
|
48
|
+
IT IS THE SAME OBSERVATION THIS CHANGE ALREADY MADE FOR THE MOUSE. The bar
|
|
49
|
+
was dead to clicks while hidden, which `pointer-events: auto` on .is-visible
|
|
50
|
+
fixed; the keyboard half of the identical problem was left open. A spec now
|
|
51
|
+
covers it, because the browser lane had no focus assertions at all.
|
|
52
|
+
|
|
53
|
+
ON BY DEFAULT rather than set only in the observer's callback, so the
|
|
54
|
+
early return for a browser without IntersectionObserver — which leaves the
|
|
55
|
+
bar hidden forever — leaves it inert forever too. %>
|
|
25
56
|
<div data-studio-identity-mini
|
|
26
57
|
class="studio-identity-mini"
|
|
27
|
-
|
|
58
|
+
inert>
|
|
28
59
|
<div class="px-4">
|
|
29
|
-
<div class="flex items-center gap-3 py-2">
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<div class="min-w-0">
|
|
41
|
-
<p class="text-sm font-bold text-heading truncate"><%= user.display_name %></p>
|
|
42
|
-
<% if user.respond_to?(:email) && user.email.present? %>
|
|
43
|
-
<p class="text-xs text-muted truncate"><%= user.email %></p>
|
|
60
|
+
<div class="studio-identity-mini-row flex items-center gap-3 py-2">
|
|
61
|
+
<div class="flex items-center gap-3 min-w-0 flex-1" aria-hidden="true">
|
|
62
|
+
<% if user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) && user.avatar.attached? %>
|
|
63
|
+
<%= image_tag user.avatar, class: "rounded-full object-cover flex-shrink-0",
|
|
64
|
+
style: "width: 32px; height: 32px;", alt: "" %>
|
|
65
|
+
<% else %>
|
|
66
|
+
<div class="rounded-full flex items-center justify-center font-bold text-white text-xs flex-shrink-0"
|
|
67
|
+
style="width: 32px; height: 32px; background-color: <%= user.avatar_color %>">
|
|
68
|
+
<%= user.avatar_initials %>
|
|
69
|
+
</div>
|
|
44
70
|
<% end %>
|
|
71
|
+
|
|
72
|
+
<div class="min-w-0">
|
|
73
|
+
<p class="text-sm font-bold text-heading truncate"><%= user.display_name %></p>
|
|
74
|
+
<% if user.respond_to?(:email) && user.email.present? %>
|
|
75
|
+
<%# THE ADDRESS YIELDS FIRST on a narrow screen. This bar is a strip,
|
|
76
|
+
and once it carries Save and Discard there is not room for an
|
|
77
|
+
avatar, two lines of text and two buttons: at 320px the name
|
|
78
|
+
measured 13px of clientWidth against 71px of content and rendered
|
|
79
|
+
as "P.", the address as "p..". Neither told anyone whose page
|
|
80
|
+
they were on, which is the only reason this bar exists.
|
|
81
|
+
|
|
82
|
+
Dropping the address gives the NAME that space back. It is the
|
|
83
|
+
line that identifies the account, and it is already on the page
|
|
84
|
+
above in full. %>
|
|
85
|
+
<p class="text-xs text-muted truncate"><%= user.email %></p>
|
|
86
|
+
<% end %>
|
|
87
|
+
</div>
|
|
45
88
|
</div>
|
|
89
|
+
|
|
90
|
+
<% if editable %>
|
|
91
|
+
<%= render "studio/profiles/save_controls", compact: true %>
|
|
92
|
+
<% end %>
|
|
46
93
|
</div>
|
|
47
94
|
</div>
|
|
48
95
|
</div>
|
|
@@ -78,6 +125,9 @@
|
|
|
78
125
|
// intersecting at all" has to count as gone in its own right.
|
|
79
126
|
var gone = !entry.isIntersecting || entry.intersectionRatio < SHOW_BELOW_RATIO;
|
|
80
127
|
mini.classList.toggle(CLASS, gone);
|
|
128
|
+
// The tab order and the accessibility tree follow the same decision the
|
|
129
|
+
// class does. opacity and pointer-events cannot express "not here".
|
|
130
|
+
mini.inert = !gone;
|
|
81
131
|
}, {
|
|
82
132
|
// The navbar covers the top of the viewport, so pixels behind it are not
|
|
83
133
|
// visible pixels — without this the card reads as on-screen while it is
|
|
@@ -97,6 +147,7 @@
|
|
|
97
147
|
var mini = document.querySelector("[data-studio-identity-mini]");
|
|
98
148
|
if (mini) {
|
|
99
149
|
mini.classList.remove(CLASS);
|
|
150
|
+
mini.inert = true;
|
|
100
151
|
if (mini._studioObserver) mini._studioObserver.disconnect();
|
|
101
152
|
}
|
|
102
153
|
});
|
|
@@ -146,6 +146,150 @@
|
|
|
146
146
|
.studio-identity-mini.is-visible {
|
|
147
147
|
opacity: 1;
|
|
148
148
|
transform: translateX(-50%);
|
|
149
|
+
/* POINTER EVENTS BACK ON, and this line is newer than it looks. The bar is
|
|
150
|
+
`pointer-events: none` while hidden so an invisible strip cannot swallow
|
|
151
|
+
clicks meant for the page under it — correct, and harmless for as long as
|
|
152
|
+
the bar held nothing but a picture and a name. It now carries Save and
|
|
153
|
+
Discard, and without this they are visible, focusable, and completely
|
|
154
|
+
dead to the mouse. */
|
|
155
|
+
pointer-events: auto;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* THE SAVE CONTROLS' CORNER (operator's call, 2026-08-15). Absolute, so the
|
|
159
|
+
card keeps its height and its centred avatar keeps its centre when they
|
|
160
|
+
appear — a card that grew on the first keystroke would shove every row below
|
|
161
|
+
it down the page.
|
|
162
|
+
|
|
163
|
+
The offsets match the card's own `p-6` padding, so the controls line up with
|
|
164
|
+
its content rather than floating at an unrelated inset. */
|
|
165
|
+
.studio-identity-actions {
|
|
166
|
+
position: absolute;
|
|
167
|
+
right: 1.5rem;
|
|
168
|
+
bottom: 1.5rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* THE TEXT MAY NOT REACH THE CONTROLS — the gate is TEXT LENGTH, not viewport
|
|
172
|
+
width, and getting that wrong is how this shipped twice.
|
|
173
|
+
|
|
174
|
+
The first fix was a 639px media query, written against review's measurements
|
|
175
|
+
at 320/375/390/480. It closed those widths and left the PROPERTY open: the
|
|
176
|
+
overlap was never about the viewport, it was about whether the centred text
|
|
177
|
+
is long enough to reach into the corner. Measured at 1280px WITH that fix in
|
|
178
|
+
place: a 30-character display name lost 27px to the controls, a
|
|
179
|
+
33-character one 51px, and a 64-character email 19px — with
|
|
180
|
+
elementFromPoint over the email returning the Discard button. Every spec
|
|
181
|
+
stayed green because the lab fixture is "Pat Studio".
|
|
182
|
+
|
|
183
|
+
THE GUTTER IS THE FIX. The name and the email are centred full-width
|
|
184
|
+
paragraphs, so padding them symmetrically keeps them centred while making it
|
|
185
|
+
ARITHMETICALLY impossible for either to enter the controls' column: the text
|
|
186
|
+
box is now 2 x gutter narrower than the card, and the controls are 107px
|
|
187
|
+
wide against a 128px gutter.
|
|
188
|
+
|
|
189
|
+
TIED TO A MEASUREMENT, NOT GUESSED. The value is a custom property so
|
|
190
|
+
e2e/profile_chrome.spec.js can assert it stays >= the controls' RENDERED
|
|
191
|
+
width — otherwise a longer button label ("Save all changes") would silently
|
|
192
|
+
reopen the overlap, which is exactly the class of regression that put this
|
|
193
|
+
card through two review rounds.
|
|
194
|
+
|
|
195
|
+
THE COST, WHICH IS THE OPERATOR'S CALL (2026-08-16): a long name now wraps a
|
|
196
|
+
line earlier, so the card is taller for those accounts — on a resting page as
|
|
197
|
+
well as a dirty one. Chosen over putting the controls in flow because it
|
|
198
|
+
keeps BOTH guarantees this card was asked for: the south-east corner, and a
|
|
199
|
+
card that does not change height when the controls appear.
|
|
200
|
+
|
|
201
|
+
SCOPED TO THE EDITABLE CARD. The read page renders the same body through
|
|
202
|
+
.studio-identity-card and has no controls to avoid, so it keeps the full
|
|
203
|
+
width. */
|
|
204
|
+
.studio-identity-card-editable {
|
|
205
|
+
--studio-actions-gutter: 8rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@media (min-width: 640px) {
|
|
209
|
+
.studio-identity-card-editable > p {
|
|
210
|
+
padding-inline: var(--studio-actions-gutter);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* NARROW SCREENS PUT THEM BACK IN FLOW, because absolute positioning inside a
|
|
215
|
+
text-center card lands the controls ON the name and the email.
|
|
216
|
+
|
|
217
|
+
Measured on the rendered glyphs, before this rule existed: at 320px the
|
|
218
|
+
block covered 57px of the name and 99px of the email; at 390px, 106px and
|
|
219
|
+
64px; at 480px, 61px and 19px. elementFromPoint along the email line
|
|
220
|
+
returned the Discard button — the controls were the topmost paint over the
|
|
221
|
+
user's own address. It reached 0px only at 640px, which is exactly the width
|
|
222
|
+
the browser lane happens to run at, which is why every spec stayed green.
|
|
223
|
+
|
|
224
|
+
THE TRADE, STATED PLAINLY: below 640px the card DOES grow when the form goes
|
|
225
|
+
dirty, which is the thing absolute positioning was chosen to avoid. Text you
|
|
226
|
+
cannot read is worse than a card that changes height, and at this width the
|
|
227
|
+
card is the whole screen — there is nothing below it to shove out of view.
|
|
228
|
+
Above 640px the corner arrangement and its no-resize guarantee both hold.
|
|
229
|
+
|
|
230
|
+
OWNED CSS, NOT a `sm:` variant: `sm:` utilities are absent from every
|
|
231
|
+
consumer bundle (see the note in _save_controls and the guard in
|
|
232
|
+
test/views/profile_partial_utility_guard_test.rb). */
|
|
233
|
+
@media (max-width: 639px) {
|
|
234
|
+
.studio-identity-actions {
|
|
235
|
+
position: static;
|
|
236
|
+
margin-top: 1rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* THE COMPACT BAR WRAPS instead. That bar is not inside
|
|
240
|
+
.studio-identity-actions, so the in-flow rule above never reached it, and
|
|
241
|
+
it kept the corner arrangement's assumptions in a strip with no room for
|
|
242
|
+
them: at 320px, scrolled and dirty, the name had 13px of clientWidth
|
|
243
|
+
against 244px of content and rendered as "P.".
|
|
244
|
+
|
|
245
|
+
HIDING THE ADDRESS WAS THE FIRST ATTEMPT AND DID NOT WORK, which is worth
|
|
246
|
+
recording because it looked obvious. Measured at 320px: the bar is 288px,
|
|
247
|
+
the avatar 32px — and the two side-by-side buttons are 187px. The text was
|
|
248
|
+
never the thing taking the room. Removing a 100px line to recover space in
|
|
249
|
+
a 49px gap fixed nothing, and the spec written for it passed only because
|
|
250
|
+
it had inherited a 390px viewport where the problem does not exist.
|
|
251
|
+
|
|
252
|
+
Wrapping gives the identity a whole line and puts the controls on their
|
|
253
|
+
own. The bar is taller for it, but only while the form is dirty — the
|
|
254
|
+
controls do not render otherwise — and a bar that says "P." is not worth
|
|
255
|
+
the height it saves. */
|
|
256
|
+
.studio-identity-mini-row {
|
|
257
|
+
flex-wrap: wrap;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.studio-identity-mini-row > [data-studio-save-controls] {
|
|
261
|
+
width: 100%;
|
|
262
|
+
justify-content: flex-end;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* THE TWO ARRANGEMENTS OF THE SAVE CONTROLS.
|
|
267
|
+
|
|
268
|
+
STACKED in the card, Save on top of Discard (operator's call, 2026-08-15) —
|
|
269
|
+
the primary action goes first in a vertical stack the same way it goes last
|
|
270
|
+
in a horizontal row.
|
|
271
|
+
|
|
272
|
+
`stretch` rather than a right-alignment so both buttons take the width of
|
|
273
|
+
the wider one and the stack reads as a block rather than a ragged edge. The
|
|
274
|
+
container is absolutely positioned, so it shrinks to that width by itself.
|
|
275
|
+
|
|
276
|
+
OWNED, NOT `flex-col items-end`. `items-end` is absent from
|
|
277
|
+
mcritchie-industries' compiled bundle (measured 2026-08-15), and the engine
|
|
278
|
+
ships a PREBUILT bundle — so that class would do nothing in that app while
|
|
279
|
+
working here. Same failure as `sm:block` and `grid-cols-7`; same answer. */
|
|
280
|
+
.studio-save-controls {
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
align-items: stretch;
|
|
284
|
+
gap: 0.5rem;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* The compact bar has width rather than height, so the same two buttons sit in
|
|
288
|
+
a row there — Discard first, Save last. */
|
|
289
|
+
.studio-save-controls--compact {
|
|
290
|
+
flex-direction: row;
|
|
291
|
+
align-items: center;
|
|
292
|
+
gap: 0.5rem;
|
|
149
293
|
}
|
|
150
294
|
|
|
151
295
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%# The edit form's save controls — the unsaved-change count, Discard, and Save.
|
|
2
|
+
|
|
3
|
+
Locals:
|
|
4
|
+
compact (Boolean, default false) — the tighter arrangement for the compact
|
|
5
|
+
identity bar, which has a strip of width rather than a card.
|
|
6
|
+
|
|
7
|
+
ONE PARTIAL, TWO HOMES (operator's call, 2026-08-15). These used to live in a
|
|
8
|
+
fixed bar across the bottom of the viewport. They now sit in the identity
|
|
9
|
+
chrome instead: the full card's south-east corner while it is on screen, and
|
|
10
|
+
the compact bar once it has scrolled away. Whichever surface is showing
|
|
11
|
+
carries them, so they are still always reachable — that was the fixed bar's
|
|
12
|
+
only real job — with one fixed element on the page instead of two.
|
|
13
|
+
|
|
14
|
+
Extracted rather than written twice because the two copies would drift on the
|
|
15
|
+
thing that matters least to look at and most to get wrong: which form the
|
|
16
|
+
Save button submits.
|
|
17
|
+
|
|
18
|
+
THE `form` ATTRIBUTE IS LOAD-BEARING. Both identity surfaces render OUTSIDE
|
|
19
|
+
the profile form — editable_identity comes before form_with, and the avatar
|
|
20
|
+
has a form of its own because nesting them is invalid HTML. A bare
|
|
21
|
+
type="submit" here would therefore submit the nearest ancestor form, which is
|
|
22
|
+
the AVATAR form, and an attachment param submitted empty PURGES the
|
|
23
|
+
attachment: saving a name would delete the photo. `form=` names the target
|
|
24
|
+
explicitly and works from anywhere in the document.
|
|
25
|
+
|
|
26
|
+
$refs.form is NOT the answer, and looks like it: inside editable_identity's
|
|
27
|
+
imageUploadHost that ref resolves to the avatar form. Two scopes, same ref
|
|
28
|
+
name, silently the wrong one.
|
|
29
|
+
|
|
30
|
+
BOTH BUTTONS STOP PROPAGATION. On the edit page the whole identity card is a
|
|
31
|
+
click target for the file picker, so a click that reached the card would open
|
|
32
|
+
a file dialog on its way to saving.
|
|
33
|
+
%>
|
|
34
|
+
<% compact = local_assigns.fetch(:compact, false) %>
|
|
35
|
+
|
|
36
|
+
<%# ONE LIVE REGION, NOT TWO. Both copies of these controls are in the document
|
|
37
|
+
at once, so declaring role="status" on both announced "1 unsaved change"
|
|
38
|
+
twice — once from a copy sitting at opacity 0. The CARD's copy keeps it: it
|
|
39
|
+
is the one that exists on an unscrolled page, which is where an edit is made.
|
|
40
|
+
The compact copy is the same information for a reader who has scrolled, and
|
|
41
|
+
the announcement has already happened by then. %>
|
|
42
|
+
<div x-show="dirty" x-cloak x-transition.opacity
|
|
43
|
+
data-studio-save-controls="<%= compact ? "compact" : "card" %>"
|
|
44
|
+
<%= 'role="status" aria-live="polite"'.html_safe unless compact %>
|
|
45
|
+
class="studio-save-controls<%= " studio-save-controls--compact" if compact %>">
|
|
46
|
+
<%# THE COUNT IS NOW SCREEN-READER ONLY (operator's call, 2026-08-15): "1
|
|
47
|
+
unsaved change Discard" became a simple "Discard". The number was telling
|
|
48
|
+
people something the buttons already imply — they only exist when there is
|
|
49
|
+
something to save.
|
|
50
|
+
|
|
51
|
+
IT IS KEPT FOR ASSISTIVE TECH RATHER THAN DELETED, because the visual and
|
|
52
|
+
the audible cue are not the same cue. Seeing two buttons appear IS the
|
|
53
|
+
notification; a screen-reader user gets no such moment, and this live region
|
|
54
|
+
is the whole of what they get. Simplifying the visible UI is not a reason to
|
|
55
|
+
take the announcement away.
|
|
56
|
+
|
|
57
|
+
`sr-only` rather than owned CSS here, unusually: it is in ALL THREE consumer
|
|
58
|
+
bundles (measured 2026-08-15) and the engine already uses it in
|
|
59
|
+
style/_modal_specimen. %>
|
|
60
|
+
<p class="sr-only">
|
|
61
|
+
<span x-text="changeCount"></span>
|
|
62
|
+
<span x-text="changeCount === 1 ? 'unsaved change' : 'unsaved changes'"></span>
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
<%# ORDER IS PER-ARRANGEMENT, and deliberately so — the primary action goes
|
|
66
|
+
LAST in a row and FIRST in a stack, which is where the eye and the
|
|
67
|
+
convention both put it. Writing it out twice keeps the DOM order equal to
|
|
68
|
+
the VISUAL order in both, which a `row-reverse` or `column-reverse` would
|
|
69
|
+
break: the tab order would then run backwards through what is on screen. %>
|
|
70
|
+
<% if compact %>
|
|
71
|
+
<button type="button" @click.stop="discard()" class="btn btn-outline btn-sm">Discard</button>
|
|
72
|
+
<button type="submit" form="studio-profile-form" @click.stop
|
|
73
|
+
class="btn btn-primary btn-sm">Save changes</button>
|
|
74
|
+
<% else %>
|
|
75
|
+
<button type="submit" form="studio-profile-form" @click.stop
|
|
76
|
+
class="btn btn-primary btn-sm">Save changes</button>
|
|
77
|
+
<button type="button" @click.stop="discard()" class="btn btn-outline btn-sm">Discard</button>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
@@ -1,28 +1,44 @@
|
|
|
1
|
-
<%# /profile/edit — ONE form, one Save, and
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
THE SAVE
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
<%# /profile/edit — ONE form, one Save, and controls that do not depend on where
|
|
2
|
+
you are in the page.
|
|
3
|
+
|
|
4
|
+
THE SAVE CONTROLS LIVE IN THE IDENTITY CHROME (operator's call, 2026-08-15):
|
|
5
|
+
the full card's south-east corner while it is on screen, and the compact bar
|
|
6
|
+
once the card has scrolled away. Whichever surface is showing carries them.
|
|
7
|
+
|
|
8
|
+
THIS REPLACED A BAR FIXED TO THE VIEWPORT BOTTOM, and the requirement did not
|
|
9
|
+
change with it — a save control that does not depend on the scroll position.
|
|
10
|
+
The old design met it by pinning to the bottom edge; this one meets it by
|
|
11
|
+
riding a bar already fixed under the navbar, which is one fixed element on the
|
|
12
|
+
page instead of two.
|
|
13
|
+
|
|
14
|
+
Rejected alternatives, so nobody re-litigates them: a sticky TOP bar stacks
|
|
15
|
+
under the navbar and eats mobile viewport; a floating button has no room for a
|
|
16
|
+
Discard; an always-present footer costs vertical space permanently on a page
|
|
17
|
+
that is usually short.
|
|
18
|
+
|
|
19
|
+
Details that make or break it, all present below:
|
|
20
|
+
|
|
21
|
+
* the controls are absolute in the card, so it does not change height when
|
|
22
|
+
they appear and the centred avatar stays centred — and the name and email
|
|
23
|
+
are padded past them, because the overlap gate is TEXT LENGTH, not
|
|
24
|
+
viewport width (see _identity_styles);
|
|
25
|
+
* below 640px they return to normal flow, where the card is the whole
|
|
26
|
+
screen and unreadable text is the worse trade;
|
|
27
|
+
* the compact bar ships `inert` and drops it only while it is showing —
|
|
28
|
+
opacity and pointer-events do not remove an element from the tab order,
|
|
29
|
+
so without it the first two tab stops on an unscrolled dirty page are an
|
|
30
|
+
INVISIBLE Discard and Save;
|
|
31
|
+
* role="status" + aria-live="polite" on the CARD's copy only, so "unsaved
|
|
32
|
+
changes" is announced once rather than twice;
|
|
17
33
|
* beforeunload AND turbo:before-visit guard the exit, so navigating away
|
|
18
34
|
does not silently drop edits.
|
|
19
35
|
|
|
20
36
|
THE DIRTY CHECK compares against what the server rendered, trimmed on both
|
|
21
37
|
sides — whitespace is not a change, and the controller trims on the way in
|
|
22
|
-
too, so the
|
|
38
|
+
too, so the controls agree with what saving would actually do.
|
|
23
39
|
|
|
24
40
|
NO-JS FALLBACK: the form has a plain submit at the end. A page whose JS never
|
|
25
|
-
ran still saves; it simply never gets the
|
|
41
|
+
ran still saves; it simply never gets the controls.
|
|
26
42
|
%>
|
|
27
43
|
<% content_for(:title, "Edit profile") %>
|
|
28
44
|
|
|
@@ -48,14 +64,17 @@
|
|
|
48
64
|
<%= render "studio/cropper_assets" %>
|
|
49
65
|
<% end %>
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
<%# NO BOTTOM PADDING ANY MORE. This reserved 96px for a save bar fixed across
|
|
68
|
+
the bottom of the viewport; the save controls now live in the identity card
|
|
69
|
+
and its compact bar, so nothing covers the last field and nothing needs to
|
|
70
|
+
make room (operator's call, 2026-08-15). %>
|
|
71
|
+
<div x-data="studioProfileForm(<%= initial.to_json %>)">
|
|
53
72
|
<div class="max-w-2xl mx-auto py-8">
|
|
54
73
|
<%# The way back. The page heading and the Done button came off on the
|
|
55
74
|
operator's call, which left browser-back as the only exit for someone who
|
|
56
75
|
opened this and changed nothing — so a small explicit one goes here.
|
|
57
|
-
Deliberately quiet: it is an escape hatch, not an action, and the save
|
|
58
|
-
|
|
76
|
+
Deliberately quiet: it is an escape hatch, not an action, and the save controls
|
|
77
|
+
take the identity card's corner when there is anything to save. %>
|
|
59
78
|
<%= link_to profile_path,
|
|
60
79
|
class: "inline-flex items-center gap-1 text-sm text-secondary hover:text-heading transition mb-4",
|
|
61
80
|
aria: { label: "Back to your profile" } do %>
|
|
@@ -70,8 +89,17 @@
|
|
|
70
89
|
and refs do not cross components. %>
|
|
71
90
|
<%= render "studio/profiles/editable_identity", user: current_user %>
|
|
72
91
|
|
|
92
|
+
<%# THE id IS LOAD-BEARING, not decoration. The Save button lives in the
|
|
93
|
+
identity card, which renders ABOVE this form and outside it — so it
|
|
94
|
+
reaches this form by `form="studio-profile-form"`. Rename the id and the
|
|
95
|
+
button silently submits the avatar form instead, which purges the photo.
|
|
96
|
+
test/views/profile_page_render_test.rb pins the two together, in
|
|
97
|
+
test_the_edit_page_renders_whole. (This named a profile_save_controls_test
|
|
98
|
+
that does not exist — a false coverage claim inside the comment warning
|
|
99
|
+
that a rename purges the photo.) %>
|
|
73
100
|
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
74
|
-
data: { turbo: false },
|
|
101
|
+
data: { turbo: false },
|
|
102
|
+
html: { "x-ref": "form", id: "studio-profile-form" } do %>
|
|
75
103
|
<%# ONE CARD PER ROW — see show.html.erb. The merged wrapper also carried
|
|
76
104
|
`overflow-hidden`, which is worth naming as it goes: the birthday
|
|
77
105
|
popover is `position: fixed` and placed from the trigger's rect, so it
|
|
@@ -91,8 +119,6 @@
|
|
|
91
119
|
<div class="mt-6" x-show="!alpine" x-cloak>
|
|
92
120
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
93
121
|
</div>
|
|
94
|
-
|
|
95
|
-
<%= render "studio/profiles/save_bar" %>
|
|
96
122
|
<% end %>
|
|
97
123
|
</div>
|
|
98
124
|
</div>
|
|
@@ -331,6 +331,65 @@
|
|
|
331
331
|
</div>
|
|
332
332
|
</section>
|
|
333
333
|
|
|
334
|
+
<!-- Hold to confirm -->
|
|
335
|
+
<section class="space-y-5">
|
|
336
|
+
<div class="space-y-1">
|
|
337
|
+
<h3 class="text-xl font-bold text-heading">Hold to confirm</h3>
|
|
338
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
339
|
+
A press-and-hold CTA for an action you do not want taken by accident, and the
|
|
340
|
+
carbonation behind it. Ported from Turf Monster, where it confirms a contest entry.
|
|
341
|
+
The bubbles sit <em>behind</em> the button and show only where they escape its edges;
|
|
342
|
+
they simmer at rest, boil on hover and through the hold, then burst outward on
|
|
343
|
+
success. The whole thing themes off the primary role token, so it wears this app’s
|
|
344
|
+
brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
|
|
345
|
+
input, so one instance can be re-themed from any ancestor.
|
|
346
|
+
<code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
|
|
347
|
+
</p>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
350
|
+
<%# 1. The fizz on its own: no button, just the layers. %>
|
|
351
|
+
<%= render layout: "style/specimen",
|
|
352
|
+
locals: { klass: ".hold-stack > .hold-fizz (.fizz-bit)",
|
|
353
|
+
usage: %(render "studio/fizz_layer", seed: "demo") } do %>
|
|
354
|
+
<div class="w-full max-w-xs">
|
|
355
|
+
<span class="hold-stack fizz-lively" style="height:3rem;--fizz-c-1:#8b5cf6;--fizz-c-4:#22d3ee;--fizz-c-7:#f59e0b;--fizz-c-10:#ef4444;--fizz-c-13:#10b981;--fizz-c-16:#ec4899">
|
|
356
|
+
<%= render "studio/fizz_layer", seed: "style-fizz" %>
|
|
357
|
+
<%= render "studio/fizz_layer", seed: "style-fizz~extra", extra: true %>
|
|
358
|
+
<span class="flex items-center justify-center h-12 text-xs text-muted">six zones, six colors</span>
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
<% end %>
|
|
362
|
+
|
|
363
|
+
<%# 2. The button itself, live: press and hold for two seconds. %>
|
|
364
|
+
<%= render layout: "style/specimen",
|
|
365
|
+
locals: { klass: ".hold-btn (studio/hold_button)",
|
|
366
|
+
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000) } do %>
|
|
367
|
+
<div class="w-full max-w-xs" x-data="{ submitting: false }">
|
|
368
|
+
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000,
|
|
369
|
+
default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
|
|
370
|
+
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
371
|
+
@click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
|
|
372
|
+
</div>
|
|
373
|
+
<% end %>
|
|
374
|
+
|
|
375
|
+
<%# 3. The same button, re-themed from an ancestor — no new CSS. %>
|
|
376
|
+
<%= render layout: "style/specimen",
|
|
377
|
+
locals: { klass: ".hold-btn (--hold-* overrides)",
|
|
378
|
+
usage: %(<div style="--hold-bg-from:#f0abfc;--hold-success-to:#4c1d95">…</div>) } do %>
|
|
379
|
+
<div class="w-full max-w-xs" x-data="{ submitting: false }"
|
|
380
|
+
style="--hold-bg-from:#f0abfc;--hold-bg-mid:#a855f7;--hold-bg-to:#6b21a8;
|
|
381
|
+
--hold-success-from:#c4b5fd;--hold-success-to:#4c1d95;--hold-success-glow-rgb:139 92 246">
|
|
382
|
+
<%= render "studio/hold_button", hold_id: "style-hold-custom", duration: 2000,
|
|
383
|
+
default_text: "Hold to Confirm", success_text: "Confirmed!",
|
|
384
|
+
fizz_colors: %w[#f0abfc #6b21a8 #a855f7 #a5b4fc #312e81 #6366f1 #fbcfe8 #831843 #ec4899
|
|
385
|
+
#bae6fd #0c4a6e #0ea5e9 #ddd6fe #4c1d95 #8b5cf6 #fde68a #78350f #f59e0b] %>
|
|
386
|
+
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
387
|
+
@click="document.querySelector('.hold-btn[data-hold-id=style-hold-custom]').classList.remove('process','success','error','loading')">Reset</button>
|
|
388
|
+
</div>
|
|
389
|
+
<% end %>
|
|
390
|
+
</div>
|
|
391
|
+
</section>
|
|
392
|
+
|
|
334
393
|
<!-- Confetti & pulse -->
|
|
335
394
|
<section class="space-y-5">
|
|
336
395
|
<div class="space-y-1">
|
data/lib/studio/version.rb
CHANGED
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.56.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -210,6 +210,7 @@ files:
|
|
|
210
210
|
- app/controllers/theme_settings_controller.rb
|
|
211
211
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
212
212
|
- app/helpers/studio/at_time_helper.rb
|
|
213
|
+
- app/helpers/studio/fizz_helper.rb
|
|
213
214
|
- app/helpers/studio_email_delivery_helper.rb
|
|
214
215
|
- app/helpers/studio_sidebar_helper.rb
|
|
215
216
|
- app/helpers/studio_theme_helper.rb
|
|
@@ -273,6 +274,8 @@ files:
|
|
|
273
274
|
- app/views/studio/_board_assets.html.erb
|
|
274
275
|
- app/views/studio/_confirm_interstitial.html.erb
|
|
275
276
|
- app/views/studio/_cropper_assets.html.erb
|
|
277
|
+
- app/views/studio/_fizz_layer.html.erb
|
|
278
|
+
- app/views/studio/_hold_button.html.erb
|
|
276
279
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
277
280
|
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
278
281
|
- app/views/studio/admin_models/_teams_table.html.erb
|
|
@@ -354,7 +357,7 @@ files:
|
|
|
354
357
|
- app/views/studio/profiles/_newsletter_modals.html.erb
|
|
355
358
|
- app/views/studio/profiles/_newsletter_section.html.erb
|
|
356
359
|
- app/views/studio/profiles/_pencil_icon.html.erb
|
|
357
|
-
- app/views/studio/profiles/
|
|
360
|
+
- app/views/studio/profiles/_save_controls.html.erb
|
|
358
361
|
- app/views/studio/profiles/edit.html.erb
|
|
359
362
|
- app/views/studio/profiles/show.html.erb
|
|
360
363
|
- app/views/style/_modal_specimen.html.erb
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
<%# The edit form's save bar — fixed to the viewport bottom, present only while
|
|
2
|
-
the form is dirty.
|
|
3
|
-
|
|
4
|
-
No locals. It reads `dirty` and `changeCount` off the enclosing
|
|
5
|
-
studioProfileForm component, so it must be rendered INSIDE that x-data scope.
|
|
6
|
-
|
|
7
|
-
ITS OWN PARTIAL so the browser lane can mount it. The lab page that drives
|
|
8
|
-
this in a real browser may set up a partial's locals and nothing else — it may
|
|
9
|
-
not hand-roll the markup — and while this lived inline in edit.html.erb there
|
|
10
|
-
was no name for it to render. A template needs a controller, a session and a
|
|
11
|
-
user; a partial needs none of those. See test/lib/e2e_lane_contract_test.rb.
|
|
12
|
-
|
|
13
|
-
Four details that make or break it:
|
|
14
|
-
|
|
15
|
-
* fixed, not sticky — it must not reserve height while it is hidden;
|
|
16
|
-
* env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
|
|
17
|
-
* role="status" + aria-live="polite" announces "unsaved changes" without
|
|
18
|
-
stealing focus;
|
|
19
|
-
* the enclosing form carries bottom padding while the bar is up, so the bar
|
|
20
|
-
never covers the last field. That padding lives on the x-data element in
|
|
21
|
-
edit.html.erb, because it is the scroll container's business, not the
|
|
22
|
-
bar's.
|
|
23
|
-
%>
|
|
24
|
-
<div x-show="dirty" x-cloak x-transition.opacity
|
|
25
|
-
data-studio-save-bar
|
|
26
|
-
role="status" aria-live="polite"
|
|
27
|
-
class="fixed inset-x-0 bottom-0 z-40 border-t border-subtle"
|
|
28
|
-
style="background: var(--color-surface); padding-bottom: env(safe-area-inset-bottom);">
|
|
29
|
-
<div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
|
|
30
|
-
<p class="text-sm text-secondary">
|
|
31
|
-
<span class="font-semibold text-heading" x-text="changeCount"></span>
|
|
32
|
-
<span x-text="changeCount === 1 ? 'unsaved change' : 'unsaved changes'"></span>
|
|
33
|
-
</p>
|
|
34
|
-
<div class="flex items-center gap-2">
|
|
35
|
-
<button type="button" @click="discard()" class="btn btn-outline btn-sm">Discard</button>
|
|
36
|
-
<button type="submit" class="btn btn-primary btn-sm">Save changes</button>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|