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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ One feature: `order`, a caller-supplied traversal.

### Changed

- **The arrow is narrow now, and the centre can be dragged.** Both were reported against the demo: the chevron looked like a glow in one corner, and a centre you had to hit with a single click was a target nobody aimed at twice.
- **The chevron is quantised**, the way `ring` already is, and the traversal is `|along - arm|` so its zero-cells ARE the arrow. The first version was a continuous ramp whose level sets are V shapes in theory and a corner blob in practice — `arm` takes five distinct values on a 9-row field and no envelope can resolve a line out of five steps. It also moves from `morph` to `spiral`: the traversal is ours but the envelope is the preset's, and a plateau HOLDS its level instead of running a crest along the arrow.
- **The centre is a drag**, with `setPointerCapture` and `touch-action: none`, and the hint names the gesture. The handle is a ring rather than a dot: a 7px disc on a field of 81 dots reads as one more dot, and the ring is what says "this is a thing you move".

- **Two lines of small grey text are gone.** T

## [0.2.0] — 2026-10-01
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

### 变更

- **箭头现在是窄的,中心可以拖拽。** 两点都是针对演示提的:原来的 chevron 看着像角落里一团光晕,而只能单击命中的中心是个没人会瞄准第二次的目标。
- **chevron 现在被量化**,与 `ring` 早已采用的方式相同,且遍历取 `|along - arm|`,所以它的零值格**就是**箭头本身。第一版是连续斜坡,其等级集理论上是 V 形、实际是角落的团 —— `arm` 在 9 行的场上只有 5 个不同取值,而任何包络都无法从 5 个台阶里解析出一条线。同时 preset 从 `morph` 换成 `spiral`:遍历是我们的,但包络是 preset 的,而 plateau 会**保持**电平,而不是沿着箭头跑出一道波峰。
- **中心改为可拖拽**,带 `setPointerCapture` 和 `touch-action: none`,提示文案直接说出这个手势。把手做成环而不是点:81 个点里的一个 7px 实心圆会被读成又一个点,而环才说明"这是个你可以移动的东西"。

- **两行灰色小字已移除。** 复制按钮上方写着"你调过的参数,只多不少",颜色拾色器写着"{value} 会原样传给引擎 —— 这里无法编辑它"。两句都是在复述一行之外就能看到的东西:片段就是片段,而字段本来就显示着它持有的值。对读者已经能看到的同一件事再做一份更小的复述,就是那种人们不再读的文字,而它正好夹在控件和即将按下的按钮之间。

## [0.2.0] — 2026-10-01
Expand Down
2 changes: 1 addition & 1 deletion apps/site/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,6 @@
"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_click": "Drag anywhere in 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."
}
2 changes: 1 addition & 1 deletion apps/site/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,6 @@
"demo_traversal_which": "选择遍历",
"demo_traversal_chevron": "箭头",
"demo_traversal_rings": "圆环",
"demo_traversal_click": "点击场来移动中心。",
"demo_traversal_click": "在场内任意位置拖动即可移动中心。",
"demo_traversal_lede": "遍历就是「格位置」的单一函数,所以你能按格计算的任何东西都能变成动效。下面这两个不是 preset — 它们是 `order` 函数,就在这里写着,并在下面运行。"
}
77 changes: 52 additions & 25 deletions apps/site/src/components/TraversalDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Matrix } from "./Matrix.js";
import { m } from "../i18n.js";
import { chevron, nearestCell, ringsFrom } from "../traversals.js";
Expand Down Expand Up @@ -42,6 +42,31 @@ export function TraversalDemo() {
[variant, origin],
);

/**
* A pointer position, in lattice cells.
*
* The field is drawn from its centre outward in the engine's own px geometry, so the
* click has to be measured from the middle rather than from the corner.
*/
const move = useCallback((e: { clientX: number; clientY: number }) => {
const box = fieldRef.current?.getBoundingClientRect();
if (!box) return;
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,
),
);
}, []);

const fieldRef = useRef<HTMLDivElement>(null);

return (
<div className="traversal-demo">
<div className="traversal-demo-head">
Expand All @@ -68,23 +93,22 @@ export function TraversalDemo() {

<div className="traversal-demo-body">
<div
ref={fieldRef}
className="traversal-field"
onClick={(e) => {
// DRAG, not click. A centre you can only reach by clicking a single pixel is a
// target nobody aims at twice, and the arrow demo asked for a centre precisely so
// it could be swept — so a pointer-down commits it and every move while held
// updates it. `touch-action: none` in the CSS, or a touch drag scrolls the page
// instead of moving the centre.
onPointerDown={(e) => {
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,
),
);
e.currentTarget.setPointerCapture(e.pointerId);
move(e);
}}
onPointerMove={(e) => {
// only while held: a hover would make the centre chase the cursor across the
// field the moment it entered, which is not a thing anybody asked for
if (variant === "rings" && e.buttons) move(e);
}}
>
<Matrix
Expand All @@ -96,19 +120,22 @@ export function TraversalDemo() {
dotSize={0.8}
gapX={0.12}
gapY={0.12}
preset={variant === "chevron" ? "morph" : "ripple"}
preset={variant === "chevron" ? "spiral" : "ripple"}
renderer="css"
order={order}
/>
{variant === "rings" && (
<span
className="traversal-origin"
style={{
left: `${(origin.col / (N - 1)) * 100}%`,
top: `${(origin.row / (N - 1)) * 100}%`,
}}
aria-hidden="true"
/>
// The handle, drawn rather than described: a ring with a filled centre, because
// a 7px dot on a field of 81 dots reads as one more dot. The ring is what says
// "this is a thing you move".
<span className="traversal-origin" aria-hidden="true">
<span
style={{
left: `${(origin.col / (N - 1)) * 100}%`,
top: `${(origin.row / (N - 1)) * 100}%`,
}}
/>
</span>
)}
</div>

Expand Down
65 changes: 49 additions & 16 deletions apps/site/src/styles/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,27 @@ h3 {
color: #fff;
}

/* The instruction names the GESTURE, because a centre you can drag and a centre you can
only click are different affordances and the sentence is the only thing that says so. */
.traversal-hint {
margin: 0;
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--muted);
}

.traversal-hint::before {
content: "";
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 25%, transparent);
flex: none;
}

.traversal-demo-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
Expand All @@ -225,33 +240,51 @@ h3 {
.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 */
/* the handle 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 reaches first */
padding: 6px;
/* only the rings variant is interactive; the chevron has no origin to move */
touch-action: none;
cursor: grab;
user-select: none;
}

/* 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;
.traversal-field:active {
cursor: grabbing;
}

/* where the rings currently expand from — the one thing the demo is about, so it is drawn
rather than described */
/* The centre handle: a ring with a filled middle. A 7px dot on a field of 81 dots reads as
one more dot, and the RING is what says "this is a thing you move" — a filled disc says
"this is a pixel", which is what it looked like before. */
/* The overlay is positioned over the FIELD'S CONTENT BOX, not its padding box. `inset: 0`
would span the padding too, so a handle placed at 0% would sit in the padding rather than
on the first row of dots — which is exactly what it did before this was measured. The
field's own padding is the same 6px, so subtracting it here keeps the two in step. */
.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);
inset: 6px;
pointer-events: none;
/* over the dots, which are drawn in `currentColor` at the page's ink */
}

.traversal-origin > span {
position: absolute;
width: 22px;
height: 22px;
margin: -11px 0 0 -11px;
border-radius: 999px;
border: 2.5px solid var(--accent);
background: color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow:
0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent),
0 1px 6px rgb(0 0 0 / 0.25);
z-index: 1;
}

.traversal-field:active .traversal-origin > span {
transform: scale(1.12);
background: color-mix(in srgb, var(--accent) 55%, transparent);
}

.traversal-code p {
margin: 0 0 8px;
font-size: 12px;
Expand Down
27 changes: 15 additions & 12 deletions apps/site/src/traversals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,28 @@ import type { OrderFn } from "@botharness/botui-core";
*/

/**
* A CHEVRON that accelerates toward its tips.
* A NARROW CHEVRON — one cell wide, and quantised like `ring` is.
*
* "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 first version of this was `arm * 0.6 + along * 0.4`, squared. Its level sets are V
* shapes in theory and a soft blob in practice, because `arm` takes only five distinct
* values on a 9-row field and no envelope can resolve a line out of five steps — the demo
* looked like a glow in one corner. Quantisation is what `ring` already does for exactly
* this reason, and on an integer lattice it is the only thing that CAN produce a discrete
* shape.
*
* The value mixes distance from the spine with progress along the field, which is what
* makes it fold rather than simply translate.
* `|along - arm|` is zero ALONG the chevron, so quantising it draws nested outlines of the
* arrow rather than a wash across it. Each is one cell wide because consecutive cells differ
* by more than one band; that is the "narrow" in narrow arrow, and it is what makes the
* shape read as an arrow instead of as a gradient.
*/
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;
// one band per step of the grid: any fewer and adjacent cells share a band and the arrow
// thickens, any more and there is no cell left to light
const bands = Math.max(cols, rows);
return Math.round(Math.abs(along - arm) * bands) / bands;
};

/**
Expand Down
42 changes: 32 additions & 10 deletions apps/site/test/traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,38 @@ const lead = (order: OrderFn, cols = 9, rows = 9) => {
};

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("is zero ALONG the arrow, which is what makes its bands an outline", () => {
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;
// The zero-cells ARE the arrow. Read the whole set rather than a few hand-picked ones:
// `arm` spans five rows and `along` spans nine columns, so the arms land every TWO
// columns — (0,4) (2,3) (4,2) (6,1) (8,0) and back down — and an earlier version of
// this test guessed a 1:1 diagonal and asserted cells that were never on the arrow.
// `field()` emits row-major, so the apex is NOT first — the arms are. Comparing by
// position in the array rather than by geometry is what made this read as a broken
// shape when the shape was fine.
const zero = f
.filter((d) => d.order === 0)
.map((d) => `${d.col},${d.row}`)
.sort((a, b) => Number(a.split(",")[1]) - Number(b.split(",")[1]));
expect(zero, "one cell per row, forming a V").toHaveLength(9);
expect(zero[0], "the top of the upper arm").toBe("8,0");
expect(zero[4], "the apex, at the left edge and the middle row").toBe("0,4");
expect(zero[8], "and the bottom of the lower arm").toBe("8,8");
// and off the arms it is not — which is what makes it an OUTLINE rather than a fill
expect(at(8, 4), "the concave side").toBeGreaterThan(0);
expect(at(0, 0), "the corner the arrow points away from").toBeGreaterThan(0);
});

it("accelerates — the claim the prose makes, checked against the numbers", () => {
it("is NARROW — few distinct bands, so the arrow is a line and not a wash", () => {
// The bug this exists to catch: the first version had a continuous ramp, which on a
// 9-row field cannot resolve a line and rendered as a corner glow.
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]!);
}
const distinct = new Set(f.map((d) => d.order));
expect(distinct.size, "quantised into bands, not continuous").toBeLessThanOrEqual(9);
// and each band's cells are few — a band that covers most of the field is a wash
const biggest = Math.max(...[...distinct].map((v) => f.filter((d) => d.order === v).length));
expect(biggest, "no band covers the field").toBeLessThan(20);
});

it("survives a 1-row field, which a pasted traversal divides by zero on", () => {
Expand Down Expand Up @@ -149,7 +169,9 @@ describe("the code shown beside the field", () => {
// 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;");
expect(raw, "the body the running function has").toContain(
"Math.round(Math.abs(along - arm) * bands)",
);
});
});

Expand Down
Loading