studio-engine 0.127.0 → 0.129.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/README.md +9 -3
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
  5. data/app/assets/tailwind/studio_engine/engine.css +572 -37
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/controllers/studio/surveys_controller.rb +16 -3
  8. data/app/helpers/studio/loader_helper.rb +2 -2
  9. data/app/helpers/studio_icon_helper.rb +10 -0
  10. data/app/javascript/studio/agent_chat.js +88 -6
  11. data/app/javascript/studio/agent_chat_voice.js +59 -5
  12. data/app/javascript/studio/alpine_stores.js +80 -22
  13. data/app/javascript/studio/loader.js +130 -5
  14. data/app/javascript/studio/microphone_chip.js +28 -2
  15. data/app/javascript/studio/page_transitions.js +28 -19
  16. data/app/javascript/studio/survey.js +16 -3
  17. data/app/javascript/studio/theme_editor.js +4 -0
  18. data/app/javascript/studio/voice_recorder.js +18 -7
  19. data/app/models/studio/survey_response.rb +24 -17
  20. data/app/views/components/_geo_signpost.html.erb +1 -0
  21. data/app/views/components/_link_sidebar.html.erb +40 -12
  22. data/app/views/components/_sidebar_panel.html.erb +15 -5
  23. data/app/views/layouts/studio/_head.html.erb +18 -7
  24. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  25. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  26. data/app/views/registrations/new.html.erb +4 -3
  27. data/app/views/sessions/new.html.erb +4 -3
  28. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  29. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  30. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  31. data/app/views/studio/loader/_overlay.html.erb +4 -4
  32. data/app/views/style/_components.html.erb +2 -4
  33. data/app/views/style/_glass.html.erb +83 -0
  34. data/app/views/style/_host.html.erb +2 -10
  35. data/app/views/style/_modals.html.erb +38 -231
  36. data/app/views/style/_scrims.html.erb +2 -9
  37. data/app/views/style/_site_footer.html.erb +5 -13
  38. data/app/views/style/_tasks.html.erb +16 -27
  39. data/app/views/style/_theme.html.erb +56 -78
  40. data/app/views/style/_tricks.html.erb +13 -11
  41. data/app/views/style/_type_ladder.html.erb +33 -0
  42. data/app/views/style/index.html.erb +5 -1
  43. data/docs/SITE_FOOTER.md +1 -1
  44. data/lib/studio/agent_chat.rb +4 -2
  45. data/lib/studio/icons/README.md +12 -0
  46. data/lib/studio/icons/lucide/LICENSE +43 -0
  47. data/lib/studio/icons/lucide/activity.svg +15 -0
  48. data/lib/studio/icons/lucide/book-open.svg +16 -0
  49. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  50. data/lib/studio/icons/lucide/diamond.svg +15 -0
  51. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  52. data/lib/studio/icons/lucide/globe.svg +17 -0
  53. data/lib/studio/icons/lucide/hammer.svg +17 -0
  54. data/lib/studio/icons/lucide/inbox.svg +16 -0
  55. data/lib/studio/icons/lucide/key-round.svg +16 -0
  56. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  57. data/lib/studio/icons/lucide/log-out.svg +17 -0
  58. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  59. data/lib/studio/icons/lucide/package.svg +18 -0
  60. data/lib/studio/icons/lucide/palette.svg +19 -0
  61. data/lib/studio/icons/lucide/plug.svg +18 -0
  62. data/lib/studio/icons/lucide/rocket.svg +18 -0
  63. data/lib/studio/icons/lucide/user.svg +16 -0
  64. data/lib/studio/icons.rb +52 -0
  65. data/lib/studio/loader.rb +7 -0
  66. data/lib/studio/sidebar_sections.rb +52 -4
  67. data/lib/studio/site_footer.rb +23 -2
  68. data/lib/studio/version.rb +1 -1
  69. data/lib/studio.rb +54 -2
  70. metadata +24 -1
@@ -5,6 +5,15 @@
5
5
  positions against the header, not the viewport); the engine navbar does
6
6
  this automatically when sections are declared. %>
7
7
  <% sections = studio_sidebar_sections %>
8
+ <%# THE PANEL'S STYLE (Studio.sidebar_style). Under :glass a row draws its link's line icon
9
+ and the row for this page says aria-current, which is what lights it. The default style
10
+ reads neither, so an app that stays on it renders what it did. A caller may pass
11
+ sidebar_style: for one render; the engine lab does, since the setting is process-wide. %>
12
+ <% glass = (local_assigns[:sidebar_style] || Studio.sidebar_style) == :glass %>
13
+ <% sidebar_request = glass && respond_to?(:request) ? request : nil %>
14
+ <%# The engine's Geo row counts too, so no section row above it claims /admin/geo as its own. %>
15
+ <% current_rows = respond_to?(:admin_geo_path) ? sections + [{ links: [{ href: admin_geo_path }] }] : sections %>
16
+ <% current_href = sidebar_request && Studio::SidebarSections.current_href(current_rows, sidebar_request.path, host: sidebar_request.host) %>
8
17
  <% sidebar_admin = respond_to?(:admin?) && admin? %>
9
18
  <%# THE PANEL'S TITLE. Signed in, admin included: the name the navbar shows, from the
10
19
  same call. Signed out: a greeting by the browser's local time, which the server
@@ -24,18 +33,28 @@
24
33
  elsif !sidebar_signed_in
25
34
  tag.p("Good morning", class: sidebar_title_class, data: { link_sidebar_title: "", link_sidebar_greeting: "" })
26
35
  end %>
27
- <%# THE GROUPS. No section shows a label word and none shows the admin chip: a divider
28
- separates the groups. The words stay as each group's accessible name, so a screen
29
- reader still announces them. An empty section draws no group, or its two dividers
30
- would touch.
36
+ <%# THE GROUPS. A divider separates them, and by default a group shows no label word. The
37
+ words stay as each group's accessible name, so a screen reader still announces them.
38
+ An empty section draws no group, or its two dividers would touch. No group shows the
39
+ admin chip.
40
+
41
+ A SECTION MAY OPT IN TO A VISIBLE HEADER with header: true: its title, in the small
42
+ label style, above its rows. The word on screen is hidden from assistive technology,
43
+ because the group's aria-label already says it. The label is not an id reference:
44
+ body_html renders in both panels, so an id here would be a duplicate. The header is
45
+ plain text, outside every row, so it takes no click and no row measures against it.
46
+ Its pt-3 and the group's py-1 make the 16px the old label had above it.
31
47
 
32
48
  A ROW IS ONE LINE AT REST, with or without a desc:, so the spacing never changes. A
33
49
  desc: fades in on hover or keyboard focus, and nothing moves to make room: it sits
34
50
  absolutely in the row's padding as the label lifts by a transform. engine.css, THE
35
51
  SIDEBAR ROW'S DESCRIPTION, holds the rules. A row with no desc: gains no attribute. %>
36
- <% sidebar_group = lambda do |title:, admin:, first:, &block|
52
+ <% sidebar_group = lambda do |title:, admin:, first:, header: false, &block|
37
53
  name = [title.presence, ("admin" if admin && !title.to_s.match?(/\badmin\b/i))].compact.join(", ").presence
38
- tag.div(role: "group", aria: { label: name }, class: ["py-1", ("border-t border-subtle" unless first)], &block)
54
+ header_html = tag.div(title, class: "px-4 pt-3 pb-1 label-upper font-bold truncate", aria: { hidden: true }, data: { sidebar_group_header: "" }) if header && title.present?
55
+ tag.div(role: "group", aria: { label: name }, class: ["py-1", ("border-t border-subtle" unless first || glass)]) do
56
+ safe_join([header_html, capture(&block)].compact)
57
+ end
39
58
  end %>
40
59
  <% close_action = "$store.sidebars.linkTreeOpen = false" %>
41
60
 
@@ -55,16 +74,20 @@
55
74
  <% body_html = capture do %>
56
75
  <nav class="flex-1 min-h-0 overflow-y-auto py-1" aria-label="Studio links">
57
76
  <% sections.select { |section| section[:links].any? }.each_with_index do |section, index| %>
58
- <%= sidebar_group.call(title: section[:title], admin: section[:admin], first: index.zero?) do %>
77
+ <%= sidebar_group.call(title: section[:title], admin: section[:admin], first: index.zero?, header: section[:header]) do %>
59
78
  <% section[:links].each do |link| %>
60
79
  <% target = link[:target].presence %>
80
+ <% line_icon = (studio_icon(link[:icon]) if glass && link[:icon].present?) %>
61
81
  <%= link_to link[:href],
62
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",
63
83
  target: target,
64
84
  rel: target == "_blank" ? "noopener" : nil,
65
85
  "data-sidebar-row-described": (link[:desc].present? ? "" : nil),
86
+ "aria-current": (current_href.present? && link[:href] == current_href ? "page" : nil),
66
87
  "@click": close_action do %>
67
- <% if link[:hover_emoji].present? %>
88
+ <% if line_icon %>
89
+ <span class="flex-shrink-0" data-sidebar-row-icon><%= line_icon %></span>
90
+ <% elsif link[:hover_emoji].present? %>
68
91
  <%= render "components/emoji_swap",
69
92
  base: link[:emoji],
70
93
  hover: link[:hover_emoji],
@@ -119,8 +142,11 @@
119
142
  rather than called: this partial renders in hosts and test rigs that
120
143
  provide studio_sidebar_sections without the rest of the helper, and a
121
144
  bare call would raise for them. The two must stay the same question —
122
- if the helper's rule changes, this changes with it. %>
123
- <% if sidebar_admin && sections.any? { |section| section[:admin] } %>
145
+ if the helper's rule changes, this changes with it.
146
+
147
+ A host that moves Geo to its own page sets Studio.sidebar_geo_signpost = false
148
+ and renders components/_geo_signpost there. %>
149
+ <% if Studio.sidebar_geo_signpost && sidebar_admin && sections.any? { |section| section[:admin] } %>
124
150
  <%= sidebar_group.call(title: "Geo", admin: true, first: false) do %>
125
151
  <%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
126
152
  <% end %>
@@ -130,11 +156,11 @@
130
156
 
131
157
  <% footer_html = capture do %>
132
158
  <% if logged_in? %>
133
- <div class="border-t border-subtle p-2">
159
+ <div class="border-t border-subtle p-2"<% if glass %> data-sidebar-glass-foot<% end %>>
134
160
  <%= link_to logout_path,
135
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",
136
162
  "@click": close_action do %>
137
- <span>👋</span><span>Log out</span>
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>
138
164
  <% end %>
139
165
  </div>
140
166
  <% end %>
@@ -151,6 +177,7 @@
151
177
  escape_action: close_action,
152
178
  display_class: "hidden md:flex",
153
179
  edge: true,
180
+ glass: glass,
154
181
  role: "navigation",
155
182
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
156
183
 
@@ -166,5 +193,6 @@
166
193
  display_class: "flex md:hidden",
167
194
  width_class: "w-full",
168
195
  edge: true,
196
+ glass: glass,
169
197
  role: "navigation",
170
198
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
@@ -19,7 +19,7 @@
19
19
  would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
20
20
  TIMELINE) moves it with the header on the scroll timeline instead.
21
21
 
22
- Three opt-in locals (unset, the panel renders as it did in 0.113.0):
22
+ Four opt-in locals (unset, the panel renders as it did in 0.113.0):
23
23
  title_html the header's LEAD SLOT: HTML in the title's place, for a panel
24
24
  whose lead is a live status line rather than a label. It is
25
25
  content, NOT a name: it never feeds aria-labelledby or
@@ -30,6 +30,11 @@
30
30
  panel's inner edge, drawn by engine.css (THE SIDEBAR'S EDGE) from md
31
31
  up. It softens the page beside the panel, so it suits a menu that
32
32
  opens over the page, not a panel kept open beside live content.
33
+ glass true floats an inset, translucent panel (engine.css, THE GLASS SIDEBAR).
34
+ The aside keeps its geometry, its slide and its pinned edge, and turns
35
+ into a clear frame around an inner shell, which holds the blur. A
36
+ backdrop-filter makes a containing block, and this keeps it off the
37
+ element Alpine and Turbo move. Glass ignores `edge`: the whole panel blurs.
33
38
  close_hook true stamps the close button data-sidebar-panel-close="<id>"
34
39
  too, so a host's own document handler can match the button
35
40
  to its panel by id. Needs `id`; without one it adds nothing.
@@ -53,7 +58,9 @@
53
58
  <% aria_label = local_assigns.fetch(:aria_label, title) %>
54
59
  <% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
55
60
  <% close_hook = local_assigns.fetch(:close_hook, false) && id.present? %>
56
- <% edge = local_assigns.fetch(:edge, false) %>
61
+ <% glass = local_assigns.fetch(:glass, false) %>
62
+ <% edge = local_assigns.fetch(:edge, false) && !glass %>
63
+ <% surface_class = glass ? "" : "bg-surface border-l border-subtle shadow-2xl " %>
57
64
 
58
65
  <aside
59
66
  <% if id.present? %>id="<%= id %>"<% end %>
@@ -78,17 +85,20 @@
78
85
  x-transition:leave="transition ease-in duration-200"
79
86
  x-transition:leave-start="translate-x-0"
80
87
  x-transition:leave-end="translate-x-full"
81
- class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
88
+ class="<%= display_class %> fixed right-0 <%= width_class %> <%= surface_class %><%= z_class %> flex-col <%= panel_class %>"
82
89
  style="top:var(--nav-bottom, var(--nav-h, 6rem));height:calc(100% - var(--nav-bottom, var(--nav-h, 6rem)))"
83
90
  data-pin-edge
84
91
  <% if edge %>
85
92
  data-sidebar-edge
86
93
  <% end %>
94
+ <% if glass %>
95
+ data-sidebar-glass
96
+ <% end %>
87
97
  role="<%= role %>"
88
98
  <% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
89
- <div class="flex flex-col h-full">
99
+ <div class="flex flex-col h-full"<% if glass %> data-sidebar-glass-shell<% end %>>
90
100
  <% if title.present? || title_html.present? || actions_html.present? || close_action.present? %>
91
- <div class="flex items-center justify-between p-4 border-b border-subtle">
101
+ <div class="flex items-center justify-between p-4 border-b border-subtle"<% if glass %> data-sidebar-glass-head<% end %>>
92
102
  <% if title.present? %>
93
103
  <h3 <% if labelledby %>id="<%= labelledby %>"<% end %> class="text-lg font-bold text-heading"><%= title %></h3>
94
104
  <% elsif title_html.present? %>
@@ -198,6 +198,12 @@
198
198
  <% if sticky_table_headers %>
199
199
  <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
200
200
  <% end %>
201
+ <%# THE PAGE-LOAD OVERLAY, FOR EVERY APP. The body is the host's, so the overlay waits
202
+ here, inert, and studio/loader adds it to the body when a visit starts. A host's own
203
+ studio_loader_overlay wins. Studio.loader_overlay = false opts out. docs/LOADER.md. %>
204
+ <% if Studio.loader_overlay && respond_to?(:studio_loader_overlay) %>
205
+ <template data-studio-loader-overlay-template><%= studio_loader_overlay %></template>
206
+ <% end %>
201
207
  <%= javascript_importmap_tags %>
202
208
  <%# EVERY ENGINE SCRIPT BELOW IS A MODULE, AND EVERY MODULE TAG COMES AFTER THE
203
209
  IMPORT MAP. A browser without dynamic import maps ignores an import map that
@@ -207,12 +213,17 @@
207
213
  carry no body and need no nonce under a policy that allows 'self'. The session
208
214
  store (studio/_session_stamp, above) and the full-load script above are the two
209
215
  classic scripts, and each says why.
