Docs.
Everything a figure is and takes. The engine's API below is read from the engine itself, so it cannot fall behind.
Getting started
Add the skill, then ask for an icon or an idea. The agent writes one figure file and hands back one HTML page.
npx skills add choicealex/isoform # then, in your agent /isoform-animate a lighthouse
The page has the engine, the icon and the figure inlined, and nothing to install. Drop it in an iframe, or open it. Inside the skill folder: find.mjs searches icons, inspect.mjs shows an icon's faces, build.mjs assembles the page, look.mjs photographs it and runs the checks.
The figure file
A figure is one script that ends by declaring itself:
isoform({
name: "house",
icon: "house",
variant: "sharp-top",
means: "A house, seen from the top: the lights come on and spill out of the doorway.",
effect: "warm lamplight lights the doorway's walls and pools on the ground in front of it",
rules: [1, 3, 4, 11],
range: [30, 55, 80],
mount,
});| name | the figure's id; the file is isoform-<name>.html |
| icon · variant | the Isocons id and one of six variants (rounded-left by default); the build inlines that SVG |
| means | one sentence, at most 140 characters: what the figure shows. It is also the drawing's accessible name |
| effect | one sentence naming the physical thing the WebGL layer adds under the traces; omitted when there is none |
| rules | the numbers of the twelve rules it leans on most |
| range | the one number the slider drives, at intensity 0, 0.5 and 1 |
| mount | mount({ stage, svg, read, src }, value) → { set(value), destroy } |
Grow, turn, spin
A part moves along the icon's own edges. It may also grow or turn, but only within a plane of those edges, the way the real object would: a disc swells as a disc, a dial turns as a dial. Never a flat zoom or spin on the screen, which makes a solid read as a sticker (rule 09).
part.stretchandpart.turnfor a flat face: a screen's window growing, a half-moon turning in its dial.part.spinandpart.growfor a solid: it is drawn again each frame, its front turned or stretched and its depth extruded behind it, so the thickness stays true. At rest the Isocons faces come back untouched.icon.holefrees an opening (Isocons draws a window or a bore as an inner outline of one face), andicon.morphflows it into another Isocons drawing's outline and back.part.extentmeasures a part in its own plane, so a figure grows or slides by measure, never by points read off a picture.
Page options
The built page reads these from its address, so a figure can be framed, held still or photographed without touching code.
| ?intensity=0…1 | the slider; 0.5 by default |
| ?theme=light|dark | force a theme; otherwise the page follows the system |
| ?gl=0 | turn the effect layer off (it is on by default) |
| ?t=ms | hold the story at one moment; negative values are moments of the draw-in |
| ?at=x,y | hold the pointer at a stage point (400 × 320), for pictures |
| ?w=240 | the page's width, for a thumbnail |
| ?palette=isocons | Isocons' own blue for every line, as on isocons.app |
Intensity per figure
What the slider's 0, 0.5 and 1 mean for each figure on this site, in that figure's own unit.
| water-bottle | 3 | 6 | 10 |
| bolt | 8 | 14 | 22 |
| rocket | 4 | 10 | 18 |
| shopping-cart | 6 | 10 | 16 |
| cart-push | 8 | 14 | 22 |
| house | 30 | 55 | 80 |
| car | 45 | 70 | 95 |
| local-gas-station | 0.45 | 0.7 | 1 |
Theme tokens
Set them on the page to restyle every figure. The defaults are the light theme's.
| --iso-hi | #229eff | the acting part and its traces: the one accent |
| --iso-edge | #8e8e97 | outlines and traces |
| --iso-lo | #a9a9b1 | inner edges: a step down from the outline, still clearly drawn |
| --iso-face | #ffffff | face fill, and solid traces |
| --iso-plate | #ffffff | the stage behind the drawing |
| --iso-stroke | 0.9 | line width in screen px, for every line |
Engine API
The index at the top of kernel.js, which is all a figure may call.
IF: everything a figure may call. Read this index; the code under it is the
engine, and a figure should not need to read it.
The stage is a 400 × 320 viewBox. An Isocons icon is placed in it with
IF.icon, which keeps the icon's own drawing: every face is one closed path,
filled with the face colour and painted in the icon's order. A figure never
redraws a face. It groups faces into parts and moves the parts.
Isocons draws on its own axes, not the 2:1 camera: IF.icon measures them
from the icon's edges. icon.u runs down-right, icon.v down-left, up is
screen up. A move of (a, b, c) is a along u, b along v and c up, in stage
units, so a part slides along its own edges.
The icon
icon(svg, src, {cx, cy, h, u, v}) places the icon's markup src in the stage, centred on (cx, cy), h units tall (by
default 220 tall or 300 wide, whichever is smaller); u, v override the measured axes
when inspect.mjs shows them wrong (a top or right view can mislead the measure);
returns {g, paths, u, v, scale, iso, cut, face, part, hit, near}
icon.pt(x, y) a point as inspect.mjs prints it (default placement) -> this placement. Pass every
point you read off inspect through it, unless you kept the default placement
icon.cut(i, [x, y], axis) cuts face i (an index or a path) along a line through the stage point, running along
"u", "v" or "up"; returns [above, below] (left, right for "up"), in i's place in the paint
order. Indices never shift: afterwards i means the first piece. Isocons draws coplanar faces as ONE path: a part that comes away needs a cut first
icon.face(i, a, b, c, before) a copy of face i moved by (a, b, c), painted just before `before` (a part, a path or a
face index); it
is a face like any other, so it can join a part (make it before the part):
the surface a moving part was hiding, so the drawing never shows a hole (rule 06)
icon.facet([[x, y], …], before) a new face from stage corners: the broken surface a cut exposes. It can join a part
icon.morph(i, points, {hide}) face i flowing into another outline and back (stage points, closed): {set(t)}, 0 the
face as drawn, 1 the target. For one Isocons drawing becoming another (a large lens
becoming the small one); place the other drawing's outline on this one first. hide:
faces that belong to this outline only (an opening's inner wall), hidden while it morphs
icon.hole(i) the openings in face i (a screen's window, a ring's bore: inner outlines of the same
path) as faces of their own, returned in a list: they can join a part, grow, turn.
The face keeps its outer outline. Call it before making parts; rest is unchanged
icon.part(name, [faces], {paint}) faces (indices or paths) become one part: {name, g, paths, rest, move, tilt, hi, dim}
the part paints where its last face did ({paint: "first"}: its first)
part.rest {cx, cy, x0, y0, x1, y1}: its box at rest, in stage units, measured once
part.move(a, b, c) places the part at that offset from rest; the same offset again does nothing
part.tilt(deg, [px, py]) a small screen-plane turn about a stage point, for a wobble or a hinge; keep it under 15°
part.extent(plane) where the part lies at rest in a plane of its own axes: {a: [lo, hi], b: [lo, hi],
pt(a, b)}, spans in stage units and the stage point at (a, b). Measure, do not guess
part.stretch(sa, sb, plane, at) grows the part within a plane of its own axes ("u-up", "u-v", "v-up"), sa along the
first, sb along the second, about a stage point (default its rest centre). Exact for a
flat face (an inner panel growing); for a solid, only a uniform swell (sa = sb) is honest
part.turn(deg, plane, at) turns the part within a plane of its own axes: a flat face turning like a dial. Not for
a solid with visible depth (its depth would swing): use spin
part.spin(deg, at) a solid turning in its front's plane, re-drawn each frame: the front face turned, the
depth re-extruded behind it. Needs icon.part(name, faces, {solid: {front, depth}}),
front a face index, depth its thickness as a move along one axis ([0, -45, 0]). At 0°
the Isocons faces come back untouched
part.grow(sa, sb, at) a solid stretched within its front's plane (sa, sb along the plane's two axes, in the
order u, v, up), re-drawn like spin so its depth stays true: a bar growing longer
part.hi(on) part.dim(on) the part's silhouette in the bright stroke, or the dim one: the whole palette.
Every part (and every run of faces left out of parts) is drawn Hairline's way:
a bright silhouette, dim inner edges. Make every part in mount, before the first frame
icon.hit(point) the part whose REST shape holds the stage point, topmost first, or null (rule 01)
icon.ink(p) the illustration drawing itself, as a hand would: one pen at a constant speed,
longest outline first, then the nearest face; fills come in once every line is drawn
(from 0.86). 0 nothing, 1 the icon as drawn. Drive it from a story channel with an
intro: {intro: {dur, from: {ink: 0}}}. Traces added to the drawing should draw on
last, as the fills come in: t.draw(lines, smooth(0.86, 0.97, ink))
icon.near(point) {part, d}: the part whose rest centre is nearest, and how far, in stage units
icon.iso(a, b, c) [dx, dy]: the screen offset of a world move, for placing an effect
after(a, b) paints part a just after part b, for a part that comes forward
Maths
clamp(v, a, b) lerp(a, b, t) smooth(e0, e1, x) rad(deg) r2(n)
Envelopes, for an effect's strength: nothing an effect shows may switch on or off in one frame
burst(age, rise, fall) a one-shot event (a flash, a spark, a snap): 0 before, a smooth rise over `rise` s that
starts flat, then an exponential fall with time constant `fall` s. age in seconds
settle(st, target, dt, secs) a level that follows its target critically damped, no overshoot: st = {x, v}; returns
whether it still moves. For a colour or a brightness that must glide, not jump
The continuous clock: one spring per moving number
spring(x, opts) at rest on x; write .t to retarget; opts {k, c, m, eps}, default k 100, c 18, m 1
stepS(sp, dt) advances by dt seconds; returns whether it is still moving
The discrete clock: a 700ms tween on (.32, .72, 0, 1)
tween(v, dur) tset(tw, to, now, delay) tval(tw, now) tdone(tw, now)
reducedMotion() true when the reader asked for less motion; springs and tweens land at once
Stories: an illustration plays on its own; the pointer takes over while it holds
story(stage, {rest, poster, beats, intro}) channels are named numbers. intro {dur, from, ease}: played once, before the
loop, from those values to rest (the draw-in); a negative ?t= is a moment of it. rest: their values at rest; poster: the one frame shown
under reduced motion (the most telling moment). beats: [{dur ms, to: {ch: v | [keys…]},
ease}] in order; a beat moves the channels it names, the rest hold; it loops.
[keys…] passes through each value in turn within the beat: {tip: [-0.05, 0]} nudges and returns
ease: "inOut" (default, a story's pace), "out", "in", "linear"
s.step(dt) call in tick; returns whether it still needs frames
s.values(live) the channels now; live: {ch: v} for those the pointer drives, blended in while held
s.hold(on) the pointer takes over the loop (its clock stops) or lets go (it carries on); the intro
draws on to the end either way, so `ink` never needs to be in live
SPRING.settle | hero | float | hand {k, c}: settle 200/25, no overshoot (the default for small gestures); hero 400/10,
the one part that carries the meaning; float 50/10, water and slow drift; hand 100/18
EASES.inOut | out | in | linear the curves beats use
Life
register(stage, tick) joins the one frame loop; tick(dt seconds, now ms) returns true while anything moves;
gives {wake, unregister}. The loop sleeps offscreen and when every tick returns false
(a host may set stage.dataset.speed: dt is scaled by it; a figure never needs to)
pointer(stage, {move, down, leave}) points in stage units; returns its disposer. Touch works the same way with no extra
code: a tap answers and holds for 1.6s, a sideways drag follows the finger
disposer() {add, on, dispose}: collects tear-down, so destroy is bag.dispose
Traces: what happens, drawn in the figure's own hairline. Every figure draws its phenomenon this way first
trace(svg, {tone, dash, under, solid, free, fallback}) a hairline over the stage; tone "edge" (default), "hi" or "lo"; dash for a guide;
under puts it behind the icon (dust on the ground, a wake); solid fills each closed
outline with the face colour, so a traced object hides what is behind it (a box
falling past a rim): draw its seen faces back to front, one closed outline each;
free: its ends may stop in the air (a flame's tip): every other line must end on the drawing;
fallback: the phenomenon's outline (a flame's edge, a beam's sides): drawn when the effect is
off, it steps aside while the effect runs, so a glow is never boxed in by its own outline
part.trace({tone, dash, clip, free, fallback}) a hairline in the part's layer: it paints with the part, and clip keeps it inside the
part's faces. Its points are stage points where the line is NOW: it does not follow
part.move, so add the part's offset yourself (icon.iso(a, b, c), as rocket.js does);
that is what lets a water line stay level while its bottle tilts
t.draw(lines, reveal) sets it from stage points: a polyline [[x, y], …] or a list of them; [] hides it.
reveal 0…1 draws the line on along its length (an ink line being drawn)
t.tone(tone) changes its tone
The effect layer: WebGL, on by default (the reader can turn it off), one per figure at most. It adds the
material under the traces, never replaces them: the figure looks the same with it off, only flatter
gl(stage, {layer, frag, uniforms, blend}) layer "under" (behind the faces: plumes, dust, arcs in the open) or "over" (on top,
masked to parts: liquid behind glass, heat in metal); uniforms {name: "float"|"vec2"|"vec3"}
returns {on, set(name, value), mask(parts), draw(now), dispose}
on is false when the reader turned effects off or WebGL is missing: the figure must still work.
blend: on a dark plate the effect is screen-blended (light only adds); "normal" for one that
must also darken. The kernel rolls off a hot core on a dark plate and dithers soft falloffs
frag GLSL defining vec4 effect(vec2 p) : p is the stage point, y down; return premultiplied rgba
given: u_time (s), u_ptr (stage point, or -1), u_dark (0|1), u_bold (0 subtle, 1 bold: the
kernel already scales the output; use it to also narrow a halo or calm a flicker),
u_line, u_hi, u_face, u_plate (rgb),
mask(p) (1 inside the masked parts), hash(p), noise(p), fbm(p), seg(p, a, b) (distance to a segment)
Classes on a path: none (face fill, line stroke) · hi (bright stroke) · dim (dim stroke). A figure sets no colour of its own.
Accessibility
- The drawing is an image named by its
meanssentence. - The state read-out is a live region, so a change is spoken once, briefly.
- Under reduced motion a figure shows one poster frame, its most telling moment, and never loops.
- Hover only takes over; nothing is reachable by hover alone. Off screen, a figure stops drawing.
Licence and credit
The icons are Isocons by @leyeConnect and @meandchimso, CC BY 4.0: you may use, adapt and sell them with credit. Every built page carries that credit; keep it wherever a figure goes. Isocons does not endorse Isoform.