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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +5 -0
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +148 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +203 -19
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/helpers/studio/fizz_helper.rb +5 -0
  8. data/app/javascript/studio/agent_chat.js +74 -3
  9. data/app/javascript/studio/agent_chat_voice.js +17 -1
  10. data/app/javascript/studio/alpine_stores.js +80 -22
  11. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  12. data/app/javascript/studio/pinned_stack.js +112 -4
  13. data/app/views/registrations/new.html.erb +4 -3
  14. data/app/views/sessions/new.html.erb +4 -3
  15. data/app/views/studio/_fizz.html.erb +26 -0
  16. data/app/views/studio/_fizz_layer.html.erb +4 -1
  17. data/app/views/studio/_hold_button.html.erb +1 -2
  18. data/app/views/studio/_page_header.html.erb +3 -2
  19. data/app/views/studio/_section_header.html.erb +23 -0
  20. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  21. data/app/views/style/_components.html.erb +3 -7
  22. data/app/views/style/_glass.html.erb +1 -1
  23. data/app/views/style/_host.html.erb +3 -14
  24. data/app/views/style/_modal_specimen.html.erb +5 -5
  25. data/app/views/style/_modals.html.erb +29 -224
  26. data/app/views/style/_scrims.html.erb +1 -8
  27. data/app/views/style/_site_footer.html.erb +5 -13
  28. data/app/views/style/_specimen.html.erb +4 -0
  29. data/app/views/style/_tasks.html.erb +17 -30
  30. data/app/views/style/_theme.html.erb +3 -9
  31. data/app/views/style/_tricks.html.erb +155 -216
  32. data/app/views/style/index.html.erb +7 -3
  33. data/lib/studio/version.rb +1 -1
  34. 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, a brighter
1469
- and an equally saturated top over a deep saturated bottom; mixing toward black or grey is
1470
- what mutes a button. White text is held to 4.5:1 against the midpoint behind it, so the
1471
- top stop reads lower, as it does in the rule this follows.
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: the app's primary is often too light for white, so it steps down its own lightness;
1520
- the semantic fills already hold 4.5:1, so the midpoint stays on the fill. */
1521
- html[data-surface-style="glass"] .btn-primary {
1522
- --lit-top-stop: oklch(from var(--color-cta) calc(l - 0.03) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
1523
- --lit-low-stop: oklch(from var(--color-cta) calc(l - 0.18) calc(c + 0.05) h / var(--lit-fill-alpha, 1));
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.075) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
1527
- --lit-low-stop: oklch(from var(--lit-fill) calc(l - 0.075) calc(c + 0.03) h / var(--lit-fill-alpha, 1));
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 already bright enough at its low stop. */
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 4.5:1 at the midpoint. An outer shadow is not painted
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.94;
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) toEnd()
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
- say(state, message)
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)