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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- 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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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|
|