givemotion 0.0.0-stage → 0.1.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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +492 -2
- package/SECURITY.md +40 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/blocks/bar-chart.html +63 -0
- package/blocks/browser-window.html +52 -0
- package/blocks/callout.html +57 -0
- package/blocks/captions.html +120 -0
- package/blocks/checklist.html +32 -0
- package/blocks/code-window.html +126 -0
- package/blocks/compare.html +33 -0
- package/blocks/cursor.html +157 -0
- package/blocks/end-card.html +28 -0
- package/blocks/gradient-bg.html +48 -0
- package/blocks/grid-bg.html +18 -0
- package/blocks/highlight.html +13 -0
- package/blocks/line-chart.html +36 -0
- package/blocks/logo-reveal.html +33 -0
- package/blocks/lottie.html +34 -0
- package/blocks/lower-third.html +24 -0
- package/blocks/phone.html +18 -0
- package/blocks/quote.html +19 -0
- package/blocks/stat.html +24 -0
- package/blocks/steps.html +40 -0
- package/blocks/title-card.html +22 -0
- package/blocks/word-pop.html +40 -0
- package/examples/bench/index.html +24 -0
- package/examples/data-story/data.json +9 -0
- package/examples/data-story/index.html +361 -0
- package/examples/data-story/other.json +46 -0
- package/examples/generative/index.html +60 -0
- package/examples/made-by-agents/README.md +5 -0
- package/examples/made-by-agents/photosynthesis/blocks/checklist.html +32 -0
- package/examples/made-by-agents/photosynthesis/blocks/end-card.html +25 -0
- package/examples/made-by-agents/photosynthesis/blocks/gradient-bg.html +48 -0
- package/examples/made-by-agents/photosynthesis/blocks/steps.html +40 -0
- package/examples/made-by-agents/photosynthesis/blocks/title-card.html +22 -0
- package/examples/made-by-agents/photosynthesis/index.html +217 -0
- package/examples/personalized/index.html +28 -0
- package/llms.txt +34 -0
- package/package.json +80 -4
- package/skills/givemotion/SKILL.md +152 -0
- package/skills/givemotion/references/api.md +40 -0
- package/skills/givemotion/references/camera.md +72 -0
- package/skills/givemotion/references/charts.md +138 -0
- package/skills/givemotion/references/check-codes.md +94 -0
- package/skills/givemotion/references/data-templates.md +61 -0
- package/skills/givemotion/references/diagrams.md +75 -0
- package/skills/givemotion/references/genres/code-explainer.md +15 -0
- package/skills/givemotion/references/genres/data-story.md +21 -0
- package/skills/givemotion/references/genres/explainer.md +20 -0
- package/skills/givemotion/references/genres/hand-drawn.md +18 -0
- package/skills/givemotion/references/genres/logo-sting.md +20 -0
- package/skills/givemotion/references/genres/promo.md +18 -0
- package/skills/givemotion/references/genres/ui-demo.md +21 -0
- package/skills/givemotion/references/genres/vertical.md +19 -0
- package/skills/givemotion/references/genres/year-recap.md +18 -0
- package/skills/givemotion/references/javascript.md +72 -0
- package/skills/givemotion/references/media-audio.md +57 -0
- package/skills/givemotion/references/motion-design.md +183 -0
- package/skills/givemotion/references/output.md +64 -0
- package/skills/givemotion/references/review.md +60 -0
- package/skills/givemotion/references/styles.md +133 -0
- package/skills/givemotion/references/techniques/backgrounds-and-ambient.md +85 -0
- package/skills/givemotion/references/techniques/camera-moves.md +88 -0
- package/skills/givemotion/references/techniques/captions-vertical.md +89 -0
- package/skills/givemotion/references/techniques/code-on-screen.md +125 -0
- package/skills/givemotion/references/techniques/hand-drawn.md +87 -0
- package/skills/givemotion/references/techniques/impacts-and-energy.md +106 -0
- package/skills/givemotion/references/techniques/kinetic-type.md +92 -0
- package/skills/givemotion/references/techniques/light-and-depth.md +91 -0
- package/skills/givemotion/references/techniques/logo-reveal.md +94 -0
- package/skills/givemotion/references/techniques/numbers-and-charts.md +92 -0
- package/skills/givemotion/references/techniques/paths-and-maps.md +99 -0
- package/skills/givemotion/references/techniques/scene-transitions.md +85 -0
- package/skills/givemotion/references/techniques/shapes-and-motion-graphics.md +92 -0
- package/skills/givemotion/references/techniques/text-reveals.md +94 -0
- package/skills/givemotion/references/techniques/ui-demo.md +110 -0
- package/skills/givemotion/references/timeline.md +85 -0
- package/src/analyze.js +1485 -0
- package/src/api.js +36 -0
- package/src/args.js +33 -0
- package/src/audio-cli.js +58 -0
- package/src/bench.js +153 -0
- package/src/browser.js +381 -0
- package/src/bundle.js +79 -0
- package/src/cdp.js +94 -0
- package/src/check.js +1818 -0
- package/src/cli.js +611 -0
- package/src/dev.js +26 -0
- package/src/ffmpeg.js +330 -0
- package/src/parts.js +380 -0
- package/src/player/gm-player.js +175 -0
- package/src/render.js +772 -0
- package/src/runtime/audio.js +306 -0
- package/src/runtime/chart.js +665 -0
- package/src/runtime/fonts/BebasNeue-OFL.txt +93 -0
- package/src/runtime/fonts/Caveat-OFL.txt +93 -0
- package/src/runtime/fonts/Inter-OFL.txt +93 -0
- package/src/runtime/fonts/JetBrainsMono-OFL.txt +93 -0
- package/src/runtime/fonts/Nunito-OFL.txt +93 -0
- package/src/runtime/fonts/PlayfairDisplay-OFL.txt +93 -0
- package/src/runtime/fonts/SpaceGrotesk-OFL.txt +93 -0
- package/src/runtime/fonts/bebas-neue-latin-400-normal.woff2 +0 -0
- package/src/runtime/fonts/bebas-neue-latin-ext-400-normal.woff2 +0 -0
- package/src/runtime/fonts/caveat-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/caveat-latin-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/inter-latin-ext-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/inter-latin-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/jetbrains-mono-latin-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/nunito-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/nunito-latin-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/playfair-display-latin-ext-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/playfair-display-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/playfair-display-latin-wght-italic.woff2 +0 -0
- package/src/runtime/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/space-grotesk-latin-ext-wght-normal.woff2 +0 -0
- package/src/runtime/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
- package/src/runtime/motion.js +2674 -0
- package/src/runtime/preset.css +97 -0
- package/src/runtime/runtime.js +2141 -0
- package/src/runtime/sketch.js +1249 -0
- package/src/server.js +402 -0
- package/src/studio/index.html +238 -0
- package/src/types/api.d.ts +354 -0
- package/templates/starter/index.html +70 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: A mouse pointer that glides between targets and clicks them. Aim at real elements with CSS selectors (the pointer follows them even while they move, through any camera zoom), or at "x,y" points (px at 1080p, in the box the cursor sits in: inside a gm.camera world that is world px). Add ! to click. Clicked elements get a short press, and the attribute data-gm-pressed while pressed and data-gm-hover while the pointer is over them, so you can style them. Put it inside a camera world (it zooms with the world) or after the world in the scene (it keeps its size and still lands on the targets in the world).
|
|
3
|
+
params: points (e.g. "#email #submit! 1500,900"; a selector aims a little below its middle, or where you say after @: "#slot-thu@30%,55%" = 30% across and 55% down its box, "#row@40,50%" = 40 px in from its left edge (px at 1080p, zoomed with it) and halfway down; put the ! last: "#slot-thu@30%,55%!"), aim (default aim point for selector targets without their own @, same form, default "50%,56%"), ring (the click ripple's color, e.g. "#e5484d"; "light" = white; default: a dark ring with a light halo that reads on light and dark UIs, white with <html data-motion="classic">), times (one per point: when the pointer arrives there. Seconds into the clip by default; "video: 12.5 13.4" for video times, "beats: 16 18 20" or "bars: 4 4.5" on the <html data-bpm> grid, or words of the voice-over "@type @send-0.2" with <html data-words>), show (always|auto, default always: from the start of the clip. auto: fades in just before its first move, or just before its first point when that is a click, fades out after its last point, and leaves during a wait of 3 s or more between two points), size (px at 1080p, default 44), style (arrow|hand|auto, default auto: hand over clickable targets)
|
|
4
|
+
usage: <gm-include src="blocks/cursor.html" points="1700,980 #run! #canvas .node:nth-child(2)!" times="0.3 1.4 3 4.2" show="auto"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-cursor" style="--s:{{size|44}}" data-points="{{points|200,800 960,540!}}" data-times="{{times}}" data-show="{{show|always}}" data-style="{{style|auto}}" data-aim="{{aim}}" data-ring="{{ring}}">
|
|
7
|
+
<div class="gmb-cursor__ring"></div>
|
|
8
|
+
<svg class="gmb-cursor__arrow" viewBox="0 0 24 24"><path d="M4 2.5l15.5 9.2-6.6 1.6-3.4 6.2z"/></svg>
|
|
9
|
+
<svg class="gmb-cursor__hand" viewBox="0 0 24 24"><path d="M9 3.2c.9 0 1.6.7 1.6 1.6v6.1l.6-.1c.8 0 1.5.6 1.6 1.4l.1.2c.3-.5.8-.8 1.4-.8.8 0 1.5.6 1.6 1.4.3-.3.7-.5 1.2-.5.9 0 1.6.7 1.6 1.6v2.3c0 3.1-2.5 5.6-5.6 5.6h-1.3c-1.9 0-3.6-1-4.6-2.6l-2.9-4.8c-.4-.7-.2-1.6.5-2 .6-.4 1.4-.2 1.9.3l.8 1V4.8c0-.9.7-1.6 1.6-1.6z"/></svg>
|
|
10
|
+
</div>
|
|
11
|
+
<script>
|
|
12
|
+
(() => {
|
|
13
|
+
const root = document.currentScript.previousElementSibling;
|
|
14
|
+
const arrow = root.querySelector('.gmb-cursor__arrow'), hand = root.querySelector('.gmb-cursor__hand'), ring = root.querySelector('.gmb-cursor__ring');
|
|
15
|
+
const scope = root.closest('.scene,[data-duration],[data-start]') || document.body;
|
|
16
|
+
// "x,y" or a CSS selector; "!" at the end means click
|
|
17
|
+
// an aim point inside the target's box: "30%,55%" (share of its size) or px at 1080p from its left/top edge ("40,50%")
|
|
18
|
+
const aimOf = (v) => { const m = /^\s*(-?[\d.]+)(%?)\s*,\s*(-?[\d.]+)(%?)\s*$/.exec(v || ''); return m ? [{ v: +m[1], pct: !!m[2] }, { v: +m[3], pct: !!m[4] }] : null; };
|
|
19
|
+
const aim0 = aimOf(root.dataset.aim);
|
|
20
|
+
if ((root.dataset.aim || '').trim() && !aim0) gm.__report('warning', 'cursor-aim', `cursor: aim="${root.dataset.aim}" is not "x,y" (like "30%,55%" or "40,50%").`, root, 'Give two values: percent of the target box (30%) or px at 1080p from its left/top edge (40).');
|
|
21
|
+
const pts = root.dataset.points.trim().split(/\s+(?![^\[]*\])/).map((p) => {
|
|
22
|
+
const click = p.endsWith('!');
|
|
23
|
+
let s = click ? p.slice(0, -1) : p;
|
|
24
|
+
const m = /^(-?[\d.]+),(-?[\d.]+)$/.exec(s);
|
|
25
|
+
if (m) return { x: +m[1], y: +m[2], click };
|
|
26
|
+
const at = /^(.+)@(-?[\d.]+%?,-?[\d.]+%?)$/.exec(s);
|
|
27
|
+
let aim = aim0;
|
|
28
|
+
if (at) { s = at[1]; aim = aimOf(at[2]); }
|
|
29
|
+
return { sel: s, click, el: null, aim };
|
|
30
|
+
});
|
|
31
|
+
// times: seconds into the clip, or words ("@book @send"), or "video: 12.5 13.4" / "beats: 16 18" / "bars: 4 4.5" (then video times)
|
|
32
|
+
const tAttr = (root.dataset.times || '').trim();
|
|
33
|
+
const unit = /^(video|global|beats?|bars?)\s*:/i.exec(tAttr);
|
|
34
|
+
// a word of the voice-over ("@type @send-0.2", with <html data-words>) is a video time; then the whole list is
|
|
35
|
+
const toks = (unit ? tAttr.slice(unit[0].length) : tAttr).trim().split(/[\s,]+/).filter(Boolean);
|
|
36
|
+
const words = toks.some((x) => x[0] === '@');
|
|
37
|
+
const video = !!unit || words;
|
|
38
|
+
let ts = toks.map((x) => (x[0] === '@' ? gm.word(x) : Number(x)));
|
|
39
|
+
if (ts.some((x) => !isFinite(x))) { gm.__report('error', 'cursor-times', `cursor: times="${tAttr}" has something that is not a number.`, root, 'Give one number per point: times="0.4 1.6 3", or times="video: 12.5 13.4" / "beats: 16 18".'); ts = ts.filter((x) => isFinite(x)); }
|
|
40
|
+
if (unit && /^beat/i.test(unit[1])) ts = ts.map((b) => gm.beat(b));
|
|
41
|
+
if (unit && /^bar/i.test(unit[1])) ts = ts.map((b) => gm.bar(b));
|
|
42
|
+
// no times, or fewer than points: 0.3 s in, then 1.2 s apart
|
|
43
|
+
while (ts.length < pts.length) ts.push(ts.length ? ts[ts.length - 1] + 1.2 : 0.3);
|
|
44
|
+
const style = root.dataset.style, show = (root.dataset.show || 'always').trim();
|
|
45
|
+
// show="auto": in just before the first move (or the first point, when that is a click), out after the last point
|
|
46
|
+
const n = pts.length, appear = pts[0].click || n < 2 ? ts[0] : ts[1] - Math.min(ts[1] - ts[0], 0.9);
|
|
47
|
+
const leave = ts[n - 1] + (pts[n - 1].click ? 0.6 : 0.25);
|
|
48
|
+
const targetOf = (p) => { if (!p.sel) return null; if (!p.el || !p.el.isConnected) p.el = scope.querySelector(p.sel) || document.querySelector(p.sel); return p.el; };
|
|
49
|
+
for (const p of pts) if (p.sel && !targetOf(p)) gm.__report('error', 'cursor-target', `cursor: "${p.sel}" matched nothing.`, root, 'Use a selector for an element in the same scene, e.g. #button.');
|
|
50
|
+
// Three invisible points next to the pointer (same containing block): screen <-> the pointer's own coordinates,
|
|
51
|
+
// through any camera zoom, pan or rotation above it
|
|
52
|
+
const probes = [[0, 0], [1000, 0], [0, 1000]].map(([x, y]) => { const d = document.createElement('div'); d.setAttribute('data-gm-internal', ''); d.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:0;height:0;margin:0;pointer-events:none;visibility:hidden`; root.parentElement.insertBefore(d, root); return d; });
|
|
53
|
+
function space() {
|
|
54
|
+
const [o, a, b] = probes.map((d) => d.getBoundingClientRect());
|
|
55
|
+
const ax = (a.left - o.left) / 1000, ay = (a.top - o.top) / 1000, bx = (b.left - o.left) / 1000, by = (b.top - o.top) / 1000, det = ax * by - ay * bx || 1;
|
|
56
|
+
return {
|
|
57
|
+
toLocal: (sx, sy) => { const dx = sx - o.left, dy = sy - o.top; return [(dx * by - dy * bx) / det, (dy * ax - dx * ay) / det]; },
|
|
58
|
+
toScreen: (x, y) => [o.left + x * ax + y * bx, o.top + x * ay + y * by],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Where a point is right now, in the pointer's own coordinates (CSS px)
|
|
62
|
+
function where(p, S) {
|
|
63
|
+
const el = targetOf(p);
|
|
64
|
+
if (!el) return { x: (p.x || 0) * gm.px, y: (p.y || 0) * gm.px };
|
|
65
|
+
const r = el.getBoundingClientRect();
|
|
66
|
+
// aim a little below the middle, like a real hand would (or at the point given with @ / aim)
|
|
67
|
+
if (!p.aim) { const [x, y] = S.toLocal(r.left + r.width * 0.5, r.top + r.height * 0.56); return { x, y }; }
|
|
68
|
+
const k = el.offsetWidth ? r.width / el.offsetWidth : 1; // the target's own zoom on screen
|
|
69
|
+
const ax = p.aim[0].pct ? r.width * p.aim[0].v / 100 : p.aim[0].v * gm.px * k, ay = p.aim[1].pct ? r.height * p.aim[1].v / 100 : p.aim[1].v * gm.px * k;
|
|
70
|
+
const [x, y] = S.toLocal(r.left + ax, r.top + ay);
|
|
71
|
+
return { x, y };
|
|
72
|
+
}
|
|
73
|
+
// the click ring: a dark ring with a light halo reads on light and dark screens (classic motion: the old white one)
|
|
74
|
+
const rc = (root.dataset.ring || '').trim(), natural = !gm.__internal || !gm.__internal.naturalMotion || gm.__internal.naturalMotion();
|
|
75
|
+
if (/^(light|white)$/i.test(rc)) { /* the stylesheet's white ring */ }
|
|
76
|
+
else if (rc || natural) {
|
|
77
|
+
const c = rc && !/^dark$/i.test(rc) ? rc : '#16181d';
|
|
78
|
+
ring.style.borderColor = c;
|
|
79
|
+
ring.style.background = `color-mix(in srgb, ${c} 14%, transparent)`;
|
|
80
|
+
ring.style.boxShadow = '0 0 0 calc(var(--px)*3) rgba(255,255,255,.75), inset 0 0 0 calc(var(--px)*2) rgba(255,255,255,.5)';
|
|
81
|
+
}
|
|
82
|
+
const pressed = new Set();
|
|
83
|
+
gm.onLayout(root, (local, info) => {
|
|
84
|
+
const t = video ? info.time : local;
|
|
85
|
+
const S = space();
|
|
86
|
+
let i = 0;
|
|
87
|
+
while (i < n - 1 && t >= ts[i + 1]) i++;
|
|
88
|
+
const P = where(pts[i], S);
|
|
89
|
+
let x = P.x, y = P.y;
|
|
90
|
+
if (i < n - 1 && t > ts[i]) {
|
|
91
|
+
const seg = ts[i + 1] - ts[i], move = Math.min(seg, 0.9);
|
|
92
|
+
const p = gm.interpolate(t, [ts[i + 1] - move, ts[i + 1]], [0, 1], { easing: 'in-out-cubic' });
|
|
93
|
+
if (p > 0) {
|
|
94
|
+
const Q = where(pts[i + 1], S);
|
|
95
|
+
const mx = (P.x + Q.x) / 2, my = Math.min(P.y, Q.y) - 60 * gm.px; // slight arc
|
|
96
|
+
const q = (a, b, c) => (1 - p) * (1 - p) * a + 2 * (1 - p) * p * b + p * p * c;
|
|
97
|
+
x = q(P.x, mx, Q.x); y = q(P.y, my, Q.y);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
let opacity = show === 'auto'
|
|
101
|
+
? Math.min(gm.interpolate(t, [appear - 0.25, appear], [0, 1]), gm.interpolate(t, [leave, leave + 0.3], [1, 0]))
|
|
102
|
+
: gm.interpolate(local, [0, 0.25], [0, 1]);
|
|
103
|
+
// show="auto" and a long wait between two points (3 s or more): the pointer leaves after the first and comes back
|
|
104
|
+
// just before it sets off for the next, instead of sitting on the screen
|
|
105
|
+
if (show === 'auto') for (let i = 0; i + 1 < n; i++) {
|
|
106
|
+
const a = ts[i] + (pts[i].click ? 0.6 : 0.25), b = ts[i + 1] - 0.9;
|
|
107
|
+
if (b - a < 2.1 || t <= a || t >= b) continue;
|
|
108
|
+
opacity = Math.min(opacity, Math.max(gm.interpolate(t, [a, a + 0.3], [1, 0]), gm.interpolate(t, [b - 0.3, b], [0, 1])));
|
|
109
|
+
}
|
|
110
|
+
// clicks: press + ripple right after arriving at a point marked with !
|
|
111
|
+
let press = 1, rp = -1;
|
|
112
|
+
const nowPressed = new Set();
|
|
113
|
+
pts.forEach((pt, k) => {
|
|
114
|
+
if (!pt.click) return;
|
|
115
|
+
const dt = t - ts[k];
|
|
116
|
+
if (dt >= 0 && dt < 0.6) {
|
|
117
|
+
rp = dt / 0.6;
|
|
118
|
+
const s = Math.sin(Math.min(1, dt / 0.24) * Math.PI);
|
|
119
|
+
press = Math.min(press, 1 - 0.18 * s);
|
|
120
|
+
const el = targetOf(pt);
|
|
121
|
+
if (el && dt < 0.24) { nowPressed.add(el); el.style.scale = String(1 - 0.05 * s); }
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
for (const el of pressed) if (!nowPressed.has(el)) { el.style.removeProperty('scale'); el.removeAttribute('data-gm-pressed'); }
|
|
125
|
+
for (const el of nowPressed) el.setAttribute('data-gm-pressed', '');
|
|
126
|
+
pressed.clear(); nowPressed.forEach((e) => pressed.add(e));
|
|
127
|
+
// hover state + hand pointer over targets (not while the pointer is hidden)
|
|
128
|
+
let over = false;
|
|
129
|
+
const [sx, sy] = S.toScreen(x, y);
|
|
130
|
+
for (const pt of pts) {
|
|
131
|
+
const el = targetOf(pt);
|
|
132
|
+
if (!el) continue;
|
|
133
|
+
const r = el.getBoundingClientRect();
|
|
134
|
+
const inside = opacity > 0 && sx >= r.left && sx <= r.right && sy >= r.top && sy <= r.bottom;
|
|
135
|
+
if (inside) { over = true; el.setAttribute('data-gm-hover', ''); } else el.removeAttribute('data-gm-hover');
|
|
136
|
+
}
|
|
137
|
+
// resting on its own target is the point: check should not call the pointer a line through that label
|
|
138
|
+
if (over) root.setAttribute('data-check', 'ignore'); else root.removeAttribute('data-check');
|
|
139
|
+
const useHand = style === 'hand' || (style === 'auto' && over);
|
|
140
|
+
arrow.style.display = useHand ? 'none' : '';
|
|
141
|
+
hand.style.display = useHand ? '' : 'none';
|
|
142
|
+
root.style.translate = `${x}px ${y}px`;
|
|
143
|
+
arrow.style.scale = hand.style.scale = press;
|
|
144
|
+
ring.style.opacity = rp < 0 ? 0 : 1 - rp;
|
|
145
|
+
ring.style.scale = rp < 0 ? 0 : 0.3 + rp * 1.4;
|
|
146
|
+
root.style.opacity = opacity;
|
|
147
|
+
});
|
|
148
|
+
})();
|
|
149
|
+
</script>
|
|
150
|
+
<style>
|
|
151
|
+
.gmb-cursor{position:absolute;left:0;top:0;width:0;height:0;z-index:50;pointer-events:none}
|
|
152
|
+
.gmb-cursor__arrow,.gmb-cursor__hand{position:absolute;width:calc(var(--px)*var(--s));height:calc(var(--px)*var(--s));filter:drop-shadow(0 calc(var(--px)*4) calc(var(--px)*8) rgba(0,0,0,.4))}
|
|
153
|
+
.gmb-cursor__arrow{left:calc(var(--px)*-4);top:calc(var(--px)*-3);transform-origin:15% 10%}
|
|
154
|
+
.gmb-cursor__hand{left:calc(var(--px)*var(--s)*-0.37);top:calc(var(--px)*var(--s)*-0.12);transform-origin:37% 12%}
|
|
155
|
+
.gmb-cursor svg path{fill:#111;stroke:#fff;stroke-width:1.6;stroke-linejoin:round}
|
|
156
|
+
.gmb-cursor__ring{position:absolute;width:calc(var(--px)*110);height:calc(var(--px)*110);left:calc(var(--px)*-55);top:calc(var(--px)*-55);border-radius:50%;border:calc(var(--px)*4) solid rgba(255,255,255,.9);background:rgba(255,255,255,.15);opacity:0}
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Closing card: optional logo, headline, subtitle and a call-to-action button that pops in. It never sits dead still: a slow camera push and a shine across the button keep it alive.
|
|
3
|
+
params: title, subtitle, cta (button text), url (small text under the button), logo (image path), accent (color)
|
|
4
|
+
usage: <gm-include src="blocks/end-card.html" title="Try it today" cta="Get started" url="example.com" data-duration="4"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-end" style="--accent:{{accent|#7c9cff}}" data-camera="push 1.06">
|
|
7
|
+
<img class="gmb-end__logo" src="{{logo}}" alt="" data-enter="scale 0.7">
|
|
8
|
+
<h2 class="gmb-end__h" data-split="words" data-enter="rise 0.8 0.15" data-stagger="0.06">{{title|Thanks for watching}}</h2>
|
|
9
|
+
<p class="gmb-end__sub" data-enter="fade-up 0.6 0.5">{{subtitle}}</p>
|
|
10
|
+
<div class="gmb-end__cta" data-enter="pop 0.6 0.8">{{cta}}</div>
|
|
11
|
+
<div class="gmb-end__url" data-enter="fade 0.6 1.1">{{url}}</div>
|
|
12
|
+
</div>
|
|
13
|
+
<script>
|
|
14
|
+
(() => { const r = document.currentScript.previousElementSibling; const i = r.querySelector('img'); if (!i.getAttribute('src')) i.remove(); })();
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
.gmb-end{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:calc(var(--px)*28);padding:0 calc(var(--px)*160)}
|
|
18
|
+
.gmb-end__logo{height:calc(var(--px)*110);width:auto;margin-bottom:calc(var(--px)*10)}
|
|
19
|
+
.gmb-end__h{margin:0;font-size:calc(var(--px)*112);font-weight:800;letter-spacing:-.035em;line-height:1.04;max-width:18ch;text-wrap:balance}
|
|
20
|
+
.gmb-end__sub{margin:0;font-size:calc(var(--px)*42);opacity:.72;max-width:32ch;text-wrap:balance}
|
|
21
|
+
.gmb-end__cta{margin-top:calc(var(--px)*18);padding:calc(var(--px)*24) calc(var(--px)*54);border-radius:99px;background:var(--accent);color:#0b0d12;font-size:calc(var(--px)*42);font-weight:800;
|
|
22
|
+
box-shadow:0 calc(var(--px)*16) calc(var(--px)*50) color-mix(in oklab,var(--accent),transparent 55%)}
|
|
23
|
+
.gmb-end__cta{position:relative;overflow:hidden;isolation:isolate}
|
|
24
|
+
.gmb-end__cta::after{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.55) 50%,transparent 70%);transform:translateX(-120%);animation:gmb-end-shine 1.1s 1.7s cubic-bezier(.4,0,.2,1) 3 forwards}
|
|
25
|
+
@keyframes gmb-end-shine{0%{transform:translateX(-120%)}60%,100%{transform:translateX(120%)}}
|
|
26
|
+
.gmb-end__url{font-size:calc(var(--px)*32);opacity:.6;letter-spacing:.02em}
|
|
27
|
+
.gmb-end__sub:empty,.gmb-end__cta:empty,.gmb-end__url:empty{display:none}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Soft animated mesh-gradient background (slowly drifting color blobs with fine grain). Put it anywhere inside a .scene: it always sits behind the scene content (no z-index needed).
|
|
3
|
+
params: c1, c2, c3 (colors; keep them mid-to-dark when the text on top is white), base (background color), speed (1 = default), grain (0-1, default 0.06)
|
|
4
|
+
usage: <gm-include src="blocks/gradient-bg.html" c1="#6d5dfc" c2="#00c2ff" c3="#ff4ecd"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-grad" data-c="{{c1|#4f3fd1}},{{c2|#0284c7}},{{c3|#be2a8f}}" data-base="{{base|#07080d}}" data-speed="{{speed|1}}" style="--grain:{{grain|0.06}}">
|
|
7
|
+
<canvas class="gmb-grad__c" width="240" height="135"></canvas><div class="gmb-grad__grain"></div>
|
|
8
|
+
</div>
|
|
9
|
+
<script>
|
|
10
|
+
(() => {
|
|
11
|
+
// Drawn small and scaled up: soft gradients look identical, and it renders ~5x faster than big blurred layers.
|
|
12
|
+
const root = document.currentScript.previousElementSibling;
|
|
13
|
+
const cv = root.querySelector('canvas'), g = cv.getContext('2d');
|
|
14
|
+
const [c1, c2, c3] = root.dataset.c.split(',');
|
|
15
|
+
const base = root.dataset.base, sp = parseFloat(root.dataset.speed) || 1;
|
|
16
|
+
const W = cv.width, H = cv.height;
|
|
17
|
+
const blobs = [
|
|
18
|
+
{ c: c1, x: 0.18, y: 0.12, ax: 0.22, ay: 0.16, p: 14, r: 0.62 },
|
|
19
|
+
{ c: c2, x: 0.82, y: 0.42, ax: -0.24, ay: 0.14, p: 17, r: 0.6 },
|
|
20
|
+
{ c: c3, x: 0.45, y: 0.95, ax: 0.16, ay: -0.22, p: 20, r: 0.66 },
|
|
21
|
+
];
|
|
22
|
+
const hex = (c, a) => { const h = c.trim().replace('#', ''); const n = parseInt(h.length === 3 ? [...h].map((x) => x + x).join('') : h.slice(0, 6), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; };
|
|
23
|
+
gm.onFrame(root, (t, f) => {
|
|
24
|
+
t = f.time; // drift on global time so it continues smoothly across scenes
|
|
25
|
+
g.globalCompositeOperation = 'source-over';
|
|
26
|
+
g.fillStyle = base; g.fillRect(0, 0, W, H);
|
|
27
|
+
g.globalCompositeOperation = 'screen';
|
|
28
|
+
for (const b of blobs) {
|
|
29
|
+
const k = (1 - Math.cos((2 * Math.PI * t * sp) / b.p)) / 2; // smooth back-and-forth drift
|
|
30
|
+
const x = (b.x + b.ax * k) * W, y = (b.y + b.ay * k) * H, r = b.r * W;
|
|
31
|
+
const gr = g.createRadialGradient(x, y, 0, x, y, r);
|
|
32
|
+
gr.addColorStop(0, hex(b.c, 0.72)); gr.addColorStop(0.45, hex(b.c, 0.26)); gr.addColorStop(1, hex(b.c, 0));
|
|
33
|
+
g.fillStyle = gr; g.fillRect(0, 0, W, H);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
// Static film grain tile
|
|
37
|
+
const n = document.createElement('canvas'); n.width = n.height = 160;
|
|
38
|
+
const ng = n.getContext('2d'), id = ng.createImageData(160, 160);
|
|
39
|
+
for (let i = 0; i < id.data.length; i += 4) { const v = Math.random() * 255; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; }
|
|
40
|
+
ng.putImageData(id, 0, 0);
|
|
41
|
+
root.querySelector('.gmb-grad__grain').style.backgroundImage = `url(${n.toDataURL()})`;
|
|
42
|
+
})();
|
|
43
|
+
</script>
|
|
44
|
+
<style>
|
|
45
|
+
.gmb-grad{position:absolute;inset:0;overflow:hidden;z-index:-1}
|
|
46
|
+
.gmb-grad__c{position:absolute;inset:0;width:100%;height:100%;display:block}
|
|
47
|
+
.gmb-grad__grain{position:absolute;inset:0;opacity:var(--grain);background-size:calc(var(--px)*160)}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Subtle tech background: a slowly drifting line grid (or dots) with a soft glow and vignette. Put it anywhere inside a .scene: it always sits behind the scene content (no z-index needed).
|
|
3
|
+
params: base (background color), line (grid color), glow (color), size (cell px at 1080p, default 64), pattern (lines|dots)
|
|
4
|
+
usage: <gm-include src="blocks/grid-bg.html" glow="#3b82f6"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-gridbg gmb-gridbg--{{pattern|lines}}" style="--base:{{base|#07090f}};--line:{{line|rgba(255,255,255,.08)}};--glow:{{glow|#4f7cff}};--cell:{{size|64}}">
|
|
7
|
+
<div class="gmb-gridbg__glow"></div><div class="gmb-gridbg__grid"></div><div class="gmb-gridbg__vig"></div>
|
|
8
|
+
</div>
|
|
9
|
+
<style>
|
|
10
|
+
.gmb-gridbg{position:absolute;inset:0;overflow:hidden;background:var(--base);z-index:-1}
|
|
11
|
+
.gmb-gridbg__grid{position:absolute;inset:calc(var(--px)*var(--cell)*-1);--c:calc(var(--px)*var(--cell));will-change:translate;animation:gmb-gridbg-drift 12s linear infinite}
|
|
12
|
+
.gmb-gridbg--lines .gmb-gridbg__grid{background-image:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);background-size:var(--c) var(--c)}
|
|
13
|
+
.gmb-gridbg--dots .gmb-gridbg__grid{background-image:radial-gradient(var(--line) calc(var(--px)*2.2),transparent calc(var(--px)*2.6));background-size:var(--c) var(--c);filter:brightness(1.8)}
|
|
14
|
+
.gmb-gridbg__vig{position:absolute;inset:0;background:radial-gradient(ellipse 62% 62% at 50% 45%,transparent 35%,var(--base) 80%)}
|
|
15
|
+
.gmb-gridbg__glow{position:absolute;left:50%;top:40%;width:80%;aspect-ratio:2;translate:-50% -50%;background:radial-gradient(closest-side,color-mix(in oklab,var(--glow),transparent 70%),transparent);will-change:opacity;animation:gmb-gridbg-breathe 6s ease-in-out infinite alternate}
|
|
16
|
+
@keyframes gmb-gridbg-drift{to{translate:calc(var(--px)*var(--cell)) calc(var(--px)*var(--cell))}}
|
|
17
|
+
@keyframes gmb-gridbg-breathe{from{opacity:.65}to{opacity:1}}
|
|
18
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: A mark that sits on an element and follows it every frame (moving, scaling, camera): a wash, a ring, an underline or a glow. Put it inside the container it marks (that clips it), before what should cover it.
|
|
3
|
+
params: target (CSS selector of what to mark), look (wash|ring|underline|glow, default wash), color, pad (px at 1080p past the target's box, default 6), radius (px, default 8), width (line px for ring and underline, default 4)
|
|
4
|
+
usage: <gm-include src="blocks/highlight.html" target="#price" look="ring" color="#ffd23f" data-start="1.2" data-duration="2"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-hl gmb-hl--{{look|wash}}" data-anchor="{{target}} fit {{pad|6}}" data-enter="fade 0.25" style="--c:{{color|#ffd23f}};--r:{{radius|8}}px;--w:{{width|4}}px"></div>
|
|
7
|
+
<style>
|
|
8
|
+
.gmb-hl{pointer-events:none;border-radius:var(--r)}
|
|
9
|
+
.gmb-hl--wash{background:color-mix(in srgb,var(--c) 38%,transparent)}
|
|
10
|
+
.gmb-hl--ring{box-shadow:inset 0 0 0 var(--w) var(--c)}
|
|
11
|
+
.gmb-hl--underline{border-radius:0;background:linear-gradient(var(--c),var(--c)) bottom/100% var(--w) no-repeat}
|
|
12
|
+
.gmb-hl--glow{box-shadow:0 0 0 2px color-mix(in srgb,var(--c) 70%,transparent),0 0 28px 4px color-mix(in srgb,var(--c) 55%,transparent)}
|
|
13
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Line chart that draws itself left to right (built on gm.chart.line): a glowing head a camera can follow, callouts that appear when the line reaches their point, pauses at chosen points, per-segment colors, an axis that can grow with the line.
|
|
3
|
+
params: values (comma list), labels (comma list, optional), title, color, prefix, suffix, width (px at 1080p; scales with the short side, default 1400), height (default 560), delay (when the line starts, s, default 0.3), duration (draw time in s including holds; default 1.8, longer for many points), holds ("Mar:1.2 Aug:1.2" pause at those points), colors ("Mar:#ff4d6d" color of the segment that ends at that point; the head takes it too), rescale ("true": the y axis grows with the highest value so far, so small early moves stay visible), axis (left | right | head | none: no labels, grid or base line), ticks (a step like 10000, or values "0,10000,20000"), grid ("false" hides the grid lines), axisintro (seconds: the axis and grid draw on from the origin at the start), dots ("false" hides the point dots), endlabel ("false" hides the last value), overview ("true": after drawing, narrows to the frame width for a camera fit)
|
|
4
|
+
usage: <gm-include src="blocks/line-chart.html" title="Weekly users" values="120,180,160,240,310,420" labels="W1,W2,W3,W4,W5,W6" data-duration="5"></gm-include>
|
|
5
|
+
Callouts go inside, pinned with data-point="<point> <side> <gap>" (point: 0-based index, a label like Mar, last, max, or head to ride the tip; side: top bottom left right top-left top-right bottom-left bottom-right center); each appears when the line gets there:
|
|
6
|
+
<gm-include src="blocks/line-chart.html" id="growth" values="{{data.values}}" labels="{{data.months}}" holds="Mar:1.2" colors="Mar:#ff4d6d" rescale="true" duration="8"><div data-point="Mar top-right 30">Outage</div></gm-include>
|
|
7
|
+
In JS: gm.chart.get('#growth') gives the chart (.timeOf('Mar'), .valueAt(t), .head); a camera follows it with follow: '#growth .gm-chart-head'.
|
|
8
|
+
-->
|
|
9
|
+
<div class="gmb-line" style="--c:{{color|#5eead4}};--w:{{width|1400}};--h:{{height|560}}" data-values="{{values|10,14,12,20,26,34}}" data-labels="{{labels}}" data-lc-prefix="{{prefix}}" data-lc-suffix="{{suffix}}" data-lc-delay="{{delay}}" data-lc-duration="{{duration}}" data-lc-holds="{{holds}}" data-lc-colors="{{colors}}" data-lc-rescale="{{rescale}}" data-lc-axis="{{axis|left}}" data-lc-ticks="{{ticks}}" data-lc-grid="{{grid}}" data-lc-axisintro="{{axisintro}}" data-lc-dots="{{dots}}" data-lc-endlabel="{{endlabel}}" data-lc-overview="{{overview}}">
|
|
10
|
+
<div class="gmb-line__title" data-enter="fade-up 0.6">{{title}}</div>
|
|
11
|
+
<div class="gmb-line__plot">
|
|
12
|
+
<div class="gmb-line__end" data-point="last top 14" data-chart-text="value"></div>
|
|
13
|
+
{{content}}
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<script>
|
|
17
|
+
(() => {
|
|
18
|
+
const root = document.currentScript.previousElementSibling;
|
|
19
|
+
const d = root.dataset, plot = root.querySelector('.gmb-line__plot');
|
|
20
|
+
if (d.lcEndlabel === 'false') plot.querySelector('.gmb-line__end').remove();
|
|
21
|
+
const yes = (v) => /^(true|1|yes)$/i.test(v || '');
|
|
22
|
+
gm.chart.line(plot, {
|
|
23
|
+
values: d.values, labels: d.labels, prefix: d.lcPrefix, suffix: d.lcSuffix,
|
|
24
|
+
delay: d.lcDelay, duration: d.lcDuration, holds: d.lcHolds, colors: d.lcColors,
|
|
25
|
+
rescale: yes(d.lcRescale), axis: d.lcAxis, dots: d.lcDots !== 'false', overview: yes(d.lcOverview),
|
|
26
|
+
ticks: d.lcTicks || undefined, grid: d.lcGrid ? d.lcGrid !== 'false' : undefined, axisIntro: +d.lcAxisintro || undefined,
|
|
27
|
+
});
|
|
28
|
+
})();
|
|
29
|
+
</script>
|
|
30
|
+
<style>
|
|
31
|
+
.gmb-line{position:relative;display:flex;flex-direction:column;gap:calc(var(--px)*28);width:calc(var(--px)*var(--w))}
|
|
32
|
+
.gmb-line__title{font-size:calc(var(--px)*48);font-weight:700}
|
|
33
|
+
.gmb-line__title:empty{display:none}
|
|
34
|
+
.gmb-line__plot{position:relative;height:calc(var(--px)*var(--h));--chart-line:var(--c)}
|
|
35
|
+
.gmb-line__end{font-size:calc(var(--px)*44);font-weight:800;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
36
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Logo reveal: the logo scales in from a soft glow, a light sweep passes over it, and an optional tagline appears.
|
|
3
|
+
params: logo (image path; or put HTML/SVG between the tags), tagline, size (logo height px at 1080p, default 220), glow (color)
|
|
4
|
+
usage: <gm-include src="blocks/logo-reveal.html" logo="assets/logo.svg" tagline="Automate anything" data-duration="3.5"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-logo" style="--size:{{size|220}};--glow:{{glow|#7c9cff}}">
|
|
7
|
+
<div class="gmb-logo__halo"></div>
|
|
8
|
+
<div class="gmb-logo__mark"><img src="{{logo}}" alt="">{{content}}<div class="gmb-logo__shinebox"><div class="gmb-logo__shine"></div></div></div>
|
|
9
|
+
<div class="gmb-logo__tag" data-enter="fade-up 0.7 1.0">{{tagline}}</div>
|
|
10
|
+
</div>
|
|
11
|
+
<script>
|
|
12
|
+
(() => {
|
|
13
|
+
const r = document.currentScript.previousElementSibling, i = r.querySelector('img'), box = r.querySelector('.gmb-logo__shinebox');
|
|
14
|
+
if (!i.getAttribute('src')) { i.remove(); box.remove(); return; }
|
|
15
|
+
// The light sweep only shows on the logo's own pixels.
|
|
16
|
+
box.style.maskImage = box.style.webkitMaskImage = `url("${i.src}")`;
|
|
17
|
+
})();
|
|
18
|
+
</script>
|
|
19
|
+
<style>
|
|
20
|
+
.gmb-logo{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:calc(var(--px)*44)}
|
|
21
|
+
.gmb-logo__halo{position:absolute;left:50%;top:50%;width:calc(var(--px)*900);aspect-ratio:1;translate:-50% -50%;border-radius:50%;
|
|
22
|
+
background:radial-gradient(closest-side,color-mix(in oklab,var(--glow),transparent 55%),transparent);animation:gmb-logo-halo 1.6s var(--ease-out-expo) both}
|
|
23
|
+
.gmb-logo__mark{position:relative;height:calc(var(--px)*var(--size));display:flex;align-items:center;justify-content:center;animation:gmb-logo-in 1.1s var(--ease-out-expo) .1s both;
|
|
24
|
+
}
|
|
25
|
+
.gmb-logo__mark img,.gmb-logo__mark svg{height:100%;width:auto;display:block}
|
|
26
|
+
.gmb-logo__shinebox{position:absolute;inset:0;overflow:hidden;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}
|
|
27
|
+
.gmb-logo__shine{position:absolute;top:0;bottom:0;left:0;width:60%;background:linear-gradient(105deg,transparent 0%,rgba(255,255,255,.55) 50%,transparent 100%);translate:-120% 0;animation:gmb-logo-shine 1.1s var(--ease-in-out-cubic) .9s both}
|
|
28
|
+
.gmb-logo__tag{font-size:calc(var(--px)*44);font-weight:500;opacity:.8;letter-spacing:.01em}
|
|
29
|
+
.gmb-logo__tag:empty{display:none}
|
|
30
|
+
@keyframes gmb-logo-in{from{opacity:0;scale:.7;filter:blur(calc(var(--px)*24))}}
|
|
31
|
+
@keyframes gmb-logo-halo{from{opacity:0;scale:.4}}
|
|
32
|
+
@keyframes gmb-logo-shine{to{translate:200% 0}}
|
|
33
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Plays a Lottie animation (After Effects / LottieFiles JSON), frame-exact on the video clock. Uses lottie-web (MIT), loaded from a CDN by default; pass lib="path/to/lottie.min.js" for offline renders.
|
|
3
|
+
params: file (path to the .json), width, height (px at 1080p, default 600), loop (true|false, default true), speed (default 1), start (seconds into the animation, default 0), renderer (svg|canvas, default svg), lib (lottie-web script URL)
|
|
4
|
+
usage: <gm-include src="blocks/lottie.html" file="assets/rocket.json" width="500" height="500"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-lottie" style="--w:{{width|600}};--h:{{height|600}}" data-src="{{file}}" data-loop="{{loop|true}}" data-speed="{{speed|1}}" data-start="0" data-offset="{{start|0}}" data-renderer="{{renderer|svg}}" data-lib="{{lib|https://cdn.jsdelivr.net/npm/lottie-web@5.12.2/build/player/lottie.min.js}}"></div>
|
|
7
|
+
<script>
|
|
8
|
+
(() => {
|
|
9
|
+
const root = document.currentScript.previousElementSibling;
|
|
10
|
+
root.removeAttribute('data-start'); // not a clip: plays with its parent clip
|
|
11
|
+
const d = root.dataset;
|
|
12
|
+
const loadLib = () => window.lottie ? Promise.resolve(window.lottie) : (window.__gmLottieLib ||= new Promise((res, rej) => {
|
|
13
|
+
const s = document.createElement('script'); s.src = d.lib; s.onload = () => res(window.lottie); s.onerror = () => rej(new Error('Could not load lottie-web from ' + d.lib)); document.head.appendChild(s);
|
|
14
|
+
}));
|
|
15
|
+
let anim = null, fr = 30, total = 0;
|
|
16
|
+
if (!d.src) { gm.__report('error', 'lottie', 'The lottie block needs file="path/to/animation.json".', root); return; }
|
|
17
|
+
gm.wait(loadLib().then((lottie) => new Promise((res, rej) => {
|
|
18
|
+
anim = lottie.loadAnimation({ container: root, renderer: d.renderer, loop: false, autoplay: false, path: d.src, rendererSettings: { preserveAspectRatio: 'xMidYMid meet', progressiveLoad: false } });
|
|
19
|
+
anim.addEventListener('DOMLoaded', () => { fr = anim.frameRate || 30; total = anim.totalFrames; res(); });
|
|
20
|
+
anim.addEventListener('data_failed', () => rej(new Error('Could not load Lottie file ' + d.src)));
|
|
21
|
+
})).catch((e) => gm.__report('error', 'lottie', String(e.message || e), root, 'Check the src path, and pass lib="…/lottie.min.js" if there is no internet.')));
|
|
22
|
+
const loop = d.loop !== 'false', speed = parseFloat(d.speed) || 1, offset = parseFloat(d.offset) || 0;
|
|
23
|
+
let last = -1;
|
|
24
|
+
gm.onFrame(root, (t) => {
|
|
25
|
+
if (!anim || !total) return;
|
|
26
|
+
let f = (t * speed + offset) * fr;
|
|
27
|
+
f = loop ? ((f % total) + total) % total : Math.min(Math.max(0, f), total - 1);
|
|
28
|
+
if (Math.abs(f - last) < 1e-6) return;
|
|
29
|
+
last = f;
|
|
30
|
+
anim.goToAndStop(f, true);
|
|
31
|
+
});
|
|
32
|
+
})();
|
|
33
|
+
</script>
|
|
34
|
+
<style>.gmb-lottie{width:calc(var(--px)*var(--w));height:calc(var(--px)*var(--h))}.gmb-lottie svg{display:block}</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Name and role plate that wipes in at the bottom left and wipes out at the end of its clip.
|
|
3
|
+
params: name, role, accent (color), position (left|right)
|
|
4
|
+
usage: <gm-include src="blocks/lower-third.html" name="Ada Lovelace" role="Mathematician" data-start="1" data-duration="4"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-lt gmb-lt--{{position|left}}" style="--accent:{{accent|#7c9cff}}">
|
|
7
|
+
<div class="gmb-lt__plate" data-enter="wipe-right 0.7 0 out-expo" data-exit="wipe-left 0.45 0 in-cubic">
|
|
8
|
+
<div class="gmb-lt__bar"></div>
|
|
9
|
+
<div class="gmb-lt__text">
|
|
10
|
+
<div class="gmb-lt__name" data-enter="fade-right 0.6 0.25">{{name|Jane Doe}}</div>
|
|
11
|
+
<div class="gmb-lt__role" data-enter="fade-right 0.6 0.38">{{role}}</div>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<style>
|
|
16
|
+
.gmb-lt{position:absolute;left:calc(var(--px)*96);bottom:calc(var(--px)*104);z-index:10}
|
|
17
|
+
.gmb-lt--right{left:auto;right:calc(var(--px)*96)}
|
|
18
|
+
.gmb-lt__plate{display:flex;align-items:stretch;gap:calc(var(--px)*22);padding:calc(var(--px)*22) calc(var(--px)*40) calc(var(--px)*22) calc(var(--px)*22);
|
|
19
|
+
background:rgba(12,14,20,.82);backdrop-filter:blur(calc(var(--px)*18));border:1px solid rgba(255,255,255,.08);border-radius:calc(var(--px)*18);box-shadow:0 calc(var(--px)*20) calc(var(--px)*60) rgba(0,0,0,.35)}
|
|
20
|
+
.gmb-lt__bar{width:calc(var(--px)*8);border-radius:99px;background:var(--accent)}
|
|
21
|
+
.gmb-lt__name{font-size:calc(var(--px)*52);font-weight:700;letter-spacing:-.01em;color:#fff;line-height:1.1}
|
|
22
|
+
.gmb-lt__role{font-size:calc(var(--px)*32);color:rgba(255,255,255,.7);margin-top:calc(var(--px)*6)}
|
|
23
|
+
.gmb-lt__role:empty{display:none}
|
|
24
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: A modern phone frame. Shows an image (image=…) or any HTML between the tags as the screen.
|
|
3
|
+
params: image (path), height (px at 1080p; scales with the short side, default 860), color (frame color, default #111), enter (its entrance, default "rise 0.9"; "none" when you bring it in yourself)
|
|
4
|
+
usage: <gm-include src="blocks/phone.html" image="assets/app.png" data-duration="4"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-phone" style="--ph:{{height|860}};--frame:{{color|#111}}" data-enter="{{enter|rise 0.9}}">
|
|
7
|
+
<div class="gmb-phone__screen"><img class="gmb-phone__img" src="{{image}}" alt="">{{content}}<div class="gmb-phone__island"></div></div>
|
|
8
|
+
</div>
|
|
9
|
+
<script>
|
|
10
|
+
(() => { const img = document.currentScript.previousElementSibling.querySelector('.gmb-phone__img'); if (!img.getAttribute('src')) img.remove(); })();
|
|
11
|
+
</script>
|
|
12
|
+
<style>
|
|
13
|
+
.gmb-phone{height:calc(var(--px)*var(--ph));aspect-ratio:9/19.5;padding:calc(var(--px)*var(--ph)*.018);border-radius:calc(var(--px)*var(--ph)*.085);background:var(--frame);
|
|
14
|
+
box-shadow:0 0 0 calc(var(--px)*2) rgba(255,255,255,.14) inset,0 calc(var(--px)*50) calc(var(--px)*120) rgba(0,0,0,.5)}
|
|
15
|
+
.gmb-phone__screen{position:relative;width:100%;height:100%;border-radius:calc(var(--px)*var(--ph)*.068);overflow:hidden;background:#000}
|
|
16
|
+
.gmb-phone__img{display:block;width:100%;height:100%;object-fit:cover}
|
|
17
|
+
.gmb-phone__island{position:absolute;top:calc(var(--px)*var(--ph)*.014);left:50%;translate:-50% 0;width:31%;height:calc(var(--px)*var(--ph)*.036);border-radius:99px;background:#000}
|
|
18
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Big pull quote. Words fade in one by one, then the author slides in.
|
|
3
|
+
params: text, author, role, accent (color)
|
|
4
|
+
usage: <gm-include src="blocks/quote.html" text="Simplicity is prerequisite for reliability." author="Edsger W. Dijkstra" data-duration="5"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<figure class="gmb-quote" style="--accent:{{accent|#7c9cff}}">
|
|
7
|
+
<div class="gmb-quote__mark" data-enter="pop 0.6">“</div>
|
|
8
|
+
<blockquote class="gmb-quote__text" data-split="words" data-enter="fade-up 0.5 0.2" data-stagger="0.05">{{text|Make it simple, but significant.}}</blockquote>
|
|
9
|
+
<figcaption class="gmb-quote__by" data-enter="fade-right 0.6 1.2"><b>{{author}}</b><span>{{role}}</span></figcaption>
|
|
10
|
+
</figure>
|
|
11
|
+
<style>
|
|
12
|
+
.gmb-quote{position:absolute;inset:0;margin:0;display:flex;flex-direction:column;justify-content:center;padding:0 calc(var(--px)*220);gap:calc(var(--px)*30)}
|
|
13
|
+
.gmb-quote__mark{font-size:calc(var(--px)*260);line-height:.6;height:calc(var(--px)*110);color:var(--accent);font-family:Georgia,"Times New Roman",serif}
|
|
14
|
+
.gmb-quote__text{margin:0;font-size:calc(var(--px)*76);line-height:1.18;font-weight:600;letter-spacing:-.02em;max-width:26ch;text-wrap:pretty}
|
|
15
|
+
.gmb-quote__by{display:flex;align-items:baseline;gap:calc(var(--px)*16);font-size:calc(var(--px)*38)}
|
|
16
|
+
.gmb-quote__by b{font-weight:700} .gmb-quote__by span{opacity:.6}
|
|
17
|
+
.gmb-quote__by b:empty,.gmb-quote__by span:empty{display:none}
|
|
18
|
+
.gmb-quote__by b::before{content:"— ";color:var(--accent)}
|
|
19
|
+
</style>
|
package/blocks/stat.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: One big number that counts up, with a label and an optional change badge.
|
|
3
|
+
params: value, from (default 0), label, prefix, suffix, decimals, delta (e.g. "+38%"), accent (color), size (px at 1080p; scales with the short side, default 220)
|
|
4
|
+
usage: <gm-include src="blocks/stat.html" value="12500" prefix="$" label="Saved per month" delta="+38%" data-duration="3"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-stat" style="--accent:{{accent|#7c9cff}};--size:{{size|220}}">
|
|
7
|
+
<div class="gmb-stat__num" data-enter="scale 0.8 0 out-expo"><span data-count="{{from|0}} {{value|100}}" data-count-duration="1.6" data-count-prefix="{{prefix}}" data-count-suffix="{{suffix}}" data-count-decimals="{{decimals}}"></span></div>
|
|
8
|
+
<div class="gmb-stat__row">
|
|
9
|
+
<div class="gmb-stat__label" data-enter="fade-up 0.6 0.3">{{label}}</div>
|
|
10
|
+
<div class="gmb-stat__delta" data-enter="pop 0.5 1.2">{{delta}}</div>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
<script>
|
|
14
|
+
(() => { const s = document.currentScript.previousElementSibling.querySelector('[data-count]'); if (!s.getAttribute('data-count-decimals')) s.removeAttribute('data-count-decimals'); })();
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
.gmb-stat{display:flex;flex-direction:column;align-items:center;text-align:center;gap:calc(var(--px)*18)}
|
|
18
|
+
.gmb-stat__num{font-size:calc(var(--px)*var(--size));font-weight:800;letter-spacing:-.045em;line-height:.95;font-variant-numeric:tabular-nums;
|
|
19
|
+
background:linear-gradient(180deg,#fff 30%,color-mix(in oklab,var(--accent),white 35%));-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
20
|
+
.gmb-stat__row{display:flex;align-items:center;gap:calc(var(--px)*20)}
|
|
21
|
+
.gmb-stat__label{font-size:calc(var(--px)*44);opacity:.75}
|
|
22
|
+
.gmb-stat__delta{font-size:calc(var(--px)*34);font-weight:700;padding:calc(var(--px)*8) calc(var(--px)*18);border-radius:99px;background:color-mix(in oklab,var(--accent),transparent 78%);color:color-mix(in oklab,var(--accent),white 40%)}
|
|
23
|
+
.gmb-stat__label:empty,.gmb-stat__delta:empty{display:none}
|
|
24
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Horizontal process steps. A progress line fills and each step lights up in turn.
|
|
3
|
+
params: steps (labels separated by |), every (seconds per step, default 0.9), first (delay, default 0.4), accent (color)
|
|
4
|
+
usage: <gm-include src="blocks/steps.html" steps="Trigger|Fetch data|Ask AI|Send email" data-duration="5"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-steps" style="--accent:{{accent|#7c9cff}}" data-steps="{{steps|Plan|Build|Ship}}" data-every="{{every|0.9}}" data-first="{{first|0.4}}">
|
|
7
|
+
<div class="gmb-steps__track"><div class="gmb-steps__fill"></div></div>
|
|
8
|
+
<div class="gmb-steps__row"></div>
|
|
9
|
+
</div>
|
|
10
|
+
<script>
|
|
11
|
+
(() => {
|
|
12
|
+
const root = document.currentScript.previousElementSibling;
|
|
13
|
+
const names = root.dataset.steps.split('|').map((s) => s.trim()).filter(Boolean);
|
|
14
|
+
const every = parseFloat(root.dataset.every) || 0.9, first = parseFloat(root.dataset.first) || 0;
|
|
15
|
+
const row = root.querySelector('.gmb-steps__row');
|
|
16
|
+
names.forEach((n, i) => {
|
|
17
|
+
const el = document.createElement('div');
|
|
18
|
+
el.className = 'gmb-steps__step';
|
|
19
|
+
el.style.setProperty('--d', (first + i * every) + 's');
|
|
20
|
+
el.innerHTML = `<div class="gmb-steps__dot">${i + 1}</div><div class="gmb-steps__name">${n.replace(/</g, '<')}</div>`;
|
|
21
|
+
row.appendChild(el);
|
|
22
|
+
});
|
|
23
|
+
root.style.setProperty('--n', names.length);
|
|
24
|
+
const fill = root.querySelector('.gmb-steps__fill');
|
|
25
|
+
fill.style.animationDuration = (every * (names.length - 1)) + 's';
|
|
26
|
+
fill.style.animationDelay = first + 's';
|
|
27
|
+
})();
|
|
28
|
+
</script>
|
|
29
|
+
<style>
|
|
30
|
+
.gmb-steps{position:relative;width:calc(var(--px)*1500);max-width:calc(var(--stage-w,1920px) - var(--px)*192)}
|
|
31
|
+
.gmb-steps__track{position:absolute;left:calc(100% / var(--n) / 2);right:calc(100% / var(--n) / 2);top:calc(var(--px)*44);height:calc(var(--px)*6);border-radius:99px;background:rgba(255,255,255,.14);overflow:hidden}
|
|
32
|
+
.gmb-steps__fill{height:100%;background:var(--accent);transform-origin:left;animation:gmb-steps-fill 2s linear both}
|
|
33
|
+
.gmb-steps__row{position:relative;display:grid;grid-template-columns:repeat(var(--n),1fr)}
|
|
34
|
+
.gmb-steps__step{display:flex;flex-direction:column;align-items:center;gap:calc(var(--px)*22);text-align:center}
|
|
35
|
+
.gmb-steps__dot{width:calc(var(--px)*94);height:calc(var(--px)*94);border-radius:50%;display:grid;place-items:center;font-size:calc(var(--px)*38);font-weight:800;
|
|
36
|
+
background:#151923;border:calc(var(--px)*4) solid rgba(255,255,255,.18);color:rgba(255,255,255,.6);animation:gmb-steps-on .45s var(--ease-out-back) var(--d) both}
|
|
37
|
+
.gmb-steps__name{font-size:calc(var(--px)*36);font-weight:600;max-width:12ch;animation:fade-up .5s var(--ease-out-expo) var(--d) both}
|
|
38
|
+
@keyframes gmb-steps-fill{from{scale:0 1}}
|
|
39
|
+
@keyframes gmb-steps-on{from{scale:.8}to{background:var(--accent);border-color:var(--accent);color:#0b0d12;scale:1;box-shadow:0 0 0 calc(var(--px)*10) color-mix(in oklab,var(--accent),transparent 80%)}}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Big headline with optional kicker and subtitle. Words rise in one by one.
|
|
3
|
+
params: title, subtitle, kicker, accent (color), align (center|left)
|
|
4
|
+
usage: <gm-include src="blocks/title-card.html" kicker="Chapter 1" title="Ship faster" subtitle="Automate the boring parts" data-duration="4"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-title gmb-title--{{align|center}}" style="--accent:{{accent|#7c9cff}}">
|
|
7
|
+
<div class="gmb-title__kicker" data-enter="fade-up 0.6 0 out-expo">{{kicker}}</div>
|
|
8
|
+
<h1 class="gmb-title__h" data-split="words" data-enter="rise 0.9 0.1" data-stagger="0.07">{{title|Your title here}}</h1>
|
|
9
|
+
<div class="gmb-title__line"></div>
|
|
10
|
+
<p class="gmb-title__sub" data-enter="fade-up 0.7 0.55">{{subtitle}}</p>
|
|
11
|
+
</div>
|
|
12
|
+
<style>
|
|
13
|
+
.gmb-title{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:0 calc(var(--px)*160);gap:calc(var(--px)*26)}
|
|
14
|
+
.gmb-title--left{align-items:flex-start;text-align:left}
|
|
15
|
+
.gmb-title__kicker{font-size:calc(var(--px)*30);font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:color-mix(in oklab,var(--accent),white 30%);text-shadow:0 1px calc(var(--px)*14) rgba(0,0,0,.45)}
|
|
16
|
+
.gmb-title__h{margin:0;font-size:calc(var(--px)*132);line-height:1.02;font-weight:800;letter-spacing:-.035em;max-width:16ch;text-wrap:balance}
|
|
17
|
+
.gmb-title__line{margin:calc(var(--px)*10) 0;width:calc(var(--px)*120);height:calc(var(--px)*8);border-radius:99px;background:var(--accent);transform-origin:left;animation:gmb-title-line .9s var(--ease-out-expo) .45s both}
|
|
18
|
+
.gmb-title--center .gmb-title__line{transform-origin:center}
|
|
19
|
+
.gmb-title__sub{margin:0;font-size:calc(var(--px)*44);line-height:1.3;opacity:.8;text-shadow:0 1px calc(var(--px)*14) rgba(0,0,0,.4);max-width:30ch;text-wrap:balance}
|
|
20
|
+
.gmb-title__kicker:empty,.gmb-title__sub:empty{display:none}
|
|
21
|
+
@keyframes gmb-title-line{from{scale:0 1}}
|
|
22
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!-- block:
|
|
2
|
+
description: Kinetic typography for shorts: shows one word (or short phrase) at a time, big and centered, with a punchy pop. Separate phrases with |.
|
|
3
|
+
params: text (phrases separated by |), every (seconds per phrase, default 0.45), accent (color for words wrapped in *stars*), size (px at 1080p; scales with the short side, default 170)
|
|
4
|
+
usage: <gm-include src="blocks/word-pop.html" text="Stop|editing|videos|*by hand*" every="0.5" data-duration="2.5"></gm-include>
|
|
5
|
+
-->
|
|
6
|
+
<div class="gmb-pop" style="--accent:{{accent|#ffd23f}};--size:{{size|170}}" data-text="{{text|Make|it|*move*}}" data-every="{{every|0.45}}"></div>
|
|
7
|
+
<script>
|
|
8
|
+
(() => {
|
|
9
|
+
const root = document.currentScript.previousElementSibling;
|
|
10
|
+
const parts = root.dataset.text.split('|').map((s) => s.trim()).filter(Boolean);
|
|
11
|
+
const every = parseFloat(root.dataset.every) || 0.45;
|
|
12
|
+
parts.forEach((p, i) => {
|
|
13
|
+
const el = document.createElement('div');
|
|
14
|
+
el.className = 'gmb-pop__w';
|
|
15
|
+
const hl = /^\*(.*)\*$/.exec(p);
|
|
16
|
+
el.textContent = hl ? hl[1] : p;
|
|
17
|
+
if (hl) el.classList.add('is-hl');
|
|
18
|
+
el.setAttribute('data-start', String(i * every));
|
|
19
|
+
if (i < parts.length - 1) el.setAttribute('data-duration', String(every));
|
|
20
|
+
root.appendChild(el);
|
|
21
|
+
});
|
|
22
|
+
// Shrink any phrase that would not fit the frame width.
|
|
23
|
+
gm.wait(document.fonts.ready.then(() => {
|
|
24
|
+
const avail = gm.width - 2 * 80 * gm.px * Math.min(1, gm.width / gm.height * 1.2);
|
|
25
|
+
for (const el of root.children) {
|
|
26
|
+
const was = el.style.display; el.style.display = 'block';
|
|
27
|
+
const w = el.scrollWidth, fs = parseFloat(getComputedStyle(el).fontSize);
|
|
28
|
+
if (w > avail) el.style.fontSize = Math.floor(fs * (avail / w) * 0.98) + 'px';
|
|
29
|
+
el.style.display = was;
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
})();
|
|
33
|
+
</script>
|
|
34
|
+
<style>
|
|
35
|
+
.gmb-pop{position:absolute;inset:0;display:grid;place-items:center}
|
|
36
|
+
.gmb-pop__w{grid-area:1/1;font-size:calc(var(--px)*var(--size));font-weight:900;letter-spacing:-.04em;text-transform:uppercase;line-height:1;text-align:center;
|
|
37
|
+
animation:gmb-pop-in .32s var(--ease-out-back) both}
|
|
38
|
+
.gmb-pop__w.is-hl{color:var(--accent)}
|
|
39
|
+
@keyframes gmb-pop-in{from{scale:1.6;opacity:0;filter:blur(calc(var(--px)*10))}}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- Benchmark composition used by `givemotion bench`: 10s of typical explainer content at 1080p30. -->
|
|
3
|
+
<html data-width="1920" data-height="1080" data-fps="30">
|
|
4
|
+
<head><meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { background: #0a0c12; color: #fff; font-family: Inter, system-ui, sans-serif; }
|
|
7
|
+
.scene { display: grid; place-items: center; }
|
|
8
|
+
.front { position: relative; z-index: 1; }
|
|
9
|
+
</style></head>
|
|
10
|
+
<body>
|
|
11
|
+
<main data-sequence data-transition="fade 0.5">
|
|
12
|
+
<section class="scene" data-duration="3.5">
|
|
13
|
+
<gm-include src="../../blocks/gradient-bg.html"></gm-include>
|
|
14
|
+
<gm-include src="../../blocks/title-card.html" kicker="Benchmark" title="Typical explainer content" subtitle="Text, motion, charts and holds"></gm-include>
|
|
15
|
+
</section>
|
|
16
|
+
<section class="scene" data-duration="3.5" style="background:#0a0c12">
|
|
17
|
+
<div class="front"><gm-include src="../../blocks/bar-chart.html" title="Revenue" values="12,19,31,48" labels="Q1,Q2,Q3,Q4" highlight="3"></gm-include></div>
|
|
18
|
+
</section>
|
|
19
|
+
<section class="scene" data-duration="4" style="background:#0a0c12">
|
|
20
|
+
<div class="front"><gm-include src="../../blocks/checklist.html" title="Done" every="0.4"><li>Write</li><li>Check</li><li>Render</li></gm-include></div>
|
|
21
|
+
</section>
|
|
22
|
+
</main>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|