Skip to content

Commit 30b0e4b

Browse files
seanhancaseanhancaclaude
authored
feat(core): RFC #5-#8 — scene composition, loop animations, schematic marks, pencil theme (#77)
## Summary Adds the foundation Glyph needs to author pages like the pendulum-clock hero **end-to-end from one JSON spec** — no hand-authored HTML scaffolding, no CSS @Keyframes, no manual SVG gear-tooth lines. This bundles four RFCs that compose cleanly: | RFC | What it adds | Status | |-----|--------------|--------| | **#5 compose** | Multi-subject canvas (`viewBox` + `children` with absolute coords) | ✅ Shipped + nested-chart support | | **#6 looping animations** | `swing`, `rotate-loop`, `pulse` SMIL emitters | ✅ Shipped (minimal set) | | **#7 schematic marks** | `frame`, `gear`, `pendulum`, `annotation-leader` | ✅ Shipped (minimal set) | | **#8 pencil-parchment theme** | Preset bundling parchment bg + graph-paper grid | ✅ Shipped | ## The proof: pendulum-clock-scene fixture A SINGLE compose JSON spec → ONE byte-locked SVG that contains: - Clock-case frame "Horologium · 1656" - Swinging pendulum (32°/6.28 s, SMIL `animateTransform type="rotate"` values "-32;32;-32") - Rotating 30-tooth escapement gear (60 s/rev CCW) - Three annotation-leaders (pivot, bob, escapement gear) - Embedded damped-pendulum trajectory chart (full ODE compiled through the existing `trajectory` shape, scaled to fit a 840×50 px slot) - Parchment background + faint blue graph-paper grid Locked at `packages/core/__fixtures__/compose/pendulum-clock-scene.svg`, exercised by `pendulum-clock-scene.test.ts`. The same SVG ships to `site/math/` and replaces the 121-line hand-authored hero in `draw-me-pendulum-clock.html` with a single `<img>` tag. ## Architecture notes - **ComposeSpec is orthogonal to GlyphSpec** — no churn on the existing 75 `spec.layers` call sites. New parser entry: `parseComposeSpec(raw)`. New compiler entry: `compileCompose(spec)`. - **SceneMark `group`** is the one new variant — wraps children + optional translate + optional uniform/per-axis scale + optional pre-rendered SMIL XML. Recursively rendered by the existing renderer's mark switch. - **`chart` mark inside compose** parses + compiles a nested chart spec through the existing `parseSpec` → `compileSpec` pipeline. The result is wrapped in an inner group with `scaleX`/`scaleY` so it fits the declared `size`. One-level deep (no compose-in-compose) by design. - **Loop animations use `additive="sum"`** so the SMIL rotation/scale layers on top of each group's translate — meaning swing/rotate/pulse always animate about the group's local origin, never the parent's. ## Test plan - [x] 668/668 tests pass (was 666, +2 new compose fixtures) - [x] Lint clean - [x] Playground bundle regenerated - [x] HTML tag balance verified on `draw-me-pendulum-clock.html` - [x] Byte-stability: two compileCompose+renderSvg calls produce identical strings ## Remaining (follow-up PRs) Tasks 16–22 in `docs/superpowers/plans/2026-05-23-glyph-scene-composition.md`: port the other 7 Life-in-Glyph hero animations (Orion, heartbeat, leopard, sunflower, steam-engine, Wankel, Antikythera) to compose specs. Each follows the same pattern as the pendulum-clock capstone — new schematic marks are added on demand (heart-icon, leopard-silhouette, slider-crank, etc.). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: seanhanca <infraservice@livepeer.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bb708a6 commit 30b0e4b

15 files changed

Lines changed: 1802 additions & 121 deletions

docs/superpowers/plans/2026-05-23-glyph-scene-composition.md

