Isoform

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.

shell
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:

house.js
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,
});
namethe figure's id; the file is isoform-<name>.html
icon · variantthe Isocons id and one of six variants (rounded-left by default); the build inlines that SVG
meansone sentence, at most 140 characters: what the figure shows. It is also the drawing's accessible name
effectone sentence naming the physical thing the WebGL layer adds under the traces; omitted when there is none
rulesthe numbers of the twelve rules it leans on most
rangethe one number the slider drives, at intensity 0, 0.5 and 1
mountmount({ 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.stretch and part.turn for a flat face: a screen's window growing, a half-moon turning in its dial.
  • part.spin and part.grow for 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.hole frees an opening (Isocons draws a window or a bore as an inner outline of one face), and icon.morph flows it into another Isocons drawing's outline and back.
  • part.extent measures 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…1the slider; 0.5 by default
?theme=light|darkforce a theme; otherwise the page follows the system
?gl=0turn the effect layer off (it is on by default)
?t=mshold the story at one moment; negative values are moments of the draw-in
?at=x,yhold the pointer at a stage point (400 × 320), for pictures
?w=240the page's width, for a thumbnail
?palette=isoconsIsocons' 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-bottle3610
bolt81422
rocket41018
shopping-cart61016
cart-push81422
house305580
car457095
local-gas-station0.450.71

Theme tokens

Set them on the page to restyle every figure. The defaults are the light theme's.

--iso-hi#229effthe acting part and its traces: the one accent
--iso-edge#8e8e97outlines and traces
--iso-lo#a9a9b1inner edges: a step down from the outline, still clearly drawn
--iso-face#ffffffface fill, and solid traces
--iso-plate#ffffffthe stage behind the drawing
--iso-stroke0.9line width in screen px, for every line

Engine API

The index at the top of kernel.js, which is all a figure may call.

kernel.js
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 means sentence.
  • 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.