studio-engine 0.129.0 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/README.md +48 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +737 -19
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/fizz_helper.rb +5 -0
  15. data/app/helpers/studio/loader_helper.rb +79 -2
  16. data/app/javascript/studio/agent_chat.js +294 -6
  17. data/app/javascript/studio/carousel.js +99 -0
  18. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  19. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  20. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  21. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  22. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  23. data/app/javascript/studio/infinite_scroll.js +142 -0
  24. data/app/javascript/studio/loader.js +6 -1
  25. data/app/javascript/studio/page_transitions.js +65 -8
  26. data/app/javascript/studio/pinned_stack.js +112 -4
  27. data/app/javascript/studio/pull_to_refresh.js +147 -0
  28. data/app/javascript/studio/stimulus.js +12 -1
  29. data/app/javascript/studio/talk_to_agent.js +116 -0
  30. data/app/views/components/_geo_signpost.html.erb +2 -2
  31. data/app/views/components/_link_sidebar.html.erb +2 -2
  32. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  33. data/app/views/components/_sidebar_panel.html.erb +1 -1
  34. data/app/views/error_logs/index.html.erb +2 -8
  35. data/app/views/studio/_carousel.html.erb +97 -0
  36. data/app/views/studio/_fizz.html.erb +26 -0
  37. data/app/views/studio/_fizz_layer.html.erb +4 -1
  38. data/app/views/studio/_hold_button.html.erb +1 -2
  39. data/app/views/studio/_page_header.html.erb +55 -13
  40. data/app/views/studio/_section_header.html.erb +23 -0
  41. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  42. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  43. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  44. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  45. data/app/views/studio/loader/_loader.html.erb +20 -3
  46. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  47. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  48. data/app/views/style/_components.html.erb +3 -5
  49. data/app/views/style/_host.html.erb +3 -6
  50. data/app/views/style/_loaders.html.erb +118 -0
  51. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  52. data/app/views/style/_modal_specimen.html.erb +5 -5
  53. data/app/views/style/_modals.html.erb +15 -5
  54. data/app/views/style/_site_footer.html.erb +1 -1
  55. data/app/views/style/_specimen.html.erb +21 -10
  56. data/app/views/style/_tasks.html.erb +3 -5
  57. data/app/views/style/_theme.html.erb +2 -2
  58. data/app/views/style/_tricks.html.erb +218 -217
  59. data/app/views/style/index.html.erb +21 -11
  60. data/app/views/style/loaders_feed.html.erb +7 -0
  61. data/lib/studio/crumbs.rb +150 -0
  62. data/lib/studio/loader.rb +30 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +3 -0
  65. metadata +22 -2
@@ -65,9 +65,16 @@ export const schema = {
65
65
  // says recording did not load (data-studio-controller-failed="voice-recorder").
66
66
  // So is the chat's mic: a mic whose controller failed says voice did not load,
67
67
  // and the box still types (data-studio-controller-failed="agent-chat-voice").
68
+ // So is the talk-to-agent modal: its links are real links, and with its
69
+ // controller failed each one opens its href (data-studio-controller-failed="talk-to-agent").
68
70
  // So is copy-text: a colour whose controller failed still shows its hex to select.
69
71
  // So is the page header: its tabs are links, and a row whose controller failed
70
72
  // keeps its bar at all times (data-studio-controller-failed="page-header").
73
+ // So is the carousel: its track is a native scroll row, and one whose controller
74
+ // failed keeps a scrollbar and hides the arrows and dots it cannot drive.
75
+ // So is infinite scroll: a list whose controller failed keeps its "Load more"
76
+ // link, which loads the next page by itself. So is pull to refresh: nothing
77
+ // depends on it, and the browser's own reload still works.
71
78
  export const LAZY = {
72
79
  "board": () => import("studio/controllers/board_controller"),
73
80
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -79,11 +86,15 @@ export const LAZY = {
79
86
  "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
80
87
  "agent-chat": () => import("studio/controllers/agent_chat_controller"),
81
88
  "agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
89
+ "talk-to-agent": () => import("studio/controllers/talk_to_agent_controller"),
82
90
  "voice-profile": () => import("studio/controllers/voice_profile_controller"),
83
91
  "microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
84
92
  "voice-recorder": () => import("studio/controllers/voice_recorder_controller"),
85
93
  "copy-text": () => import("studio/controllers/copy_text_controller"),
86
- "page-header": () => import("studio/controllers/page_header_controller")
94
+ "page-header": () => import("studio/controllers/page_header_controller"),
95
+ "carousel": () => import("studio/controllers/carousel_controller"),
96
+ "infinite-scroll": () => import("studio/controllers/infinite_scroll_controller"),
97
+ "pull-to-refresh": () => import("studio/controllers/pull_to_refresh_controller")
87
98
  }
88
99
 
89
100
  export const application = Application.start(document.documentElement, schema)
@@ -0,0 +1,116 @@
1
+ // studio/talk_to_agent: the talk-to-agent modal (studio/agent_chat/_talk_modal;
2
+ // docs/AGENT_CHAT.md, "Talk to an agent in a modal"). It wraps the shared agent
3
+ // chat, studio/agent_chat, in the page-scoped modal host; it is not a second
4
+ // chat.
5
+ //
6
+ // LINKS any element carrying data-studio-talk-to-agent opens the modal:
7
+ //
8
+ // <a href="/chat" data-studio-talk-to-agent data-talk-soul="alex"
9
+ // data-talk-topic="Pro" data-talk-opener="Tell me about Pro.">
10
+ //
11
+ // The click is taken only when the soul is this modal's, no modifier
12
+ // key is held and the host's Alpine store is there. Otherwise the
13
+ // link does what its href says, so a page whose modal failed to load
14
+ // still reaches the chat.
15
+ // OPEN the store opens TALK_MODAL_ID; the panel the host clones in asks
16
+ // for its chat (PANEL_EVENT), and this module fills it with a fresh
17
+ // copy of the chat from the partial's <template>, marked with the
18
+ // link's opener (data-agent-chat-opener, keyed by soul and topic)
19
+ // and the type-in. studio/agent_chat then loads the thread and sends
20
+ // the opener, so the agent is answering while the card animates in.
21
+ // CLOSE the host drops the panel; the chat's controller disconnects and
22
+ // stops polling. The thread stays on the hub: the next open shows it.
23
+ //
24
+ // It imports nothing, so test/javascript/talk_to_agent.test.mjs loads it as a
25
+ // data: module; studio/controllers/talk_to_agent_controller mounts it.
26
+
27
+ export const TALK_SELECTOR = "[data-studio-talk-to-agent]"
28
+ export const TALK_MODAL_ID = "talk-to-agent"
29
+ export const PANEL_EVENT = "studio:talk-to-agent:panel"
30
+ export const TOPIC_MAX = 80
31
+ export const OPENER_MAX = 500
32
+
33
+ function clean(value, max) {
34
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max)
35
+ }
36
+
37
+ // What a link asks for: {soul, topic, opener}. The opener defaults to a plain
38
+ // line naming the topic; with no topic and no opener there is nothing to send.
39
+ export function talkFrom(link) {
40
+ const data = (link && link.dataset) || {}
41
+ const topic = clean(data.talkTopic, TOPIC_MAX)
42
+ const opener = clean(data.talkOpener, OPENER_MAX) || (topic ? `I'd like to talk about ${topic}.` : "")
43
+ return { soul: clean(data.talkSoul, 64), topic, opener }
44
+ }
45
+
46
+ // The key an opener is sent once under (per tab): the soul and the topic.
47
+ export function openerKey(soul, topic) {
48
+ return `${soul}:${topic || "general"}`
49
+ }
50
+
51
+ // Whether this click is the modal's to take: a plain primary click.
52
+ export function plainClick(event) {
53
+ if (!event) return false
54
+ if (event.defaultPrevented) return false
55
+ if (event.button != null && event.button !== 0) return false
56
+ return !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
57
+ }
58
+
59
+ // The dialog's accessible name and the header's words.
60
+ export function dialogTitle(name, topic) {
61
+ return topic ? `Talk to ${name} about ${topic}` : `Talk to ${name}`
62
+ }
63
+
64
+ export function mountTalkToAgent(root, {
65
+ doc = globalThis.document,
66
+ store = (name) => (globalThis.Alpine && typeof globalThis.Alpine.store === "function" ? globalThis.Alpine.store(name) : null)
67
+ } = {}) {
68
+ const soul = root.dataset.talkToAgentSoul
69
+ const name = root.dataset.talkToAgentName || "the agent"
70
+ const storeName = root.dataset.talkToAgentStore
71
+ let current = null
72
+
73
+ function onClick(event) {
74
+ const link = event.target && event.target.closest ? event.target.closest(TALK_SELECTOR) : null
75
+ if (!link || !plainClick(event)) return
76
+ const asked = talkFrom(link)
77
+ if (asked.soul !== soul) return
78
+ const modals = store(storeName)
79
+ if (!modals || typeof modals.open !== "function") return
80
+ event.preventDefault()
81
+ current = asked
82
+ modals.open(TALK_MODAL_ID, { title: dialogTitle(name, asked.topic), topic: asked.topic })
83
+ }
84
+
85
+ // The host cloned the panel in: give it a fresh chat for the link that opened it.
86
+ function onPanel(event) {
87
+ const panel = event.target
88
+ const template = root.querySelector("template[data-talk-to-agent-chat]")
89
+ const slot = panel && panel.querySelector ? panel.querySelector("[data-talk-to-agent-slot]") : null
90
+ if (!template || !slot || !template.content || !template.content.firstElementChild) return
91
+ const asked = current || { topic: "", opener: "" }
92
+ const heading = panel.querySelector("[data-talk-to-agent-title]")
93
+ if (heading) heading.textContent = dialogTitle(name, asked.topic)
94
+ const chat = template.content.firstElementChild.cloneNode(true)
95
+ if (asked.opener) {
96
+ chat.dataset.agentChatOpener = asked.opener
97
+ chat.dataset.agentChatOpenerKey = openerKey(soul, asked.topic)
98
+ }
99
+ chat.dataset.agentChatReveal = "true"
100
+ slot.replaceChildren(chat)
101
+ }
102
+
103
+ doc && doc.addEventListener && doc.addEventListener("click", onClick)
104
+ root.addEventListener(PANEL_EVENT, onPanel)
105
+ root.dataset.talkToAgentReady = "true"
106
+
107
+ return {
108
+ open(link) { onClick({ target: link, button: 0, preventDefault() {} }) },
109
+ get current() { return current },
110
+ destroy() {
111
+ doc && doc.removeEventListener && doc.removeEventListener("click", onClick)
112
+ root.removeEventListener(PANEL_EVENT, onPanel)
113
+ delete root.dataset.talkToAgentReady
114
+ }
115
+ }
116
+ }
@@ -86,7 +86,7 @@
86
86
  line at rest, its description on hover or focus. %>
87
87
  <% if geo_live %>
88
88
  <%= link_to admin_geo_path,
89
- class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
89
+ class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink focus-visible:ring-inset transition",
90
90
  "data-sidebar-row-described": "",
91
91
  "aria-current": (Studio.sidebar_style == :glass && respond_to?(:request) && request&.path == admin_geo_path ? "page" : nil),
92
92
  "@click": geo_close do %>
@@ -104,7 +104,7 @@
104
104
  omission: the row's whole job in this state is to hand over the string, and
105
105
  select-none made it unselectable. role/aria-disabled give a screen reader
106
106
  the "unavailable destination" this paints for a sighted eye. %>
107
- <span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset"
107
+ <span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink focus-visible:ring-inset"
108
108
  role="link"
109
109
  aria-disabled="true"
110
110
  tabindex="0"
@@ -79,7 +79,7 @@
79
79
  <% target = link[:target].presence %>
80
80
  <% line_icon = (studio_icon(link[:icon]) if glass && link[:icon].present?) %>
81
81
  <%= link_to link[:href],
82
- class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
82
+ class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink focus-visible:ring-inset transition",
83
83
  target: target,
84
84
  rel: target == "_blank" ? "noopener" : nil,
85
85
  "data-sidebar-row-described": (link[:desc].present? ? "" : nil),
@@ -158,7 +158,7 @@
158
158
  <% if logged_in? %>
159
159
  <div class="border-t border-subtle p-2"<% if glass %> data-sidebar-glass-foot<% end %>>
160
160
  <%= link_to logout_path,
161
- class: "flex items-center gap-3 rounded-md px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition",
161
+ class: "flex items-center gap-3 rounded-md px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink transition",
162
162
  "@click": close_action do %>
163
163
  <% if glass %><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("log-out") %></span><% else %><span>👋</span><% end %><span>Log out</span>
164
164
  <% end %>
@@ -10,7 +10,7 @@
10
10
  <% sidebar_admin = respond_to?(:admin?) && admin? %>
11
11
  <button type="button"
12
12
  @click.stop="$store.sidebars.linkTreeOpen = !$store.sidebars.linkTreeOpen"
13
- class="<%= extra_classes %> h-9 w-9 items-center justify-center rounded-md text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
13
+ class="<%= extra_classes %> h-9 w-9 items-center justify-center rounded-md text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink transition"
14
14
  title="<%= sidebar_admin ? 'Admin Menu' : 'Links' %>"
15
15
  aria-label="<%= sidebar_admin ? 'Toggle admin menu' : 'Toggle links menu' %>"
16
16
  aria-controls="studio-link-sidebar studio-link-sidebar-mobile"
@@ -111,7 +111,7 @@
111
111
  <% if close_action.present? %>
112
112
  <button type="button"
113
113
  @click="<%= close_action %>"
114
- class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
114
+ class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink transition"
115
115
  aria-label="Close sidebar"
116
116
  data-link-sidebar-close="true"<% if close_hook %> data-sidebar-panel-close="<%= id %>"<% end %>>
117
117
  <span class="text-xl leading-none">&times;</span>
@@ -26,19 +26,13 @@
26
26
  class="input-field pr-10 py-2.5 text-sm placeholder:text-muted"
27
27
  :readonly="loading" />
28
28
  <div x-show="loading" x-cloak class="absolute right-3 top-1/2 -translate-y-1/2">
29
- <svg class="animate-spin h-4 w-4 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
30
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
31
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
32
- </svg>
29
+ <%= studio_loader style: :spinner, size: 16, label: "Searching" %>
33
30
  </div>
34
31
  </form>
35
32
 
36
33
  <!-- Loading state -->
37
34
  <div x-show="loading" x-cloak class="py-16 text-center">
38
- <svg class="animate-spin h-8 w-8 text-primary mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
39
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
40
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
41
- </svg>
35
+ <div class="mb-3 flex justify-center"><%= studio_loader style: :spinner, size: 32, label: "" %></div>
42
36
  <p class="text-muted text-sm">Searching...</p>
43
37
  </div>
44
38
 
@@ -0,0 +1,97 @@
1
+ <%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil) %>
2
+ <%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
3
+ long to grid. Render it as a layout, so each slide is your own block:
4
+
5
+ render layout: "studio/carousel", locals: { label: "Portfolio", items: @apps } do |app|
6
+ render "apps/card", app: app
7
+ end
8
+
9
+ label the region's accessible name. Required: a screen reader says
10
+ "Portfolio, carousel".
11
+ items one slide per item; the block draws it.
12
+ per_view whole slides in view on a wide row (1 to 6). A medium row shows
13
+ up to two, a phone one, and every width shows a sliver of the
14
+ next slide, so a visitor sees there is more.
15
+ id the track's id, for the arrows' aria-controls. Defaults from
16
+ the label; pass one when two carousels on a page share a label.
17
+ slide_label a callable that names a slide from its item. Without one a
18
+ slide is "2 of 6".
19
+ data data attributes for the region, such as a test hook.
20
+ class_name classes for the region, such as its width.
21
+ style :standard (slides snap to the start) or :spotlight (the
22
+ centred slide is full size and colour, its neighbours smaller,
23
+ faded and grey, and the arrows and keys move one slide).
24
+ caption a callable that writes a line under a slide from its item.
25
+ The spotlight shows it under the centred slide only.
26
+
27
+ THE SPOTLIGHT (engine.css, carousel-spotlight) snaps each slide to the centre.
28
+ Where the browser has scroll-driven animations, each slide's own view timeline
29
+ scales and fades it as it nears the centre, so the effect follows the finger.
30
+ Elsewhere the controller marks the centred slide (data-current) and CSS
31
+ transitions it. Under reduced motion nothing scales: the neighbours are only
32
+ dimmed.
33
+
34
+ THE TRACK IS A NATIVE SCROLL-SNAP ROW (engine.css, carousel). A swipe, a
35
+ trackpad, a wheel and a focused link move it with no script. The carousel
36
+ controller (studio/controllers/carousel_controller) adds the edge fade, the
37
+ arrows, the dots and the keys, and until it connects the controls stay
38
+ hidden and the row keeps a thin scrollbar. The widths come from container
39
+ queries on the region, so a carousel in a narrow column lays out as a phone
40
+ does.
41
+
42
+ ACCESSIBLE BY THE APG CAROUSEL PATTERN: a labelled region with
43
+ aria-roledescription carousel, a focusable track named "<label> slides", each
44
+ slide a group with aria-roledescription slide and its own label, named arrows
45
+ that set aria-disabled at an end (not disabled, so focus stays on the arrow),
46
+ and a polite, atomic status line that speaks only after a press that moves the
47
+ row. A key pressed in a form field, an arrow-key widget (a slider, a tab list,
48
+ a listbox...) or a nested carousel stays that element's. %>
49
+ <%
50
+ per_view = per_view.to_f.clamp(1, 6)
51
+ per_view = per_view.to_i if per_view == per_view.floor
52
+ items = items.to_a
53
+ total = items.size
54
+ spotlight = case style.to_s
55
+ when "standard" then false
56
+ when "spotlight" then true
57
+ else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
58
+ end
59
+ track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
60
+ %>
61
+ <%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), class_name].compact.join(" "),
62
+ role: "region",
63
+ aria: { roledescription: "carousel", label: label },
64
+ style: "--carousel-per-view: #{per_view}",
65
+ data: { studio_controller: "carousel", studio_action: "keydown->carousel#key" }.merge(data)) do %>
66
+ <%# The track takes focus for the keys, so it carries a name: a group, since a
67
+ bare div may not be named. %>
68
+ <div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
69
+ aria-label="<%= label %> slides" data-carousel-target="track">
70
+ <% items.each_with_index do |item, index| %>
71
+ <div class="carousel-slide" role="group" aria-roledescription="slide"
72
+ aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
73
+ data-carousel-target="slide"><% if spotlight %><div class="carousel-spotlight-body"><%= yield(item) %></div><% else %><%= yield(item) %><% end %><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
74
+ <% end %>
75
+ </div>
76
+ <div class="carousel-controls">
77
+ <div class="carousel-dots" role="group" aria-label="Choose a slide">
78
+ <% total.times do |index| %>
79
+ <button type="button" class="carousel-dot" aria-label="Go to slide <%= index + 1 %>"
80
+ aria-controls="<%= track_id %>"<%= ' aria-current="true"'.html_safe if index.zero? %>
81
+ data-carousel-target="dot" data-carousel-index="<%= index %>"
82
+ data-studio-action="click->carousel#goTo"></button>
83
+ <% end %>
84
+ </div>
85
+ <div class="carousel-arrows">
86
+ <button type="button" class="carousel-button" aria-label="Previous slides" aria-controls="<%= track_id %>"
87
+ aria-disabled="true" data-carousel-target="prev" data-studio-action="click->carousel#prev">
88
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12.5 15 7.5 10l5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
89
+ </button>
90
+ <button type="button" class="carousel-button" aria-label="Next slides" aria-controls="<%= track_id %>"
91
+ data-carousel-target="next" data-studio-action="click->carousel#next">
92
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m7.5 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
93
+ </button>
94
+ </div>
95
+ </div>
96
+ <p class="sr-only" role="status" aria-live="polite" aria-atomic="true" data-carousel-target="status"></p>
97
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%#
2
+ Fizz around any button: bubbles that rise from behind it. Render it as a layout,
3
+ with the button as the block.
4
+
5
+ render layout: "studio/fizz", locals: { seed: "save" } do
6
+ <button class="btn btn-primary">Save</button>
7
+ end
8
+
9
+ Locals:
10
+ seed - the string the bubble table is seeded from, so the scatter is stable
11
+ for this button and differs from its neighbours
12
+ level - :lively (default), which boils at rest and adds a layer on hover, or
13
+ :calm, which simmers at rest and boils on hover
14
+ colors - optional palette, up to 18, three per zone (--fizz-c-1..18)
15
+
16
+ Decoration only. The hold button keeps its own stack (studio/_hold_button).
17
+ %>
18
+ <% lively = local_assigns.fetch(:level, :lively).to_s == "lively" %>
19
+ <% fizz_style = fizz_stack_style(local_assigns[:colors]) %>
20
+ <span class="fizz-stack<%= " fizz-lively" if lively %>"<% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
21
+ <%= render "studio/fizz_layer", seed: seed, neutral: true %>
22
+ <% if lively %>
23
+ <%= render "studio/fizz_layer", seed: "#{seed}~extra", extra: true, neutral: true %>
24
+ <% end %>
25
+ <%= yield %>
26
+ </span>
@@ -7,10 +7,13 @@
7
7
  in each other's gaps rather than on top of each other)
8
8
  extra - true for the hover-only layer, which rests invisible and fades in
9
9
  when the pointer arrives (default: false)
10
+ neutral - true inside a .fizz-stack (studio/_fizz), a button with no hold, so
11
+ the layer carries the neutral class names (default: false)
10
12
 
11
13
  The two layers also split each zone's three colour slots: the resting one
12
14
  takes the first, the hover one alternates the second and third.
13
15
  Decoration only: aria-hidden here, pointer-events off in CSS.
14
16
  %>
15
17
  <% extra ||= false %>
16
- <span class="hold-fizz<%= " hold-fizz-extra" if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
18
+ <% neutral ||= false %>
19
+ <span class="<%= neutral ? "fizz-layer" : "hold-fizz" %><%= (neutral ? " fizz-layer-extra" : " hold-fizz-extra") if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
@@ -74,8 +74,7 @@
74
74
  <% fizz_portal = fizz && local_assigns[:fizz_portal] %>
75
75
  <%# Static palette (gallery, or any call site with fixed colors). A runtime
76
76
  palette rides fizz_bind instead. %>
77
- <% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
78
- .each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";") %>
77
+ <% fizz_style = fizz_stack_style(fizz_colors) %>
79
78
 
80
79
  <%# The stack exists so the fizz can sit BEHIND the button. Inside the button
81
80
  a negative z-index still paints above its own background (the button's
@@ -1,20 +1,33 @@
1
- <%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
2
- <%# THE PAGE HEADER: the second layer of the triple stack (docs/PAGE_HEADER.md).
3
- navbar, then this row, then a table's sticky header, each below the last.
1
+ <%# locals: (title:, crumbs: [], crumb_label: nil, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, links: [], links_label: "More links", data: {}, row_class: "-mx-4 px-4") %>
2
+ <%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
3
+ navbar, then this row, then a section's header (studio/_section_header), then a
4
+ table's sticky header, each below the last.
4
5
 
5
6
  AT REST it has no background and no border: a title on the left, tabs on the
6
7
  right. ONCE STUCK under the navbar it takes the bar. Its box never changes
7
8
  between the two, so a state change moves nothing and republishes nothing.
8
9
 
9
10
  title the page's one h1.
11
+ crumbs the trail above the title: the page's ancestors, root first, as
12
+ [{ label:, href: }]. studio_crumbs builds it from the app's one
13
+ parent map (Studio::Crumbs). The trail ends with the page itself,
14
+ crumb_label or else the title. Below 40rem the trail gives way to
15
+ one link to the parent. Every ancestor link carries
16
+ data-page-transition-back, so the page transition plays the
17
+ app's move the other way (docs/PAGE_TRANSITIONS.md).
18
+ crumb_label the page's own name in the trail, when the title is long.
10
19
  meta short text beside the title, such as the version. Hidden on a
11
20
  phone, where the tabs need the width.
12
21
  meta_title the meta's title attribute.
13
- label the nav's accessible name. A row with tabs needs it.
22
+ label the tabs' nav's accessible name. A row with tabs needs it.
14
23
  tabs [{ label:, href:, current:, data: }]. `current` is the
15
24
  aria-current value ("page" or "true"), and blank on the others.
16
25
  tabs_data data attributes for the tabs' scroller.
17
- actions_html a control on the right, for a row with no tabs.
26
+ actions_html a control on the right, such as the page's one primary action.
27
+ After the tabs, when the row has both.
28
+ links the page's other links, [{ label:, href:, current: }], on a line of
29
+ their own at the foot of the row, so they stick with it.
30
+ links_label that line's accessible name.
18
31
  data data attributes for the row, such as the page's own controller.
19
32
  row_class the bleed. The default fits a container with px-4; pass "" in a
20
33
  container with no gutter of its own.
@@ -26,6 +39,9 @@
26
39
  <header> as the navbar, so on a page with no navbar this row would publish as
27
40
  --nav-bottom.
28
41
 
42
+ NOR IS IT A LANDMARK. The row is a div; the tabs, the crumbs and the links line
43
+ are each their own <nav>, side by side, so no landmark nests in another.
44
+
29
45
  IT JOINS THE STACK WITH data-pin="page" AND POSITIONS OFF --pin-page-top, the
30
46
  bottom of everything above it. --pin-stack-bottom includes the row's own edge,
31
47
  so a row positioned off it would chase itself down the page. Anything below,
@@ -39,11 +55,24 @@
39
55
  page's own. So the row carries no top margin: the space above it belongs to its
40
56
  container. %>
41
57
  <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
42
- <%= content_tag(tabs.any? ? :nav : :div,
43
- class: "page-header sticky #{row_class}".strip,
58
+ <%= tag.div(class: "page-header sticky #{row_class}".strip,
44
59
  style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
45
- aria: { label: label.presence },
46
60
  data: data.merge(pin: "page")) do %>
61
+ <% if crumbs.any? %>
62
+ <% parent = crumbs.last %>
63
+ <div class="page-header-trail">
64
+ <nav class="page-header-crumbs" aria-label="Breadcrumb">
65
+ <ol>
66
+ <% crumbs.each do |crumb| %>
67
+ <li><%= link_to crumb.fetch(:label), crumb.fetch(:href), class: "page-header-crumb", data: { page_transition_back: "" } %></li>
68
+ <% end %>
69
+ <li><span class="page-header-crumb-current" aria-current="page"><%= crumb_label.presence || title %></span></li>
70
+ </ol>
71
+ </nav>
72
+ <%= link_to parent.fetch(:href), class: "page-header-parent", data: { page_transition_back: "" },
73
+ aria: { label: "Back to #{parent.fetch(:label)}" } do %><span aria-hidden="true">&lsaquo;</span> <%= parent.fetch(:label) %><% end %>
74
+ </div>
75
+ <% end %>
47
76
  <div class="page-header-lead">
48
77
  <h1 class="page-header-title"><%= title %></h1>
49
78
  <% if meta.present? %>
@@ -51,17 +80,30 @@
51
80
  <% end %>
52
81
  </div>
53
82
  <% if tabs.any? %>
54
- <%= tag.div(class: "page-header-tabs", data: tabs_data) do %>
83
+ <%= tag.nav(class: "page-header-tabs", aria: { label: label.presence }, data: tabs_data) do %>
84
+ <%# The glide: one pill, drawn once, that CSS anchors to the tab with aria-current and
85
+ slides from tab to tab (engine.css, "The gliding pill"). Each tab keeps its own
86
+ fill where the browser has no anchor positioning. %>
55
87
  <div class="page-header-track">
56
88
  <% tabs.each do |tab| %>
57
- <%= link_to tab.fetch(:label), tab.fetch(:href),
58
- class: "btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
89
+ <%= link_to tab.fetch(:href),
90
+ class: "page-header-tab btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
59
91
  aria: { current: tab[:current].presence },
60
- data: tab.fetch(:data, {}) %>
92
+ data: tab.fetch(:data, {}) do %><span class="page-header-tab-label"><%= tab.fetch(:label) %></span><% end %>
61
93
  <% end %>
94
+ <span class="page-header-glide btn btn-sm btn-primary" aria-hidden="true"></span>
62
95
  </div>
63
96
  <% end %>
64
- <% elsif actions_html.present? %>
97
+ <% end %>
98
+ <% if actions_html.present? %>
65
99
  <div class="page-header-actions"><%= actions_html %></div>
66
100
  <% end %>
101
+ <% if links.any? %>
102
+ <nav class="page-header-links" aria-label="<%= links_label %>">
103
+ <% links.each do |link| %>
104
+ <%= link_to link.fetch(:label), link.fetch(:href), class: "page-header-link",
105
+ aria: { current: link[:current].presence }, data: link.fetch(:data, {}) %>
106
+ <% end %>
107
+ </nav>
108
+ <% end %>
67
109
  <% end %>
@@ -0,0 +1,23 @@
1
+ <%# locals: (title:, level: :h2, row_class: "-mx-4 px-4") %>
2
+ <%# THE SECTION HEADER: a section's title that sticks under the rest of the pinned stack
3
+ until the next section's header takes its place (docs/PAGE_HEADER.md).
4
+
5
+ RENDER IT AS THE FIRST DIRECT CHILD OF ITS <section>. A sticky element travels only
6
+ inside its parent, and that is what gives the swap: inside a wrapper div, it would
7
+ leave with the wrapper. No backdrop-filter, transform or overflow on an ancestor.
8
+
9
+ title the heading's text. The heading is the sticky element, so an intro can
10
+ follow as its next sibling.
11
+ level the tag. A page should stick one level only: one bar at a time.
12
+ row_class the bleed. The default fits a container with px-4, as the page row's does.
13
+
14
+ AT REST a normal title. STUCK it takes the bar. Its box never changes between the two.
15
+
16
+ IT JOINS THE STACK WITH data-pin-section, NOT data-pin. Every header on the page shares
17
+ one layer, `section`, and only the stuck one counts (studio/pinned_stack, stuckSection).
18
+ So it positions off --pin-section-top, which also stands when none is stuck. Anything
19
+ under the stack, such as a table's sticky header, sits under it with no edit. %>
20
+ <%= content_tag(level, title,
21
+ class: "section-header #{row_class}".strip,
22
+ style: "top: var(--pin-section-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: calc(var(--z-sticky, 30) - 1)",
23
+ data: { pin_section: "" }) %>
@@ -1,4 +1,4 @@
1
- <%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil, voice: false, voice_copy: nil) %>
1
+ <%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil, voice: false, voice_copy: nil, compact: false) %>
2
2
  <%#
3
3
  The shared agent chat (docs/AGENT_CHAT.md): one conversation with one agent,
4
4
  held by the hub, on any app's page.
@@ -19,6 +19,9 @@
19
19
  Studio.voice_chat_allowed lets talk; never to a signed-out visitor.
20
20
  voice_copy the words: { privacy:, recorder:, chip: }, each optional, over the
21
21
  defaults (Studio::AgentChat.voice_copy).
22
+ compact true for a chat inside a card (studio/agent_chat/_talk_modal): no
23
+ big portrait head (the card has its own), no page padding, and a
24
+ shorter thread.
22
25
 
23
26
  Top to bottom, as the hub's /build: the portrait and name, the card slot, the
24
27
  thread, the typing line, the offline notice and the composer. The thread is
@@ -32,19 +35,19 @@
32
35
  <% souls = Studio.agent_chat_soul_list %>
33
36
  <% raise ArgumentError, "studio/agent_chat/chat: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
34
37
  <% if Studio.agent_chat_allowed?(studio_agent_chat_viewer) %>
35
- <section class="w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0"
38
+ <section class="<%= compact ? "w-full flex flex-col gap-3 min-w-0" : "w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0" %>"
36
39
  data-studio-controller="agent-chat" data-test="agent-chat"
37
40
  data-agent-chat-soul="<%= soul %>" data-agent-chat-name="<%= name %>"
38
41
  data-agent-chat-messages-url="<%= studio_agent_chat_messages_path %>"
39
42
  data-agent-chat-turns-url="<%= studio_agent_chat_turns_path %>">
40
- <%= render "studio/agent_chat/header", name: name, portrait: portrait, role: role %>
43
+ <%= render "studio/agent_chat/header", name: name, portrait: portrait, role: role unless compact %>
41
44
  <% if cards.present? %>
42
45
  <div class="flex flex-col gap-3 min-w-0" data-agent-chat-cards data-test="agent-chat-cards"><%= cards %></div>
43
46
  <% end %>
44
47
  <div class="flex flex-col gap-3 min-w-0">
45
48
  <%# relative: a turn's sr-only name is absolutely positioned. With no positioned ancestor it escapes this scroller, is not clipped by it, and stretches the document by the thread's whole scrolled height. %>
46
49
  <div role="log" aria-live="polite" aria-label="Conversation with <%= name %>"
47
- class="relative flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
50
+ class="relative flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain <%= compact ? "min-h-24 max-h-[45vh]" : "max-h-[60vh]" %> min-w-0"
48
51
  data-agent-chat-log data-test="agent-chat-messages">
49
52
  <p class="hidden only:block text-center text-secondary text-sm py-6" data-test="agent-chat-empty"><%= empty.presence || "Say hello to #{name}." %></p>
