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
@@ -308,6 +308,13 @@ body.modal-open .studio-app-banner {
308
308
  font-size: calc(var(--type-base, 16px) * 1.5);
309
309
  font-weight: var(--type-display-weight, 800);
310
310
  line-height: 1.25;
311
+
312
+ /* From 40rem, the h2 of the type scale (type-h2), Alex McRitchie's "H2 header"
313
+ (2026-11-05). A phone keeps 1.5x, so a long title leaves the tabs room. */
314
+ @media (width >= 40rem) {
315
+ font-size: 30px;
316
+ line-height: 1.2;
317
+ }
311
318
  min-width: 0;
312
319
  overflow-wrap: anywhere;
313
320
  display: -webkit-box;
@@ -340,6 +347,11 @@ body.modal-open .studio-app-banner {
340
347
  min-width: 0;
341
348
  overflow-x: auto;
342
349
  scrollbar-width: none;
350
+ /* A sideways scroller clips both axes, so the pills get room inside it: their contact
351
+ shadow (3px below), the lit edge (1px) and a focus ring (2px). The negative margin
352
+ gives the room back, so the row's box does not move. */
353
+ padding: 0.25rem;
354
+ margin: -0.25rem;
343
355
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
344
356
  mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
345
357
 
@@ -365,9 +377,652 @@ body.modal-open .studio-app-banner {
365
377
 
366
378
  @utility page-header-actions {
367
379
  flex-shrink: 0;
380
+ /* Never wider than the row: a group of controls wraps inside it on a phone, and the
381
+ page does not scroll sideways. */
382
+ max-width: 100%;
383
+ margin-left: auto;
384
+
385
+ /* After the tabs, the tabs' own auto margin already pushes both right. */
386
+ .page-header-tabs + & {
387
+ margin-left: 0;
388
+ }
389
+ }
390
+
391
+ /* THE TRAIL: the page's ancestors above the title (docs/PAGE_HEADER.md, "Crumbs"). It takes
392
+ the row's whole first line, so the title, the tabs and the actions share the next one.
393
+ Ancestors are muted links, the page itself is bold, and a chevron sits between. Below
394
+ 40rem the trail gives way to one link to the parent. The negative margin brings the
395
+ title up to the trail: the row's gap is for the title and the tabs. */
396
+ @utility page-header-trail {
397
+ flex: 1 0 100%;
398
+ min-width: 0;
399
+ margin-bottom: -0.5rem;
400
+ font-size: calc(var(--type-base, 16px) * 0.8125);
401
+ line-height: 1.25;
402
+ }
403
+
404
+ @utility page-header-crumbs {
405
+ min-width: 0;
406
+
407
+ & ol {
408
+ display: flex;
409
+ flex-wrap: wrap;
410
+ align-items: center;
411
+ column-gap: 0.5rem;
412
+ row-gap: 0.125rem;
413
+ margin: 0;
414
+ padding: 0;
415
+ list-style: none;
416
+ }
417
+
418
+ & li {
419
+ display: inline-flex;
420
+ align-items: center;
421
+ gap: 0.5rem;
422
+ min-width: 0;
423
+ }
424
+
425
+ /* The chevron is CSS, with empty alt text, so a reader hears the names alone. */
426
+ & li + li::before {
427
+ content: "\203A";
428
+ content: "\203A" / "";
429
+ @apply text-muted;
430
+ opacity: 0.6;
431
+ }
432
+
433
+ @media (width < 40rem) {
434
+ display: none;
435
+ }
436
+ }
437
+
438
+ @utility page-header-crumb {
439
+ @apply text-muted;
440
+ text-decoration: none;
441
+ transition: color 150ms ease;
442
+
443
+ &:hover,
444
+ &:focus-visible {
445
+ @apply text-heading;
446
+ }
447
+ }
448
+
449
+ @utility page-header-crumb-current {
450
+ @apply text-heading;
451
+ font-weight: 600;
452
+ overflow-wrap: anywhere;
453
+ }
454
+
455
+ @utility page-header-parent {
456
+ display: none;
457
+ @apply text-muted;
458
+ font-size: calc(var(--type-base, 16px) * 0.875);
459
+ text-decoration: none;
460
+
461
+ &:hover,
462
+ &:focus-visible {
463
+ @apply text-heading;
464
+ }
465
+
466
+ @media (width < 40rem) {
467
+ display: inline-flex;
468
+ gap: 0.25rem;
469
+ align-items: baseline;
470
+ }
471
+ }
472
+
473
+ /* THE LINKS LINE: the page's other links, on a line of their own at the foot of the row, so
474
+ they stick with it. Muted, small, one line; on a phone it scrolls sideways inside the row. */
475
+ @utility page-header-links {
476
+ flex: 1 0 100%;
477
+ display: flex;
478
+ align-items: center;
479
+ gap: 1rem;
480
+ min-width: 0;
481
+ margin-top: -0.25rem;
482
+ overflow-x: auto;
483
+ scrollbar-width: none;
484
+ white-space: nowrap;
485
+ font-size: calc(var(--type-base, 16px) * 0.8125);
486
+ }
487
+
488
+ @utility page-header-link {
489
+ @apply text-muted;
490
+ text-decoration: none;
491
+ transition: color 150ms ease;
492
+
493
+ &:hover,
494
+ &:focus-visible,
495
+ &[aria-current] {
496
+ @apply text-heading;
497
+ }
498
+
499
+ /* More than colour marks the current link (WCAG 1.4.1). */
500
+ &[aria-current] {
501
+ font-weight: 600;
502
+ }
503
+ }
504
+
505
+ /* The tab's label sits above the gliding pill. */
506
+ @utility page-header-tab-label {
507
+ position: relative;
508
+ z-index: 2;
509
+ }
510
+
511
+ /* The pill is hidden until the browser can anchor it (below). */
512
+ @utility page-header-glide {
513
+ display: none;
514
+ }
515
+
516
+ /* -- The gliding pill -------------------------------------------------------
517
+ Alex McRitchie, 2026-11-05: the current tab's pill glides to the next current tab, as
518
+ one highlight, instead of each tab switching its own fill. One pill per row
519
+ (.page-header-glide, the lit primary button's own classes, so it keeps that look in
520
+ light, dark and glass) is anchored with CSS anchor positioning to whichever tab carries
521
+ aria-current. aria-current stays the one source of truth: the scroll spy and a page's
522
+ own markup set it, and the pill follows with no script measuring anything. Its insets
523
+ transition, so a change of anchor slides it. Reduced motion jumps.
524
+
525
+ The tab with aria-current gives up its own fill so the pill shows through, and the
526
+ labels sit above the pill. !important on the fill: the glass lit button's selectors are
527
+ heavier than any a row should repeat.
528
+
529
+ Where anchor positioning or :has is missing, nothing in this block applies and each tab
530
+ keeps its own fill, as before. */
531
+ @supports (anchor-name: --a) and (position-anchor: --a) and selector(:has(a)) {
532
+ .page-header-track {
533
+ position: relative;
534
+ /* Each row's pill anchors to its own current tab, not the last one on the page. */
535
+ anchor-scope: --page-header-current;
536
+ }
537
+
538
+ .page-header-track > [aria-current] {
539
+ anchor-name: --page-header-current;
540
+ }
541
+
542
+ .page-header-track:has(> .page-header-tab[aria-current]) > .page-header-glide {
543
+ display: block;
544
+ position: absolute;
545
+ position-anchor: --page-header-current;
546
+ top: anchor(top);
547
+ right: anchor(right);
548
+ bottom: anchor(bottom);
549
+ left: anchor(left);
550
+ z-index: 1;
551
+ margin: 0;
552
+ padding: 0;
553
+ pointer-events: none;
554
+ transition: top 320ms cubic-bezier(0.2, 0.8, 0.2, 1), right 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
555
+ bottom 320ms cubic-bezier(0.2, 0.8, 0.2, 1), left 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
556
+ }
557
+
558
+ .page-header-track:has(> .page-header-glide) > .page-header-tab[aria-current] {
559
+ background-color: transparent !important;
560
+ background-image: none !important;
561
+ border-color: transparent !important;
562
+ box-shadow: none !important;
563
+ }
564
+
565
+ @media (prefers-reduced-motion: reduce) {
566
+ .page-header-track > .page-header-glide {
567
+ transition: none !important;
568
+ }
569
+ }
570
+ }
571
+
572
+ /* -- The clear border glow (.studio-border-glow--clear) ------------------------
573
+ Alex McRitchie, 2026-11-05: "a clear background and the glowing circumference". A
574
+ modifier on .studio-border-glow (engine-motion.css), named as .sheen--loop is: the
575
+ button has no fill, so the page shows through, and only the moving rainbow ring and
576
+ its soft halo draw. The label is the heading ink, which clears 4.5:1 on every surface
577
+ (e2e/border_glow_clear.spec.js). Pair it with .btn alone, not a filled variant.
578
+
579
+ <button class="btn studio-border-glow studio-border-glow--clear">Save</button> */
580
+ .btn.studio-border-glow--clear {
581
+ @apply text-heading;
582
+ background-color: transparent;
583
+ background-image: none;
584
+ }
585
+
586
+ .btn.studio-border-glow--clear:not(:disabled, [aria-disabled="true"]):hover {
587
+ --studio-border-glow-halo-opacity: 0.4;
588
+ }
589
+
590
+ /* Reduced motion: the ring and the halo stay, still. */
591
+ @media (prefers-reduced-motion: reduce) {
592
+ .studio-border-glow::before,
593
+ .studio-border-glow::after {
594
+ animation: none;
595
+ }
596
+ }
597
+
598
+ /* -- The carousel (studio/_carousel) -----------------------------------------
599
+ One sideways row of slides on a native scroll-snap track: a swipe, a trackpad and
600
+ a focused link move it with no script. The carousel controller adds the edge fade,
601
+ the arrows, the dots and the keys (studio/controllers/carousel_controller).
602
+
603
+ WIDTHS COME FROM THE REGION, NOT THE WINDOW. The region is an inline-size container,
604
+ so a carousel in a narrow column lays out as a phone does. A phone row shows one
605
+ slide, a medium row up to two, a wide row --carousel-per-view (the partial's
606
+ per_view). Every row adds --carousel-peek of the next slide, so a visitor sees
607
+ there is more. n slides and their n gaps plus the peek fill the row:
608
+ n * w + n * gap + peek * w = 100 percent.
609
+
610
+ BEFORE THE CONTROLLER CONNECTS, or when it failed to load, the controls are hidden
611
+ (they would do nothing) and the track keeps a thin scrollbar, so the row still
612
+ scrolls with a mouse. */
613
+ @utility carousel {
614
+ --carousel-gap: 1rem;
615
+ --carousel-peek: 0.2;
616
+ --carousel-fade: 2.5rem;
617
+ position: relative;
618
+ min-width: 0;
619
+ container-type: inline-size;
620
+
621
+ /* The track's focus ring is drawn here: the track's edge mask clips anything
622
+ outside its own box, an outline included. */
623
+ &:has(> .carousel-track:focus-visible) {
624
+ outline: 2px solid color-mix(in srgb, var(--color-cta) 70%, transparent);
625
+ outline-offset: 2px;
626
+ border-radius: 0.75rem;
627
+ }
628
+ }
629
+
630
+ @utility carousel-track {
631
+ --carousel-n: min(var(--carousel-per-view, 3), 1);
632
+ --carousel-fade-start: 0px;
633
+ --carousel-fade-end: 0px;
634
+ position: relative;
635
+ display: flex;
636
+ gap: var(--carousel-gap);
637
+ overflow-x: auto;
638
+ overscroll-behavior-x: contain;
639
+ scroll-snap-type: x mandatory;
640
+ scroll-behavior: smooth;
641
+ scrollbar-width: none;
642
+ /* Room for a slide's hover shadow and focus ring: the scroll box clips both ways. */
643
+ padding-block: 0.25rem 0.5rem;
644
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--carousel-fade-start), #000 calc(100% - var(--carousel-fade-end)), transparent 100%);
645
+ mask-image: linear-gradient(to right, transparent 0, #000 var(--carousel-fade-start), #000 calc(100% - var(--carousel-fade-end)), transparent 100%);
646
+
647
+ &::-webkit-scrollbar {
648
+ display: none;
649
+ }
650
+
651
+ &[data-fade-start] {
652
+ --carousel-fade-start: var(--carousel-fade);
653
+ }
654
+
655
+ &[data-fade-end] {
656
+ --carousel-fade-end: var(--carousel-fade);
657
+ }
658
+
659
+ &:focus-visible {
660
+ outline: none;
661
+ }
662
+
663
+ .carousel:not([data-carousel-ready]) & {
664
+ scrollbar-width: thin;
665
+ }
666
+
667
+ .carousel:not([data-carousel-ready]) &::-webkit-scrollbar {
668
+ display: initial;
669
+ }
670
+
671
+ @container (width >= 36rem) {
672
+ --carousel-n: min(var(--carousel-per-view, 3), 2);
673
+ }
674
+
675
+ @container (width >= 56rem) {
676
+ --carousel-n: var(--carousel-per-view, 3);
677
+ }
678
+
679
+ @media (prefers-reduced-motion: reduce) {
680
+ scroll-behavior: auto;
681
+ }
682
+ }
683
+
684
+ @utility carousel-slide {
685
+ flex: 0 0 calc((100% - var(--carousel-n) * var(--carousel-gap)) / (var(--carousel-n) + var(--carousel-peek)));
686
+ min-width: 0;
687
+ scroll-snap-align: start;
688
+ }
689
+
690
+ @utility carousel-controls {
691
+ display: flex;
692
+ align-items: center;
693
+ justify-content: space-between;
694
+ gap: 0.75rem;
695
+ margin-top: 0.75rem;
696
+
697
+ /* Until the controller connects the row is hidden but keeps its room, so nothing
698
+ below moves when the arrows arrive (a hash landing, CLS). A row that fits, or a
699
+ controller that failed to load, gives the room back. */
700
+ .carousel:not([data-carousel-ready]) & {
701
+ visibility: hidden;
702
+ }
703
+
704
+ .carousel[data-carousel-static] &,
705
+ .carousel[data-studio-controller-failed] & {
706
+ display: none;
707
+ }
708
+ }
709
+
710
+ @utility carousel-dots {
711
+ display: flex;
712
+ flex-wrap: wrap;
713
+ align-items: center;
714
+ min-width: 0;
715
+ }
716
+
717
+ /* A 1.5rem tap target around a small pill. The open stop's pill is wider and wears
718
+ the primary, so the dots read as a progress line. */
719
+ @utility carousel-dot {
720
+ display: inline-flex;
721
+ align-items: center;
722
+ justify-content: center;
723
+ height: 1.5rem;
724
+ padding-inline: 0.1875rem;
725
+ border-radius: 9999px;
726
+ cursor: pointer;
727
+
728
+ &::before {
729
+ content: "";
730
+ display: block;
731
+ width: 0.5rem;
732
+ height: 0.5rem;
733
+ border-radius: 9999px;
734
+ background-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent);
735
+ transition: width 200ms ease, background-color 200ms ease;
736
+ }
737
+
738
+ &:hover::before {
739
+ background-color: var(--color-text-muted);
740
+ }
741
+
742
+ &[aria-current="true"]::before {
743
+ width: 1.5rem;
744
+ background-color: var(--color-primary);
745
+ }
746
+
747
+ &[hidden] {
748
+ display: none;
749
+ }
750
+
751
+ &:focus-visible {
752
+ outline: 2px solid color-mix(in srgb, var(--color-cta) 70%, transparent);
753
+ outline-offset: 0;
754
+ }
755
+
756
+ @media (prefers-reduced-motion: reduce) {
757
+ &::before {
758
+ transition: none;
759
+ }
760
+ }
761
+ }
762
+
763
+ @utility carousel-arrows {
764
+ display: flex;
765
+ flex-shrink: 0;
766
+ gap: 0.5rem;
368
767
  margin-left: auto;
369
768
  }
370
769
 
770
+ /* Round arrows on the card fill, so under the glass surfaces they are glass too. */
771
+ @utility carousel-button {
772
+ @apply border border-subtle text-heading;
773
+ display: inline-flex;
774
+ align-items: center;
775
+ justify-content: center;
776
+ width: 2.5rem;
777
+ height: 2.5rem;
778
+ border-radius: 9999px;
779
+ background-color: var(--surface-card-fill, var(--color-surface));
780
+ -webkit-backdrop-filter: var(--surface-glass-filter, none);
781
+ backdrop-filter: var(--surface-glass-filter, none);
782
+ box-shadow: var(--surface-glass-highlight, 0 0 #0000), var(--surface-glass-shadow, 0 0 #0000);
783
+ cursor: pointer;
784
+ transition: border-color 150ms ease, opacity 150ms ease, transform 150ms ease;
785
+
786
+ & svg {
787
+ width: 1.125rem;
788
+ height: 1.125rem;
789
+ }
790
+
791
+ &:hover:not([aria-disabled="true"]) {
792
+ @apply border-primary;
793
+ }
794
+
795
+ &:active:not([aria-disabled="true"]) {
796
+ transform: scale(0.94);
797
+ }
798
+
799
+ &[aria-disabled="true"] {
800
+ opacity: 0.4;
801
+ cursor: default;
802
+ }
803
+
804
+ &:focus-visible {
805
+ outline: 2px solid color-mix(in srgb, var(--color-cta) 70%, transparent);
806
+ outline-offset: 2px;
807
+ }
808
+
809
+ @media (prefers-reduced-motion: reduce) {
810
+ transition: none;
811
+
812
+ &:active:not([aria-disabled="true"]) {
813
+ transform: none;
814
+ }
815
+ }
816
+ }
817
+
818
+ /* -- The spotlight carousel (studio/_carousel, style: :spotlight) --------------
819
+ The same region, track, arrows, dots and keys, but each slide snaps to the centre.
820
+ The centred slide is full size and full colour, with its caption under it; its
821
+ neighbours are smaller, faded and grey, and the next ones peek at the edges.
822
+
823
+ WIDTHS. A slide is --carousel-spot-w wide, in units of the region (cqi, since the
824
+ region is the inline-size container). n slides and a 0.6 peek fill the row:
825
+ n * w + (n - 1) * gap + 0.6 * w = 100cqi. The track pads each end by half the
826
+ rest, so the first and the last slide can centre too.
827
+
828
+ THE EFFECT FOLLOWS THE SCROLL where the browser has scroll-driven animations:
829
+ each slide is its own inline view timeline, and its body and caption run one
830
+ animation across the stretch from one neighbour's place to the other's (a step,
831
+ a slide and a gap, either side of the centre), full at the middle. Outside that
832
+ stretch the fill holds the neighbour look.
833
+
834
+ ELSEWHERE, the controller marks the centred slide (data-current) and the body
835
+ transitions to it. Until the controller connects every slide stays plain.
836
+
837
+ UNDER REDUCED MOTION nothing scales, greys or animates: the neighbours of the
838
+ marked slide are only dimmed. */
839
+ @keyframes carousel-spotlight {
840
+ 0%, 100% {
841
+ scale: var(--carousel-spot-scale);
842
+ opacity: var(--carousel-spot-opacity);
843
+ filter: grayscale(1);
844
+ }
845
+ 50% {
846
+ scale: 1;
847
+ opacity: 1;
848
+ filter: grayscale(0);
849
+ }
850
+ }
851
+
852
+ @keyframes carousel-spotlight-caption {
853
+ 0%, 30%, 70%, 100% {
854
+ opacity: 0;
855
+ translate: 0 0.25rem;
856
+ }
857
+ 50% {
858
+ opacity: 1;
859
+ translate: 0 0;
860
+ }
861
+ }
862
+
863
+ @utility carousel-spotlight {
864
+ --carousel-spot-scale: 0.75;
865
+ --carousel-spot-opacity: 0.45;
866
+
867
+ /* On the track, not the region: the track's container queries set --carousel-n,
868
+ and a container query cannot read the element it sits on. */
869
+ & > .carousel-track {
870
+ /* A smaller gap: the neighbours' scale already opens space around the centre. */
871
+ --carousel-gap: 0.5rem;
872
+ --carousel-spot-w: calc((100cqi - (var(--carousel-n) - 1) * var(--carousel-gap)) / (var(--carousel-n) + 0.6));
873
+ --carousel-spot-step: calc(var(--carousel-spot-w) + var(--carousel-gap));
874
+ padding-inline: calc((100cqi - var(--carousel-spot-w)) / 2);
875
+ /* The neighbours are already dimmed; an edge fade over them reads as a smear. */
876
+ -webkit-mask-image: none;
877
+ mask-image: none;
878
+ }
879
+
880
+ & > .carousel-track > .carousel-slide {
881
+ flex: 0 0 var(--carousel-spot-w);
882
+ scroll-snap-align: center;
883
+ display: flex;
884
+ flex-direction: column;
885
+ view-timeline: --carousel-slide inline;
886
+ }
887
+
888
+ & .carousel-spotlight-body {
889
+ flex: 1 1 auto;
890
+ min-height: 0;
891
+ transform-origin: 50% 50%;
892
+ }
893
+
894
+ & .carousel-caption {
895
+ margin-top: 0.75rem;
896
+ text-align: center;
897
+ @apply text-sm text-body;
898
+ }
899
+
900
+ /* The fallback: the controller's mark, with a transition. */
901
+ &[data-carousel-ready] .carousel-slide .carousel-spotlight-body {
902
+ transition: scale 300ms ease, opacity 300ms ease, filter 300ms ease;
903
+ }
904
+
905
+ &[data-carousel-ready] .carousel-slide:not([data-current]) .carousel-spotlight-body {
906
+ scale: var(--carousel-spot-scale);
907
+ opacity: var(--carousel-spot-opacity);
908
+ filter: grayscale(1);
909
+ }
910
+
911
+ &[data-carousel-ready] .carousel-caption {
912
+ transition: opacity 300ms ease;
913
+ }
914
+
915
+ &[data-carousel-ready] .carousel-slide:not([data-current]) .carousel-caption {
916
+ opacity: 0;
917
+ }
918
+
919
+ /* A neighbour holding focus drops the neighbour look on every path, so its focus
920
+ ring shows at full strength (WCAG 2.4.7). It outranks the animation and the mark. */
921
+ & .carousel-slide:focus-within .carousel-spotlight-body,
922
+ &[data-carousel-ready] .carousel-slide:focus-within .carousel-spotlight-body {
923
+ animation: none;
924
+ scale: 1;
925
+ opacity: 1;
926
+ filter: none;
927
+ }
928
+
929
+ /* The scroll-driven path. It needs no controller, so it runs from the first paint. */
930
+ @supports (animation-timeline: view()) and (animation-range: cover 0% cover 100%) {
931
+ & .carousel-spotlight-body,
932
+ & .carousel-caption {
933
+ transition: none;
934
+ animation: carousel-spotlight linear both;
935
+ animation-timeline: --carousel-slide;
936
+ animation-range: cover calc(50% - var(--carousel-spot-step)) cover calc(50% + var(--carousel-spot-step));
937
+ }
938
+
939
+ & .carousel-caption {
940
+ animation-name: carousel-spotlight-caption;
941
+ }
942
+ }
943
+
944
+ @media (prefers-reduced-motion: reduce) {
945
+ & .carousel-spotlight-body,
946
+ & .carousel-caption {
947
+ animation: none !important;
948
+ transition: none !important;
949
+ }
950
+
951
+ &[data-carousel-ready] .carousel-slide:not([data-current]) .carousel-spotlight-body {
952
+ scale: none;
953
+ filter: none;
954
+ }
955
+ }
956
+ }
957
+
958
+ /* -- The section header (studio/_section_header, docs/PAGE_HEADER.md) --------
959
+ A section's title that sticks under the rest of the stack, inside its own <section>,
960
+ so the next section's header takes its place with no scroll script. At rest it reads as
961
+ a normal title. Stuck it takes the page header's bar, so content does not show through
962
+ it. studio/pinned_stack says which one is stuck. Its box never changes between the two,
963
+ so the change moves nothing. One line, always: a long title ends in an ellipsis on the
964
+ bar, the height is known, and the whole text stays in the heading.
965
+
966
+ IT STICKS ONLY ONCE THE PUBLISHER RUNS (html[data-pin-live]). With no publisher no bar
967
+ ever comes, and a clear title would lie over the page; in the flow it is a plain title.
968
+
969
+ A SHORT SCREEN KEEPS ITS ROOM. Under 36rem tall, a small phone or one on its side, the
970
+ header stays in the flow: the navbar and the page row already take a quarter of the height.
971
+
972
+ z-index is one tier under the page row, set by the partial, so a header being pushed out
973
+ passes under the row and over the content. --section-header-h is what a target inside a
974
+ section adds to its scroll margin, so it lands below the bar. */
975
+ :root {
976
+ --section-header-h: 2.25rem;
977
+ }
978
+
979
+ @utility section-header {
980
+ @apply text-heading;
981
+ position: relative;
982
+ min-width: 0;
983
+ min-height: var(--section-header-h);
984
+ padding-block: 0.25rem;
985
+ margin-block-end: 0.25rem;
986
+ border-block: 1px solid transparent;
987
+ background-color: transparent;
988
+ font-size: calc(var(--type-base, 16px) * 1.25);
989
+ font-weight: 700;
990
+ line-height: 1.3;
991
+ overflow: hidden;
992
+ white-space: nowrap;
993
+ text-overflow: ellipsis;
994
+ transition: background-color 150ms ease, border-color 150ms ease;
995
+
996
+ html[data-pin-live] & {
997
+ @media (height >= 36rem) {
998
+ position: sticky;
999
+ }
1000
+ }
1001
+
1002
+ &[data-stuck] {
1003
+ @apply bg-surface border-subtle;
1004
+ border-top-color: transparent;
1005
+ }
1006
+ }
1007
+
1008
+ /* Where a jump lands. A jump goes deep, so what counts is the stack once PINNED, with the
1009
+ navbar compact and the page row stuck: heights, not --pin-section-top, whose value at the
1010
+ top of the page holds the row's rest place and whatever scrolls away above it. So this one
1011
+ composes, which a thing placed on screen must not: a margin is read once, at the jump. Less
1012
+ two pixels, so the header has left its place on a fractional height and its title shows on
1013
+ the bar.
1014
+
1015
+ A JUMP FROM THE TOP OF THE PAGE reads the navbar still tall, and lands by the navbar's
1016
+ collapse below the line. Landing short is safe, the title shows. Landing past is safe too,
1017
+ since the bar holds the title. A target inside a section: a bar's height lower. */
1018
+ @utility section-target {
1019
+ scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) - 2px);
1020
+ }
1021
+
1022
+ @utility section-subtarget {
1023
+ scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) + var(--section-header-h));
1024
+ }
1025
+
371
1026
  /* -- Scrims: tints and shades ----------------------------------------------
372
1027
  A scrim is the wash laid over media (a photo, a video) so text reads on it.
373
1028
  It comes in two TONES, and the tone is a design decision, not a colour pick:
@@ -1514,14 +2169,36 @@ html[data-theme-flip]::view-transition-new(*) {
1514
2169
  }
1515
2170
  }
1516
2171
 
2172
+ /* THE SECTION HEADER UNDER GLASS, the same bar one layer down. While it is stuck the bar
2173
+ under the row owns the round corners and the shadow: the row goes square, or the page
2174
+ would show in the notches between the two. */
2175
+ html[data-surface-style="glass"] .section-header[data-stuck] {
2176
+ background-color: var(--color-page);
2177
+ border-top-color: transparent;
2178
+ border-bottom-color: var(--surface-glass-line);
2179
+ border-radius: 0 0 0.875rem 0.875rem;
2180
+ box-shadow: var(--surface-glass-shadow);
2181
+ }
2182
+ @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
2183
+ html[data-surface-style="glass"] .section-header[data-stuck] {
2184
+ background-color: color-mix(in srgb, var(--color-page) 62%, transparent);
2185
+ -webkit-backdrop-filter: blur(18px) saturate(140%);
2186
+ backdrop-filter: blur(18px) saturate(140%);
2187
+ }
2188
+ }
2189
+ html[data-surface-style="glass"][data-pin-section-stuck] .page-header[data-stuck] {
2190
+ border-radius: 0;
2191
+ box-shadow: var(--surface-glass-highlight);
2192
+ }
2193
+
1517
2194
  /* LIT FROM ABOVE, under the same opt-in: one light for buttons, badges and inputs, from the
1518
2195
  direction of the cards' top highlight. Four shadows: top light, bottom shade, contact, and
1519
2196
  a glow in the button's own color.
1520
2197
 
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.
2198
+ THE GRADIENT KEEPS ITS CHROMA. Both stops are built in oklch from the token, the same step
2199
+ lighter and darker at the token's own chroma; mixing toward black or grey is what mutes a
2200
+ button. The midpoint is the token, so white text holds 4.5:1 there on the semantic fills
2201
+ and about 3:1 on the default primary, which Alex McRitchie accepted to match the brand word.
1525
2202
 
1526
2203
  WHO GETS WHAT. Every full-size filled button: gradient, top, bottom, contact and a glow in
1527
2204
  its own color, on the same 0.15 lightness range. A .btn-sm has no glow, since a toolbar or
@@ -1569,16 +2246,17 @@ html[data-theme-flip]::view-transition-new(*) {
1569
2246
  --lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.14), 0 1px 1px rgb(0 0 0 / 0.35);
1570
2247
  --lit-input: inset 0 1px 2px rgb(0 0 0 / 0.4), inset 0 -1px 0 rgb(255 255 255 / 0.06);
1571
2248
  }
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
- }
2249
+ /* Stops: CENTERED ON THE FILL. Each stop moves lightness the same step up and down, with
2250
+ chroma and hue unchanged, so the gradient's midpoint (oklab interpolation) is the token
2251
+ itself. The primary centers on --color-cta, the brand violet a text-primary word paints,
2252
+ so the button and the word read as one purple. Alex McRitchie accepted white near 3:1 on
2253
+ the default primary for that; the semantic fills hold 4.5:1 at the midpoint. A top stop
2254
+ past sRGB clips only the top edge: the midpoint is interpolated before it is clipped. */
2255
+ html[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success) {
2256
+ --lit-top-stop: oklch(from var(--lit-fill) calc(l + 0.06) c h / var(--lit-fill-alpha, 1));
2257
+ --lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.06) c h / var(--lit-fill-alpha, 1));
2258
+ }
2259
+ html[data-surface-style="glass"] .btn-primary { --lit-fill: var(--color-cta); }
1582
2260
  html[data-surface-style="glass"] .btn-secondary { --lit-fill: var(--btn-secondary-bg, var(--color-success)); }