210
- A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
211
- also sends a `Link: rel=modulepreload` response header, which the browser
212
- acts on before it parses this map, so a bare import in that module can
213
- resolve against no map at all and fail for good (0.115.1.rc1, task
216
+ A MODULE TAG BY URL SENDS NO PRELOAD HEADER. javascript_include_tag answers
217
+ type: "module" with a `Link: rel=modulepreload` response header unless the call
218
+ passes preload_links_header: false, and the browser acts on that header before it
219
+ parses this map. Firefox then refuses the map outright ("Import maps are not allowed
220
+ after a module load or preload has started"), so every bare studio/* import on the
221
+ page fails and no panel opens (task firefox-import-map-preload,
222
+ test/views/head_sends_no_module_preload_test.rb).
223
+ A MODULE LOADED BY URL ALSO IMPORTS NO BARE SPECIFIER. A preload that arrived some
224
+ other way would resolve it against no map and fail for good (0.115.1.rc1, task
214
225
  confetti-resolves-on-hub). A module with bare imports takes a
215
- javascript_import_module_tag, which sends no header. %>
226
+ javascript_import_module_tag. %>
216
227
  <%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
217
228
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
218
229
  carries the nonce. %>
@@ -311,7 +322,7 @@
311
322
  assigns window.Alpine and starts Alpine in a microtask after it is evaluated,
312
323
  which as a module is after every module above it. window.Alpine is not
313
324
  deprecated; Alpine itself and every host read it. %>
314
- <%= javascript_include_tag "studio/alpine", type: "module", "data-turbo-track": "reload" %>
325
+ <%= javascript_include_tag "studio/alpine", type: "module", preload_links_header: false, "data-turbo-track": "reload" %>
315
326
  <% if sticky_table_headers %>
316
- <%= javascript_include_tag "studio/sticky_table_header", type: "module", "data-turbo-track": "reload" %>
327
+ <%= javascript_include_tag "studio/sticky_table_header", type: "module", preload_links_header: false, "data-turbo-track": "reload" %>
317
328
  <% end %>
@@ -21,4 +21,5 @@
21
21
  <% end %>
22
22
  <% if page_transition_direction %>
23
23
  <meta name="page-transition-direction" content="<%= page_transition_direction %>">
24
+ <meta name="page-transition-style" content="<%= @studio_page_transition_style || Studio.page_transition_style %>">
24
25
  <% end %>
@@ -3,7 +3,7 @@
3
3
  so a preview looks as it does in the app, and nothing else: no navbar, no
4
4
  session, no script. -%>
5
5
  <!DOCTYPE html>
6
- <html>
6
+ <html<%= %( data-surface-style="glass").html_safe if Studio.surface_style == :glass %>>
7
7
  <head>
8
8
  <meta charset="utf-8">
9
9
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -81,9 +81,10 @@
81
81
 
82
82
  <% if Studio.auth_method?(:google) %>
83
83
  <% if email_signup %>
84
- <div class="relative my-6">
85
- <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
86
- <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
84
+ <div class="flex items-center gap-3 my-6 text-sm">
85
+ <div class="flex-1 border-t border-subtle"></div>
86
+ <span class="text-secondary">or</span>
87
+ <div class="flex-1 border-t border-subtle"></div>
87
88
  </div>
88
89
  <% end %>
89
90
 
@@ -81,9 +81,10 @@
81
81
 
82
82
  <% if Studio.auth_method?(:google) %>
83
83
  <% if Studio.password_login_available? || Studio.auth_method?(:magic_link) %>
84
- <div class="relative my-6">
85
- <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
86
- <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
84
+ <div class="flex items-center gap-3 my-6 text-sm">
85
+ <div class="flex-1 border-t border-subtle"></div>
86
+ <span class="text-secondary">or</span>
87
+ <div class="flex-1 border-t border-subtle"></div>
87
88
  </div>
88
89
  <% end %>
89
90
 
@@ -42,8 +42,9 @@
42
42
  <div class="flex flex-col gap-3 min-w-0" data-agent-chat-cards data-test="agent-chat-cards"><%= cards %></div>
43
43
  <% end %>
44
44
  <div class="flex flex-col gap-3 min-w-0">
45
+ <%# 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. %>
45
46
  <div role="log" aria-live="polite" aria-label="Conversation with <%= name %>"
46
- class="flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
47
+ class="relative flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
47
48
  data-agent-chat-log data-test="agent-chat-messages">
48
49
  <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>
49
50
  </div>
@@ -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. Text only, never HTML: the script sets textContent. A failed turn says so. %>
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. Text only, never HTML: the script sets textContent. 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" %>"
@@ -16,4 +16,6 @@
16
16
  <% end %>
17
17
  <p class="<%= agent ? "self-start" : "self-end" %> text-xs text-danger-ink" data-agent-chat-failed data-test="agent-chat-failed"
18
18
  <%= "hidden" unless status == "failed" %>><%= name %> could not answer this one. Send it again.</p>
19
+ <p class="<%= agent ? "self-start" : "self-end" %> text-xs text-muted" data-agent-chat-held data-test="agent-chat-held"
20
+ <%= "hidden" unless status == "held" %>>Your message was received, but it won't get an automatic answer.</p>
19
21
  </div>
@@ -7,16 +7,19 @@
7
7
  No audio is kept. Status lines are set as text.
8
8
 
9
9
  Scoped CSS for the reason studio/voice/_styles gives: it must render in an
10
- app whose Tailwind build never saw this file. The status line keeps its one
11
- line even when empty, so a one-line status coming or going under a held finger
12
- does not move the button. Two things still can at 390px outside a stuck composer,
13
- and the pointer stays captured through both: the quiet hint wrapping to two lines,
14
- and the chip's prompt shortening on the first hold. %>
10
+ app whose Tailwind build never saw this file. A held button must not move, stuck
11
+ or not, so everything above and below it keeps its size through a hold: the
12
+ status line reserves the two lines the quiet hint wraps to at phone width (one
13
+ where the composer is wide enough), and holds its height through the hold
14
+ (holdLine, studio/agent_chat_voice); the chip is always one line, whatever name it
15
+ shows; the timer sits on the button, not beside it; and the recorder's own line
16
+ never shows here. e2e/agent_chat_voice.spec.js measures all of it at 390px. %>
15
17
  <% words = Studio::AgentChat.voice_copy(copy) %>
16
18
  <style>
17
- .studio-agent-voice { display: grid; gap: .25rem; padding: 0 .25rem .25rem; min-width: 0; }
18
- .studio-agent-voice-status { min-height: 1.25rem; margin: 0; padding: 0 .5rem; font-size: .875rem; line-height: 1.25rem; overflow-wrap: anywhere;
19
+ .studio-agent-voice { display: grid; gap: .25rem; padding: 0 .25rem .25rem; min-width: 0; container-type: inline-size; }
20
+ .studio-agent-voice-status { min-height: 2.5rem; margin: 0; padding: 0 .5rem; font-size: .875rem; line-height: 1.25rem; overflow-wrap: anywhere;
19
21
  color: var(--color-text-body, inherit); }
22
+ @container (min-width: 36rem) { .studio-agent-voice-status { min-height: 1.25rem; } }
20
23
  .studio-agent-voice-failed { display: none; margin: 0; padding: 0 .5rem; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
21
24
  [data-studio-controller-failed~="agent-chat-voice"] > .studio-agent-voice-failed { display: block; }
22
25
  /* One row where both fit; on a phone the chip takes the first line and the
@@ -24,18 +27,30 @@
24
27
  .studio-agent-voice-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; }
25
28
  .studio-agent-voice-row .studio-mic { display: contents; }
26
29
  .studio-agent-voice-row .studio-mic-chip { flex: 1 1 14rem; min-width: 0; }
27
- .studio-agent-voice-row .studio-mic-panel { flex: 1 0 100%; max-width: none; order: 3; }
30
+ .studio-agent-voice-row .studio-mic-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
31
+ /* The composer sticks to the window's bottom, so what doesn't fit is cut off at the
32
+ top, the chip with it. The picker scrolls inside what the rest leaves it: 20rem is
33
+ the composer's 15rem at 390px, the privacy line under it, and a margin. */
34
+ .studio-agent-voice-row .studio-mic-panel { flex: 1 0 100%; max-width: none; order: 3; align-content: start; overflow-y: auto; overscroll-behavior: contain;
35
+ max-height: max(6rem, calc(100vh - 20rem)); max-height: max(6rem, calc(100dvh - 20rem)); }
28
36
  .studio-agent-voice-row .studio-mic-failed { flex: 1 0 100%; order: 4; }
29
- .studio-agent-voice-row .studio-recorder { flex: 1 1 10rem; min-width: 0; flex-flow: row wrap; justify-content: flex-end; gap: .5rem; }
37
+ /* 14rem: the label and, either side of it, the timer's room. Narrower, the two overlap. */
38
+ .studio-agent-voice-row .studio-recorder { flex: 1 1 14rem; min-width: 0; flex-flow: row wrap; justify-content: flex-end; gap: .5rem; }
30
39
  .studio-agent-voice-row .studio-recorder-button { flex: 1 1 10rem; min-width: 0; padding: .625rem 1rem; }
31
40
  .studio-agent-voice-row .studio-recorder-timer { display: none; }
32
- .studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-timer { display: inline; }
41
+ /* On the button, not beside it: beside it, it took 40px off a held button. */
42
+ .studio-agent-voice-row .studio-recorder { position: relative; }
43
+ .studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-timer { display: inline; position: absolute; top: 50%; right: 1rem; transform: translateY(-50%);
44
+ pointer-events: none; color: var(--btn-primary-fg, #fff); }
33
45
  .studio-agent-voice-row .studio-recorder-status { flex: 1 0 100%; min-height: 0; max-width: none; font-size: .875rem; }
34
- /* The chat's status line speaks for these three; a second live line would say
35
- them twice, and its coming and going would move a held button. */
46
+ /* The chat's status line speaks for these; a second live line would say them
47
+ twice, and its coming and going would move a held button. */
36
48
  .studio-agent-voice-row .studio-recorder-status:empty,
37
49
  .studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-status,
38
- .studio-agent-voice-row [data-voice-recorder-state="silent"] .studio-recorder-status { display: none; }
50
+ .studio-agent-voice-row [data-voice-recorder-state="silent"] .studio-recorder-status,
51
+ .studio-agent-voice-row [data-voice-recorder-state="failed"] .studio-recorder-status,
52
+ .studio-agent-voice-row [data-voice-recorder-state="denied"] .studio-recorder-status,
53
+ .studio-agent-voice-row [data-voice-recorder-state="tapped"] .studio-recorder-status { display: none; }
39
54
  .studio-agent-voice-row .studio-recorder-failed { flex: 1 0 100%; }
40
55
  </style>
41
56
  <div class="studio-agent-voice" role="group" aria-label="Talk to <%= name %>"
@@ -1,14 +1,14 @@
1
- <%# locals: (label:, delay:, max_wait:, size:, options: {}) %>
1
+ <%# locals: (label:, delay:, min_show:, max_wait:, size:, options: {}) %>
2
2
  <%#
3
3
  The page-load overlay (Studio::LoaderHelper#studio_loader_overlay). Rendered
4
4
  hidden, once per page; studio/loader shows it when a visit the user started
5
- or a form marked data-studio-loader-overlay outlasts data-delay ms, clears it
6
- after data-max-wait ms at most, and window.StudioLoader.show(caption) /
5
+ or a form marked data-studio-loader-overlay outlasts data-delay ms, keeps it
6
+ data-min-show ms once shown, clears it after data-max-wait ms at most, and window.StudioLoader.show(caption) /
7
7
  hide() drive it for any other long wait. The caption line is empty until a
8
8
  caller names one. It never marks itself aria-busy: that would mute the
9
9
  "Loading" status inside it.
10
10
  %>
11
- <%= tag.div(class: "studio-loader-overlay", hidden: true, data: { studio_loader_overlay: "", delay: delay, max_wait: max_wait }) do %>
11
+ <%= tag.div(class: "studio-loader-overlay", hidden: true, data: { studio_loader_overlay: "", delay: delay, min_show: min_show, max_wait: max_wait }) do %>
12
12
  <div class="studio-loader-overlay-panel">
13
13
  <%= studio_loader(size: size, label: label, **options) %>
14
14
  <p class="studio-loader-caption" data-studio-loader-caption></p>
@@ -7,10 +7,8 @@
7
7
  to a 404. -%>
8
8
  <section id="components" class="space-y-6" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
9
9
  <div class="space-y-1">
10
- <h2 class="text-2xl font-bold text-heading">Components</h2>
11
- <p class="text-muted text-sm max-w-2xl">
12
- The engine's ViewComponent classes, one preview per state, in the component gallery.
13
- </p>
10
+ <h2 class="type-h3 text-heading">Components</h2>
11
+ <p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
14
12
  </div>
15
13
 
16
14
  <% if Studio.lookbook_mounted? %>
@@ -0,0 +1,83 @@
1
+ <%# The glass surfaces, the glass sidebar and the line icons (Studio.surface_style,
2
+ Studio.sidebar_style, studio_icon). Real classes and the real helper, so a change to any
3
+ of them shows here. The stage has a hard-edged backing because glass needs something
4
+ behind it: on a flat page it looks like a flat tint. Each specimen says whether this app
5
+ has turned its switch on, because the surface rules only apply when it has. %>
6
+ <%
7
+ glass_backing = "rounded-xl p-4 [background-image:repeating-linear-gradient(135deg,rgb(var(--color-primary-rgb)/0.55)_0_14px,transparent_14px_28px)]"
8
+ surface_on = Studio.surface_style == :glass
9
+ sidebar_on = Studio.sidebar_style == :glass
10
+ %>
11
+ <section class="space-y-4" data-test="style-glass">
12
+ <div>
13
+ <h3 class="type-h4 text-heading">Glass</h3>
14
+ <p class="text-sm text-secondary mt-1">Each one is opt-in per app.</p>
15
+ </div>
16
+ <div class="grid gap-4 md:grid-cols-2">
17
+ <%= render layout: "style/specimen",
18
+ locals: { klass: "Studio.surface_style = :glass",
19
+ usage: %(config.surface_style = :glass, and <html data-surface-style="glass">),
20
+ disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
21
+ <div class="<%= glass_backing %> w-full space-y-2" data-test="style-glass-surfaces">
22
+ <div class="card p-3 text-sm text-heading">.card, the dense tier: no blur</div>
23
+ <div class="card surface-glass-blur p-3 text-sm text-heading">.card.surface-glass-blur</div>
24
+ </div>
25
+ <% end %>
26
+ <%= render layout: "style/specimen",
27
+ locals: { klass: "Studio.sidebar_style = :glass",
28
+ usage: %(config.sidebar_style = :glass),
29
+ disabled: !sidebar_on, disabled_label: "glass sidebar is off on this app" } do %>
30
+ <div class="<%= glass_backing %> w-full" data-test="style-glass-sidebar">
31
+ <div class="flex flex-col" data-sidebar-glass-shell>
32
+ <nav aria-label="Glass sidebar specimen">
33
+ <div role="group" class="pt-2.5">
34
+ <a href="#tricks" class="group flex items-center" aria-current="page"><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("chart-column") %></span><span class="min-w-0"><span class="block truncate">Current page</span></span></a>
35
+ <a href="#tricks" class="group flex items-center" data-sidebar-row-described><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("rocket") %></span><span class="relative min-w-0 flex-1"><span class="block truncate" data-sidebar-row-label>Hover this row</span><span class="block truncate text-xs text-muted" data-sidebar-row-desc>Its description moves nothing</span></span></a>
36
+ </div>
37
+ </nav>
38
+ </div>
39
+ </div>
40
+ <% if respond_to?(:studio_sidebar?) && studio_sidebar? %>
41
+ <button type="button" class="btn btn-sm btn-outline mt-3" @click="$store.sidebars.linkTreeOpen = true" data-test="style-glass-open">Open the real sidebar</button>
42
+ <% end %>
43
+ <% end %>
44
+ <%= render layout: "style/specimen",
45
+ locals: { klass: "Lit from above, under surface_style = :glass",
46
+ usage: %(.btn-primary, .btn-danger, .btn-neutral, .badge, .input-field),
47
+ disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
48
+ <div class="w-full overflow-x-auto" data-test="style-lit-states">
49
+ <table class="text-xs text-muted">
50
+ <thead><tr><th></th><% %w[rest hover pressed focus disabled].each do |state| %><th class="font-medium text-left"><%= state %></th><% end %></tr></thead>
51
+ <tbody>
52
+ <% [["btn-primary", "Primary"], ["btn-secondary", "Secondary"], ["btn-success", "Success"], ["btn-danger", "Danger"], ["btn-warning", "Warning"], ["btn-primary btn-sm", "Small"], ["btn-neutral btn-sm", "Neutral"], ["btn-outline btn-sm", "Outline"]].each do |classes, label| %>
53
+ <tr>
54
+ <th class="font-medium text-left pr-2"><%= label %></th>
55
+ <% [nil, "hover", "pressed", "focus"].each do |state| %>
56
+ <td class="p-1.5"><button type="button" class="btn <%= classes %>" tabindex="-1" <%= %(data-lit-state="#{state}").html_safe if state %>>Go</button></td>
57
+ <% end %>
58
+ <td class="p-1.5"><button type="button" class="btn <%= classes %>" disabled>Go</button></td>
59
+ </tr>
60
+ <% end %>
61
+ </tbody>
62
+ </table>
63
+ <div class="flex flex-wrap items-center gap-2 mt-3">
64
+ <span class="badge text-heading border-subtle">Badge</span>
65
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
66
+ <input class="input-field max-w-[12rem]" type="text" value="Input, pressed in" aria-label="Input specimen" readonly>
67
+ </div>
68
+ </div>
69
+ <% end %>
70
+ <%= render layout: "style/specimen",
71
+ locals: { klass: "studio_icon(name, size: 18)",
72
+ usage: %(studio_icon("rocket", size: 22)) } do %>
73
+ <ul class="grid grid-cols-4 gap-3 w-full text-heading" data-test="style-glass-icons">
74
+ <% Studio::Icons.names.each do |name| %>
75
+ <li class="flex flex-col items-center gap-1 min-w-0" title="<%= name %>">
76
+ <%= studio_icon(name, size: 22) %>
77
+ <span class="text-3xs text-muted truncate max-w-full"><%= name %></span>
78
+ </li>
79
+ <% end %>
80
+ </ul>
81
+ <% end %>
82
+ </div>
83
+ </section>
@@ -93,16 +93,8 @@
93
93
  <section id="host-modals" class="space-y-8" x-data="{}"
94
94
  style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
95
95
  <div class="space-y-1">
96
- <h2 class="text-2xl font-bold text-heading"><%= Studio.app_name %></h2>
97
- <p class="text-muted text-sm max-w-2xl">
98
- This app&rsquo;s own modals, contributed by
99
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">app/views/style/host/_modals.html.erb</code>.
100
- Every section above belongs to the engine and is inherited; everything
101
- here belongs to <%= Studio.app_name %> and ships with it. These specimens
102
- drive the app&rsquo;s <strong>real</strong>
103
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">$store.modals</code>
104
- host, so the card that opens here is the card that opens in production.
105
- </p>
96
+ <h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
97
+ <p class="text-muted text-sm max-w-2xl">This app&rsquo;s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
106
98
  </div>
107
99
 
108
100
  <%# The app's content. Reached only when it resolves - style/index.html.erb