studio-engine 0.54.1 → 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.
@@ -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
- NOT INTERACTIVE, and aria-hidden. It duplicates content that is already on
20
- the page and already reachable: the full card above is the link to editing.
21
- A second link here would be a duplicate tab stop and a second announcement of
22
- the same name for no gain — this is an orientation aid for people who can see
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
- aria-hidden="true">
58
+ inert>
28
59
  <div class="px-4">
29
- <div class="flex items-center gap-3 py-2">
30
- <% if user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) && user.avatar.attached? %>
31
- <%= image_tag user.avatar, class: "rounded-full object-cover flex-shrink-0",
32
- style: "width: 32px; height: 32px;", alt: "" %>
33
- <% else %>
34
- <div class="rounded-full flex items-center justify-center font-bold text-white text-xs flex-shrink-0"
35
- style="width: 32px; height: 32px; background-color: <%= user.avatar_color %>">
36
- <%= user.avatar_initials %>
37
- </div>
38
- <% end %>
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 a save bar that does not depend on
2
- where you are in the page.
3
-
4
- THE SAVE BAR is fixed to the viewport bottom and exists only while the form
5
- is dirty. Rejected alternatives, so nobody re-litigates them: a sticky TOP bar
6
- stacks under the navbar and eats mobile viewport; a floating button has no
7
- room for a Discard or a change count; an always-present footer costs vertical
8
- space permanently on a page that is usually short.
9
-
10
- Four details that make or break it, all present below:
11
-
12
- * the form carries bottom padding while the bar is up, so the bar never
13
- covers the last field;
14
- * env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
15
- * role="status" + aria-live="polite" announces "unsaved changes" without
16
- stealing focus;
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 bar agrees with what saving would actually do.
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 bar.
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
- <div x-data="studioProfileForm(<%= initial.to_json %>)"
52
- x-bind:style="dirty ? 'padding-bottom: 96px' : ''">
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 bar
58
- owns the bottom of the screen when there is anything to save. %>
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 }, html: { "x-ref": "form" } do %>
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>
@@ -272,7 +272,7 @@
272
272
  <h3 class="text-xl font-bold text-heading">Effects</h3>
273
273
  <p class="text-muted text-sm">
274
274
  The <code class="font-mono text-2xs">engine-motion.css</code> visual-effect primitives beyond motion -
275
- gradient text, a soft glow halo, a single-color rotating <strong>selection glow</strong>
275
+ gradient text, a soft glow halo, a one- or two-color rotating <strong>selection glow</strong>
276
276
  (ported from Turf Monster's selected-team-card ring), frosted glass, and a conic wash. Each is
277
277
  themed through the role tokens and tunable via CSS-var knobs.
278
278
  </p>
@@ -297,22 +297,18 @@
297
297
  </div>
298
298
  <% end %>
299
299
 
300
- <%# The glow host is a BARE wrapper and the opaque face is a CHILD. The ring
301
- pseudos sit at z-index -1/-2, and a negative-z descendant paints ABOVE its
302
- host's own background so a background on the HOST washes the whole card
303
- face and there is no ring left. The child in front is what covers the
304
- middle, exactly as TM's holo-wrap / holo-card split does. See
305
- engine-motion.css §9b. %>
300
+ <%# The CARD shape: the host carries its own background and the ring still
301
+ rings, because the primitive punches the host's box out of both wedge
302
+ layers. The WRAPPER shape (bare host + opaque child) is staged by the
303
+ Modals section's glow cards both are documented in engine-motion.css
304
+ §9b. Two wedges travel the edge, so a pair of colors reads as a pair. %>
306
305
  <%= render layout: "style/specimen",
307
- locals: { klass: ".studio-team-glow (knob: --studio-team-glow-color)",
308
- usage: %(<div class="studio-team-glow rounded-xl">\n <div class="rounded-xl bg-surface p-5">Selected</div>\n</div>) } do %>
306
+ locals: { klass: ".studio-team-glow (knobs: --studio-team-glow-color / -color-b)",
307
+ usage: %(<div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>\n<div class="studio-team-glow rounded-xl bg-surface p-5"\n style="--studio-team-glow-color: #F08030; --studio-team-glow-color-b: #6890F0">Fire / Flying</div>) } do %>
309
308
  <div class="flex flex-wrap items-center justify-center gap-6">
310
- <div class="studio-team-glow rounded-xl">
311
- <div class="rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
312
- </div>
313
- <div class="studio-team-glow rounded-xl" style="--studio-team-glow-color: #E11D48">
314
- <div class="rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Ravens</div>
315
- </div>
309
+ <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
310
+ <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center"
311
+ style="--studio-team-glow-color: #F08030; --studio-team-glow-color-b: #6890F0">Fire / Flying</div>
316
312
  </div>
317
313
  <% end %>
318
314
 
@@ -335,6 +331,65 @@
335
331
  </div>
336
332
  </section>
337
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&rsquo;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
+
338
393
  <!-- Confetti & pulse -->
339
394
  <section class="space-y-5">
340
395
  <div class="space-y-1">
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.54.1"
2
+ VERSION = "0.56.0"
3
3
  end
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.54.1
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/_save_bar.html.erb
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