studio-engine 0.128.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +5 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +148 -56
- data/app/assets/tailwind/studio_engine/engine.css +203 -19
- data/app/controllers/concerns/studio/error_handling.rb +17 -0
- data/app/helpers/studio/fizz_helper.rb +5 -0
- data/app/javascript/studio/agent_chat.js +74 -3
- data/app/javascript/studio/agent_chat_voice.js +17 -1
- data/app/javascript/studio/alpine_stores.js +80 -22
- data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
- data/app/javascript/studio/pinned_stack.js +112 -4
- data/app/views/registrations/new.html.erb +4 -3
- data/app/views/sessions/new.html.erb +4 -3
- data/app/views/studio/_fizz.html.erb +26 -0
- data/app/views/studio/_fizz_layer.html.erb +4 -1
- data/app/views/studio/_hold_button.html.erb +1 -2
- data/app/views/studio/_page_header.html.erb +3 -2
- data/app/views/studio/_section_header.html.erb +23 -0
- data/app/views/studio/agent_chat/_chat.html.erb +2 -1
- data/app/views/style/_components.html.erb +3 -7
- data/app/views/style/_glass.html.erb +1 -1
- data/app/views/style/_host.html.erb +3 -14
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +29 -224
- data/app/views/style/_scrims.html.erb +1 -8
- data/app/views/style/_site_footer.html.erb +5 -13
- data/app/views/style/_specimen.html.erb +4 -0
- data/app/views/style/_tasks.html.erb +17 -30
- data/app/views/style/_theme.html.erb +3 -9
- data/app/views/style/_tricks.html.erb +155 -216
- data/app/views/style/index.html.erb +7 -3
- data/lib/studio/version.rb +1 -1
- metadata +3 -1
|
@@ -368,6 +368,74 @@ body.modal-open .studio-app-banner {
|
|
|
368
368
|
margin-left: auto;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/* -- The section header (studio/_section_header, docs/PAGE_HEADER.md) --------
|
|
372
|
+
A section's title that sticks under the rest of the stack, inside its own <section>,
|
|
373
|
+
so the next section's header takes its place with no scroll script. At rest it reads as
|
|
374
|
+
a normal title. Stuck it takes the page header's bar, so content does not show through
|
|
375
|
+
it. studio/pinned_stack says which one is stuck. Its box never changes between the two,
|
|
376
|
+
so the change moves nothing. One line, always: a long title ends in an ellipsis on the
|
|
377
|
+
bar, the height is known, and the whole text stays in the heading.
|
|
378
|
+
|
|
379
|
+
IT STICKS ONLY ONCE THE PUBLISHER RUNS (html[data-pin-live]). With no publisher no bar
|
|
380
|
+
ever comes, and a clear title would lie over the page; in the flow it is a plain title.
|
|
381
|
+
|
|
382
|
+
A SHORT SCREEN KEEPS ITS ROOM. Under 36rem tall, a small phone or one on its side, the
|
|
383
|
+
header stays in the flow: the navbar and the page row already take a quarter of the height.
|
|
384
|
+
|
|
385
|
+
z-index is one tier under the page row, set by the partial, so a header being pushed out
|
|
386
|
+
passes under the row and over the content. --section-header-h is what a target inside a
|
|
387
|
+
section adds to its scroll margin, so it lands below the bar. */
|
|
388
|
+
:root {
|
|
389
|
+
--section-header-h: 2.25rem;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@utility section-header {
|
|
393
|
+
@apply text-heading;
|
|
394
|
+
position: relative;
|
|
395
|
+
min-width: 0;
|
|
396
|
+
min-height: var(--section-header-h);
|
|
397
|
+
padding-block: 0.25rem;
|
|
398
|
+
margin-block-end: 0.25rem;
|
|
399
|
+
border-block: 1px solid transparent;
|
|
400
|
+
background-color: transparent;
|
|
401
|
+
font-size: calc(var(--type-base, 16px) * 1.25);
|
|
402
|
+
font-weight: 700;
|
|
403
|
+
line-height: 1.3;
|
|
404
|
+
overflow: hidden;
|
|
405
|
+
white-space: nowrap;
|
|
406
|
+
text-overflow: ellipsis;
|
|
407
|
+
transition: background-color 150ms ease, border-color 150ms ease;
|
|
408
|
+
|
|
409
|
+
html[data-pin-live] & {
|
|
410
|
+
@media (height >= 36rem) {
|
|
411
|
+
position: sticky;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
&[data-stuck] {
|
|
416
|
+
@apply bg-surface border-subtle;
|
|
417
|
+
border-top-color: transparent;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* Where a jump lands. A jump goes deep, so what counts is the stack once PINNED, with the
|
|
422
|
+
navbar compact and the page row stuck: heights, not --pin-section-top, whose value at the
|
|
423
|
+
top of the page holds the row's rest place and whatever scrolls away above it. So this one
|
|
424
|
+
composes, which a thing placed on screen must not: a margin is read once, at the jump. Less
|
|
425
|
+
two pixels, so the header has left its place on a fractional height and its title shows on
|
|
426
|
+
the bar.
|
|
427
|
+
|
|
428
|
+
A JUMP FROM THE TOP OF THE PAGE reads the navbar still tall, and lands by the navbar's
|
|
429
|
+
collapse below the line. Landing short is safe, the title shows. Landing past is safe too,
|
|
430
|
+
since the bar holds the title. A target inside a section: a bar's height lower. */
|
|
431
|
+
@utility section-target {
|
|
432
|
+
scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) - 2px);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@utility section-subtarget {
|
|
436
|
+
scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) + var(--section-header-h));
|
|
437
|
+
}
|
|
438
|
+
|
|
371
439
|
/* -- Scrims: tints and shades ----------------------------------------------
|
|
372
440
|
A scrim is the wash laid over media (a photo, a video) so text reads on it.
|
|
373
441
|
It comes in two TONES, and the tone is a design decision, not a colour pick:
|
|
@@ -799,6 +867,59 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
799
867
|
@keyframes pt-unfold-bottom { from { opacity: 0; transform: perspective(1200px) translateY(100%) rotateX(-90deg); } }
|
|
800
868
|
@keyframes pt-unfold-top { from { opacity: 0; transform: perspective(1200px) translateY(-100%) rotateX(90deg); } }
|
|
801
869
|
|
|
870
|
+
/* -- The theme flip --------------------------------------------------------
|
|
871
|
+
$store.theme.set (studio/alpine_stores) puts data-theme-flip on the root for one
|
|
872
|
+
flip. The whole page changes in one crossfade, whatever its size: no element
|
|
873
|
+
runs its own transition, no named element keeps its own picture, and nothing
|
|
874
|
+
here moves. !important because a wildcard pseudo-element is weaker than the
|
|
875
|
+
rules above and than a host's rule for a name. A pointer's click during the
|
|
876
|
+
crossfade hits the root, not the page (Chromium ignores pointer-events on these
|
|
877
|
+
pseudo-elements); a key press still reaches the focused toggle. */
|
|
878
|
+
html[data-theme-flip],
|
|
879
|
+
html[data-theme-flip] *,
|
|
880
|
+
html[data-theme-flip] *::before,
|
|
881
|
+
html[data-theme-flip] *::after {
|
|
882
|
+
transition: none !important;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* The root keeps its name: it is the one picture. */
|
|
886
|
+
html[data-theme-flip] * {
|
|
887
|
+
view-transition-name: none !important;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
html[data-theme-flip]::view-transition {
|
|
891
|
+
background: none !important;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
html[data-theme-flip]::view-transition-group(*) {
|
|
895
|
+
animation: none !important;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
html[data-theme-flip]::view-transition-old(*),
|
|
899
|
+
html[data-theme-flip]::view-transition-new(*) {
|
|
900
|
+
mix-blend-mode: plus-lighter !important;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
html[data-theme-flip]::view-transition-old(*) {
|
|
904
|
+
animation: vt-theme-out 160ms linear both !important;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
html[data-theme-flip]::view-transition-new(*) {
|
|
908
|
+
animation: vt-theme-in 160ms linear both !important;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
@keyframes vt-theme-out {
|
|
912
|
+
to {
|
|
913
|
+
opacity: 0;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
@keyframes vt-theme-in {
|
|
918
|
+
from {
|
|
919
|
+
opacity: 0;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
802
923
|
/* Turbo's progress bar is the slow-load feedback under no-preview (the old
|
|
803
924
|
page holds until the fresh response). Theme it so every app's bar matches
|
|
804
925
|
its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
|
|
@@ -1461,14 +1582,36 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
1461
1582
|
}
|
|
1462
1583
|
}
|
|
1463
1584
|
|
|
1585
|
+
/* THE SECTION HEADER UNDER GLASS, the same bar one layer down. While it is stuck the bar
|
|
1586
|
+
under the row owns the round corners and the shadow: the row goes square, or the page
|
|
1587
|
+
would show in the notches between the two. */
|
|
1588
|
+
html[data-surface-style="glass"] .section-header[data-stuck] {
|
|
1589
|
+
background-color: var(--color-page);
|
|
1590
|
+
border-top-color: transparent;
|
|
1591
|
+
border-bottom-color: var(--surface-glass-line);
|
|
1592
|
+
border-radius: 0 0 0.875rem 0.875rem;
|
|
1593
|
+
box-shadow: var(--surface-glass-shadow);
|
|
1594
|
+
}
|
|
1595
|
+
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
1596
|
+
html[data-surface-style="glass"] .section-header[data-stuck] {
|
|
1597
|
+
background-color: color-mix(in srgb, var(--color-page) 62%, transparent);
|
|
1598
|
+
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
|
1599
|
+
backdrop-filter: blur(18px) saturate(140%);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
html[data-surface-style="glass"][data-pin-section-stuck] .page-header[data-stuck] {
|
|
1603
|
+
border-radius: 0;
|
|
1604
|
+
box-shadow: var(--surface-glass-highlight);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1464
1607
|
/* LIT FROM ABOVE, under the same opt-in: one light for buttons, badges and inputs, from the
|
|
1465
1608
|
direction of the cards' top highlight. Four shadows: top light, bottom shade, contact, and
|
|
1466
1609
|
a glow in the button's own color.
|
|
1467
1610
|
|
|
1468
|
-
THE GRADIENT KEEPS ITS CHROMA. Both stops are built in oklch from the token,
|
|
1469
|
-
and
|
|
1470
|
-
|
|
1471
|
-
|
|
1611
|
+
THE GRADIENT KEEPS ITS CHROMA. Both stops are built in oklch from the token, the same step
|
|
1612
|
+
lighter and darker at the token's own chroma; mixing toward black or grey is what mutes a
|
|
1613
|
+
button. The midpoint is the token, so white text holds 4.5:1 there on the semantic fills
|
|
1614
|
+
and about 3:1 on the default primary, which Alex McRitchie accepted to match the brand word.
|
|
1472
1615
|
|
|
1473
1616
|
WHO GETS WHAT. Every full-size filled button: gradient, top, bottom, contact and a glow in
|
|
1474
1617
|
its own color, on the same 0.15 lightness range. A .btn-sm has no glow, since a toolbar or
|
|
@@ -1516,16 +1659,17 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
1516
1659
|
--lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.14), 0 1px 1px rgb(0 0 0 / 0.35);
|
|
1517
1660
|
--lit-input: inset 0 1px 2px rgb(0 0 0 / 0.4), inset 0 -1px 0 rgb(255 255 255 / 0.06);
|
|
1518
1661
|
}
|
|
1519
|
-
/* Stops:
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
html[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) {
|
|
1526
|
-
--lit-top-stop: oklch(from var(--lit-fill) calc(l + 0.
|
|
1527
|
-
--lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.
|
|
1528
|
-
}
|
|
1662
|
+
/* Stops: CENTERED ON THE FILL. Each stop moves lightness the same step up and down, with
|
|
1663
|
+
chroma and hue unchanged, so the gradient's midpoint (oklab interpolation) is the token
|
|
1664
|
+
itself. The primary centers on --color-cta, the brand violet a text-primary word paints,
|
|
1665
|
+
so the button and the word read as one purple. Alex McRitchie accepted white near 3:1 on
|
|
1666
|
+
the default primary for that; the semantic fills hold 4.5:1 at the midpoint. A top stop
|
|
1667
|
+
past sRGB clips only the top edge: the midpoint is interpolated before it is clipped. */
|
|
1668
|
+
html[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success) {
|
|
1669
|
+
--lit-top-stop: oklch(from var(--lit-fill) calc(l + 0.06) c h / var(--lit-fill-alpha, 1));
|
|
1670
|
+
--lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.06) c h / var(--lit-fill-alpha, 1));
|
|
1671
|
+
}
|
|
1672
|
+
html[data-surface-style="glass"] .btn-primary { --lit-fill: var(--color-cta); }
|
|
1529
1673
|
html[data-surface-style="glass"] .btn-secondary { --lit-fill: var(--btn-secondary-bg, var(--color-success)); }
|
|
1530
1674
|
html[data-surface-style="glass"] .btn-danger { --lit-fill: var(--color-danger-fill, var(--color-danger)); }
|
|
1531
1675
|
html[data-surface-style="glass"] .btn-warning { --lit-fill: var(--color-warning-fill, var(--color-warning)); }
|
|
@@ -1545,16 +1689,16 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
1545
1689
|
var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
|
|
1546
1690
|
}
|
|
1547
1691
|
/* On a dark page a deep green, red or orange glow disappears, so these glow from the top
|
|
1548
|
-
stop. The violet is
|
|
1692
|
+
stop. The violet, centered on the brand colour, is bright enough at its low stop. */
|
|
1549
1693
|
html.dark[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) { --lit-glow-stop: var(--lit-top-stop); }
|
|
1550
1694
|
/* A TOUCH OF TRANSPARENCY on a full-size filled button, dark theme only: a photo behind it
|
|
1551
|
-
shows subtly, and white text holds
|
|
1695
|
+
shows subtly, and white text holds its contrast at the midpoint. An outer shadow is not painted
|
|
1552
1696
|
under its own box, so the glow never brightens the fill. On a light page the backdrop would
|
|
1553
1697
|
lift the fill and cost contrast, so it stays solid there. Without a blur, or without relative
|
|
1554
1698
|
oklch (the gradient would drop out and leave white text on nothing), it stays solid. */
|
|
1555
1699
|
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (color: oklch(from red l c h)) {
|
|
1556
1700
|
html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"], [class*=" bg-"], [class^="bg-"]) {
|
|
1557
|
-
--lit-fill-alpha: 0.
|
|
1701
|
+
--lit-fill-alpha: 0.85;
|
|
1558
1702
|
background-color: transparent;
|
|
1559
1703
|
-webkit-backdrop-filter: blur(6px);
|
|
1560
1704
|
backdrop-filter: blur(6px);
|
|
@@ -1602,13 +1746,53 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
1602
1746
|
html[data-surface-style="glass"] .badge { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-badge); }
|
|
1603
1747
|
html[data-surface-style="glass"] .input-field { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-input); }
|
|
1604
1748
|
|
|
1749
|
+
/* BUTTON TRICKS ON A LIT BUTTON (the lit rules above).
|
|
1750
|
+
A lit button already uses its box-shadow for its contact shadow and glow, and
|
|
1751
|
+
its ::after for its inset light. These rules sit in the utilities layer and the
|
|
1752
|
+
tricks in engine-motion.css sit in components, so the lit rules win, and each trick
|
|
1753
|
+
that shares one of them makes room here. Keep a trick on a real .btn lit.
|
|
1754
|
+
|
|
1755
|
+
--btn-lit-rest is the lit button's outer shadow at rest. The pulse's keyframes
|
|
1756
|
+
replace box-shadow for the whole animation, so they put it back themselves. */
|
|
1757
|
+
html[data-surface-style="glass"] .btn:not(:disabled, [aria-disabled="true"]) {
|
|
1758
|
+
--btn-lit-rest: var(--lit-contact);
|
|
1759
|
+
}
|
|
1760
|
+
html[data-surface-style="glass"] .btn:is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"]) {
|
|
1761
|
+
--btn-lit-rest: var(--lit-contact),
|
|
1762
|
+
var(--lit-glow) oklch(from var(--lit-glow-stop, var(--lit-low-stop)) l c h / var(--lit-glow-alpha));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/* The glows need ::after for their bloom, so the inset light moves to the host and
|
|
1766
|
+
the bloom gets its own box back. Without this the lit rule's inset: -1px closes
|
|
1767
|
+
the bloom's hole and it washes the whole face. .btn-outline has no lit ::after. */
|
|
1768
|
+
html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]) {
|
|
1769
|
+
box-shadow: var(--lit-inset), var(--btn-lit-rest);
|
|
1770
|
+
}
|
|
1771
|
+
html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow, .studio-border-glow):not(.btn-outline, :disabled, [aria-disabled="true"]):active {
|
|
1772
|
+
box-shadow: var(--lit-inset-pressed);
|
|
1773
|
+
}
|
|
1774
|
+
html[data-surface-style="glass"] .btn:is(.edge-rotate-glow, .studio-team-glow):not(.btn-outline, :disabled, [aria-disabled="true"])::after {
|
|
1775
|
+
inset: calc(-1 * (var(--edge-rotate-glow-thickness) + 4px + var(--edge-rotate-glow-bloom) * 2));
|
|
1776
|
+
box-shadow: none;
|
|
1777
|
+
}
|
|
1778
|
+
html[data-surface-style="glass"] .btn.studio-border-glow:not(.btn-outline, :disabled, [aria-disabled="true"])::after {
|
|
1779
|
+
inset: -46px;
|
|
1780
|
+
box-shadow: none;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/* The sheen's band already stops at its own rounded box, and overflow: hidden would
|
|
1784
|
+
cut the lit ::after's outer pixel, the top highlight. */
|
|
1785
|
+
html[data-surface-style="glass"] .btn.sheen:not(:disabled, [aria-disabled="true"]) {
|
|
1786
|
+
overflow: visible;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1605
1789
|
/* LESS TRANSPARENCY: every surface above goes solid and drops its blur. Last, to win on order;
|
|
1606
1790
|
both spellings of the root, since html.dark outranks a bare html[...] on specificity. */
|
|
1607
1791
|
@media (prefers-reduced-transparency: reduce) {
|
|
1608
1792
|
html[data-surface-style="glass"], html.dark[data-surface-style="glass"] { --surface-glass-fill: var(--color-surface); --surface-glass-fill-hover: var(--color-surface-alt); }
|
|
1609
|
-
html[data-surface-style="glass"] :is(.surface-glass-blur, .nav-shell, .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)),
|
|
1793
|
+
html[data-surface-style="glass"] :is(.surface-glass-blur, .nav-shell, .section-header[data-stuck], .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)),
|
|
1610
1794
|
html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"]) { --lit-fill-alpha: 1; -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
1611
|
-
html[data-surface-style="glass"] :is(.nav-shell, .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)) { background-color: var(--color-page); }
|
|
1795
|
+
html[data-surface-style="glass"] :is(.nav-shell, .section-header[data-stuck], .page-header:is([data-stuck], [data-studio-controller-failed~="page-header"] + .page-header)) { background-color: var(--color-page); }
|
|
1612
1796
|
}
|
|
1613
1797
|
|
|
1614
1798
|
/* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest
|
|
@@ -7,6 +7,8 @@ module Studio
|
|
|
7
7
|
# methods only.
|
|
8
8
|
include Studio::SessionDrift
|
|
9
9
|
|
|
10
|
+
INVALID_TOKEN_MESSAGE = "This page has expired. Reload it, then try again."
|
|
11
|
+
|
|
10
12
|
included do
|
|
11
13
|
# ORDER IS LOAD-BEARING — ActiveSupport::Rescuable resolves handlers with
|
|
12
14
|
# `reverse_each`, so the LAST matching rescue_from registered wins. The
|
|
@@ -19,6 +21,7 @@ module Studio
|
|
|
19
21
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
20
22
|
# A string, so including this concern does not load the model concern.
|
|
21
23
|
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
24
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_authenticity_token
|
|
22
25
|
|
|
23
26
|
before_action :require_authentication
|
|
24
27
|
after_action :track_user_session
|
|
@@ -271,6 +274,20 @@ module Studio
|
|
|
271
274
|
end
|
|
272
275
|
end
|
|
273
276
|
|
|
277
|
+
# Layer 1: a bad or missing CSRF token on a fetch is a 422 the page can read, with
|
|
278
|
+
# no ErrorLog row: anyone can send one, and the catch-all answered it 500.
|
|
279
|
+
#
|
|
280
|
+
# HTML stays with the catch-all on purpose. Its redirect home hands a stale login or
|
|
281
|
+
# magic-link form a fresh token and a way on, where public/422.html is a dead end
|
|
282
|
+
# with no link (Back shows the same stale form). Its row is what the health report
|
|
283
|
+
# sees when a session config breaks every form at once.
|
|
284
|
+
def handle_invalid_authenticity_token(exception)
|
|
285
|
+
respond_to do |format|
|
|
286
|
+
format.json { render json: { error: INVALID_TOKEN_MESSAGE, reason: "invalid_token" }, status: :unprocessable_entity }
|
|
287
|
+
format.any { handle_unexpected_error(exception) }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
274
291
|
# Layer 1: Catch-all for unexpected errors — log + friendly response.
|
|
275
292
|
# Skips logging if rescue_and_log already captured it.
|
|
276
293
|
def handle_unexpected_error(exception)
|
|
@@ -73,6 +73,11 @@ module Studio
|
|
|
73
73
|
"--fd:#{bit[:delay]}s;--ft:#{bit[:duration]}s;--fc:#{fizz_color(bit)}"
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# A fixed palette as the inline style a stack carries: `--fizz-c-1:red;...`.
|
|
77
|
+
def fizz_stack_style(colors)
|
|
78
|
+
Array(colors).first(SLOTS).each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";")
|
|
79
|
+
end
|
|
80
|
+
|
|
76
81
|
private
|
|
77
82
|
|
|
78
83
|
# Where in its own zone a bubble sits. The outer columns each throw one
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// until the app answers 201; a refusal shows its reason above them.
|
|
19
19
|
// FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
|
|
20
20
|
// it, and always after their own send (the hub's chat_follow rule).
|
|
21
|
+
// REVEAL the composer sticks over the window's bottom, so on a short window the
|
|
22
|
+
// thread's end sits under it. On load and after a new turn the page
|
|
23
|
+
// scrolls that end clear of the composer, with the same manners as
|
|
24
|
+
// FOLLOW: not for an empty thread, not for a reader who scrolled the page
|
|
25
|
+
// up (revealFollowing, until their own send), and never during a hold,
|
|
26
|
+
// when it waits for the button to be let go.
|
|
21
27
|
// REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
|
|
22
28
|
// host's own composer): dispatch studio:agent-chat:refresh on the
|
|
23
29
|
// chat's section, optionally with {detail: {turn}}, a turn the app
|
|
@@ -43,6 +49,11 @@ export const IDLE_MS = 30000
|
|
|
43
49
|
export const OFFLINE_MS = 15000
|
|
44
50
|
export const FOLLOW_PX = 20
|
|
45
51
|
export const LIMIT = 50
|
|
52
|
+
export const REVEAL_GAP_PX = 12
|
|
53
|
+
export const INVALID_TOKEN = "invalid_token"
|
|
54
|
+
export const EXPIRED_MESSAGE = "This page has expired. Copy your message, then reload the page to send it."
|
|
55
|
+
export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
|
|
56
|
+
const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
|
|
46
57
|
export const REFRESH_EVENT = "studio:agent-chat:refresh"
|
|
47
58
|
export const SENT_EVENT = "studio:agent-chat:sent"
|
|
48
59
|
const UNSETTLED = new Set(["pending", "typing"])
|
|
@@ -102,6 +113,12 @@ export function isFollowing(log, px = FOLLOW_PX) {
|
|
|
102
113
|
return log.scrollHeight - log.clientHeight - log.scrollTop <= px
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
// How many pixels of the thread's end the composer (or the window's bottom) hides;
|
|
117
|
+
// zero or less when it is clear of both. Rects are the elements' own.
|
|
118
|
+
export function coveredPx(endRect, composerRect, windowHeight) {
|
|
119
|
+
return endRect.bottom - Math.min(composerRect.top, windowHeight)
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
// The page's authenticity token: the form's own field, else the csrf meta.
|
|
106
123
|
export function csrfToken(form, doc = globalThis.document) {
|
|
107
124
|
const field = form && form.querySelector("input[name='authenticity_token']")
|
|
@@ -138,13 +155,57 @@ export function mountAgentChat(root, {
|
|
|
138
155
|
const soul = root.dataset.agentChatSoul
|
|
139
156
|
const name = root.dataset.agentChatName || "The agent"
|
|
140
157
|
|
|
141
|
-
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
|
|
158
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
|
|
142
159
|
|
|
143
160
|
const onScroll = () => { state.following = isFollowing(log) }
|
|
144
161
|
log.addEventListener("scroll", onScroll, { passive: true })
|
|
145
162
|
|
|
146
163
|
function toEnd() { log.scrollTop = log.scrollHeight }
|
|
147
164
|
|
|
165
|
+
// The last thing in the thread's column above the composer that is showing.
|
|
166
|
+
function threadEnd() {
|
|
167
|
+
return [offlineNote, typing].find((el) => el && el.hidden === false) || log
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function windowHeight() {
|
|
171
|
+
return doc && doc.documentElement ? doc.documentElement.clientHeight : 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function covered() {
|
|
175
|
+
const end = threadEnd()
|
|
176
|
+
if (!form || !end.getBoundingClientRect || !form.getBoundingClientRect) return 0
|
|
177
|
+
return coveredPx(end.getBoundingClientRect(), form.getBoundingClientRect(), windowHeight())
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// scrollIntoView, not window.scrollBy: a host may scroll an inner container. The
|
|
181
|
+
// margin is what the composer takes off the bottom of the window once it sticks:
|
|
182
|
+
// its height and its sticky bottom, not its top now, because a composer lower down
|
|
183
|
+
// the page is not stuck until this scroll has run. Instant, so a host's smooth
|
|
184
|
+
// scrolling cannot leave the page moving when a hold begins.
|
|
185
|
+
function revealEnd() {
|
|
186
|
+
if (root.querySelector(HOLDING)) { state.revealOwed = true; return }
|
|
187
|
+
state.revealOwed = false
|
|
188
|
+
if (!state.turns.length || covered() <= 0) return
|
|
189
|
+
const end = threadEnd()
|
|
190
|
+
const stuckAt = parseFloat(globalThis.getComputedStyle?.(form).bottom) || 0
|
|
191
|
+
end.style.scrollMarginBottom = `${form.getBoundingClientRect().height + stuckAt + REVEAL_GAP_PX}px`
|
|
192
|
+
end.scrollIntoView({ block: "end", behavior: "instant" })
|
|
193
|
+
end.style.scrollMarginBottom = ""
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A scroll of the page or a host's container says whether the reader is still at
|
|
197
|
+
// the thread's end. The log's own scroll, the box's and the picker's say nothing:
|
|
198
|
+
// only something that holds the chat can move it. A reveal passes this too, and
|
|
199
|
+
// ends there.
|
|
200
|
+
const onPageScroll = (event) => {
|
|
201
|
+
if (!event.target || !event.target.contains || !event.target.contains(root)) return
|
|
202
|
+
state.revealFollowing = covered() <= FOLLOW_PX
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onRecorderState = () => {
|
|
206
|
+
if (state.revealOwed && !root.querySelector(HOLDING) && state.following && state.revealFollowing) revealEnd()
|
|
207
|
+
}
|
|
208
|
+
|
|
148
209
|
function setOffline(offline) {
|
|
149
210
|
state.offline = offline
|
|
150
211
|
if (offlineNote) offlineNote.hidden = !offline
|
|
@@ -185,7 +246,10 @@ export function mountAgentChat(root, {
|
|
|
185
246
|
const waiting = state.pending || state.turns.some((turn) => !settled(turn))
|
|
186
247
|
if (typing) typing.hidden = !waiting
|
|
187
248
|
if (phase) phase.dataset.phase = typingPhase(state.turns)
|
|
188
|
-
if (follow)
|
|
249
|
+
if (follow) {
|
|
250
|
+
toEnd()
|
|
251
|
+
if (state.revealFollowing) revealEnd()
|
|
252
|
+
}
|
|
189
253
|
}
|
|
190
254
|
|
|
191
255
|
function schedule() {
|
|
@@ -233,6 +297,7 @@ export function mountAgentChat(root, {
|
|
|
233
297
|
state.turns = mergeTurns(state.turns, [turn])
|
|
234
298
|
state.pending = turn.status !== "held"
|
|
235
299
|
state.following = true
|
|
300
|
+
state.revealFollowing = true
|
|
236
301
|
state.waitingSince = now()
|
|
237
302
|
setOffline(false)
|
|
238
303
|
render()
|
|
@@ -272,6 +337,7 @@ export function mountAgentChat(root, {
|
|
|
272
337
|
state.turns = mergeTurns(state.turns, [data.turn])
|
|
273
338
|
state.pending = data.turn.status !== "held"
|
|
274
339
|
state.following = true
|
|
340
|
+
state.revealFollowing = true
|
|
275
341
|
setOffline(false)
|
|
276
342
|
render()
|
|
277
343
|
toEnd()
|
|
@@ -281,7 +347,7 @@ export function mountAgentChat(root, {
|
|
|
281
347
|
clearTimer(state.timer)
|
|
282
348
|
state.timer = setTimer(poll, POLL_MS)
|
|
283
349
|
} else {
|
|
284
|
-
showError(data.error || `Couldn't send that to ${name}. Try again.`)
|
|
350
|
+
showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
|
|
285
351
|
if (status) status.textContent = ""
|
|
286
352
|
}
|
|
287
353
|
} catch (_error) {
|
|
@@ -313,7 +379,10 @@ export function mountAgentChat(root, {
|
|
|
313
379
|
input && input.addEventListener("keydown", onKeydown)
|
|
314
380
|
retry && retry.addEventListener("click", onRetry)
|
|
315
381
|
root.addEventListener(REFRESH_EVENT, onRefresh)
|
|
382
|
+
root.addEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
316
383
|
doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
|
|
384
|
+
// Scroll events do not bubble; capture hears the page's and any container's.
|
|
385
|
+
doc && doc.addEventListener && doc.addEventListener("scroll", onPageScroll, { capture: true, passive: true })
|
|
317
386
|
root.dataset.agentChatReady = "true"
|
|
318
387
|
poll()
|
|
319
388
|
|
|
@@ -330,7 +399,9 @@ export function mountAgentChat(root, {
|
|
|
330
399
|
input && input.removeEventListener("keydown", onKeydown)
|
|
331
400
|
retry && retry.removeEventListener("click", onRetry)
|
|
332
401
|
root.removeEventListener(REFRESH_EVENT, onRefresh)
|
|
402
|
+
root.removeEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
333
403
|
doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
|
|
404
|
+
doc && doc.removeEventListener && doc.removeEventListener("scroll", onPageScroll, { capture: true })
|
|
334
405
|
delete root.dataset.agentChatReady
|
|
335
406
|
}
|
|
336
407
|
}
|
|
@@ -50,6 +50,8 @@ export const MESSAGES = {
|
|
|
50
50
|
notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
|
|
51
51
|
rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
|
|
52
52
|
signedOut: "Sign in again to talk.",
|
|
53
|
+
expired: "This page has expired. Reload it, then hold to talk again.",
|
|
54
|
+
expiredSend: "This page has expired. Copy your words from the box, then reload the page to send them.",
|
|
53
55
|
unavailable: "I couldn't transcribe that just now. Try again in a moment.",
|
|
54
56
|
failed: "That recording didn't go through. Try again.",
|
|
55
57
|
offline: "Can't reach the server right now. Try again in a moment.",
|
|
@@ -65,11 +67,19 @@ export function retryHint(seconds) {
|
|
|
65
67
|
return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// A refused CSRF token: the engine's own answer (Studio::ErrorHandling), or Rails'
|
|
71
|
+
// {status: 422, error: "Unprocessable Content"} from a host that re-raises it. Retrying
|
|
72
|
+
// on this page can never pass, and Rails' words mean nothing to a person.
|
|
73
|
+
export function tokenRefused(status, body = {}) {
|
|
74
|
+
return status === 422 && !!body && (body.reason === "invalid_token" || body.status === 422)
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
// What POST /voice/takes's refusal means on the page: {state, message}.
|
|
69
78
|
// `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
|
|
70
79
|
export function refusalFor(status, body = {}, retryAfter = null) {
|
|
71
80
|
const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
|
|
72
81
|
if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
|
|
82
|
+
if (tokenRefused(status, body)) return { state: "refused", message: MESSAGES.expired }
|
|
73
83
|
if (status === 429) {
|
|
74
84
|
return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
|
|
75
85
|
}
|
|
@@ -235,6 +245,10 @@ export function mountAgentChatVoice(root, {
|
|
|
235
245
|
return
|
|
236
246
|
}
|
|
237
247
|
keepWords(answer.text)
|
|
248
|
+
if (tokenRefused(response.status, data)) {
|
|
249
|
+
say("refused", MESSAGES.expiredSend)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
238
252
|
const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
|
|
239
253
|
say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
|
|
240
254
|
}
|
|
@@ -255,7 +269,9 @@ export function mountAgentChatVoice(root, {
|
|
|
255
269
|
if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
|
|
256
270
|
const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
|
|
257
271
|
const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
|
|
258
|
-
|
|
272
|
+
// A reload clears the box, so words already in it get the copy-first line.
|
|
273
|
+
const inBox = form && form.querySelector("[data-agent-chat-input]")?.value.trim()
|
|
274
|
+
say(state, inBox && tokenRefused(response.status, body) ? MESSAGES.expiredSend : message)
|
|
259
275
|
return
|
|
260
276
|
}
|
|
261
277
|
await send(body)
|