1583
2261
  html[data-surface-style="glass"] .btn-danger { --lit-fill: var(--color-danger-fill, var(--color-danger)); }
1584
2262
  html[data-surface-style="glass"] .btn-warning { --lit-fill: var(--color-warning-fill, var(--color-warning)); }
@@ -1598,16 +2276,16 @@ html[data-theme-flip]::view-transition-new(*) {
1598
2276
  var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
1599
2277
  }
1600
2278
  /* 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. */
2279
+ stop. The violet, centered on the brand colour, is bright enough at its low stop. */
1602
2280
  html.dark[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) { --lit-glow-stop: var(--lit-top-stop); }
1603
2281
  /* 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
2282
+ shows subtly, and white text holds its contrast at the midpoint. An outer shadow is not painted
1605
2283
  under its own box, so the glow never brightens the fill. On a light page the backdrop would
1606
2284
  lift the fill and cost contrast, so it stays solid there. Without a blur, or without relative
1607
2285
  oklch (the gradient would drop out and leave white text on nothing), it stays solid. */
1608
2286
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (color: oklch(from red l c h)) {
1609
2287
  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;
2288
+ --lit-fill-alpha: 0.85;
1611
2289
  background-color: transparent;
1612
2290
  -webkit-backdrop-filter: blur(6px);
1613
2291
  backdrop-filter: blur(6px);
@@ -1655,13 +2333,53 @@ html[data-theme-flip]::view-transition-new(*) {
1655
2333
  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
2334
  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
2335
 
2336
+ /* BUTTON TRICKS ON A LIT BUTTON (the lit rules above).
2337
+ A lit button already uses its box-shadow for its contact shadow and glow, and
2338
+ its ::after for its inset light. These rules sit in the utilities layer and the
2339
+ tricks in engine-motion.css sit in components, so the lit rules win, and each trick
2340
+ that shares one of them makes room here. Keep a trick on a real .btn lit.
2341
+
2342
+ --btn-lit-rest is the lit button's outer shadow at rest. The pulse's keyframes
2343
+ replace box-shadow for the whole animation, so they put it back themselves. */
2344
+ html[data-surface-style="glass"] .btn:not(:disabled, [aria-disabled="true"]) {
2345
+ --btn-lit-rest: var(--lit-contact);
2346
+ }
2347
+ html[data-surface-style="glass"] .btn:is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"]) {
2348
+ --btn-lit-rest: var(--lit-contact),
2349
+ var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
2350
+ }
2351
+
2352
+ /* The glows need ::after for their bloom, so the inset light moves to the host and
2353
+ the bloom gets its own box back. Without this the lit rule's inset: -1px closes
2354
+ the bloom's hole and it washes the whole face. .btn-outline has no lit ::after. */
2355
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]) {
2356
+ box-shadow: var(--lit-inset), var(--btn-lit-rest);
2357
+ }
2358
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]):active {
2359
+ box-shadow: var(--lit-inset-pressed);
2360
+ }
2361
+ html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow):not(.btn-outline, :disabled, [aria-disabled="true"])::after {
2362
+ inset: calc(-1 * (var(--edge-rotate-glow-thickness) + 4px + var(--edge-rotate-glow-bloom) * 2));
2363
+ box-shadow: none;
2364
+ }
2365
+ html[data-surface-style="glass"] .btn.studio-border-glow:not(.btn-outline, :disabled, [aria-disabled="true"])::after {
2366
+ inset: -46px;
2367
+ box-shadow: none;
2368
+ }
2369
+
2370
+ /* The sheen's band already stops at its own rounded box, and overflow: hidden would
2371
+ cut the lit ::after's outer pixel, the top highlight. */
2372
+ html[data-surface-style="glass"] .btn.sheen:not(:disabled, [aria-disabled="true"]) {
2373
+ overflow: visible;
2374
+ }
2375
+
1658
2376
  /* LESS TRANSPARENCY: every surface above goes solid and drops its blur. Last, to win on order;
1659
2377
  both spellings of the root, since html.dark outranks a bare html[...] on specificity. */
1660
2378
  @media (prefers-reduced-transparency: reduce) {
1661
2379
  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)),
2380
+ 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
2381
  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); }
2382
+ 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
2383
  }
1666
2384
 
1667
2385
  /* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest