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 @@ -146,6 +146,10 @@ he copy button said "Your settings, as props — only what you changed", and the

### Fixed

- **The drag now tracks the cursor.** The handle drifted further out the further you dragged, which is the symptom of a conversion that was wrong twice over: the pitch was `box.width / cols`, which is not the pitch — the field's box is `size` px including one dot of overhang at each end — and the centre-to-cell step multiplied by the half-width a _second_ time, making the whole thing four times too sensitive. At the rim of a 9-wide field that is a twelve-cell error, clamped: the handle sat at the edge while the cursor was halfway out, and dragging further changed nothing.
- Both numbers now come from the engine's own `layout()`, and `cellCentre` is shared by the handle and the hit-test, so "the handle is under the cursor" is true by construction rather than by two approximations agreeing. The handle is positioned in px from the same geometry rather than as a percentage of the box, because a cell's centre is half a dot in from the edge — a percentage put it 16px out at this size.
- The regression is a test that walks **all 81 cells** and requires the mapping to be the identity, plus one that measures the error growing outward, since "the error grows with distance" is exactly what a hand-picked sample of cells misses.

- **Fixed: `Column Drop` still showed nothing, and this time the field was being clipped rather than missing.** The previous fix made the motion render, and it _was_ rendering — 25 cells, animating, one column lit at a time. The stage's controls column was an `auto` track, and `auto` is sized by max-content, so the refusal caption ("this motion is a highlight moving down the field…", shown only when the CSS renderer refuses the chosen motion) grew that track from **264px to 602px** and squeezed the field's own track to **zero**. The stage is `overflow: hidden`, so the field was not squashed — it was clipped out of existence. The tracks are capped now, and the caption wraps inside the cap instead of widening it.
- **This one has no automated test, on purpose.** jsdom computes no layout, so no test in this repo can catch a collapsed track, and the first attempt at one was worse than nothing: it regexed the stylesheet, matched a **different** component's `repeat(auto-fill, …)`, and stayed green with the bug present. A test that asserts about the wrong rule is not a slow test, it is a wrong one. `AGENTS.md` now records that this class of check needs a real browser over CDP, with the `--user-data-dir` detail that cost the most time to work out.

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea

### 修复

- **拖拽现在跟手了。** 把手越往外拖偏得越多,这个症状来自一个错了两次的换算:pitch 用了 `box.width / cols`,而那不是 pitch —— 场的 box 是 `size` px,两端各含一个点的外伸;而中心到格的那一步又**第二次**乘了半宽,使整体灵敏度变成 4 倍。在 9 格宽场的边缘那是 12 格的误差,被 clamp 掉:光标还在半路把手就已在边缘,再往外拖毫无变化。
- 两个数现在都取自引擎自己的 `layout()`,且把手与命中测试**共用** `cellCentre`,于是"把手就在光标下"由构造保证,而不是靠两处近似恰好一致。把手用同一套几何以 px 定位,而不是场的百分比 —— 因为格的中心是从边缘内半个点的位置,百分比在此尺寸下会偏 16px。
- 回归测试**遍历全部 81 格**要求映射是恒等的,另有一条测量误差是否随距离增长,因为"误差随距离变大"正是手挑几个格子会漏掉的东西。

- **修复:`Column Drop` 仍然什么都看不到,而这次场是被裁掉了而不是缺失。** 上一个修复让这个动效真的渲染出来了,而且它**确实**在渲染 —— 25 个格子、在动、每次点亮一列。舞台的控件列是 `auto` 轨道,而 `auto` 按 max-content 定尺寸,于是那条拒绝提示("这个动效是高亮沿着场移动…" —— 只在 CSS 渲染器拒绝当前动效时显示)把该轨道从 **264px 撑到 602px**,把场自己的轨道挤到 **0**。舞台是 `overflow: hidden`,所以场不是被压扁,而是被整个裁掉。轨道现在设了上限,提示在上限内换行,而不是把列撑宽。
- **这一条故意没有自动化测试。** jsdom 完全不算布局,所以本仓库里没有任何测试能抓住塌掉的轨道;而我写的第一版比没有更糟:它用正则去匹配样式表,匹配到了**另一个**组件的 `repeat(auto-fill, …)`,于是在 bug 存在时依然全绿。能断言到错误规则的测试不是慢的测试,是错的测试。`AGENTS.md` 现在记下了这类检查需要用 CDP 驱动真实浏览器,并写下了最耗时间才弄明白的 `--user-data-dir` 细节。

Expand Down
96 changes: 56 additions & 40 deletions apps/site/src/components/TraversalDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Matrix } from "./Matrix.js";
import { m } from "../i18n.js";
import { chevron, nearestCell, ringsFrom } from "../traversals.js";
import { layout } from "@botharness/botui-core";
import { cellCentre, 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.
Expand All @@ -14,6 +15,26 @@ const COPYABLE = traversalSource.slice(traversalSource.indexOf("export"));

const N = 9;

/**
* The field's geometry, from the ENGINE.
*
* The same numbers `<Matrix>` lays out with, so the handle can be placed on a cell and the
* hit-test can be taken against it with no approximation in between. This is the library's
* own rule — `field()` is the only place geometry is computed — applied to a control that
* has to agree with the thing it is pointing at.
*/
const FIELD = {
cols: N,
rows: N,
silhouette: "square",
dot: "circle",
dotSize: 0.8,
gapX: 0.12,
gapY: 0.12,
size: 260,
} as const;
const GEO = layout(FIELD);

/**
* The `order` seam, on the page.
*
Expand All @@ -37,35 +58,37 @@ export function TraversalDemo() {

// 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
// the handle's position, from the same geometry the hit-test uses
const at = cellCentre(GEO, origin);

const order = useMemo(
() => (variant === "chevron" ? chevron : ringsFrom(origin)),
[variant, origin],
);

/**
* A pointer position, in lattice cells.
* A pointer position, as a cell.
*
* 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.
* Measured from the field's CENTRE and divided by the engine's pitch — the identity, not
* an approximation. An earlier version used `box.width / cols` as the pitch and scaled by
* the half-width a second time, which made the control four times too sensitive: at the
* rim of a 9-wide field that is a twelve-cell error, so the handle saturated at the edge
* while the cursor was halfway out and dragging further did nothing.
*/
const move = useCallback((e: { clientX: number; clientY: number }) => {
const box = fieldRef.current?.getBoundingClientRect();
if (!box) return;
const pitch = box.width / N;
const host = hostRef.current?.getBoundingClientRect();
if (!host) return;
setOrigin(
nearestCell(
{
x: e.clientX - box.left - box.width / 2,
y: e.clientY - box.top - box.height / 2,
},
{ x: pitch, y: pitch },
{ x: e.clientX - host.left - host.width / 2, y: e.clientY - host.top - host.height / 2 },
GEO,
N,
N,
),
);
}, []);

const fieldRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);

return (
<div className="traversal-demo">
Expand Down Expand Up @@ -93,7 +116,6 @@ export function TraversalDemo() {

<div className="traversal-demo-body">
<div
ref={fieldRef}
className="traversal-field"
// 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
Expand All @@ -111,32 +133,26 @@ export function TraversalDemo() {
if (variant === "rings" && e.buttons) move(e);
}}
>
<Matrix
cols={N}
rows={N}
size={260}
silhouette="square"
dot="circle"
dotSize={0.8}
gapX={0.12}
gapY={0.12}
preset={variant === "chevron" ? "spiral" : "ripple"}
renderer="css"
order={order}
/>
{variant === "rings" && (
// 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>
)}
{/* The wrapper is sized by the field, so the overlay inside it lines up with the
host EXACTLY. Positioning the handle in percentages of the padded box put it
up to 16px from the cell it claimed to be on — right for the middle, wrong at
every edge. */}
<div className="traversal-stage" ref={hostRef}>
<Matrix
{...FIELD}
preset={variant === "chevron" ? "spiral" : "ripple"}
renderer="css"
order={order}
/>
{variant === "rings" && (
// 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: at.x, top: at.y }} />
</span>
)}
</div>
</div>

<div className="traversal-code">
Expand Down
15 changes: 10 additions & 5 deletions apps/site/src/styles/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,18 @@ h3 {
/* 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. */
/* Inside a wrapper sized by the field itself, so `inset: 0` IS the host box and a handle
placed in px lands on a cell rather than near one. The field's padding lives on the
OUTER element, which is what keeps the corner handle from being clipped without
perturbing anything the geometry depends on. */
.traversal-stage {
position: relative;
line-height: 0;
}

.traversal-origin {
position: absolute;
inset: 6px;
inset: 0;
pointer-events: none;
}

Expand Down
56 changes: 49 additions & 7 deletions apps/site/src/traversals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,61 @@ export function ringsFrom(origin: { col: number; row: number }): OrderFn {
};
}

/** the cell nearest a click, so a visitor can pick the origin with a pointer */
/**
* The cell a pointer is over, and the pixel position of a cell's centre.
*
* Both come from the ENGINE's own `layout()` numbers, and that is the whole point: the
* handle is drawn where this function says and the hit-test is taken against the same
* function, so "the handle is under the cursor" is true by construction rather than by two
* approximations agreeing.
*
* Two mistakes are written into the old version and are worth naming, because both produce
* a handle that drifts further out the further out you drag — which is exactly the symptom
* that sent this back:
*
* · the pitch was `box.width / cols`, which is not the pitch. The field's box is `size`
* px including one dot of overhang, so the centre-to-centre distance is
* `layout().pitchX` and the box/n is about 3% out before anything else is wrong;
* · and the centre-to-cell conversion multiplied by `(cols - 1) / 2` a second time, making
* the whole thing FOUR TIMES as sensitive as it should be. At the edge of a 9-wide field
* that is a twelve-cell error, clamped — so the handle sat at the rim while the cursor
* was halfway out, and dragging further changed nothing at all.
*
* The fixed version is the identity: a dot's centre is `dotPx/2 + col * pitchX` from the
* field's left edge, and a pointer at offset `x` from the field's centre is
* `x / pitchX` cells from the middle one.
*/
export interface FieldGeometry {
/** centre-to-centre distance in px, per axis */
pitchX: number;
pitchY: number;
/** one dot's width in px — the overhang at each end of the box */
dotPx: number;
}

/** where a cell's centre sits, relative to the field's box */
export function cellCentre(
geo: FieldGeometry,
cell: { col: number; row: number },
): { x: number; y: number } {
return {
x: geo.dotPx / 2 + cell.col * geo.pitchX,
y: geo.dotPx / 2 + cell.row * geo.pitchY,
};
}

/** the cell whose centre is nearest a point given as an offset from the field's centre */
export function nearestCell(
at: { x: number; y: number },
pitch: { x: number; y: number },
geo: FieldGeometry,
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);
const col = Math.round((cols - 1) / 2 + at.x / geo.pitchX);
const row = Math.round((rows - 1) / 2 + at.y / geo.pitchY);
return {
// clamped, because a drag that leaves the field should park the origin at the rim
// rather than index off the lattice
col: Math.min(cols - 1, Math.max(0, col)),
row: Math.min(rows - 1, Math.max(0, row)),
};
Expand Down
79 changes: 61 additions & 18 deletions apps/site/test/traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* 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";
import { field, layout, type OrderFn } from "@botharness/botui-core";
import { cellCentre, 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) => {
Expand Down Expand Up @@ -176,26 +176,69 @@ describe("the code shown beside the field", () => {
});

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 });
/** the same field the demo renders, so the numbers are the engine's real ones */
const FIELD = {
cols: 9,
rows: 9,
silhouette: "square",
dot: "circle",
dotSize: 0.8,
gapX: 0.12,
gapY: 0.12,
size: 260,
} as const;
const GEO = layout(FIELD);
/** a pointer at a cell's centre, as an offset from the field's centre */
const overCell = (col: number, row: number) => {
const c = cellCentre(GEO, { col, row });
return { x: c.x - GEO.size / 2, y: c.y - GEO.size / 2 };
};

