studio-engine 0.122.0 → 0.124.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +142 -0
  3. data/README.md +18 -3
  4. data/app/assets/tailwind/studio_engine/engine.css +210 -11
  5. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  6. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  7. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  8. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  9. data/app/controllers/studio/voice_takes_controller.rb +11 -0
  10. data/app/controllers/style_controller.rb +2 -5
  11. data/app/controllers/theme_settings_controller.rb +8 -8
  12. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  13. data/app/helpers/studio/site_footer_helper.rb +15 -2
  14. data/app/javascript/studio/agent_chat.js +42 -2
  15. data/app/javascript/studio/agent_chat_voice.js +318 -0
  16. data/app/javascript/studio/alpine_shims.js +1 -3
  17. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  18. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  19. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  20. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  21. data/app/javascript/studio/microphone_chip.js +1 -0
  22. data/app/javascript/studio/page_header.js +24 -0
  23. data/app/javascript/studio/page_transitions.js +181 -0
  24. data/app/javascript/studio/pinned_stack.js +12 -1
  25. data/app/javascript/studio/stimulus.js +9 -1
  26. data/app/javascript/studio/style_guide.js +2 -0
  27. data/app/javascript/studio/style_section_nav.js +16 -0
  28. data/app/javascript/studio/theme_editor.js +6 -106
  29. data/app/javascript/studio/voice_recorder.js +23 -2
  30. data/app/models/studio/user_session.rb +76 -0
  31. data/app/models/theme_setting.rb +3 -0
  32. data/app/views/components/_link_sidebar.html.erb +5 -2
  33. data/app/views/components/_user_nav.html.erb +5 -4
  34. data/app/views/layouts/_navbar.html.erb +7 -4
  35. data/app/views/layouts/studio/_head.html.erb +6 -0
  36. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  37. data/app/views/studio/_page_header.html.erb +65 -0
  38. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  39. data/app/views/studio/admin_models/show.html.erb +15 -12
  40. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  41. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  42. data/app/views/studio/agent_chat/_form.html.erb +4 -3
  43. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  44. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  45. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  46. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  47. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  48. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  49. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  50. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  51. data/app/views/style/_theme.html.erb +75 -12
  52. data/app/views/style/_tricks.html.erb +35 -0
  53. data/app/views/style/index.html.erb +21 -34
  54. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  55. data/docs/SITE_FOOTER.md +146 -2
  56. data/lib/studio/agent_chat.rb +83 -5
  57. data/lib/studio/marketing_kit.rb +304 -0
  58. data/lib/studio/site_footer.rb +65 -6
  59. data/lib/studio/ui_primitives.rb +4 -2
  60. data/lib/studio/version.rb +1 -1
  61. data/lib/studio.rb +146 -6
  62. metadata +21 -4
  63. data/app/views/studio/admin_models/index.html.erb +0 -31
  64. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -0,0 +1,181 @@
1
+ // studio/page_transitions: the app-to-app page transition (Studio.page_transition_direction,
2
+ // docs/PAGE_TRANSITIONS.md). Loaded by its own tag in layouts/studio/_head, only with the
3
+ // setting on. It names a style on <html>, and engine.css draws it. It imports nothing.
4
+ //
5
+ // Turbo calls document.startViewTransition BEFORE turbo:before-render, and the browser takes
6
+ // the old page's picture before Turbo's callback runs. So what the old picture holds is
7
+ // settled ahead of the visit: the held banner stack and the morph names are decided here on
8
+ // turbo:visit and from the response, never first at before-render.
9
+ //
10
+ // The effects are from the Codrops Page Transitions demo; the keyframes were written fresh.
11
+
12
+ export const DIRECTIONS = Object.freeze({
13
+ left: Object.freeze(["fade-left", "unfold-right"]),
14
+ right: Object.freeze(["fade-right", "unfold-left"]),
15
+ top: Object.freeze(["fade-top", "unfold-bottom"]),
16
+ bottom: Object.freeze(["fade-bottom", "unfold-top"]),
17
+ })
18
+
19
+ // A same-page visit (a refresh, a poll): the transition still runs, and draws nothing.
20
+ export const STILL = "still"
21
+ export const READY_CAP_MS = 500
22
+ const MAX_READY_IMAGES = 12
23
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
24
+
25
+ export function pickStyle(direction, random = Math.random) {
26
+ if (!Object.hasOwn(DIRECTIONS, direction)) return null
27
+ const pair = DIRECTIONS[direction]
28
+ return pair[Math.floor(random() * pair.length)]
29
+ }
30
+
31
+ // The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
32
+ // and a morph between two pages stands).
33
+ export function planVisit({ direction, from, to, transitions, sharedName }, random = Math.random) {
34
+ if (!transitions || !from || !to || !Object.hasOwn(DIRECTIONS, direction)) return null
35
+ if (from === to) return STILL
36
+ return sharedName ? null : pickStyle(direction, random)
37
+ }
38
+
39
+ // A slow image must not hold a click: past the cap the swap goes ahead.
40
+ export function withCap(promise, ms, timer = setTimeout) {
41
+ return Promise.race([promise, new Promise((done) => timer(done, ms))])
42
+ }
43
+
44
+ export function morphNames(root) {
45
+ return new Set([...root.querySelectorAll("[data-vt-morph]")].map((el) => el.getAttribute("data-vt-morph")))
46
+ }
47
+
48
+ export function shareName(here, there) {
49
+ return !!there && [...here].some((name) => there.has(name))
50
+ }
51
+
52
+ // Exactly one element takes the stack's view-transition name, however many a page renders:
53
+ // a duplicate name silently cancels the whole transition.
54
+ export function markHeld(root) {
55
+ const stack = root.querySelector(".studio-bar-stack")
56
+ if (stack) stack.setAttribute("data-vt-held", "")
57
+ }
58
+
59
+ export function eagerImages(body) {
60
+ return [...body.querySelectorAll("img")]
61
+ .filter((img) => img.getAttribute("loading") !== "lazy" && (img.getAttribute("src") || img.getAttribute("srcset")))
62
+ .slice(0, MAX_READY_IMAGES)
63
+ }
64
+
65
+ // The incoming body is inert, so each image is copied here to load, <picture> and all, so
66
+ // the browser chooses the same source. Marked decoding=sync, the real one paints at once.
67
+ function loadImage(doc, img) {
68
+ img.decoding = "sync"
69
+ const source = img.closest("picture") || img
70
+ const copy = doc.importNode(source, true)
71
+ const loader = copy.tagName === "IMG" ? copy : copy.querySelector("img")
72
+ return loader.decode().catch(() => {})
73
+ }
74
+
75
+ function readyForSwap(doc, body) {
76
+ const fonts = doc.fonts ? doc.fonts.ready.catch(() => {}) : null
77
+ // At least one tick, so Turf's one-tick hold for its snapshot stays honest.
78
+ const tick = new Promise((done) => setTimeout(done, 0))
79
+ return Promise.all([...eagerImages(body).map((img) => loadImage(doc, img)), fonts, tick])
80
+ }
81
+
82
+ // Turbo's resume is a promise's resolve, so the FIRST caller starts the render. Another
83
+ // listener's short hold (Turf's turbo_snapshot_cache resumes on a timeout of 0) would cut
84
+ // this wait to nothing, so a listener that runs after this one gets a resume that does
85
+ // nothing, and this wait releases the render. It always does: cap, failure or success.
86
+ export function holdRender(event, wait, cap = READY_CAP_MS) {
87
+ const resume = event.detail && event.detail.resume
88
+ if (typeof resume !== "function") return false
89
+ event.preventDefault()
90
+ event.detail.resume = () => {}
91
+ const release = () => resume()
92
+ withCap(Promise.resolve().then(wait), cap).then(release, release)
93
+ return true
94
+ }
95
+
96
+ function readDirection(doc) {
97
+ const meta = doc.querySelector('meta[name="page-transition-direction"]')
98
+ return meta ? meta.content : null
99
+ }
100
+
101
+ let installed = false
102
+
103
+ export function installPageTransitions(doc) {
104
+ if (installed || !readDirection(doc)) return false
105
+ installed = true
106
+
107
+ const win = doc.defaultView
108
+ const root = doc.documentElement
109
+ const known = new Map()
110
+ let from = win.location.pathname
111
+ let target = null
112
+
113
+ const nameTheMorph = (shared) => {
114
+ if (shared) root.dataset.pageTransitionMorph = ""
115
+ else delete root.dataset.pageTransitionMorph
116
+ }
117
+ const predict = (names) => nameTheMorph(target !== from && shareName(morphNames(doc), names))
118
+
119
+ root.dataset.pageTransitionDirection = readDirection(doc)
120
+ markHeld(doc)
121
+ known.set(from, morphNames(doc))
122
+
123
+ doc.addEventListener("turbo:visit", (event) => {
124
+ root.dataset.pageTransitionVisit = ""
125
+ target = new URL(event.detail.url).pathname
126
+ predict(known.get(target))
127
+ })
128
+
129
+ // A page never visited: read its names from the response, ahead of the old page's picture.
130
+ // Only a page with names of its own can share one, so every other page skips the parse.
131
+ doc.addEventListener("turbo:before-fetch-response", (event) => {
132
+ if (event.target && event.target.tagName === "TURBO-FRAME") return
133
+ if (!morphNames(doc).size) return
134
+ const { fetchResponse } = event.detail
135
+ Promise.resolve(fetchResponse.responseHTML).then((html) => {
136
+ if (!html) return
137
+ const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
138
+ const path = new URL(fetchResponse.response.url).pathname
139
+ known.set(path, names)
140
+ if (target && (path === target || fetchResponse.redirected)) predict(names)
141
+ }).catch(() => {})
142
+ })
143
+
144
+ // In capture, so the hold is in place before any other listener takes Turbo's resume.
145
+ doc.addEventListener("turbo:before-render", (event) => {
146
+ const { newBody } = event.detail
147
+ markHeld(newBody)
148
+ const names = morphNames(newBody)
149
+ if (target) known.set(target, names)
150
+ const sharedName = shareName(morphNames(doc), names)
151
+ const style = planVisit({
152
+ direction: readDirection(doc),
153
+ from,
154
+ to: target,
155
+ transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
156
+ sharedName,
157
+ })
158
+ nameTheMorph(sharedName && style !== STILL)
159
+ if (style) root.dataset.pageTransition = style
160
+ else delete root.dataset.pageTransition
161
+ if (style && style !== STILL) holdRender(event, () => readyForSwap(doc, newBody))
162
+ }, { capture: true })
163
+
164
+ // Turbo fires this after the transition has finished.
165
+ doc.addEventListener("turbo:load", () => {
166
+ from = win.location.pathname
167
+ target = null
168
+ known.set(from, morphNames(doc))
169
+ delete root.dataset.pageTransition
170
+ delete root.dataset.pageTransitionVisit
171
+ nameTheMorph(false)
172
+ })
173
+
174
+ // A visit that never renders: the stack must not keep its name at rest.
175
+ for (const name of ["turbo:fetch-request-error", "turbo:reload"]) {
176
+ doc.addEventListener(name, () => { delete root.dataset.pageTransitionVisit })
177
+ }
178
+ return true
179
+ }
180
+
181
+ if (typeof document !== "undefined") installPageTransitions(document)
@@ -141,6 +141,17 @@ export function measurePin(el, name, legacy, timeline) {
141
141
  return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
142
142
  }
143
143
 
144
+ // WHERE AN ADOPTED HEADER GOES: its place in the document, since order is
145
+ // document order (composed). Appended, it read as below every layer, so a layer
146
+ // under it (studio/_page_header) composed a top of 0 and pinned over it.
147
+ export function documentIndex(list, el) {
148
+ var FOLLOWING = 4; // Node.DOCUMENT_POSITION_FOLLOWING
149
+ for (var i = 0; i < list.length; i++) {
150
+ if (el.compareDocumentPosition(list[i].el) & FOLLOWING) return i;
151
+ }
152
+ return list.length;
153
+ }
154
+
144
155
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
145
156
  //
146
157
  // This used to build a `pins` array once and hold each element by reference
@@ -179,7 +190,7 @@ export function readStack() {
179
190
  // contest board and mcritchie-studio's heartbeat CSS all read — would
180
191
  // never publish at all. Both live consumers own their header, so it is
181
192
  // adopted as a pin even when it does not ask to be.
182
- if (header && !headerPinned) list.push(measurePin(header, 'nav', true, timeline));
193
+ if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
183
194
  return {
184
195
  pins: list,
185
196
  stack: timeline ? pinnedAt(list) : composed(list),
@@ -63,6 +63,11 @@ export const schema = {
63
63
  // So are the voice capture pair (docs/VOICE_CAPTURE.md): a chip whose controller
64
64
  // failed still shows its prompt line, and a recorder whose controller failed
65
65
  // says recording did not load (data-studio-controller-failed="voice-recorder").
66
+ // So is the chat's mic: a mic whose controller failed says voice did not load,
67
+ // and the box still types (data-studio-controller-failed="agent-chat-voice").
68
+ // So is copy-text: a colour whose controller failed still shows its hex to select.
69
+ // So is the page header: its tabs are links, and a row whose controller failed
70
+ // keeps its bar at all times (data-studio-controller-failed="page-header").
66
71
  export const LAZY = {
67
72
  "board": () => import("studio/controllers/board_controller"),
68
73
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -73,9 +78,12 @@ export const LAZY = {
73
78
  "survey": () => import("studio/controllers/survey_controller"),
74
79
  "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
75
80
  "agent-chat": () => import("studio/controllers/agent_chat_controller"),
81
+ "agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
76
82
  "voice-profile": () => import("studio/controllers/voice_profile_controller"),
77
83
  "microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
78
- "voice-recorder": () => import("studio/controllers/voice_recorder_controller")
84
+ "voice-recorder": () => import("studio/controllers/voice_recorder_controller"),
85
+ "copy-text": () => import("studio/controllers/copy_text_controller"),
86
+ "page-header": () => import("studio/controllers/page_header_controller")
79
87
  }
80
88
 
81
89
  export const application = Application.start(document.documentElement, schema)
@@ -9,5 +9,7 @@
9
9
  // demos the moment the page is up finds them there.
10
10
  import { application } from "studio/stimulus"
11
11
  import StyleModalsController from "studio/controllers/style_modals_controller"
12
+ import StyleSectionNavController from "studio/controllers/style_section_nav_controller"
12
13
 
13
14
  application.register("style-modals", StyleModalsController)
15
+ application.register("style-section-nav", StyleSectionNavController)
@@ -0,0 +1,16 @@
1
+ // studio/style_section_nav: which section of the style guide the reader is in.
2
+ // It imports nothing; test/javascript/style_section_nav.test.mjs loads it as a
3
+ // data: module.
4
+ //
5
+ // `tops` is each section's top in viewport pixels, in page order. A section is
6
+ // current once its top reaches the bottom of the sticky row (`line`). Before
7
+ // the first one reaches it, the first is current. At the end of the page the
8
+ // last is current, because a short last section can never reach the line.
9
+ export function activeIndex(tops, line, atPageEnd = false) {
10
+ if (tops.length === 0) return -1
11
+ if (atPageEnd) return tops.length - 1
12
+
13
+ let active = 0
14
+ tops.forEach((top, index) => { if (top <= line) active = index })
15
+ return active
16
+ }
@@ -1,72 +1,18 @@
1
- // studio/theme_editor: the two live-preview theme editors, as the Alpine
2
- // factories their templates bind.
1
+ // studio/theme_editor: the live-preview theme editor, as the Alpine factory its
2
+ // template binds.
3
3
  //
4
- // themeEditor(colors) theme_settings/edit, the standalone /admin/theme page
5
4
  // dsThemeEditor(seed) style/_theme, the Theme section of the style guide
6
5
  //
7
- // Both are seeded with the server-resolved role colours and write the themeable
6
+ // It is seeded with the server-resolved role colours and writes the themeable
8
7
  // role variables on the document as the operator types, so every specimen on
9
8
  // the page restyles at once. The inputs still carry the real values.
10
9
  //
11
- // studio/alpine_shims publishes both as window globals before Alpine starts,
12
- // because x-data="themeEditor(...)" is evaluated the moment Alpine does.
10
+ // studio/alpine_shims publishes it as a window global before Alpine starts,
11
+ // because x-data="dsThemeEditor(...)" is evaluated the moment Alpine does.
13
12
  //
14
13
  // It imports nothing; test/javascript/theme_editor.test.mjs loads it as a
15
14
  // data: module.
16
15
 
17
- // The seven roles the standalone editor edits, in the order it lists them.
18
- export const ROLES = ["primary", "success", "accent", "warning", "danger", "dark", "light"]
19
-
20
- // A typed colour as #rrggbb: a missing # is added and a 3-digit hex is doubled.
21
- // Answers null for anything that is not a 3- or 6-digit hex.
22
- export function normalizeHex(value) {
23
- let hex = String(value == null ? "" : value).trim()
24
- if (!hex.startsWith("#")) hex = "#" + hex
25
- if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
26
- hex = "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
27
- }
28
- return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : null
29
- }
30
-
31
- // `hex` (#rrggbb) moved `amount` (0..1) of the way to white.
32
- export function lightenColor(hex, amount) {
33
- hex = hex.replace("#", "")
34
- const r = parseInt(hex.substring(0, 2), 16)
35
- const g = parseInt(hex.substring(2, 4), 16)
36
- const b = parseInt(hex.substring(4, 6), 16)
37
- const nr = Math.round(r + (255 - r) * amount)
38
- const ng = Math.round(g + (255 - g) * amount)
39
- const nb = Math.round(b + (255 - b) * amount)
40
- return "#" + [nr, ng, nb].map((c) => c.toString(16).padStart(2, "0")).join("")
41
- }
42
-
43
- // The standalone editor's colours: every role present, blank when the server
44
- // resolved none.
45
- export function seededColors(seed) {
46
- const colors = {}
47
- ROLES.forEach((role) => { colors[role] = (seed && seed[role]) || "" })
48
- return colors
49
- }
50
-
51
- // What the standalone editor writes on the root for `colors`, as
52
- // [property, value] pairs; a null value removes the property.
53
- //
54
- // btn-warning and btn-danger paint --color-<role>-fill first (the resolver
55
- // emits it for the engine defaults), so a typed colour sets it too or the
56
- // specimens keep the default fill. A cleared colour drops the override.
57
- export function previewProperties(colors) {
58
- const properties = [
59
- ["--color-cta", colors.primary],
60
- ["--color-success", colors.success],
61
- ["--color-warning", colors.warning || "#FF7C47"],
62
- ["--color-danger", colors.danger || "#EF4444"]
63
- ]
64
- for (const role of ["warning", "danger"]) {
65
- properties.push([`--color-${role}-fill`, colors[role] ? colors[role] : null])
66
- }
67
- return properties
68
- }
69
-
70
16
  // What the style guide's editor writes on the root for `colors`. It sets only
71
17
  // what has a value and removes nothing.
72
18
  export function stylePreviewProperties(colors) {
@@ -87,52 +33,6 @@ function applyProperties(style, properties) {
87
33
  }
88
34
  }
89
35
 
90
- // x-data="themeEditor({...})": theme_settings/edit.
91
- export function themeEditor(seed) {
92
- return {
93
- colors: seededColors(seed),
94
-
95
- resetColor(role, defaultVal) {
96
- this.colors[role] = defaultVal
97
- this.updatePreview()
98
- },
99
-
100
- normalizeHex(role) {
101
- const hex = normalizeHex(this.colors[role])
102
- if (hex) this.colors[role] = hex
103
- this.updatePreview()
104
- },
105
-
106
- // Live CSS variables on the document, for immediate feedback.
107
- updatePreview() {
108
- applyProperties(document.documentElement.style, previewProperties(this.colors))
109
- },
110
-
111
- darkPreviewStyle() {
112
- return { backgroundColor: this.colors.dark, borderRadius: "0" }
113
- },
114
-
115
- darkSurfaceStyle() {
116
- return {
117
- backgroundColor: this.lightenColor(this.colors.dark, 0.3),
118
- border: "1px solid rgba(255,255,255,0.1)"
119
- }
120
- },
121
-
122
- lightPreviewStyle() {
123
- return { backgroundColor: this.colors.light, borderRadius: "0" }
124
- },
125
-
126
- lightSurfaceStyle() {
127
- return { backgroundColor: "#ffffff", border: "1px solid #e2e8f0" }
128
- },
129
-
130
- lightenColor(hex, amount) {
131
- return lightenColor(hex, amount)
132
- }
133
- }
134
- }
135
-
136
36
  // The method a form really sends, and its body without Rails' _method override.
137
37
  // The editor's form carries _method=patch, and sending the real method means
138
38
  // nothing depends on Rack::MethodOverride parsing a multipart fetch body.
@@ -170,7 +70,7 @@ export function failureReason(response) {
170
70
  //
171
71
  // SAVE IN PLACE. Save and Regenerate submit by fetch and stay on the style
172
72
  // guide: the form's @submit.prevent stops the browser navigating to
173
- // /admin/theme, the live preview persists, and the outcome is a toast (the
73
+ // its save endpoint, the live preview persists, and the outcome is a toast (the
174
74
  // engine's `toast` window event). The form's own POST still works when Alpine
175
75
  // never loads.
176
76
  export function dsThemeEditor(seed) {
@@ -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 || !copy.recording[kind]) return
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
@@ -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
@@ -6,6 +6,9 @@
6
6
  this automatically when sections are declared. %>
7
7
  <% sections = studio_sidebar_sections %>
8
8
  <% sidebar_admin = respond_to?(:admin?) && admin? %>
9
+ <%# A signed-out visitor's panel has no title: its links say what it is. It keeps its
10
+ accessible name (aria_label) and its close button. %>
11
+ <% sidebar_title = if sidebar_admin then "Admin Menu" elsif respond_to?(:logged_in?) && logged_in? then "Links" end %>
9
12
  <% close_action = "$store.sidebars.linkTreeOpen = false" %>
10
13
 
11
14
  <style>
@@ -113,7 +116,7 @@
113
116
 
114
117
  <%= render "components/sidebar_panel",
115
118
  id: "studio-link-sidebar",
116
- title: sidebar_admin ? "Admin Menu" : "Links",
119
+ title: sidebar_title,
117
120
  open: "$store.sidebars.linkTreeOpen",
118
121
  body_html: body_html,
119
122
  footer_html: footer_html,
@@ -126,7 +129,7 @@
126
129
 
127
130
  <%= render "components/sidebar_panel",
128
131
  id: "studio-link-sidebar-mobile",
129
- title: sidebar_admin ? "Admin Menu" : "Links",
132
+ title: sidebar_title,
130
133
  open: "$store.sidebars.linkTreeOpen",
131
134
  body_html: body_html,
132
135
  footer_html: footer_html,
@@ -5,8 +5,8 @@
5
5
  { partial: "path", locals: { ... } } when the partial needs locals:
6
6
  balance_slot — balance display, rendered at the start of the
7
7
  top row (e.g. a wallet balance link)
8
- extra_icons_slot — app-specific icon buttons, rendered before the
9
- admin gear + theme toggle
8
+ extra_icons_slot — app-specific icon buttons, rendered between the
9
+ theme toggle and the admin gear
10
10
  div2_slot — replaces the default second row (the wallet
11
11
  address + level progress bar) entirely
12
12
  (e.g. a seeds bar)
@@ -329,13 +329,14 @@
329
329
  <div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
330
330
  <% end %>
331
331
  <% else %>
332
+ <%# The signed-in rail's order: the theme toggle, then the gear. Desktop
333
+ only, as above: the phone row carries the toggle below md. %>
334
+ <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
332
335
  <% if menu_trigger %>
333
336
  <span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
334
337
  <% else %>
335
338
  <%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
336
339
  <% end %>
337
- <%# Desktop only, as above: the phone row carries the toggle below md. %>
338
- <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
339
340
  <div class="flex items-center gap-3 ml-auto">
340
341
  <%= link_to Studio.sign_in_label, login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
341
342
  <%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>