studio-engine 0.130.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +20 -2
|
@@ -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 %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
|
|
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
2
|
<%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
|
|
3
3
|
navbar, then this row, then a section's header (studio/_section_header), then a
|
|
4
4
|
table's sticky header, each below the last.
|
|
@@ -8,14 +8,26 @@
|
|
|
8
8
|
between the two, so a state change moves nothing and republishes nothing.
|
|
9
9
|
|
|
10
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.
|
|
11
19
|
meta short text beside the title, such as the version. Hidden on a
|
|
12
20
|
phone, where the tabs need the width.
|
|
13
21
|
meta_title the meta's title attribute.
|
|
14
|
-
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.
|
|
15
23
|
tabs [{ label:, href:, current:, data: }]. `current` is the
|
|
16
24
|
aria-current value ("page" or "true"), and blank on the others.
|
|
17
25
|
tabs_data data attributes for the tabs' scroller.
|
|
18
|
-
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.
|
|
19
31
|
data data attributes for the row, such as the page's own controller.
|
|
20
32
|
row_class the bleed. The default fits a container with px-4; pass "" in a
|
|
21
33
|
container with no gutter of its own.
|
|
@@ -27,6 +39,9 @@
|
|
|
27
39
|
<header> as the navbar, so on a page with no navbar this row would publish as
|
|
28
40
|
--nav-bottom.
|
|
29
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
|
+
|
|
30
45
|
IT JOINS THE STACK WITH data-pin="page" AND POSITIONS OFF --pin-page-top, the
|
|
31
46
|
bottom of everything above it. --pin-stack-bottom includes the row's own edge,
|
|
32
47
|
so a row positioned off it would chase itself down the page. Anything below,
|
|
@@ -40,11 +55,24 @@
|
|
|
40
55
|
page's own. So the row carries no top margin: the space above it belongs to its
|
|
41
56
|
container. %>
|
|
42
57
|
<div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
|
|
43
|
-
<%=
|
|
44
|
-
class: "page-header sticky #{row_class}".strip,
|
|
58
|
+
<%= tag.div(class: "page-header sticky #{row_class}".strip,
|
|
45
59
|
style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
|
|
46
|
-
aria: { label: label.presence },
|
|
47
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 %>
|
|
48
76
|
<div class="page-header-lead">
|
|
49
77
|
<h1 class="page-header-title"><%= title %></h1>
|
|
50
78
|
<% if meta.present? %>
|
|
@@ -52,17 +80,30 @@
|
|
|
52
80
|
<% end %>
|
|
53
81
|
</div>
|
|
54
82
|
<% if tabs.any? %>
|
|
55
|
-
<%= 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. %>
|
|
56
87
|
<div class="page-header-track">
|
|
57
88
|
<% tabs.each do |tab| %>
|
|
58
|
-
<%= link_to tab.fetch(:
|
|
59
|
-
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'}",
|
|
60
91
|
aria: { current: tab[:current].presence },
|
|
61
|
-
data: tab.fetch(:data, {}) %>
|
|
92
|
+
data: tab.fetch(:data, {}) do %><span class="page-header-tab-label"><%= tab.fetch(:label) %></span><% end %>
|
|
62
93
|
<% end %>
|
|
94
|
+
<span class="page-header-glide btn btn-sm btn-primary" aria-hidden="true"></span>
|
|
63
95
|
</div>
|
|
64
96
|
<% end %>
|
|
65
|
-
<%
|
|
97
|
+
<% end %>
|
|
98
|
+
<% if actions_html.present? %>
|
|
66
99
|
<div class="page-header-actions"><%= actions_html %></div>
|
|
67
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 %>
|
|
68
109
|
<% end %>
|
|
@@ -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 %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<%# locals: (turn:, name:) %>
|
|
2
|
-
<%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader.
|
|
2
|
+
<%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader. Never HTML: the script sets a visitor's words as text and builds an agent's small Markdown subset as nodes (studio/agent_chat, MARKDOWN). This partial renders a real turn's words as text. A failed turn says so. A held one (stored, no automatic answer coming) says that, and never asks for a resend. %>
|
|
3
3
|
<% agent = turn["role"].to_s == "agent" %>
|
|
4
4
|
<% status = turn["status"].to_s %>
|
|
5
5
|
<div class="flex flex-col gap-2 min-w-0" data-agent-chat-turn data-role="<%= agent ? "agent" : "user" %>"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# locals: (id:, page:, next_url:, more_label:, end_label:, announce:, items:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
Infinite scroll (Studio::LoaderHelper#studio_infinite_scroll, docs/LOADER.md).
|
|
4
|
+
Every page renders this whole wrapper, so a page opened with no script is a
|
|
5
|
+
complete page. Page 2 and later put their items in the frame the page before
|
|
6
|
+
left for them; Turbo lifts just that frame out of the response, and the frame
|
|
7
|
+
takes no box (display: contents), so the items join the list in place.
|
|
8
|
+
|
|
9
|
+
The next page's frame holds an ordinary link. A click loads the frame; with no
|
|
10
|
+
script it opens the page. studio/infinite_scroll clicks it early, as the frame
|
|
11
|
+
nears the viewport, and says on the status line how many items came.
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
extra = html.dup
|
|
15
|
+
data = (extra.delete(:data) || {}).merge(studio_controller: "infinite-scroll",
|
|
16
|
+
infinite_scroll_announce: announce,
|
|
17
|
+
infinite_scroll_end: end_label)
|
|
18
|
+
classes = [ "studio-infinite-scroll", extra.delete(:class) ].compact.join(" ")
|
|
19
|
+
body = capture do
|
|
20
|
+
concat items
|
|
21
|
+
if next_url
|
|
22
|
+
concat(content_tag("turbo-frame", id: studio_infinite_scroll_frame_id(id, page + 1),
|
|
23
|
+
class: "studio-infinite-scroll-next", data: { infinite_scroll_next: "" }) do
|
|
24
|
+
tag.div(class: "studio-infinite-scroll-more") do
|
|
25
|
+
link_to(next_url, class: "btn btn-secondary", data: { infinite_scroll_more: "" }) do
|
|
26
|
+
safe_join([ tag.span(more_label), studio_loader_pending(style: :spinner, size: 16) ])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end)
|
|
30
|
+
elsif end_label.present?
|
|
31
|
+
concat tag.p(end_label, class: "studio-infinite-scroll-end", data: { infinite_scroll_done: "" })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
%>
|
|
35
|
+
<%= tag.div(id: id, class: classes, data: data, **extra) do %>
|
|
36
|
+
<% if page > 1 %>
|
|
37
|
+
<%= content_tag("turbo-frame", body, id: studio_infinite_scroll_frame_id(id, page)) %>
|
|
38
|
+
<% else %>
|
|
39
|
+
<%= body %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<p class="sr-only" role="status" aria-live="polite" data-infinite-scroll-status></p>
|
|
42
|
+
<% end %>
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
A pending loader sits inside a button: hidden from assistive tech (the button
|
|
9
9
|
carries aria-busy) and from sight until the button or its form is busy.
|
|
10
|
+
|
|
11
|
+
The plain styles (spinner, dots, bar) draw no icon. They wrap the engine's
|
|
12
|
+
canonical motion primitives (.spinner, .loading-dots, .progress-meter), so the
|
|
13
|
+
family and those classes are one set of rules. A bar is a progressbar: its
|
|
14
|
+
label is its name, and a percent is its value; with none it sweeps and has no
|
|
15
|
+
value, which is how an indeterminate progressbar reads.
|
|
10
16
|
%>
|
|
11
17
|
<%
|
|
12
18
|
extra = html.dup
|
|
@@ -28,9 +34,14 @@
|
|
|
28
34
|
tag.img(src: icon, alt: "", class: css, draggable: "false", decoding: "async")
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
|
-
|
|
37
|
+
bar = style == :bar
|
|
38
|
+
role = pending ? nil : (bar ? "progressbar" : "status")
|
|
39
|
+
aria = if pending then { hidden: "true" }
|
|
40
|
+
elsif bar then { label: label.presence || "Loading", valuemin: 0, valuemax: 100, valuenow: (percent && (percent % 1).zero? ? percent.to_i : percent) }.compact
|
|
41
|
+
else { live: "polite" }
|
|
42
|
+
end
|
|
32
43
|
%>
|
|
33
|
-
<%= tag.span(class: classes, role:
|
|
44
|
+
<%= tag.span(class: classes, role: role, aria: aria, data: data, style: vars.join("; "), **extra) do %>
|
|
34
45
|
<% case style %>
|
|
35
46
|
<% when :breathe %>
|
|
36
47
|
<span class="studio-loader-halo" aria-hidden="true"></span>
|
|
@@ -62,6 +73,12 @@
|
|
|
62
73
|
<% end %>
|
|
63
74
|
<% end %>
|
|
64
75
|
</svg>
|
|
76
|
+
<% when :spinner %>
|
|
77
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
78
|
+
<% when :dots %>
|
|
79
|
+
<span class="loading-dots" aria-hidden="true"><span></span><span></span><span></span></span>
|
|
80
|
+
<% when :bar %>
|
|
81
|
+
<span class="progress-meter<%= " progress-meter--indeterminate" if percent.nil? %>" aria-hidden="true"><span class="progress-meter-fill"></span></span>
|
|
65
82
|
<% when :sparkle %>
|
|
66
83
|
<%= draw_icon.call %>
|
|
67
84
|
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
@@ -71,5 +88,5 @@
|
|
|
71
88
|
<% end %>
|
|
72
89
|
</svg>
|
|
73
90
|
<% end %>
|
|
74
|
-
<% unless pending || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
91
|
+
<% unless pending || bar || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
75
92
|
<% end %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# locals: (frame:, label:, body:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
Pull to refresh (Studio::LoaderHelper#studio_pull_to_refresh, docs/LOADER.md).
|
|
4
|
+
The indicator is hidden until a finger pulls; studio/pull_to_refresh arms only
|
|
5
|
+
on a phone-width touch screen, so elsewhere this is a plain wrapper. The
|
|
6
|
+
status line says the label while a refresh runs.
|
|
7
|
+
%>
|
|
8
|
+
<%
|
|
9
|
+
extra = html.dup
|
|
10
|
+
data = (extra.delete(:data) || {}).merge(studio_controller: "pull-to-refresh", pull_to_refresh_label: label)
|
|
11
|
+
data[:pull_to_refresh_frame] = frame if frame.present?
|
|
12
|
+
classes = [ "studio-pull-refresh", extra.delete(:class) ].compact.join(" ")
|
|
13
|
+
%>
|
|
14
|
+
<%= tag.div(class: classes, data: data, **extra) do %>
|
|
15
|
+
<span class="studio-pull-refresh-indicator" aria-hidden="true" hidden>
|
|
16
|
+
<span class="spinner studio-pull-refresh-spinner" style="--spinner-size: 1.25rem; --spinner-color: currentColor; --spinner-track: color-mix(in srgb, currentColor 22%, transparent)"></span>
|
|
17
|
+
</span>
|
|
18
|
+
<p class="sr-only" role="status" data-pull-to-refresh-status></p>
|
|
19
|
+
<%= body %>
|
|
20
|
+
<% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# locals: (kind:, count:, lines:, label:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
A skeleton (Studio::LoaderHelper#studio_skeleton, docs/LOADER.md): grey shapes
|
|
4
|
+
of the content to come. The wrapper is aria-busy, so assistive tech knows the
|
|
5
|
+
region is not ready; each item is aria-hidden, since a shape means nothing
|
|
6
|
+
read aloud. One line of screen-reader text names the wait. The shimmer is
|
|
7
|
+
engine-motion.css ("Loader family"), and stops under reduced motion.
|
|
8
|
+
%>
|
|
9
|
+
<%
|
|
10
|
+
extra = html.dup
|
|
11
|
+
data = (extra.delete(:data) || {}).merge(skeleton: kind.to_s)
|
|
12
|
+
classes = [ "studio-skeleton", extra.delete(:class) ].compact.join(" ")
|
|
13
|
+
draw_lines = lambda do |total|
|
|
14
|
+
safe_join(Array.new(total) { |i| tag.span(class: "studio-skeleton-bone", data: { bone: "line" }, style: "--studio-skeleton-width: #{studio_skeleton_width(i, total)}") })
|
|
15
|
+
end
|
|
16
|
+
%>
|
|
17
|
+
<%= tag.div(class: classes, aria: { busy: "true" }, data: data, **extra) do %>
|
|
18
|
+
<% if label.present? %><span class="sr-only"><%= label %></span><% end %>
|
|
19
|
+
<% count.times do %>
|
|
20
|
+
<div class="studio-skeleton-item" data-skeleton-kind="<%= kind %>" aria-hidden="true">
|
|
21
|
+
<% case kind %>
|
|
22
|
+
<% when :avatar %>
|
|
23
|
+
<span class="studio-skeleton-bone" data-bone="avatar"></span>
|
|
24
|
+
<span class="studio-skeleton-lines"><%= draw_lines.call([ lines, 1 ].max) %></span>
|
|
25
|
+
<% when :card %>
|
|
26
|
+
<span class="studio-skeleton-bone" data-bone="media"></span>
|
|
27
|
+
<span class="studio-skeleton-bone" data-bone="title"></span>
|
|
28
|
+
<%= draw_lines.call(lines) %>
|
|
29
|
+
<% when :media %>
|
|
30
|
+
<span class="studio-skeleton-bone" data-bone="media"></span>
|
|
31
|
+
<%= draw_lines.call(lines) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= draw_lines.call([ lines, 1 ].max) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|