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 +23 -0
- package/CLAUDE.md +5 -0
- package/dist/assets/css/_studio.scss +229 -0
- package/dist/assets/js/core/auth.js +4 -1
- package/dist/assets/js/pages/admin/studio/index.js +535 -0
- package/dist/defaults/CLAUDE.md +30 -0
- package/dist/defaults/dist/_includes/admin/sections/sidebar.json +9 -0
- package/dist/defaults/dist/_layouts/blueprint/admin/studio/index.html +92 -0
- package/dist/defaults/dist/pages/admin/studio/index.html +7 -0
- package/docs/animation-studio.md +166 -0
- package/docs/themes.md +50 -0
- package/package.json +1 -1
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:
|
|
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
|
+
}
|
package/dist/defaults/CLAUDE.md
CHANGED
|
@@ -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,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.
|