it("is the IDENTITY — a pointer on a cell's centre selects that cell", () => {
// This is the whole complaint. The previous formula used `box.width / cols` as the
// pitch AND scaled by the half-width a second time, so it was four times too
// sensitive: at the rim of a 9-wide field that is a twelve-cell error, clamped, and the
// handle sat at the edge while the cursor was halfway out. `box.width / cols` is not
// even the pitch — the box is `size` px including a dot of overhang at each end.
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
expect(nearestCell(overCell(col, row), GEO, 9, 9), `pointer on ${col},${row}`).toEqual({
col,
row,
});
}
}
});

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("does not drift as the pointer moves outward, which is what a drag reports", () => {
// The symptom, stated as the assertion: the error against the true cell must not grow
// with distance from the middle.
for (const col of [0, 1, 2, 3, 5, 6, 7, 8]) {
const got = nearestCell(overCell(col, 4), GEO, 9, 9);
expect(Math.abs(got.col - col), `pointer on col ${col}`).toBeLessThanOrEqual(1);
}
});

it("places the handle where the hit-test will look for it", () => {
// Both consume `cellCentre`, which is the point: the handle cannot be drawn somewhere
// the hit-test does not consider.
const c = cellCentre(GEO, { col: 7, row: 2 });
expect(nearestCell({ x: c.x - GEO.size / 2, y: c.y - GEO.size / 2 }, GEO, 9, 9)).toEqual({
col: 7,
row: 2,
});
});

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 });
it("puts the first cell's centre one half-dot from the box edge, not at it", () => {
// The overhang is half a dot at each end, so a cell centre is `dotPx/2` in. Placing the
// handle at 0% instead put it half a dot outside the first dot — 16px at this size.
expect(cellCentre(GEO, { col: 0, row: 0 }).x).toBeCloseTo(GEO.dotPx / 2, 6);
});

it("clamps a pointer past the edge rather than indexing off the lattice", () => {
// A drag that leaves the field should park the origin at the rim rather than select
// nothing — which is the first place anyone drags to.
expect(nearestCell({ x: 9999, y: 9999 }, GEO, 9, 9)).toEqual({ col: 8, row: 8 });
expect(nearestCell({ x: -9999, y: -9999 }, GEO, 9, 9)).toEqual({ col: 0, row: 0 });
});
});
Loading