Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions blog/MG_KIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Blog scene kit

Coded motion graphics for blog posts. One script and one stylesheet for every post, no library.

| File | What it is |
| --- | --- |
| `src/assets/mg/kit.js` | The scenes, the cast and the player |
| `src/assets/mg/kit.css` | The frame, the focus ring |
| `src/assets/mg/cast.png` | The landing page cast, 15 characters, built by `scripts/build-mg-cast.mjs` |
| `src/assets/mg/demo.html` | Every scene with sample data: `/blog/assets/mg/demo.html` |

## Put a scene in a post

Add the two files once, at the end of the post:

```html
<link rel="stylesheet" href="/blog/assets/mg/kit.css"><script defer src="/blog/assets/mg/kit.js"></script>
```

Then one figure per scene. Keep it on one line so Markdown leaves it alone. The image is the still; the JSON is the page's own data.

```html
<figure class="mg" data-scene="price-ladder"><img src="/blog/assets/media/SLUG/prices.png" width="1600" height="900" loading="lazy" decoding="async" alt="Animation. Four price steps rise from Free to Team while Kevin points at them."><script type="application/json">{"title":"What each plan costs a month","prefix":"$","items":[{"label":"Free","value":0,"display":"Free"},{"label":"Pro","value":20,"highlight":true,"note":"Most people pick Pro."}],"host":"kevin","say":"Pro is the sweet spot."}</script><figcaption>Prices checked on 5 Oct 2026.</figcaption></figure>
```

What the kit does for every scene:

* Swaps the still for a live SVG, 960 by 540, and plays it only while it is on screen.
* Reduced motion: draws the still frame and does not loop. Taps and keys still work, without the move.
* Script blocked or a broken scene: the still image stays.
* Every tap target is a button: Tab reaches it, Enter or Space presses it, arrow keys move along a row.
* After a reader taps, the story stops on its still frame and a "Play again" button appears.
* Sends `blog_scene_view` (first time on screen) and `blog_scene_interact` (first tap) to PostHog with `slug` and `scene`, only when `window.posthog` exists.

## Phones

Under 520 px wide a kit scene draws on a tall stage (540 by 720) instead of the wide one (960 by 540): larger type, the content on top, the host and the bubble below. The timeline runs down the page and a flow uses two columns. The kit picks the stage from the width of the figure and swaps when the window changes. `?mgtall=1` forces the tall stage and `?mgtall=0` the wide one. The still image stays 16 by 9; only the live scene is tall.

## Stills

`?mgstill` freezes every scene on its own still frame. `?mgstill=3.5` freezes on second 3.5. Screenshot the figure at 1600 by 900 for the post still.

For the title image use the `title` scene, not the first scene of the post, so the top of the post does not show the same picture twice. It is composed for 1600 by 900 and stays readable as a small card on the blog index: keep the title under about 60 characters.

## Scenes and their data

Every scene takes `kicker` (the yellow chip) and `title`. `host` is a cast name; `say` is the host's closing line (keep it under 40 characters).

| Scene | Data | The reader can |
| --- | --- | --- |
| `title` | `title` (up to 3 lines of 27 characters), `sub`, `cast: [names]` (2 or 3, default Michael and Jim), `say` (first cast member, under 40 characters) | Nothing to tap, no hint |
| `stage` | `script: [{ who, say, point, carry }]`, optional `board: { label, lines }`. Up to 5 script lines. A board holds 4 lines of 43 characters; with a board, keep each `say` under 68 characters (two bubble lines) so it stays below the board. `point`: left, right, up-left, up-right, raise. `carry`: a short sign held at the chest | Tap a character to hear the line again |
| `price-ladder` | `prefix`, `suffix`, `items: [{ label, value, display, note, highlight }]`, up to 6 | Tap or hover a step for its note |
| `compare` | `cols: [a, b]`, `pick` (0 or 1), `rows: [{ label, a, b, win, note }]`, up to 5. `win` is "a", "b" or "". A cell fits about 17 characters at full size (15 with a tick); longer cells shrink to fit and are cut at 30 | Tap a row for its note |
| `timeline` | `events: [{ when, label, note }]`, up to 6 | Tap a date; the host walks to it |
| `flow` | `nodes: [{ label, note }]`, up to 8 (5 or more wrap to two rows), `labels: [text on arrow i]`, each up to 16 characters, drawn as a tag above the arrow | Tap a step or use arrow keys |
| `before-after` | `before: { label, lines }`, `after: { label, lines }` | Tap the card to flip it |
| `counter` | `from`, `to`, `prefix`, `suffix`, `label`, `note` | Tap the number to count again |

Fixed roles, so readers learn the cast: Jim explains, Dwight gives rules and warnings, Kevin does prices and numbers, Oscar checks facts, Pam draws diagrams, Michael opens and closes. The defaults follow this.

## The cast

`cast.png` is packed from the landing page (`docs/index.html`, classes `.sp-<name>` and `.pt-<name>`), pixel for pixel. Nothing is redrawn. Run `node scripts/build-mg-cast.mjs` when the landing page cast changes; it also rewrites the cast table in `kit.js`.

The landing strips only walk. The kit adds, on the same pixel grid and in each character's own colours: an arm that points (side or up), two raised arms, a hop, a talking bob, a speech bubble and a carried sign.

## A custom scene for one page

A page script registers its own scene with the same tools the kit uses:

```html
<script>
(window.MGQ = window.MGQ || []).push((MG) => MG.scene("my-scene", {
build(svg, data, api) {
MG.stage(svg, { kicker: "Launch", title: "My scene" });
const jim = MG.actor(svg, "jim"), bub = MG.bubble(svg, 20);
bub.set("Hello.");
const draw = (t) => {
const w = MG.walk(t, 0.2, 1.4, -60, 300);
jim.draw({ x: w.x, step: w.step, arm: t > 1.5 ? "up-right" : "" });
bub.draw(300, MG.GROUND - jim.top - 4, MG.p(t, 1.6, 2));
};
return { draw, dur: 6, still: 3 };
},
}));
</script>
```

A custom scene always gets the wide stage. To support phones, set `tall: true` on the scene, pass `api.L` to `MG.stage(svg, data, api.L)` and lay out from the layout it returns (`W`, `H`, `x0`, `x1`, `top`, `bot`, `gy`, `hx`, `tall`); `MG.host(svg, L, name)` places the host and the bubble for both.

`draw(t)` must draw the whole frame from the time alone, so stills and reduced motion work. For a tap target use `MG.hit(parent, label, fn)` then `MG.ring(...)`, and call `api.poke(change, ms)` inside `fn`; read `api.ui.k` (0 to 1) in `draw` for the move.

## Rules

No red in anything new. Stage `#1A1320`, accent `#FFCA54`. The cast keeps its landing page colours.
83 changes: 83 additions & 0 deletions blog/scripts/build-mg-cast.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// node scripts/build-mg-cast.mjs
// Builds the blog scene kit's cast from the landing page, so the blog never redraws a character.
// Reads the .sp-<name> walk strips (72 by 32, 4 frames) and .pt-<name> portraits (18 by 28) out of docs/index.html,
// packs them into src/assets/mg/cast.png (one row per character: 4 walk frames, then the portrait),
// and rewrites the CAST table in src/assets/mg/kit.js (row, shoulder edges, sleeve, skin and outline colours for the arms).
// Run it again whenever the landing page cast changes.
import fs from 'node:fs';
import zlib from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = fs.readFileSync(path.join(root, '../docs/index.html'), 'utf8');
const ORDER = ['michael', 'jim', 'pam', 'dwight', 'kevin', 'angela', 'oscar', 'stanley', 'phyllis', 'andy', 'kelly', 'ryan', 'toby', 'creed', 'meredith'];
const FW = 18, FH = 32, COLS = 5;

function decode(buf) {
let o = 8, w = 0, h = 0; const idat = [];
while (o < buf.length) {
const len = buf.readUInt32BE(o), type = buf.toString('latin1', o + 4, o + 8), data = buf.subarray(o + 8, o + 8 + len);
if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); if (data[8] !== 8 || data[9] !== 6 || data[12]) throw new Error('expected 8 bit RGBA, not interlaced'); }
if (type === 'IDAT') idat.push(data);
o += 12 + len;
}
const raw = zlib.inflateSync(Buffer.concat(idat)), px = Buffer.alloc(w * h * 4), bpl = w * 4;
for (let y = 0; y < h; y++) {
const f = raw[y * (bpl + 1)], row = y * bpl;
for (let x = 0; x < bpl; x++) {
const v = raw[y * (bpl + 1) + 1 + x], a = x >= 4 ? px[row + x - 4] : 0, b = y ? px[row - bpl + x] : 0, c = x >= 4 && y ? px[row - bpl + x - 4] : 0;
let pr = 0;
if (f === 1) pr = a; else if (f === 2) pr = b; else if (f === 3) pr = (a + b) >> 1;
else if (f === 4) { const q = a + b - c, pa = Math.abs(q - a), pb = Math.abs(q - b), pc = Math.abs(q - c); pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
px[row + x] = (v + pr) & 255;
}
}
return { w, h, px };
}
const crcT = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c; });
const crc = (b) => { let c = -1; for (const x of b) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; };
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]); };
function encode(px, w, h) {
const raw = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) px.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6;
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
}

