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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +18 -3
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- 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/marketing_kit_controller.rb +24 -0
- 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/helpers/studio/site_footer_helper.rb +15 -2
- 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/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -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/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +9 -1
- 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/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +15 -12
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +4 -3
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -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/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +21 -34
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +146 -2
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +65 -6
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +146 -6
- metadata +21 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- 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.
|
|
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
|
12
|
-
// because x-data="
|
|
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
|
-
//
|
|
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 ||
|
|
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
|
|
@@ -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:
|
|
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:
|
|
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
|
|
9
|
-
|
|
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" %>
|