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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +192 -0
- data/README.md +48 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
- data/app/assets/tailwind/studio_engine/engine.css +737 -19
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/fizz_helper.rb +5 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pinned_stack.js +112 -4
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_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 +55 -13
- data/app/views/studio/_section_header.html.erb +23 -0
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_components.html.erb +3 -5
- data/app/views/style/_host.html.erb +3 -6
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +15 -5
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_specimen.html.erb +21 -10
- data/app/views/style/_tasks.html.erb +3 -5
- data/app/views/style/_theme.html.erb +2 -2
- data/app/views/style/_tricks.html.erb +218 -217
- data/app/views/style/index.html.erb +21 -11
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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">×</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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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 =
|
|
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
|
|
3
|
-
navbar, then this row, then a
|
|
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,
|
|
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
|
-
<%=
|
|
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">‹</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.
|
|
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(:
|
|
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
|
-
<%
|
|
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 %>
|