studio-engine 0.128.0 → 0.130.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +5 -0
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +148 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +203 -19
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/helpers/studio/fizz_helper.rb +5 -0
  8. data/app/javascript/studio/agent_chat.js +74 -3
  9. data/app/javascript/studio/agent_chat_voice.js +17 -1
  10. data/app/javascript/studio/alpine_stores.js +80 -22
  11. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  12. data/app/javascript/studio/pinned_stack.js +112 -4
  13. data/app/views/registrations/new.html.erb +4 -3
  14. data/app/views/sessions/new.html.erb +4 -3
  15. data/app/views/studio/_fizz.html.erb +26 -0
  16. data/app/views/studio/_fizz_layer.html.erb +4 -1
  17. data/app/views/studio/_hold_button.html.erb +1 -2
  18. data/app/views/studio/_page_header.html.erb +3 -2
  19. data/app/views/studio/_section_header.html.erb +23 -0
  20. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  21. data/app/views/style/_components.html.erb +3 -7
  22. data/app/views/style/_glass.html.erb +1 -1
  23. data/app/views/style/_host.html.erb +3 -14
  24. data/app/views/style/_modal_specimen.html.erb +5 -5
  25. data/app/views/style/_modals.html.erb +29 -224
  26. data/app/views/style/_scrims.html.erb +1 -8
  27. data/app/views/style/_site_footer.html.erb +5 -13
  28. data/app/views/style/_specimen.html.erb +4 -0
  29. data/app/views/style/_tasks.html.erb +17 -30
  30. data/app/views/style/_theme.html.erb +3 -9
  31. data/app/views/style/_tricks.html.erb +155 -216
  32. data/app/views/style/index.html.erb +7 -3
  33. data/lib/studio/version.rb +1 -1
  34. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3abb879eda738b9ebe2d7ba3aa0c7400e4e335eccdf5775c654b8810db8f96e2
4
- data.tar.gz: 440cde0469a8d8d8905a7250da40fbd2576e3f9b5a6b9c2a68036368486759cc
3
+ metadata.gz: 00ce81b9c4f82a619f86521fd4495d07e8a2a81f9f1eb5f15963e26f0cb038aa
4
+ data.tar.gz: ed8a7169b6322c577ca796c974f4561bd3c4a2e29fa7da917cd28aec84f401b5
5
5
  SHA512:
6
- metadata.gz: e73f00423880d25f49324ed5ef4c3c152caf27f7bc144815ea4928242933e675c1e879393bdd2777d40659ddb472ddae8c23eb3ad9f0ae2f3f9e25e04e443033
7
- data.tar.gz: '09e30076328185194bbd9773a059c1ee3a984e60e2376ff08aaf0f785ee3888c85e056dbffc38c69fa3477d03adff476beb7832bb60c974d2dc5f010a42fbc5d'
6
+ metadata.gz: 4f0f640580ed2f77fdc6093e9a90f2edfc12bf2ba6925fc8ad6b24cd5587668c84d465090be866dcf19c5f536cb38ccbff123f078a52dbee52f8b2f7d73d302d
7
+ data.tar.gz: c02e3417b8f8eb1bb4b6b38706ddf79561959b61cacb7317a9465825c1724097c3b77fc5c4cf131f0011dff852b0c430c90f660277fd0067100a611cec10ec55
data/CHANGELOG.md CHANGED
@@ -4,6 +4,92 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.130.0 — 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - **Section headers that stack under the page header: `studio/_section_header`.** Render it
12
+ as the first child of a `<section>`. The title sticks directly under the page row, takes a
13
+ bar (solid in light and dark, glass under glass), and the next section's header takes its
14
+ place, by CSS sticky and no scroll script. `/admin/style` uses it on every section,
15
+ including a host's own. It joins the pinned stack as one layer, `section`: a new
16
+ `data-pin-section`, and `--pin-section-top`, `-h` and `-bottom`. Only the stuck header
17
+ counts, so `--pin-stack-bottom` and a table's sticky header sit under it. New classes:
18
+ `section-header`, `section-target` and `section-subtarget`, which
19
+ replace a hand-written `scroll-margin-top`. The publisher now reads the layers once more after
20
+ a settle and after a layer's top moves, so a layer under a layer does not keep a stale line.
21
+ Detail: `docs/PAGE_HEADER.md`.
22
+ ### Changed
23
+
24
+ - **A lit button's gradient centers on its own colour.** Every lit variant steps lightness 0.06 up
25
+ and 0.06 down from its fill, with chroma and hue unchanged, so the painted midpoint is the token.
26
+ The lit primary now centers on `--color-cta`, the violet a `text-primary` word paints, instead of
27
+ sitting darker and more saturated. White on the default primary is about 3.1:1 at the midpoint;
28
+ Alex McRitchie accepted that so the button and the brand word read as one purple. The semantic
29
+ fills still hold 4.5:1 there. `/lab/hero_button` shows a `text-primary` word beside the button.
30
+
31
+ - **Tricks starts with the buttons, then what you can add to them.** A new Button tricks group
32
+ sits directly under Buttons: rotating edge glow, border glow, sheen, pulse, fizz, hold to confirm,
33
+ confetti burst and cannons. Each sits on a real `.btn` or fires from one, with its code to copy.
34
+ The other groups keep their order, with one short sentence each at most.
35
+ - **`.studio-team-glow` is now `.edge-rotate-glow`, with `--edge-rotate-glow-*` knobs.** The old class
36
+ and knobs are a deprecated alias and paint the same, so no host has to change.
37
+ - **Fizz works around any button.** `render layout: "studio/fizz", locals: { seed: "save" }` wraps a
38
+ button in a `.fizz-stack`. The hold button's markup and selectors are unchanged. The only tie was
39
+ the `.hold-stack` selector.
40
+ - **Trial: a dark, full-size lit button's fill drops from 0.94 to 0.85 alpha** (`--lit-fill-alpha`).
41
+
42
+ ### Fixed
43
+
44
+ - **The button tricks keep a lit button lit.** A lit button's `::after` closed the hole in the glow's
45
+ bloom, which washed its whole face. The sheen clipped the top highlight, and the pulse dropped the
46
+ lit shadow. `.studio-border-glow` on a button no longer ends its halo on a hard box edge.
47
+
48
+ ## 0.129.0 — 2026-11-05
49
+
50
+ ### Changed
51
+
52
+ - **The `/admin/style` copy is trimmed.** Each section keeps at most one short sentence of intro,
53
+ and the notes under specimens are cut the same way. Controls, specimens and error messages are
54
+ unchanged. The hidden copy-to-agent references on the modal cards are unchanged too. The Tricks
55
+ section is not part of this trim.
56
+
57
+ - **A bad or missing CSRF token on a JSON or `Accept: */*` request is now a 422 with no ErrorLog
58
+ row, on every controller using `Studio::ErrorHandling`.** The catch-all answered JSON 500 and
59
+ wrote a row. It now answers `422 {error, reason: "invalid_token"}` in every environment, and a
60
+ test on such a route that asserted `assert_raises(ActionController::InvalidAuthenticityToken)`
61
+ now asserts the 422. An HTML form is unchanged: the redirect home with its alert, and the row.
62
+
63
+ ### Fixed
64
+
65
+ - **A theme toggle is one crossfade.** `$store.theme.toggle()` added `theme-transition` for 300ms, and
66
+ in a host with that rule about 210 elements ran their own 200ms transition while gradients and
67
+ the page background snapped. It now flips the whole page in one 160ms view transition with no
68
+ movement. No element runs its own transition, and no named element keeps its own picture. A
69
+ browser with no view transitions, a reduced-motion reader, and a toggle during a Turbo visit
70
+ flip at once, and the visit's motion plays on. Under the crossfade the class, the store value
71
+ and localStorage change together in the transition's callback, so all three still read the old
72
+ theme for up to a frame after the call: a caller that reads them in the same tick must wait.
73
+ `toggleTheme` and `storedTheme` are no longer exported, and the store starts from the root's
74
+ class. `$store.theme.set("dark" | "light")` is the one setter, and `toggle()` stays. The
75
+ `theme-transition` rule in a host is now dead. Measured on the hub's `/deployments`, signed in,
76
+ 2100 elements, Metal GPU: the largest step between frames fell from 35-65% of the brightness
77
+ change to 11%, and element transitions started by one toggle from about 1170 to 0.
78
+ Chromium sends every pointer hit to the root during a view transition, for about 160ms. A pointer
79
+ that stops in that time can show no `:hover` until it moves again, and a second click on the toggle
80
+ is dropped. A host spec that toggles and then hovers must wait for `data-theme-flip` to clear.
81
+
82
+ - **The agent chat at phone width.** Measured at 390x844 and 320x568, light and dark:
83
+ - No blank tail. A turn's `sr-only` name escaped the log, which had no positioned ancestor,
84
+ and stretched the document to 1933px where `main` ended at 977px. The log is now
85
+ `position: relative`.
86
+ - The newest turn shows above the composer, on load and after a new turn. The page scrolls
87
+ the thread's end clear of the stuck composer; not for an empty thread, not for a reader
88
+ scrolled up in the log or the page, and not during a hold, when it waits for the button
89
+ to be let go.
90
+ - A stale page says so. A refused token reads "This page has expired" with a reload hint on
91
+ the voice line and the typed error, and a refused send keeps its words in the box.
92
+
7
93
  ## 0.128.0 — 2026-11-05
8
94
 
9
95
  ### Fixed
data/README.md CHANGED
@@ -294,6 +294,11 @@ An admin page's title: a plain header at rest, a bar once stuck under the navbar
294
294
  in the pinned stack so a table's sticky header sits under it
295
295
  ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
296
296
 
297
+ ### The section header — `studio/_section_header`
298
+
299
+ A section's title that sticks under the page row until the next section's takes its place,
300
+ with a bar once stuck ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md#the-section-header)).
301
+
297
302
  ```erb
298
303
  <%= render "studio/page_header", title: "Models", meta: "v1.2.3", label: "Models",
299
304
  tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %>
@@ -19,6 +19,7 @@
19
19
  Effect:
20
20
  .text-gradient gradient-clipped text fill (authored fresh)
21
21
  .studio-glow soft themed glow halo (authored fresh)
22
+ .edge-rotate-glow a glow rotating around the edge (ported, TM)
22
23
  .surface-glass frosted translucent glass panel (authored fresh)
23
24
  .conic-surface slow rotating conic-gradient wash (authored fresh)
24
25
  .pulse-cta attention pulse/glow for a CTA (ported, TM)
@@ -26,6 +27,7 @@
26
27
  Action:
27
28
  .hold-btn press-and-hold confirm button (ported, TM)
28
29
  .hold-stack/.fizz-bit the carbonation behind it (ported, TM)
30
+ .fizz-stack the same bubbles around any button (studio/_fizz)
29
31
  .email-reject email field rejection shake + fade (extracted, TM)
30
32
 
31
33
  Loader:
@@ -167,6 +169,16 @@
167
169
  transform: translateZ(0);
168
170
  }
169
171
 
172
+ /* On a button the halo needs room, as .edge-rotate-glow's bloom does. Mask applies
173
+ after filter, so the gradient stops at the padding box and the transparent border
174
+ is empty room for the blur to fade into. Without it the halo ends on a hard box. */
175
+ .btn.studio-border-glow::after {
176
+ inset: -46px;
177
+ padding: 10px;
178
+ border: 36px solid transparent;
179
+ background-clip: padding-box;
180
+ }
181
+
170
182
  @keyframes studioBorderGlowSteam {
171
183
  0% {
172
184
  background-position: var(--studio-border-glow-offset, 0%) 0;
@@ -521,37 +533,32 @@
521
533
  }
522
534
 
523
535
  /* =============================================================================
524
- 9b. studio-team-glow — rotating "selected card" ring glow, one or two colors
525
- Ported from turf-monster's per-team selected-card glow (.holo-border-glow):
526
- a conic-gradient ring rotates around the element's edge, with a blurred bloom
527
- twin behind it. DISTINCT from .studio-border-glow (an animated RAINBOW border)
528
- and .studio-glow (a static soft box-shadow halo) — this is the "this card is
529
- selected / live" ring.
530
-
531
- The color is a CSS var (default the CTA role, so purple on McRitchie Studio),
532
- just like TM's per-team --glow-c. TWO opposed wedges travel the edge, and they
533
- can carry two colors: --studio-team-glow-color-b defaults to the first, so a
534
- one-color caller sees no change, and a caller with a pair to show (a Pokémon's
535
- two types) gets one per wedge. The wedges are swept by animating a registered
536
- }
537
- @property angle (rotating the GRADIENT, not the element — so no oversized-child
538
- @layer components {
539
- clipping trick is needed). Opacity is a var (--studio-team-glow-opacity) with a
540
- transition, so a consumer can fade the glow in/out (the living style guide
541
- slides it between step cards as a modal's step machine advances).
542
-
543
- THE HOST MAY CARRY ITS OWN BACKGROUND — the ring cuts the host's box out of
544
- both wedge layers, so only the band around the edge paints. That hole is what
545
- makes the primitive work in the two shapes its consumers need:
536
+ 9b. edge-rotate-glow — a glow that rotates around an element's edge
537
+ Ported from turf-monster's selected-card glow (.holo-border-glow): a conic
538
+ gradient rotates around the element's edge, with a blurred bloom twin behind it.
539
+ DISTINCT from .studio-border-glow (an animated RAINBOW border) and .studio-glow
540
+ (a static soft box-shadow halo).
541
+
542
+ The color is a CSS var, default the CTA role. TWO opposed wedges travel the
543
+ edge: --edge-rotate-glow-color-b defaults to the first color, so a one-color
544
+ caller sees one color and a caller with a pair gets one per wedge. The wedges
545
+ are swept by animating a registered @property angle (rotating the GRADIENT, not
546
+ the element, so no oversized-child clipping trick is needed). Opacity is a var
547
+ with a transition, so a caller can fade the glow in and out (the living style
548
+ guide slides it between step cards as a modal's step machine advances).
549
+
550
+ THE HOST MAY CARRY ITS OWN BACKGROUND. The ring cuts the host's box out of both
551
+ wedge layers, so only the band around the edge paints. That hole is what makes
552
+ the primitive work in the two shapes its consumers need:
546
553
 
547
554
  · the WRAPPER shape (TM's holo-wrap / holo-card split) — a bare host with an
548
555
  opaque child in front. The engine's style/_modal_specimen and the hub's
549
556
  release-phase-meter both ship this. Unchanged by the hole: the child was
550
557
  already covering that interior.
551
- · the CARD shape — the host IS the opaque surface. This is what a live board
552
- card needs, because it cannot take a wrapper at all: Turbo targets the card
553
- element for both replace and remove, so a wrapper is orphaned every time a
554
- card leaves the board.
558
+ · the CARD shape — the host IS the opaque surface, as a .btn is. A live board
559
+ card needs this too, because it cannot take a wrapper at all: Turbo targets
560
+ the card element for both replace and remove, so a wrapper is orphaned every
561
+ time a card leaves the board.
555
562
 
556
563
  Without the hole the card shape is impossible, and not for a tunable reason:
557
564
  both layers are pseudo-elements at z-index -1 / -2, and CSS paints an element's
@@ -563,59 +570,87 @@
563
570
  The host must still NOT be clipped by an overflow-hidden ancestor near its
564
571
  border, or the ring is cut off with it.
565
572
 
566
- Usage: <div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>
573
+ Usage: <button class="btn btn-primary edge-rotate-glow">Save</button>
567
574
  wrapper shape, when the face is its own element:
568
- <div class="studio-team-glow rounded-xl">
575
+ <div class="edge-rotate-glow rounded-xl">
569
576
  <div class="rounded-xl bg-surface p-5">Selected</div>
570
577
  </div>
571
- red glow: style="--studio-team-glow-color: #E11D48"
572
- two types: style="--studio-team-glow-color: #F08030;
573
- --studio-team-glow-color-b: #6890F0"
574
- Tune: --studio-team-glow-color / -color-b / -opacity / -speed / -thickness /
578
+ one color: style="--edge-rotate-glow-color: #E11D48"
579
+ two colors: style="--edge-rotate-glow-color: #F08030;
580
+ --edge-rotate-glow-color-b: #6890F0"
581
+ Tune: --edge-rotate-glow-color / -color-b / -opacity / -speed / -thickness /
575
582
  -bloom
583
+
584
+ DEPRECATED ALIAS: .studio-team-glow and its --studio-team-glow-* knobs. Every
585
+ old name keeps working, because hub and Turf views, scripts and e2e use them on
586
+ their main branches. Remove the alias only after both have moved. An old knob
587
+ on the new class also still applies; a new knob on the old class is not
588
+ supported, so move the class and its knobs together.
576
589
  ========================================================================== */
577
590
  }
578
- @property --studio-team-glow-angle {
591
+ @property --edge-rotate-glow-angle {
579
592
  syntax: "<angle>";
580
593
  inherits: false;
581
594
  initial-value: 0deg;
582
595
  }
583
596
  @layer components {
597
+ .edge-rotate-glow {
598
+ /* Each default reads the old knob first, so a host that moves the class name
599
+ and leaves an old knob in its inline style keeps its value. */
600
+ --edge-rotate-glow-color: var(--studio-team-glow-color, var(--color-cta));
601
+ /* The SECOND wedge's color. Defaults to the first. Resolved on this element, so
602
+ an inline --edge-rotate-glow-color override feeds this default too. */
603
+ --edge-rotate-glow-color-b: var(--studio-team-glow-color-b, var(--edge-rotate-glow-color));
604
+ --edge-rotate-glow-opacity: var(--studio-team-glow-opacity, 1);
605
+ --edge-rotate-glow-speed: var(--studio-team-glow-speed, 4s);
606
+ --edge-rotate-glow-thickness: var(--studio-team-glow-thickness, 4px);
607
+ --edge-rotate-glow-bloom: var(--studio-team-glow-bloom, 10px);
608
+ }
609
+ /* DEPRECATED ALIAS. See the note above. The old knobs keep their old defaults on
610
+ the old class, at the old specificity, so a consumer's own rule or inline style
611
+ wins exactly as it did; the new knobs then read them. */
584
612
  .studio-team-glow {
585
613
  --studio-team-glow-color: var(--color-cta);
586
- /* The SECOND wedge's color. Defaults to the first, so a one-color caller sees
587
- no change; a caller with two colors to show (a Pokémon's two types, a pair of
588
- brand colors) gets one per wedge. Resolved on this element, so an inline
589
- --studio-team-glow-color override feeds this default too. */
590
614
  --studio-team-glow-color-b: var(--studio-team-glow-color);
591
615
  --studio-team-glow-opacity: 1;
592
616
  --studio-team-glow-speed: 4s;
593
617
  --studio-team-glow-thickness: 4px;
594
618
  --studio-team-glow-bloom: 10px;
619
+ --edge-rotate-glow-color: var(--studio-team-glow-color);
620
+ --edge-rotate-glow-color-b: var(--studio-team-glow-color-b);
621
+ --edge-rotate-glow-opacity: var(--studio-team-glow-opacity);
622
+ --edge-rotate-glow-speed: var(--studio-team-glow-speed);
623
+ --edge-rotate-glow-thickness: var(--studio-team-glow-thickness);
624
+ --edge-rotate-glow-bloom: var(--studio-team-glow-bloom);
625
+ }
626
+ .edge-rotate-glow,
627
+ .studio-team-glow {
595
628
  position: relative;
596
629
  isolation: isolate;
597
630
  }
631
+ .edge-rotate-glow::before,
632
+ .edge-rotate-glow::after,
598
633
  .studio-team-glow::before,
599
634
  .studio-team-glow::after {
600
635
  content: "";
601
636
  position: absolute;
602
- inset: calc(-1 * var(--studio-team-glow-thickness));
637
+ inset: calc(-1 * var(--edge-rotate-glow-thickness));
603
638
  border-radius: inherit;
604
639
  z-index: -1;
605
640
  pointer-events: none;
606
- opacity: var(--studio-team-glow-opacity);
641
+ opacity: var(--edge-rotate-glow-opacity);
607
642
  transition: opacity 0.4s ease;
608
643
  /* Two opposed wedges so two stretches of the ring glow at once. */
609
644
  background: conic-gradient(
610
- from var(--studio-team-glow-angle),
645
+ from var(--edge-rotate-glow-angle),
611
646
  transparent 0%,
612
- var(--studio-team-glow-color) 12%,
647
+ var(--edge-rotate-glow-color) 12%,
613
648
  transparent 33%,
614
649
  transparent 50%,
615
- var(--studio-team-glow-color-b) 62%,
650
+ var(--edge-rotate-glow-color-b) 62%,
616
651
  transparent 83%
617
652
  );
618
- animation: studio-team-glow-spin var(--studio-team-glow-speed) linear infinite;
653
+ animation: edge-rotate-glow-spin var(--edge-rotate-glow-speed) linear infinite;
619
654
  /* THE HOST'S OWN BOX IS PUNCHED OUT of both wedge layers, so what paints is the
620
655
  band around the edge and nothing else. `padding` makes each layer's CONTENT
621
656
  box exactly the host's box; mask-composite then removes that box from the
@@ -625,7 +660,7 @@
625
660
  itself for replace and remove, so a wrapper is orphaned on every remove. With
626
661
  the hole cut here the ring rides the card directly. For a wrapper host this
627
662
  changes nothing visible: the child was already covering that interior. */
628
- padding: var(--studio-team-glow-thickness);
663
+ padding: var(--edge-rotate-glow-thickness);
629
664
  -webkit-mask:
630
665
  linear-gradient(#000 0 0) content-box,
631
666
  linear-gradient(#000 0 0);
@@ -635,6 +670,7 @@
635
670
  linear-gradient(#000 0 0);
636
671
  mask-composite: exclude;
637
672
  }
673
+ .edge-rotate-glow::after,
638
674
  .studio-team-glow::after {
639
675
  /* Blurred bloom twin, a touch larger: its wedges reach 4px PAST the ring, which
640
676
  is what makes the bloom read as a halo, not a soft edge on the ring. Drop the
@@ -647,19 +683,21 @@
647
683
  card's own or the wrapper child's). Mask applies AFTER filter, which is why
648
684
  the reach has to out-measure the blur — without it the halo ends on a hard
649
685
  line at the layer's own box. */
650
- inset: calc(-1 * (var(--studio-team-glow-thickness) + 4px + var(--studio-team-glow-bloom) * 2));
651
- padding: calc(var(--studio-team-glow-thickness) + 4px);
652
- border: calc(var(--studio-team-glow-bloom) * 2) solid transparent;
686
+ inset: calc(-1 * (var(--edge-rotate-glow-thickness) + 4px + var(--edge-rotate-glow-bloom) * 2));
687
+ padding: calc(var(--edge-rotate-glow-thickness) + 4px);
688
+ border: calc(var(--edge-rotate-glow-bloom) * 2) solid transparent;
653
689
  /* The wedges stop at the padding box (host + ring + the 4px); the border is empty
654
690
  room the blur is allowed to spread into. */
655
691
  background-clip: padding-box;
656
692
  z-index: -2;
657
- filter: blur(var(--studio-team-glow-bloom)) saturate(1.35);
693
+ filter: blur(var(--edge-rotate-glow-bloom)) saturate(1.35);
658
694
  }
659
- @keyframes studio-team-glow-spin {
660
- to { --studio-team-glow-angle: 360deg; }
695
+ @keyframes edge-rotate-glow-spin {
696
+ to { --edge-rotate-glow-angle: 360deg; }
661
697
  }
662
698
  @media (prefers-reduced-motion: reduce) {
699
+ .edge-rotate-glow::before,
700
+ .edge-rotate-glow::after,
663
701
  .studio-team-glow::before,
664
702
  .studio-team-glow::after { animation: none; }
665
703
  }
@@ -1262,7 +1300,7 @@ body.modal-open { overflow: visible; }
1262
1300
  Named pulse-cta (not btn-pulse) so it stays a plain motion-layer effect, not a
1263
1301
  .btn component variant the engine.css component contract would demand.
1264
1302
 
1265
- Usage: <button class="btn btn-primary pulse-cta">Change Username</button>
1303
+ Usage: <button class="btn btn-primary pulse-cta">Save</button>
1266
1304
  green pulse: style="--pulse-cta-color: var(--color-success)"
1267
1305
  Tune: --pulse-cta-color / --pulse-cta-speed / --pulse-cta-scale /
1268
1306
  --pulse-cta-ring / --pulse-cta-strength
@@ -1278,19 +1316,19 @@ body.modal-open { overflow: visible; }
1278
1316
  @keyframes pulse-cta {
1279
1317
  0% {
1280
1318
  transform: scale(1);
1281
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 100%), transparent);
1319
+ box-shadow: var(--btn-lit-rest, 0 0 #0000), 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 100%), transparent);
1282
1320
  }
1283
1321
  10% {
1284
1322
  transform: scale(var(--pulse-cta-scale));
1285
- box-shadow: 0 0 0 calc(var(--pulse-cta-ring) * 0.6) color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 50%), transparent);
1323
+ box-shadow: var(--btn-lit-rest, 0 0 #0000), 0 0 0 calc(var(--pulse-cta-ring) * 0.6) color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 50%), transparent);
1286
1324
  }
1287
1325
  22% {
1288
1326
  transform: scale(1);
1289
- box-shadow: 0 0 0 var(--pulse-cta-ring) color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1327
+ box-shadow: var(--btn-lit-rest, 0 0 #0000), 0 0 0 var(--pulse-cta-ring) color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1290
1328
  }
1291
1329
  100% {
1292
1330
  transform: scale(1);
1293
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1331
+ box-shadow: var(--btn-lit-rest, 0 0 #0000), 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1294
1332
  }
1295
1333
  }
1296
1334
  @media (prefers-reduced-motion: reduce) {
@@ -1866,6 +1904,58 @@ body.modal-open { overflow: visible; }
1866
1904
  opacity: 0;
1867
1905
  }
1868
1906
 
1907
+ /* FIZZ AROUND ANY BUTTON (studio/_fizz). The bubbles have no tie to the hold: the
1908
+ only thing that held them to it was this section's .hold-stack selector, and the
1909
+ hold's own states. So .fizz-stack gives them the same stack without a hold, with
1910
+ a neutral name, and it rests and answers hover as a hold button does. */
1911
+ .fizz-stack {
1912
+ position: relative;
1913
+ display: inline-flex;
1914
+ isolation: isolate;
1915
+ }
1916
+
1917
+ .fizz-stack > .fizz-layer {
1918
+ position: absolute;
1919
+ inset: 0;
1920
+ z-index: 0;
1921
+ pointer-events: none;
1922
+ }
1923
+
1924
+ .fizz-stack > :not(.fizz-layer) {
1925
+ position: relative;
1926
+ z-index: 1;
1927
+ }
1928
+
1929
+ .fizz-stack .fizz-bit {
1930
+ position: absolute;
1931
+ width: var(--fs, 4px);
1932
+ height: var(--fs, 4px);
1933
+ margin-top: calc(var(--fs, 4px) / -2);
1934
+ margin-left: calc(var(--fs, 4px) / -2);
1935
+ border-radius: 100px;
1936
+ background: var(--fc, hsl(292 92% 70%));
1937
+ box-shadow:
1938
+ 0 0 6px var(--fc, hsl(292 92% 70%)),
1939
+ 0 0 0 0.5px rgb(255 255 255 / 0.35);
1940
+ opacity: 0;
1941
+ will-change: transform, opacity;
1942
+ animation: fizz-simmer var(--ft, 1.8s) linear var(--fd, 0s) infinite;
1943
+ }
1944
+
1945
+ .fizz-stack:not(.fizz-lively):hover .fizz-bit,
1946
+ .fizz-stack.fizz-lively .fizz-bit {
1947
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1948
+ }
1949
+
1950
+ .fizz-stack > .fizz-layer-extra {
1951
+ opacity: 0;
1952
+ transition: opacity 0.45s ease;
1953
+ }
1954
+
1955
+ .fizz-stack.fizz-lively:hover > .fizz-layer-extra {
1956
+ opacity: 1;
1957
+ }
1958
+
1869
1959
  /* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
1870
1960
  overflow-hidden card has its bubbles cut off at the card's edge, because the
1871
1961
  layer is absolutely positioned inside the card's clip. With the option set,
@@ -1967,7 +2057,9 @@ body.modal-open { overflow: visible; }
1967
2057
  .hold-stack .fizz-bit,
1968
2058
  .hold-stack .hold-fizz-extra,
1969
2059
  .hold-fizz-portal .fizz-bit,
1970
- .hold-fizz-portal .hold-fizz-extra {
2060
+ .hold-fizz-portal .hold-fizz-extra,
2061
+ .fizz-stack .fizz-bit,
2062
+ .fizz-stack .fizz-layer-extra {
1971
2063
  animation: none !important;
1972
2064
  opacity: 0 !important;
1973
2065
  transition: none !important;