50
53
  </div>
@@ -0,0 +1,58 @@
1
+ <%# locals: (soul:, name:, portrait: nil, role: nil, store: "studioTalkToAgent") %>
2
+ <%#
3
+ The talk-to-agent modal (docs/AGENT_CHAT.md, "Talk to an agent in a modal"):
4
+ the shared agent chat (studio/agent_chat/_chat) in a card on the page-scoped
5
+ modal host (studio/modals/_scoped_host). Render it ONCE per page, then mark any
6
+ link with studio_talk_to_agent_link (Studio::AgentChatHelper), or by hand with
7
+ data-studio-talk-to-agent, data-talk-soul, data-talk-topic and an optional
8
+ data-talk-opener.
9
+
10
+ soul the agent's slug; must be on Studio.agent_chat_souls.
11
+ name the agent's display name.
12
+ portrait ready markup for a small round portrait (40px works); nil shows the initial.
13
+ role ready markup or text beside the name.
14
+ store the Alpine store the host registers; unique on the page, never "modals".
15
+
16
+ A click opens the card and studio/talk_to_agent clones the chat out of the
17
+ <template> below into it, with the link's opener: the chat loads the thread
18
+ and sends the opener, so the agent is already answering as the card animates
19
+ in, and each new answer types itself in. The chat is the same one as anywhere
20
+ else (one thread per person and soul, held by the hub): this partial adds a
21
+ card and an opener, never a second chat. One page holds one chat, so the page
22
+ that renders this modal renders no other studio/agent_chat/chat.
23
+
24
+ A viewer Studio.agent_chat_allowed refuses gets nothing here, and every link
25
+ then opens its href. The x-data on the wrapper is load-bearing: the scoped host
26
+ declares no Alpine scope of its own.
27
+ %>
28
+ <% souls = Studio.agent_chat_soul_list %>
29
+ <% raise ArgumentError, "studio/agent_chat/talk_modal: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
30
+ <% if Studio.agent_chat_allowed?(studio_agent_chat_viewer) %>
31
+ <div x-data data-studio-controller="talk-to-agent" data-test="talk-to-agent"
32
+ data-talk-to-agent-soul="<%= soul %>" data-talk-to-agent-name="<%= name %>" data-talk-to-agent-store="<%= store %>">
33
+ <%= render "studio/modals/scoped_host", store: store,
34
+ card_class: "bg-surface rounded-2xl border border-subtle shadow-2xl w-full max-w-lg p-4 sm:p-5" do %>
35
+ <template x-if="$store.<%= store %>.current()?.id === 'talk-to-agent'">
36
+ <div class="flex flex-col gap-3 min-w-0" data-talk-to-agent-panel data-test="talk-to-agent-panel"
37
+ x-init="$nextTick(() => $dispatch('studio:talk-to-agent:panel'))">
38
+ <div class="flex items-center gap-3 min-w-0">
39
+ <span class="w-10 h-10 shrink-0 rounded-full overflow-hidden bg-inset border border-subtle flex items-center justify-center text-heading font-bold [&>img]:w-full [&>img]:h-full [&>img]:object-cover" aria-hidden="true">
40
+ <% if portrait.present? %><%= portrait %><% else %><%= name.to_s.first.to_s.upcase %><% end %>
41
+ </span>
42
+ <div class="min-w-0 flex-1">
43
+ <p class="font-semibold text-heading break-words" data-talk-to-agent-title data-test="talk-to-agent-title">Talk to <%= name %></p>
44
+ <% if role.present? %><p class="text-xs text-secondary break-words"><%= role %></p><% end %>
45
+ </div>
46
+ <button type="button" class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-secondary hover:bg-inset hover:text-heading transition-colors" aria-label="Close"
47
+ @click="$store.<%= store %>.close()" data-test="talk-to-agent-close">
48
+ <svg viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg>
49
+ </button>
50
+ </div>
51
+ <div class="min-w-0" data-talk-to-agent-slot data-test="talk-to-agent-slot"></div>
52
+ </div>
53
+ </template>
54
+ <% end %>
55
+ <template data-talk-to-agent-chat><%= render "studio/agent_chat/chat", soul: soul, name: name, portrait: portrait, compact: true,
56
+ empty: "#{name} will answer here in a moment." %></template>
57
+ </div>
58
+ <% end %>