// The last rule for a class wins in the page, so the last match wins here.
const art = {};
for (const m of html.matchAll(/\.(sp|pt)-([a-z]+)\{background-image:url\(data:image\/png;base64,([A-Za-z0-9+/=]+)\)/g)) art[m[1] + '-' + m[2]] = decode(Buffer.from(m[3], 'base64'));

const W = FW * COLS, H = FH * ORDER.length, out = Buffer.alloc(W * H * 4);
const blit = (src, sx, sw, dx, dy) => { for (let y = 0; y < src.h; y++) for (let x = 0; x < sw; x++) src.px.copy(out, ((dy + y) * W + dx + x) * 4, (y * src.w + sx + x) * 4, (y * src.w + sx + x) * 4 + 4); };
const hex = (s, x, y) => { const i = (y * s.w + x) * 4; return '#' + [0, 1, 2].map((k) => s.px[i + k].toString(16).padStart(2, '0')).join('').toUpperCase(); };
const alpha = (s, x, y) => s.px[(y * s.w + x) * 4 + 3];
const table = {};
ORDER.forEach((name, row) => {
const sp = art['sp-' + name], pt = art['pt-' + name];
if (!sp || !pt) throw new Error('landing page has no sprite for ' + name);
if (sp.w !== 72 || sp.h !== 32 || pt.w !== 18 || pt.h !== 28) throw new Error('unexpected sprite size for ' + name);
for (let f = 0; f < 4; f++) blit(sp, f * FW, FW, f * FW, row * FH);
blit(pt, 0, FW, 4 * FW, row * FH);
// Shoulder row of frame 0: where an arm joins the body, and the colours it is drawn in.
const Y = 19; let l = 0, r = FW - 1;
while (l < FW && alpha(sp, l, Y) < 128) l++;
while (r > 0 && alpha(sp, r, Y) < 128) r--;
const tally = {};
for (let y = 8; y <= 14; y++) for (let x = 5; x <= 12; x++) { const c = hex(sp, x, y); tally[c] = (tally[c] || 0) + 1; }
const skin = Object.entries(tally).sort((a, b) => b[1] - a[1])[0][0];
table[name] = [row, l, r, hex(sp, l + 1, Y), skin, hex(sp, l, Y)];
});
fs.mkdirSync(path.join(root, 'src/assets/mg'), { recursive: true });
fs.writeFileSync(path.join(root, 'src/assets/mg/cast.png'), encode(out, W, H));

const kit = path.join(root, 'src/assets/mg/kit.js');
if (fs.existsSync(kit)) {
const body = '\n' + ORDER.map((n) => ` ${n}: ${JSON.stringify(table[n])},`).join('\n') + '\n ';
const src = fs.readFileSync(kit, 'utf8'), next = src.replace(/(\/\*CAST:START\*\/)[\s\S]*?(\/\*CAST:END\*\/)/, `$1${body}$2`);
if (next === src && !src.includes(body)) throw new Error('kit.js has no CAST markers');
fs.writeFileSync(kit, next);
}
console.log(`cast.png ${W} by ${H}, ${ORDER.length} characters`);
console.log(table);
33 changes: 33 additions & 0 deletions blog/src/_data/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -4418,5 +4418,38 @@
"status": "ready"
},
"inline": {}
},
"how-to-use-claude-code": {
"title": "How to Use Claude Code: Setup, First Task and the Commands That Matter",
"category": "guides",
"hero": {
"file": "assets/media/how-to-use-claude-code/hero.png",
"alt": "Title card. The words How to use Claude Code, with Michael and Pam from the office cast standing beside them",
"prompt": "Title card: the kit's title scene (assets/mg/kit.js).",
"status": "ready"
},
"inline": {}
},
"openclaw-alternatives": {
"title": "OpenClaw Alternatives: 7 Picks for Chat, Code and Cloud",
"category": "comparisons",
"hero": {
"file": "assets/media/openclaw-alternatives/hero.png",
"alt": "Title card. The words OpenClaw alternatives: 7 picks, with Michael and Jim from the office cast standing beside them",
"prompt": "Title card: the kit's title scene (assets/mg/kit.js).",
"status": "ready"
},
"inline": {}
},
"claude-code-alternatives": {
"title": "Claude Code Alternatives: 7 Coding Agents to Switch To",
"category": "comparisons",
"hero": {
"file": "assets/media/claude-code-alternatives/hero.png",
"alt": "Title card. The words Claude Code alternatives: 7 coding agents, with Michael, Oscar and Kevin from the office cast standing beside them",
"prompt": "Title card: the kit's title scene (assets/mg/kit.js).",
"status": "ready"
},
"inline": {}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added blog/src/assets/mg/cast.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading