studio-engine 0.129.0 → 0.131.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/README.md +48 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +737 -19
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/fizz_helper.rb +5 -0
  15. data/app/helpers/studio/loader_helper.rb +79 -2
  16. data/app/javascript/studio/agent_chat.js +294 -6
  17. data/app/javascript/studio/carousel.js +99 -0
  18. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  19. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  20. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  21. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  22. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  23. data/app/javascript/studio/infinite_scroll.js +142 -0
  24. data/app/javascript/studio/loader.js +6 -1
  25. data/app/javascript/studio/page_transitions.js +65 -8
  26. data/app/javascript/studio/pinned_stack.js +112 -4
  27. data/app/javascript/studio/pull_to_refresh.js +147 -0
  28. data/app/javascript/studio/stimulus.js +12 -1
  29. data/app/javascript/studio/talk_to_agent.js +116 -0
  30. data/app/views/components/_geo_signpost.html.erb +2 -2
  31. data/app/views/components/_link_sidebar.html.erb +2 -2
  32. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  33. data/app/views/components/_sidebar_panel.html.erb +1 -1
  34. data/app/views/error_logs/index.html.erb +2 -8
  35. data/app/views/studio/_carousel.html.erb +97 -0
  36. data/app/views/studio/_fizz.html.erb +26 -0
  37. data/app/views/studio/_fizz_layer.html.erb +4 -1
  38. data/app/views/studio/_hold_button.html.erb +1 -2
  39. data/app/views/studio/_page_header.html.erb +55 -13
  40. data/app/views/studio/_section_header.html.erb +23 -0
  41. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  42. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  43. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  44. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  45. data/app/views/studio/loader/_loader.html.erb +20 -3
  46. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  47. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  48. data/app/views/style/_components.html.erb +3 -5
  49. data/app/views/style/_host.html.erb +3 -6
  50. data/app/views/style/_loaders.html.erb +118 -0
  51. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  52. data/app/views/style/_modal_specimen.html.erb +5 -5
  53. data/app/views/style/_modals.html.erb +15 -5
  54. data/app/views/style/_site_footer.html.erb +1 -1
  55. data/app/views/style/_specimen.html.erb +21 -10
  56. data/app/views/style/_tasks.html.erb +3 -5
  57. data/app/views/style/_theme.html.erb +2 -2
  58. data/app/views/style/_tricks.html.erb +218 -217
  59. data/app/views/style/index.html.erb +21 -11
  60. data/app/views/style/loaders_feed.html.erb +7 -0
  61. data/lib/studio/crumbs.rb +150 -0
  62. data/lib/studio/loader.rb +30 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +3 -0
  65. metadata +22 -2
@@ -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,11 +27,15 @@
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:
32
34
  .studio-loader the app's icon as a branded loader (Loader Lab picks)
33
35
  .studio-loader-overlay the page-load overlay around it (docs/LOADER.md)
36
+ .studio-skeleton the content's shape while it loads (loader family)
37
+ .studio-infinite-scroll the next page near the end (loader family)
38
+ .studio-pull-refresh pull down to reload, phones only (loader family)
34
39
 
35
40
  Leveling:
36
41
  .level-badge(-1..-10) progressive level pill ladder (ported, TM)
@@ -167,6 +172,16 @@
167
172
  transform: translateZ(0);
168
173
  }
169
174
 
