studio-engine 0.130.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +43 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +587 -0
  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/loader_helper.rb +79 -2
  15. data/app/javascript/studio/agent_chat.js +294 -6
  16. data/app/javascript/studio/carousel.js +99 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  18. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  19. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  20. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  21. data/app/javascript/studio/infinite_scroll.js +142 -0
  22. data/app/javascript/studio/loader.js +6 -1
  23. data/app/javascript/studio/page_transitions.js +65 -8
  24. data/app/javascript/studio/pull_to_refresh.js +147 -0
  25. data/app/javascript/studio/stimulus.js +12 -1
  26. data/app/javascript/studio/talk_to_agent.js +116 -0
  27. data/app/views/components/_geo_signpost.html.erb +2 -2
  28. data/app/views/components/_link_sidebar.html.erb +2 -2
  29. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  30. data/app/views/components/_sidebar_panel.html.erb +1 -1
  31. data/app/views/error_logs/index.html.erb +2 -8
  32. data/app/views/studio/_carousel.html.erb +97 -0
  33. data/app/views/studio/_page_header.html.erb +52 -11
  34. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  35. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  36. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  37. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  38. data/app/views/studio/loader/_loader.html.erb +20 -3
  39. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  40. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  41. data/app/views/style/_loaders.html.erb +118 -0
  42. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  43. data/app/views/style/_modals.html.erb +12 -0
  44. data/app/views/style/_specimen.html.erb +17 -10
  45. data/app/views/style/_tricks.html.erb +64 -2
  46. data/app/views/style/index.html.erb +14 -8
  47. data/app/views/style/loaders_feed.html.erb +7 -0
  48. data/lib/studio/crumbs.rb +150 -0
  49. data/lib/studio/loader.rb +30 -2
  50. data/lib/studio/version.rb +1 -1
  51. data/lib/studio.rb +3 -0
  52. metadata +20 -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,584 @@ 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
+
371
958
  /* -- The section header (studio/_section_header, docs/PAGE_HEADER.md) --------
372
959
  A section's title that sticks under the rest of the stack, inside its own <section>,
373
960
  so the next section's header takes its place with no scroll script. At rest it reads as
@@ -125,6 +125,10 @@
125
125
  --text-color-success-ink: var(--color-success-ink);
126
126
  /* The primary role as chip text; text-primary stays the brand colour. */
127
127
  --text-color-primary-ink: var(--color-primary-ink);
128
+ /* The keyboard focus ring: the primary ink, so a ring clears 3:1 beside every
129
+ surface it lands on (WCAG 1.4.11). A translucent ring-primary/40 measured 1.3
130
+ to 2.0:1 (e2e/sidebar_focus_ring.spec.js). */
131
+ --ring-color-primary-ink: var(--color-primary-ink);
128
132
 
129
133
  /* The status-role FILLS, for backgrounds and borders ONLY. */
130
134
  --background-color-success: var(--color-success);
@@ -213,25 +213,56 @@ module Studio
213
213
  rescue ActiveRecord::RecordNotFound => e
214
214
  raise e
215
215
  rescue StandardError => e
216
- error_log = create_error_log(e)
217
- # `slug` is NOT in the engine's User contract (REQUIRED_USER_INSTANCE_METHODS
218
- # is admin? + display_name), and `target` is routinely a User. Unguarded,
219
- # the LOGGER raises NoMethodError and MASKS the original exception — the
220
- # exact inverse of what this method exists to do. The name is a convenience
221
- # column; its absence must never cost the record.
222
- if target
223
- error_log.target = target
224
- error_log.target_name = target.slug if target.respond_to?(:slug)
216
+ begin
217
+ error_log = create_error_log(e)
218
+ attach_error_context(error_log, :target, target)
219
+ attach_error_context(error_log, :parent, parent)
220
+ error_log.save!
221
+ rescue StandardError => log_error
222
+ log_error_write_failed(log_error, e)
225
223
  end
226
- if parent
227
- error_log.parent = parent
228
- error_log.parent_name = parent.slug if parent.respond_to?(:slug)
229
- end
230
- error_log.save!
224
+ # Set on a failed write too: the catch-all would only try the same write
225
+ # again, and its raise would replace the friendly reply with a raw 500.
231
226
  @_error_logged = true
232
227
  raise e
233
228
  end
234
229
 
230
+ # The row could not be written. The original error still reaches the caller,
231
+ # and the process log keeps both, so a rescue that redirects never leaves the
232
+ # failure with no trace at all.
233
+ def log_error_write_failed(log_error, original)
234
+ Rails.logger.error(
235
+ "[Studio::ErrorHandling] ErrorLog write failed (#{log_error.class}: #{log_error.message}) " \
236
+ "while logging #{original.class}: #{original.message}"
237
+ )
238
+ end
239
+
240
+ # Names `record` on the row as its target or parent.
241
+ #
242
+ # An UNSAVED record is named (its slug, else "unsaved <Class>"), never
243
+ # linked. ErrorLog `belongs_to` it, and a belongs_to autosaves a new record:
244
+ # the logger would run the failing save a second time, and that raise
245
+ # escaped before @_error_logged was set, so the catch-all wrote the same
246
+ # failure twice. It also created the account a failed signup was meant not
247
+ # to create.
248
+ #
249
+ # `slug` is NOT in the engine's User contract (REQUIRED_USER_INSTANCE_METHODS
250
+ # is admin? + display_name), and a target is routinely a User. Unguarded, the
251
+ # LOGGER raises NoMethodError and MASKS the original exception. The name is a
252
+ # convenience column; its absence must never cost the record.
253
+ def attach_error_context(error_log, role, record)
254
+ return unless record
255
+
256
+ name_column = :"#{role}_name"
257
+ slug = record.slug.presence if record.respond_to?(:slug)
258
+ if record.respond_to?(:new_record?) && record.new_record?
259
+ slug ||= "unsaved #{record.class.name}"
260
+ else
261
+ error_log.public_send(:"#{role}=", record)
262
+ end
263
+ error_log[name_column] = slug if slug && error_log.has_attribute?(name_column)
264
+ end
265
+
235
266
  # Layer 1: RecordNotFound — Rails' own 404, never an ErrorLog row.
236
267
  #
237
268
  # This handler exists for exactly ONE reason: to stop `handle_unexpected_error`
@@ -291,7 +322,14 @@ module Studio
291
322
  # Layer 1: Catch-all for unexpected errors — log + friendly response.
292
323
  # Skips logging if rescue_and_log already captured it.
293
324
  def handle_unexpected_error(exception)
294
- create_error_log(exception) unless @_error_logged
325
+ unless @_error_logged
326
+ begin
327
+ create_error_log(exception)
328
+ rescue StandardError => log_error
329
+ # Unguarded, this raise replaced the friendly reply with a raw 500.
330
+ log_error_write_failed(log_error, exception)
331
+ end
332
+ end
295
333
  raise exception if Rails.env.development? || Rails.env.test?
296
334
 
297
335
  respond_to do |format|