diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43a9119..382efa2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -81,6 +81,9 @@ he copy button said "Your settings, as props — only what you changed", and the
### Added
+- **The site demonstrates `order`.** A capability nobody can see is a capability that quietly stops working, so the seam now has a section of its own with two traversals written the way a reader would write them: a **chevron that accelerates** toward its tips, and **rings that expand from a dot the visitor clicks**. Both answer questions the library's own tables cannot: the shape of a traversal can be anything computable per cell, and its origin can be anywhere.
+- **The demo is deliberately separate from the playground.** `order` is a function, so it cannot travel in the copied settings, and a visitor who reproduced the playground's snippet would silently get the preset's motion instead. Its own field, its own code — and the code shown is the traversals file **imported with `?raw`**, so the source a visitor reads is literally the source that is running and cannot drift from it.
+
- **You can now supply your own traversal.** `order` takes an `OrderFn` and replaces the preset's — which is the seam the layered design always left open, finally reachable from an option instead of only from a custom renderer. Anything you can compute per cell, you can animate:
```ts
diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md
index 5127610..5eeab0a 100644
--- a/CHANGELOG.zh.md
+++ b/CHANGELOG.zh.md
@@ -60,6 +60,9 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea
### 新增
+- **站点演示 `order`。** 没人能看见的能力,就是会悄悄坏掉的能力,所以这个接缝现在有了自己的版块,包含两个按读者会写的方式写的遍历:一个**向尖端加速的箭头**,以及**从访客点击的那个点扩散的圆环**。两者都回答了库自身表格无法回答的问题:遍历的形状可以是任何能按格计算的东西,而它的起点可以在任何位置。
+- **这个演示刻意与 playground 分开。** `order` 是函数,无法随复制的设置一起传递,而照着 playground 的片段重建组件的人会悄悄拿到 preset 的动效。它有自己的场、自己的代码 —— 而展示的代码是用 `?raw` 导入的遍历文件,所以读者读到的源码就是正在运行的源码,两者不可能漂移。
+
- **现在可以提供你自己的遍历。** `order` 接受一个 `OrderFn`,替换 preset 自带的 —— 这是分层设计一直留着的接缝,终于能从 option 到达,而不再只存在于自定义渲染器里。你能按格计算的任何东西,都能变成动效:
```ts
diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json
index 353f097..f143281 100644
--- a/apps/site/messages/en.json
+++ b/apps/site/messages/en.json
@@ -128,5 +128,11 @@
"ctl_color_swatch": "Pick a colour",
"color_hex": "HEX",
"color_rgb": "RGB",
- "color_hsl": "HSL"
+ "color_hsl": "HSL",
+ "demo_traversal_title": "Your own traversal",
+ "demo_traversal_which": "Which traversal",
+ "demo_traversal_chevron": "Chevron",
+ "demo_traversal_rings": "Rings",
+ "demo_traversal_click": "Click the field to move the centre.",
+ "demo_traversal_lede": "A traversal is one function of lattice position, so anything you can compute per cell you can animate. These two are not presets — they are `order` functions, written here and running below."
}
diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json
index 4ff6d60..d490522 100644
--- a/apps/site/messages/zh.json
+++ b/apps/site/messages/zh.json
@@ -128,5 +128,11 @@
"ctl_color_swatch": "选择颜色",
"color_hex": "HEX",
"color_rgb": "RGB",
- "color_hsl": "HSL"
+ "color_hsl": "HSL",
+ "demo_traversal_title": "你自己的遍历",
+ "demo_traversal_which": "选择遍历",
+ "demo_traversal_chevron": "箭头",
+ "demo_traversal_rings": "圆环",
+ "demo_traversal_click": "点击场来移动中心。",
+ "demo_traversal_lede": "遍历就是「格位置」的单一函数,所以你能按格计算的任何东西都能变成动效。下面这两个不是 preset — 它们是 `order` 函数,就在这里写着,并在下面运行。"
}
diff --git a/apps/site/src/components/Home.astro b/apps/site/src/components/Home.astro
index 909f3c9..a78853f 100644
--- a/apps/site/src/components/Home.astro
+++ b/apps/site/src/components/Home.astro
@@ -3,6 +3,7 @@ import Base from '../layouts/Base.astro';
import { InstallCommand } from './InstallCommand';
import { Playground } from './Playground';
import { RegistryList } from './RegistryList';
+import { TraversalDemo } from './TraversalDemo';
import LocaleSwitch from './LocaleSwitch.astro';
import { m } from '../paraglide/messages.js';
import { localizeHref, locales } from '../paraglide/runtime.js';
@@ -51,6 +52,12 @@ const path = Astro.url.pathname;
+
+
{m.demo_traversal_title()}
+
{m.demo_traversal_lede()}
+
+
+
{m.components_heading()}
{m.components_note()}
diff --git a/apps/site/src/components/TraversalDemo.tsx b/apps/site/src/components/TraversalDemo.tsx
new file mode 100644
index 0000000..737e877
--- /dev/null
+++ b/apps/site/src/components/TraversalDemo.tsx
@@ -0,0 +1,125 @@
+import { useMemo, useState } from "react";
+import { Matrix } from "./Matrix.js";
+import { m } from "../i18n.js";
+import { chevron, nearestCell, ringsFrom } from "../traversals.js";
+// The source of the traversals file, at build time. Shown beside the field so the code a
+// visitor reads IS the code that is running — a hand-kept copy of a function is a copy that
+// lies the first time anyone edits one of them.
+import traversalSource from "../traversals.ts?raw";
+
+// The file, from the first export onward. The header comment explains WHY these exist, which
+// is for us; a visitor's first question is what to copy, and scrolling past five paragraphs
+// of rationale to reach it is the wrong first impression of a code sample.
+const COPYABLE = traversalSource.slice(traversalSource.indexOf("export"));
+
+const N = 9;
+
+/**
+ * The `order` seam, on the page.
+ *
+ * A capability the page cannot demonstrate is a capability that quietly stops working, and
+ * this one has a specific hazard: `order` is a FUNCTION, so it cannot go in the copied
+ * snippet. A visitor who reproduced the settings from the copy button would get the preset's
+ * motion and no indication why. So this demo is deliberately separate from the playground —
+ * its own field, its own settings, its own copy button that copies the CODE — and the code is
+ * right there to paste.
+ *
+ * Two traversals, because they answer two different questions:
+ *
+ * chevron the SHAPE of the traversal can be anything you can compute per cell, including
+ * an easing along its own axis — which is what "accelerating" actually is.
+ * rings the ORIGIN can be anywhere, which the library's concentric preset pins to the
+ * middle. Click the field to move it.
+ */
+export function TraversalDemo() {
+ const [variant, setVariant] = useState<"chevron" | "rings">("chevron");
+ const [origin, setOrigin] = useState({ col: 4, row: 4 });
+
+ // rebuilt whenever the origin moves, and the field below is keyed on it so the engine
+ // re-ranks rather than remounting — the same path a real caller takes
+ const order = useMemo(
+ () => (variant === "chevron" ? chevron : ringsFrom(origin)),
+ [variant, origin],
+ );
+
+ return (
+
+
+
+
+
+
+ {variant === "rings" &&
{m.demo_traversal_click()}
}
+
+
+
+
{
+ if (variant !== "rings") return;
+ const box = e.currentTarget.getBoundingClientRect();
+ // the field is drawn from its centre outward, in the engine's own px geometry
+ const pitch = box.width / N;
+ setOrigin(
+ nearestCell(
+ {
+ x: e.clientX - box.left - box.width / 2,
+ y: e.clientY - box.top - box.height / 2,
+ },
+ { x: pitch, y: pitch },
+ N,
+ N,
+ ),
+ );
+ }}
+ >
+
+ {variant === "rings" && (
+
+ )}
+
+
+
+ {/* No caption here: the section's lede already says what these are, and a second
+ copy of the same sentence one inch away is the thing people stop reading. */}
+
+ {COPYABLE}
+
+
+
+
+ );
+}
diff --git a/apps/site/src/rawprobe.ts b/apps/site/src/rawprobe.ts
new file mode 100644
index 0000000..c00fb9a
--- /dev/null
+++ b/apps/site/src/rawprobe.ts
@@ -0,0 +1,2 @@
+import src from "./traversals.ts?raw";
+export const hasRaw = typeof src === "string" && src.includes("chevron");
diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css
index 8093de3..3150c68 100644
--- a/apps/site/src/styles/site.css
+++ b/apps/site/src/styles/site.css
@@ -167,6 +167,115 @@ h3 {
cursor: pointer;
}
+/* The `order` seam. Separate from the playground on purpose: `order` is a FUNCTION, so it
+ cannot travel in the copied settings, and a visitor who reproduced the playground's
+ snippet would silently get the preset's motion instead. Its own field, its own code. */
+.traversal-demo {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--card);
+ padding: 16px;
+}
+
+.traversal-demo-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.traversal-choices {
+ display: flex;
+ gap: 2px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 2px;
+}
+
+.traversal-choices button {
+ font: inherit;
+ font-size: 13px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ border-radius: 6px;
+ padding: 5px 12px;
+ cursor: pointer;
+}
+
+.traversal-choices button.is-on {
+ background: var(--accent);
+ color: #fff;
+}
+
+.traversal-hint {
+ margin: 0;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.traversal-demo-body {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+.traversal-field {
+ position: relative;
+ line-height: 0;
+ /* the origin marker sits ON a cell, so a cell at the very corner would have half of it
+ clipped by the field's own edge — which is exactly where a visitor clicks first */
+ padding: 6px;
+}
+
+/* only the rings variant is interactive, and saying so with a pointer is cheaper than
+ disabling it: the chevron has no origin to click */
+.traversal-field[role="button"],
+.traversal-field:has(.traversal-origin) {
+ cursor: crosshair;
+}
+
+/* where the rings currently expand from — the one thing the demo is about, so it is drawn
+ rather than described */
+.traversal-origin {
+ position: absolute;
+ width: 7px;
+ height: 7px;
+ margin: -3.5px 0 0 -3.5px;
+ border-radius: 999px;
+ background: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
+ pointer-events: none;
+ /* over the dots, which are drawn in `currentColor` at the page's ink */
+ z-index: 1;
+}
+
+.traversal-code p {
+ margin: 0 0 8px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.traversal-code pre {
+ margin: 0;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--bg);
+ font-size: 11px;
+ line-height: 1.6;
+ max-height: 320px;
+ overflow: auto;
+}
+
+@media (max-width: 860px) {
+ .traversal-demo-body {
+ grid-template-columns: 1fr;
+ }
+}
+
/* The dot colour. Full width like the copy button, and in the sticky pane beside the
field, because a colour you have to look away from to check is not a colour you are
choosing. */
diff --git a/apps/site/src/traversals.ts b/apps/site/src/traversals.ts
new file mode 100644
index 0000000..f07f2f8
--- /dev/null
+++ b/apps/site/src/traversals.ts
@@ -0,0 +1,82 @@
+import type { OrderFn } from "@botharness/botui-core";
+
+/**
+ * Two traversals the library does not ship, written the way a reader would write them.
+ *
+ * These exist to demonstrate the `order` seam on the page rather than only in a README: a
+ * capability nobody can see is a capability that quietly stops working. Both are ordinary
+ * `OrderFn`s — a function of lattice position returning `0…1`, where `0` leads.
+ *
+ * The source of this file is shown verbatim beside the field, imported with `?raw`, so the
+ * code a visitor reads cannot drift from the code that is running. A hand-maintained copy
+ * of a function is a copy that lies the first time someone edits one of them.
+ */
+
+/**
+ * A CHEVRON that accelerates toward its tips.
+ *
+ * "Accelerating" is not a property of the traversal's SHAPE but of how the value is
+ * distributed along it: squaring compresses the early differences and stretches the late
+ * ones, so successive dots along each arm start closer together in phase near the tip. A
+ * linear chevron is a chevron; an eased one is a chevron that moves like it is going
+ * somewhere.
+ *
+ * The value mixes distance from the spine with progress along the field, which is what
+ * makes it fold rather than simply translate.
+ */
+export const chevron: OrderFn = (col, row, cols, rows) => {
+ const mid = (rows - 1) / 2;
+ // 0 at the spine, 1 at the outer tips. `|| 1` because a single row has no mid to divide by
+ const arm = Math.abs(row - mid) / (mid || 1);
+ const along = col / (cols - 1 || 1);
+ const v = arm * 0.6 + along * 0.4;
+ // ease-in
+ return v * v;
+};
+
+/**
+ * RINGS expanding from a dot the visitor chooses.
+ *
+ * Chebyshev distance — `max(|dc|, |dr|)` — rather than Euclidean, and that choice is the
+ * whole character: on an integer lattice the Chebyshev radius takes only a handful of
+ * distinct values, so every dot on a ring shares one phase and the field reads as discrete
+ * bands. Euclidean would give nearly every dot its own value and turn the same traversal
+ * into a smooth travelling wave. The concentric `ring` preset is this function with the
+ * origin pinned to the middle; here the origin is a parameter, which is the request the
+ * library's own table could not express.
+ *
+ * Normalised by the largest distance the lattice can hold, so the outermost dot leads at 1
+ * whatever the grid size — otherwise a 9×9 and a 3×3 would animate at different rates for
+ * the same nominal shape.
+ */
+export function ringsFrom(origin: { col: number; row: number }): OrderFn {
+ return (col, row, cols, rows) => {
+ const d = Math.max(Math.abs(col - origin.col), Math.abs(row - origin.row));
+ // The furthest cell in the lattice FROM THE ORIGIN, which is whichever corner is
+ // diagonally opposite it. Normalising by a fixed denominator — `cols`, or
+ // `max(cols, rows)` — is the tempting one-liner and it is wrong twice over: it makes
+ // the outermost dot's value depend on the GRID SIZE rather than on the origin, so a
+ // 9×9 and a 3×3 animate at different rates for the same nominal shape, and it leaves
+ // the last ring short of 1, so the wave never quite arrives.
+ const far = Math.max(origin.col, cols - 1 - origin.col, origin.row, rows - 1 - origin.row);
+ return far > 0 ? Math.min(1, d / far) : 0;
+ };
+}
+
+/** the cell nearest a click, so a visitor can pick the origin with a pointer */
+export function nearestCell(
+ at: { x: number; y: number },
+ pitch: { x: number; y: number },
+ cols: number,
+ rows: number,
+): { col: number; row: number } {
+ // the field is centred, so a click at (0,0) in field space is the top-left cell
+ const cx = (cols - 1) / 2;
+ const cy = (rows - 1) / 2;
+ const col = Math.round(cx + (at.x / pitch.x) * cx);
+ const row = Math.round(cy + (at.y / pitch.y) * cy);
+ return {
+ col: Math.min(cols - 1, Math.max(0, col)),
+ row: Math.min(rows - 1, Math.max(0, row)),
+ };
+}
diff --git a/apps/site/test/i18n.test.ts b/apps/site/test/i18n.test.ts
index 01d7f73..f40ee7a 100644
--- a/apps/site/test/i18n.test.ts
+++ b/apps/site/test/i18n.test.ts
@@ -97,6 +97,7 @@ describe("the message catalogue", () => {
// scan silently stops covering half the site
"components/ShowcaseCopy.tsx",
"components/ColorPicker.tsx",
+ "components/TraversalDemo.tsx",
"i18n.ts",
"components/Home.astro",
"layouts/Base.astro",
diff --git a/apps/site/test/traversal.test.ts b/apps/site/test/traversal.test.ts
new file mode 100644
index 0000000..4d545bb
--- /dev/null
+++ b/apps/site/test/traversal.test.ts
@@ -0,0 +1,179 @@
+/**
+ * The `order` examples on the page.
+ *
+ * These are the only place the `order` seam is visible to a visitor, so the failure this
+ * guards against is not a wrong number — it is a demo that renders nothing, or renders the
+ * PRESET's motion while claiming to render something else, which is precisely the failure
+ * that made `order` worth having in the first place.
+ */
+import { describe, expect, it } from "vitest";
+import { field, type OrderFn } from "@botharness/botui-core";
+import { chevron, nearestCell, ringsFrom } from "../src/traversals.js";
+
+/** the leading cell of a rendered field — the one whose traversal value is lowest */
+const lead = (order: OrderFn, cols = 9, rows = 9) => {
+ const f = field({ cols, rows, silhouette: "square", renderer: "svg", order }, 0);
+ const t = f.reduce((a, b) => (b.order < a.order ? b : a));
+ return { col: t.col, row: t.row, order: t.order };
+};
+
+describe("the chevron example", () => {
+ it("leads from the spine, which is what makes it a chevron", () => {
+ const { row } = lead(chevron);
+ expect(row, "the fold, not a corner or a sweep").toBe(4);
+ });
+
+ it("accelerates — the claim the prose makes, checked against the numbers", () => {
+ const f = field({ cols: 9, rows: 9, silhouette: "square", renderer: "svg", order: chevron }, 0);
+ const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order;
+ const steps = [0, 1, 2, 3].map((c) => at(c, 4) - (c > 0 ? at(c - 1, 4) : 0));
+ for (let i = 1; i < steps.length; i++) {
+ expect(steps[i]!, `step ${i} should exceed step ${i - 1}`).toBeGreaterThan(steps[i - 1]!);
+ }
+ });
+
+ it("survives a 1-row field, which a pasted traversal divides by zero on", () => {
+ const f = field({ cols: 5, rows: 1, silhouette: "square", renderer: "svg", order: chevron }, 0);
+ expect(f).toHaveLength(5);
+ expect(f.every((d) => Number.isFinite(d.order))).toBe(true);
+ });
+});
+
+describe("the rings example", () => {
+ it("expands from the chosen cell, not from the middle", () => {
+ expect(lead(ringsFrom({ col: 4, row: 4 })).order, "the origin leads").toBe(0);
+ const corner = lead(ringsFrom({ col: 0, row: 0 }));
+ expect(corner.col, "move the origin and the light moves with it").toBe(0);
+ expect(corner.row).toBe(0);
+ });
+
+ it("is a ring, not a wave — Chebyshev gives every dot on a ring one phase", () => {
+ const f = field(
+ {
+ cols: 9,
+ rows: 9,
+ silhouette: "square",
+ renderer: "svg",
+ order: ringsFrom({ col: 4, row: 4 }),
+ },
+ 0,
+ );
+ // the diagonals: a Euclidean radius would give each of these its own value, a Chebyshev
+ // one gives them all the SAME value as their ring
+ const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order;
+ expect(at(4, 1), "(4,1) and (1,4) are on the same Chebyshev ring").toBe(at(1, 4));
+ expect(at(4, 2)).toBe(at(2, 4));
+ // 0 leads and 1 trails, so a FARTHER ring is a LARGER value
+ expect(at(4, 1), "ring 3 outranks ring 2").toBeGreaterThan(at(4, 2));
+ expect(at(4, 2), "ring 2 outranks ring 1").toBeGreaterThan(at(4, 3));
+ });
+
+ it("reaches 1 at the far corner for EVERY origin, not just the middle one", () => {
+ // The bug this catches: normalising by a fixed denominator, or by the distance to the
+ // nearest edge, leaves the last ring short of 1 whenever the origin is off-centre — so
+ // the wave never quite arrives, and by an amount that depends on where you clicked.
+ for (const origin of [
+ { col: 0, row: 0 },
+ { col: 8, row: 0 },
+ { col: 0, row: 8 },
+ { col: 4, row: 4 },
+ { col: 2, row: 6 },
+ ]) {
+ const f = field(
+ {
+ cols: 9,
+ rows: 9,
+ silhouette: "square",
+ renderer: "svg",
+ order: ringsFrom(origin),
+ },
+ 0,
+ );
+ expect(Math.max(...f.map((d) => d.order)), `origin ${origin.col},${origin.row}`).toBe(1);
+ }
+ });
+
+ it("normalises so the outermost dot leads at 1 whatever the grid", () => {
+ // otherwise a 9×9 and a 3×3 would animate at different rates for the same nominal shape
+ for (const n of [3, 5, 9, 16]) {
+ const f = field(
+ {
+ cols: n,
+ rows: n,
+ silhouette: "square",
+ renderer: "svg",
+ order: ringsFrom({ col: Math.floor(n / 2), row: Math.floor(n / 2) }),
+ },
+ 0,
+ );
+ const far = Math.max(...f.map((d) => d.order));
+ expect(far, `${n}×${n}`).toBeCloseTo(1, 6);
+ }
+ });
+
+ it("does not divide by zero when the origin is the only cell", () => {
+ const f = field(
+ {
+ cols: 1,
+ rows: 1,
+ silhouette: "square",
+ renderer: "svg",
+ order: ringsFrom({ col: 0, row: 0 }),
+ },
+ 0,
+ );
+ expect(f[0]!.order).toBe(0);
+ });
+});
+
+describe("the code shown beside the field", () => {
+ it("starts at the first export, not at the file's opening comment", async () => {
+ // A visitor's first question is what to copy. Five paragraphs of rationale about why
+ // these examples exist is for us, and scrolling past it to reach the function is the
+ // wrong first impression of a code sample.
+ const raw = (await import("../src/traversals.ts?raw")).default as string;
+ const shown = raw.slice(raw.indexOf("export"));
+ expect(shown.startsWith("export"), "and it is a real slice of this file").toBe(true);
+ expect(shown).toContain("export const chevron");
+ expect(shown).toContain("export function ringsFrom");
+ // the header comment is where it starts, so slicing must exclude it
+ expect(shown, "the file header is not shown").not.toContain(
+ "Two traversals the library does not ship",
+ );
+ // and nothing was lost that a copy needs: the type import is the one thing that is
+ expect(shown).not.toContain("import type");
+ });
+
+ it("cannot drift from the code that runs, because it IS that code", async () => {
+ // The whole reason for `?raw` rather than a hand-maintained string: a copied snippet of
+ // a function is a snippet that lies the first time someone edits the function.
+ const raw = (await import("../src/traversals.ts?raw")).default as string;
+ expect(raw, "the raw import is the file's own text").toContain("const arm = Math.abs");
+ expect(raw).toContain("return v * v;");
+ });
+});
+
+describe("picking the origin by clicking", () => {
+ it("maps a click near the centre to the centre cell", () => {
+ const pitch = 260 / 9;
+ expect(nearestCell({ x: 0, y: 0 }, { x: pitch, y: pitch }, 9, 9)).toEqual({ col: 4, row: 4 });
+ });
+
+ it("maps the top-left corner to cell 0,0", () => {
+ const pitch = 260 / 9;
+ const half = 260 / 2;
+ expect(nearestCell({ x: -half, y: -half }, { x: pitch, y: pitch }, 9, 9)).toEqual({
+ col: 0,
+ row: 0,
+ });
+ });
+
+ it("clamps a click past the edge rather than indexing out of the lattice", () => {
+ // The dot marker is drawn INSIDE the field box, so a click near the border can land a
+ // little beyond the last cell. An unclamped index would select nothing and the demo
+ // would silently stop responding at exactly the edge a visitor aims for.
+ const pitch = 260 / 9;
+ const far = { x: 9999, y: 9999 };
+ expect(nearestCell(far, { x: pitch, y: pitch }, 9, 9)).toEqual({ col: 8, row: 8 });
+ });
+});