Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的值,改为保留上一个陈旧颜色。现在有一条测试断言色块拿到的永远是它能渲染的形式。
Expand Down
32 changes: 32 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/dot-matrix/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
43 changes: 41 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -232,9 +262,18 @@ export interface DotMatrixOptions {
reducedMotion?: boolean;
}

/** A dot matrix with every option filled in. */
export type ResolvedDotMatrixOptions = Required<Omit<DotMatrixOptions, "stagger">> & {
/**
* 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<Omit<DotMatrixOptions, "stagger" | "order">> & {
stagger: number | null;
order?: OrderFn;
};

/** The geometry of a field, in px. Both renderers are driven from this. */
Expand Down
144 changes: 144 additions & 0 deletions packages/core/test/custom-order.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
75 changes: 75 additions & 0 deletions packages/core/test/readme.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 53 in packages/core/test/readme.test.ts

View workflow job for this annotation

GitHub Actions / verify

'rows' is possibly 'undefined'.

Check failure on line 53 in packages/core/test/readme.test.ts

View workflow job for this annotation

GitHub Actions / verify

'cols' is possibly 'undefined'.
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));
});
});
Loading