ultimate-jekyll-manager 1.6.9 → 1.7.1

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ ---
18
+ ## [1.7.1] - 2026-06-09
19
+
20
+ ### Added
21
+
22
+ - **Auto-subscribe push notifications on authenticated page load** — calls `webManager.notifications().subscribe()` in the core auth listener after the consent guard passes. Fires for both fresh signups and returning sign-ins. Failure logs a warning and never blocks navigation.
23
+
24
+ ---
25
+ ## [1.7.0] - 2026-06-09
26
+
27
+ ### Added
28
+
29
+ - **Animation Studio admin page** (`/admin/studio`). New default admin page for creating screen-recording-ready product demo animation clips. Framework provides all boilerplate: sidebar, FormManager controls, resolution picker (540p–4K), aspect ratio toggle (16:9/9:16), playback loop with speed/pause controls, and 60fps tab recording via `getDisplayMedia` + `CropTarget`.
30
+ - **Clip builder helpers** — `flowClip`, `cardClip`, `chatClip` passed alongside `animate`/`el` to clip `build()` functions. Consumers declare data, not structure.
31
+ - **Studio CSS partial** (`_studio.scss`) — importable via `@use 'studio'` in consumer page CSS. Includes boilerplate layout + generic animation primitives (`.s-hidden`, `.s-step-bg`, `.s-bar-fill`).
32
+ - **`docs/animation-studio.md`** — full reference for the clip contract, helpers, recording, resolution, aspect ratios.
33
+ - **Admin sidebar entry** for Animation Studio under new "Content" section.
34
+
17
35
  ---
18
36
  ## [1.6.9] - 2026-06-08
19
37
 
package/CLAUDE.md CHANGED
@@ -205,6 +205,7 @@ Deep references live in `docs/`. Treat docs as a first-class deliverable. **When
205
205
 
206
206
  ### Pages, layouts, content
207
207
 
208
+ - [docs/animation-studio.md](docs/animation-studio.md) — Animation Studio admin page: clip registration via `window.STUDIO_CLIPS`, helpers (`animate`, `el`, `flowClip`, `cardClip`, `chatClip`), resolution picker, recording, aspect ratio modes
208
209
  - [docs/themes.md](docs/themes.md) — theme system: selection + resolution (SCSS loadPaths, `__theme__`, classy layout fallback), shared vs per-theme layers, authoring a theme inside UJM OR in a consumer project, live validation
209
210
  - [docs/layouts-and-pages.md](docs/layouts-and-pages.md) — page types, layout chain, `asset_path` frontmatter
210
211
  - [docs/images.md](docs/images.md) — `@post/` shortcut for blog post images, BEM admin/post image handling, imagemin pipeline + source-size constraints + `UJ_IMAGEMIN_REWRITE_SOURCES` cleanup flag
@@ -0,0 +1,229 @@
1
+ // ============================================
2
+ // Studio Layout — theme-agnostic boilerplate
3
+ // Forces a fullscreen dark takeover regardless
4
+ // of whatever theme chrome wraps the page.
5
+ // ============================================
6
+ body:has(#studio:not([hidden])) {
7
+ overflow: hidden !important;
8
+ background: #111 !important;
9
+ }
10
+
11
+ #studio {
12
+ position: fixed;
13
+ inset: 0;
14
+ z-index: 9999;
15
+ display: flex;
16
+ background: #0d0d0d;
17
+ color: #f5f5f5;
18
+ overflow: hidden;
19
+ }
20
+
21
+ // ============================================
22
+ // Sidebar nav
23
+ // ============================================
24
+ #studio-nav {
25
+ width: 240px;
26
+ flex-shrink: 0;
27
+ background: rgba(0, 0, 0, 0.3);
28
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
29
+ padding: 24px 16px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 20px;
33
+ z-index: 10;
34
+ }
35
+
36
+ .studio-brand {
37
+ font-weight: 700;
38
+ font-size: 1.1rem;
39
+ color: var(--bs-primary, #0d6efd);
40
+ padding-bottom: 16px;
41
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
42
+ }
43
+
44
+ .studio-clips {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 4px;
48
+ flex: 1;
49
+ overflow-y: auto;
50
+ }
51
+
52
+ .studio-clip-btn {
53
+ background: transparent;
54
+ border: none;
55
+ color: rgba(255, 255, 255, 0.6);
56
+ text-align: left;
57
+ padding: 10px 14px;
58
+ border-radius: 8px;
59
+ font-size: 0.88rem;
60
+ font-weight: 600;
61
+ cursor: pointer;
62
+ transition: all 0.15s ease;
63
+
64
+ &:hover {
65
+ color: #f5f5f5;
66
+ background: rgba(255, 255, 255, 0.06);
67
+ }
68
+
69
+ &.active {
70
+ color: #fff;
71
+ background: var(--bs-primary, #0d6efd);
72
+ }
73
+ }
74
+
75
+ // ============================================
76
+ // Controls panel
77
+ // ============================================
78
+ .studio-controls {
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: 12px;
82
+ padding-top: 16px;
83
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
84
+
85
+ fieldset {
86
+ border: none;
87
+ margin: 0;
88
+ padding: 0;
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 12px;
92
+
93
+ &:disabled { opacity: 0.4; pointer-events: none; }
94
+ }
95
+ }
96
+
97
+ .studio-label {
98
+ font-size: 0.72rem;
99
+ font-family: var(--bs-font-monospace, monospace);
100
+ letter-spacing: 0.06em;
101
+ text-transform: uppercase;
102
+ color: rgba(255, 255, 255, 0.4);
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 6px;
106
+
107
+ input, select {
108
+ background: rgba(255, 255, 255, 0.08);
109
+ border: 1px solid rgba(255, 255, 255, 0.12);
110
+ border-radius: 6px;
111
+ color: #f5f5f5;
112
+ padding: 4px 8px;
113
+ font-size: 0.82rem;
114
+ }
115
+
116
+ input[type="range"] {
117
+ padding: 0;
118
+ accent-color: var(--bs-primary, #0d6efd);
119
+ }
120
+ }
121
+
122
+ .studio-aspect-btn,
123
+ .studio-toggle-btn {
124
+ flex: 1;
125
+ background: rgba(255, 255, 255, 0.06);
126
+ border: 1px solid rgba(255, 255, 255, 0.12);
127
+ border-radius: 6px;
128
+ color: rgba(255, 255, 255, 0.5);
129
+ padding: 6px 12px;
130
+ font-size: 0.78rem;
131
+ font-weight: 700;
132
+ font-family: var(--bs-font-monospace, monospace);
133
+ cursor: pointer;
134
+ transition: all 0.15s ease;
135
+
136
+ &:hover { color: #f5f5f5; background: rgba(255, 255, 255, 0.1); }
137
+ &.active { background: var(--bs-primary, #0d6efd); color: #fff; border-color: var(--bs-primary, #0d6efd); }
138
+ }
139
+
140
+ .studio-play-btn {
141
+ background: rgba(255, 255, 255, 0.08);
142
+ border: 1px solid rgba(255, 255, 255, 0.12);
143
+ border-radius: 8px;
144
+ color: #f5f5f5;
145
+ padding: 8px;
146
+ font-size: 0.85rem;
147
+ font-weight: 600;
148
+ cursor: pointer;
149
+
150
+ &:hover { background: rgba(255, 255, 255, 0.14); }
151
+ }
152
+
153
+ .studio-record-btn {
154
+ background: rgba(220, 53, 69, 0.15);
155
+ border: 1px solid rgba(220, 53, 69, 0.4);
156
+ border-radius: 8px;
157
+ color: #f5f5f5;
158
+ padding: 8px;
159
+ font-size: 0.85rem;
160
+ font-weight: 600;
161
+ cursor: pointer;
162
+
163
+ &:hover { background: rgba(220, 53, 69, 0.3); }
164
+ &.recording {
165
+ background: rgba(220, 53, 69, 0.5);
166
+ border-color: #dc3545;
167
+ animation: studio-pulse-record 1s ease infinite;
168
+ }
169
+ }
170
+
171
+ @keyframes studio-pulse-record {
172
+ 0%, 100% { opacity: 1; }
173
+ 50% { opacity: 0.6; }
174
+ }
175
+
176
+ // ============================================
177
+ // Stage — where animations render
178
+ // ============================================
179
+ #studio-stage {
180
+ flex: 1;
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ position: relative;
185
+ overflow: hidden;
186
+ }
187
+
188
+ #studio-canvas {
189
+ position: relative;
190
+ display: flex;
191
+ flex-direction: column;
192
+ align-items: center;
193
+ justify-content: center;
194
+ gap: 40px;
195
+ border: 2px dashed rgba(0, 0, 0, 0.15);
196
+ border-radius: 8px;
197
+ transition: width 0.4s ease, height 0.4s ease;
198
+
199
+ background: var(--lm-paper, #FAF5EC);
200
+ color: var(--lm-ink, #191613);
201
+
202
+ // Width/height set by JS from resolution picker
203
+
204
+ &.recording {
205
+ border-color: transparent;
206
+ border-radius: 0;
207
+ &::after { display: none; }
208
+ }
209
+ }
210
+
211
+ #studio-canvas::after {
212
+ content: attr(data-aspect) " · " attr(data-size);
213
+ position: absolute;
214
+ top: -24px;
215
+ right: 0;
216
+ font-family: var(--bs-font-monospace, monospace);
217
+ font-size: 0.62rem;
218
+ letter-spacing: 0.08em;
219
+ color: rgba(255, 255, 255, 0.35);
220
+ text-transform: uppercase;
221
+ }
222
+
223
+ // ============================================
224
+ // Generic clip primitives — used by framework
225
+ // builder helpers (flowClip, cardClip, chatClip)
226
+ // ============================================
227
+ .s-hidden { opacity: 0; }
228
+ .s-step-bg { background: rgba(0, 0, 0, 0.04); }
229
+ .s-bar-fill { height: 100%; width: 0; }
@@ -105,6 +105,11 @@ export default function () {
105
105
  }
106
106
  }
107
107
 
108
+ // Prompt for push notification subscription (fire-and-forget)
109
+ webManager.notifications().subscribe().catch((e) => {
110
+ console.warn('[Auth] Notification subscribe failed:', e.message);
111
+ });
112
+
108
113
  // Check if page requires user to be unauthenticated (e.g., signin page)
109
114
  if (policy === 'unauthenticated') {
110
115
  // Check for authReturnUrl first (takes precedence)
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Animation Studio — UJM default admin page
3
+ *
4
+ * Uses FormManager for sidebar controls. Canvas content is designed at a
5
+ * base resolution (960×540) and CSS-scaled to match the selected resolution,
6
+ * so clips look identical at any size.
7
+ */
8
+
9
+ import webManager from 'web-manager';
10
+ import { FormManager } from '__main_assets__/js/libs/form-manager.js';
11
+
12
+ const BASE_W = 960;
13
+ const BASE_H = 540;
14
+
15
+ let currentClip = null;
16
+ let loopTimer = null;
17
+ let paused = false;
18
+ let speed = 1;
19
+ let clips = {};
20
+ let recording = false;
21
+ let formManager = null;
22
+
23
+ export default () => {
24
+ return new Promise(async function (resolve) {
25
+ await webManager.dom().ready();
26
+
27
+ webManager.auth().listen({ once: true }, (auth) => {
28
+ if (!auth.user) {
29
+ return;
30
+ }
31
+
32
+ clips = window.STUDIO_CLIPS || {};
33
+
34
+ const clipIds = Object.keys(clips);
35
+ if (clipIds.length === 0) {
36
+ const $studio = document.getElementById('studio');
37
+ if ($studio) {
38
+ $studio.hidden = false;
39
+ $studio.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;color:rgba(255,255,255,0.5);font-size:1.1rem">No clips registered. Set <code style="color:#fff;margin:0 6px">window.STUDIO_CLIPS</code> in your project JS.</div>';
40
+ }
41
+ return;
42
+ }
43
+
44
+ buildNav(clipIds);
45
+ setupForm();
46
+ setupRecord();
47
+
48
+ document.getElementById('studio').hidden = false;
49
+ updateCanvasSize();
50
+
51
+ currentClip = clipIds[0];
52
+ playClip(currentClip);
53
+ });
54
+
55
+ return resolve();
56
+ });
57
+ };
58
+
59
+ // ============================================
60
+ // Navigation — built from registered clips
61
+ // ============================================
62
+ function buildNav(clipIds) {
63
+ const $container = document.getElementById('studio-clips');
64
+ if (!$container) {
65
+ return;
66
+ }
67
+
68
+ clipIds.forEach((id, i) => {
69
+ const $btn = document.createElement('button');
70
+ $btn.className = 'studio-clip-btn' + (i === 0 ? ' active' : '');
71
+ $btn.dataset.clip = id;
72
+ $btn.textContent = clips[id].label || id;
73
+ $container.appendChild($btn);
74
+
75
+ $btn.addEventListener('click', () => {
76
+ if (recording) {
77
+ return;
78
+ }
79
+ document.querySelector('.studio-clip-btn.active')?.classList.remove('active');
80
+ $btn.classList.add('active');
81
+ currentClip = id;
82
+ if (!paused) {
83
+ playClip(currentClip);
84
+ }
85
+ });
86
+ });
87
+ }
88
+
89
+ // ============================================
90
+ // FormManager for sidebar controls
91
+ // ============================================
92
+ function setupForm() {
93
+ formManager = new FormManager('#studio-controls-form', {
94
+ autoReady: true,
95
+ allowResubmit: true,
96
+ });
97
+
98
+ // Aspect ratio buttons
99
+ document.querySelectorAll('.studio-aspect-btn').forEach($btn => {
100
+ $btn.addEventListener('click', () => {
101
+ if (recording) {
102
+ return;
103
+ }
104
+ document.querySelector('.studio-aspect-btn.active')?.classList.remove('active');
105
+ $btn.classList.add('active');
106
+
107
+ const $canvas = document.getElementById('studio-canvas');
108
+ if ($canvas) {
109
+ $canvas.setAttribute('data-aspect', $btn.dataset.aspect);
110
+ }
111
+
112
+ updateCanvasSize();
113
+ playClip(currentClip);
114
+ });
115
+ });
116
+
117
+ // Pause slider
118
+ const $pause = document.getElementById('studio-pause');
119
+ const $pauseVal = document.getElementById('studio-pause-val');
120
+ $pause?.addEventListener('input', () => {
121
+ $pauseVal.textContent = $pause.value;
122
+ });
123
+
124
+ // Speed select
125
+ const $speed = document.getElementById('studio-speed');
126
+ $speed?.addEventListener('change', () => {
127
+ speed = parseFloat($speed.value);
128
+ });
129
+
130
+ // Resolution select
131
+ const $res = document.getElementById('studio-resolution');
132
+ $res?.addEventListener('change', () => {
133
+ updateCanvasSize();
134
+ playClip(currentClip);
135
+ });
136
+
137
+ // Play/pause toggle
138
+ const $toggle = document.getElementById('studio-toggle');
139
+ $toggle?.addEventListener('click', () => {
140
+ if (recording) {
141
+ return;
142
+ }
143
+ paused = !paused;
144
+ $toggle.textContent = paused ? '▶ Play' : '⏸ Pause';
145
+ if (!paused) {
146
+ playClip(currentClip);
147
+ }
148
+ });
149
+ }
150
+
151
+ // ============================================
152
+ // Canvas sizing + content scaling
153
+ // ============================================
154
+ function getResolution() {
155
+ const $res = document.getElementById('studio-resolution');
156
+ const val = $res?.value || '1920x1080';
157
+ const [w, h] = val.split('x').map(Number);
158
+ return { w, h };
159
+ }
160
+
161
+ function updateCanvasSize() {
162
+ const $canvas = document.getElementById('studio-canvas');
163
+ if (!$canvas) {
164
+ return;
165
+ }
166
+
167
+ const aspect = $canvas.getAttribute('data-aspect');
168
+ const { w, h } = getResolution();
169
+
170
+ let cw, ch;
171
+ if (aspect === '9:16') {
172
+ ch = h;
173
+ cw = Math.round(h * 9 / 16);
174
+ } else {
175
+ cw = w;
176
+ ch = h;
177
+ }
178
+
179
+ // Canvas is always rendered at base size, then CSS-scaled
180
+ const baseW = aspect === '9:16' ? Math.round(BASE_H * 9 / 16) : BASE_W;
181
+ const baseH = BASE_H;
182
+ const scaleX = cw / baseW;
183
+ const scaleY = ch / baseH;
184
+
185
+ $canvas.style.width = baseW + 'px';
186
+ $canvas.style.height = baseH + 'px';
187
+ $canvas.style.transform = `scale(${scaleX}, ${scaleY})`;
188
+ $canvas.style.transformOrigin = 'center center';
189
+ $canvas.setAttribute('data-size', `${cw} × ${ch}`);
190
+ }
191
+
192
+ // ============================================
193
+ // Clip runner — delay BEFORE animation
194
+ // ============================================
195
+ function playClip(clipId) {
196
+ clearTimeout(loopTimer);
197
+ const $canvas = document.getElementById('studio-canvas');
198
+ if (!$canvas) {
199
+ return;
200
+ }
201
+
202
+ $canvas.innerHTML = '';
203
+
204
+ const clip = clips[clipId];
205
+ if (!clip) {
206
+ return;
207
+ }
208
+
209
+ const pauseMs = parseInt(document.getElementById('studio-pause')?.value || 1500);
210
+ const totalDuration = clip.duration / speed;
211
+
212
+ loopTimer = setTimeout(() => {
213
+ clip.build($canvas, getHelpers($canvas));
214
+
215
+ loopTimer = setTimeout(() => {
216
+ if (!paused && currentClip === clipId && !recording) {
217
+ playClip(clipId);
218
+ }
219
+ }, totalDuration);
220
+ }, pauseMs);
221
+ }
222
+
223
+ // ============================================
224
+ // Recording
225
+ // ============================================
226
+ function setupRecord() {
227
+ const $btn = document.getElementById('studio-record');
228
+ if (!$btn) {
229
+ return;
230
+ }
231
+
232
+ $btn.addEventListener('click', () => {
233
+ if (recording) {
234
+ return;
235
+ }
236
+ recordClip();
237
+ });
238
+ }
239
+
240
+ async function recordClip() {
241
+ const $canvas = document.getElementById('studio-canvas');
242
+ const $btn = document.getElementById('studio-record');
243
+ const $fieldset = document.getElementById('studio-fieldset');
244
+ const clip = clips[currentClip];
245
+ if (!$canvas || !clip) {
246
+ return;
247
+ }
248
+
249
+ console.log(`[Studio] Recording clip: ${currentClip}`);
250
+
251
+ recording = true;
252
+ paused = true;
253
+ clearTimeout(loopTimer);
254
+
255
+ // Disable all controls via fieldset
256
+ if ($fieldset) {
257
+ $fieldset.disabled = true;
258
+ }
259
+ // Keep record button enabled to show state
260
+ $btn.disabled = false;
261
+ $btn.classList.add('recording');
262
+ $btn.textContent = '⏺ Recording...';
263
+
264
+ let stream;
265
+ try {
266
+ console.log('[Studio] Requesting screen capture...');
267
+ stream = await navigator.mediaDevices.getDisplayMedia({
268
+ video: { displaySurface: 'browser', frameRate: { ideal: 60 } },
269
+ preferCurrentTab: true,
270
+ });
271
+ console.log('[Studio] Screen capture granted');
272
+ } catch (err) {
273
+ console.warn('[Studio] Screen capture denied:', err.message);
274
+ resetRecordState($btn, $fieldset);
275
+ return;
276
+ }
277
+
278
+ // Attempt Region Capture (CropTarget) to scope to canvas element
279
+ try {
280
+ if (typeof CropTarget !== 'undefined' && stream.getVideoTracks()[0].cropTo) {
281
+ const cropTarget = await CropTarget.fromElement($canvas);
282
+ await stream.getVideoTracks()[0].cropTo(cropTarget);
283
+ console.log('[Studio] CropTarget applied — recording canvas only');
284
+ } else {
285
+ console.log('[Studio] CropTarget not available — recording full tab');
286
+ }
287
+ } catch (err) {
288
+ console.log('[Studio] CropTarget failed:', err.message, '— recording full tab');
289
+ }
290
+
291
+ const mimeType = getSupportedMimeType();
292
+ console.log(`[Studio] Using mime type: ${mimeType}`);
293
+
294
+ const recorder = new MediaRecorder(stream, {
295
+ mimeType,
296
+ videoBitsPerSecond: 16_000_000,
297
+ });
298
+
299
+ const chunks = [];
300
+ recorder.ondataavailable = (e) => {
301
+ if (e.data.size > 0) {
302
+ chunks.push(e.data);
303
+ console.log(`[Studio] Chunk received: ${(e.data.size / 1024).toFixed(1)}KB (${chunks.length} total)`);
304
+ }
305
+ };
306
+
307
+ recorder.onstop = () => {
308
+ stream.getTracks().forEach(t => t.stop());
309
+
310
+ const ext = recorder.mimeType.includes('mp4') ? 'mp4' : 'webm';
311
+ const aspect = $canvas.getAttribute('data-aspect') || '16-9';
312
+ const { w, h } = getResolution();
313
+ const blob = new Blob(chunks, { type: recorder.mimeType });
314
+ const filename = `${currentClip}-${aspect.replace(':', 'x')}-${w}x${h}.${ext}`;
315
+
316
+ console.log(`[Studio] Recording complete — ${chunks.length} chunks, ${(blob.size / 1024).toFixed(1)}KB, mime: ${recorder.mimeType}`);
317
+ console.log(`[Studio] Downloading: ${filename}`);
318
+
319
+ const url = URL.createObjectURL(blob);
320
+ const a = document.createElement('a');
321
+ a.href = url;
322
+ a.download = filename;
323
+ a.click();
324
+ URL.revokeObjectURL(url);
325
+
326
+ resetRecordState($btn, $fieldset);
327
+ playClip(currentClip);
328
+ };
329
+
330
+ recorder.onerror = (e) => {
331
+ console.error('[Studio] Recorder error:', e.error);
332
+ };
333
+
334
+ stream.getVideoTracks()[0].addEventListener('ended', () => {
335
+ console.log('[Studio] Screen sharing ended by user');
336
+ if (recorder.state !== 'inactive') {
337
+ recorder.stop();
338
+ }
339
+ });
340
+
341
+ // Clear canvas, enter recording mode, settle
342
+ $canvas.innerHTML = '';
343
+ $canvas.classList.add('recording');
344
+ await sleep(1000);
345
+ console.log('[Studio] Canvas settled, starting recorder...');
346
+
347
+ await new Promise((resolve) => {
348
+ recorder.onstart = () => {
349
+ console.log('[Studio] Recorder started');
350
+ resolve();
351
+ };
352
+ recorder.start(100);
353
+ });
354
+
355
+ const pauseMs = parseInt(document.getElementById('studio-pause')?.value || 1500);
356
+ const totalDuration = clip.duration / speed;
357
+
358
+ console.log(`[Studio] Pre-delay: ${pauseMs}ms, clip duration: ${totalDuration}ms (speed: ${speed}x)`);
359
+
360
+ await sleep(pauseMs);
361
+
362
+ console.log('[Studio] Building clip animation...');
363
+ clip.build($canvas, getHelpers($canvas));
364
+
365
+ await sleep(totalDuration);
366
+
367
+ console.log('[Studio] Clip finished, stopping recorder...');
368
+ if (recorder.state !== 'inactive') {
369
+ recorder.stop();
370
+ }
371
+ }
372
+
373
+ function resetRecordState($btn, $fieldset) {
374
+ recording = false;
375
+ paused = false;
376
+ $btn.classList.remove('recording');
377
+ $btn.textContent = '⏺ Record';
378
+
379
+ if ($fieldset) {
380
+ $fieldset.disabled = false;
381
+ }
382
+
383
+ const $canvas = document.getElementById('studio-canvas');
384
+ if ($canvas) {
385
+ $canvas.classList.remove('recording');
386
+ }
387
+
388
+ const $toggle = document.getElementById('studio-toggle');
389
+ if ($toggle) {
390
+ $toggle.textContent = '⏸ Pause';
391
+ }
392
+ }
393
+
394
+ function sleep(ms) {
395
+ return new Promise(r => setTimeout(r, ms));
396
+ }
397
+
398
+ function getSupportedMimeType() {
399
+ const types = [
400
+ 'video/webm;codecs=vp9',
401
+ 'video/webm;codecs=vp8',
402
+ 'video/webm',
403
+ ];
404
+
405
+ for (const type of types) {
406
+ if (MediaRecorder.isTypeSupported(type)) {
407
+ return type;
408
+ }
409
+ }
410
+
411
+ return 'video/webm';
412
+ }
413
+
414
+ // ============================================
415
+ // Helpers — passed to clip build() functions
416
+ // ============================================
417
+ function getHelpers($canvas) {
418
+ return { animate, el, flowClip: (opts) => buildFlowClip($canvas, opts), cardClip: (opts) => buildCardClip($canvas, opts), chatClip: (opts) => buildChatClip($canvas, opts) };
419
+ }
420
+
421
+ function animate(element, keyframes, options = {}) {
422
+ const delay = (options.delay || 0) / speed;
423
+ const duration = (options.duration || 500) / speed;
424
+ const fill = options.fill || 'forwards';
425
+ const easing = options.easing || 'cubic-bezier(0.34, 1.56, 0.64, 1)';
426
+
427
+ return element.animate(keyframes, { delay, duration, fill, easing });
428
+ }
429
+
430
+ function el(tag, classes, html) {
431
+ const node = document.createElement(tag);
432
+ if (classes) {
433
+ node.className = classes;
434
+ }
435
+ if (html) {
436
+ node.innerHTML = html;
437
+ }
438
+ return node;
439
+ }
440
+
441
+ // ============================================
442
+ // Builder helpers — reusable clip patterns
443
+ // ============================================
444
+ function buildFlowClip($c, { title, nodes }) {
445
+ const isV = $c.getAttribute('data-aspect') === '9:16';
446
+
447
+ const titleEl = el('div', 's-hero-title text-center px-3');
448
+ titleEl.innerHTML = `<span class="s-display d-block fw-bold">${title}</span>`;
449
+ $c.appendChild(titleEl);
450
+
451
+ const arrow = isV ? '↓' : '→';
452
+ const flow = el('div', `s-hidden d-flex align-items-center justify-content-center gap-3 px-4 ${isV ? 'flex-column' : ''}`);
453
+
454
+ nodes.forEach((n, i) => {
455
+ if (i > 0) {
456
+ flow.appendChild(el('span', 's-hidden s-accent fw-bold fs-4 flex-shrink-0', arrow));
457
+ }
458
+ const node = el('div', 's-hidden card rounded-4 p-3 text-center');
459
+ node.style.width = '140px';
460
+ node.innerHTML = `
461
+ <span class="d-block fs-4 mb-2">${n.icon}</span>
462
+ <span class="d-block fw-bold small">${n.label}</span>
463
+ <small class="d-block font-monospace text-body-secondary" style="font-size:0.65rem">${n.stat}</small>
464
+ `;
465
+ flow.appendChild(node);
466
+ });
467
+
468
+ $c.appendChild(flow);
469
+
470
+ animate(titleEl, [
471
+ { opacity: 0, transform: 'translateY(-20px)' },
472
+ { opacity: 1, transform: 'translateY(0)' },
473
+ ], { duration: 600 });
474
+
475
+ animate(flow, [{ opacity: 0 }, { opacity: 1 }], { delay: 300, duration: 300, easing: 'ease' });
476
+
477
+ flow.querySelectorAll('.card').forEach((node, i) => {
478
+ animate(node, [
479
+ { opacity: 0, transform: 'scale(0.5) translateY(20px)' },
480
+ { opacity: 1, transform: 'scale(1) translateY(0)' },
481
+ ], { delay: 500 + i * 250, duration: 500 });
482
+ });
483
+
484
+ flow.querySelectorAll('.s-accent').forEach((a, i) => {
485
+ animate(a, [{ opacity: 0 }, { opacity: 1 }], { delay: 650 + i * 250, duration: 300, easing: 'ease' });
486
+ });
487
+ }
488
+
489
+ function buildCardClip($c, { badge, title, contentFn }) {
490
+ const card = el('div', 's-hidden card rounded-4 p-4');
491
+ card.style.maxWidth = '500px';
492
+ card.style.width = '90%';
493
+ card.innerHTML = `
494
+ <div class="d-flex align-items-center gap-2 mb-3">${badge}</div>
495
+ <div class="s-display fs-5 fw-bold mb-3">${title}</div>
496
+ <div class="d-flex flex-column gap-2" data-content></div>
497
+ `;
498
+ $c.appendChild(card);
499
+
500
+ animate(card, [
501
+ { opacity: 0, transform: 'scale(0.8) translateY(30px)' },
502
+ { opacity: 1, transform: 'scale(1) translateY(0)' },
503
+ ], { duration: 600 });
504
+
505
+ contentFn(card.querySelector('[data-content]'), { animate, el });
506
+ }
507
+
508
+ function buildChatClip($c, { header, messages }) {
509
+ const headerEl = el('div', 's-hidden text-center px-3');
510
+ headerEl.innerHTML = header;
511
+ $c.appendChild(headerEl);
512
+
513
+ animate(headerEl, [
514
+ { opacity: 0, transform: 'translateY(-20px)' },
515
+ { opacity: 1, transform: 'translateY(0)' },
516
+ ], { duration: 500 });
517
+
518
+ const chat = el('div', 'd-flex flex-column gap-2 px-4 w-100');
519
+ chat.style.maxWidth = '480px';
520
+
521
+ messages.forEach((m, i) => {
522
+ const isAI = m.type === 'ai';
523
+ const bubble = el('div', `s-hidden rounded-4 px-3 py-2 small lh-sm ${isAI ? 's-bubble-ai align-self-end' : 's-bubble-them align-self-start'}`);
524
+ bubble.style.maxWidth = '85%';
525
+ bubble.innerHTML = m.text + (m.label ? `<span class="s-chat-label d-block text-uppercase mt-1">${m.label}</span>` : '');
526
+ chat.appendChild(bubble);
527
+
528
+ animate(bubble, [
529
+ { opacity: 0, transform: 'translateY(16px) scale(0.92)' },
530
+ { opacity: 1, transform: 'translateY(0) scale(1)' },
531
+ ], { delay: 800 + i * 800, duration: 500 });
532
+ });
533
+
534
+ $c.appendChild(chat);
535
+ }
@@ -74,6 +74,16 @@ See `node_modules/ultimate-jekyll-manager/docs/themes.md` for the full "Bootstra
74
74
  - `dist/` — intermediate compile output (webpack bundles, sass, processed images) before Jekyll merges them into `_site/`.
75
75
  - `test/**/*.js` — your project test suites (framework auto-runs them alongside its own).
76
76
 
77
+ ## Animation Studio
78
+
79
+ UJM ships a default Animation Studio admin page at `/admin/studio` for creating screen-recording-ready product demo clips. The framework provides all boilerplate (sidebar, controls, canvas, clip runner engine). Consumer projects only supply clip definitions and clip-specific CSS:
80
+
81
+ - **Clips:** Set `window.STUDIO_CLIPS` in `src/assets/js/pages/admin/studio/index.js` — each clip has a `label`, `duration`, and `build($canvas, helpers)` function. Helpers include `animate`, `el`, and reusable builders: `flowClip`, `cardClip`, `chatClip`.
82
+ - **Clip CSS:** Put animation primitives in `src/assets/css/pages/admin/studio/index.scss` (must include `@use 'studio'` to pull in boilerplate)
83
+ - **No page file needed** — the framework default provides the page
84
+
85
+ See `node_modules/ultimate-jekyll-manager/docs/animation-studio.md` for the full reference (clip contract, helpers, aspect ratios, playback controls).
86
+
77
87
  ## Per-context imports
78
88
 
79
89
  ```js
@@ -44,6 +44,15 @@
44
44
  href: '/admin/calendar',
45
45
  icon: 'calendar-days'
46
46
  },
47
+ {
48
+ header: true,
49
+ label: 'Content'
50
+ },
51
+ {
52
+ label: 'Animation Studio',
53
+ href: '/admin/studio',
54
+ icon: 'clapperboard',
55
+ },
47
56
  {
48
57
  header: true,
49
58
  label: 'External tools'
@@ -0,0 +1,92 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: themes/[ site.theme.id ]/frontend/core/base
4
+
5
+ ### THEME CONFIG ###
6
+ theme:
7
+ nav:
8
+ enabled: false
9
+ footer:
10
+ enabled: false
11
+
12
+ ### REGULAR PAGES ###
13
+ meta:
14
+ title: "Animation Studio"
15
+ index: false
16
+ sitemap:
17
+ include: false
18
+
19
+ ### WEB MANAGER CONFIG ###
20
+ web_manager:
21
+ auth:
22
+ config:
23
+ policy: "authenticated"
24
+ cookieConsent:
25
+ enabled: false
26
+ chatsy:
27
+ enabled: false
28
+ exitPopup:
29
+ enabled: false
30
+ ---
31
+
32
+ <div id="studio" hidden>
33
+ <!-- Clip selector sidebar -->
34
+ <nav id="studio-nav">
35
+ <div class="studio-brand">{{ site.brand.name }} Studio</div>
36
+ <div class="studio-clips" id="studio-clips">
37
+ <!-- Populated by JS from registered clips -->
38
+ </div>
39
+ <form id="studio-controls-form" class="studio-controls">
40
+ <fieldset id="studio-fieldset">
41
+ <!-- Aspect ratio toggle -->
42
+ <label class="studio-label">
43
+ Aspect Ratio
44
+ <div class="d-flex gap-1">
45
+ <button type="button" class="studio-aspect-btn active" data-aspect="16:9">16:9</button>
46
+ <button type="button" class="studio-aspect-btn" data-aspect="9:16">9:16</button>
47
+ </div>
48
+ </label>
49
+ <label class="studio-label">
50
+ Resolution
51
+ <select name="resolution" id="studio-resolution">
52
+ <option value="960x540">540p (960×540)</option>
53
+ <option value="1280x720">720p (1280×720)</option>
54
+ <option value="1920x1080" selected>1080p (1920×1080)</option>
55
+ <option value="3840x2160">4K (3840×2160)</option>
56
+ </select>
57
+ </label>
58
+ <label class="studio-label">
59
+ Pause (ms)
60
+ <input type="range" name="pause" id="studio-pause" min="500" max="4000" value="1500" step="100">
61
+ <span id="studio-pause-val">1500</span>
62
+ </label>
63
+ <label class="studio-label">
64
+ Speed
65
+ <select name="speed" id="studio-speed">
66
+ <option value="0.5">0.5×</option>
67
+ <option value="1" selected>1×</option>
68
+ <option value="1.5">1.5×</option>
69
+ <option value="2">2×</option>
70
+ </select>
71
+ </label>
72
+ <label class="studio-label">
73
+ Appearance
74
+ <div class="d-flex gap-1">
75
+ <button type="button" class="studio-toggle-btn" data-appearance-set="light">Light</button>
76
+ <button type="button" class="studio-toggle-btn" data-appearance-set="dark">Dark</button>
77
+ <button type="button" class="studio-toggle-btn" data-appearance-set="system">System</button>
78
+ </div>
79
+ </label>
80
+ <button type="button" id="studio-toggle" class="studio-play-btn">⏸ Pause</button>
81
+ <button type="button" id="studio-record" class="studio-record-btn">⏺ Record</button>
82
+ </fieldset>
83
+ </form>
84
+ </nav>
85
+
86
+ <!-- Animation stage -->
87
+ <main id="studio-stage">
88
+ <div id="studio-canvas" data-aspect="16:9">
89
+ <!-- Animation content injected by JS -->
90
+ </div>
91
+ </main>
92
+ </div>
@@ -0,0 +1,7 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: blueprint/admin/studio/index
4
+ permalink: /admin/studio
5
+
6
+ ### REGULAR PAGES ###
7
+ ---
@@ -0,0 +1,166 @@
1
+ # Animation Studio
2
+
3
+ Admin page at `/admin/studio` for creating screen-recording-ready product demo animation clips. Ships with UJM as a default admin page — consumer projects supply only clip definitions and clip-specific CSS.
4
+
5
+ ## Architecture
6
+
7
+ The Studio page is a standalone full-viewport dark canvas with a sidebar for clip selection and playback controls. It bypasses the standard admin sidebar/topbar/header chrome (`theme.sidebar/topbar/header: enabled: false` in the blueprint).
8
+
9
+ **Framework provides (boilerplate):**
10
+ - Page file (`dist/pages/admin/studio/index.html`) + blueprint layout
11
+ - Sidebar with auto-generated clip buttons from registered clips
12
+ - Playback controls (pause duration slider, speed selector, play/pause toggle)
13
+ - Aspect ratio toggle (16:9 landscape / 9:16 vertical for reels/stories)
14
+ - Recording-ready canvas with dashed border and aspect ratio label
15
+ - Clip runner loop with speed-scaled timing
16
+ - `animate()` helper — wraps Web Animations API with auto-speed scaling
17
+ - `el()` helper — DOM element factory (`el(tag, classes, innerHTML)`)
18
+ - Admin role gate (redirects non-admins to `/dashboard`)
19
+ - All boilerplate CSS (sidebar, controls, canvas frame — theme-agnostic)
20
+
21
+ **Consumer provides (project-specific):**
22
+ - Clip definitions via `window.STUDIO_CLIPS` (set in the project JS layer)
23
+ - Clip-specific CSS (animation primitives, keyframes, component styles)
24
+
25
+ ## Clip registration
26
+
27
+ Consumer projects register clips by setting `window.STUDIO_CLIPS` in their page-specific JS at `src/assets/js/pages/admin/studio/index.js`. The framework's main-layer JS reads this object after auth resolves (by which time the project-layer module has executed).
28
+
29
+ ```js
30
+ // src/assets/js/pages/admin/studio/index.js (consumer project)
31
+
32
+ window.STUDIO_CLIPS = {
33
+ 'hero': {
34
+ label: 'Hero Intro', // sidebar button text
35
+ duration: 3000, // total animation duration in ms (before speed scaling)
36
+ build($canvas, { animate, el }) {
37
+ // Create DOM elements and animate them
38
+ const title = el('div', 'my-title-class');
39
+ title.innerHTML = '<h1>Hello World</h1>';
40
+ $canvas.appendChild(title);
41
+
42
+ animate(title, [
43
+ { opacity: 0, transform: 'translateY(30px)' },
44
+ { opacity: 1, transform: 'translateY(0)' },
45
+ ], { duration: 800 });
46
+ },
47
+ },
48
+
49
+ 'feature-demo': {
50
+ label: 'Feature Demo',
51
+ duration: 5000,
52
+ build($canvas, { animate, el }) {
53
+ // ...
54
+ },
55
+ },
56
+ };
57
+
58
+ export default () => {};
59
+ ```
60
+
61
+ ### Clip contract
62
+
63
+ Each clip in `window.STUDIO_CLIPS` is keyed by a unique ID string and must have:
64
+
65
+ | Field | Type | Description |
66
+ |---|---|---|
67
+ | `label` | `string` | Button text in the sidebar. Falls back to the clip ID if omitted. |
68
+ | `duration` | `number` | Total animation duration in milliseconds (pre-speed scaling). Used to calculate when the loop restarts. |
69
+ | `build` | `function($canvas, helpers)` | Called each loop iteration. Receives the cleared canvas element and helper functions. Must build DOM and start animations. |
70
+
71
+ ### `build()` helpers
72
+
73
+ The `build` function receives helpers as its second argument:
74
+
75
+ **Core helpers:**
76
+
77
+ **`animate(element, keyframes, options)`** — Wraps `Element.animate()` (Web Animations API). Auto-scales `delay` and `duration` by the current speed setting. Returns the `Animation` object.
78
+
79
+ Options:
80
+ - `delay` (ms, default `0`) — scaled by speed
81
+ - `duration` (ms, default `500`) — scaled by speed
82
+ - `fill` (default `'forwards'`)
83
+ - `easing` (default `'cubic-bezier(0.34, 1.56, 0.64, 1)'`)
84
+
85
+ **`el(tag, classes, innerHTML)`** — Creates a DOM element. All three arguments are optional strings.
86
+
87
+ **Builder helpers** (reusable clip patterns — zero boilerplate in consumer):
88
+
89
+ **`flowClip({ title, nodes })`** — Animated title + horizontal/vertical flow of cards with arrows between them. Automatically switches to vertical layout in 9:16. Each node has `{ icon, label, stat }`.
90
+
91
+ **`cardClip({ badge, title, contentFn })`** — Animated neobrutalist card (uses Bootstrap `.card` with theme shadow). `badge` is badge HTML, `title` is the heading, `contentFn(container, { animate, el })` is called to populate the card body.
92
+
93
+ **`chatClip({ header, messages })`** — Header + animated chat bubble sequence. Each message has `{ type: 'them'|'ai', text, label? }`.
94
+
95
+ ### Speed scaling
96
+
97
+ The `animate()` helper divides `delay` and `duration` by the current speed multiplier (0.5×, 1×, 1.5×, 2×). At 2× speed, a 1000ms animation completes in 500ms. The clip's `duration` is also scaled when calculating the loop restart time.
98
+
99
+ If a clip uses raw `Element.animate()` directly (bypassing the helper), it must manually divide timings by the speed — but this is not recommended.
100
+
101
+ ## Clip-specific CSS
102
+
103
+ Animation primitives go in the consumer's page-specific CSS at `src/assets/css/pages/admin/studio/index.scss`. These are project-specific styles for the animation content rendered inside the canvas — things like card shapes, flow diagrams, chat bubbles, custom keyframes.
104
+
105
+ **Important:** The consumer's page CSS MUST import the framework's boilerplate partial with `@use 'studio'`. This is required because page-specific CSS compiles independently per layer, and the consumer's file overwrites the framework's. The `@use 'studio'` directive pulls in the boilerplate (sidebar, controls, canvas) from `dist/assets/css/_studio.scss` via the SASS loadPaths. Consumer CSS should NOT redefine these boilerplate styles.
106
+
107
+ ```scss
108
+ // src/assets/css/pages/admin/studio/index.scss (consumer project)
109
+ @use 'ultimate-jekyll-manager' as *;
110
+ @use 'studio';
111
+
112
+ // Animation card
113
+ .my-card {
114
+ background: var(--my-theme-color);
115
+ border-radius: 18px;
116
+ padding: 24px;
117
+ opacity: 0; // animated in by JS
118
+ }
119
+
120
+ // Custom keyframe
121
+ @keyframes my-slide-in {
122
+ 0% { opacity: 0; transform: translateX(-40px); }
123
+ 100% { opacity: 1; transform: translateX(0); }
124
+ }
125
+ ```
126
+
127
+ ## Canvas dimensions
128
+
129
+ The canvas has two aspect ratio modes, toggled via the sidebar buttons:
130
+
131
+ | Aspect | Width | Height | Use case |
132
+ |---|---|---|---|
133
+ | 16:9 | 960px | 540px | Landscape video, website demos |
134
+ | 9:16 | 405px | 720px | Vertical reels, stories, TikTok |
135
+
136
+ The canvas transitions smoothly between sizes. The currently selected aspect ratio is shown as a label above the top-right corner.
137
+
138
+ ## Playback controls
139
+
140
+ | Control | Default | Range | Description |
141
+ |---|---|---|---|
142
+ | Pause (ms) | 1500 | 500–4000 | Delay between clip loop iterations |
143
+ | Speed | 1× | 0.5×–2× | Playback speed multiplier |
144
+ | Play/Pause | Playing | — | Toggle clip loop |
145
+
146
+ ## Admin gating
147
+
148
+ The Studio page requires `admin` role authentication (inherited from the `classy/admin/core/minimal` layout). Non-admin users are redirected to `/dashboard`. The `#studio` container starts `hidden` and is revealed after auth confirms admin access.
149
+
150
+ ## No-clips fallback
151
+
152
+ If `window.STUDIO_CLIPS` is not set or is empty (no clips registered), the Studio page shows a message: "No clips registered. Set `window.STUDIO_CLIPS` in your project JS." This is the expected state for consumer projects that haven't defined clips yet.
153
+
154
+ ## File locations
155
+
156
+ **Framework (UJM):**
157
+ - `src/defaults/dist/pages/admin/studio/index.html` — page file
158
+ - `src/defaults/dist/_layouts/blueprint/admin/studio/index.html` — blueprint layout (HTML + sidebar + controls)
159
+ - `src/assets/js/pages/admin/studio/index.js` — boilerplate JS (engine, helpers)
160
+ - `src/assets/css/_studio.scss` — boilerplate CSS partial (sidebar, controls, canvas). Consumers import via `@use 'studio'`.
161
+ - `src/defaults/dist/_includes/admin/sections/sidebar.json` — includes Studio link
162
+
163
+ **Consumer project:**
164
+ - `src/assets/js/pages/admin/studio/index.js` — clip definitions (`window.STUDIO_CLIPS`)
165
+ - `src/assets/css/pages/admin/studio/index.scss` — clip-specific animation CSS (must include `@use 'studio'` to pull in boilerplate)
166
+ - No page file needed — the framework default provides `/admin/studio`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.6.9",
3
+ "version": "1.7.1",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {