studio-engine 0.127.0 → 0.128.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -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 +519 -37
  6. data/app/controllers/studio/surveys_controller.rb +16 -3
  7. data/app/helpers/studio/loader_helper.rb +2 -2
  8. data/app/helpers/studio_icon_helper.rb +10 -0
  9. data/app/javascript/studio/agent_chat.js +14 -3
  10. data/app/javascript/studio/agent_chat_voice.js +42 -4
  11. data/app/javascript/studio/loader.js +130 -5
  12. data/app/javascript/studio/microphone_chip.js +28 -2
  13. data/app/javascript/studio/page_transitions.js +28 -19
  14. data/app/javascript/studio/survey.js +16 -3
  15. data/app/javascript/studio/theme_editor.js +4 -0
  16. data/app/javascript/studio/voice_recorder.js +18 -7
  17. data/app/models/studio/survey_response.rb +24 -17
  18. data/app/views/components/_geo_signpost.html.erb +1 -0
  19. data/app/views/components/_link_sidebar.html.erb +40 -12
  20. data/app/views/components/_sidebar_panel.html.erb +15 -5
  21. data/app/views/layouts/studio/_head.html.erb +18 -7
  22. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  23. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  25. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  26. data/app/views/studio/loader/_overlay.html.erb +4 -4
  27. data/app/views/style/_components.html.erb +1 -1
  28. data/app/views/style/_glass.html.erb +83 -0
  29. data/app/views/style/_host.html.erb +1 -1
  30. data/app/views/style/_modals.html.erb +11 -11
  31. data/app/views/style/_scrims.html.erb +1 -1
  32. data/app/views/style/_site_footer.html.erb +1 -1
  33. data/app/views/style/_tasks.html.erb +1 -1
  34. data/app/views/style/_theme.html.erb +56 -72
  35. data/app/views/style/_tricks.html.erb +13 -11
  36. data/app/views/style/_type_ladder.html.erb +33 -0
  37. data/app/views/style/index.html.erb +5 -1
  38. data/docs/SITE_FOOTER.md +1 -1
  39. data/lib/studio/agent_chat.rb +4 -2
  40. data/lib/studio/icons/README.md +12 -0
  41. data/lib/studio/icons/lucide/LICENSE +43 -0
  42. data/lib/studio/icons/lucide/activity.svg +15 -0
  43. data/lib/studio/icons/lucide/book-open.svg +16 -0
  44. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  45. data/lib/studio/icons/lucide/diamond.svg +15 -0
  46. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  47. data/lib/studio/icons/lucide/globe.svg +17 -0
  48. data/lib/studio/icons/lucide/hammer.svg +17 -0
  49. data/lib/studio/icons/lucide/inbox.svg +16 -0
  50. data/lib/studio/icons/lucide/key-round.svg +16 -0
  51. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  52. data/lib/studio/icons/lucide/log-out.svg +17 -0
  53. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  54. data/lib/studio/icons/lucide/package.svg +18 -0
  55. data/lib/studio/icons/lucide/palette.svg +19 -0
  56. data/lib/studio/icons/lucide/plug.svg +18 -0
  57. data/lib/studio/icons/lucide/rocket.svg +18 -0
  58. data/lib/studio/icons/lucide/user.svg +16 -0
  59. data/lib/studio/icons.rb +52 -0
  60. data/lib/studio/loader.rb +7 -0
  61. data/lib/studio/sidebar_sections.rb +52 -4
  62. data/lib/studio/site_footer.rb +23 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +54 -2
  65. metadata +24 -1
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // AUTOSAVE IS BEST EFFORT: the final Submit posts the whole form, so a failed
11
11
  // autosave loses nothing. Without this module the page is the plain form, every
12
- // question on one page, and it submits the same answers.
12
+ // question on one page, and it submits the same answers. So a submit cancels every
13
+ // save still in flight: one left running could reach the server after the POST.
13
14
  //
14
15
  // It imports nothing; test/javascript/survey.test.mjs loads it as a data:
15
16
  // module. Bound to the page by the survey controller.
