studio-engine 0.122.0 → 0.123.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 +78 -0
- data/README.md +2 -2
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/voice_takes_controller.rb +11 -0
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +1 -0
- data/app/javascript/studio/stimulus.js +3 -0
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_recorder.js +23 -2
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +22 -11
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +3 -2
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/voice/_microphone_help.html.erb +6 -3
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/index.html.erb +28 -30
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +33 -0
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/site_footer.rb +45 -2
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +64 -6
- metadata +11 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- data/app/views/theme_settings/edit.html.erb +0 -304
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// studio:voice-recorder:take detail { blob, mimeType, durationMs, peakRms, microphone: { label, deviceId } }
|
|
23
23
|
// studio:voice-recorder:silent detail { durationMs, microphone } (nothing heard: no blob is handed over)
|
|
24
24
|
// studio:voice-recorder:state detail { state }
|
|
25
|
+
// studio:voice-recorder:hint detail { kind, text } (kind "heard" or "quiet", while it records)
|
|
25
26
|
// and, on window, studio/microphone's EVENTS, which the chip listens to.
|
|
26
27
|
//
|
|
27
28
|
// MODES: "toggle" (tap to start, tap to stop) or "hold" (press and hold to
|
|
@@ -34,7 +35,8 @@ import { createLevelMeter, SILENCE_RMS } from "studio/level_meter"
|
|
|
34
35
|
export const EVENTS = {
|
|
35
36
|
take: "studio:voice-recorder:take",
|
|
36
37
|
silent: "studio:voice-recorder:silent",
|
|
37
|
-
state: "studio:voice-recorder:state"
|
|
38
|
+
state: "studio:voice-recorder:state",
|
|
39
|
+
hint: "studio:voice-recorder:hint"
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// What to ask MediaRecorder for, in order: webm/opus on Chrome and Firefox,
|
|
@@ -125,6 +127,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
125
127
|
let microphone = { label: "", deviceId: null }
|
|
126
128
|
let hinted = null
|
|
127
129
|
let stopWanted = false
|
|
130
|
+
let keyHeld = false
|
|
128
131
|
let destroyed = false
|
|
129
132
|
|
|
130
133
|
function setState(next, message = null) {
|
|
@@ -159,10 +162,11 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
function hint(kind) {
|
|
162
|
-
if (state !== "recording" || hinted === kind ||
|
|
165
|
+
if (state !== "recording" || hinted === kind || copy.recording[kind] == null) return
|
|
163
166
|
hinted = kind
|
|
164
167
|
const status = one("[data-voice-recorder-status]")
|
|
165
168
|
if (status) status.textContent = copy.recording[kind]
|
|
169
|
+
root.dispatchEvent(new win.CustomEvent(EVENTS.hint, { bubbles: true, detail: { kind, text: copy.recording[kind] } }))
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
function onSample(rms) {
|
|
@@ -302,6 +306,9 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
302
306
|
if (mode !== "hold") return
|
|
303
307
|
const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
|
|
304
308
|
if (!button || !root.contains(button)) return
|
|
309
|
+
// Only the main button holds: a right-click (or a Mac's control-click) is a
|
|
310
|
+
// context menu, not a take. Touch and pen report button 0.
|
|
311
|
+
if (event.button || event.ctrlKey) return
|
|
305
312
|
event.preventDefault()
|
|
306
313
|
if (button.setPointerCapture && event.pointerId != null) {
|
|
307
314
|
try { button.setPointerCapture(event.pointerId) } catch (_) { /* a synthetic pointer */ }
|
|
@@ -318,12 +325,24 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
318
325
|
if (mode !== "hold" || event.repeat || ![" ", "Enter"].includes(event.key)) return
|
|
319
326
|
if (!event.target.closest || !event.target.closest("[data-voice-recorder-button]")) return
|
|
320
327
|
event.preventDefault()
|
|
328
|
+
keyHeld = true
|
|
321
329
|
start()
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
function onKeyUp(event) {
|
|
325
333
|
if (mode !== "hold" || ![" ", "Enter"].includes(event.key)) return
|
|
326
334
|
event.preventDefault()
|
|
335
|
+
keyHeld = false
|
|
336
|
+
onPointerUp()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A key held on the button when focus leaves it (Tab, another window): its
|
|
340
|
+
// keyup lands elsewhere and never reaches here, and the microphone would stay
|
|
341
|
+
// live to the max_ms cap and send a take on its own. A pointer hold is captured,
|
|
342
|
+
// so it has its own pointerup.
|
|
343
|
+
function onFocusOut() {
|
|
344
|
+
if (!keyHeld) return
|
|
345
|
+
keyHeld = false
|
|
327
346
|
onPointerUp()
|
|
328
347
|
}
|
|
329
348
|
|
|
@@ -333,6 +352,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
333
352
|
root.addEventListener("pointercancel", onPointerUp)
|
|
334
353
|
root.addEventListener("keydown", onKeyDown)
|
|
335
354
|
root.addEventListener("keyup", onKeyUp)
|
|
355
|
+
root.addEventListener("focusout", onFocusOut)
|
|
336
356
|
root.dataset.voiceRecorderMode = mode
|
|
337
357
|
paintLevel(0)
|
|
338
358
|
if (supported()) setState("idle")
|
|
@@ -352,6 +372,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
352
372
|
root.removeEventListener("pointercancel", onPointerUp)
|
|
353
373
|
root.removeEventListener("keydown", onKeyDown)
|
|
354
374
|
root.removeEventListener("keyup", onKeyUp)
|
|
375
|
+
root.removeEventListener("focusout", onFocusOut)
|
|
355
376
|
if (recorder && recorder.state !== "inactive") {
|
|
356
377
|
try { recorder.stop() } catch (_) { /* already stopped */ }
|
|
357
378
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# One signed-in browser session: who, whether they were an admin, when it
|
|
5
|
+
# started and when it was last seen. It is a cached record for the app's own
|
|
6
|
+
# dashboard, kept on purpose without an IP, a user agent or a path.
|
|
7
|
+
#
|
|
8
|
+
# A new row starts after IDLE without a request. An open row is written at most
|
|
9
|
+
# once per THROTTLE, and the cookie remembers when, so a request inside that
|
|
10
|
+
# window runs no query at all.
|
|
11
|
+
#
|
|
12
|
+
# Nil-safe, because the table comes from a migration the host runs: an app that
|
|
13
|
+
# has not run it records nothing and serves as before.
|
|
14
|
+
class UserSession < ApplicationRecord
|
|
15
|
+
self.table_name = "studio_user_sessions"
|
|
16
|
+
|
|
17
|
+
IDLE = 30.minutes
|
|
18
|
+
THROTTLE = 5.minutes
|
|
19
|
+
|
|
20
|
+
belongs_to :user, class_name: "::User", optional: true
|
|
21
|
+
|
|
22
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
23
|
+
scope :non_admin, -> { where(admin: false) }
|
|
24
|
+
|
|
25
|
+
# Per app, like Studio.session_key: apps on the hub's shared cookie
|
|
26
|
+
# (STUDIO_SSO_SHARED_COOKIE) must not read each other's mark.
|
|
27
|
+
def self.cookie_key = :"#{Studio.session_key}_session_mark"
|
|
28
|
+
|
|
29
|
+
# Off under test unless a test asks: the row a first request writes would count in
|
|
30
|
+
|
|
31
|
+
# every host's query-budget test, which measures the page and not this.
|
|
32
|
+
|
|
33
|
+
cattr_accessor :record_in_test, default: false
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def self.recording?
|
|
37
|
+
|
|
38
|
+
!Rails.env.test? || record_in_test
|
|
39
|
+
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def self.available?
|
|
44
|
+
connection.data_source_exists?(table_name)
|
|
45
|
+
rescue ActiveRecord::ActiveRecordError, NoMethodError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Records this request for `user`, reading and writing `store` (the session).
|
|
50
|
+
# Answers the row it wrote, or nil. Never raises: a request must not fail
|
|
51
|
+
# because its bookkeeping did.
|
|
52
|
+
def self.track(store, user, now: Time.current)
|
|
53
|
+
return if user.nil? || !recording?
|
|
54
|
+
|
|
55
|
+
mark = store[cookie_key]
|
|
56
|
+
mark = mark.to_h.transform_keys(&:to_s) if mark.respond_to?(:to_h)
|
|
57
|
+
same_user = mark.is_a?(Hash) && mark["user"] == user.id
|
|
58
|
+
seen = same_user ? Time.zone.at(mark["at"].to_i) : nil
|
|
59
|
+
return if seen && seen > now - THROTTLE
|
|
60
|
+
return unless available?
|
|
61
|
+
|
|
62
|
+
row = same_user && seen > now - IDLE ? find_by(id: mark["id"], user_id: user.id) : nil
|
|
63
|
+
if row
|
|
64
|
+
row.update_columns(last_seen_at: now)
|
|
65
|
+
else
|
|
66
|
+
admin = user.respond_to?(:admin?) && user.admin?
|
|
67
|
+
row = create!(user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now)
|
|
68
|
+
end
|
|
69
|
+
store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }
|
|
70
|
+
row
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/app/models/theme_setting.rb
CHANGED
|
@@ -9,6 +9,9 @@ class ThemeSetting < ApplicationRecord
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
validates :app_name, presence: true, uniqueness: true
|
|
12
|
+
# Every page's head runs these through Studio::ColorScale, which reads six hex
|
|
13
|
+
# digits. A saved "#abc" would answer 500 on every page, the editor included.
|
|
14
|
+
validates(*ROLES.map { |role| db_column_for(role) }, format: { with: /\A#\h{6}\z/, message: "must be a #rrggbb colour" }, allow_blank: true)
|
|
12
15
|
|
|
13
16
|
# Returns the ThemeSetting for the current app, or a new unsaved instance.
|
|
14
17
|
def self.current
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<%# locals: (records:) %>
|
|
2
|
+
<div class="overflow-x-auto">
|
|
3
|
+
<table id="models-sessions-table" class="w-full min-w-[640px] text-sm">
|
|
4
|
+
<thead class="bg-surface-alt text-muted text-xs uppercase tracking-wide">
|
|
5
|
+
<tr>
|
|
6
|
+
<th class="px-4 py-3 text-left font-semibold">User</th>
|
|
7
|
+
<th class="px-4 py-3 text-left font-semibold">Started</th>
|
|
8
|
+
<th class="px-4 py-3 text-left font-semibold">Last seen</th>
|
|
9
|
+
</tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody class="divide-y divide-subtle">
|
|
12
|
+
<% records.each do |row| %>
|
|
13
|
+
<tr>
|
|
14
|
+
<td class="px-4 py-3">
|
|
15
|
+
<% user = row.user %>
|
|
16
|
+
<span class="font-semibold text-heading"><%= user ? (user.email.presence || user.try(:display_name).presence || "User ##{user.id}") : "Deleted user" %></span>
|
|
17
|
+
<% if row.admin %><span class="ml-2 label-upper">admin</span><% end %>
|
|
18
|
+
</td>
|
|
19
|
+
<td class="px-4 py-3 text-muted whitespace-nowrap"><%= time_ago_in_words(row.started_at) %> ago</td>
|
|
20
|
+
<td class="px-4 py-3 text-muted whitespace-nowrap"><%= time_ago_in_words(row.last_seen_at) %> ago</td>
|
|
21
|
+
</tr>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% if records.empty? %>
|
|
24
|
+
<tr><td colspan="3" class="px-4 py-6 text-muted">No sessions yet.</td></tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
</div>
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
<% content_for(:title, "#{@config.fetch(:label)} - Models - Admin") %>
|
|
2
2
|
|
|
3
|
-
<div class="max-w-7xl mx-auto px-4
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
<div class="max-w-7xl mx-auto px-4 pb-8 space-y-6">
|
|
4
|
+
<%# The title and a tab per model, one sticky row, as on /admin/style. A tab is a
|
|
5
|
+
link, so the open one is the page's own model. %>
|
|
6
|
+
<nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
|
|
7
|
+
style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
|
|
8
|
+
aria-label="Models">
|
|
9
|
+
<h1 class="text-xl font-bold text-heading shrink-0">Models</h1>
|
|
10
|
+
<div class="overflow-x-auto min-w-0 flex-1">
|
|
11
|
+
<div class="flex items-center gap-2 min-w-max pl-4">
|
|
12
|
+
<% admin_model_configs.each do |key, config| %>
|
|
13
|
+
<% open = key.to_s == @key %>
|
|
14
|
+
<%= link_to config.fetch(:label), admin_model_path(key),
|
|
15
|
+
class: "btn btn-sm #{open ? 'btn-primary' : 'btn-neutral'}",
|
|
16
|
+
aria: { current: (open ? "page" : nil) } %>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
12
19
|
</div>
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
</nav>
|
|
21
|
+
|
|
22
|
+
<p class="text-muted text-sm">
|
|
23
|
+
<%= @config.fetch(:description) %>.
|
|
24
|
+
Page <%= @page %> of <%= @total_pages %>, <%= @total_count %> total.
|
|
25
|
+
</p>
|
|
15
26
|
|
|
16
27
|
<section class="card overflow-hidden">
|
|
17
28
|
<%= render admin_models_table_partial(@key), records: @records %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil) %>
|
|
1
|
+
<%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil, voice: false, voice_copy: nil) %>
|
|
2
2
|
<%#
|
|
3
3
|
The shared agent chat (docs/AGENT_CHAT.md): one conversation with one agent,
|
|
4
4
|
held by the hub, on any app's page.
|
|
@@ -13,15 +13,25 @@
|
|
|
13
13
|
cards THE CARD SLOT: ready markup shown between the header and the
|
|
14
14
|
thread (/build's intake cards). A local, never a bare yield: a
|
|
15
15
|
partial rendered without a block yields the LAYOUT's body.
|
|
16
|
+
voice true puts hold to talk in the composer's mic slot, with the privacy
|
|
17
|
+
line under it. It still shows only where the app turned takes on
|
|
18
|
+
(Studio.voice_transcription) and only to a user
|
|
19
|
+
Studio.voice_chat_allowed lets talk; never to a signed-out visitor.
|
|
20
|
+
voice_copy the words: { privacy:, recorder:, chip: }, each optional, over the
|
|
21
|
+
defaults (Studio::AgentChat.voice_copy).
|
|
16
22
|
|
|
17
23
|
Top to bottom, as the hub's /build: the portrait and name, the card slot, the
|
|
18
24
|
thread, the typing line, the offline notice and the composer. The thread is
|
|
19
25
|
drawn by studio/agent_chat (the "agent-chat" controller, loaded lazily) from
|
|
20
26
|
the two <template>s below, which are studio/agent_chat/_turn: the look lives
|
|
21
27
|
here, in ERB, and the script only fills it. ONE PAGE HOLDS ONE CHAT.
|
|
28
|
+
|
|
29
|
+
A viewer Studio.agent_chat_allowed refuses gets nothing here: the proxy would
|
|
30
|
+
answer them 403 on every poll.
|
|
22
31
|
%>
|
|
23
32
|
<% souls = Studio.agent_chat_soul_list %>
|
|
24
33
|
<% raise ArgumentError, "studio/agent_chat/chat: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
|
|
34
|
+
<% if Studio.agent_chat_allowed?(studio_agent_chat_viewer) %>
|
|
25
35
|
<section class="w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0"
|
|
26
36
|
data-studio-controller="agent-chat" data-test="agent-chat"
|
|
27
37
|
data-agent-chat-soul="<%= soul %>" data-agent-chat-name="<%= name %>"
|
|
@@ -45,8 +55,13 @@
|
|
|
45
55
|
<p class="text-sm text-body min-w-0">Can't reach <%= name %> right now. Your messages are safe; try again in a moment.</p>
|
|
46
56
|
<button type="button" class="btn btn-secondary btn-sm shrink-0" data-agent-chat-retry data-test="agent-chat-retry">Try again</button>
|
|
47
57
|
</div>
|
|
48
|
-
|
|
58
|
+
<% mic = render("studio/agent_chat/voice", soul: soul, name: name, copy: voice_copy) if voice && studio_agent_chat_voice? %>
|
|
59
|
+
<%= render "studio/agent_chat/form", soul: soul, name: name, mic: mic %>
|
|
60
|
+
<% if mic %>
|
|
61
|
+
<p class="px-3 text-center text-xs text-secondary break-words" data-test="agent-chat-voice-privacy"><%= Studio::AgentChat.voice_copy(voice_copy)["privacy"] %></p>
|
|
62
|
+
<% end %>
|
|
49
63
|
</div>
|
|
50
64
|
<template data-agent-chat-template="user"><%= render "studio/agent_chat/turn", turn: { "role" => "user" }, name: name %></template>
|
|
51
65
|
<template data-agent-chat-template="agent"><%= render "studio/agent_chat/turn", turn: { "role" => "agent" }, name: name %></template>
|
|
52
66
|
</section>
|
|
67
|
+
<% end %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<%# locals: (soul:, name:) %>
|
|
2
|
-
<%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. %>
|
|
1
|
+
<%# locals: (soul:, name:, mic: nil) %>
|
|
2
|
+
<%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. `mic` is the mic slot: ready markup under the box (studio/agent_chat/_voice), nothing without it. %>
|
|
3
3
|
<%= form_with url: studio_agent_chat_messages_path, method: :post, local: true,
|
|
4
4
|
html: { style: "view-transition-name: agent-composer;" },
|
|
5
5
|
class: "sticky bottom-3 z-10 rounded-3xl border border-subtle bg-surface shadow-lg focus-within:border-primary transition-colors p-2 flex flex-col gap-2",
|
|
@@ -20,4 +20,5 @@
|
|
|
20
20
|
</svg>
|
|
21
21
|
</button>
|
|
22
22
|
</div>
|
|
23
|
+
<%= mic %>
|
|
23
24
|
<% end %>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<%# locals: (soul:, name:, copy: nil) %>
|
|
2
|
+
<%# Hold to talk, in the composer's mic slot (studio/agent_chat/_form;
|
|
3
|
+
docs/AGENT_CHAT.md, "Talking by voice"). The engine's microphone chip and
|
|
4
|
+
recorder capture the take; studio/agent_chat_voice (the lazy
|
|
5
|
+
"agent-chat-voice" controller) embeds it on this device when the page names
|
|
6
|
+
an embedder, posts it to POST /voice/takes and sends the words to the chat.
|
|
7
|
+
No audio is kept. Status lines are set as text.
|
|
8
|
+
|
|
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. %>
|
|
15
|
+
<% words = Studio::AgentChat.voice_copy(copy) %>
|
|
16
|
+
<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
|
+
color: var(--color-text-body, inherit); }
|
|
20
|
+
.studio-agent-voice-failed { display: none; margin: 0; padding: 0 .5rem; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
|
|
21
|
+
[data-studio-controller-failed~="agent-chat-voice"] > .studio-agent-voice-failed { display: block; }
|
|
22
|
+
/* One row where both fit; on a phone the chip takes the first line and the
|
|
23
|
+
talk button the whole of the second. */
|
|
24
|
+
.studio-agent-voice-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; }
|
|
25
|
+
.studio-agent-voice-row .studio-mic { display: contents; }
|
|
26
|
+
.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; }
|
|
28
|
+
.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; }
|
|
30
|
+
.studio-agent-voice-row .studio-recorder-button { flex: 1 1 10rem; min-width: 0; padding: .625rem 1rem; }
|
|
31
|
+
.studio-agent-voice-row .studio-recorder-timer { display: none; }
|
|
32
|
+
.studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-timer { display: inline; }
|
|
33
|
+
.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. */
|
|
36
|
+
.studio-agent-voice-row .studio-recorder-status:empty,
|
|
37
|
+
.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; }
|
|
39
|
+
.studio-agent-voice-row .studio-recorder-failed { flex: 1 0 100%; }
|
|
40
|
+
</style>
|
|
41
|
+
<div class="studio-agent-voice" role="group" aria-label="Talk to <%= name %>"
|
|
42
|
+
data-studio-controller="agent-chat-voice" data-agent-chat-voice data-test="agent-chat-voice"
|
|
43
|
+
data-agent-chat-voice-state="idle"
|
|
44
|
+
data-agent-chat-voice-soul="<%= soul %>" data-agent-chat-voice-name="<%= name %>"
|
|
45
|
+
data-agent-chat-voice-takes-url="<%= studio_voice_takes_path %>"
|
|
46
|
+
<%= tag.attributes("data-agent-chat-voice-embedder": studio_agent_chat_embedder(studio_agent_chat_viewer)) %>>
|
|
47
|
+
<p class="studio-agent-voice-status" role="status" aria-live="polite"
|
|
48
|
+
data-agent-chat-voice-status data-test="agent-chat-voice-status"></p>
|
|
49
|
+
<div class="studio-agent-voice-row">
|
|
50
|
+
<%= render "studio/voice/microphone_chip", copy: words["chip"] %>
|
|
51
|
+
<%= render "studio/voice/recorder", id: "#{soul}-voice-recorder", mode: "hold", copy: words["recorder"] %>
|
|
52
|
+
</div>
|
|
53
|
+
<p class="studio-agent-voice-failed">Voice didn't load on this page. Reload to try again, or type instead.</p>
|
|
54
|
+
</div>
|
|
@@ -58,8 +58,11 @@
|
|
|
58
58
|
|
|
59
59
|
<p class="studio-mic-help-other" data-microphone-help-other hidden><%= help.fetch("other") %></p>
|
|
60
60
|
|
|
61
|
-
<form method="dialog"
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
<%# A button, never <form method="dialog">: this chip can sit inside a form (the
|
|
62
|
+
chat's composer), where the parser drops a nested form and Close would
|
|
63
|
+
submit the outer one. studio/microphone_chip closes the dialog. %>
|
|
64
|
+
<div class="studio-mic-help-actions">
|
|
65
|
+
<button type="button" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
|
|
66
|
+
</div>
|
|
64
67
|
</div>
|
|
65
68
|
</dialog>
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
<%# Theme section of the living style guide (admin/style) — the LANDING section.
|
|
2
|
-
Theme owns "the colors": it leads with the 7 role tokens/swatches
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Save-in-place:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
it 204 once it has written; the standalone page's redirect is unchanged.
|
|
2
|
+
Theme owns "the colors": it leads with the 7 role tokens/swatches, then the
|
|
3
|
+
editor with a live dark/light preview, then the logos, the semantic tokens and
|
|
4
|
+
the type. GET /admin/theme redirects here.
|
|
5
|
+
|
|
6
|
+
Save-in-place: this section intercepts Save + Regenerate and submits them via
|
|
7
|
+
fetch, so the page never navigates away, the live preview stays put, and the
|
|
8
|
+
outcome is a toast. The fetch asks for JSON and the controller answers it 204
|
|
9
|
+
once it has written.
|
|
11
10
|
The editor is studio/theme_editor's dsThemeEditor factory, which
|
|
12
11
|
studio/alpine_shims publishes.
|
|
13
12
|
|
|
@@ -63,8 +62,7 @@
|
|
|
63
62
|
<div class="space-y-1">
|
|
64
63
|
<h2 class="text-2xl font-bold text-heading">Theme</h2>
|
|
65
64
|
<p class="text-muted text-sm max-w-2xl">
|
|
66
|
-
The color foundation. The 7-role system, editable here
|
|
67
|
-
<code class="font-mono text-2xs">/admin/theme</code>. Edits preview live; Save writes them
|
|
65
|
+
The color foundation. The 7-role system, editable here. Edits preview live; Save writes them
|
|
68
66
|
and Regenerate clears the theme cache. Both persist <strong>in place</strong> — the page never
|
|
69
67
|
leaves. Every specimen on this page restyles from these roles.
|
|
70
68
|
</p>
|
|
@@ -171,4 +169,69 @@
|
|
|
171
169
|
</div>
|
|
172
170
|
</div>
|
|
173
171
|
</div>
|
|
174
|
-
|
|
172
|
+
|
|
173
|
+
<% logos = Studio.respond_to?(:theme_logos) ? Array(Studio.theme_logos) : [] %>
|
|
174
|
+
<% if logos.any? %>
|
|
175
|
+
<div class="space-y-3">
|
|
176
|
+
<p class="label-upper">Logos</p>
|
|
177
|
+
<div class="grid gap-4 grid-cols-2 md:grid-cols-5">
|
|
178
|
+
<% logos.each do |logo| %>
|
|
179
|
+
<% file = logo.is_a?(Hash) ? logo[:file] : logo %>
|
|
180
|
+
<% title = logo.is_a?(Hash) ? logo[:title] : file %>
|
|
181
|
+
<div class="card p-4 space-y-3">
|
|
182
|
+
<p class="text-sm text-heading font-medium"><%= title %></p>
|
|
183
|
+
<div class="flex items-center justify-center w-20 h-20 rounded-lg" style="background-color: #ddd; background-image: linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%), linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%); background-size: 12px 12px; background-position: 0 0, 6px 6px;">
|
|
184
|
+
<img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
|
|
185
|
+
</div>
|
|
186
|
+
<code class="font-mono text-2xs text-muted break-all"><%= file %></code>
|
|
187
|
+
</div>
|
|
188
|
+
<% end %>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
<% end %>
|
|
192
|
+
|
|
193
|
+
<%# Semantic tokens: the utilities a page paints with, under the role colours. %>
|
|
194
|
+
<div class="space-y-3">
|
|
195
|
+
<p class="label-upper">Semantic tokens</p>
|
|
196
|
+
<div class="grid gap-3 grid-cols-2 md:grid-cols-5">
|
|
197
|
+
<% [["Page", "bg-page", :bg], ["Surface", "bg-surface", :bg], ["Surface Alt", "bg-surface-alt", :bg], ["Inset", "bg-inset", :bg],
|
|
198
|
+
["Heading", "text-heading", :text], ["Body", "text-body", :text], ["Secondary", "text-secondary", :text], ["Muted", "text-muted", :text],
|
|
199
|
+
["Border", "border-subtle", :border], ["Border Strong", "border-strong", :border]].each do |name, utility, kind| %>
|
|
200
|
+
<div class="card p-3 flex items-center gap-3">
|
|
201
|
+
<% case kind
|
|
202
|
+
when :bg %>
|
|
203
|
+
<div class="w-8 h-8 rounded-full border border-subtle shrink-0 <%= utility %>"></div>
|
|
204
|
+
<% when :text %>
|
|
205
|
+
<div class="w-8 h-8 rounded-full border border-subtle shrink-0 bg-surface flex items-center justify-center">
|
|
206
|
+
<span class="text-sm font-bold <%= utility %>">A</span>
|
|
207
|
+
</div>
|
|
208
|
+
<% else %>
|
|
209
|
+
<div class="w-8 h-8 rounded-full shrink-0 border-2 bg-transparent <%= utility %>"></div>
|
|
210
|
+
<% end %>
|
|
211
|
+
<div class="min-w-0">
|
|
212
|
+
<p class="text-heading text-sm font-semibold truncate"><%= name %></p>
|
|
213
|
+
<code class="font-mono text-2xs text-muted truncate block"><%= utility %></code>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<% end %>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<%# Type in this app's own font, named by role: the font itself is the host's. %>
|
|
221
|
+
<div class="space-y-3">
|
|
222
|
+
<p class="label-upper">Typography</p>
|
|
223
|
+
<div class="card p-6 space-y-4">
|
|
224
|
+
<p class="text-heading text-3xl font-extrabold">Heading</p>
|
|
225
|
+
<p class="text-heading text-xl font-bold">Subheading</p>
|
|
226
|
+
<p class="text-body text-base">Body text. The quick brown fox jumps over the lazy dog.</p>
|
|
227
|
+
<p class="text-secondary text-sm">Secondary text. Additional context and metadata.</p>
|
|
228
|
+
<p class="text-muted text-xs">Muted text. Timestamps, hints and fine print.</p>
|
|
229
|
+
<hr class="border-subtle">
|
|
230
|
+
<div class="flex flex-wrap gap-4">
|
|
231
|
+
<% [400, 500, 600, 700, 800, 900].each do |weight| %>
|
|
232
|
+
<span class="text-heading text-lg" style="font-weight: <%= weight %>"><%= weight %></span>
|
|
233
|
+
<% end %>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</section>
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
application.html.erb, inheriting that app's navbar and theme, so every
|
|
4
4
|
specimen restyles per app and in dark or light automatically.
|
|
5
5
|
|
|
6
|
-
Five ENGINE sections, reached by the sticky
|
|
6
|
+
Five ENGINE sections, reached by the sticky title row below and rendered
|
|
7
7
|
as sibling partials: Theme is the landing section (the color foundation - it
|
|
8
|
-
owns the role tokens/swatches, the
|
|
9
|
-
|
|
8
|
+
owns the role tokens/swatches, the theme editor, the live preview, and the
|
|
9
|
+
logos and type), then Modals (the shared host + engine card blocks), Tricks
|
|
10
10
|
(the button/surface/motion/effect/leveling primitives, framed as a board of
|
|
11
11
|
copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
|
|
12
12
|
Components (the ViewComponent previews, linking into the gallery at
|
|
@@ -54,36 +54,34 @@
|
|
|
54
54
|
]
|
|
55
55
|
%>
|
|
56
56
|
<div class="max-w-6xl mx-auto px-4 pb-16">
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<div class="flex items-center gap-2 min-w-max">
|
|
82
|
-
<% sections.each do |anchor, label| %>
|
|
83
|
-
<a href="#<%= anchor %>" class="btn btn-neutral btn-sm"><%= label %></a>
|
|
57
|
+
<%# The page's title and its section tabs, one sticky row under the whole pinned
|
|
58
|
+
stack (--pin-stack-bottom; then --nav-bottom and --nav-h for a host on an
|
|
59
|
+
older publisher; then 0). Not --nav-h first: that is the header's HEIGHT,
|
|
60
|
+
and chrome stacked above the header would sit over the row by its own
|
|
61
|
+
height. The tabs scroll sideways on a narrow screen, softened by
|
|
62
|
+
.fade-edge-x; px-8 matches its 2rem fade, so a tab at rest is not faded.
|
|
63
|
+
The row sits at --z-sticky, above --z-raised, so the live hold button's
|
|
64
|
+
portal bubbles scroll under it rather than over it.
|
|
65
|
+
|
|
66
|
+
The style-section-nav controller moves the highlight as the page scrolls
|
|
67
|
+
(studio/style_section_nav). Theme starts active, so the first paint is
|
|
68
|
+
right before the module loads. %>
|
|
69
|
+
<nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
|
|
70
|
+
style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
|
|
71
|
+
aria-label="Style guide sections"
|
|
72
|
+
data-studio-controller="style-section-nav">
|
|
73
|
+
<h1 class="text-xl font-bold text-heading shrink-0">Style</h1>
|
|
74
|
+
<div class="fade-edge-x overflow-x-auto min-w-0 flex-1" data-style-section-nav-target="scroller">
|
|
75
|
+
<div class="flex items-center gap-2 min-w-max px-8">
|
|
76
|
+
<% sections.each_with_index do |(anchor, label), index| %>
|
|
77
|
+
<a href="#<%= anchor %>"
|
|
78
|
+
class="btn btn-sm <%= index.zero? ? "btn-primary" : "btn-neutral" %>"
|
|
79
|
+
<%= 'aria-current="true"'.html_safe if index.zero? %>
|
|
80
|
+
data-style-section-nav-target="link"><%= label %></a>
|
|
84
81
|
<% end %>
|
|
85
82
|
</div>
|
|
86
83
|
</div>
|
|
84
|
+
<p class="label-upper shrink-0 hidden md:block" title="studio-engine">v<%= Studio::VERSION %></p>
|
|
87
85
|
</nav>
|
|
88
86
|
|
|
89
87
|
<div class="space-y-16 pt-10">
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# One row per signed-in browser session (Studio::UserSession). A small, cached
|
|
2
|
+
# record every app keeps the same way. No foreign key: a host's users table is
|
|
3
|
+
# its own, and a deleted user's sessions may stay as counts.
|
|
4
|
+
class CreateStudioUserSessions < ActiveRecord::Migration[7.2]
|
|
5
|
+
def change
|
|
6
|
+
create_table :studio_user_sessions do |t|
|
|
7
|
+
t.bigint :user_id, null: false
|
|
8
|
+
t.boolean :admin, null: false, default: false
|
|
9
|
+
t.datetime :started_at, null: false
|
|
10
|
+
t.datetime :last_seen_at, null: false
|
|
11
|
+
t.timestamps
|
|
12
|
+
t.index :user_id
|
|
13
|
+
t.index :last_seen_at
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/docs/SITE_FOOTER.md
CHANGED
|
@@ -319,6 +319,39 @@ config.site_footer = ->(view) {
|
|
|
319
319
|
only with a lockup (`studio/site_footer/_lockup_assets`). An engine older
|
|
320
320
|
than the fact ignores the key, so a host can declare it before it upgrades.
|
|
321
321
|
|
|
322
|
+
## The Chat link
|
|
323
|
+
|
|
324
|
+
The standard way to reach an app's agent chat ([`AGENT_CHAT.md`](AGENT_CHAT.md)):
|
|
325
|
+
a plain link at the end of the column headed **Contact** (any case), or a new
|
|
326
|
+
Contact column at the end if the facts have none. An app that sets nothing sees
|
|
327
|
+
no change.
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
|
|
331
|
+
# config.site_footer_chat_label = "Chat" # the default
|
|
332
|
+
# config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
| Setting | Meaning | Unset |
|
|
336
|
+
|---------|---------|-------|
|
|
337
|
+
| `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page | No link |
|
|
338
|
+
| `site_footer_chat_label` | The link's words | "Chat" |
|
|
339
|
+
| `site_footer_chat_allowed` | A callable taking the viewer: `current_user`, **nil when signed out**, so write `user&.admin?` | `Studio.agent_chat_allowed` (everyone, by default) |
|
|
340
|
+
|
|
341
|
+
- The link looks and behaves as any other column link (`_link.html.erb`). An href
|
|
342
|
+
the footer refuses (`javascript:`) adds nothing, and a Contact column that
|
|
343
|
+
already links the same path keeps its own row and label.
|
|
344
|
+
- A hook that raises hides the link and reports once through
|
|
345
|
+
`Studio::SiteFooter.reporter`, so `user.admin?` on a signed-out viewer shows the
|
|
346
|
+
link to no one and costs no page. Write `user&.admin?` all the same.
|
|
347
|
+
- **Hiding the link is not a gate.** `Studio.agent_chat_allowed` is
|
|
348
|
+
([`AGENT_CHAT.md`](AGENT_CHAT.md), "Who may use it"); set that one, and the link
|
|
349
|
+
follows it.
|
|
350
|
+
- **An admin-only link needs a page that shows the footer to a signed-in viewer.**
|
|
351
|
+
A signed-in viewer sees the footer only on `site_footer_controllers` (see
|
|
352
|
+
*Where it shows*), so list the pages your admins will look at.
|
|
353
|
+
- Never cache the footer across viewers while the link is limited.
|
|
354
|
+
|
|
322
355
|
## Where it shows
|
|
323
356
|
|
|
324
357
|
`studio_site_footer` asks `config.site_footer_visible`, one callable that
|