Lines changed: 817 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"compose": {
3+
"viewBox": { "width": 600, "height": 400 },
4+
"theme": { "preset": "pencil-parchment", "gridPattern": "graph-paper" },
5+
"children": [
6+
{
7+
"at": { "x": 100, "y": 50 },
8+
"mark": "frame",
9+
"frame": { "width": 400, "height": 300, "title": "RFC #5–#8 capstone" }
10+
},
11+
{
12+
"at": { "x": 300, "y": 200 },
13+
"mark": "gear",
14+
"gear": { "radius": 60, "teeth": 24, "toothLength": 8 },
15+
"animation": { "kind": "rotate-loop", "periodMs": 20000, "direction": "cw" }
16+
},
17+
{
18+
"at": { "x": 300, "y": 90 },
19+
"mark": "pendulum",
20+
"pendulum": { "length": 90, "bobRadius": 16, "markerKind": "cross" },
21+
"animation": { "kind": "swing", "amplitudeDeg": 28, "periodMs": 4000 }
22+
},
23+
{
24+
"at": { "x": 0, "y": 0 },
25+
"mark": "annotation-leader",
26+
"annotation": {
27+
"from": [360, 200],
28+
"to": [430, 180],
29+
"text": "gear · 24 teeth",
30+
"italic": true,
31+
"anchor": "start"
32+
}
33+
},
34+
{
35+
"at": { "x": 0, "y": 0 },
36+
"mark": "annotation-leader",
37+
"annotation": {
38+
"from": [300, 180],
39+
"to": [220, 160],
40+
"text": "swinging pendulum",
41+
"italic": true,
42+
"anchor": "end"
43+
}
44+
}
45+
]
46+
}
47+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* RFC #5–#8 capstone fixture: a compose scene containing a frame
3+
* + a swinging pendulum + a rotating gear + two annotation-leaders,
4+
* themed with pencil-parchment + graph-paper grid.
5+
*
6+
* This is the FIRST byte-locked Glyph fixture rendered ENTIRELY from
7+
* a compose spec — no chart-spec layers, no data, just schematic
8+
* marks composed on a parchment canvas. Proves the full pipeline:
9+
*
10+
* parseComposeSpec(raw)
11+
* → ComposeSpec (RFC #5 schema)
12+
* compileCompose(spec)
13+
* → Scene with `group` SceneMarks (RFC #5 compiler)
14+
* → each group contains primitive marks from compileFrame /
15+
* compileGear / compilePendulum / compileAnnotationLeader
16+
* (RFC #7 schematic marks)
17+
* → each group carries optional loopAnimationXml from
18+
* emitSwing / emitRotateLoop / emitPulse (RFC #6 animations)
19+
* → background + grid from pencil-parchment preset (RFC #8)
20+
* renderSvg(scene)
21+
* → byte-stable SVG with `<g transform>` wrappers and SMIL
22+
* `<animateTransform>` elements
23+
*/
24+
import { readFileSync } from "node:fs";
25+
import { fileURLToPath } from "node:url";
26+
import { describe, expect, it } from "vitest";
27+
import { compileCompose } from "../../src/compiler/compose.js";
28+
import { renderSvg } from "../../src/render/svg.js";
29+
import { parseComposeSpec } from "../../src/spec/compose-schema.js";
30+
31+
const fixtureUrl = new URL("./clock-with-rotating-gear.json", import.meta.url);
32+
const fixturePath = fileURLToPath(fixtureUrl);
33+
34+
describe("compose — RFC #5–#8 capstone (clock-with-rotating-gear)", () => {
35+
it("renders the full compose scene deterministically", async () => {
36+
const raw = JSON.parse(readFileSync(fixturePath, "utf8"));
37+
const spec = parseComposeSpec(raw);
38+
const scene = compileCompose(spec);
39+
const svg = renderSvg(scene);
40+
const svg2 = renderSvg(compileCompose(parseComposeSpec(raw)));
41+
expect(svg2).toBe(svg);
42+
// Sanity: at least one rect (frame), gear teeth (lines), a circle
43+
// (gear rim / pendulum bob), a group wrapper, and SMIL animations.
44+
expect(svg).toContain('<g transform="translate');
45+
expect(svg).toContain("<animateTransform");
46+
expect(svg).toContain("RFC #5"); // frame title
47+
expect(svg).toContain("gear · 24 teeth");
48+
expect(svg).toContain("swinging pendulum");
49+
// Background fill present (pencil-parchment)
50+
expect(svg).toContain("#f5edd9");
51+
// Graph paper grid pattern present (faint blue lines)
52+
expect(svg).toContain("rgba(80,110,160,.08)");
53+
await expect(svg).toMatchFileSnapshot("./clock-with-rotating-gear.svg");
54+
});
55+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"compose": {
3+
"viewBox": { "width": 1000, "height": 440 },
4+
"theme": { "preset": "pencil-parchment", "gridPattern": "graph-paper" },
5+
"children": [
6+
{
7+
"at": { "x": 380, "y": 60 },
8+
"mark": "frame",
9+
"frame": { "width": 240, "height": 320, "title": "Horologium · 1656" }
10+
},
11+
{
12+
"at": { "x": 500, "y": 80 },
13+
"mark": "pendulum",
14+
"pendulum": { "length": 225, "bobRadius": 22, "markerKind": "cross" },
15+
"animation": { "kind": "swing", "amplitudeDeg": 32, "periodMs": 6280 }
16+
},
17+
{
18+
"at": { "x": 710, "y": 250 },
19+
"mark": "gear",
20+
"gear": { "radius": 40, "teeth": 30, "toothLength": 8, "hubRadius": 6 },
21+
"animation": { "kind": "rotate-loop", "periodMs": 60000, "direction": "ccw" }
22+
},
23+
{
24+
"at": { "x": 0, "y": 0 },
25+
"mark": "annotation-leader",
26+
"annotation": {
27+
"from": [510, 80],
28+
"to": [560, 56],
29+
"text": "pivot",
30+
"italic": true,
31+
"anchor": "start"
32+
}
33+
},
34+
{
35+
"at": { "x": 0, "y": 0 },
36+
"mark": "annotation-leader",
37+
"annotation": {
38+
"from": [524, 305],
39+
"to": [580, 335],
40+
"text": "bob",
41+
"italic": true,
42+
"anchor": "start"
43+
}
44+
},
45+
{
46+
"at": { "x": 0, "y": 0 },
47+
"mark": "annotation-leader",
48+
"annotation": {
49+
"from": [665, 215],
50+
"to": [625, 180],
51+
"text": "escapement gear · 30 teeth",
52+
"italic": true,
53+
"anchor": "end"
54+
}
55+
},
56+
{
57+
"at": { "x": 80, "y": 380 },
58+
"size": { "w": 840, "h": 50 },
59+
"mark": "chart",
60+
"chart": {
61+
"version": "glyph/0.1",
62+
"data": {
63+
"trajectory": {
64+
"shape": "trajectory",
65+
"dxdt": "y",
66+
"dydt": "-x - 0.02*y",
67+
"initial": { "x": 0.6, "y": 0 },
68+
"time": { "min": 0, "max": 60, "samples": 1200 }
69+
}
70+
},
71+
"layers": [
72+
{
73+
"mark": "line",
74+
"encoding": {
75+
"x": {
76+
"field": "t",
77+
"type": "quantitative",
78+
"scale": { "domain": [0, 60] }
79+
},
80+
"y": {
81+
"field": "x",
82+
"type": "quantitative",
83+
"scale": { "domain": [-0.7, 0.7] }
84+
}
85+
}
86+
}
87+
]
88+
}
89+
}
90+
]
91+
}
92+
}

packages/core/__fixtures__/compose/pendulum-clock-scene.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* RFC #5–#8 CAPSTONE — the full pendulum-clock hero rendered ENTIRELY
3+
* from one Glyph compose spec. No hand-authored HTML scaffolding,
4+
* no decorative SVG outside Glyph's compiler.
5+
*
6+
* This proves the answer to "can Glyph do the pendulum-clock page
7+
* end-to-end?" is **yes** for the hero animation, after this PR.
8+
*
9+
* What's composed:
10+
* - frame: the clock case "Horologium · 1656"
11+
* - pendulum: swinging at 32° amplitude with a 6.28-second period
12+
* (matches the underlying ODE's natural period 2π s)
13+
* - gear: 30-tooth escapement rotating CCW once per minute
14+
* - 3 annotation-leaders: pivot, bob, escapement gear
15+
* - chart: an embedded trajectory plot of the same damped pendulum
16+
* ODE that the existing `pendulum-clock.json` fixture compiles
17+
* - pencil-parchment theme + graph-paper grid
18+
*
19+
* Determinism: byte-identical SVG across runs and platforms. The
20+
* SMIL `<animateTransform>` elements are emitted by the loop
21+
* emitters in `src/animation/loops.ts` with `additive="sum"` so the
22+
* loop layers on top of each group's translate.
23+
*/
24+
import { readFileSync } from "node:fs";
25+
import { fileURLToPath } from "node:url";
26+
import { describe, expect, it } from "vitest";
27+
import { compileCompose } from "../../src/compiler/compose.js";
28+
import { renderSvg } from "../../src/render/svg.js";
29+
import { parseComposeSpec } from "../../src/spec/compose-schema.js";
30+
31+
const fixtureUrl = new URL("./pendulum-clock-scene.json", import.meta.url);
32+
const fixturePath = fileURLToPath(fixtureUrl);
33+
34+
describe("compose — RFC #5–#8 capstone (pendulum-clock-scene)", () => {
35+
it("renders the full pendulum-clock hero from one compose spec", async () => {
36+
const raw = JSON.parse(readFileSync(fixturePath, "utf8"));
37+
const spec = parseComposeSpec(raw);
38+
const scene = compileCompose(spec);
39+
const svg = renderSvg(scene);
40+
const svg2 = renderSvg(compileCompose(parseComposeSpec(raw)));
41+
expect(svg2).toBe(svg);
42+
// Sanity: clock-case title, all three annotations, swing + rotate
43+
// SMIL animations, the trajectory chart's polyline, parchment fill,
44+
// and the graph-paper grid all present in one SVG.
45+
expect(svg).toContain("Horologium");
46+
expect(svg).toContain("pivot");
47+
expect(svg).toContain("bob");
48+
expect(svg).toContain("escapement gear");
49+
expect(svg).toContain('<animateTransform attributeName="transform" type="rotate"');
50+
expect(svg).toContain("#f5edd9");
51+
expect(svg).toContain("rgba(80,110,160,.08)");
52+
// Embedded trajectory chart contributes at least one path mark.
53+
expect(svg).toContain("<path");
54+
await expect(svg).toMatchFileSnapshot("./pendulum-clock-scene.svg");
55+
});
56+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* RFC #6 — looping animation emitters.
3+
*
4+
* Each emitter returns a SMIL `<animateTransform>` XML string ready
5+
* to be embedded inside an SVG `<g>` element. SMIL is declarative
6+
* and byte-stable: same input → same output bytes, every render.
7+
*
8+
* The renderer composes these by wrapping a child group's contents
9+
* in `<g transform="translate(...)"><contents/><animateTransform/></g>`.
10+
* The `additive="sum"` attribute means the loop transform layers on
11+
* TOP of the parent translate — the group ends up at its placed
12+
* position while the contents rotate / swing / pulse about their
13+
* local origin.
14+
*/
15+
16+
import type { LoopAnimation } from "../spec/compose-schema.js";
17+
18+
/**
19+
* Emit the right SMIL XML for any LoopAnimation, or empty string if
20+
* none. Delegates to one of the kind-specific helpers below.
21+
*/
22+
export function emitLoopAnimation(anim: LoopAnimation): string {
23+
if (!anim) return "";
24+
if (anim.kind === "swing") return emitSwing(anim.amplitudeDeg, anim.periodMs);
25+
if (anim.kind === "rotate-loop") return emitRotateLoop(anim.periodMs, anim.direction);
26+
if (anim.kind === "pulse") return emitPulse(anim.periodMs, anim.scale);
27+
return "";
28+
}
29+
30+
/**
31+
* `swing` — back-and-forth rotation about the group's origin.
32+
* Values: -amp → +amp → -amp over one period. Used for pendulum
33+
* bobs, escapement levers, anything that oscillates.
34+
*/
35+
export function emitSwing(amplitudeDeg: number, periodMs: number): string {
36+
const a = amplitudeDeg.toFixed(3);
37+
const dur = (periodMs / 1000).toFixed(3);
38+
return `<animateTransform attributeName="transform" type="rotate" values="-${a};${a};-${a}" keyTimes="0;0.5;1" calcMode="spline" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" dur="${dur}s" repeatCount="indefinite" additive="sum"/>`;
39+
}
40+
41+
/**
42+
* `rotate-loop` — continuous rotation, one full revolution per period.
43+
* `cw` direction means clockwise in SVG visual frame (positive degrees);
44+
* `ccw` means negative degrees. Used for gears, wheels, flywheels.
45+
*/
46+
export function emitRotateLoop(periodMs: number, direction: "cw" | "ccw"): string {
47+
const dur = (periodMs / 1000).toFixed(3);
48+
const end = direction === "cw" ? "360" : "-360";
49+
return `<animateTransform attributeName="transform" type="rotate" values="0;${end}" dur="${dur}s" repeatCount="indefinite" additive="sum"/>`;
50+
}
51+
52+
/**
53+
* `pulse` — scale oscillation about the group's origin. Values
54+
* 1 → scale → 1 over one period. Used for heartbeats, glowing
55+
* stars, anything that "breathes".
56+
*/
57+
export function emitPulse(periodMs: number, scale: number): string {
58+
const dur = (periodMs / 1000).toFixed(3);
59+
const s = scale.toFixed(3);
60+
return `<animateTransform attributeName="transform" type="scale" values="1;${s};1" keyTimes="0;0.5;1" calcMode="spline" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" dur="${dur}s" repeatCount="indefinite" additive="sum"/>`;
61+
}

0 commit comments

Comments
 (0)