@@ -84,6 +85,7 @@ export function enhanceSurvey(root, win = window, doc = document) {
84
85
  var reduce = win.matchMedia && win.matchMedia("(prefers-reduced-motion: reduce)").matches;
85
86
  var saved = {};
86
87
  var timers = {};
88
+ var requests = [];
87
89
  var current = -1;
88
90
  var pointerPick = false;
89
91
  var submitting = false;
@@ -121,12 +123,20 @@ export function enhanceSurvey(root, win = window, doc = document) {
121
123
  }
122
124
  var url = root.dataset.answerBase + encodeURIComponent(key);
123
125
  status.textContent = "Saving…";
126
+ var request = new win.AbortController();
127
+ requests.push(request);
124
128
  return win.fetch(url, {
125
129
  method: "PATCH",
126
130
  body: body,
127
131
  credentials: "same-origin",
132
+ signal: request.signal,
128
133
  headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
129
134
  }).then(function (res) {
135
+ // Completed elsewhere, such as in a second tab. There is nothing to fix here.
136
+ if (res.status === 409) {
137
+ status.textContent = "";
138
+ return false;
139
+ }
130
140
  // fetch resolves on a 4xx/5xx — the status is the only signal.
131
141
  if (!res.ok) {
132
142
  return res.json().catch(function () { return {}; }).then(function (data) {
@@ -139,6 +149,8 @@ export function enhanceSurvey(root, win = window, doc = document) {
139
149
  status.textContent = "Saved";
140
150
  return true;
141
151
  }).catch(function () {
152
+ // The submit cancelled it, which is not the visitor being offline.
153
+ if (submitting) return false;
142
154
  status.textContent = "Offline? Your answers still go in when you submit.";
143
155
  return false;
144
156
  });
@@ -196,8 +208,8 @@ export function enhanceSurvey(root, win = window, doc = document) {
196
208
  setError(step, "");
197
209
  }
198
210
  if (current === steps.length - 1) {
199
- // No autosave here: the POST carries every answer, and a PATCH racing
200
- // it would land after completion and be refused.
211
+ // No autosave here: the POST carries every answer. The submit handler
212
+ // cancels any earlier save still in flight.
201
213
  submitForm();
202
214
  } else {
203
215
  if (step.dataset.key) save(step);
@@ -222,6 +234,7 @@ export function enhanceSurvey(root, win = window, doc = document) {
222
234
  }
223
235
  Object.keys(timers).forEach(function (key) { win.clearTimeout(timers[key]); });
224
236
  submitting = true;
237
+ requests.forEach(function (request) { request.abort(); });
225
238
  submit.disabled = true;
226
239
  submit.textContent = "Sending…";
227
240
  });
@@ -82,7 +82,11 @@ export function dsThemeEditor(seed) {
82
82
  }, seed || {}),
83
83
  saving: false,
84
84
 
85
+ // The preview block shows once a role has been edited: before that it only repeats the page.
86
+ edited: false,
87
+
85
88
  updatePreview() {
89
+ this.edited = true
86
90
  applyProperties(document.documentElement.style, stylePreviewProperties(this.colors))
87
91
  },
88
92
 
@@ -14,9 +14,9 @@
14
14
  // </div>
15
15
  //
16
16
  // STATES, on data-voice-recorder-state: idle -> starting -> recording ->
17
- // captured | silent | failed, plus denied (the microphone was refused) and
18
- // unsupported (no MediaRecorder). Any of idle, captured, silent, failed and
19
- // denied takes a new press.
17
+ // captured | silent | failed, plus denied (the microphone was refused),
18
+ // unsupported (no MediaRecorder) and, in hold mode, tapped (let go too soon to be a
19
+ // take). Any of idle, captured, silent, failed, denied and tapped takes a new press.
20
20
  //
21
21
  // EVENTS, dispatched on the root (they bubble):
22
22
  // studio:voice-recorder:take detail { blob, mimeType, durationMs, peakRms, microphone: { label, deviceId } }
@@ -49,7 +49,12 @@ export const MIME_CANDIDATES = [
49
49
  "audio/ogg;codecs=opus"
50
50
  ]
51
51
 
52
- export const READY = ["idle", "captured", "silent", "failed", "denied"]
52
+ export const READY = ["idle", "captured", "silent", "failed", "denied", "tapped"]
53
+
54
+ // Hold mode: a take shorter than this is a tap, not a hold. It says how to use the
55
+ // button and tells the chip nothing: the frames a tap records are too few to judge
56
+ // the microphone by, and two silent takes in a row would open the help.
57
+ export const TAP_MS = 300
53
58
 
54
59
  // How long a take may stay silent before the status asks whether the
55
60
  // microphone is on.
@@ -66,6 +71,7 @@ export const COPY = {
66
71
  missing: "I couldn't find a microphone. Plug one in and try again.",
67
72
  busy: "Your microphone is busy in another app. Close it there, then try again." },
68
73
  denied: { button: "Record", status: "Allow the microphone to record." },
74
+ tapped: { button: "Record", status: "Hold the button while you talk, then let go." },
69
75
  unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
70
76
  }
71
77
 
@@ -83,8 +89,9 @@ export function formatElapsed(ms) {
83
89
 
84
90
  // The line for a microphone that would not open (NotAllowedError is "denied",
85
91
  // not this). NotReadableError is a microphone another app holds (Chrome's
86
- // older name, TrackStartError); everything else reads as no microphone found.
87
- export const BUSY_ERRORS = ["NotReadableError", "TrackStartError"]
92
+ // older name, TrackStartError; Firefox can say AbortError); everything else
93
+ // reads as no microphone found.
94
+ export const BUSY_ERRORS = ["NotReadableError", "TrackStartError", "AbortError"]
88
95
 
89
96
  export function failedLine(error, copy = COPY) {
90
97
  const busy = !!error && BUSY_ERRORS.includes(error.name)
@@ -216,7 +223,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
216
223
  // Destroyed, or let go of (hold mode) before the microphone opened.
217
224
  if (destroyed || stopWanted) {
218
225
  release()
219
- if (!destroyed) setState("idle")
226
+ if (!destroyed) setState(mode === "hold" ? "tapped" : "idle")
220
227
  return
221
228
  }
222
229
  chunks = []
@@ -275,6 +282,10 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
275
282
  recorder = null
276
283
  release()
277
284
  if (destroyed) return
285
+ if (mode === "hold" && durationMs < TAP_MS) {
286
+ setState("tapped")
287
+ return
288
+ }
278
289
  if (blob.size === 0 || !heard) {
279
290
  setState("silent", silentMessage(copy.silent.named, microphone.label))
280
291
  announce(MICROPHONE_EVENTS.silent, { label: microphone.label }, win)
@@ -87,25 +87,32 @@ module Studio
87
87
 
88
88
  # Stores one answer (or clears it, for a blank). Returns [ok, error]. Never
89
89
  # touches a completed response: a finished survey is a record, not a draft.
90
+ #
91
+ # THE ROW LOCK IS THE GUARD, not the loaded copy. This writes the whole answers
92
+ # hash, so a copy loaded before a submit completed the row would put its stale
93
+ # hash over the finished answers, and two saves at once would lose each other's
94
+ # keys. with_lock reloads under the lock, so both checks read the row as it is.
90
95
  def record_answer(question, raw)
91
- return [false, "This survey is already complete."] if completed?
92
-
93
- value, error = question.normalize(raw)
94
- return [false, error] if error
95
-
96
- next_answers = (answers || {}).dup
97
- if value.nil?
98
- next_answers.delete(question.key)
99
- else
100
- next_answers[question.key] = {
101
- "value" => value, "label" => question.label, "type" => question.type.to_s,
102
- "display" => question.display(value), "answered_at" => Time.current.utc.iso8601
103
- }
96
+ with_lock do
97
+ next [false, "This survey is already complete."] if completed?
98
+
99
+ value, error = question.normalize(raw)
100
+ next [false, error] if error
101
+
102
+ next_answers = (answers || {}).dup
103
+ if value.nil?
104
+ next_answers.delete(question.key)
105
+ else
106
+ next_answers[question.key] = {
107
+ "value" => value, "label" => question.label, "type" => question.type.to_s,
108
+ "display" => question.display(value), "answered_at" => Time.current.utc.iso8601
109
+ }
110
+ end
111
+ self.answers = next_answers
112
+ self.current_key = question.key
113
+ save!
114
+ [true, nil]
104
115
  end
105
- self.answers = next_answers
106
- self.current_key = question.key
107
- save!
108
- [true, nil]
109
116
  end
110
117
 
111
118
  def missing_required(survey)
@@ -88,6 +88,7 @@
88
88
  <%= link_to admin_geo_path,
89
89
  class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
90
90
  "data-sidebar-row-described": "",
91
+ "aria-current": (Studio.sidebar_style == :glass && respond_to?(:request) && request&.path == admin_geo_path ? "page" : nil),
91
92
  "@click": geo_close do %>
92
93
  <span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
93
94
  <span class="relative min-w-0 flex-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">
@@ -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,7 +7,7 @@
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>
10
+ <h2 class="type-h3 text-heading">Components</h2>
11
11
  <p class="text-muted text-sm max-w-2xl">
12
12
  The engine's ViewComponent classes, one preview per state, in the component gallery.
13
13
  </p>