From 8d6ed455ee6422183f661d0493d79f9c45271132 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Fri, 2 Oct 2026 05:28:51 +0900 Subject: [PATCH] feat: you can now supply your own traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `order` takes an `OrderFn` and replaces the preset's. This is the seam the layered design always left open — which dot leads is one function of lattice position — finally reachable from an option rather than only from someone writing their own renderer. IT IS FREE IN BOTH RENDERERS, which is the asymmetry worth stating. The CSS host derives each cell's `animation-delay` from the same traversal value, so a custom traversal costs zero per-frame JS there too. A custom ENVELOPE would not be free: the CSS animation is one shared `@keyframes` published per preset, so a per-caller brightness curve needs the stylesheet's cache key and the animation name to change. Hence traversal only, and a test that asserts the CSS host's `--botui-o` matches — a traversal the engine forgot to apply there would produce two renderers that disagree, which is the one thing this library is arranged to make impossible. TWO THINGS THE TYPE HAS TO GET RIGHT, and the second is a trap: - `order` must be omitted from `ResolvedDotMatrixOptions`'s `Required` alongside `stagger`. Left in, the whole resolved-options type demands a traversal nobody supplied, and `DEFAULTS` would need a default traversal — which is one more thing silently overriding the caller. `order` stays `undefined` until someone passes one, and `resolveField` is where the preset's traversal takes over. - `OrderFn`'s fifth argument stays optional, so an existing four-argument traversal is still a valid `OrderFn` and a custom traversal with no sense of direction is not forced to accept one. THE README EXAMPLE IS EXECUTED BY A TEST. A README snippet that does not compile is worse than none — it is the first thing a reader runs and the thing they judge the API by. The test runs the snippet verbatim, on 1×1 / 1×7 / 7×1 grids where a pasted traversal divides by zero, and asserts that it really ACCELERATES: an easing that did nothing would still run and still look plausible, so the prose's claim has to be checked against the numbers it makes. The snippet is the accelerating chevron, which is one of the two motions asked for — so "accelerating arrows" turned out not to need a new preset at all, only a traversal that eases along its own axis. A centre point for the concentric orders is the same shape of answer: it is geometry the traversal computes, so `order` covers it without a new option. IT IS A FUNCTION, SO IT IS NOT COPYABLE, and the playground's copy button leaves it out rather than emitting a prop that would not compile. A component carrying a custom traversal is a component whose motion is code; that is a real constraint of this route and it is stated in both the README and the option's own docs. 260 tests (was 249), oxlint 0, registry ok, both changelog ledgers. --- CHANGELOG.md | 19 ++++ CHANGELOG.zh.md | 19 ++++ packages/core/README.md | 32 ++++++ packages/core/src/dot-matrix/field.ts | 4 +- packages/core/src/types.ts | 43 ++++++- packages/core/test/custom-order.test.ts | 144 ++++++++++++++++++++++++ packages/core/test/readme.test.ts | 75 ++++++++++++ 7 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/custom-order.test.ts create mode 100644 packages/core/test/readme.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e52d7a7..c451627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,25 @@ he copy button said "Your settings, as props — only what you changed", and the ### Added +- **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 + // a chevron that speeds up toward its tip + const chevron = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const v = arm * 0.6 + (col / (cols - 1 || 1)) * 0.4; + return v * v; + }; + createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); + ``` + + **It costs nothing in the CSS renderer** — the host derives each cell's `animation-delay` from this same value, so a custom traversal is still zero JS per frame. That is the asymmetry worth knowing about: a custom _traversal_ is free in both renderers, while a custom _envelope_ would not be, because the CSS animation is a shared `@keyframes` published per preset. + + **It is a function, so it is not copyable.** A component carrying a custom traversal is a component whose motion is code; the playground's copy button omits it rather than pretending a function is a value. + + The README's example is executed by a test — including on 1×1, 1×7 and 7×1 grids, which is where a pasted snippet divides by zero — and including a check that it really accelerates, since an easing that did nothing would still run and still look plausible. + - the swatch is resolved in an effect, and the ref is actually ON the element. Both got broken while editing this and nothing failed: a ref that is not attached reads as null, the effect returns early, and `?? "#000000"` does the rest — so a fallback that HIDES a missing ref is itself part of the defect. jsdom does not resolve `color` into `getComputedStyle`, so the inherited branch is only observable in a browser; what the test pins is the contract that let it slip, namely that the swatch is never handed the literal string. - the swatch is resolved in an effect, not from a ref. A ref is null during the render that needs it, so the first version always fell through to black — a swatch reporting black for dots that are not black — and `input[type=color]` silently ignores a non-hex value, keeping a stale colour instead. There is now a test that the swatch is always handed a form it will render. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 9a8d682..341c453 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -47,6 +47,25 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea ### 新增 +- **现在可以提供你自己的遍历。** `order` 接受一个 `OrderFn`,替换 preset 自带的 —— 这是分层设计一直留着的接缝,终于能从 option 到达,而不再只存在于自定义渲染器里。你能按格计算的任何东西,都能变成动效: + + ```ts + // 一个向尖端加速的箭头 + const chevron = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const v = arm * 0.6 + (col / (cols - 1 || 1)) * 0.4; + return v * v; + }; + createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); + ``` + + **在 CSS 渲染器下零成本** —— host 就是从同一个值导出每格的 `animation-delay`,所以自定义遍历在那里依然是每帧零 JS。这是值得知道的不对称之处:自定义**遍历**在两个渲染器下都免费,而自定义**包络**不会 —— CSS 动效是按 preset 全局发布的共享 `@keyframes`。 + + **它是函数,因此无法被复制。** 携带自定义遍历的组件,其动效就是代码;playground 的复制按钮会略过它,而不是假装函数是个值。 + + README 里的示例由测试实际执行 —— 包括 1×1、1×7 和 7×1 这些一粘贴就会除零的网格 —— 并且验证它**确实**在加速,因为一个什么都不做的缓动照样能跑、看起来也照样合理。 + - 色块在 effect 里解析,而且 ref 确实挂在元素上。两者都在编辑过程中被弄坏过,且没有任何检查失败:没挂上的 ref 读出来是 null,effect 提前返回,`?? "#000000"` 兜住了剩下的部分 —— 所以一个**掩盖**了 ref 缺失的兜底,本身就是缺陷的一部分。jsdom 不会把 `color` 解析进 `getComputedStyle`,因此继承分支只能在浏览器里观察;测试锁定的是让它溜过去的那条契约:色块永远不会拿到那个字面字符串。 - 色块在 effect 里解析,而不是从 ref。ref 在需要它的那次渲染里必然为 null,所以第一版总是回落到黑色 —— 一个为一群并非黑色的点报告黑色的色块 —— 而且 `input[type=color]` 会静默忽略非 hex 的值,改为保留上一个陈旧颜色。现在有一条测试断言色块拿到的永远是它能渲染的形式。 diff --git a/packages/core/README.md b/packages/core/README.md index 064ffda..a6b9aeb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -59,6 +59,38 @@ Both are driven by the same `field(options, t)`, a pure function of the options | **Glyph** | how one dot is drawn — a polygon plus two corner radii | | **Renderer** | SVG or CSS | +### Your own traversal + +The **Traversal** layer is a plain function of the lattice position returning `0…1`, so +anything you can compute per cell you can animate. Pass it as `order` and it replaces the +preset's: + +```ts +// a chevron that speeds up toward its tip: the traversal value is eased, so successive +// dots along each arm start closer together in phase than a linear ramp would +const chevron = (col: number, row: number, cols: number, rows: number) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); // 0 at the spine, 1 at the tips + const along = col / (cols - 1 || 1); + const v = arm * 0.6 + along * 0.4; // down one arm, then along the other + return v * v; // ease-in: faster toward the tip +}; + +createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); +``` + +Two things worth knowing: + +- **It works in both renderers.** The CSS host derives each cell's `animation-delay` from + this same value, so a custom traversal costs zero per-frame JS there as well. +- **It is a function, so it is not copyable.** A component carrying a custom traversal is a + component whose motion is code — keep it in the source. The playground's copy button + omits it rather than pretending a function is a value. + +`direction` arrives as a **fifth** argument, so a traversal with no sense of direction can +stay four-argument and ignore it, and one that does can read +`dir?.includes("rightToLeft")` without re-deriving anything. + The full option list, and the reasoning behind the size algebra, are in [`src/types.ts`](https://github.com/BotHarness/BotUI/blob/main/packages/core/src/types.ts). diff --git a/packages/core/src/dot-matrix/field.ts b/packages/core/src/dot-matrix/field.ts index fcf3de9..320fae1 100644 --- a/packages/core/src/dot-matrix/field.ts +++ b/packages/core/src/dot-matrix/field.ts @@ -37,7 +37,9 @@ export function resolveField(options: DotMatrixOptions = {}): ResolvedField | nu return { options: o, preset, - order: orderFor(preset.order), + // the caller's own traversal wins over the preset's, and `order` stays undefined + // otherwise rather than being filled in here — see ResolvedDotMatrixOptions + order: o.order ?? orderFor(preset.order), env: ENVELOPES[preset.env], spread: o.stagger ?? preset.spread, glyph: glyphPath(specFor(o.dot, o.spec)), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8870253..253e979 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -198,6 +198,36 @@ export interface DotMatrixOptions { gapX?: number; gapY?: number; preset?: PresetName; + /** + * YOUR OWN TRAVERSAL, replacing the preset's. This is the seam the whole layered design + * leaves open: which dot leads, and in what order, is one function of the lattice + * position, so anything you can compute per cell you can animate. + * + * ```ts + * // a chevron that speeds up toward its tip: the traversal value is eased, so + * // successive dots along the V start closer together in phase + * order: (col, row, cols, rows) => { + * const mid = (rows - 1) / 2; + * const across = Math.abs(row - mid) / (mid || 1); // 0 at the spine, 1 at the tips + * const along = col / (cols - 1 || 1); + * const v = across * 0.5 + along * 0.5; // down one arm, along the other + * return v * v; // ease-in: faster toward the tip + * } + * ``` + * + * The function is called once per cell per frame (SVG) or once per cell when the host is + * built and again whenever it is re-ranked (CSS), so it should be cheap and pure. + * + * `dir` arrives as a FIFTH argument — see `OrderFn`. A four-argument function still works + * and simply ignores it, which is the common case: a custom traversal that has no + * direction of its own should not be forced to accept one. + * + * It is a FUNCTION, and that has one consequence worth stating: it cannot be serialised, + * so the playground's copy-your-settings button leaves it out rather than pretending. + * A component carrying a custom traversal is a component whose motion is code — keep the + * copyable settings for what the library can express, and keep this in the source. + */ + order?: OrderFn; /** * Which way the light travels, as one token per axis. OMITTED or empty is the motion's * own direction — every existing call means that, so this is backward compatible. @@ -232,9 +262,18 @@ export interface DotMatrixOptions { reducedMotion?: boolean; } -/** A dot matrix with every option filled in. */ -export type ResolvedDotMatrixOptions = Required> & { +/** + * A dot matrix with every option filled in. + * + * `order` is omitted from the `Required` for the same reason `stagger` is: absent means + * "use the preset's own", which is not a value the resolver can fill in with anything — + * there is no default traversal, because a default would be one more thing silently + * overriding the caller. It stays `undefined` until a caller supplies one, and + * `resolveField` is where the preset's traversal takes over. + */ +export type ResolvedDotMatrixOptions = Required> & { stagger: number | null; + order?: OrderFn; }; /** The geometry of a field, in px. Both renderers are driven from this. */ diff --git a/packages/core/test/custom-order.test.ts b/packages/core/test/custom-order.test.ts new file mode 100644 index 0000000..e357fda --- /dev/null +++ b/packages/core/test/custom-order.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +/** + * A caller's own traversal. + * + * This is the seam the layered design leaves open — which dot leads is one function of + * lattice position — and a seam is only real if BOTH renderers use it. Every test here + * exists because the failure mode is not a crash: a custom traversal that reaches the SVG + * path and is quietly ignored by the CSS one produces a field that is correct in one + * renderer and wrong in the other, which is exactly the pair of renderers that are supposed + * to be unable to disagree. + */ +import { describe, expect, it } from "vitest"; +import { + buildCss, + createDotMatrix, + field, + resolveField, + resolveOptions, + type DotMatrixOptions, + type OrderFn, +} from "../src/index.js"; + +/** a traversal nothing in the library happens to implement: distance from the top-left */ +const fromCorner: OrderFn = (col, row, cols, rows) => + cols * rows > 1 ? (col + row) / (cols * rows - 1) : 0; + +/** the leading cell of a rendered field, by its lattice address */ +const leader = (options: DotMatrixOptions) => { + const f = field({ cols: 4, rows: 4, silhouette: "square", renderer: "svg", ...options }, 0); + const top = f.reduce((a, b) => (b.order < a.order ? b : a)); + return `${top.col},${top.row}`; +}; + +describe("a caller's own traversal", () => { + it("replaces the preset's, rather than adding to it", () => { + const custom = field({ cols: 5, rows: 5, order: fromCorner, renderer: "svg" }, 0); + const preset = field({ cols: 5, rows: 5, renderer: "svg" }, 0); + // `field()` rounds to 4 places for the public record, so the comparison does too — + // otherwise this asserts the ROUNDING rather than the traversal + const r4 = (n: number) => Math.round(n * 1e4) / 1e4; + expect(custom.map((d) => d.order)).toEqual( + custom.map((d) => r4(fromCorner(d.col, d.row, 5, 5))), + ); + expect(custom.map((d) => d.order)).not.toEqual(preset.map((d) => d.order)); + }); + + it("reaches the CSS renderer, which is the default one", () => { + // The whole reason this is a test and not a comment: the CSS host bakes `--botui-o` + // into each cell, so an order the engine forgot to apply would produce two renderers + // that disagree — the exact failure this library is arranged to make impossible. + // `square` explicitly: the default is `circle`, which keeps 13 of 25 cells, and a + // comparison against a full lattice would then be comparing different grids + const host = buildCss({ + cols: 5, + rows: 5, + silhouette: "square", + order: fromCorner, + renderer: "css", + })!; + const got = [...host.children].map((c) => + Number((c as HTMLElement).style.getPropertyValue("--botui-o")), + ); + // `--botui-o` is written with three decimals by the host, so compare at that precision + expect(got).toEqual( + [...Array(25)].map((_, i) => Number(fromCorner(i % 5, Math.floor(i / 5), 5, 5).toFixed(3))), + ); + }); + + it("moves the LIGHT, not just the numbers", () => { + // A traversal that is merely different is not necessarily a different motion. This one + // leads at the top-left corner, where the default leads at the middle of a spiral. + expect(leader({ order: fromCorner }), "leads at a corner").toBe("0,0"); + }); + + it("is optional, and absent means the preset's own", () => { + const withNone = field({ cols: 5, rows: 5, renderer: "svg" }, 0); + const withUndefined = field({ cols: 5, rows: 5, order: undefined, renderer: "svg" }, 0); + expect(withNone.map((d) => d.order)).toEqual(withUndefined.map((d) => d.order)); + // and the preset still supplies one + expect(resolveField({ cols: 5, rows: 5 })!.order).toBeTypeOf("function"); + expect(resolveField({ cols: 5, rows: 5, order: fromCorner })!.order).toBe(fromCorner); + }); + + it("receives `direction`, and a four-argument function simply ignores it", () => { + // Both shapes are legal: a traversal with no sense of direction should not be forced to + // accept a parameter, and one that does should not have to re-derive it. + const five: OrderFn = (col, _row, cols, _rows, dir) => + dir?.includes("rightToLeft") ? 1 - col / (cols - 1) : col / (cols - 1); + const four: OrderFn = (col, _row, cols) => col / (cols - 1); + + /** + * Which end of a single row LEADS — read from the traversal value, not from the + * brightest dot. The two are not the same question: brightness also depends on where + * the cycle happens to be, so at any one frame the leading cell need not be the + * brightest one. + */ + const leadsAt = (fn: OrderFn, dir: DotMatrixOptions["direction"]) => { + const f = field( + { cols: 5, rows: 1, silhouette: "square", order: fn, direction: dir, renderer: "svg" }, + 0, + ); + return f.reduce((a, b) => (b.order < a.order ? b : a)).col; + }; + + expect(leadsAt(five, []), "left by default").toBe(0); + expect(leadsAt(five, ["rightToLeft"]), "a five-argument traversal can act on it").toBe(4); + expect(leadsAt(four, ["rightToLeft"]), "a four-argument one ignores it").toBe(0); + }); + + it("is re-ranked when it changes on a LIVE CSS host", () => { + // The host bakes `--botui-o` into each cell once. `set()` re-ranks in place, which only + // works if the re-rank reads the CURRENT traversal — so this drives the real + // `createDotMatrix`, not `buildCss`, because the failure is a stale host and a stale + // host is a lifecycle question. + const element = document.createElement("div"); + const dm = createDotMatrix(element, { + cols: 4, + rows: 4, + silhouette: "square", + renderer: "css", + order: fromCorner, + }); + const read = () => + [...element.querySelectorAll(".botui-dot-matrix > i")].map((c) => + (c as HTMLElement).style.getPropertyValue("--botui-o"), + ); + expect(read()[0], "leads at the corner").toBe("0.000"); + + dm.set({ order: (col, row, cols, rows) => 1 - fromCorner(col, row, cols, rows) }); + expect(read()[0], "and the mounted host followed it").toBe("1.000"); + + dm.set({ order: undefined }); + expect(read()[0], "and handing control back returns it to the preset's traversal").not.toBe( + "1.000", + ); + dm.destroy(); + }); + + it("does not appear in a resolved options copy as a default", () => { + // `resolveOptions` fills in everything else, and filling THIS in would mean inventing a + // default traversal — one more thing silently overriding the caller. + expect(resolveOptions({}).order).toBeUndefined(); + }); +}); diff --git a/packages/core/test/readme.test.ts b/packages/core/test/readme.test.ts new file mode 100644 index 0000000..a352140 --- /dev/null +++ b/packages/core/test/readme.test.ts @@ -0,0 +1,75 @@ +/** + * The examples in the README. + * + * A README example that does not compile is worse than no example: it is the first thing + * a reader runs, and it is the thing they will judge the API by. Both snippets here are + * lifted verbatim, so they cannot rot independently of the prose describing them. + */ +import { describe, expect, it } from "vitest"; +import { field, type OrderFn } from "../src/index.js"; + +const chevron: OrderFn = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const along = col / (cols - 1 || 1); + const v = arm * 0.6 + along * 0.4; + return v * v; +}; + +describe("the README's own traversal", () => { + it("runs, and produces a traversal rather than a crash", () => { + // `square` explicitly: the default silhouette is `circle`, which keeps 49 of 81 cells + const f = field( + { + cols: 9, + rows: 9, + silhouette: "square", + preset: "morph", + order: chevron, + renderer: "svg", + }, + 0, + ); + expect(f).toHaveLength(81); + for (const d of f) { + expect(d.order, `${d.col},${d.row}`).toBeGreaterThanOrEqual(0); + expect(d.order).toBeLessThanOrEqual(1); + expect(Number.isFinite(d.opacity), "and a real brightness").toBe(true); + } + }); + + it("survives the degenerate grids a reader will paste it into", () => { + // `mid || 1` and `cols - 1 || 1` are in the snippet precisely because a 1-row or + // 1-column field divides by zero — the kind of thing that only shows up after somebody + // has already copied the code. + for (const [cols, rows] of [ + [1, 1], + [1, 7], + [7, 1], + [3, 3], + [2, 9], + ]) { + const f = field({ cols, rows, silhouette: "square", order: chevron, renderer: "svg" }, 0); + expect(f.length, `${cols}x${rows}`).toBe(cols * rows); + expect( + f.every((d) => Number.isFinite(d.order)), + `${cols}x${rows}`, + ).toBe(true); + } + }); + + it("actually accelerates toward the tips, which is what the prose claims", () => { + // If the easing were a no-op the example would still run and still look plausible, so + // the claim in the text has to be checked against the numbers it produces. + const f = field({ cols: 9, rows: 9, silhouette: "square", order: chevron, renderer: "svg" }, 0); + const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order; + // down the spine, the value rises; and it rises SLOWLY then FASTLY, which is the ease-in + 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} is not larger than step ${i - 1}`).toBeGreaterThan( + steps[i - 1]!, + ); + } + expect(at(8, 4), "and the tip is the far end").toBeGreaterThan(at(0, 4)); + }); +});