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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +9 -3
- data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
- data/app/assets/tailwind/studio_engine/engine.css +519 -37
- data/app/controllers/studio/surveys_controller.rb +16 -3
- data/app/helpers/studio/loader_helper.rb +2 -2
- data/app/helpers/studio_icon_helper.rb +10 -0
- data/app/javascript/studio/agent_chat.js +14 -3
- data/app/javascript/studio/agent_chat_voice.js +42 -4
- data/app/javascript/studio/loader.js +130 -5
- data/app/javascript/studio/microphone_chip.js +28 -2
- data/app/javascript/studio/page_transitions.js +28 -19
- data/app/javascript/studio/survey.js +16 -3
- data/app/javascript/studio/theme_editor.js +4 -0
- data/app/javascript/studio/voice_recorder.js +18 -7
- data/app/models/studio/survey_response.rb +24 -17
- data/app/views/components/_geo_signpost.html.erb +1 -0
- data/app/views/components/_link_sidebar.html.erb +40 -12
- data/app/views/components/_sidebar_panel.html.erb +15 -5
- data/app/views/layouts/studio/_head.html.erb +18 -7
- data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
- data/app/views/layouts/studio/component_preview.html.erb +1 -1
- data/app/views/studio/agent_chat/_turn.html.erb +3 -1
- data/app/views/studio/agent_chat/_voice.html.erb +28 -13
- data/app/views/studio/loader/_overlay.html.erb +4 -4
- data/app/views/style/_components.html.erb +1 -1
- data/app/views/style/_glass.html.erb +83 -0
- data/app/views/style/_host.html.erb +1 -1
- data/app/views/style/_modals.html.erb +11 -11
- data/app/views/style/_scrims.html.erb +1 -1
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_tasks.html.erb +1 -1
- data/app/views/style/_theme.html.erb +56 -72
- data/app/views/style/_tricks.html.erb +13 -11
- data/app/views/style/_type_ladder.html.erb +33 -0
- data/app/views/style/index.html.erb +5 -1
- data/docs/SITE_FOOTER.md +1 -1
- data/lib/studio/agent_chat.rb +4 -2
- data/lib/studio/icons/README.md +12 -0
- data/lib/studio/icons/lucide/LICENSE +43 -0
- data/lib/studio/icons/lucide/activity.svg +15 -0
- data/lib/studio/icons/lucide/book-open.svg +16 -0
- data/lib/studio/icons/lucide/chart-column.svg +18 -0
- data/lib/studio/icons/lucide/diamond.svg +15 -0
- data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
- data/lib/studio/icons/lucide/globe.svg +17 -0
- data/lib/studio/icons/lucide/hammer.svg +17 -0
- data/lib/studio/icons/lucide/inbox.svg +16 -0
- data/lib/studio/icons/lucide/key-round.svg +16 -0
- data/lib/studio/icons/lucide/layout-grid.svg +18 -0
- data/lib/studio/icons/lucide/log-out.svg +17 -0
- data/lib/studio/icons/lucide/message-circle.svg +15 -0
- data/lib/studio/icons/lucide/package.svg +18 -0
- data/lib/studio/icons/lucide/palette.svg +19 -0
- data/lib/studio/icons/lucide/plug.svg +18 -0
- data/lib/studio/icons/lucide/rocket.svg +18 -0
- data/lib/studio/icons/lucide/user.svg +16 -0
- data/lib/studio/icons.rb +52 -0
- data/lib/studio/loader.rb +7 -0
- data/lib/studio/sidebar_sections.rb +52 -4
- data/lib/studio/site_footer.rb +23 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +54 -2
- 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
|
|
200
|
-
//
|
|
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)
|
|
18
|
-
// unsupported (no MediaRecorder)
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%
|
|
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 %>
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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 %>
|
|
@@ -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.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
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,
|
|
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="
|
|
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>
|