studio-engine 0.129.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed5be658c4ba3547da2294995bc7c13e7181bccbd5710fd07d84c7fb0af95a2c
4
- data.tar.gz: d48d2fbebaee5b7e33a22c16bc2f6d40b1afc161d0dacb6eb42d51a8e36cd4fe
3
+ metadata.gz: 00ce81b9c4f82a619f86521fd4495d07e8a2a81f9f1eb5f15963e26f0cb038aa
4
+ data.tar.gz: ed8a7169b6322c577ca796c974f4561bd3c4a2e29fa7da917cd28aec84f401b5
5
5
  SHA512:
6
- metadata.gz: 99e6c74a4c11320449ee3b58d89b56712067f02bc6b2228d602f4ec942b06617b77960e05d8d29dc03dda95e5c92868d4da18bd67922494fbe84abb40b6e308f
7
- data.tar.gz: 353b2199a4a651ee65a0d00c448c8273731b9a4bebbb5c5eff97fe97ba57177ae8f1a47ab0fa6d4b9f2f99856c46a8992c62c84d4319db28116b0dd557c05adf
6
+ metadata.gz: 4f0f640580ed2f77fdc6093e9a90f2edfc12bf2ba6925fc8ad6b24cd5587668c84d465090be866dcf19c5f536cb38ccbff123f078a52dbee52f8b2f7d73d302d
7
+ data.tar.gz: c02e3417b8f8eb1bb4b6b38706ddf79561959b61cacb7317a9465825c1724097c3b77fc5c4cf131f0011dff852b0c430c90f660277fd0067100a611cec10ec55
data/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ 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
+
7
48
  ## 0.129.0 — 2026-11-05
8
49
 
9
50
  ### Changed
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;
@@ -368,6 +368,74 @@ body.modal-open .studio-app-banner {
368
368
  margin-left: auto;
369
369
  }
370
370
 
371
+ /* -- The section header (studio/_section_header, docs/PAGE_HEADER.md) --------
372
+ A section's title that sticks under the rest of the stack, inside its own <section>,
373
+ so the next section's header takes its place with no scroll script. At rest it reads as
374
+ a normal title. Stuck it takes the page header's bar, so content does not show through
375
+ it. studio/pinned_stack says which one is stuck. Its box never changes between the two,
376
+ so the change moves nothing. One line, always: a long title ends in an ellipsis on the
377
+ bar, the height is known, and the whole text stays in the heading.
378
+
379
+ IT STICKS ONLY ONCE THE PUBLISHER RUNS (html[data-pin-live]). With no publisher no bar
380
+ ever comes, and a clear title would lie over the page; in the flow it is a plain title.
381
+
382
+ A SHORT SCREEN KEEPS ITS ROOM. Under 36rem tall, a small phone or one on its side, the
383
+ header stays in the flow: the navbar and the page row already take a quarter of the height.
384
+
385
+ z-index is one tier under the page row, set by the partial, so a header being pushed out
386
+ passes under the row and over the content. --section-header-h is what a target inside a
387
+ section adds to its scroll margin, so it lands below the bar. */
388
+ :root {
389
+ --section-header-h: 2.25rem;
390
+ }
391
+
392
+ @utility section-header {
393
+ @apply text-heading;
394
+ position: relative;
395
+ min-width: 0;
396
+ min-height: var(--section-header-h);
397
+ padding-block: 0.25rem;
398
+ margin-block-end: 0.25rem;
399
+ border-block: 1px solid transparent;
400
+ background-color: transparent;
401
+ font-size: calc(var(--type-base, 16px) * 1.25);
402
+ font-weight: 700;
403
+ line-height: 1.3;
404
+ overflow: hidden;
405
+ white-space: nowrap;
406
+ text-overflow: ellipsis;
407
+ transition: background-color 150ms ease, border-color 150ms ease;
408
+
409
+ html[data-pin-live] & {
410
+ @media (height >= 36rem) {
411
+ position: sticky;
412
+ }
413
+ }
414
+
415
+ &[data-stuck] {
416
+ @apply bg-surface border-subtle;
417
+ border-top-color: transparent;
418
+ }
419
+ }
420
+
421
+ /* Where a jump lands. A jump goes deep, so what counts is the stack once PINNED, with the
422
+ navbar compact and the page row stuck: heights, not --pin-section-top, whose value at the
423
+ top of the page holds the row's rest place and whatever scrolls away above it. So this one
424
+ composes, which a thing placed on screen must not: a margin is read once, at the jump. Less
425
+ two pixels, so the header has left its place on a fractional height and its title shows on
426
+ the bar.
427
+
428
+ A JUMP FROM THE TOP OF THE PAGE reads the navbar still tall, and lands by the navbar's
429
+ collapse below the line. Landing short is safe, the title shows. Landing past is safe too,
430
+ since the bar holds the title. A target inside a section: a bar's height lower. */
431
+ @utility section-target {
432
+ scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) - 2px);
433
+ }
434
+
435
+ @utility section-subtarget {
436
+ scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) + var(--section-header-h));
437
+ }
438
+
371
439
  /* -- Scrims: tints and shades ----------------------------------------------
372
440
  A scrim is the wash laid over media (a photo, a video) so text reads on it.
373
441
  It comes in two TONES, and the tone is a design decision, not a colour pick:
@@ -1514,14 +1582,36 @@ html[data-theme-flip]::view-transition-new(*) {
1514
1582
  }
1515
1583
  }
1516
1584
 
1585
+ /* THE SECTION HEADER UNDER GLASS, the same bar one layer down. While it is stuck the bar
1586
+ under the row owns the round corners and the shadow: the row goes square, or the page
1587
+ would show in the notches between the two. */
1588
+ html[data-surface-style="glass"] .section-header[data-stuck] {
1589
+ background-color: var(--color-page);
1590
+ border-top-color: transparent;
1591
+ border-bottom-color: var(--surface-glass-line);
1592
+ border-radius: 0 0 0.875rem 0.875rem;
1593
+ box-shadow: var(--surface-glass-shadow);
1594
+ }
1595
+ @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
1596
+ html[data-surface-style="glass"] .section-header[data-stuck] {
1597
+ background-color: color-mix(in srgb, var(--color-page) 62%, transparent);
1598
+ -webkit-backdrop-filter: blur(18px) saturate(140%);
1599
+ backdrop-filter: blur(18px) saturate(140%);
1600
+ }
1601
+ }
1602
+ html[data-surface-style="glass"][data-pin-section-stuck] .page-header[data-stuck] {
1603
+ border-radius: 0;
1604
+ box-shadow: var(--surface-glass-highlight);
1605
+ }
1606
+
1517
1607
  /* LIT FROM ABOVE, under the same opt-in: one light for buttons, badges and inputs, from the
1518
1608
  direction of the cards' top highlight. Four shadows: top light, bottom shade, contact, and
1519
1609
  a glow in the button's own color.
1520
1610
 
1521
- THE GRADIENT KEEPS ITS CHROMA. Both stops are built in oklch from the token, a brighter
1522
- and an equally saturated top over a deep saturated bottom; mixing toward black or grey is
1523
- what mutes a button. White text is held to 4.5:1 against the midpoint behind it, so the
1524
- top stop reads lower, as it does in the rule this follows.
1611
+ THE GRADIENT KEEPS ITS CHROMA. Both stops are built in oklch from the token, the same step
1612
+ lighter and darker at the token's own chroma; mixing toward black or grey is what mutes a
1613
+ button. The midpoint is the token, so white text holds 4.5:1 there on the semantic fills
1614
+ and about 3:1 on the default primary, which Alex McRitchie accepted to match the brand word.
1525
1615
 
1526
1616
  WHO GETS WHAT. Every full-size filled button: gradient, top, bottom, contact and a glow in
1527
1617
  its own color, on the same 0.15 lightness range. A .btn-sm has no glow, since a toolbar or
@@ -1569,16 +1659,17 @@ html[data-theme-flip]::view-transition-new(*) {
1569
1659
  --lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.14), 0 1px 1px rgb(0 0 0 / 0.35);
1570
1660
  --lit-input: inset 0 1px 2px rgb(0 0 0 / 0.4), inset 0 -1px 0 rgb(255 255 255 / 0.06);
1571
1661
  }
1572
- /* Stops: the app's primary is often too light for white, so it steps down its own lightness;
1573
- the semantic fills already hold 4.5:1, so the midpoint stays on the fill. */
1574
- html[data-surface-style="glass"] .btn-primary {
1575
- --lit-top-stop: oklch(from var(--color-cta) calc(l - 0.03) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
1576
- --lit-low-stop: oklch(from var(--color-cta) calc(l - 0.18) calc(c + 0.05) h / var(--lit-fill-alpha, 1));
1577
- }
1578
- html[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) {
1579
- --lit-top-stop: oklch(from var(--lit-fill) calc(l + 0.075) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
1580
- --lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.075) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
1581
- }
1662
+ /* Stops: CENTERED ON THE FILL. Each stop moves lightness the same step up and down, with
1663
+ chroma and hue unchanged, so the gradient's midpoint (oklab interpolation) is the token
1664
+ itself. The primary centers on --color-cta, the brand violet a text-primary word paints,
1665
+ so the button and the word read as one purple. Alex McRitchie accepted white near 3:1 on
1666
+ the default primary for that; the semantic fills hold 4.5:1 at the midpoint. A top stop
1667
+ past sRGB clips only the top edge: the midpoint is interpolated before it is clipped. */
1668
+ html[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success) {
1669
+ --lit-top-stop: oklch(from var(--lit-fill) calc(l + 0.06) c h / var(--lit-fill-alpha, 1));
1670
+ --lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.06) c h / var(--lit-fill-alpha, 1));
1671
+ }
1672
+ html[data-surface-style="glass"] .btn-primary { --lit-fill: var(--color-cta); }
1582
1673
  html[data-surface-style="glass"] .btn-secondary { --lit-fill: var(--btn-secondary-bg, var(--color-success)); }
1583
1674
  html[data-surface-style="glass"] .btn-danger { --lit-fill: var(--color-danger-fill, var(--color-danger)); }
1584
1675
  html[data-surface-style="glass"] .btn-warning { --lit-fill: var(--color-warning-fill, var(--color-warning)); }
@@ -1598,16 +1689,16 @@ html[data-theme-flip]::view-transition-new(*) {
1598
1689
  var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
1599
1690
  }
1600
1691
  /* On a dark page a deep green, red or orange glow disappears, so these glow from the top
1601
- stop. The violet is already bright enough at its low stop. */
1692
+ stop. The violet, centered on the brand colour, is bright enough at its low stop. */
1602
1693
  html.dark[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) { --lit-glow-stop: var(--lit-top-stop); }
1603
1694
  /* A TOUCH OF TRANSPARENCY on a full-size filled button, dark theme only: a photo behind it
1604
- shows subtly, and white text holds 4.5:1 at the midpoint. An outer shadow is not painted
1695
+ shows subtly, and white text holds its contrast at the midpoint. An outer shadow is not painted
1605
1696
  under its own box, so the glow never brightens the fill. On a light page the backdrop would
1606
1697
  lift the fill and cost contrast, so it stays solid there. Without a blur, or without relative
1607
1698
  oklch (the gradient would drop out and leave white text on nothing), it stays solid. */
1608
1699
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (color: oklch(from red l c h)) {
1609
1700
  html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"], [class*=" bg-"], [class^="bg-"]) {
1610
- --lit-fill-alpha: 0.94;
1701
+ --lit-fill-alpha: 0.85;
1611
1702
  background-color: transparent;
1612
1703
  -webkit-backdrop-filter: blur(6px);
1613
1704
  backdrop-filter: blur(6px);
@@ -1655,13 +1746,53 @@ html[data-theme-flip]::view-transition-new(*) {
1655
1746
  html[data-surface-style="glass"] .badge { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-badge); }
1656
1747
  html[data-surface-style="glass"] .input-field { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-input); }
1657
1748
 
1749
+ /* BUTTON TRICKS ON A LIT BUTTON (the lit rules above).
1750
+ A lit button already uses its box-shadow for its contact shadow and glow, and
1751
+ its ::after for its inset light. These rules sit in the utilities layer and the
1752
+ tricks in engine-motion.css sit in components, so the lit rules win, and each trick
1753
+ that shares one of them makes room here. Keep a trick on a real .btn lit.
1754
+
1755
+ --btn-lit-rest is the lit button's outer shadow at rest. The pulse's keyframes
1756
+ replace box-shadow for the whole animation, so they put it back themselves. */
1757
+ html[data-surface-style="glass"] .btn:not(:disabled, [aria-disabled="true"]) {
1758
+ --btn-lit-rest: var(--lit-contact);
1759
+ }
1760
+ html[data-surface-style="glass"] .btn:is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"]) {
1761
+ --btn-lit-rest: var(--lit-contact),
1762
+ var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
1763
+ }
1764
+
1765
+ /* The glows need ::after for their bloom, so the inset light moves to the host and
1766
+ the bloom gets its own box back. Without this the lit rule's inset: -1px closes
1767
+ the bloom's hole and it washes the whole face. .btn-outline has no lit ::after. */
1768
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]) {
1769
+ box-shadow: var(--lit-inset), var(--btn-lit-rest);
1770
+ }
1771
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]):active {
1772
+ box-shadow: var(--lit-inset-pressed);
1773
+ }
1774
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow):not(.btn-outline, :disabled, [aria-disabled="true"])::after {
1775
+ inset: calc(-1 * (var(--edge-rotate-glow-thickness) + 4px + var(--edge-rotate-glow-bloom) * 2));
1776
+ box-shadow: none;
1777
+ }
1778
+ html[data-surface-style="glass"] .btn.studio-border-glow:not(.btn-outline, :disabled, [aria-disabled="true"])::after {
1779
+ inset: -46px;
1780
+ box-shadow: none;
1781
+ }
1782
+
1783
+ /* The sheen's band already stops at its own rounded box, and overflow: hidden would
1784
+ cut the lit ::after's outer pixel, the top highlight. */
1785
+ html[data-surface-style="glass"] .btn.sheen:not(:disabled, [aria-disabled="true"]) {
1786
+ overflow: visible;
1787
+ }
1788
+
1658
1789
  /* LESS TRANSPARENCY: every surface above goes solid and drops its blur. Last, to win on order;
1659
1790
  both spellings of the root, since html.dark outranks a bare html[...] on specificity. */
1660
1791
  @media (prefers-reduced-transparency: reduce) {
1661
1792
  html[data-surface-style="glass"], html.dark[data-surface-style="glass"] { --surface-glass-fill: var(--color-surface); --surface-glass-fill-hover: var(--color-surface-alt); }
1662
- html[data-surface-style="glass"] :is(.surface-glass-blur, .nav-shell, .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)),
1793
+ html[data-surface-style="glass"] :is(.surface-glass-blur, .nav-shell, .section-header[data-stuck], .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)),
1663
1794
  html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"]) { --lit-fill-alpha: 1; -webkit-backdrop-filter: none; backdrop-filter: none; }
1664
- html[data-surface-style="glass"] :is(.nav-shell, .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)) { background-color: var(--color-page); }
1795
+ html[data-surface-style="glass"] :is(.nav-shell, .section-header[data-stuck], .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)) { background-color: var(--color-page); }
1665
1796
  }
1666
1797
 
1667
1798
  /* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest