ultimate-jekyll-manager 1.6.8 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,29 @@ 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.0] - 2026-06-09
19
+
20
+ ### Added
21
+
22
+ - **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`.
23
+ - **Clip builder helpers** — `flowClip`, `cardClip`, `chatClip` passed alongside `animate`/`el` to clip `build()` functions. Consumers declare data, not structure.
24
+ - **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`).
25
+ - **`docs/animation-studio.md`** — full reference for the clip contract, helpers, recording, resolution, aspect ratios.
26
+ - **Admin sidebar entry** for Animation Studio under new "Content" section.
27
+
28
+ ---
29
+ ## [1.6.9] - 2026-06-08
30
+
31
+ ### Added
32
+
33
+ - **Bootstrap-first theming convention.** Added comprehensive guidance to `docs/themes.md` and the default consumer `CLAUDE.md` — themes must restyle Bootstrap classes, not create parallel design systems.
34
+ - **Dev workflow documentation.** Added explicit instructions to `CLAUDE.md` and consumer defaults to check `logs/dev.log` instead of running `npm start`/`npm test` in consumer projects.
35
+
36
+ ### Fixed
37
+
38
+ - **Signup metadata failure notification timeout.** Changed dev-only notification from permanent (`timeout: 0`) to 1 second (`timeout: 1000`) so it doesn't block the screen during development.
39
+
17
40
  ---
18
41
  ## [1.6.8] - 2026-06-07
19
42
 
package/CLAUDE.md CHANGED
@@ -158,6 +158,10 @@ Same `{ layer, description, run(ctx) }` contract as EM/BXM. JSON-line reporter p
158
158
 
159
159
  Note: `-t` short alias belongs to `translation`. The `test` command uses `--test` flag + `test` positional only. See [docs/cli.md](docs/cli.md) (planned).
160
160
 
161
+ ## Development Workflow
162
+
163
+ - **🚫 NEVER run `npm start` / `npm run build` / `npm test` in a consumer project** unless the user explicitly asks. The user runs the dev server — running it again kills theirs. Instead, **check `logs/dev.log`** after editing files to confirm the watcher recompiled successfully (`Reloading Browsers...` = success; `errored` = fix the error). If editing multiple files, check the log once after the last edit. A change that breaks the build is not a completed change.
164
+
161
165
  ## File Conventions
162
166
 
163
167
  - **CommonJS** in build-time / Node files (gulp tasks, commands, lib/). **ESM** in `src/index.js` (frontend Manager — webpack-bundled).
@@ -201,6 +205,7 @@ Deep references live in `docs/`. Treat docs as a first-class deliverable. **When
201
205
 
202
206
  ### Pages, layouts, content
203
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
204
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
205
210
  - [docs/layouts-and-pages.md](docs/layouts-and-pages.md) — page types, layout chain, `asset_path` frontmatter
206
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,9 @@ export default function () {
105
105
  }
106
106
  }
107
107
 
108
+ // Prompt for push notification subscription (fire-and-forget)
109
+ webManager.notifications().subscribe().catch(() => {});
110
+
108
111
  // Check if page requires user to be unauthenticated (e.g., signin page)
109
112
  if (policy === 'unauthenticated') {
110
113
  // Check for authReturnUrl first (takes precedence)
@@ -316,7 +319,7 @@ async function sendUserSignupMetadata(account) {
316
319
  /* @dev-only:start */
317
320
  webManager.utilities().showNotification(
318
321
  `[DEV] Failed to send signup metadata. Will retry on next page load (flags.signupProcessed is still false).`,
319
- { type: 'warning', timeout: 0 }
322
+ { type: 'warning', timeout: 1000 }
320
323
  );
321
324
  /* @dev-only:end */
322
325
  }
@@ -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
+ }
@@ -38,6 +38,26 @@ npx mgr install live # restore the published ultimate-jekyll-manager from npm
38
38
 
39
39
  > Editing the UJM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/ultimate-jekyll-manager`). Run `npx mgr install live` to switch back.
40
40
 
41
+ ## 🚨 BOOTSTRAP-FIRST — NEVER reinvent the wheel
42
+
43
+ **UJM is built on Bootstrap 5.** Every page MUST use Bootstrap classes for layout, spacing, typography, buttons, cards, grid, flex, and all standard components. Custom CSS exists ONLY to override how Bootstrap classes LOOK (via theme SCSS), NOT to replace them with parallel classes.
44
+
45
+ - **DO:** Use `.btn .btn-primary`, `.container`, `.row`, `.col-*`, `.d-flex`, `.gap-*`, `.py-5`, `.text-center`, `.card`, `.lead`, `.shadow`, `.rounded-*`, etc.
46
+ - **DO NOT:** Create custom `.my-btn`, `.my-wrap`, `.my-section` classes when Bootstrap already has equivalents. Don't write `padding`, `display: flex`, `gap`, `margin`, `text-align`, `font-weight` in custom CSS when a Bootstrap utility does the same thing.
47
+ - **Theme SCSS overrides appearance:** `.btn { border-radius: 50px; box-shadow: ... }` changes ALL buttons site-wide. You don't need `.lm-btn` — just restyle `.btn`.
48
+ - **Custom CSS is for genuinely novel components only:** animated hero illustrations, grain overlays, marquee strips — things with no Bootstrap equivalent.
49
+
50
+ See `node_modules/ultimate-jekyll-manager/docs/themes.md` for the full "Bootstrap-first" convention.
51
+
52
+ ## 🚨 Development workflow — MUST follow
53
+
54
+ - **🚫 NEVER run `npm start`, `npm run build`, or `npm test`** unless the user explicitly asks. Assume the user is already running the dev server. Running these commands kills the user's process and wastes time.
55
+ - **✅ ALWAYS check `logs/dev.log`** after editing source files (SCSS, JS, HTML, config) to confirm the build succeeded. The dev server's gulp watcher recompiles on file change — check the log for errors.
56
+ - Success: `Reloading Browsers...`
57
+ - Failure: `'sass' errored`, `'webpack' errored`, `'build-error'`, `'jekyll' errored`
58
+ - If editing multiple files in a batch, check the log once after the last edit. Wait a few seconds for the watcher to recompile before reading the log.
59
+ - **If the log shows an error, fix it immediately.** A change that breaks the build is not a completed change.
60
+
41
61
  ## Where things live
42
62
 
43
63
  - `src/_config.yml` — Jekyll config: brand, theme, meta, web_manager (Firebase). `Manager.getConfig('project')` reads this. **`brand.id` + `theme.id` are required.**
@@ -54,6 +74,16 @@ npx mgr install live # restore the published ultimate-jekyll-manager from npm
54
74
  - `dist/` — intermediate compile output (webpack bundles, sass, processed images) before Jekyll merges them into `_site/`.
55
75
  - `test/**/*.js` — your project test suites (framework auto-runs them alongside its own).
56
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
+
57
87
  ## Per-context imports
58
88
 
59
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/docs/themes.md CHANGED
@@ -345,6 +345,56 @@ their own (frozen) text color. Nav/dropdown/footer links already win on specific
345
345
 
346
346
  ---
347
347
 
348
+ ## 🚨 BOOTSTRAP-FIRST — NEVER reinvent the wheel
349
+
350
+ **This is the #1 theme authoring mistake.** A theme's job is to RESTYLE Bootstrap — not to build a parallel design system alongside it.
351
+
352
+ ### The rule
353
+
354
+ Every HTML element must use **Bootstrap classes first**. Custom CSS exists ONLY to override how those Bootstrap classes look (colors, shadows, borders, radii, typography) via the theme's SCSS. You should NEVER:
355
+
356
+ - Invent custom layout classes when Bootstrap grid/flex utilities exist (`.row`, `.col-*`, `.d-flex`, `.gap-*`, `.justify-content-*`, `.align-items-*`, `.text-center`, `.mx-auto`, etc.)
357
+ - Create custom button classes (`.my-btn`, `.lm-btn`) when `.btn .btn-primary`, `.btn .btn-outline-dark`, etc. already exist — restyle `.btn` in theme SCSS instead
358
+ - Create custom spacing/sizing classes when Bootstrap has `p-*`, `m-*`, `w-*`, `rounded-*`, `shadow-*`
359
+ - Create custom text utilities when Bootstrap has `.text-muted`, `.lead`, `.display-*`, `.fw-*`, `.fs-*`
360
+ - Create custom card/container classes when `.card`, `.card-body`, `.container`, `.lh-*` exist
361
+ - Write `position`, `display`, `flex`, `gap`, `padding`, `margin`, `border-radius`, `text-align`, `font-weight`, `font-size` in custom CSS when a Bootstrap utility class does the same thing
362
+
363
+ ### What theme CSS IS for
364
+
365
+ - Overriding Bootstrap component appearance: `.btn { border-radius: 50px; box-shadow: ... }` — changes how ALL buttons look
366
+ - Setting design tokens: `$primary`, `$border-radius`, `$font-family-sans-serif` — passed to Bootstrap's `@forward`
367
+ - CSS custom properties for the theme palette: `--lm-accent`, `--lm-ink`, etc.
368
+ - Dark mode overrides via `[data-bs-theme="dark"]` variable remapping
369
+ - Truly novel components with no Bootstrap equivalent (grain overlays, animated hero cards, marquee strips)
370
+ - Mixins/utilities that compose Bootstrap patterns, not replace them
371
+
372
+ ### How to check yourself
373
+
374
+ Before writing ANY custom CSS class, ask: "Does Bootstrap already have a class for this?" If yes, use it. If the Bootstrap class doesn't look right, override its appearance in theme SCSS — don't create a parallel class. The HTML should be 90%+ Bootstrap classes with custom classes only for genuinely novel UI patterns.
375
+
376
+ ### Anti-pattern example
377
+
378
+ ```html
379
+ <!-- BAD: parallel design system -->
380
+ <div class="lm-wrap">
381
+ <div class="lm-section">
382
+ <a class="lm-btn lm-btn-primary">Click</a>
383
+ </div>
384
+ </div>
385
+
386
+ <!-- GOOD: Bootstrap classes, theme restyled -->
387
+ <div class="container">
388
+ <section class="py-5">
389
+ <a class="btn btn-primary">Click</a>
390
+ </section>
391
+ </div>
392
+ ```
393
+
394
+ The GOOD version uses zero custom CSS for layout/buttons — the theme's `_buttons.scss` restyled `.btn` and `.btn-primary` to look however it wants. The page `main.scss` only adds styles for genuinely novel components.
395
+
396
+ ---
397
+
348
398
  ## Authoring conventions (both paths)
349
399
 
350
400
  1. **Every token is `!default`** so consumers can override without forking.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.6.8",
3
+ "version": "1.7.0",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {