studio-engine 0.95.0 → 0.96.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 +131 -16
- data/README.md +193 -12
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/controllers/modal_host_controller.js +40 -0
- data/app/javascript/studio/modal_host.js +574 -0
- data/app/models/concerns/sluggable.rb +11 -1
- data/app/models/studio/knowledge_doc/recording.rb +214 -0
- data/app/models/studio/knowledge_doc.rb +3 -0
- data/app/views/layouts/studio/_head.html.erb +10 -2
- data/app/views/studio/emails/index.html.erb +13 -0
- data/app/views/studio/modals/_host.html.erb +33 -597
- data/app/views/studio/modals/_load_convention.html.erb +6 -27
- data/app/views/studio/modals/_scoped_host.html.erb +14 -257
- data/app/views/studio/modals/auth/_resend_footer.html.erb +2 -2
- data/app/views/studio/modals/blocks/_processing_card.html.erb +1 -1
- data/app/views/style/_modals.html.erb +12 -15
- data/db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb +19 -0
- data/lib/studio/component_gallery.rb +13 -3
- data/lib/studio/email_image_generator.rb +125 -0
- data/lib/studio/engine.rb +2 -5
- data/lib/studio/knowledge_recording.rb +579 -0
- data/lib/studio/knowledge_transcript.rb +185 -0
- data/lib/studio/s3/multipart.rb +193 -0
- data/lib/studio/theme_resolver.rb +11 -22
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +33 -1
- data/lib/tasks/studio_knowledge.rake +89 -0
- metadata +10 -1
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
resolveAt = max(min_duration, actual_completion)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
style
|
|
13
|
-
page share a single definition:
|
|
10
|
+
studio/modal_host publishes it on window.StudioModals on every page, merged
|
|
11
|
+
over anything an app defined first, so every consumer app and the
|
|
12
|
+
/admin/style demo page share a single definition:
|
|
14
13
|
|
|
15
14
|
MIN_LOAD_MS — the standard default minimum (1400 ms). A caller may pass its
|
|
16
15
|
own, but reach for this so every load modal feels consistent.
|
|
@@ -29,27 +28,7 @@
|
|
|
29
28
|
min_duration exactly:
|
|
30
29
|
StudioModals.holdAtLeast(StudioModals.MIN_LOAD_MS).then(advance);
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
THIS PARTIAL RENDERS NOTHING. It carried the convention as an inline script
|
|
32
|
+
until the modal host moved to studio/modal_host; it stays, empty, so an app
|
|
33
|
+
that still renders it by name gets no missing-template error.
|
|
34
34
|
%>
|
|
35
|
-
<script>
|
|
36
|
-
(function () {
|
|
37
|
-
window.StudioModals = window.StudioModals || {};
|
|
38
|
-
// Standard minimum-visible duration for a load spinner, in ms. ~1200–1500
|
|
39
|
-
// reads as "processing → done"; below ~1s the spinner flashes and reads as a
|
|
40
|
-
// glitch. Callers may override per-modal, but default to this for consistency.
|
|
41
|
-
window.StudioModals.MIN_LOAD_MS = window.StudioModals.MIN_LOAD_MS || 1400;
|
|
42
|
-
// holdAtLeast(minMs).then(cb) — cb fires at max(minMs, elapsed-since-call).
|
|
43
|
-
// Stamp the moment a loading view becomes visible; the anti-flicker floor.
|
|
44
|
-
window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function (minMs) {
|
|
45
|
-
var startedAt = Date.now();
|
|
46
|
-
return {
|
|
47
|
-
then: function (callback) {
|
|
48
|
-
var remaining = Math.max(0, minMs - (Date.now() - startedAt));
|
|
49
|
-
if (remaining === 0) { callback(); return; }
|
|
50
|
-
setTimeout(callback, remaining);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
})();
|
|
55
|
-
</script>
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
|
|
25
25
|
Both go away when the page owns its host.
|
|
26
26
|
|
|
27
|
+
The behaviour (the stack, the focus trap shared with the app's host, the
|
|
28
|
+
cleanup) is studio/modal_host; this partial renders markup only.
|
|
29
|
+
|
|
27
30
|
Animations come from the shared motion layer
|
|
28
31
|
(app/assets/tailwind/studio_engine/engine-motion.css) — the .modal-card-* and
|
|
29
32
|
.modal-backdrop-* classes — rather than an inline copy, so this stays small.
|
|
@@ -95,263 +98,17 @@
|
|
|
95
98
|
body.modal-open { overflow: visible; }
|
|
96
99
|
</style>
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function(minMs) {
|
|
110
|
-
var startedAt = Date.now();
|
|
111
|
-
return {
|
|
112
|
-
then: function(callback) {
|
|
113
|
-
var remaining = Math.max(0, minMs - (Date.now() - startedAt));
|
|
114
|
-
if (remaining === 0) { callback(); return; }
|
|
115
|
-
setTimeout(callback, remaining);
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// Named, so the dual-guard below can call it directly on a Turbo visit.
|
|
121
|
-
// Alpine's deferred script fires alpine:init exactly ONCE per full document
|
|
122
|
-
// load; this registration lives in a PAGE BODY, so it was absent during that
|
|
123
|
-
// one init. Without the guard, a Turbo Drive visit to this page leaves the
|
|
124
|
-
// store undefined and every x-data on it throws.
|
|
125
|
-
var _returnFocusTo = null;
|
|
126
|
-
// The backdrop node, kept in the same closure and for the same proxy reason.
|
|
127
|
-
var _backdropEl = null;
|
|
128
|
-
|
|
129
|
-
function registerScopedStore() {
|
|
130
|
-
if (Alpine.store(STORE_NAME)) return;
|
|
131
|
-
|
|
132
|
-
Alpine.store(STORE_NAME, {
|
|
133
|
-
stack: [],
|
|
134
|
-
|
|
135
|
-
// FOCUS — the same contract as the shared host (studio/modals/_host).
|
|
136
|
-
// Both hosts render [role=dialog]; a fix in only one of them leaves every
|
|
137
|
-
// page that brings its own store still trapping keyboard users behind an
|
|
138
|
-
// overlay, which is the "engine-wide" half of this defect.
|
|
139
|
-
//
|
|
140
|
-
// The return target is held in the closure below, NOT on the store: an
|
|
141
|
-
// Alpine store is a reactive Proxy, so a DOM node assigned to it reads back
|
|
142
|
-
// as a proxy and every identity check against it (===, contains) is false.
|
|
143
|
-
captureFocus: function(el) {
|
|
144
|
-
_returnFocusTo = document.activeElement;
|
|
145
|
-
_backdropEl = el;
|
|
146
|
-
if (el && el.focus) el.focus();
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
// RE-FOCUS AFTER A REPLACE, without re-capturing the return target. See
|
|
150
|
-
// the long note in _host.html.erb: captureFocus runs from x-init on the
|
|
151
|
-
// backdrop, and a replace keeps current() truthy, so the outer template
|
|
152
|
-
// never re-mounts and x-init never re-runs. The inner content DOES
|
|
153
|
-
// re-mount and unmounts the focused control, activeElement falls back to
|
|
154
|
-
// the document body, and the tab handler bound on the backdrop stops seeing
|
|
155
|
-
// the key. (Written out rather than as a literal tag: this comment ships
|
|
156
|
-
// inside the rendered script, and studio/emails asserts the page emits no
|
|
157
|
-
// body element of its own — a tag in prose trips it.)
|
|
158
|
-
// _returnFocusTo is untouched on purpose — re-capturing would overwrite
|
|
159
|
-
// the opener with a node inside the dialog.
|
|
160
|
-
refocus: function() {
|
|
161
|
-
var run = function() {
|
|
162
|
-
if (_backdropEl && _backdropEl.focus && document.contains(_backdropEl)) {
|
|
163
|
-
_backdropEl.focus();
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(run);
|
|
167
|
-
else setTimeout(run, 0);
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
releaseFocus: function() {
|
|
171
|
-
var target = _returnFocusTo;
|
|
172
|
-
_returnFocusTo = null;
|
|
173
|
-
_backdropEl = null;
|
|
174
|
-
if (target && target.focus && document.contains(target)) target.focus();
|
|
175
|
-
},
|
|
176
|
-
|
|
177
|
-
focusables: function(el) {
|
|
178
|
-
if (!el) return [];
|
|
179
|
-
var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
|
|
180
|
-
'select:not([disabled]), textarea:not([disabled]), [tabindex]';
|
|
181
|
-
return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function(n) {
|
|
182
|
-
return n.tabIndex >= 0 && n.offsetParent !== null;
|
|
183
|
-
});
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
cycleFocus: function(el, event) {
|
|
187
|
-
var items = this.focusables(el);
|
|
188
|
-
if (items.length === 0) { if (el && el.focus) el.focus(); return; }
|
|
189
|
-
|
|
190
|
-
var idx = items.indexOf(document.activeElement);
|
|
191
|
-
var next;
|
|
192
|
-
if (event && event.shiftKey) {
|
|
193
|
-
next = idx <= 0 ? items[items.length - 1] : items[idx - 1];
|
|
194
|
-
} else {
|
|
195
|
-
next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1];
|
|
196
|
-
}
|
|
197
|
-
next.focus();
|
|
198
|
-
},
|
|
199
|
-
|
|
200
|
-
dialogLabel: function() {
|
|
201
|
-
var entry = this.current();
|
|
202
|
-
if (!entry) return 'Dialog';
|
|
203
|
-
var p = entry.props || {};
|
|
204
|
-
// TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
|
|
205
|
-
// pass straight through — and the accessible-name computation then trims it to
|
|
206
|
-
// empty and announces a bare "dialog", the exact outcome this fallback chain
|
|
207
|
-
// exists to prevent. An empty string already fell through (it is falsy); a
|
|
208
|
-
// whitespace-only one did not, which is the more likely typo of the two.
|
|
209
|
-
var named = function(v) {
|
|
210
|
-
return (typeof v === 'string' && v.trim() !== '') ? v : null;
|
|
211
|
-
};
|
|
212
|
-
return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
// open(id, props, opts) — opts.replace swaps the top entry in place
|
|
216
|
-
// (what submitFormWithProgress does to turn crop-photo into saving).
|
|
217
|
-
open: function(id, props, opts) {
|
|
218
|
-
props = props || {};
|
|
219
|
-
opts = opts || {};
|
|
220
|
-
var entry = { id: id, props: props };
|
|
221
|
-
|
|
222
|
-
// Did this call re-mount the INNER content template without re-mounting the
|
|
223
|
-
// OUTER one? That is the whole question the focus trap turns on.
|
|
224
|
-
var remounted = false;
|
|
225
|
-
if (opts.replace && this.stack.length > 0) {
|
|
226
|
-
this.stack.splice(this.stack.length - 1, 1, entry);
|
|
227
|
-
remounted = true;
|
|
228
|
-
} else {
|
|
229
|
-
// THE COMMENT THIS REPLACES SAID "only on a REPLACE", on the premise that a
|
|
230
|
-
// fresh push takes current() falsy -> truthy so the outer template mounts and
|
|
231
|
-
// x-init captures for us. That is TRUE only when the stack was EMPTY, and
|
|
232
|
-
// silently false for a stacked push: current() was already truthy, so the
|
|
233
|
-
// outer template does not re-mount, x-init never re-runs, and the inner
|
|
234
|
-
// template unmounts whatever was focused. Focus lands on the document body — not a
|
|
235
|
-
// descendant of the backdrop — so the @keydown.tab handler bound there stops
|
|
236
|
-
// firing and native tabbing walks straight onto the page behind.
|
|
237
|
-
remounted = this.stack.length > 0;
|
|
238
|
-
this.stack.push(entry);
|
|
239
|
-
}
|
|
240
|
-
this._sync();
|
|
241
|
-
if (remounted) this.refocus();
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
swap: function(id, props, opts) {
|
|
245
|
-
opts = opts || {};
|
|
246
|
-
opts.replace = true;
|
|
247
|
-
this.open(id, props, opts);
|
|
248
|
-
},
|
|
249
|
-
|
|
250
|
-
// Flip _closing so the exit animation plays, then splice.
|
|
251
|
-
close: function() {
|
|
252
|
-
var entry = this.current();
|
|
253
|
-
if (!entry) return;
|
|
254
|
-
|
|
255
|
-
var self = this;
|
|
256
|
-
entry._closing = true;
|
|
257
|
-
setTimeout(function() {
|
|
258
|
-
var index = self.stack.indexOf(entry);
|
|
259
|
-
if (index !== -1) self.stack.splice(index, 1);
|
|
260
|
-
self._sync();
|
|
261
|
-
// Only when the LAST modal leaves — a stacked flow unmounts one dialog
|
|
262
|
-
// and mounts the next, and restoring in between yanks focus out of a
|
|
263
|
-
// flow the user is still inside.
|
|
264
|
-
//
|
|
265
|
-
// NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down TO an
|
|
266
|
-
// underlying modal re-mounts the inner template, so focus falls to the document body
|
|
267
|
-
// with that dialog still open — the trap is off while the user is still
|
|
268
|
-
// inside it. The gate was right; the else was missing.
|
|
269
|
-
if (self.stack.length === 0) self.releaseFocus();
|
|
270
|
-
else self.refocus();
|
|
271
|
-
}, CLOSE_ANIM_MS);
|
|
272
|
-
},
|
|
273
|
-
|
|
274
|
-
closeAll: function() {
|
|
275
|
-
// Teardown path (Turbo/bfcache): drop the target rather than restoring
|
|
276
|
-
// into a document that is about to be replaced.
|
|
277
|
-
_returnFocusTo = null;
|
|
278
|
-
this.stack = [];
|
|
279
|
-
this._sync();
|
|
280
|
-
},
|
|
281
|
-
|
|
282
|
-
// Keeps dismissible: false entries — a still-in-flight upload must not
|
|
283
|
-
// silently lose the card its promise will resolve against.
|
|
284
|
-
closeAllDismissible: function() {
|
|
285
|
-
this.stack = this.stack.filter(function(entry) {
|
|
286
|
-
return entry.props && entry.props.dismissible === false;
|
|
287
|
-
});
|
|
288
|
-
this._sync();
|
|
289
|
-
// Same re-mount, same release: a surviving non-dismissible card is still on
|
|
290
|
-
// screen and the cards dropped from under it took focus with them. Nothing is
|
|
291
|
-
// done when the stack empties — deliberately unlike close(): this is the
|
|
292
|
-
// Turbo/bfcache teardown path, where closeAll() already declines to restore
|
|
293
|
-
// into a document about to be replaced.
|
|
294
|
-
if (this.stack.length > 0) this.refocus();
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
// isOpen(id) — on the stack in ANY lifecycle state, including a card
|
|
298
|
-
// mid-close (close() flips _closing at once and splices only after
|
|
299
|
-
// CLOSE_ANIM_MS). isLive() below is the "still up" question.
|
|
300
|
-
isOpen: function(id) {
|
|
301
|
-
return this.stack.some(function(entry) { return entry.id === id; });
|
|
302
|
-
},
|
|
303
|
-
|
|
304
|
-
// isLive(id) / isLive([id, ...]) — on the stack and NOT on its way
|
|
305
|
-
// out. Mirrors the shared host's primitive so a page-scoped store
|
|
306
|
-
// answers the same question the same way; see studio/modals/_host
|
|
307
|
-
// for the full lifecycle rationale. Tests _closing ALONE: a leaving
|
|
308
|
-
// swap() entry carries _swappingOut AND _closing, so _swappingOut on
|
|
309
|
-
// its own must never count as leaving.
|
|
310
|
-
isLive: function(id) {
|
|
311
|
-
var ids = Array.isArray(id) ? id : [id];
|
|
312
|
-
return this.stack.some(function(entry) {
|
|
313
|
-
return entry && !entry._closing && ids.indexOf(entry.id) !== -1;
|
|
314
|
-
});
|
|
315
|
-
},
|
|
316
|
-
|
|
317
|
-
current: function() {
|
|
318
|
-
return this.stack.length ? this.stack[this.stack.length - 1] : null;
|
|
319
|
-
},
|
|
320
|
-
|
|
321
|
-
// Mount on open, unmount while closing. _settled pins the entry after
|
|
322
|
-
// its first render so a stale Alpine re-evaluation cannot re-fire the
|
|
323
|
-
// bounce-in keyframe mid-life (which reads as a flash).
|
|
324
|
-
cardClasses: function() {
|
|
325
|
-
var entry = this.current();
|
|
326
|
-
if (!entry) return '';
|
|
327
|
-
if (entry._closing) return 'modal-card-unmount';
|
|
328
|
-
if (!entry._settled) { entry._settled = true; }
|
|
329
|
-
return 'modal-card-mount';
|
|
330
|
-
},
|
|
331
|
-
|
|
332
|
-
_sync: function() {
|
|
333
|
-
document.body.classList.toggle('modal-open', this.stack.length > 0);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (window.Alpine) { registerScopedStore(); }
|
|
339
|
-
else { document.addEventListener('alpine:init', registerScopedStore); }
|
|
340
|
-
|
|
341
|
-
// bfcache + Turbo snapshot cleanup, so a modal left open does not reappear
|
|
342
|
-
// on a back navigation.
|
|
343
|
-
function clearStaleScopedModals() {
|
|
344
|
-
if (!window.Alpine || !Alpine.store) return;
|
|
345
|
-
|
|
346
|
-
var store = Alpine.store(STORE_NAME);
|
|
347
|
-
if (store && typeof store.closeAllDismissible === 'function') store.closeAllDismissible();
|
|
348
|
-
}
|
|
349
|
-
window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleScopedModals(); });
|
|
350
|
-
document.addEventListener('turbo:before-cache', clearStaleScopedModals);
|
|
351
|
-
})();
|
|
352
|
-
</script>
|
|
353
|
-
|
|
354
|
-
<template x-if="$store.<%= scoped_store %>.current()">
|
|
101
|
+
<%# THE STORE IS NOT HERE. $store.<store>, its focus trap and its bfcache / Turbo
|
|
102
|
+
snapshot cleanup are studio/modal_host (createScopedModalStore), bound to this
|
|
103
|
+
host by the modal-host controller on the template below. The module registers
|
|
104
|
+
the store from the template's data attributes before Alpine starts on a full
|
|
105
|
+
load and before a Turbo visit renders this page, so a page that brings its own
|
|
106
|
+
host needs no script of its own. %>
|
|
107
|
+
|
|
108
|
+
<template x-if="$store.<%= scoped_store %>.current()"
|
|
109
|
+
data-studio-controller="modal-host"
|
|
110
|
+
data-modal-host-store-value="<%= scoped_store %>"
|
|
111
|
+
data-modal-host-scoped-value="true">
|
|
355
112
|
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
356
113
|
:class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
|
|
357
114
|
style="background:rgba(0,0,0,0.6)"
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
ERROR INK, not a static red. The error paragraph is text-danger-ink, the
|
|
15
15
|
per-theme red Studio::ThemeResolver derives to a 4.5:1 target. It replaced
|
|
16
16
|
text-red-400. Measured through the resolver on turf-monster's real palette
|
|
17
|
-
(primary 4BAF50), on the modal card, which is bg-surface: danger-ink is 4.
|
|
18
|
-
on the dark card 3C3853 and
|
|
17
|
+
(primary 4BAF50), on the modal card, which is bg-surface: danger-ink is 4.89
|
|
18
|
+
on the dark card 3C3853 and 6.49 on the light card ffffff, clearing AA on
|
|
19
19
|
both; text-red-400 was 3.86 and 2.89, failing both. Those are Tailwind v4's
|
|
20
20
|
red-400, about FF6467, which is what these apps compile; the v3 hex F87171
|
|
21
21
|
gives 4.03 and 2.77 and fails the same way. No STATIC red clears AA
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%#
|
|
2
2
|
Reusable processing / loading card. Spinner + title + optional message.
|
|
3
3
|
|
|
4
|
-
Minimum-visible-duration convention (studio/
|
|
4
|
+
Minimum-visible-duration convention (studio/modal_host, window.StudioModals):
|
|
5
5
|
a load spinner must show for AT LEAST a floor, but longer if the real async
|
|
6
6
|
runs longer — resolveAt = max(min_duration, actual_completion) — so it never
|
|
7
7
|
flashes past the user ("did anything happen?") yet never cuts a slow op short.
|
|
@@ -212,9 +212,8 @@
|
|
|
212
212
|
var SWAP_IN_MS = 220;
|
|
213
213
|
|
|
214
214
|
// THE LIVE REGISTRY IS window.ModalAnimations, NOT THIS TABLE. This is a
|
|
215
|
-
// fallback only, for the case where
|
|
216
|
-
//
|
|
217
|
-
// layouts mount it, so in practice the lookup below always finds it.
|
|
215
|
+
// fallback only, for the case where studio/modal_host, which publishes
|
|
216
|
+
// the registry on every page, failed to load.
|
|
218
217
|
//
|
|
219
218
|
// It used to be a hard-coded local copy, and that made the Enter/Leave
|
|
220
219
|
// simulator below a LIAR: the simulator builds its controls from the live
|
|
@@ -362,11 +361,10 @@
|
|
|
362
361
|
</script>
|
|
363
362
|
|
|
364
363
|
<%# The minimum-visible-duration convention (window.StudioModals.holdAtLeast +
|
|
365
|
-
MIN_LOAD_MS)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<%= render "studio/modals/load_convention" %>
|
|
364
|
+
MIN_LOAD_MS) is studio/modal_host's, published on every page, so the demo
|
|
365
|
+
load modals here (payment/entry processing, on-chain tx, saving) honor
|
|
366
|
+
resolveAt = max(min_duration, actual) and auto-resolve after min_duration
|
|
367
|
+
in demo mode. %>
|
|
370
368
|
|
|
371
369
|
<%# Cropper.js + the cropPhotoModal factory — makes the Profile crop / upload
|
|
372
370
|
modals openable on this page (loads cropper.js via <script defer> and
|
|
@@ -1642,7 +1640,7 @@
|
|
|
1642
1640
|
</div>
|
|
1643
1641
|
</div>
|
|
1644
1642
|
|
|
1645
|
-
<%# The anti-flicker floor from studio/
|
|
1643
|
+
<%# The anti-flicker floor from studio/modal_host (window.StudioModals.holdAtLeast). %>
|
|
1646
1644
|
<div class="card p-5 space-y-3">
|
|
1647
1645
|
<h4 class="text-heading font-semibold text-sm">Hold at least</h4>
|
|
1648
1646
|
<p class="text-secondary text-xs">
|
|
@@ -1806,13 +1804,12 @@
|
|
|
1806
1804
|
});
|
|
1807
1805
|
}
|
|
1808
1806
|
|
|
1809
|
-
// WHEN TO BUILD. window.ModalAnimations is published by
|
|
1810
|
-
//
|
|
1811
|
-
// build waits for the rest of the
|
|
1812
|
-
// full load — DOMContentLoaded, the
|
|
1807
|
+
// WHEN TO BUILD. window.ModalAnimations is published by studio/modal_host,
|
|
1808
|
+
// a module, which runs after the document has parsed and before
|
|
1809
|
+
// DOMContentLoaded, so the build waits for the rest of the load either way:
|
|
1810
|
+
// full load — DOMContentLoaded, the module has run by then;
|
|
1813
1811
|
// Turbo visit — DOMContentLoaded already fired and never fires again, so
|
|
1814
|
-
// fall through to a macrotask
|
|
1815
|
-
// swap finishes inserting its scripts.
|
|
1812
|
+
// fall through to a macrotask; the registry is already up.
|
|
1816
1813
|
// turbo:load covers the remaining visits; the build is idempotent, so the
|
|
1817
1814
|
// overlap between these paths is redundant work and nothing worse.
|
|
1818
1815
|
if (document.readyState === 'loading') {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A meeting's recording, stored beside its transcript. The document row stays
|
|
2
|
+
# the transcript (s3_key); these four columns point at ONE recording object in
|
|
3
|
+
# the same private bucket, written by Studio::KnowledgeDoc#attach_recording!.
|
|
4
|
+
#
|
|
5
|
+
# recording_source_url is the external page the recording came from (the
|
|
6
|
+
# notetaker's share page), kept as a link. It is never fetched.
|
|
7
|
+
#
|
|
8
|
+
# Installed per consumer; do not hand-copy. An app that has not installed this
|
|
9
|
+
# yet keeps working: Studio::KnowledgeDoc#recording? answers false without the
|
|
10
|
+
# columns.
|
|
11
|
+
class AddRecordingToStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
|
|
12
|
+
def change
|
|
13
|
+
add_column :studio_knowledge_docs, :recording_key, :string
|
|
14
|
+
add_column :studio_knowledge_docs, :recording_mime_type, :string
|
|
15
|
+
add_column :studio_knowledge_docs, :recording_byte_size, :bigint
|
|
16
|
+
add_column :studio_knowledge_docs, :recording_source_url, :text
|
|
17
|
+
add_index :studio_knowledge_docs, :recording_key, unique: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -16,6 +16,8 @@ module Studio
|
|
|
16
16
|
# engine never requires it; a host adds `gem "lookbook"` after
|
|
17
17
|
# studio-engine). Such an app draws it in development and test, and in
|
|
18
18
|
# production only if it also sets Studio.lookbook_in_production = true.
|
|
19
|
+
# A host that lists lookbook BEFORE studio-engine draws nothing and is
|
|
20
|
+
# told so at boot (LOAD_ORDER_WARNING).
|
|
19
21
|
# Everywhere else there is no route, no ViewComponent preview route, no
|
|
20
22
|
# /lookbook-assets middleware, and no Lookbook in memory.
|
|
21
23
|
# 2. WHO SEES IT. An admin with a live session. Anyone else gets 404, not a
|
|
@@ -40,6 +42,11 @@ module Studio
|
|
|
40
42
|
# preview's Ruby and template) and :params (live inputs from the URL).
|
|
41
43
|
MAIN_PANELS = %i[preview output].freeze
|
|
42
44
|
DRAWER_PANELS = %i[notes].freeze
|
|
45
|
+
# Printed at boot by a host whose bundle loads lookbook before the engine.
|
|
46
|
+
LOAD_ORDER_WARNING =
|
|
47
|
+
"studio-engine: lookbook was required before studio-engine, so the component gallery " \
|
|
48
|
+
"(#{MOUNT_PATH}) is NOT mounted. Move `gem \"lookbook\"` below `gem \"studio-engine\"` " \
|
|
49
|
+
"in the Gemfile and restart."
|
|
43
50
|
|
|
44
51
|
module_function
|
|
45
52
|
|
|
@@ -48,10 +55,13 @@ module Studio
|
|
|
48
55
|
# lib/studio/engine.rb).
|
|
49
56
|
mattr_accessor :lookbook_loaded_before_engine, default: false
|
|
50
57
|
|
|
51
|
-
# Whether this app draws the gallery. Pure: the env, the flag
|
|
52
|
-
# lookbook is loaded are passed in, so the
|
|
53
|
-
|
|
58
|
+
# Whether this app draws the gallery. Pure: the env, the flag, whether
|
|
59
|
+
# lookbook is loaded and whether it loaded in order are passed in, so the
|
|
60
|
+
# rule unit-tests without Rails. Out of order it is not drawn: Lookbook
|
|
61
|
+
# turned previews on before ViewComponent decided its preview routes.
|
|
62
|
+
def mounted?(env:, in_production:, lookbook_loaded:, load_order_ok: true)
|
|
54
63
|
return false unless lookbook_loaded
|
|
64
|
+
return false unless load_order_ok
|
|
55
65
|
|
|
56
66
|
!env.to_s.casecmp?("production") || in_production == true
|
|
57
67
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The "Email image generator" link at the top of /admin/emails: where an
|
|
7
|
+
# operator goes to make a new banner with the app's own artwork. Each app
|
|
8
|
+
# points it at its own generator, from config/initializers/studio.rb:
|
|
9
|
+
#
|
|
10
|
+
# config.email_manager_generator_url = "https://example.com/email-art"
|
|
11
|
+
# # or, worked out per request:
|
|
12
|
+
# config.email_manager_generator_url = ->(request) { "#{request.base_url}/admin/email-art" }
|
|
13
|
+
#
|
|
14
|
+
# config.email_manager_generator_label = "Header generator" # optional
|
|
15
|
+
# config.email_manager_generator_description = "Open it, copy ..." # optional
|
|
16
|
+
#
|
|
17
|
+
# Unset, nil or blank means no link, and the page renders exactly as it did
|
|
18
|
+
# before the setting existed.
|
|
19
|
+
#
|
|
20
|
+
# The URL is written into an href, so only an absolute http or https URL with
|
|
21
|
+
# a host is accepted. A configured String that is anything else (a
|
|
22
|
+
# `javascript:` URL, a relative path, a bare word) raises at boot, where the
|
|
23
|
+
# mistake is cheap to see. A callable is judged on what it RETURNS, at render
|
|
24
|
+
# time, and an unusable answer renders no link rather than breaking the page.
|
|
25
|
+
#
|
|
26
|
+
# Pure Ruby, so it loads and unit-tests without Rails.
|
|
27
|
+
module EmailImageGenerator
|
|
28
|
+
DEFAULT_LABEL = "Email image generator"
|
|
29
|
+
|
|
30
|
+
# Generic on purpose: every consuming app shows it unless it says otherwise.
|
|
31
|
+
DEFAULT_DESCRIPTION = "Make a new header with %<app>s's character model: " \
|
|
32
|
+
"open the generator, copy the prompt, paste it into Claude Code."
|
|
33
|
+
|
|
34
|
+
SCHEMES = %w[http https].freeze
|
|
35
|
+
|
|
36
|
+
# Whitespace, a control character, a quote, an angle bracket or a backslash
|
|
37
|
+
# anywhere. URI's parser tolerates several of these, a browser strips tab and
|
|
38
|
+
# newline from an href and reads "\\" as "/", and the view escapes the rest
|
|
39
|
+
# anyway; a URL that carries one is refused rather than reasoned about.
|
|
40
|
+
UNSAFE_CHARACTER = /[\x00-\x20\x7f"'<>\\`]/
|
|
41
|
+
|
|
42
|
+
# The link to render: url, label and description, all plain strings (the
|
|
43
|
+
# view escapes them).
|
|
44
|
+
Link = Struct.new(:url, :label, :description, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# An absolute http(s) URL with a host.
|
|
49
|
+
def web_url?(value)
|
|
50
|
+
return false unless value.is_a?(String)
|
|
51
|
+
|
|
52
|
+
string = value.strip
|
|
53
|
+
return false if string.empty? || string.match?(UNSAFE_CHARACTER)
|
|
54
|
+
|
|
55
|
+
uri = URI.parse(string)
|
|
56
|
+
SCHEMES.include?(uri.scheme.to_s.downcase) && !uri.host.to_s.empty?
|
|
57
|
+
rescue URI::InvalidURIError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The setter's check. nil, a blank String and a callable pass; any other
|
|
62
|
+
# value must be a web URL.
|
|
63
|
+
def validate_url!(value)
|
|
64
|
+
return if value.nil? || value.respond_to?(:call)
|
|
65
|
+
return if value.is_a?(String) && value.strip.empty?
|
|
66
|
+
return if web_url?(value)
|
|
67
|
+
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"Studio.email_manager_generator_url must be an http(s) URL, a callable returning one, " \
|
|
70
|
+
"or nil (got #{value.inspect})."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# What the setter stores: nil for blank, a stripped String, or the callable.
|
|
74
|
+
def normalize_url(value)
|
|
75
|
+
validate_url!(value)
|
|
76
|
+
return value if value.respond_to?(:call)
|
|
77
|
+
|
|
78
|
+
string = value.to_s.strip
|
|
79
|
+
string.empty? ? nil : string
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The URL for this request, or nil when there is no usable one. A callable
|
|
83
|
+
# receives the request (or nothing, if it takes no argument).
|
|
84
|
+
def resolve_url(configured, request = nil)
|
|
85
|
+
value =
|
|
86
|
+
if configured.respond_to?(:call)
|
|
87
|
+
call_configured(configured, request)
|
|
88
|
+
else
|
|
89
|
+
configured
|
|
90
|
+
end
|
|
91
|
+
web_url?(value) ? value.strip : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A callable that raises draws no link rather than a 500 on /admin/emails;
|
|
95
|
+
# the exception goes to ErrorLog when the host has one (this file is pure Ruby).
|
|
96
|
+
def call_configured(callable, request)
|
|
97
|
+
arity = callable.respond_to?(:arity) ? callable.arity : 1
|
|
98
|
+
arity.zero? ? callable.call : callable.call(request)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
begin
|
|
101
|
+
::ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The Link to render, or nil when no link is configured.
|
|
109
|
+
def link(url:, label: nil, description: nil, app_name: nil, request: nil)
|
|
110
|
+
resolved = resolve_url(url, request)
|
|
111
|
+
return nil if resolved.nil?
|
|
112
|
+
|
|
113
|
+
Link.new(
|
|
114
|
+
url: resolved,
|
|
115
|
+
label: present(label) || DEFAULT_LABEL,
|
|
116
|
+
description: present(description) || format(DEFAULT_DESCRIPTION, app: present(app_name) || "this app")
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def present(value)
|
|
121
|
+
string = value.to_s.strip
|
|
122
|
+
string.empty? ? nil : string
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/studio/engine.rb
CHANGED
|
@@ -8,7 +8,7 @@ require_relative "component_gallery"
|
|
|
8
8
|
# view_component is required first: its after_initialize, which decides whether
|
|
9
9
|
# to draw its own preview routes, must run before Lookbook's, which turns
|
|
10
10
|
# previews on for its own use. Studio::ComponentGallery.load_order_ok? records
|
|
11
|
-
# the order this file saw.
|
|
11
|
+
# the order this file saw; out of order, the gallery is not mounted.
|
|
12
12
|
Studio::ComponentGallery.lookbook_loaded_before_engine = defined?(::Lookbook::Engine) ? true : false
|
|
13
13
|
require "view_component"
|
|
14
14
|
|
|
@@ -148,10 +148,7 @@ module Studio
|
|
|
148
148
|
initializer "studio.component_gallery", after: "lookbook.assets.serve" do
|
|
149
149
|
next unless Studio::ComponentGallery.lookbook_loaded?
|
|
150
150
|
|
|
151
|
-
unless Studio::ComponentGallery.load_order_ok?
|
|
152
|
-
warn "studio-engine: lookbook was required before studio-engine; list it after " \
|
|
153
|
-
"studio-engine in the Gemfile so ViewComponent decides its preview routes first"
|
|
154
|
-
end
|
|
151
|
+
warn Studio::ComponentGallery::LOAD_ORDER_WARNING unless Studio::ComponentGallery.load_order_ok?
|
|
155
152
|
Studio::ComponentGallery.configure_lookbook!(::Lookbook.config)
|
|
156
153
|
config.app_middleware.delete(::Rack::Static)
|
|
157
154
|
end
|