lucasmarkes/hairline実行前に内容を確認
SKILL DETAIL
hairline-create
lucasmarkes/hairline/hairline-create
Use when someone asks for a new Hairline figure, or runs /hairline-create with an idea. Draws one isometric line figure that answers the pointer, in the style and on the engine of @lucasmarkes/hairline, and hands it over as a single self-contained HTML file.
インストール · 311出典を見る
Installation
npx skills add https://github.com/lucasmarkes/hairline --skill hairline-create
スキルファイル
SKILL.md
最終同期 · 2026/10/09
bench.html›
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hairline figure</title>
<link rel="icon" href="data:,">
<style>
:root { color-scheme: light; --ground: #ffffff; --ink: #232327; --muted: #6f6f78; --line: #e0e0e4; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { color-scheme: dark; --ground: #08090a; --ink: #d0d6e0; --muted: #8a8c95; --line: #29292d; }
}
:root[data-theme="dark"] { color-scheme: dark; --ground: #08090a; --ink: #d0d6e0; --muted: #8a8c95; --line: #29292d; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
background: var(--ground); color: var(--ink);
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased;
}
main { width: 100%; max-width: 640px; }
.plate { position: relative; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.tag, .label, output, button { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: 0.02em; }
.tag { position: absolute; top: 12px; z-index: 1; color: var(--muted); pointer-events: none; }
#name { left: 14px; }
#read { right: 14px; color: var(--ink); font-variant-numeric: tabular-nums; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin-top: 14px; }
.label { color: var(--muted); }
input[type="range"] {
flex: 1 1 96px; min-width: 0; height: 32px; margin: 0; background: transparent; cursor: pointer;
-webkit-appearance: none; appearance: none;
}
input[type="range"]::-webkit-slider-runnable-track { height: 1px; background: var(--muted); }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 13px; height: 13px; margin-top: -6px; border-radius: 50%;
border: 1px solid var(--ink); background: var(--ground);
}
input[type="range"]::-moz-range-track { height: 1px; background: var(--muted); }
input[type="range"]::-moz-range-thumb { width: 11px; height: 11px; border-radius: 50%; border: 1px solid var(--ink); background: var(--ground); }
input[type="range"]:focus-visible, button:focus-visible { outline: 1.5px solid var(--ink); outline-offset: 2px; }
output { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
button {
height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px;
background: transparent; color: var(--ink); cursor: pointer;
}
#play[aria-pressed="true"] { background: var(--ink); color: var(--ground); border-color: var(--ink); }
#means { margin: 14px 0 0; text-wrap: pretty; }
#rules { margin: 6px 0 0; line-height: 1.6; }
#error { margin: 12px 0 0; padding-left: 10px; border-left: 1px solid var(--ink); }
</style>
</head>
<body>
<main>
<div class="plate">
<span class="tag" id="name"></span>
<span class="tag" id="read"></span>
<div id="stage"></div>
</div>
<div class="controls">
<label class="label" for="intensity">intensity</label>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.5">
<output id="value" for="intensity"></output>
<span class="label">theme</span>
<button id="theme" type="button" aria-label="Switch between the light and the dark theme"></button>
<span class="label">play</span>
<button id="play" type="button" aria-pressed="false">play</button>
</div>
<p id="means"></p>
<p id="rules" class="label"></p>
<p id="error" role="alert" hidden></p>
</main>
<script id="hl-kernel">/*KERNEL*/</script>
<script id="hl-bench">
/*
* The bench: the page a figure is shown on. It makes the host and the svg the
* way the package's mount does, hands them to the figure, and owns everything
* around it: the read-out, the slider, the theme, the line under the stage.
*/
(() => {
const RULES = ["hit", "order", "reach", "accent", "rest", "honesty", "cost", "clock", "radius", "quiet"];
const $ = (id) => document.getElementById(id);
const root = document.documentElement, params = new URLSearchParams(location.search);
/* the theme: the system's until the switch is pressed, then the switch's */
const dark = () => (root.dataset.theme ? root.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches);
const label = () => { $("theme").textContent = dark() ? "dark" : "light"; };
$("theme").addEventListener("click", () => { root.dataset.theme = dark() ? "light" : "dark"; label(); });
/* ?theme=light or ?theme=dark wins over the system's, as pressing the switch does */
if (["light", "dark"].includes(params.get("theme"))) root.dataset.theme = params.get("theme");
label();
/* ?intensity=0 sets the slider before the figure mounts, for a look at either end */
const i = (params.get("intensity") || "").trim();
if (i && Number.isFinite(Number(i))) $("intensity").value = String(Math.min(1, Math.max(0, Number(i))));
/* ?w=240 narrows the page, for a look at the size of a thumbnail */
const w = Number(params.get("w"));
if (w > 0) document.querySelector("main").style.maxWidth = w + "px";
/* a figure that fails says so under the stage, instead of leaving an empty plate */
addEventListener("error", (e) => {
$("error").hidden = false;
$("error").textContent = String((e.error && e.error.message) || e.message);
});
let mounted = false;
window.hairline = (figure) => {
if (mounted) throw new Error("hairline: one figure per page.");
mounted = true;
const stage = $("stage"), slider = $("intensity");
/* intensity to the figure's own number: two straight lines that meet at 0.5, as in the package */
const [lo, mid, hi] = figure.range;
const at = (i) => Math.round((i <= 0.5 ? lo + (i / 0.5) * (mid - lo) : mid + ((i - 0.5) / 0.5) * (hi - mid)) * 1000) / 1000;
HL.inject(document);
stage.setAttribute("data-hairline", figure.name);
stage.setAttribute("role", "img");
stage.setAttribute("aria-label", figure.means);
const svg = HL.mk("svg", { viewBox: "0 0 400 320", "aria-hidden": "true" }, stage);
let text = null;
const read = {
get textContent() { return text; },
set textContent(value) { text = value == null ? "" : String(value); $("read").textContent = text; },
};
document.title = "Hairline · " + figure.name;
$("name").textContent = figure.name;
$("means").textContent = figure.means;
$("rules").textContent = figure.rules.map((n) => String(n).padStart(2, "0") + " " + RULES[n - 1]).join(" · ");
const show = () => { $("value").textContent = String(at(Number(slider.value))); };
show();
const handle = figure.mount({ stage, svg, read }, at(Number(slider.value)));
if (text === null) read.textContent = "rest";
slider.addEventListener("input", () => { show(); handle.set(at(Number(slider.value))); });
window.hairline.figure = handle;
/* play: an unseen pointer walks the figure's tour (or HL.LAP) until a hand arrives; ?play=1 starts it on load */
const play = $("play");
window.hairline.playing = null;
play.addEventListener("click", () => {
const was = play.getAttribute("aria-pressed") === "true";
if (was) { window.hairline.playing.stop(); window.hairline.playing = null; }
else window.hairline.playing = HL.tour(stage, figure.tour || HL.LAP, (i) => { if (window.hairline.onStop) window.hairline.onStop(i); });
play.setAttribute("aria-pressed", String(!was));
});
if (params.get("play") === "1") play.click();
/* ?at=200,160 holds the pointer at a viewBox point, for a picture of the answer without a mouse */
const p = (params.get("at") || "").split(",").map(Number);
if (p.length === 2 && p.every(Number.isFinite)) {
const r = stage.getBoundingClientRect();
stage.dispatchEvent(new PointerEvent("pointermove", {
pointerType: "mouse", pointerId: 1, bubbles: true,
clientX: r.left + (p[0] / 400) * r.width, clientY: r.top + (p[1] / 320) * r.height,
}));
}
};
})();
</script>
<script type="module" id="hl-figure">/*FIGURE*/</script>
</body>
</html>
build.mjs›
#!/usr/bin/env node
/**
* Puts a figure on the bench: `node build.mjs <figure.js> [out.html]` writes
* one self-contained page, bench.html with kernel.js and the figure in its two
* slots and nothing else changed, and prints where it is. Without a path the
* page is hairline-<name>.html in the working directory.
*/
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
/** The page for a figure's source. The replacements are functions so `$&` in the kernel or the figure is pasted as it is. */
export function assemble(figure) {
const kernel = readFileSync(here("./kernel.js"), "utf8").replace(/\r\n/g, "\n").trimEnd();
return readFileSync(here("./bench.html"), "utf8").replace(/\r\n/g, "\n")
.replace("/*KERNEL*/", () => `\n${kernel}\n`)
.replace("/*FIGURE*/", () => `\n${figure.replace(/\r\n/g, "\n").trim()}\n`);
}
/** The figure's name, read from its declaration: the last hairline({ … }) call, as validate.mjs reads it, so a `name:` earlier in the figure is not taken for it. */
export function nameOf(figure) {
const call = [...figure.matchAll(/\bhairline\s*\(\s*\{/g)].at(-1);
return call ? /\bname:\s*["'`]([a-z][a-z0-9-]*)["'`]/.exec(figure.slice(call.index))?.[1] ?? null : null;
}
/** Whether two resolved paths are one file. Windows paths ignore case, and the drive letter's case can differ between the two. */
export const same = (a, b, platform = process.platform) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
/* The skill is often installed as a symlink, so the path Node was given is resolved before it is compared. */
if (process.argv[1] && same(realpathSync(process.argv[1]), realpathSync(here("./build.mjs")))) {
const [src, out] = process.argv.slice(2);
if (!src) {
console.error("usage: node build.mjs <figure.js> [out.html]");
process.exit(2);
}
const figure = readFileSync(src, "utf8");
const file = resolve(out ?? `hairline-${nameOf(figure) ?? "figure"}.html`);
writeFileSync(file, assemble(figure));
console.log(file);
}
concepts.md›
# From an idea to a concept
A concept is three things: **an object**, **what the pointer does to it**, and **what the read-out says**. It fits on one line. If it does not, it is not ready to build.
## Finding it
1. **Find the object.** Something you could put on a desk: a tray, a belt, a platter, a stack, a board of pegs. Not a diagram. A deploy pipeline is not boxes and arrows; it might be a flight of canal locks.
2. **Find what gives it away.** For each kind of part, name the two or three features that make it what it is, each something the object would really have. A car: a cabin narrower than its body, a windscreen that slopes, wheels. A lighthouse: a tower that tapers, a lantern on top, a gallery round it. A part with none of them is a rounded block, and a row of rounded blocks is nothing in particular. Keep the list: you draw from it, and the look checks the small picture against it.
3. **Find the variable.** Every idea has one number or one choice that matters: how far, which one, how fast, how long. That is what the pointer gets.
4. **Give the pointer that variable.** Where it is sets a position (continuous); what it is over sets a choice (discrete). The answer should be what a hand would expect from the object.
5. **Design the rest.** What does the object look like when nobody touches it? It must already be a composition, and already say what the figure is about.
6. **Choose the read-out's words.** A few characters naming what is under the pointer: `cell 4·2`, `08`, `lock 3`. And `rest`.
7. **Choose the slider's number.** One number that makes the answer weaker or stronger: a radius, a stagger, a gap, a rate. Its three values go in `range`.
## Six answers already proven
The package's six figures each answer the pointer a different way. A new figure usually borrows one.
| Answer | In the package | The pointer | The clock | The slider |
| --- | --- | --- | --- | --- |
| **A field** | Terrain: pillars rise near the pointer | sets a position on the ground; height falls off with distance | a spring per part | the radius |
| **One of many** | Riffle: the card under the pointer stands up | picks an item; its neighbours part, staggered outwards | tweens | the stagger |
| **Scrub and pick** | Exploded: a window comes apart in layers | x scrubs the gap, y picks a layer, which gets the bright edge | a spring for the gap | the gap |
| **Paint and decay** | Phosphor: a dot matrix you can draw on | excites what it passes; the marks fade; an idle loop returns | a decay per dot | the afterglow |
| **Dilate time** | Slow: crates ride a belt through a gate | the world keeps moving, hovering slows it so it can be read | a spring on the rate | the rate |
| **Push the camera** | Turntable: a platter you flick round | pushes; friction bleeds the spin; detents catch it | friction, then a spring | the coast |
`examples/terrain.js` is the first and `examples/riffle.js` the second, in full.
## An empty state
Asked for an empty state (no results, nothing here yet, not found), draw the absence, not a sad face.
- **The subject is absence.** Pick the tool that looks for things or holds them, and show it holding none: a sieve with a bare mesh, a rail of bare hangers, a loupe over a blank sheet.
- **Rest is the whole figure.** An empty state sits small above a heading and a button, and most people never bring the pointer to it. Judge the rest pose first. The site's thumbnail is 160px, smaller than `look.mjs`'s small picture, so judge that picture as if it were two-thirds its size.
- **Empty is not blank.** The object is all there and composed; what is missing is what it would hold. One bright mark says where to look.
- **The read-out counts to zero.** Under the pointer it names the part and what it holds, none: `sieve 2 · 0`, `row 4 · 0`. At rest it still says `rest`.
- **The tour may be short.** Two stops and a leave: the rest pose does most of the work, and each stop reads a part out as empty.
## From a mark
Asked to draw from a company's mark, the mark is not a label on something else.
- **The mark is the object.** Build it as a solid with the kernel, the way you would build a padlock; never trace it flat onto a face.
- **Take the idea from the product.** Its own empty state names it: "No deployments yet", "No pages inside". The figure shows that.
- **Give the mark something to answer with.** It lands, it joins, it opens. A mark that only sits there is an icon.
- **One mark, drawn whole.** Keep its proportions and the features that make it that mark. A glyph that is part of the mark is a relief on a face, built, not written.
- **Ask whether the mark is theirs to use.** If it belongs to someone else, say that the page shows another company's trademark and is theirs to publish or not.
## A weak concept
Drop it, or fix it before building, when:
- **It is dead at rest.** Nothing to look at until touched.
- **It needs words.** Without a label nobody would know what it is.
- **It holds more than one idea.** Two gestures, two variables, two objects.
- **The pointer has no reason.** The figure would be the same as a loop.
- **It is a diagram.** Boxes, arrows and lines between them are not an object.
- **It is an icon.** The literal symbol of the idea (a padlock for security) has nothing to answer with. A mark the person asked for is the exception: see "From a mark".
- **It depends on colour.** The palette is one stroke in four weights.
- **It will not read at 240px.** Too many parts, or parts too small.
## An example
For "a deploy pipeline":
> **Locks.** A flight of canal locks stepping down, each a basin. The pointer picks a lock; its gates open and its water drops to the next, staggered from the pointer. The read-out names the stage: `lock 3`.
>
> **Marble run.** A wooden frame with three ramps zigzagging down it, one ramp a stage, and marbles always rolling down them and lifted back to the top. The run never stops; hovering slows it so a marble can be followed, and the one under the pointer is bright. The read-out names its stage: `ramp 2`.
>
> **Cabinet.** A server cabinet of twelve blades, a few half out where the last update stopped. The pointer's height sets where the update is; the blades near it slide out, the farther the less, and the one under it is bright. The read-out names the slot: `blade 7`.
Each has an object, a gesture and a read-out; each is one idea; each is something at rest.
examples/riffle.js›
/**
* Riffle: a rounded tray holding eight cards. The card under the pointer
* stands up and lifts; the ones in front lean forward and the ones behind lean
* back, staggered outwards from it on the 700ms lift curve. Each card's number
* is punched on its tab in a 4 × 2 grid, and goes to the read-out when the
* card is pulled. The slider is the stagger, in ms.
*
* The pattern: discrete items. Tweens, a stagger by distance, identity carried
* by geometry, and a hit test on static bands along the resting top edges, so
* a card moving out from under the pointer cannot flip the choice.
*/
const {
Cam, clamp, facing, fillet, fit, hull, open, poly, proj, rad, ringAt, rrect, run, seg,
tdone, tset, tval, tween, disposer, mk, place, pointer, reflect, register,
} = HL;
const N = 8, W = 84, H = 54, G = 13, TW = 22, TH = 7, TABS = [6, 31, 56], TK = 1.4;
const REST = -12, BACK = -24, FWD = 20, LIFT = 16;
const X0 = -5, X1 = W + 5, Y0 = -9, Y1 = (N - 1) * G + 9, WH = 20, WR = 6, WT = 2.4;
/** A run of points ordered left to right on screen. */
const LR = (pts) => (pts[0][0] <= pts[pts.length - 1][0] ? pts : pts.slice().reverse());
/** The tray, which never moves: `far` is painted before the cards, `near` after them; each entry is [d, class]. */
function tray(P, front, outer, inner) {
// far half: body, the rim's inner edge, and the floor seam along the far walls
const far = [
[poly(hull(ringAt(P, outer, 0).concat(ringAt(P, outer, WH)))), "sil"],
[poly(ringAt(P, inner, WH)), "nf"],
[open(ringAt(P, run(inner, (q) => !front(q)), 2.5)), "nf lo"],
];
// near half: one opaque piece from the rim's inner edge down to the floor
const iF = LR(ringAt(P, run(inner, front), WH)), oT = LR(ringAt(P, run(outer, front), WH)), oB = LR(ringAt(P, run(outer, front), 0));
// a finger pull, set into the front
const hx = (X0 + X1) / 2, onFront = (ring) => ring.map((q) => P(q.u, Y1, q.v));
const near = [
[poly([...iF, oT[oT.length - 1], ...oB.slice().reverse(), oT[0]]), "fo"],
[open(oT), "nf lo"],
[open(iF), "nf"],
[open([oT[0], ...oB, oT[oT.length - 1]]), "nf sil"],
[poly(onFront(rrect(hx - 11, 6.5, hx + 11, 12.5, 3, 5))), "nf"],
[poly(onFront(rrect(hx - 9.4, 8, hx + 9.4, 11, 1.5, 5))), "nf lo"],
];
return { far, near };
}
/** Card i, back to front: its number (8 at the back), which of three tab positions it takes, and its filleted outline, upright in its own plane. */
function card(i) {
const n = N - i, t0 = TABS[(N - 1 - i) % 3];
const shape = fillet(
[[0, 0], [W, 0], [W, H], [t0 + TW, H], [t0 + TW, H + TH], [t0, H + TH], [t0, H], [0, H]],
[1, 1, 3.2, 1.8, 2.4, 2.4, 1.8, 3.2],
);
return { n, t0, shape };
}
/** Card i leaning th degrees (negative leans back) and lifted by `lift`: its paths, and where its eight punches sit. */
function pose(P, i, t0, shape, th, lift) {
const yb = i * G, s = Math.sin(rad(th)), c = Math.cos(rad(th));
const w = (u, v) => P(u, yb + v * s, v * c + lift);
const wb = (u, v) => P(u, yb + v * s - TK * c, v * c + TK * s + lift);
const punch = [];
for (let k = 0; k < 8; k++) punch.push(w(t0 + TW / 2 + ((k % 4) - 1.5) * 3.6, H + TH / 2 + (0.5 - Math.floor(k / 4)) * 2.8));
return {
back: poly(shape.map((p) => wb(p[0], p[1]))),
face: poly(shape.map((p) => w(p[0], p[1]))),
head: seg(w(6, H - 11), w(W - 6, H - 11)),
rules: [H - 18, H - 25, H - 32, H - 39].map((v) => seg(w(6, v), w(W - 6, v))).join(""),
punch,
};
}
function mount({ stage, svg, read }, value) {
const bag = disposer();
let stag = value;
// The camera is fitted to the tray with a card lifted, so nothing leaves the frame in any pose.
const C = Cam(45, 0.5, 1.62);
fit(C, [[X0, Y0, 0], [X1, Y1, -8], [X1, Y0, 0], [X0, Y1, 0], [X0, Y0, H + TH], [X1, Y0, H + TH + LIFT]], 200, 166);
const P = proj(C), front = facing(C);
const outer = rrect(X0, Y0, X1, Y1, WR, 6), inner = rrect(X0 + WT, Y0 + WT, X1 - WT, Y1 - WT, WR - WT, 6);
const paths = tray(P, front, outer, inner);
const g = mk("g", {}, svg);
reflect(svg, g, P, front, outer, 0, 14);
for (const [d, cls] of paths.far) mk("path", { d, class: cls }, g);
const cards = [];
for (let i = 0; i < N; i++) {
const { n, t0, shape } = card(i);
const grp = mk("g", {}, g);
const back = mk("path", { class: "lo" }, grp), face = mk("path", { class: "sil" }, grp);
const head = mk("path", { class: "nf" }, grp), rules = mk("path", { class: "nf lo" }, grp);
// the card's number, punched in a 4 × 2 grid on its tab
const punch = [];
for (let k = 0; k < 8; k++) punch.push(mk("circle", { r: 1.05, class: "dot " + (k === n - 1 ? "m" : "off") }, grp));
cards.push({ n, t0, shape, back, face, head, rules, punch, a: tween(REST), z: tween(0) });
}
for (const [d, cls] of paths.near) mk("path", { d, class: cls }, g);
// hit bands: oblique strips along the RESTING top edges. They never move, and nothing draws them.
const top = (i) => P(W / 2, i * G + H * Math.sin(rad(REST)), H * Math.cos(rad(REST)));
const c0 = top(0), c1 = top(1), d = [c1[0] - c0[0], c1[1] - c0[1]];
const px0 = P(0, 0, 0), px1 = P(1, 0, 0), ex = [px1[0] - px0[0], px1[1] - px0[1]];
const HALF = W / 2 + 6, det = d[0] * ex[1] - d[1] * ex[0];
/** The card whose band holds the point, in the band's own (s, r) coordinates; -1 outside. */
function hit([x, y]) {
const qx = x - c0[0], qy = y - c0[1];
const s = (qx * ex[1] - qy * ex[0]) / det, r = (d[0] * qy - d[1] * qx) / det;
if (Math.abs(r) > HALF || s < -0.5 || s > N + 1) return -1;
return clamp(Math.round(s), 0, N - 1);
}
function draw(i, th, lift) {
const cd = cards[i], q = pose(P, i, cd.t0, cd.shape, th, lift);
cd.back.setAttribute("d", q.back);
cd.face.setAttribute("d", q.face);
cd.head.setAttribute("d", q.head);
cd.rules.setAttribute("d", q.rules);
cd.punch.forEach((el, k) => place(el, q.punch[k]));
}
const B = register(stage, (_dt, now) => {
let moving = false;
cards.forEach((cd, i) => { draw(i, tval(cd.a, now), tval(cd.z, now)); if (!tdone(cd.a, now) || !tdone(cd.z, now)) moving = true; });
return moving;
});
bag.add(B.unregister);
let act = -1;
const caption = (a) => (a < 0 ? "rest" : String(N - a).padStart(2, "0"));
/** Pulls card a (-1 puts them all back). The stagger spreads out from the card pulled, or the one let go. */
function setActive(a) {
if (a === act) return;
const now = performance.now(), from = a >= 0 ? a : act;
act = a;
cards.forEach((cd, i) => {
const delay = Math.abs(i - from) * stag;
const th = a < 0 ? REST : i < a ? BACK : i > a ? FWD : 0;
tset(cd.a, th, now, delay); tset(cd.z, a === i ? LIFT : 0, now, delay);
cd.face.classList.toggle("hi", i === a); cd.head.classList.toggle("hi", i === a); cd.punch[cd.n - 1].classList.toggle("m", i !== a);
});
read.textContent = caption(a);
B.wake();
}
bag.add(pointer(stage, { move: (p) => setActive(hit(p)), leave: () => setActive(-1) }));
bag.add(() => svg.replaceChildren());
return {
set: (v) => { stag = v; },
destroy: bag.dispose,
};
}
hairline({
name: "riffle",
means: "Eight cards in a tray: the one under the pointer stands up, and its neighbours lean away in turn.",
rules: [1, 2, 8, 10],
range: [0, 40, 90],
tour: [[160, 160], [200, 120], [280, 120], null],
mount,
});
examples/terrain.js›
/**
* Terrain: a field of 81 pillars on a rounded plinth. The pointer is projected
* back onto the ground, and each pillar takes its height from its distance to
* it, on its own spring. At rest the field is a dune with two rises. A 3 × 3
* dot mark rides the lid of the pillar under the pointer, or the peak at rest;
* pillars above half height take the bright stroke. The slider is the radius,
* in cells.
*
* The pattern: a continuous field. Springs, a falloff by distance, a hit test
* on the ground plane (which never moves), and a rest that is a composition.
*/
const {
Cam, clamp, facing, fit, prism, proj, rings, unproj, spring, stepS,
flatDot, mk, place, pointer, put, register, disposer, solid,
} = HL;
const N = 9, CELL = 14, FOOT = 11, HMAX = 58, EXT = N * CELL, PB = 5;
/** The share of full height at u radii from the pointer: 1 → .31 at 42% → .09 at the edge and beyond. */
const falloff = (u) =>
u <= 0 ? 1 : u <= 0.417 ? 1 - (u / 0.417) * 0.6875 : u <= 1 ? 0.3125 - ((u - 0.417) / 0.583) * 0.2185 : 0.094;
function mount({ stage, svg, read }, value) {
const bag = disposer();
const C = Cam(45, 0.5, 1.58);
fit(C, [[-6, -6, -PB], [EXT + 6, EXT + 6, -PB], [EXT + 6, -6, -PB], [-6, EXT + 6, -PB], [0, 0, HMAX * 0.75]], 200, 166);
const P = proj(C), front = facing(C);
let R = value * CELL, over = null;
const g = mk("g", {}, svg), cols = [];
const [pr, pi] = rings(-6, -6, EXT + 6, EXT + 6, 9, 2.2);
put(solid(g), prism(P, front, pr, pi, -PB, 0));
// Diagonal by diagonal from the back corner, so appending is painting back to front.
for (let s = 0; s <= 2 * (N - 1); s++) for (let i = 0; i < N; i++) {
const j = s - i;
if (j < 0 || j >= N) continue;
const u = i / (N - 1), v = j / (N - 1);
const h0 = 4 + 25 * Math.exp(-((u - 0.22) ** 2 + (v - 0.74) ** 2) / 0.07) + 12 * Math.exp(-((u - 0.8) ** 2 + (v - 0.26) ** 2) / 0.035);
const x0 = i * CELL + (CELL - FOOT) / 2, y0 = j * CELL + (CELL - FOOT) / 2;
const [ring, inner] = rings(x0, y0, x0 + FOOT, y0 + FOOT, 2.6, 0.9);
cols.push({ i, j, h0, ring, inner, sp: spring(h0, { eps: 0.04 }), el: solid(g), drawn: NaN });
}
// The mark: a 3 × 3 of dots riding the lid of one pillar, moved in the paint
// order to just after it, so the pillars in front still cover it.
const mark = mk("g", {}, g), md = [];
for (let k = 0; k < 9; k++) md.push(flatDot(mark, C, 0.55, k === 4 ? "dot" : "dot m"));
const peak = cols.reduce((a, b) => (b.h0 > a.h0 ? b : a));
const byCell = new Map();
cols.forEach((c) => byCell.set(c.i + "," + c.j, c));
let mc = null, want = peak;
function drawMark() {
if (want !== mc) { mc = want; mc.el.g.after(mark); }
const cx = (mc.i + 0.5) * CELL, cy = (mc.j + 0.5) * CELL, h = Math.max(0.6, mc.sp.x);
md.forEach((el, k) => place(el, P(cx + ((k % 3) - 1) * 2.5, cy + (Math.floor(k / 3) - 1) * 2.5, h)));
}
// A pillar whose spring hasn't moved keeps its paths: most of the 81 are still on most frames.
function drawCol(c) {
const h = Math.max(0.6, c.sp.x);
if (h === c.drawn) return;
c.drawn = h;
put(c.el, prism(P, front, c.ring, c.inner, 0, h));
c.el.sil.classList.toggle("hi", h > HMAX * 0.5);
}
const B = register(stage, (dt) => {
let m = false;
for (const c of cols) { if (stepS(c.sp, dt)) m = true; drawCol(c); }
drawMark();
return m;
});
bag.add(B.unregister);
function retarget() {
for (const c of cols) {
if (!over) { c.sp.t = c.h0; continue; }
const dx = (c.i + 0.5) * CELL - over[0], dy = (c.j + 0.5) * CELL - over[1];
c.sp.t = HMAX * falloff(Math.hypot(dx, dy) / R);
}
if (over) {
const i = clamp(Math.floor(over[0] / CELL), 0, N - 1), j = clamp(Math.floor(over[1] / CELL), 0, N - 1);
want = byCell.get(i + "," + j);
read.textContent = `cell ${i}·${j}`;
} else { want = peak; read.textContent = "rest"; }
B.wake();
}
bag.add(pointer(stage, {
move: (p) => { over = unproj(C, p[0], p[1], 0); retarget(); },
leave: () => { over = null; retarget(); },
}));
bag.add(() => svg.replaceChildren());
return {
set: (v) => { R = v * CELL; if (over) retarget(); },
destroy: bag.dispose,
};
}
hairline({
name: "terrain",
means: "A field of pillars rises under the pointer and falls off with distance.",
rules: [1, 3, 5, 9],
range: [1.5, 3, 5],
tour: [[128, 150], [272, 150], [200, 206], null],
mount,
});
kernel.js›
/* hairline kernel sha256:3a19aa2640ba8c6c306184d05fddfbd4f3323a6ebd9217dd3cbb38e9db74991c */
/*
* HL: everything a figure may call. Read this index; the code under it is the
* package's src/core, unchanged, and a figure should not need to read it.
*
* Every figure is drawn in a 400 × 320 viewBox. World space is x/y on the
* ground and z up. Plates are filled with the ground colour and painted back
* to front, so a nearer one covers a farther one: append in that order. At
* the default camera, Cam(45, 0.5, S), +x runs down to the right and +y down
* to the left, so the corner with the largest x and y is nearest the viewer:
* append by ascending x + y, from the far corner (smallest x and y) to it.
*
* Camera
* Cam(azDeg, k, S) a camera: azimuth in degrees, k = sin(elevation) (0.5 is the 2:1 view), S = scale
* fit(C, points, cx, cy) centres the box of [x, y, z] points on (cx, cy); call it once, before proj
* it only centres, it never scales: choose S by trying values, with the most extreme pose in points.
* The six figures use S 1.42 to 2.12; the boxes they fit come out 230 to 310 wide and 180 to 245 tall
* proj(C) returns P(x, y, z), which gives [sx, sy]
* unproj(C, sx, sy, z) the world [x, y] under a screen point, on the plane at height z
* facing(C) returns front(sample): whether a ring sample faces the camera
* Rounded solids
* rrect(u0, v0, u1, v1, r, n) a rounded rectangle, as a ring of samples {u, v, nu, nv}
* circ(R, n) a circle, as a ring
* rings(x0, y0, x1, y1, r, b) [ring, inner]: a rounded footprint and its crease ring, inset by b
* r, the corner radius, is cut to half the shorter side. b, the crease's inset in world units
* (0.6 to 2.2 in the figures), must stay under half the shorter side or the crease turns inside out
* prism(P, front, ring, inner, z0, z1) {sil, crease}: a solid standing from z0 to z1, as two path strings
* ringAt(P, ring, z) the ring's points, projected at height z
* run(ring, keep) the one cyclic run of samples that pass keep
* hull(points) the convex hull of screen points
* extremes(P, ring) [left, right, nearest] samples: where dashed drops fall from
* fillet(points, radii, n) rounds every vertex of a closed polygon; returns the new points
* radii is an array, one radius per vertex, each cut to half its shorter edge; a single number gives NaN.
* n is the steps round each corner, default 4: each vertex becomes n + 1 points
* ghost(P, front, ring, z0, depth) a reflection's path {d, y0, y1}; reflect() draws it for you
* Paths and numbers
* poly(points) a closed path string
* open(points) an open polyline string
* seg(a, b) one segment between two screen points [sx, sy], as its own subpath; project world points with P first
* clamp(v, a, b)
* lerp(a, b, t)
* rad(deg)
* r2(n) two decimals
* 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) at rest on v; dur defaults to 700 (ms)
* tset(tw, to, now, delay) retargets from where it is, after delay ms: the stagger
* the same target again does nothing, so calling it on every pointer move is safe
* tval(tw, now) its value at now
* tdone(tw, now) whether it has landed
* bezier(x1, y1, x2, y2) a CSS cubic-bezier, as a function of progress
* EASE_LIFT the lift curve itself
* reducedMotion() true when the reader asked for less motion; springs and tweens already land at once
* setReducedMotion(on) the loop's business, not a figure's
* Drawing
* mk(tag, attrs, parent) one svg element: the only way a figure makes a node
* solid(parent) {g, sil, cr}: a group holding a silhouette path and a crease path
* put(solid, paths) writes prism()'s {sil, crease} into solid()'s {sil, cr}: sil into sil, crease into cr
* flatDot(parent, C, r, cls) a dot lying on the ground plane; cls is "dot", "dot m" or "dot off"
* place(el, point) moves a dot or a circle to [sx, sy]
* reflect(svg, parent, P, front, ring, z0, depth) a fading mirror under a solid
* fade(svg, y0, y1, a0) a vertical fade, as a mask; returns the value for a mask attribute
* Life
* register(stage, tick) joins the one frame loop; tick(dt in seconds, now in ms) returns true to ask for another frame; gives {wake, unregister}
* pointer(stage, handlers) {move(point), down(point), leave()}, points in viewBox units; returns its disposer
* disposer() {add, on, dispose}: collects tear-down, so destroy is bag.dispose
* The bench's business, not a figure's
* css(lightDark)
* inject(root)
* tour(stage, stops, onStop) an unseen pointer that walks the stops, [x, y] in viewBox units or null to leave, and gives way to a real one; onStop(i) on each arrival; returns {stop}
* LAP the default stops: a diamond around the centre, then a leave
*
* Classes, on path, polygon, ellipse and line. They are the whole palette; a
* figure sets no colour, width or fill of its own.
* (none) filled with the ground colour, medium stroke
* sil the silhouette's stroke hi the bright stroke: the only highlight
* lo the dim stroke nf no fill fo fill only, no stroke
* dash a dashed guide
* dot a bright dot dot m a medium dot dot off a dim dot
*/
var HL = (() => {
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// packages/hairline/src/core/kernel.ts
var kernel_exports = {};
__export(kernel_exports, {
Cam: () => Cam,
EASE_LIFT: () => EASE_LIFT,
LAP: () => LAP,
bezier: () => bezier,
circ: () => circ,
clamp: () => clamp,
css: () => css,
disposer: () => disposer,
extremes: () => extremes,
facing: () => facing,
fade: () => fade,
fillet: () => fillet,
fit: () => fit,
flatDot: () => flatDot,
ghost: () => ghost,
hull: () => hull,
inject: () => inject,
lerp: () => lerp,
mk: () => mk,
open: () => open,
place: () => place,
pointer: () => pointer,
poly: () => poly,
prism: () => prism,
proj: () => proj,
put: () => put,
r2: () => r2,
rad: () => rad,
reducedMotion: () => reducedMotion,
reflect: () => reflect,
register: () => register,
ringAt: () => ringAt,
rings: () => rings,
rrect: () => rrect,
run: () => run,
seg: () => seg,
setReducedMotion: () => setReducedMotion,
solid: () => solid,
spring: () => spring,
stepS: () => stepS,
tdone: () => tdone,
tour: () => tour,
tset: () => tset,
tval: () => tval,
tween: () => tween,
unproj: () => unproj
});
// packages/hairline/src/core/iso.ts
var clamp = (v, a, b) => Math.max(a, Math.min(b, v));
var lerp = (a, b, t) => a + (b - a) * t;
var rad = (d) => d * Math.PI / 180;
var r2 = (n) => Math.round(n * 100) / 100;
var poly = (pts) => "M" + pts.map((p) => r2(p[0]) + " " + r2(p[1])).join("L") + "Z";
var seg = (a, b) => `M${r2(a[0])} ${r2(a[1])}L${r2(b[0])} ${r2(b[1])}`;
var open = (pts) => pts.length < 2 ? "" : "M" + pts.map((p) => r2(p[0]) + " " + r2(p[1])).join("L");
var Cam = (azDeg, k, S) => ({ az: rad(azDeg), k, S, ox: 0, oy: 0 });
function proj(C) {
const c = Math.cos(C.az), s = Math.sin(C.az), zf = Math.sqrt(1 - C.k * C.k);
return (x, y, z) => {
const X = x * c - y * s, Y = x * s + y * c;
return [C.ox + C.S * X, C.oy + C.S * (Y * C.k - z * zf)];
};
}
function unproj(C, sx, sy, z) {
const c = Math.cos(C.az), s = Math.sin(C.az), zf = Math.sqrt(1 - C.k * C.k);
const X = (sx - C.ox) / C.S, Y = ((sy - C.oy) / C.S + z * zf) / C.k;
return [X * c + Y * s, -X * s + Y * c];
}
function fit(C, pts, cx, cy) {
C.ox = 0;
C.oy = 0;
const P = proj(C);
let a = 1e9, b = -1e9, c = 1e9, d = -1e9;
for (const p of pts) {
const q = P(p[0], p[1], p[2]);
a = Math.min(a, q[0]);
b = Math.max(b, q[0]);
c = Math.min(c, q[1]);
d = Math.max(d, q[1]);
}
C.ox = cx - (a + b) / 2;
C.oy = cy - (c + d) / 2;
}
function rrect(u0, v0, u1, v1, r, n = 4) {
r = Math.max(0, Math.min(r, (u1 - u0) / 2, (v1 - v0) / 2));
const out = [];
for (const [cu, cv, a0] of [[u1 - r, v1 - r, 0], [u0 + r, v1 - r, 90], [u0 + r, v0 + r, 180], [u1 - r, v0 + r, 270]])
for (let k = 0; k <= n; k++) {
const a = rad(a0 + 90 * k / n), ca = Math.cos(a), sa = Math.sin(a);
out.push({ u: cu + r * ca, v: cv + r * sa, nu: ca, nv: sa });
}
return out;
}
function circ(R, n = 96) {
const out = [];
for (let k = 0; k < n; k++) {
const a = k / n * Math.PI * 2, ca = Math.cos(a), sa = Math.sin(a);
out.push({ u: R * ca, v: R * sa, nu: ca, nv: sa });
}
return out;
}
function hull(input) {
const pts = input.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const x = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lo = [], up = [];
for (const p of pts) {
while (lo.length > 1 && x(lo[lo.length - 2], lo[lo.length - 1], p) <= 0) lo.pop();
lo.push(p);
}
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (up.length > 1 && x(up[up.length - 2], up[up.length - 1], p) <= 0) up.pop();
up.push(p);
}
lo.pop();
up.pop();
return lo.concat(up);
}
var ringAt = (P, ring, z) => ring.map((q) => P(q.u, q.v, z));
var facing = (C) => {
const s = Math.sin(C.az), c = Math.cos(C.az);
return (q) => q.nu * s + q.nv * c >= -1e-6;
};
function run(ring, keep) {
const n = ring.length;
let s = -1;
for (let i = 0; i < n; i++) if (keep(ring[i]) && !keep(ring[(i + n - 1) % n])) {
s = i;
break;
}
if (s < 0) return keep(ring[0]) ? ring.slice() : [];
const out = [];
for (let k = 0; k < n && keep(ring[(s + k) % n]); k++) out.push(ring[(s + k) % n]);
return out;
}
function prism(P, front, ring, inner, z0, z1) {
return {
sil: poly(hull(ringAt(P, ring, z1).concat(ringAt(P, ring, z0)))),
crease: inner ? open(ringAt(P, run(inner, front), z1)) : ""
};
}
var rings = (x0, y0, x1, y1, r, b) => [
rrect(x0, y0, x1, y1, r),
rrect(x0 + b, y0 + b, x1 - b, y1 - b, Math.max(0.3, r - b))
];
function extremes(P, ring) {
const pr = ring.map((q) => P(q.u, q.v, 0));
let a = 0, b = 0, c = 0;
pr.forEach((p, k) => {
if (p[0] < pr[a][0]) a = k;
if (p[0] > pr[b][0]) b = k;
if (p[1] > pr[c][1]) c = k;
});
return [ring[a], ring[b], ring[c]];
}
function fillet(pts, rs, n = 4) {
const m = pts.length, out = [];
for (let i = 0; i < m; i++) {
const a = pts[(i + m - 1) % m], p = pts[i], b = pts[(i + 1) % m];
const la = Math.hypot(a[0] - p[0], a[1] - p[1]), lb = Math.hypot(b[0] - p[0], b[1] - p[1]);
const t = Math.min(rs[i], la / 2, lb / 2);
const p1 = [p[0] + (a[0] - p[0]) / la * t, p[1] + (a[1] - p[1]) / la * t];
const p2 = [p[0] + (b[0] - p[0]) / lb * t, p[1] + (b[1] - p[1]) / lb * t];
for (let k = 0; k <= n; k++) {
const s = k / n, w = 1 - s;
out.push([w * w * p1[0] + 2 * w * s * p[0] + s * s * p2[0], w * w * p1[1] + 2 * w * s * p[1] + s * s * p2[1]]);
}
}
return out;
}
function ghost(P, front, ring, z0, depth) {
const f = run(ring, front), lowP = ringAt(P, f, z0 - depth);
return {
d: open(lowP) + [f[0], f[f.length - 1]].map((q) => seg(P(q.u, q.v, z0), P(q.u, q.v, z0 - depth))).join(""),
y0: Math.min(...ringAt(P, f, z0).map((p) => p[1])),
y1: Math.max(...lowP.map((p) => p[1])) + 2
};
}
// packages/hairline/src/core/motion.ts
var reduced = false;
var setReducedMotion = (on) => {
reduced = on;
};
var reducedMotion = () => reduced;
function spring(x, o = {}) {
return { x, v: 0, t: x, k: o.k ?? 100, c: o.c ?? 18, m: o.m ?? 1, eps: o.eps ?? 0.01 };
}
function stepS(sp, dt) {
if (reduced) {
sp.x = sp.t;
sp.v = 0;
return false;
}
const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
for (let i = 0; i < n; i++) {
const a = (-sp.k * (sp.x - sp.t) - sp.c * sp.v) / sp.m;
sp.v += a * h;
sp.x += sp.v * h;
}
if (Math.abs(sp.x - sp.t) < sp.eps && Math.abs(sp.v) < sp.eps * 10) {
sp.x = sp.t;
sp.v = 0;
return false;
}
return true;
}
function bezier(x1, y1, x2, y2) {
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
const X = (u) => ((ax * u + bx) * u + cx) * u;
const Y = (u) => ((ay * u + by) * u + cy) * u;
const dX = (u) => (3 * ax * u + 2 * bx) * u + cx;
return (t) => {
if (t <= 0) return 0;
if (t >= 1) return 1;
let u = t;
for (let i = 0; i < 8; i++) {
const e = X(u) - t;
if (Math.abs(e) < 1e-5) break;
const d = dX(u);
if (Math.abs(d) < 1e-6) break;
u -= e / d;
}
if (!(u >= 0 && u <= 1) || Math.abs(X(u) - t) > 1e-4) {
let lo = 0, hi = 1;
u = t;
for (let i = 0; i < 24; i++) {
if (X(u) < t) lo = u;
else hi = u;
u = (lo + hi) / 2;
}
}
return Y(u);
};
}
var EASE_LIFT = bezier(0.32, 0.72, 0, 1);
var tween = (v, dur = 700) => ({ from: v, to: v, t0: -1e9, dur });
var tval = (tw, now) => {
const p = clamp((now - tw.t0) / tw.dur, 0, 1);
return tw.from + (tw.to - tw.from) * (reduced ? 1 : EASE_LIFT(p));
};
var tset = (tw, to, now, delay) => {
if (tw.to === to) return;
tw.from = tval(tw, now);
tw.to = to;
tw.t0 = now + delay;
};
var tdone = (tw, now) => reduced || now >= tw.t0 + tw.dur;
// packages/hairline/src/core/stage.ts
var NS = "http://www.w3.org/2000/svg";
function mk(tag, attrs, parent) {
const e = document.createElementNS(NS, tag);
if (attrs) for (const k in attrs) e.setAttribute(k, String(attrs[k]));
if (parent) parent.appendChild(e);
return e;
}
function solid(parent) {
const g = mk("g", {}, parent);
return { g, sil: mk("path", { class: "sil" }, g), cr: mk("path", { class: "nf lo" }, g) };
}
var put = (el, s) => {
el.sil.setAttribute("d", s.sil);
el.cr.setAttribute("d", s.crease);
};
var flatDot = (parent, C, r, cls) => mk("ellipse", { rx: r2(r * C.S), ry: r2(r * C.S * C.k), class: cls }, parent);
var place = (el, q) => {
el.setAttribute("cx", String(r2(q[0])));
el.setAttribute("cy", String(r2(q[1])));
};
var fid = 0;
function fade(svg, y0, y1, a0 = 0.7) {
const id = "hl-fd" + ++fid, defs = mk("defs", {}, svg);
const lg = mk("linearGradient", { id: id + "g", gradientUnits: "userSpaceOnUse", x1: 0, y1: r2(y0), x2: 0, y2: r2(y1) }, defs);
mk("stop", { offset: 0, "stop-color": "#fff", "stop-opacity": a0 }, lg);
mk("stop", { offset: 1, "stop-color": "#fff", "stop-opacity": 0 }, lg);
const m = mk("mask", { id, maskUnits: "userSpaceOnUse", x: 0, y: 0, width: 400, height: 320 }, defs);
mk("rect", { x: 0, y: 0, width: 400, height: 320, fill: `url(#${id}g)` }, m);
return `url(#${id})`;
}
function reflect(svg, parent, P, front, ring, z0, depth) {
const r = ghost(P, front, ring, z0, depth);
const gh = mk("g", { class: "ghost", mask: fade(svg, r.y0, r.y1) }, parent);
mk("path", { d: r.d }, gh);
}
var boards = [];
var byStage = /* @__PURE__ */ new Map();
var raf = 0;
var last = 0;
var io = null;
var rm = null;
function frame(now) {
const dt = Math.min(0.05, Math.max(0, (now - last) / 1e3));
last = now;
let any = false;
for (const b of boards.slice()) if (b.vis && b.awake) {
try {
b.awake = !!b.tick(dt, now);
} catch (err) {
b.awake = false;
setTimeout(() => {
throw err;
});
}
any = any || b.awake;
}
raf = any ? requestAnimationFrame(frame) : 0;
}
function wake(b) {
b.awake = true;
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(frame);
}
}
var onMotion = () => {
setReducedMotion(!!rm?.matches);
boards.forEach(wake);
};
function start() {
if (io) return;
io = new IntersectionObserver((es) => {
for (const e of es) {
const set = byStage.get(e.target);
if (!set) continue;
for (const b of set) {
b.vis = e.isIntersecting;
if (b.vis) wake(b);
}
}
}, { rootMargin: "80px" });
rm = matchMedia("(prefers-reduced-motion: reduce)");
setReducedMotion(rm.matches);
rm.addEventListener("change", onMotion);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
io?.disconnect();
io = null;
rm?.removeEventListener("change", onMotion);
rm = null;
}
function register(stage, tick) {
start();
const b = { stage, tick, vis: false, awake: true };
boards.push(b);
const peers = byStage.get(stage);
if (peers) {
for (const p of peers) b.vis = p.vis;
peers.add(b);
if (b.vis) wake(b);
} else {
byStage.set(stage, /* @__PURE__ */ new Set([b]));
io.observe(stage);
}
tick(0, performance.now());
let gone = false;
return {
wake: () => {
if (!gone) wake(b);
},
unregister: () => {
if (gone) return;
gone = true;
boards = boards.filter((x) => x !== b);
const set = byStage.get(stage);
if (set) {
set.delete(b);
if (!set.size) {
byStage.delete(stage);
io?.unobserve(stage);
}
}
if (!boards.length) stop();
}
};
}
var handlers = /* @__PURE__ */ new WeakMap();
var touring = /* @__PURE__ */ new WeakMap();
function pointer(stage, on) {
handlers.set(stage, on);
let tm = 0;
const pt = (e) => {
const r = stage.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * 400, (e.clientY - r.top) / r.height * 320];
};
const move = (e) => {
clearTimeout(tm);
touring.get(stage)?.hold();
on.move(pt(e), e);
};
const down = (e) => {
clearTimeout(tm);
touring.get(stage)?.hold();
if (e.pointerType !== "mouse") stage.releasePointerCapture?.(e.pointerId);
if (on.down) on.down(pt(e), e);
else on.move(pt(e), e);
};
const leave = (e) => {
clearTimeout(tm);
tm = window.setTimeout(() => {
on.leave(e);
touring.get(stage)?.release();
}, e.pointerType === "mouse" ? 0 : 1400);
};
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerdown", down);
stage.addEventListener("pointerleave", leave);
return () => {
clearTimeout(tm);
if (handlers.get(stage) === on) handlers.delete(stage);
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerdown", down);
stage.removeEventListener("pointerleave", leave);
};
}
var LAP = [[128, 150], [200, 118], [272, 150], [200, 206], null];
var TRAVEL = 900;
var DWELL = 1200;
var REST = 1800;
var RESUME = 1200;
var STAGGER = 450;
var GHOST = { pointerType: "ghost" };
var started = 0;
function entry([x, y]) {
const dx = x - 200, dy = y - 160;
if (!dx && !dy) return [200, 320];
const k = Math.min(dx ? (dx > 0 ? 200 : -200) / dx : Infinity, dy ? (dy > 0 ? 160 : -160) / dy : Infinity);
return [200 + dx * k, 160 + dy * k];
}
function tour(stage, stops, onStop) {
let i = 0;
let wait = RESUME + STAGGER * (started++ % 4);
let t = -1;
let at = null;
let origin = [200, 320];
let hand = false, keys = stage.contains(stage.ownerDocument.activeElement);
let gone = false;
const leave = () => {
at = null;
t = -1;
handlers.get(stage)?.leave(GHOST);
};
const tick = (dt) => {
if (hand || keys || !stops.length) return false;
if (reducedMotion()) {
if (at) leave();
return false;
}
if (wait > 0) {
wait -= dt * 1e3;
return true;
}
const stop2 = stops[i];
if (stop2 === null) {
if (at) leave();
onStop?.(i);
i = (i + 1) % stops.length;
wait = REST;
return true;
}
const h = handlers.get(stage);
if (!h) return true;
if (t < 0) {
origin = at ?? entry(stop2);
t = 0;
}
t = Math.min(TRAVEL, t + dt * 1e3);
const k = EASE_LIFT(t / TRAVEL);
at = [origin[0] + (stop2[0] - origin[0]) * k, origin[1] + (stop2[1] - origin[1]) * k];
h.move(at, GHOST);
if (t < TRAVEL) return true;
onStop?.(i);
i = (i + 1) % stops.length;
t = -1;
wait = DWELL;
return true;
};
const take = () => {
at = null;
t = -1;
};
const give = () => {
if (hand || keys) return;
i = 0;
t = -1;
wait = RESUME;
board.wake();
};
const me = {
hold: () => {
hand = true;
take();
},
release: () => {
if (!hand) return;
hand = false;
give();
}
};
const focusIn = () => {
keys = true;
take();
};
const focusOut = (e) => {
if (keys && !stage.contains(e.relatedTarget)) {
keys = false;
give();
}
};
stage.addEventListener("focusin", focusIn);
stage.addEventListener("focusout", focusOut);
touring.set(stage, me);
const board = register(stage, tick);
return {
stop: () => {
if (gone) return;
gone = true;
board.unregister();
stage.removeEventListener("focusin", focusIn);
stage.removeEventListener("focusout", focusOut);
if (touring.get(stage) === me) touring.delete(stage);
if (at) leave();
}
};
}
function disposer() {
let fns = [];
return {
add: (fn) => {
fns.push(fn);
},
on: (target, type, fn, opts) => {
const h = fn;
target.addEventListener(type, h, opts);
fns.push(() => target.removeEventListener(type, h, opts));
},
dispose: () => {
const run2 = fns;
fns = [];
for (let i = run2.length - 1; i >= 0; i--) run2[i]();
}
};
}
// packages/hairline/src/core/styles.ts
var LIGHT = { plate: "#ffffff", hi: "#232327", edge: "#a4a4ac", mid: "#c3c3c9", lo: "#e0e0e4" };
var DARK = { plate: "#08090a", hi: "#d0d6e0", edge: "#5b5d64", mid: "#3e3e44", lo: "#29292d" };
var KEYS = ["plate", "hi", "edge", "mid", "lo"];
var vars = (p) => KEYS.map((k) => `--hl-${k}:var(--hairline-${k},${p[k]});`).join("");
var EASE = "cubic-bezier(0.5,0,0.1,1)";
var SVG = ":where([data-hairline]>svg)";
function css(lightDark) {
const both = Object.fromEntries(KEYS.map((k) => [k, `light-dark(${LIGHT[k]},${DARK[k]})`]));
return [
// the box, and the palette: light unless something below says otherwise
`:where([data-hairline]){display:block;position:relative;aspect-ratio:5/4;touch-action:pan-y;user-select:none;-webkit-user-select:none;--hl-sw:var(--hairline-stroke,0.9);${vars(LIGHT)}}`,
// the page's color-scheme
lightDark ? `:where([data-hairline]){${vars(both)}}` : "",
// an ancestor that says dark
`:where(.dark,[data-theme="dark"]) :where([data-hairline]){${vars(DARK)}}`,
// the figure's own theme option
`:where([data-hairline][data-hairline-theme="light"]){${vars(LIGHT)}}`,
`:where([data-hairline][data-hairline-theme="dark"]){${vars(DARK)}}`,
`:where([data-hairline]:focus-visible){outline:1.5px solid var(--hl-hi);outline-offset:2px}`,
`${SVG}{position:absolute;inset:0;width:100%;height:100%;display:block}`,
// Riffle's live region: read, not seen
`:where([data-hairline]>[data-hairline-live]){position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`,
// the drawing: plates are filled with the plate colour and painted back to front
`${SVG} :where(path,polygon,ellipse,line){fill:var(--hl-plate);stroke:var(--hl-mid);stroke-width:var(--hl-sw);vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round;transition:stroke 260ms ${EASE}}`,
`${SVG} :where(.nf){fill:none}`,
`${SVG} :where(.fo){stroke:none}`,
`${SVG} :where(.sil){stroke:var(--hl-edge)}`,
`${SVG} :where(.hi){stroke:var(--hl-hi)}`,
`${SVG} :where(.lo){stroke:var(--hl-lo)}`,
`${SVG} :where(.dash){stroke-dasharray:1 3}`,
`${SVG} :where(.dot){stroke:none;fill:var(--hl-hi);transition:fill 260ms ${EASE}}`,
`${SVG} :where(.dot.m){fill:var(--hl-edge)}`,
`${SVG} :where(.dot.off){fill:var(--hl-lo)}`,
`${SVG} :where(.ghost path){fill:none;stroke:var(--hl-mid)}`
].join("");
}
var done = /* @__PURE__ */ new WeakSet();
function inject(root) {
if (done.has(root)) return;
done.add(root);
const doc = root.nodeType === 9 ? root : root.ownerDocument;
const win = doc.defaultView;
const text = css(!!win?.CSS?.supports?.("color", "light-dark(#000,#fff)"));
if (win && "adoptedStyleSheets" in root) {
try {
const sheet = new win.CSSStyleSheet();
sheet.replaceSync(text);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
return;
} catch {
}
}
const style = doc.createElement("style");
style.setAttribute("data-hairline-style", "");
style.textContent = text;
(root.nodeType === 9 ? doc.head ?? doc.documentElement : root).appendChild(style);
}
return __toCommonJS(kernel_exports);
})();
/* /hairline kernel */
look.md›
# The look
The validator reads text. This is what only eyes can check. Do it every time the figure changes.
## One command
From the person's working directory:
`node <skill folder>/look.mjs <name>.js --answer x,y,z --edge x,y,z`
It builds `hairline-<name>.html` and validates it, as `build.mjs` and `validate.mjs` do. If the validator rejects the page, it prints the lines to fix and stops before opening a browser. Otherwise it opens the eight pictures of the table below at once, in one browser, takes each once its drawing holds still, and writes them, labelled, on one sheet: `hairline-<name>-look.png`. Then it prints one line for each of items 9, 8, 12 and 4, and the sheet's path. It exits 1 when the validator rejects the page or item 9, 8 or 12 fails, and 0 otherwise. Read every line it prints, then read the sheet and answer the rest.
- `--answer x,y,z` is a world point on the part that should answer the pointer: a point on the part's top in its rest pose. `look.mjs` runs it through the `P` your figure made with its own camera and prints the `?at=` point it got. For the riffle example, the top edge of card 05 at rest: `--answer 42,28,52` prints `answer 42,28,52 -> at=220,120`, and the answering pictures read `05`.
- `--edge x,y,z` is a world point at the figure's edge, for the slider's two ends (item 9). Give it twice for a point per end: the first is taken at intensity 0, the second at 1. Without it, the ends hold the `--answer` point.
- Either takes a viewBox point `x,y` instead, used as it is.
- Without `--answer`, the answering pictures are taken at rest, and it says so. That is not a finished look.
- `--zoom <shot>` also writes one picture's stage at three times the pixels, as `hairline-<name>-<shot>.png`, for what is too small on the sheet: a crease, a far edge, a dot. The shots are `rest`, `answer`, `small`, `small-answer`, `low`, `high`, `dark` and `light`, in the table's order.
- In place of `<name>.js` it takes a page already built, `hairline-<name>.html`.
A change that moves the fit or the scale moves every `?at=` point with it. The world points stay where they are: give the same ones again and the new `?at=` points come out.
The first run installs `playwright-core` once, into a cache folder of yours (`~/Library/Caches/hairline-look` on macOS, `%LOCALAPPDATA%\hairline-look` on Windows, `~/.cache/hairline-look` elsewhere; `HAIRLINE_LOOK_CACHE` moves it), and never into the skill or the working directory. It drives your Chrome, or Playwright's Chromium when there is no Chrome. With neither, it prints the one command that installs Chromium and exits 2. It exits 2 as well when it cannot install; then do the look as "Without a browser" says.
## The pictures
These are the eight `look.mjs` takes. Four parameters in the address make them, and they work in any browser, for a look by hand; join two with `&`:
- `?w=240` narrows the page to 240px, the size of a thumbnail.
- `?at=x,y` holds the pointer at a point of the 400 × 320 viewBox, for tools that cannot hover. x runs to the right and y down, from the viewBox's top-left corner. Take the point from your own figure: the screen point `P(x, y, z)` of the part you want answered, rounded. `P` exists only inside the page; `look.mjs` prints it for you.
- `?intensity=` sets the slider, from 0 to 1, before the figure mounts.
- `?theme=light` or `?theme=dark` sets the theme, as pressing its button does.
| Shot | Picture | Address |
| --- | --- | --- |
| `rest` | full size, at rest | `hairline-<name>.html` |
| `answer` | full size, answering | `hairline-<name>.html?at=<x>,<y>` |
| `small` | 240px, at rest | `hairline-<name>.html?w=240` |
| `small-answer` | 240px, answering | `hairline-<name>.html?w=240&at=<x>,<y>` |
| `low` | slider at 0, pointer at the figure's edge (item 9) | `hairline-<name>.html?intensity=0&at=<x>,<y>` |
| `high` | slider at 1, pointer at the figure's edge (item 9) | `hairline-<name>.html?intensity=1&at=<x>,<y>` |
| `dark` | dark theme, answering (item 10) | `hairline-<name>.html?theme=dark&at=<x>,<y>` |
| `light` | light theme, answering (item 10) | `hairline-<name>.html?theme=light&at=<x>,<y>` |
By hand, keep the window at least 800 × 900 for every picture. `?w=240` narrows the page, not the window. A headless Chrome window narrower than 500px still lays the page out 500px wide and keeps only its left part, so the small picture comes out cropped. And wait 1.5 seconds after loading before each picture: strokes fade over 260ms, tweens take 700ms and springs about a second, so a picture taken sooner catches the figure mid-way.
On the sheet, `rest` and `answer` are large; `small` and `small-answer` are at their own size, 240px, beside `dark` and `light`; `low` and `high` close it. Each picture keeps the plate, the controls and the line under the stage, and its label holds the address and the read-out.
## What to see
Answer each with yes or no. A no is fixed in the figure before anything is handed over. `look.mjs` answers 8, 9 and 12, and measures part of 4; the rest are answered from the sheet. Zoom when a line is too small to judge.
1. **The silhouette reads at 240px.** You can say what the object is from the small picture alone. You know what you drew, so do not ask yourself whether it looks like it: for each kind of part, point in the `small` picture to the features you named for it in the concept (`concepts.md`, step 2). A part that shows none of them there is a no, however it looks at full size.
2. **Rest is a composition** (rule 05). Not flat, not empty, not a regular grid; something is bright where the eye should start.
3. **The answer falls off with distance, or spreads out from the pointer** (rules 02 and 03). It is not everything at once, and not one part alone.
4. **Nothing flickers** (rule 01). With the pointer held on a part that moves when touched, the drawing comes to rest. `look.mjs` waits 1.5 seconds after each page loads, then until its drawing holds still for a quarter of a second, for 5 seconds at most. `still` is a yes, and says how long the slowest took. `moving` names the pictures that never held still: answering pictures moving while rest held still is a flicker loop; every picture moving is an ambient figure, or a loop that never ends. A machine too busy to keep up says `moving` too, so run it again before you believe it. `at` moves the pointer once, so also read the hit test: it picks from the rest pose or the target, never from the pose on screen.
5. **Bright outside, dim inside** (rule 09). Every solid is a silhouette and one crease; no vertical corner is drawn; no corner is sharp.
6. **Nothing shows through** (rule 06). No far edge crosses a near solid; no guide crosses its own plate.
7. **One highlight** (rule 04). At rest, one bright mark says where the eye should start. When the pointer chooses, the bright goes to what it chose and the rest mark gives it up. One highlight may cover the parts of one thing, a tray's rim and its beads, but it marks one place and means one thing. It is a stroke or a dot, never a fill.
8. **The read-out names what is under the pointer**, in a few characters, and says `rest` at rest. `look.mjs` prints every picture's read-out, and fails when the rest picture's is not `rest`. A warning that an answering picture still says `rest` means the `--answer` point misses the part, or the hit test does. Whether the name is right is for you to say.
9. **Nothing leaves the frame.** With the slider at each end (`?intensity=0`, `?intensity=1`) and the pointer at the figure's edges, every part stays inside the plate. `look.mjs` fails when the drawing's box leaves the 400 × 320 viewBox in any picture. The box is the geometry: a stroke reaches half its width past it, so a line on the frame's edge is still a no. It also says how much of the frame the rest pose covers, and warns below a quarter; the examples cover 36%.
10. **Both themes.** In `?theme=dark` and `?theme=light`, nothing vanishes and nothing is left the wrong colour.
11. **No words** (rule 10). Nothing in the drawing is a letter, a digit, an arrow or an icon. In a figure made from a mark, the mark itself and a glyph that is part of it are the exception, and so is a glyph built as a solid that is the figure's whole subject; anything else still fails.
12. **The page is clean.** No line under the stage reporting an error, and nothing on the console. `look.mjs` reads both, on every picture, and fails on any console error, console warning or page error.
13. **An empty state says something is missing.** Only for an empty state: in the smallest picture, at rest, with no read-out, can you say what the object would hold and that it holds none?
If you are unsure whether the figure's weight is right, build an example the same way and put the two side by side: `node <skill folder>/build.mjs <skill folder>/examples/terrain.js`. Or look at it: `node <skill folder>/look.mjs <skill folder>/examples/terrain.js --answer 30,90,0` writes its sheet beside yours.
## Without a browser
Answer the same questions from the code, each with the line that makes it true: the rest values for 2, the falloff or stagger for 3, the hit test for 4, the paint order for 6. Then say at hand-over, in these words, that the figure was **not looked at in a browser**. Do not skip the list and do not guess a yes.
look.mjs›
#!/usr/bin/env node
/**
* The look in one command, run from the person's working directory:
*
* node <skill folder>/look.mjs <name>.js --answer x,y,z [--edge x,y,z [--edge x,y,z]] [--zoom <shot>]
*
* It builds and validates the page as build.mjs and validate.mjs do, opens
* look.md's eight addresses at once in one browser, and writes the eight
* pictures, labelled, on one sheet: hairline-<name>-look.png. Beside it go the
* two 240px pictures with the name and the read-out hidden, for a reader told
* nothing, hairline-<name>-blind.png, and, when there is an answer point, the
* stage taken while the answer plays, rest to answer, hairline-<name>-motion.png.
* Then it prints what can be measured, one line each, under look.md's item
* numbers: 9 the frame, 8 the read-out, 12 the console, 4 the flicker, and 3
* how much of the thumbnail's ink the pointer moves. It exits 1 when the
* validator rejects the page or one of those fails, 2 when it cannot run, and
* 0 otherwise. Everything else is for eyes, on the three pictures.
*
* A point is a world point x,y,z, run through the P the figure made with its
* own camera, or a viewBox point x,y, taken as it is. --answer is on the part
* that should answer the pointer. --edge is at the figure's edge, for the two
* ends of the slider; given twice, the first is for intensity 0 and the second
* for 1. Without them, an `answer` or `edge` the figure gives in its
* hairline({ … }) call is used. --zoom writes one shot's stage at three times
* the pixels.
*
* The browser is Playwright's. playwright-core is installed once into a cache
* folder of the user's (HAIRLINE_LOOK_CACHE moves it), never into the skill or
* the working directory, and it drives the installed Chrome, or Playwright's
* own Chromium when there is no Chrome.
*/
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { assemble, nameOf } from "./build.mjs";
import { validate } from "./validate.mjs";
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const USAGE = "usage: node look.mjs <name>.js --answer x,y,z [--edge x,y,z [--edge x,y,z]] [--zoom <shot>]";
/** Each picture waits 1.5 seconds after its page loads, then until its drawing holds still for a quarter of a second, and no longer than 5 seconds from the load. */
const WAIT = 1500, STILL = 250, CAP = 5000;
/** A rest pose whose box covers less of the frame than this is reported as small. Both examples' rest boxes cover 36%: Terrain's 297 × 155, Riffle's 226 × 205. */
const SMALL = 0.25;
/** An answer that moves less of the thumbnail's ink than this is reported as faint. Terrain's answer moves 74%, Riffle's 139%; this is half the smaller. */
const FAINT = 0.37;
/** A pixel this far from the ground on any channel is ink: the dim stroke on a white plate stands 31 from it. */
const INKED = 24;
/** The motion strip: 16 frames of the stage 40ms apart, four to a row. The last, at 600ms, is most of the way through a 700ms tween; the sheet's answer picture is where it ends. */
const FRAMES = 16, EVERY = 40, ACROSS = 4;
/** The share of the rest picture's ink the answer moved: pixels inked in one and not the other, over the rest's ink. Each mask is a row of 0s and 1s. */
export function moved(rest, answer) {
let ink = 0, changed = 0;
for (let i = 0; i < rest.length; i++) { ink += rest[i]; if (rest[i] !== answer[i]) changed++; }
return ink ? changed / ink : 0;
}
/** When each frame of the motion strip is taken, in ms after the pointer arrives: the first at rest, as it arrives, the others every `every` ms after. */
export function beats(n = FRAMES, every = EVERY) {
return Array.from({ length: n }, (_, i) => i * every);
}
/* A picture's ink, read in the page: 1 where a pixel stands INKED or more from the ground, the colour 14px inside the picture's top left corner, on the plate and clear of the drawing. The outer 12px, where the plate's own outline runs, are left out. */
const INK = async ([b64, INKED]) => {
const img = new Image();
img.src = "data:image/png;base64," + b64;
await img.decode();
const c = Object.assign(document.createElement("canvas"), { width: img.width, height: img.height }), x = c.getContext("2d");
x.drawImage(img, 0, 0);
const d = x.getImageData(0, 0, c.width, c.height).data, at = (px, py) => 4 * (py * c.width + px), g = at(14, 14), mask = [];
for (let py = 12; py < c.height - 12; py++) for (let px = 12; px < c.width - 12; px++) {
const i = at(px, py);
mask.push(Math.max(Math.abs(d[i] - d[g]), Math.abs(d[i + 1] - d[g + 1]), Math.abs(d[i + 2] - d[g + 2])) >= INKED ? 1 : 0);
}
return mask;
};
/** look.md's eight pictures, in its order: the shot's name, what it adds to the address, and which point it holds. */
export const SHOTS = [
["rest", "", null],
["answer", "", "answer"],
["small", "w=240", null],
["small-answer", "w=240", "answer"],
["low", "intensity=0", "low"],
["high", "intensity=1", "high"],
["dark", "theme=dark", "answer"],
["light", "theme=light", "answer"],
];
/** The folder playwright-core is installed into: the user's cache folder, or HAIRLINE_LOOK_CACHE. */
export function cacheDir(env = process.env, platform = process.platform, home = homedir()) {
if (env.HAIRLINE_LOOK_CACHE) return resolve(env.HAIRLINE_LOOK_CACHE);
if (platform === "darwin") return join(home, "Library", "Caches", "hairline-look");
if (platform === "win32") return join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "hairline-look");
return join(env.XDG_CACHE_HOME || join(home, ".cache"), "hairline-look");
}
/** A point given on the command line: three numbers for the world, two for the viewBox, or null. */
export function point(s) {
const n = String(s ?? "").split(",").map((v) => (v.trim() === "" ? NaN : Number(v)));
return (n.length === 2 || n.length === 3) && n.every(Number.isFinite) ? n : null;
}
/** playwright-core from the cache, installed there first when it is not. Throws with a reason when it cannot be had. */
function playwright(dir) {
const req = createRequire(join(dir, "look.cjs"));
const has = () => { try { return req.resolve("playwright-core/package.json"); } catch { return null; } };
if (!has()) {
console.log(`installing playwright-core once, into ${dir}`);
mkdirSync(dir, { recursive: true });
try { writeFileSync(join(dir, "package.json"), '{ "private": true }\n', { flag: "wx" }); } catch { /* already there */ }
const win = process.platform === "win32";
const run = spawnSync(win ? "npm.cmd" : "npm", ["install", "playwright-core@1", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error"], { encoding: "utf8", shell: win });
const why = run.error?.code === "ENOENT" ? "npm is not on the PATH" : run.error?.message ?? /Error: (.*)$/m.exec(run.stderr)?.[1] ?? run.stderr.trim().split("\n")[0];
if (run.status !== 0 || !has()) throw new Error(why || "npm failed");
}
return { chromium: req("playwright-core").chromium, cli: join(dirname(has()), "cli.js") };
}
/*
* Keeps the P the figure makes from its own camera, so a world point can be
* turned into an ?at= point, and the `answer` and `edge` points a figure may
* give in its hairline({ … }) call. The bench is not touched: both are caught
* as the page sets them.
*/
const TRAP = () => {
let hl, call;
Object.defineProperty(window, "HL", { configurable: true, get: () => hl, set: (v) => { hl = { ...v, proj: (C) => (window.P = v.proj(C)) }; } });
Object.defineProperty(window, "hairline", {
configurable: true,
get: () => call,
set: (v) => {
call = (figure) => {
window.declared = { answer: figure.answer ?? null, edge: figure.edge ?? null };
return v(figure);
};
},
});
};
/**
* What the page shows after the wait: the drawing's box in viewBox units, the
* read-out, the line under the stage, and the part of the page a picture
* keeps, from the plate's top to the last line under it. The box is getBBox's,
* so it is geometry: a stroke reaches half its width past it.
*/
const STATE = () => {
const $ = (s) => document.querySelector(s), svg = $("#stage > svg"), err = $("#error");
const b = svg && svg.childElementCount ? svg.getBBox() : null;
const plate = $(".plate").getBoundingClientRect();
const bottom = Math.max(...["#means", "#error"].map((s) => $(s)).filter((el) => !el.hidden).map((el) => el.getBoundingClientRect().bottom));
return {
box: b && { x: b.x, y: b.y, w: b.width, h: b.height },
read: $("#read").textContent,
error: err.hidden ? "" : err.textContent,
clip: { x: Math.max(0, plate.left - 8), y: Math.max(0, plate.top - 8), width: plate.width + 16, height: bottom - plate.top + 16 },
stage: { x: plate.left, y: plate.top, width: plate.width, height: plate.height },
};
};
/* The pointer at a viewBox point x,y, put there as the bench's ?at= puts it: one pointermove on the stage. */
const POINT = (at) => {
const [x, y] = at.split(",").map(Number), stage = document.getElementById("stage"), r = stage.getBoundingClientRect();
stage.dispatchEvent(new PointerEvent("pointermove", {
pointerType: "mouse", pointerId: 1, bubbles: true,
clientX: r.left + (x / 400) * r.width, clientY: r.top + (y / 320) * r.height,
}));
};
/** A browser context with look.md's window: 800 × 900, so ?w=240 narrows the page and not the window. */
const window800 = (browser, scale = 1) => browser.newContext({ viewport: { width: 800, height: 900 }, deviceScaleFactor: scale, colorScheme: "light" });
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const drawing = (page) => page.evaluate(() => document.querySelector("#stage > svg")?.innerHTML ?? "");
/** Waits out the settle, then until the drawing holds still. Returns the seconds from the load to the still drawing, or null when it never held still. */
async function settle({ page, loaded }) {
await sleep(loaded + WAIT - Date.now());
for (let was = await drawing(page); Date.now() + STILL - loaded <= CAP;) {
await sleep(STILL);
const now = await drawing(page);
if (now === was) return (Date.now() - loaded) / 1000;
was = now;
}
return null;
}
/**
* Opens a page and collects what it says on the console. The pages of one
* context are each laid out in a viewport of their own, so every figure's
* IntersectionObserver finds it on screen and its loop runs.
*/
async function open(context, url, trap = false) {
const page = await context.newPage(), bad = [];
page.on("console", (m) => { if (m.type() === "error" || m.type() === "warning") bad.push(`console ${m.type()}: ${m.text()}`); });
page.on("pageerror", (e) => bad.push(`page error: ${e.message}`));
if (trap) await page.addInitScript(TRAP);
await page.goto(url);
return { page, bad, loaded: Date.now() };
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
/** The sheet: rest and answering large, the two thumbnails at their own size beside the themes, then the slider's two ends. */
function sheet(name, shots) {
const cell = (s, cls = "") => `<figure class="${cls}"><figcaption>${esc(s.label)}</figcaption><img src="data:image/png;base64,${s.png}"></figure>`;
const by = Object.fromEntries(shots.map((s) => [s.name, s]));
return `<!doctype html><meta charset="utf-8"><style>
body { margin: 0; padding: 16px; width: 1400px; box-sizing: border-box; background: #ececef; color: #18181b; font: 13px/1.3 ui-monospace, Menlo, Consolas, monospace; }
h1 { font: inherit; margin: 0 0 10px; color: #55555c; }
.row { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
figure { margin: 0; flex: 1 1 0; min-width: 0; }
figure.own { flex: 0 0 272px; }
figcaption { margin: 0 0 6px; overflow-wrap: anywhere; }
img { display: block; width: 100%; outline: 1px solid #c9c9ce; }
.own img { width: auto; }
</style><h1>hairline-${esc(name)} · the look</h1>
<div class="row">${cell(by.rest)}${cell(by.answer)}</div>
<div class="row">${cell(by.small, "own")}${cell(by["small-answer"], "own")}${cell(by.dark)}${cell(by.light)}</div>
<div class="row">${cell(by.low)}${cell(by.high)}</div>`;
}
/** The motion strip: the stage's frames, ACROSS to a row at their own size, each under its time since the pointer arrived. */
function strip(name, at, frames) {
return `<!doctype html><meta charset="utf-8"><style>
body { margin: 0; padding: 16px; width: max-content; background: #ececef; color: #18181b; font: 13px/1.3 ui-monospace, Menlo, Consolas, monospace; }
h1 { font: inherit; margin: 0 0 10px; color: #55555c; }
.grid { display: grid; grid-template-columns: repeat(${ACROSS}, auto); gap: 12px; }
figure { margin: 0; }
figcaption { margin: 0 0 6px; }
img { display: block; outline: 1px solid #c9c9ce; }
</style><h1>hairline-${esc(name)} · the answer while it plays, the pointer to at=${esc(at)} after the first picture</h1>
<div class="grid">${frames.map((f) => `<figure><figcaption>${esc(f.label)}</figcaption><img src="data:image/png;base64,${f.png}"></figure>`).join("")}</div>`;
}
/** Whether a box leaves the 400 × 320 viewBox. */
const outside = (b) => b.x < 0 || b.y < 0 || b.x + b.w > 400 || b.y + b.h > 320;
const n0 = (v) => Math.round(v);
/** The look of one figure (a .js, built here, or a page build.mjs made). Prints as it goes and returns the exit code. */
export async function look(src, given0 = {}, cwd = process.cwd()) {
let { answer = null, edge = [] } = given0;
const { zoom = null } = given0;
if (zoom && !SHOTS.some(([s]) => s === zoom)) { console.error(`look: no shot "${zoom}". The shots: ${SHOTS.map(([s]) => s).join(", ")}.`); return 2; }
// 1. the page, built and validated
let file, page;
try { page = readFileSync(src, "utf8"); } catch { console.error(`look: cannot read ${src}`); return 2; }
if (src.endsWith(".html")) file = resolve(src);
else {
file = join(cwd, `hairline-${nameOf(page) ?? "figure"}.html`);
page = assemble(page);
writeFileSync(file, page);
}
const name = /^hairline-(.+)\.html$/.exec(basename(file))?.[1] ?? basename(file, ".html");
const problems = validate(page);
if (problems.length) {
for (const p of problems) console.log(p);
console.log(`${problems.length} to fix in ${file}. Fix the figure and run this again; no browser was opened.`);
return 1;
}
console.log(`${src.endsWith(".html") ? "read" : "built"} ${file}, and the validator passes it`);
// the browser, from the cache
const dir = cacheDir();
let pw;
try { pw = playwright(dir); } catch (e) {
console.error(`look: could not install playwright-core into ${dir}: ${e.message}`);
console.error('Check the network and run this again, or look without a browser: look.md, "Without a browser".');
return 2;
}
const browser = await pw.chromium.launch({ channel: "chrome" }).catch(() => pw.chromium.launch()).catch(() => null);
if (!browser) {
console.error(`look: no Chrome, and no Chromium of Playwright's. Install one once:\n node "${pw.cli}" install chromium`);
console.error('Then run this again. Without a browser, look.md says what to do: "Without a browser".');
return 2;
}
try {
const url = pathToFileURL(file).href, address = (q) => (q ? `${url}?${q}` : url);
// 2. the points, through the figure's own P, from the rest page
const context = await window800(browser);
const rest = await open(context, url, true);
// the points the figure gives in its hairline({ … }) call, if any; a point on the command line wins over them
const decl = (await rest.page.evaluate(() => window.declared ?? null)) ?? { answer: null, edge: null }, theirs = new Set();
const isPoint = (p) => Array.isArray(p) && (p.length === 2 || p.length === 3) && p.every((v) => typeof v === "number" && Number.isFinite(v));
if (!answer && decl.answer) {
if (isPoint(decl.answer)) theirs.add((answer = decl.answer));
else console.log(`the figure's hairline call gives answer ${[decl.answer].flat().join(",")}, which is not a point: two numbers for the viewBox, three for the world. Give --answer x,y,z instead.`);
}
if (!edge.length && decl.edge) {
const list = Array.isArray(decl.edge[0]) ? decl.edge : [decl.edge];
if (list.length <= 2 && list.every(isPoint)) for (const p of (edge = list)) theirs.add(p);
else console.log(`the figure's hairline call gives edge ${JSON.stringify(decl.edge)}, which is not one point or two. Give --edge x,y,z instead.`);
}
const given = { answer, low: edge[0] ?? answer, high: edge[1] ?? edge[0] ?? answer }, at = {}, said = [];
for (const [key, p] of Object.entries({ answer, edge0: edge[0], edge1: edge[1] })) {
if (!p) continue;
const v = p.length === 2 ? p : await rest.page.evaluate((q) => (window.P ? window.P(...q).map(Math.round) : null), p);
if (!v) { said.push(`${p} -> no P: the figure stopped before it called HL.proj`); continue; }
for (const use of Object.keys(given)) if (given[use] === p) at[use] = v.join(",");
said.push(`${key === "answer" ? "answer" : "edge"} ${p}${theirs.has(p) ? " (from the figure's hairline call)" : ""} -> at=${v.join(",")}`);
}
if (said.length) console.log(said.join(" · "));
if (!answer) console.log("no --answer: the answering shots are taken at rest. Give --answer x,y,z, a world point on the part that should answer.");
else if (!edge.length) console.log(`no --edge: the slider's two ends are taken with the pointer at the ${theirs.has(answer) ? "answer" : "--answer"} point.`);
// 3. the other seven, and the zoom, opened at once; each waits from its own load, so the waits overlap
const queryOf = (q, use) => [q, use && at[use] && `at=${at[use]}`].filter(Boolean).join("&");
const taking = SHOTS.map(async ([shot, q, use]) => {
const query = queryOf(q, use);
const s = { name: shot, query, answering: !!(use && at[use]), ...(shot === "rest" ? rest : await open(context, address(query))) };
s.held = await settle(s);
Object.assign(s, await s.page.evaluate(STATE));
s.png = (await s.page.screenshot({ clip: s.clip })).toString("base64");
return s;
});
const zooming = zoom && (async () => {
const [, q, use] = SHOTS.find(([s]) => s === zoom), z = await open(await window800(browser, 3), address(queryOf(q, use)));
await settle(z);
const png = join(cwd, `hairline-${name}-${zoom}.png`);
await z.page.screenshot({ path: png, clip: (await z.page.evaluate(STATE)).stage });
return png;
})();
const shots = await Promise.all(taking), zoomOut = zoom ? await zooming : null;
// 4. the sheet, composed in the browser
for (const s of shots) s.label = `${s.name} ${s.query ? "?" + s.query : "(plain)"} read: ${s.read}${s.box && outside(s.box) ? " OUT OF FRAME" : ""}`;
const out = join(cwd, `hairline-${name}-look.png`);
const board = await (await browser.newContext({ viewport: { width: 1400, height: 800 } })).newPage();
await board.setContent(sheet(name, shots));
await board.evaluate(() => Promise.all([...document.images].map((i) => i.decode())));
await board.screenshot({ path: out, fullPage: true });
// the blind pair: the two thumbnails' stages with the name and the read-out hidden, for a reader who was told nothing
const unnamed = async (s) => {
await s.page.addStyleTag({ content: "#name, #read { visibility: hidden }" });
return (await s.page.screenshot({ clip: s.stage })).toString("base64");
};
const small = (n) => shots.find((s) => s.name === n);
const blind = [await unnamed(small("small")), await unnamed(small("small-answer"))];
const blindOut = join(cwd, `hairline-${name}-blind.png`);
const pair = await (await browser.newContext({ viewport: { width: 600, height: 300 } })).newPage();
await pair.setContent(`<!doctype html><meta charset="utf-8"><style>body { margin: 0; padding: 12px; display: flex; gap: 12px; align-items: flex-start; width: max-content; background: #ececef; } img { display: block; outline: 1px solid #c9c9ce; }</style>${blind.map((b) => `<img src="data:image/png;base64,${b}">`).join("")}`);
await pair.evaluate(() => Promise.all([...document.images].map((i) => i.decode())));
await pair.locator("body").screenshot({ path: blindOut });
const share = moved(await pair.evaluate(INK, [blind[0], INKED]), await pair.evaluate(INK, [blind[1], INKED]));
// the motion strip: the rest page, held still, given the pointer at the answer point as ?at= gives it, and taken while it plays
const motion = [];
let motionOut = null;
if (at.answer) {
const stage = shots[0], snap = async () => (await stage.page.screenshot({ clip: stage.stage })).toString("base64");
motion.push({ label: "rest", png: await snap() });
await stage.page.evaluate(POINT, at.answer);
const t0 = Date.now();
for (const t of beats().slice(1)) {
await sleep(t0 + t - Date.now());
const ms = Date.now() - t0;
motion.push({ label: `${ms}ms`, png: await snap() });
}
motionOut = join(cwd, `hairline-${name}-motion.png`);
const film = await (await browser.newContext({ viewport: { width: 1400, height: 800 } })).newPage();
await film.setContent(strip(name, at.answer, motion));
await film.evaluate(() => Promise.all([...document.images].map((i) => i.decode())));
await film.screenshot({ path: motionOut, fullPage: true });
}
// 5. what can be measured
let failed = false;
const fail = (line) => { failed = true; console.log(line); };
const away = shots.filter((s) => s.box && outside(s.box));
const box = shots[0].box;
const fills = box && `Rest fills ${n0(box.w)} × ${n0(box.h)}, ${n0((100 * box.w * box.h) / 128000)}% of it`;
if (!box) fail("9 frame: fail. The rest shot's svg is empty: the figure drew nothing.");
else if (away.length) {
const x0 = Math.min(...away.map((s) => s.box.x)), x1 = Math.max(...away.map((s) => s.box.x + s.box.w));
const y0 = Math.min(...away.map((s) => s.box.y)), y1 = Math.max(...away.map((s) => s.box.y + s.box.h));
fail(`9 frame: fail. ${away.length === shots.length ? "Every shot" : away.map((s) => s.name).join(", ")} leaves the 400 × 320 viewBox: the drawing reaches x ${n0(x0)} to ${n0(x1)} and y ${n0(y0)} to ${n0(y1)}.`);
} else {
const small = (box.w * box.h) / 128000 < SMALL;
console.log(`9 frame: ${small ? "warn" : "ok"}. Every shot stays inside the 400 × 320 viewBox. ${fills}${small ? `; the examples' cover 36%. Raise S, or say why the figure is small` : ""}.`);
}
const reads = shots.map((s) => `${s.name} "${s.read}"`).join(", ");
const still = shots.filter((s) => s.answering && ["answer", "small-answer", "dark", "light"].includes(s.name) && s.read === "rest");
if (shots[0].read !== "rest") fail(`8 read-out: fail. At rest it says "${shots[0].read}", not "rest". ${reads}.`);
else console.log(`8 read-out: ${still.length ? `warn. ${still.map((s) => s.name).join(", ")} still say "rest": the --answer point is not on the part, or the hit test misses it` : "ok"}. ${reads}.`);
// each complaint once, with the shots that made it
const noise = new Map();
for (const s of shots) for (const b of [...s.bad, ...(s.error ? [`error line under the stage: ${s.error}`] : [])]) noise.set(b, [...(noise.get(b) ?? []), s.name]);
const heard = [...noise].map(([b, where]) => `${b} (${where.length === shots.length ? "every shot" : where.join(", ")})`);
if (heard.length) fail(`12 console: fail. ${heard.slice(0, 4).join("; ")}${heard.length > 4 ? `; and ${heard.length - 4} more` : ""}.`);
else console.log("12 console: ok. No console error or warning, no page error, no error line under the stage.");
// a drawing that never held still: a flicker loop, an ambient figure, or a machine too busy to finish the motion in time
const restless = shots.filter((s) => s.held === null), slowest = Math.max(...shots.map((s) => s.held ?? 0));
console.log(`4 flicker: ${restless.length
? `moving. ${restless.length === shots.length ? "Every shot" : restless.map((s) => s.name).join(", ")} still moving ${CAP / 1000} seconds after loading${shots[0].held === null ? "" : ", while rest held still"}`
: `still. Every shot held still within ${slowest.toFixed(1)} seconds of loading`}. Information, not a check: look.md, item 4.`);
// how much of the thumbnail the answer moves: a warning, never a fail
if (!answer) console.log("3 answer: not measured. No --answer.");
else {
const faint = share < FAINT;
console.log(`3 answer: ${faint ? "warn" : "ok"}. At 240px the pointer moves ${n0(share * 100)}% of the ink, the pixels drawn in one small picture and not the other, over those drawn at rest${faint ? `; under ${n0(FAINT * 100)}% the answer is hard to see in a thumbnail. A change of colour moves no ink: make the answer move more, or say why a small one is right` : ""}.`);
}
console.log(`sheet ${out}`);
console.log(`blind ${blindOut} holds the small and small-answer pictures with the name and the read-out hidden, as someone who has not seen the figure would meet it: for item 1, look at it that way, or show it to such a reader.`);
if (motionOut) console.log(`motion ${motionOut} is the stage while the answer plays: ${motion.length} pictures, rest first, then each labelled with the ms since the pointer reached the answer point. The sheet shows only pictures that held still, so a part that folds, crosses another part, or jumps between two frames on the way is seen only here: look for one, and fix it in the figure.`);
if (zoomOut) console.log(`zoom ${zoomOut}`);
return failed ? 1 : 0;
} finally {
await browser.close();
}
}
/** Whether two resolved paths are one file. Windows paths ignore case, and the drive letter's case can differ between the two. */
export const same = (a, b, platform = process.platform) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
/* The skill is often installed as a symlink, so the path Node was given is resolved before it is compared. */
if (process.argv[1] && same(realpathSync(process.argv[1]), realpathSync(here("./look.mjs")))) {
let args;
try {
args = parseArgs({ allowPositionals: true, options: { answer: { type: "string" }, edge: { type: "string", multiple: true }, zoom: { type: "string" } } });
} catch (e) {
console.error(`${e.message}\n${USAGE}`);
process.exit(2);
}
const { positionals: [src], values } = args;
const answer = values.answer === undefined ? null : point(values.answer);
const edge = (values.edge ?? []).map(point);
if (!src || (values.answer !== undefined && !answer) || edge.some((p) => !p) || edge.length > 2) {
console.error(USAGE);
process.exit(2);
}
process.exit(await look(src, { answer, edge, zoom: values.zoom ?? null }));
}
rules.md›
# The ten rules
They come from the study behind the package (lucasmarkes.com/lab/hairline). Each has what it says, how to keep it with the kernel, and what gets a figure rejected.
## 01 · hit: hit areas don't move
Test the pointer against the rest pose, or the target pose, never the pose on screen. Otherwise the geometry lifts out from under the pointer, the hover drops, the geometry falls back, and the hover returns: a flicker loop.
- **Keep it:** `unproj(C, sx, sy, 0)` puts the pointer on the ground plane, which never moves; pick by world coordinates (Terrain). For items, test static bands along their resting edges (Riffle). When a choice depends on a moving value, read the spring's target (`.t`), not its position (`.x`).
- **Keep it, when parts stand at different heights or rise when chosen:** one fixed plane is not enough. The pointer over a raised top lands on that plane behind the part, and between parts at different heights it finds no part at all. Test each part's rest pose instead, as Riffle's bands do. For a single row, take the part whose rest centre, `P(…)[0]`, is nearest the pointer's screen x. For stepped parts, `unproj` onto each part's own rest top, `z` its height, and take the part that puts the pointer nearest its middle.
- **Rejected when:** the pointer held still on a moving edge makes the figure oscillate; picking reads a spring's current value; one fixed plane picks the part behind a raised one, or reads `rest` in the gap between parts at different heights; the figure measures the DOM (`getBoundingClientRect`, `elementFromPoint`, `:hover`) or adds its own pointer listeners.
## 02 · order: stagger by distance
`delay = |i − a| × step`. The motion spreads out from the pointer instead of running down a list.
- **Keep it:** `tset(tween, target, now, Math.abs(i - a) * step)`, with a step of 30–60ms so it reads as one gesture.
- **Rejected when:** items move in index order whatever was touched; all move at once when the concept is a spread; a step so long the last item starts after the first has landed and the gesture reads as a queue.
## 03 · reach: clamp the reach
The answer has a far end, and the figure is still composed there.
- **Keep it:** a falloff that reaches a floor (Terrain's is 1 → .31 at 42% of the radius → .09 beyond); `clamp` every lift, gap and lean; fit the camera to the most extreme pose (`fit` with the lifted points), so nothing leaves the 400 × 320 frame at intensity 1.
- **Rejected when:** at the slider's far end the figure comes apart, overlaps itself or leaves the frame; an unclamped value follows the pointer without limit.
## 04 · accent: the stroke is the only highlight
No fills, glows or shadows. The active edge goes from the silhouette's stroke to the bright one and nothing else changes colour, so the colour reads as information.
- **Keep it:** `el.sil.classList.toggle("hi", active)`. Dots change between `dot`, `dot m` and `dot off`. That is the whole palette. At rest, one bright mark says where the eye should start. When the pointer chooses, the bright goes to what it chose and the rest mark gives it up. One highlight may cover the parts of one thing, a tray's rim and its beads, a dune's top, but it marks one place and means one thing.
- **Rejected when:** the figure sets any colour, fill, opacity trick, gradient, filter, shadow or stroke width of its own; two places are bright at once; the rest mark stays lit beside the pointer's choice; bright is used as decoration.
## 05 · rest: rest is designed, never flat
Leaving returns the figure to a composition: a dune, a lean, a slight explode. The still frame is the thumbnail, so it has to hold up alone.
- **Keep it:** give every part a rest value that is not zero and not uniform (Terrain's dune is two Gaussians; Riffle's cards lean back 12°). Put one bright mark at rest where the eye should start. When the pointer chooses, the mark gives the bright up to what it chose (rule 04).
- **Keep it, when emptiness is the concept:** an empty state shows an object holding nothing. That is right when the object itself is composed, uneven and whole, and one bright mark says where to look. The tray is empty; the drawing is not.
- **Rejected when:** at rest the figure is a flat grid, a tray left empty because nothing was drawn in it, a perfectly regular row; at rest nothing says what the figure is about; rest and "nothing rendered yet" look alike.
## 06 · honesty: construction stays honest
Plates are opaque, filled with the ground colour. Guides and dashed lines are painted behind the plates they belong to, so the drawing never shows a line it should not.
- **Keep it:** append back to front (Terrain walks its grid diagonal by diagonal from the far corner); a shape that must hide what is behind it keeps its fill (no `nf`); guides use `dash` and are appended before their plate. When depth order changes, move the group (`a.after(b)`), do not redraw.
- **Rejected when:** a far edge shows through a near solid; paint order is the order the code happened to make things in; a dashed guide crosses the face of the plate it belongs to.
## 07 · cost: loops sleep offscreen
Only ambient motion runs without input, and only while visible.
- **Keep it:** all motion happens inside `register(stage, tick)`. Return `true` from `tick` only while something is still moving; call `wake()` after input. The loop stops when every figure has settled, sleeps offscreen, and lands springs and tweens at once under reduced motion.
- **Rejected when:** the figure has a timer, a `requestAnimationFrame` or a CSS animation of its own; `tick` always returns `true` in a figure that is not ambient; a path is rewritten on frames where its value did not change (keep the last drawn value and skip).
## 08 · clock: two clocks
A discrete change (which item) gets a long ease-out: 700ms on `(.32, .72, 0, 1)`. A continuous input (where the pointer is) gets a spring, `k 100 · c 18 · m 1`, because its target moves every frame and a timed ease would always be chasing it.
- **Keep it:** `tween` / `tset` / `tval` for which; `spring` / `stepS` for where. Use the defaults unless the figure has a reason you can state.
- **Rejected when:** a tween follows the pointer's position; a spring animates a choice between items; durations or spring constants are invented; anything is linear.
## 09 · radius: round every corner, then draw less
A solid is the hull of two rounded rings, its top and its base. The vertical corners are never drawn. The top edge becomes one dim crease a unit or two inside the silhouette. Bright outside, dim inside: that hierarchy is most of the polish.
- **Keep it:** `rings(x0, y0, x1, y1, r, b)` then `prism(P, front, ring, inner, z0, z1)` into `solid(parent)` with `put`. A thin plate gets a second line for its thickness, not a second solid. Round flat outlines with `fillet`. `rings` takes four steps round each corner, so a full round has sixteen sides, and they show once its radius on screen passes about 20 viewBox units (r × S; r 12 at S 1.9). For a large round, build the two rings yourself with more steps: `rrect(x0, y0, x1, y1, r, 14)` and `rrect(x0 + b, y0 + b, x1 - b, y1 - b, r - b, 14)`.
- **Keep it, when a solid's top is smaller than its foot** (a cabin, a roof, a tower that tapers): `prism` stands one ring straight up, so write its two strings yourself, from two rings. With `foot`, `top` and `inner` each an `rrect`, `inner` being `top` inset by b: the silhouette is `poly(hull(ringAt(P, foot, z0).concat(ringAt(P, top, z1))))` and the crease is `open(ringAt(P, run(inner, front), z1))`. Then `put(solid(parent), { sil, crease })`, as with `prism`. That is all `prism` does, so there is nothing to look up under the index.
- **Keep it, for a mark:** a mark keeps its own corners where rounding would make it another mark. Use the smallest radius that still reads as built, half a unit to two, and round everything that is not the mark as usual.
- **Rejected when:** a box shows twelve edges or any vertical corner line; a corner is sharp; inner lines are as bright as the silhouette; a solid has more than its silhouette, one crease, and at most a few marks the object would really have.
## 10 · quiet: no words inside the figure
Geometry carries identity: a punch on a card's tab, a dot code on a crate's lid, a bright edge instead of a label. Names go to the corner read-out, outside the drawing. Anything decorative has to be something the object would really have.
- **Keep it:** `read.textContent = …` names what is under the pointer, in a few characters (`cell 4·2`, `08`), and says `rest` when nothing is. Number things with dots.
- **Keep it, for a figure made from a mark:** logos stay out when they label something else. The one exception is a figure made from a mark, where the mark is the object: built as a solid, answering the pointer. A glyph that belongs to the mark is a relief on one face. Nothing else in that figure is a letter, and the mark's name still goes to the read-out's side, never into the drawing.
- **Keep it, when a glyph is the subject:** a glyph may be the object when it is built as a solid and is the figure's whole subject, as the package's Query is: a question mark made of a bent bar over a loose ball, answering the pointer. It is a thing in the scene, not a label on one, and nothing else in that figure is a letter.
- **Rejected when:** the svg holds text, letters or digits drawn as paths, icons, arrows or logos, other than the mark, and a glyph that belongs to it, in a figure made from a mark, or a glyph built as a solid that is the figure's whole subject; the figure cannot be understood without its read-out; the read-out is a sentence.
## The frame
Not a rule of the study, but every figure shares it.
- The viewBox is 400 × 320. Centre the figure near (200, 166) with `fit`.
- The camera is `Cam(45, 0.5, S)`: the 2:1 view. Change `S` to fit; leave the angle alone unless the camera is the concept.
- The silhouette must read at 240px wide. Past a hundred or so solids, or with parts under ten viewBox units, it will not.
- `mount` keeps no state outside itself, and `destroy` leaves the svg empty and nothing running: collect tear-down in `disposer()` and return `bag.dispose`.
- At most 200 lines. A longer figure is usually two ideas.
- The tour is three to six viewBox points, or `null` to leave. Pick stops on different parts, in the order a hand would reach them, and end with `null` so the lap returns to rest.
SKILL.md›
---
name: hairline-create
description: Use when someone asks for a new Hairline figure, or runs /hairline-create with an idea. Draws one isometric line figure that answers the pointer, in the style and on the engine of @lucasmarkes/hairline, and hands it over as a single self-contained HTML file.
argument-hint: "[idea]"
---
# Hairline: create a figure
You are making one figure in the Hairline style: an isometric line drawing, built from rounded solids in a single stroke, that answers the pointer. It ships as one HTML file with nothing to install. The six figures of `@lucasmarkes/hairline` are the bar; `examples/terrain.js` and `examples/riffle.js` are two of them, in the format you will write.
You write one thing: the figure. The engine (`kernel.js`) and the page (`bench.html`) are fixed. Never edit them, never paste a changed copy of them, and never write again what the kernel already gives you.
Every file named below is in this skill's folder. The figure and the page are written in the person's working directory: run `build.mjs`, `validate.mjs` and `look.mjs` from there, by their path in this folder.
## 1. Concept
Read `concepts.md`. It has a section for an empty state and one for a figure drawn from a mark. Then offer two or three concepts, one line each:
> **Name.** The object. What the pointer does to it. What the read-out says. Where the tour stops.
Wait for the person to pick. Skip this step only when they arrived with the object and the gesture already chosen. If there is nobody to ask, take the concept with the strongest rest pose and say which you took.
One figure, one idea. A concept that needs a label to be understood is not a concept yet.
## 2. Build
1. Read `rules.md`. The ten rules are not advice: a figure that breaks one is not finished.
2. Read the index at the top of `kernel.js`: the comment under the hash line, down to `var HL`. It lists everything you may call. Do not read the code under it.
3. Read the example nearer your concept: `examples/terrain.js` for a continuous field, `examples/riffle.js` for discrete items.
4. Write the figure as `<name>.js` in the person's working directory, in the shape of the examples: take what you need from `HL`, define `mount({ stage, svg, read }, value)` returning `{ set, destroy }`, and end the file with `hairline({ name, means, rules, range, tour, mount })`.
- `name`: lowercase, one word or hyphenated.
- `means`: one sentence, 140 characters at most, saying what the figure shows. It is the line under the stage.
- `rules`: the numbers of the rules this figure leans on most.
- `range`: the one number the slider drives, at intensity 0, 0.5 and 1. The middle one is the default, and the three move one way. `mount`'s `value`, and the `value` that `set(value)` gets when the slider moves, is this number: the figure's own, read on `range`, not 0 to 1.
- `tour`: the three to six viewBox points the unseen pointer visits when the page plays, or `null` to leave; the lap a stranger sees with no hand on the page. Literal `[x, y]` numbers, at least one of them a point. The figure never plays itself: the page's play button and the package do.
5. Assemble it: `node build.mjs <name>.js` writes `hairline-<name>.html`, and so does each run of `look.mjs` in step 3. Without Node, copy `bench.html` and put the contents of `kernel.js` where `/*KERNEL*/` is and your figure where `/*FIGURE*/` is, by file operation, changing nothing else.
## 3. Check
1. `node look.mjs <name>.js --answer x,y,z --edge x,y,z`, the points being world points of your figure, as `look.md` says. It builds the page, validates it, takes the eight pictures on one sheet, `hairline-<name>-look.png`, and checks the frame, the read-out and the console. Fix every line it prints as failed, and run it again until it exits 0.
2. Read `look.md`, then the sheet, and answer its questions. Fix what fails, then go back to 1.
`look.mjs` needs a browser and installs `playwright-core` once, outside this folder. Without one, check with `node validate.mjs hairline-<name>.html` after each build and do the look as `look.md` says under "Without a browser". Without Node, read the list of checks at the top of `validate.mjs` and answer each one from your code.
Do not hand over a page the validator rejects. Do not say the look is done if you did not look.
## 4. Hand over
Publish `hairline-<name>.html` as an artifact if you can. If you cannot, leave the file where the person can open it and say where it is. Then say, one line each:
- the metaphor: what the object is, and what the pointer does to it;
- the rules it leans on;
- the tour: where it stops, in order;
- anything you could not verify (no Node, no browser), plainly.
Then ask the person to press play on the page, or open it with `?play=1`, and say whether the lap tells the figure's story. If there is nobody to ask, say the tour was not judged.
## 5. Adjust
When the person asks for a change, edit only `<name>.js`, then run `look.mjs` again and read the new sheet. The tenth version is held to the same bar as the first. A change to the lap is a change to `tour` alone.
## What goes wrong
| If you catch yourself | Do this instead |
| --- | --- |
| adding a label, a number or a letter to the drawing | say it with geometry (rule 10); names go to `read.textContent` |
| reaching for a colour, a fill or a glow | move one stroke from `sil` to `hi` (rule 04) |
| writing a timer, a `requestAnimationFrame` or a CSS animation | `register(stage, tick)`, with springs or tweens (rules 07 and 08) |
| testing the pointer against what is drawn right now | test it against the rest or target pose (rule 01) |
| drawing a box with twelve edges | `prism` of two rounded rings: a silhouette and one crease (rule 09) |
| drawing a part as a plain rounded block | give it the features that make it what it is, the ones you named in the concept (`concepts.md`) |
| leaving rest flat, empty, or symmetric because that was easy | compose it: rest is the thumbnail (rule 05) |
| stamping a mark on a box to say whose it is | build the mark as the object, or leave it out (`concepts.md`, "From a mark") |
| editing the kernel or the bench to make something work | the figure is wrong; change the figure |
| making the tour visit every part in turn | three stops that each show a different answer, then a leave: a lap is a sentence, not an inventory |
| letting in a second idea | cut it: one figure, one idea |
validate.mjs›
#!/usr/bin/env node
/**
* Checks a page made by build.mjs: `node validate.mjs hairline-<name>.html`.
* It prints what to fix and exits 1, or prints `ok` and exits 0. The checks
* read the text of the file, so they catch what is mechanical; the look
* (look.md) catches the rest. Each line it prints starts with the check's name:
*
* kernel the kernel in the page is this folder's kernel.js, untouched
* parse the figure loads as a module: no syntax error stops it before it draws
* bench nothing but the figure differs from bench.html
* text no words inside the figure (rule 10)
* paint no stroke width, colour, fill, opacity, filter or shadow of its own (rule 04)
* outside nothing loaded or reached outside the file; every node from HL.mk
* clock no timers, frames or SMIL animation of its own; it joins HL.register (rule 07)
* tween every tset is given its four values, the delay last
* hit input only through HL.pointer; nothing measured on screen (rule 01)
* readout it writes read.textContent
* handle mount returns { set, destroy }
* declare the file ends with hairline({ name, means, rules, range, tour, mount })
* tour hairline() gives tour: three to six stops, each [x, y] in the viewBox or null, one of them a point; the figure never calls HL.tour
* length at most 200 lines
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { assemble } from "./build.mjs";
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const LIMIT = 200;
const unix = (s) => s.replace(/\r\n/g, "\n");
/**
* A figure's source without its comments, so its prose is never read as code.
* It is read the way JavaScript reads it, so a `//` inside a string, a template
* or a regex is not taken for a comment, and what follows it is still checked.
* With `words` false the strings are emptied too, for a check that must not
* read a string's words as code.
*/
function bare(src, words = true) {
let out = "", i = 0, prev = "", depth = 0;
const open = []; // for each `${` still open, the brace depth it opened at
/** Reads from just past an opening quote to its close, or, in a template, to a `${`. */
const quoted = (q) => {
const from = i;
while (i < src.length && src[i] !== q && (q === "`" ? !(src[i] === "$" && src[i + 1] === "{") : src[i] !== "\n")) {
i += src[i] === "\\" ? 2 : 1;
}
if (words) out += src.slice(from, i);
if (q === "`" && src[i] === "$") { out += "${"; i += 2; open.push(depth++); prev = "{"; return; }
out += src[i] ?? ""; i++; prev = q;
};
while (i < src.length) {
const c = src[i], d = src[i + 1];
if (c === "/" && d === "/") { while (i < src.length && src[i] !== "\n") i++; }
else if (c === "/" && d === "*") { const end = src.indexOf("*/", i + 2); i = end < 0 ? src.length : end + 2; out += " "; }
else if (c === "/" && /^(?:|[(,=:[!&|?{};+\-*%<>~^])$/.test(prev)) {
// A regex: a slash where a value starts. A quote or a `//` inside it is the regex's own.
let j = i + 1, cls = false;
for (; j < src.length && src[j] !== "\n"; j++) {
if (src[j] === "\\") j++;
else if (src[j] === "[") cls = true;
else if (src[j] === "]") cls = false;
else if (src[j] === "/" && !cls) break;
}
out += src.slice(i, j + 1); i = j + 1; prev = ")";
} else if (c === '"' || c === "'" || c === "`") { out += c; i++; quoted(c); }
else if (c === "}" && open.length && depth - 1 === open[open.length - 1]) { depth--; open.pop(); out += c; i++; quoted("`"); }
else {
if (c === "{") depth++;
else if (c === "}") depth--;
out += c; i++;
if (!/\s/.test(c)) prev = c;
}
}
return out;
}
/**
* What a figure must not contain: a pattern read in the code with its strings,
* and, for an import, one read with its strings emptied, so the word in a
* string is not taken for the statement.
*/
const BAD = [
["text", /<\s*(?:text|tspan|textPath|foreignObject)\b|["'`](?:text|tspan|textPath|foreignObject)["'`]|\b(?:innerHTML|outerHTML|insertAdjacentHTML|innerText)\b/,
"rule 10. No words inside the figure, and no markup written as a string. Say it with geometry (a punch, a dot code, a bright edge); names go to read.textContent."],
["paint", /stroke-width|strokeWidth|stroke-dasharray|-opacity|(?:fill|stroke)Opacity|["'`](?:fill|stroke|filter|style|color|stop-color|opacity)["'`]|\b(?:fill|stroke|filter|style|opacity)\s*:|\.style\b|(["'`])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\1|\b(?:rgba?|hsla?|oklch|oklab|color-mix)\(|drop-shadow|box-shadow|feDropShadow|feGaussianBlur|[Ll]inearGradient|[Rr]adialGradient|(?:linear|radial|conic)-gradient/,
"rule 04. The figure sets a stroke width, colour, fill, opacity, filter, gradient or shadow of its own. Use the kernel's classes and nothing else: sil, hi, lo, nf, fo, dash, dot, dot m, dot off."],
["outside", /\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource|sendBeacon|https?:\/\/|\burl\(|<\/?script|<(?:link|img|iframe|style)\b|new\s+Image\b|createElement|\beval\s*\(|new\s+Function\b|localStorage|sessionStorage|\.cookie\b/,
"the figure reaches outside the file or makes nodes by hand. One self-contained file: no fetch, import, URL, script tag or storage, and every node comes from HL.mk.",
// The keyword ends where the binding starts, so `important` or `imported` is a name, not an import.
// `import.meta` is left alone on purpose: by itself it reaches nothing outside the file.
/\bimport(?:\s*["'`({*]|\s+[\w$]+\s*(?:,|from\b))/],
["clock", /\bsetInterval\b|\bsetTimeout\b|\brequestAnimationFrame\b|\.animate\s*\(|<animate|["'`]animate(?:Transform|Motion)?["'`]|IntersectionObserver|\bmatchMedia\b/,
"rule 07. The figure runs a clock of its own. Move inside HL.register(stage, tick), with springs (stepS) or tweens (tset, tval); a delay is a tween's delay. The loop sleeps offscreen and honours reduced motion for you."],
["hit", /getBoundingClientRect|elementFromPoint|elementsFromPoint|:hover|(?:addEventListener|\.on)\s*\(\s*(?:\w+\s*,\s*)?["'`](?:mouse|pointer|touch|click)|\bon(?:mouse|pointer|touch|click)\w*\s*=/,
"rule 01. The figure listens to the pointer itself or measures what is on screen. Take the pointer from HL.pointer(stage, { move, leave }) and test it against the rest or target pose, in world units."],
];
/**
* What a figure must contain. The handle's keys may be written `set(v)`, `set: f`
* or, shorthand, `{ set, destroy }`. A call on something else, such as
* `map.set(k, v)`, is not the handle's `set`.
*/
const NEED = [
["clock", /\bregister\s*\(/, "rule 07. The figure never joins the kernel's loop. Draw inside HL.register(stage, tick), and give its unregister to destroy."],
["hit", /\bpointer\s*\(/, "the figure never listens to the pointer. Call HL.pointer(stage, { move, leave }) and answer it."],
["readout", /\bread\.textContent\s*=/, 'the figure never writes the read-out. Set read.textContent to what is under the pointer, and to "rest" when nothing is.'],
["handle", /(?<![.\w$])set\s*[:(,}]/, "mount must return { set(value), destroy() }, and set is missing. It takes the slider's number."],
["handle", /(?<![.\w$])destroy\s*[:(,}]/, "mount must return { set(value), destroy() }, and destroy is missing. It undoes everything mount did: bag.dispose."],
];
/**
* Each `tset` given fewer than its four values. Without its delay a tween
* starts at no time at all: its value is NaN, and so is every path drawn from it.
*/
function tweens(shape) {
const out = [];
for (const m of shape.matchAll(/(?<![\w$])tset\s*\(/g)) {
let i = m.index + m[0].length, depth = 1, given = 1;
for (; i < shape.length && depth; i++) {
const c = shape[i];
if ("([{".includes(c)) depth++;
else if (")]}".includes(c)) depth--;
else if (c === "," && depth === 1) given++;
}
const call = shape.slice(m.index, i).replace(/\s+/g, " ");
if (given < 4 && !call.includes("...")) {
out.push(`tween: \`${call}\` is given ${given} of tset's four values: tset(tw, to, now, delay). Without the delay the tween's value is NaN and nothing is drawn. Give it 0 to start at once.`);
}
}
return out;
}
/** The declaration at the end of the file, read as text. */
function declared(code) {
const say = (what) => `declare: the file must end with hairline({ name, means, rules, range, tour, mount }). ${what}`;
const m = /\bhairline\s*\(\s*\{([\s\S]*?)\}\s*\)\s*;?\s*$/.exec(code.trimEnd());
if (!m) return [say("That call is missing, or is not the last statement.")];
const d = m[1], wrong = [];
const list = (key) => {
const a = new RegExp(`\\b${key}:\\s*\\[([^\\]]*)\\]`).exec(d);
return a ? a[1].split(",").map((s) => s.trim()).filter(Boolean).map(Number) : null;
};
if (!/\bname:\s*(["'`])[a-z][a-z0-9-]{1,30}\1/.test(d)) wrong.push("name (lowercase letters, digits and hyphens)");
if (!/\bmeans:\s*(["'`])(?:(?!\1).){1,140}\1/.test(d)) wrong.push("means (one sentence, at most 140 characters)");
const rules = list("rules"), range = list("range");
if (!rules || !rules.length || rules.some((r) => !Number.isInteger(r) || r < 1 || r > 10)) wrong.push("rules (the numbers, 1 to 10, of the rules it leans on)");
const oneWay = range && range.length === 3 && range.every(Number.isFinite) && range[0] !== range[2] && (range[1] - range[0]) * (range[2] - range[1]) >= 0;
if (!oneWay) wrong.push("range (three numbers that move one way: the figure's value at intensity 0, 0.5 and 1)");
if (!/\bmount\b/.test(d)) wrong.push("mount");
return wrong.length ? [say(`Wrong or missing: ${wrong.join("; ")}.`)] : [];
}
/**
* The tour: three to six stops in the hairline() call, each a literal [x, y]
* inside the viewBox or null, at least one a point. The figure's own code never
* calls HL.tour; the bench and the package do.
*/
function toured(code, shape) {
const out = [];
if (/\bHL\.tour\s*\(/.test(shape)) out.push("tour: the figure calls HL.tour itself. Declare tour in hairline() and let the bench play it.");
const call = /\bhairline\s*\(\s*\{([\s\S]*?)\}\s*\)\s*;?\s*$/.exec(code);
if (!call) return out; // declare already said so
const body = call[1];
const open = /\btour:\s*\[/.exec(body);
if (!open) { out.push("tour: the call gives no tour. Add tour: [[x, y], ..., null], three to six stops that walk the figure through its answer."); return out; }
let depth = 0, end = -1;
for (let i = open.index + open[0].length - 1; i < body.length; i++) {
if (body[i] === "[") depth++;
else if (body[i] === "]" && --depth === 0) { end = i; break; }
}
if (end < 0) { out.push("tour: the tour's brackets do not close."); return out; }
const inner = body.slice(open.index + open[0].length, end);
const items = inner.match(/\[\s*([^\[\]]*)\]|null|[^\s,\[\]]+/g) ?? [];
const bad = [];
let points = 0;
for (const item of items) {
if (item === "null") continue;
const m = /^\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]$/.exec(item);
const x = m && Number(m[1]), y = m && Number(m[2]);
if (!m || x < 0 || x > 400 || y < 0 || y > 320) { bad.push(item.replace(/\s+/g, " ")); continue; }
points++;
}
if (bad.length) out.push(`tour: each stop is a literal [x, y] with x in 0..400 and y in 0..320, or null. Not: ${bad.join(", ")}.`);
if (items.length < 3 || items.length > 6) out.push(`tour: ${items.length} stop${items.length === 1 ? "" : "s"}. A tour has three to six: enough to show the answer, few enough to stay a sentence.`);
if (!bad.length && !points) out.push("tour: every stop is null. At least one stop is a point on the figure.");
return out;
}
/**
* The syntax error that keeps the figure from loading, or nothing. The page
* loads the figure as a module, so it is checked as one, by `node --check`:
* the browser would stop at the same error and draw nothing.
*/
function parse(src) {
const dir = mkdtempSync(join(tmpdir(), "hl-parse-")), file = join(dir, "figure.mjs");
try {
writeFileSync(file, src);
const run = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
if (run.status === 0) return [];
const line = /figure\.mjs:(\d+)/.exec(run.stderr)?.[1];
const error = /^\w*Error: .*$/m.exec(run.stderr)?.[0] ?? run.stderr.trim().split("\n")[0];
return [`parse: the figure does not load${line ? `, line ${line}` : ""}: ${error}. The browser stops there and draws nothing. Fix it in the figure and build again.`];
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** Everything wrong with a page, one line each; empty when it passes. */
export function validate(input) {
const page = unix(input), out = [];
const kernel = unix(readFileSync(here("./kernel.js"), "utf8")).trimEnd();
const cut = kernel.indexOf("\n");
const hash = /sha256:([0-9a-f]{64})/.exec(kernel.slice(0, cut))?.[1];
if (hash !== createHash("sha256").update(kernel.slice(cut + 1) + "\n").digest("hex")) {
out.push("kernel: kernel.js in the skill folder has been edited. Install the skill again; the kernel is never changed by hand.");
}
const fig = /<script type="module" id="hl-figure">\n([\s\S]*?)\n<\/script>/.exec(page);
if (!fig) return [...out, "bench: the page has no figure slot. Make it with `node build.mjs <figure.js>`, not by hand."];
if (!page.includes(kernel)) {
out.push("kernel: the kernel in the page is not kernel.js. Build again with `node build.mjs`; never edit or retype the kernel.");
} else if (page.trimEnd() !== assemble(fig[1]).trimEnd()) {
out.push("bench: the page differs from bench.html outside the figure. Build again with `node build.mjs`; the bench is fixed, and a change belongs in the figure.");
}
const src = fig[1], code = bare(src), shape = bare(src, false);
out.push(...parse(src));
for (const [id, re, say, empty] of BAD) if (re.test(code) || empty?.test(shape)) out.push(`${id}: ${say}`);
for (const [id, re, say] of NEED) if (!re.test(code)) out.push(`${id}: ${say}`);
out.push(...tweens(shape));
out.push(...declared(code));
out.push(...toured(code, shape));
const lines = src.split("\n").length;
if (lines > LIMIT) out.push(`length: the figure is ${lines} lines and the limit is ${LIMIT}. A figure this long is usually two ideas: cut the concept down to one.`);
return out;
}
/** Whether two resolved paths are one file. Windows paths ignore case, and the drive letter's case can differ between the two. */
export const same = (a, b, platform = process.platform) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
/* The skill is often installed as a symlink, so the path Node was given is resolved before it is compared. */
if (process.argv[1] && same(realpathSync(process.argv[1]), realpathSync(here("./validate.mjs")))) {
const file = process.argv[2];
if (!file) {
console.error("usage: node validate.mjs <page.html>");
process.exit(2);
}
let page;
try { page = readFileSync(file, "utf8"); } catch {
console.error(`cannot read ${file}`);
process.exit(2);
}
const problems = validate(page);
if (problems.length) {
for (const p of problems) console.error(p);
console.error(`${problems.length} to fix in ${file}`);
process.exit(1);
}
console.log(`ok ${file}: kernel and bench intact, and the figure passes the static checks. Now the look: look.md.`);
}