175
+ /* On a button the halo needs room, as .edge-rotate-glow's bloom does. Mask applies
176
+ after filter, so the gradient stops at the padding box and the transparent border
177
+ is empty room for the blur to fade into. Without it the halo ends on a hard box. */
178
+ .btn.studio-border-glow::after {
179
+ inset: -46px;
180
+ padding: 10px;
181
+ border: 36px solid transparent;
182
+ background-clip: padding-box;
183
+ }
184
+
170
185
  @keyframes studioBorderGlowSteam {
171
186
  0% {
172
187
  background-position: var(--studio-border-glow-offset, 0%) 0;
@@ -521,37 +536,32 @@
521
536
  }
522
537
 
523
538
  /* =============================================================================
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:
539
+ 9b. edge-rotate-glow — a glow that rotates around an element's edge
540
+ Ported from turf-monster's selected-card glow (.holo-border-glow): a conic
541
+ gradient rotates around the element's edge, with a blurred bloom twin behind it.
542
+ DISTINCT from .studio-border-glow (an animated RAINBOW border) and .studio-glow
543
+ (a static soft box-shadow halo).
544
+
545
+ The color is a CSS var, default the CTA role. TWO opposed wedges travel the
546
+ edge: --edge-rotate-glow-color-b defaults to the first color, so a one-color
547
+ caller sees one color and a caller with a pair gets one per wedge. The wedges
548
+ are swept by animating a registered @property angle (rotating the GRADIENT, not
549
+ the element, so no oversized-child clipping trick is needed). Opacity is a var
550
+ with a transition, so a caller can fade the glow in and out (the living style
551
+ guide slides it between step cards as a modal's step machine advances).
552
+
553
+ THE HOST MAY CARRY ITS OWN BACKGROUND. The ring cuts the host's box out of both
554
+ wedge layers, so only the band around the edge paints. That hole is what makes
555
+ the primitive work in the two shapes its consumers need:
546
556
 
547
557
  · the WRAPPER shape (TM's holo-wrap / holo-card split) — a bare host with an
548
558
  opaque child in front. The engine's style/_modal_specimen and the hub's
549
559
  release-phase-meter both ship this. Unchanged by the hole: the child was
550
560
  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.
561
+ · the CARD shape — the host IS the opaque surface, as a .btn is. A live board
562
+ card needs this too, because it cannot take a wrapper at all: Turbo targets
563
+ the card element for both replace and remove, so a wrapper is orphaned every
564
+ time a card leaves the board.
555
565
 
556
566
  Without the hole the card shape is impossible, and not for a tunable reason:
557
567
  both layers are pseudo-elements at z-index -1 / -2, and CSS paints an element's
@@ -563,59 +573,87 @@
563
573
  The host must still NOT be clipped by an overflow-hidden ancestor near its
564
574
  border, or the ring is cut off with it.
565
575
 
566
- Usage: <div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>
576
+ Usage: <button class="btn btn-primary edge-rotate-glow">Save</button>
567
577
  wrapper shape, when the face is its own element:
568
- <div class="studio-team-glow rounded-xl">
578
+ <div class="edge-rotate-glow rounded-xl">
569
579
  <div class="rounded-xl bg-surface p-5">Selected</div>
570
580
  </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 /
581
+ one color: style="--edge-rotate-glow-color: #E11D48"
582
+ two colors: style="--edge-rotate-glow-color: #F08030;
583
+ --edge-rotate-glow-color-b: #6890F0"
584
+ Tune: --edge-rotate-glow-color / -color-b / -opacity / -speed / -thickness /
575
585
  -bloom
586
+
587
+ DEPRECATED ALIAS: .studio-team-glow and its --studio-team-glow-* knobs. Every
588
+ old name keeps working, because hub and Turf views, scripts and e2e use them on
589
+ their main branches. Remove the alias only after both have moved. An old knob
590
+ on the new class also still applies; a new knob on the old class is not
591
+ supported, so move the class and its knobs together.
576
592
  ========================================================================== */
577
593
  }
578
- @property --studio-team-glow-angle {
594
+ @property --edge-rotate-glow-angle {
579
595
  syntax: "<angle>";
580
596
  inherits: false;
581
597
  initial-value: 0deg;
582
598
  }
583
599
  @layer components {
600
+ .edge-rotate-glow {
601
+ /* Each default reads the old knob first, so a host that moves the class name
602
+ and leaves an old knob in its inline style keeps its value. */
603
+ --edge-rotate-glow-color: var(--studio-team-glow-color, var(--color-cta));
604
+ /* The SECOND wedge's color. Defaults to the first. Resolved on this element, so
605
+ an inline --edge-rotate-glow-color override feeds this default too. */
606
+ --edge-rotate-glow-color-b: var(--studio-team-glow-color-b, var(--edge-rotate-glow-color));
607
+ --edge-rotate-glow-opacity: var(--studio-team-glow-opacity, 1);
608
+ --edge-rotate-glow-speed: var(--studio-team-glow-speed, 4s);
609
+ --edge-rotate-glow-thickness: var(--studio-team-glow-thickness, 4px);
610
+ --edge-rotate-glow-bloom: var(--studio-team-glow-bloom, 10px);
611
+ }
612
+ /* DEPRECATED ALIAS. See the note above. The old knobs keep their old defaults on
613
+ the old class, at the old specificity, so a consumer's own rule or inline style
614
+ wins exactly as it did; the new knobs then read them. */
584
615
  .studio-team-glow {
585
616
  --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
617
  --studio-team-glow-color-b: var(--studio-team-glow-color);
591
618
  --studio-team-glow-opacity: 1;
592
619
  --studio-team-glow-speed: 4s;
593
620
  --studio-team-glow-thickness: 4px;
594
621
  --studio-team-glow-bloom: 10px;
622
+ --edge-rotate-glow-color: var(--studio-team-glow-color);
623
+ --edge-rotate-glow-color-b: var(--studio-team-glow-color-b);
624
+ --edge-rotate-glow-opacity: var(--studio-team-glow-opacity);
625
+ --edge-rotate-glow-speed: var(--studio-team-glow-speed);
626
+ --edge-rotate-glow-thickness: var(--studio-team-glow-thickness);
627
+ --edge-rotate-glow-bloom: var(--studio-team-glow-bloom);
628
+ }
629
+ .edge-rotate-glow,
630
+ .studio-team-glow {
595
631
  position: relative;
596
632
  isolation: isolate;
597
633
  }
634
+ .edge-rotate-glow::before,
635
+ .edge-rotate-glow::after,
598
636
  .studio-team-glow::before,
599
637
  .studio-team-glow::after {
600
638
  content: "";
601
639
  position: absolute;
602
- inset: calc(-1 * var(--studio-team-glow-thickness));
640
+ inset: calc(-1 * var(--edge-rotate-glow-thickness));
603
641
  border-radius: inherit;
604
642
  z-index: -1;
605
643
  pointer-events: none;
606
- opacity: var(--studio-team-glow-opacity);
644
+ opacity: var(--edge-rotate-glow-opacity);
607
645
  transition: opacity 0.4s ease;
608
646
  /* Two opposed wedges so two stretches of the ring glow at once. */
609
647
  background: conic-gradient(
610
- from var(--studio-team-glow-angle),
648
+ from var(--edge-rotate-glow-angle),
611
649
  transparent 0%,
612
- var(--studio-team-glow-color) 12%,
650
+ var(--edge-rotate-glow-color) 12%,
613
651
  transparent 33%,
614
652
  transparent 50%,
615
- var(--studio-team-glow-color-b) 62%,
653
+ var(--edge-rotate-glow-color-b) 62%,
616
654
  transparent 83%
617
655
  );
618
- animation: studio-team-glow-spin var(--studio-team-glow-speed) linear infinite;
656
+ animation: edge-rotate-glow-spin var(--edge-rotate-glow-speed) linear infinite;
619
657
  /* THE HOST'S OWN BOX IS PUNCHED OUT of both wedge layers, so what paints is the
620
658
  band around the edge and nothing else. `padding` makes each layer's CONTENT
621
659
  box exactly the host's box; mask-composite then removes that box from the
@@ -625,7 +663,7 @@
625
663
  itself for replace and remove, so a wrapper is orphaned on every remove. With
626
664
  the hole cut here the ring rides the card directly. For a wrapper host this
627
665
  changes nothing visible: the child was already covering that interior. */
628
- padding: var(--studio-team-glow-thickness);
666
+ padding: var(--edge-rotate-glow-thickness);
629
667
  -webkit-mask:
630
668
  linear-gradient(#000 0 0) content-box,
631
669
  linear-gradient(#000 0 0);
@@ -635,6 +673,7 @@
635
673
  linear-gradient(#000 0 0);
636
674
  mask-composite: exclude;
637
675
  }
676
+ .edge-rotate-glow::after,
638
677
  .studio-team-glow::after {
639
678
  /* Blurred bloom twin, a touch larger: its wedges reach 4px PAST the ring, which
640
679
  is what makes the bloom read as a halo, not a soft edge on the ring. Drop the
@@ -647,19 +686,21 @@
647
686
  card's own or the wrapper child's). Mask applies AFTER filter, which is why
648
687
  the reach has to out-measure the blur — without it the halo ends on a hard
649
688
  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;
689
+ inset: calc(-1 * (var(--edge-rotate-glow-thickness) + 4px + var(--edge-rotate-glow-bloom) * 2));
690
+ padding: calc(var(--edge-rotate-glow-thickness) + 4px);
691
+ border: calc(var(--edge-rotate-glow-bloom) * 2) solid transparent;
653
692
  /* The wedges stop at the padding box (host + ring + the 4px); the border is empty
654
693
  room the blur is allowed to spread into. */
655
694
  background-clip: padding-box;
656
695
  z-index: -2;
657
- filter: blur(var(--studio-team-glow-bloom)) saturate(1.35);
696
+ filter: blur(var(--edge-rotate-glow-bloom)) saturate(1.35);
658
697
  }
659
- @keyframes studio-team-glow-spin {
660
- to { --studio-team-glow-angle: 360deg; }
698
+ @keyframes edge-rotate-glow-spin {
699
+ to { --edge-rotate-glow-angle: 360deg; }
661
700
  }
662
701
  @media (prefers-reduced-motion: reduce) {
702
+ .edge-rotate-glow::before,
703
+ .edge-rotate-glow::after,
663
704
  .studio-team-glow::before,
664
705
  .studio-team-glow::after { animation: none; }
665
706
  }
@@ -1262,7 +1303,7 @@ body.modal-open { overflow: visible; }
1262
1303
  Named pulse-cta (not btn-pulse) so it stays a plain motion-layer effect, not a
1263
1304
  .btn component variant the engine.css component contract would demand.
1264
1305
 
1265
- Usage: <button class="btn btn-primary pulse-cta">Change Username</button>
1306
+ Usage: <button class="btn btn-primary pulse-cta">Save</button>
1266
1307
  green pulse: style="--pulse-cta-color: var(--color-success)"
1267
1308
  Tune: --pulse-cta-color / --pulse-cta-speed / --pulse-cta-scale /
1268
1309
  --pulse-cta-ring / --pulse-cta-strength
@@ -1278,19 +1319,19 @@ body.modal-open { overflow: visible; }
1278
1319
  @keyframes pulse-cta {
1279
1320
  0% {
1280
1321
  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);
1322
+ 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
1323
  }
1283
1324
  10% {
1284
1325
  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);
1326
+ 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
1327
  }
1287
1328
  22% {
1288
1329
  transform: scale(1);
1289
- box-shadow: 0 0 0 var(--pulse-cta-ring) color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1330
+ 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
1331
  }
1291
1332
  100% {
1292
1333
  transform: scale(1);
1293
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1334
+ box-shadow: var(--btn-lit-rest, 0 0 #0000), 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
1294
1335
  }
1295
1336
  }
1296
1337
  @media (prefers-reduced-motion: reduce) {
@@ -1866,6 +1907,58 @@ body.modal-open { overflow: visible; }
1866
1907
  opacity: 0;
1867
1908
  }
1868
1909
 
1910
+ /* FIZZ AROUND ANY BUTTON (studio/_fizz). The bubbles have no tie to the hold: the
1911
+ only thing that held them to it was this section's .hold-stack selector, and the
1912
+ hold's own states. So .fizz-stack gives them the same stack without a hold, with
1913
+ a neutral name, and it rests and answers hover as a hold button does. */
1914
+ .fizz-stack {
1915
+ position: relative;
1916
+ display: inline-flex;
1917
+ isolation: isolate;
1918
+ }
1919
+
1920
+ .fizz-stack > .fizz-layer {
1921
+ position: absolute;
1922
+ inset: 0;
1923
+ z-index: 0;
1924
+ pointer-events: none;
1925
+ }
1926
+
1927
+ .fizz-stack > :not(.fizz-layer) {
1928
+ position: relative;
1929
+ z-index: 1;
1930
+ }
1931
+
1932
+ .fizz-stack .fizz-bit {
1933
+ position: absolute;
1934
+ width: var(--fs, 4px);
1935
+ height: var(--fs, 4px);
1936
+ margin-top: calc(var(--fs, 4px) / -2);
1937
+ margin-left: calc(var(--fs, 4px) / -2);
1938
+ border-radius: 100px;
1939
+ background: var(--fc, hsl(292 92% 70%));
1940
+ box-shadow:
1941
+ 0 0 6px var(--fc, hsl(292 92% 70%)),
1942
+ 0 0 0 0.5px rgb(255 255 255 / 0.35);
1943
+ opacity: 0;
1944
+ will-change: transform, opacity;
1945
+ animation: fizz-simmer var(--ft, 1.8s) linear var(--fd, 0s) infinite;
1946
+ }
1947
+
1948
+ .fizz-stack:not(.fizz-lively):hover .fizz-bit,
1949
+ .fizz-stack.fizz-lively .fizz-bit {
1950
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1951
+ }
1952
+
1953
+ .fizz-stack > .fizz-layer-extra {
1954
+ opacity: 0;
1955
+ transition: opacity 0.45s ease;
1956
+ }
1957
+
1958
+ .fizz-stack.fizz-lively:hover > .fizz-layer-extra {
1959
+ opacity: 1;
1960
+ }
1961
+
1869
1962
  /* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
1870
1963
  overflow-hidden card has its bubbles cut off at the card's edge, because the
1871
1964
  layer is absolutely positioned inside the card's clip. With the option set,
@@ -1967,7 +2060,9 @@ body.modal-open { overflow: visible; }
1967
2060
  .hold-stack .fizz-bit,
1968
2061
  .hold-stack .hold-fizz-extra,
1969
2062
  .hold-fizz-portal .fizz-bit,
1970
- .hold-fizz-portal .hold-fizz-extra {
2063
+ .hold-fizz-portal .hold-fizz-extra,
2064
+ .fizz-stack .fizz-bit,
2065
+ .fizz-stack .fizz-layer-extra {
1971
2066
  animation: none !important;
1972
2067
  opacity: 0 !important;
1973
2068
  transition: none !important;
@@ -2390,10 +2485,55 @@ body.modal-open { overflow: visible; }
2390
2485
  animation-delay: calc(var(--studio-loader-i, 0) * 0.6s - var(--studio-loader-sparks, 5) * 0.6s);
2391
2486
  }
2392
2487
 
2488
+ /* ---- The plain styles: no icon, only the theme's colours ----------------
2489
+ spinner, dots and bar wrap the canonical .spinner, .loading-dots and
2490
+ .progress-meter rules (sections 2, 3 and 7 above) and only set their knobs,
2491
+ so the family and those classes stay one set of rules. */
2492
+ /* Drawn on the page like ink, so in the theme's contrast-checked primary
2493
+ ink (theme_resolver), not the raw primary, which can sink into a dark
2494
+ page. A mono loader keeps currentColor. */
2495
+ .studio-loader:is([data-loader-style="spinner"], [data-loader-style="dots"], [data-loader-style="bar"]):not([data-loader-mono]) {
2496
+ --studio-loader-color: var(--color-primary-ink, var(--color-primary));
2497
+ }
2498
+
2499
+ /* :where() keeps the family's selectors from ending in the bare spinner class: a host
2500
+ counts that one rule to prove the motion layer is imported once
2501
+ (turf-monster test/lib/tailwind_css_first_test.rb). */
2502
+ .studio-loader[data-loader-style="spinner"] :where(.spinner) {
2503
+ --spinner-size: var(--studio-loader-size);
2504
+ --spinner-thickness: max(2px, calc(var(--studio-loader-size) * 0.1));
2505
+ --spinner-color: var(--studio-loader-color);
2506
+ --spinner-track: color-mix(in srgb, var(--studio-loader-color) 22%, transparent);
2507
+ }
2508
+
2509
+ .studio-loader[data-loader-style="dots"] { height: calc(var(--studio-loader-size) * 0.5); }
2510
+ .studio-loader[data-loader-style="dots"] .loading-dots {
2511
+ --loading-dots-size: calc(var(--studio-loader-size) * 0.22);
2512
+ --loading-dots-gap: calc(var(--studio-loader-size) * 0.11);
2513
+ --loading-dots-color: var(--studio-loader-color);
2514
+ }
2515
+
2516
+ .studio-loader[data-loader-style="bar"] {
2517
+ display: block;
2518
+ height: var(--studio-loader-bar-height, 6px);
2519
+ max-width: 100%;
2520
+ }
2521
+ .studio-loader[data-loader-style="bar"] .progress-meter {
2522
+ --progress-height: 100%;
2523
+ --progress: calc(var(--studio-loader-percent, 0) * 1%);
2524
+ --progress-fill: var(--studio-loader-color);
2525
+ --progress-track: color-mix(in srgb, var(--studio-loader-color) 16%, transparent);
2526
+ display: block;
2527
+ width: 100%;
2528
+ }
2529
+
2393
2530
  /* ---- Pending: inside a button, shown only while it or its form is busy --- */
2394
2531
  .studio-loader[data-loader-pending] { display: none; }
2395
2532
  :where(form, button)[aria-busy="true"] .studio-loader[data-loader-pending] { display: inline-grid; }
2396
2533
  :where(form, button)[aria-busy="true"] [data-loader-idle] { display: none; }
2534
+ /* The words a busy button says instead ("Saving…"): shown only while busy. */
2535
+ [data-loader-busy] { display: none; }
2536
+ :where(form, button)[aria-busy="true"] [data-loader-busy] { display: inline; margin-inline-start: 0.5rem; }
2397
2537
  .studio-loader[data-loader-pending] .studio-loader-halo { display: none; }
2398
2538
  .studio-loader[data-loader-pending] .studio-loader-icon { width: 100%; height: 100%; animation-duration: 1.4s; }
2399
2539
 
@@ -2440,6 +2580,9 @@ body.modal-open { overflow: visible; }
2440
2580
  .studio-loader-orb,
2441
2581
  .studio-loader-art { display: none; }
2442
2582
  .studio-loader-fill { --studio-loader-level: 0.45; }
2583
+ /* A still band would read as a percent; a still bar with no percent is a
2584
+ faint full fill instead. */
2585
+ .studio-loader[data-loader-style="bar"] .progress-meter--indeterminate .progress-meter-fill { width: 100%; opacity: 0.35; }
2443
2586
  }
2444
2587
 
2445
2588
  @keyframes studio-loader-breathe {
@@ -2486,3 +2629,126 @@ body.modal-open { overflow: visible; }
2486
2629
  26%, 100% { transform: scale(0.8); opacity: 0; }
2487
2630
  }
2488
2631
  }
2632
+
2633
+ /* =============================================================================
2634
+ LOADER FAMILY — skeleton, infinite scroll and pull to refresh (docs/LOADER.md).
2635
+ The branded loader above draws a wait; these three hold a page's shape while
2636
+ it waits. Markup: app/views/studio/loader/_skeleton, _infinite_scroll and
2637
+ _pull_to_refresh, through Studio::LoaderHelper. Behaviour:
2638
+ studio/infinite_scroll and studio/pull_to_refresh (lazy controllers).
2639
+
2640
+ Tune: --studio-skeleton-bone a bone's fill (default text at 9%)
2641
+ --studio-skeleton-shine the shimmer's highlight (default text at 7%)
2642
+ --studio-skeleton-width one line's width (the helper varies them)
2643
+ --studio-skeleton-speed one shimmer pass (default 1.6s)
2644
+
2645
+ The bones are tints of --color-text over whatever sits behind them, so one
2646
+ rule reads on a dark, light or glass surface. Under prefers-reduced-motion a
2647
+ skeleton is still and the pull indicator does not spin.
2648
+ ========================================================================== */
2649
+ @layer components {
2650
+ .studio-skeleton {
2651
+ --studio-skeleton-bone: color-mix(in srgb, var(--color-text) 9%, transparent);
2652
+ --studio-skeleton-shine: color-mix(in srgb, var(--color-text) 7%, transparent);
2653
+ --studio-skeleton-speed: 1.6s;
2654
+ position: relative;
2655
+ display: grid;
2656
+ gap: 1rem;
2657
+ }
2658
+
2659
+ .studio-skeleton-item { display: grid; gap: 0.6rem; align-content: start; }
2660
+ .studio-skeleton-item[data-skeleton-kind="avatar"] {
2661
+ grid-template-columns: auto 1fr;
2662
+ align-items: center;
2663
+ column-gap: 0.85rem;
2664
+ }
2665
+ .studio-skeleton-item[data-skeleton-kind="avatar"] > .studio-skeleton-lines { display: grid; gap: 0.5rem; }
2666
+ .studio-skeleton-lines { display: grid; gap: 0.55rem; }
2667
+
2668
+ .studio-skeleton-bone {
2669
+ display: block;
2670
+ border-radius: 0.375rem;
2671
+ background:
2672
+ linear-gradient(90deg, transparent 0%, var(--studio-skeleton-shine) 50%, transparent 100%) 0 0 / 200% 100% no-repeat,
2673
+ var(--studio-skeleton-bone);
2674
+ background-position: 150% 0, 0 0;
2675
+ animation: studio-skeleton-shimmer var(--studio-skeleton-speed) ease-in-out infinite;
2676
+ }
2677
+
2678
+ .studio-skeleton-bone[data-bone="line"] {
2679
+ height: 0.75rem;
2680
+ width: var(--studio-skeleton-width, 100%);
2681
+ }
2682
+ .studio-skeleton-bone[data-bone="title"] { height: 1.1rem; width: var(--studio-skeleton-width, 55%); }
2683
+ .studio-skeleton-bone[data-bone="avatar"] { width: 2.75rem; height: 2.75rem; border-radius: 9999px; }
2684
+ .studio-skeleton-bone[data-bone="media"] { width: 100%; aspect-ratio: 16 / 9; border-radius: 0.75rem; }
2685
+
2686
+ .studio-skeleton-item[data-skeleton-kind="card"] {
2687
+ padding: 0.85rem;
2688
+ border-radius: 1rem;
2689
+ border: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
2690
+ }
2691
+
2692
+ @keyframes studio-skeleton-shimmer {
2693
+ from { background-position: 150% 0, 0 0; }
2694
+ to { background-position: -50% 0, 0 0; }
2695
+ }
2696
+
2697
+ /* ---- Infinite scroll ----------------------------------------------------
2698
+ Each page arrives inside a <turbo-frame> nested in the one before. The
2699
+ frames take no box (display: contents), so the items flow into the
2700
+ list's own grid or stack as if they were one list. */
2701
+ .studio-infinite-scroll turbo-frame { display: contents; }
2702
+ .studio-infinite-scroll-more,
2703
+ .studio-infinite-scroll-end {
2704
+ grid-column: 1 / -1;
2705
+ display: flex;
2706
+ justify-content: center;
2707
+ padding-block: 1rem;
2708
+ }
2709
+ .studio-infinite-scroll-more .studio-loader { margin-inline-start: 0.5rem; }
2710
+ .studio-infinite-scroll-end { margin: 0; font-size: 0.875rem; color: var(--color-text-secondary); }
2711
+ /* Turbo marks the frame aria-busy while it fetches: the button keeps its
2712
+ place and its words, and shows a spinner beside them (a [data-loader-idle]
2713
+ child would hide while busy; "Load more" has none). */
2714
+ .studio-infinite-scroll-next[aria-busy="true"] .studio-loader[data-loader-pending] { display: inline-grid; }
2715
+ .studio-infinite-scroll-next[aria-busy="true"] [data-loader-idle] { display: none; }
2716
+
2717
+ /* ---- Pull to refresh ----------------------------------------------------
2718
+ Phone widths and touch only: the controller arms itself there and nowhere
2719
+ else, so on a desktop the indicator never shows. --studio-pull is how far
2720
+ the finger has pulled, 0..1 of the threshold. */
2721
+ .studio-pull-refresh { position: relative; }
2722
+ .studio-pull-refresh-indicator {
2723
+ --studio-pull: 0;
2724
+ position: absolute;
2725
+ top: 0;
2726
+ left: 50%;
2727
+ z-index: var(--z-raised, 10);
2728
+ display: grid;
2729
+ place-items: center;
2730
+ width: 2.5rem;
2731
+ height: 2.5rem;
2732
+ border-radius: 9999px;
2733
+ background: var(--color-surface);
2734
+ box-shadow: 0 2px 10px color-mix(in srgb, var(--color-text) 18%, transparent);
2735
+ color: var(--color-primary-ink, var(--color-primary));
2736
+ opacity: min(1, calc(var(--studio-pull) * 1.4));
2737
+ transform: translate(-50%, calc(var(--studio-pull) * 4.5rem - 3rem)) rotate(calc(var(--studio-pull) * 270deg));
2738
+ pointer-events: none;
2739
+ }
2740
+ .studio-pull-refresh-indicator[hidden] { display: none; }
2741
+ .studio-pull-refresh[data-pull-state="settling"] .studio-pull-refresh-indicator { transition: transform 0.2s ease, opacity 0.2s ease; }
2742
+ .studio-pull-refresh[data-pull-state="refreshing"] .studio-pull-refresh-indicator {
2743
+ --studio-pull: 1;
2744
+ transform: translate(-50%, 1.5rem);
2745
+ }
2746
+ .studio-pull-refresh-indicator .studio-pull-refresh-spinner { animation-play-state: paused; }
2747
+ .studio-pull-refresh[data-pull-state="refreshing"] .studio-pull-refresh-indicator .studio-pull-refresh-spinner { animation-play-state: running; }
2748
+
2749
+ @media (prefers-reduced-motion: reduce) {
2750
+ .studio-skeleton-bone { animation: none; background-position: 150% 0, 0 0; }
2751
+ .studio-pull-refresh-indicator,
2752
+ .studio-pull-refresh-indicator * { transition: none !important; animation: none !important; }
2753
+ }
2754
+ }