From 7e08abe1f9ac3041f702d82a2e4bd324eef1400e Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Fri, 2 Oct 2026 06:51:55 +0900 Subject: [PATCH] fix: the arrow is narrow, and the centre can be dragged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reported against the demo. THE CHEVRON WAS A CORNER GLOW. Its traversal was a continuous ramp, and a continuous ramp cannot draw an arrow here: `arm` takes five distinct values on a 9-row field, so its level sets are V shapes in theory and a blob in practice, and no envelope resolves a line out of five steps. `ring` already solved this by being QUANTISED, on an integer lattice, for exactly this reason — and quantising `|along - arm|` makes the zero-cells be the arrow itself rather than a wash across it. One band per grid step is what keeps it narrow; any fewer and adjacent cells share a band and the arrow thickens. Its preset also moved 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, so the right traversal under the wrong envelope still looked like a static gradient. The test asserts the zero-cells form the V, by reading the WHOLE set rather than a few guessed ones — `arm` spans five rows and `along` nine columns, so the arms land every two columns, and my first attempt asserted a 1:1 diagonal and reported a shape that was fine. THE CENTRE IS A DRAG, not a click. `setPointerCapture` so the gesture survives leaving the element, `touch-action: none` so a touch drag does not scroll the page, and movement only while held — a hover would make the centre chase the cursor the moment it entered the field, which nobody asked for. The hint names the GESTURE, because a centre you can drag and a centre you can click are different affordances and the sentence is the only thing saying so. 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". AND A LAYOUT BUG THE SCREENSHOT CAUGHT, which the drag probe could not: the overlay was `inset: 0`, spanning the field's PADDING box, so a handle at 0% sat in the padding rather than on the first row of dots — it rendered outside the dot grid while every behavioural assertion reported the correct cell. Fixed by insetting the overlay by the same 6px. This is the second time this session that geometry had to be read off a screenshot: the numbers were right and the pixels were somewhere else. 273 tests, oxlint 0, registry ok, both changelog ledgers. --- CHANGELOG.md | 4 ++ CHANGELOG.zh.md | 4 ++ apps/site/messages/en.json | 2 +- apps/site/messages/zh.json | 2 +- apps/site/src/components/TraversalDemo.tsx | 77 +++++++++++++++------- apps/site/src/styles/site.css | 65 +++++++++++++----- apps/site/src/traversals.ts | 27 ++++---- apps/site/test/traversal.test.ts | 42 +++++++++--- 8 files changed, 158 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382efa2..313b83c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 5eeab0a..e7208ef 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -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 diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json index f143281..2d62014 100644 --- a/apps/site/messages/en.json +++ b/apps/site/messages/en.json @@ -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." } diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json index d490522..f310758 100644 --- a/apps/site/messages/zh.json +++ b/apps/site/messages/zh.json @@ -133,6 +133,6 @@ "demo_traversal_which": "选择遍历", "demo_traversal_chevron": "箭头", "demo_traversal_rings": "圆环", - "demo_traversal_click": "点击场来移动中心。", + "demo_traversal_click": "在场内任意位置拖动即可移动中心。", "demo_traversal_lede": "遍历就是「格位置」的单一函数,所以你能按格计算的任何东西都能变成动效。下面这两个不是 preset — 它们是 `order` 函数,就在这里写着,并在下面运行。" } diff --git a/apps/site/src/components/TraversalDemo.tsx b/apps/site/src/components/TraversalDemo.tsx index 737e877..a63beee 100644 --- a/apps/site/src/components/TraversalDemo.tsx +++ b/apps/site/src/components/TraversalDemo.tsx @@ -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"; @@ -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(null); + return (
@@ -68,23 +93,22 @@ export function TraversalDemo() {
{ + // 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); }} > {variant === "rings" && ( -
diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index 3150c68..286c6bb 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -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); @@ -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; diff --git a/apps/site/src/traversals.ts b/apps/site/src/traversals.ts index f07f2f8..8f26336 100644 --- a/apps/site/src/traversals.ts +++ b/apps/site/src/traversals.ts @@ -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; }; /** diff --git a/apps/site/test/traversal.test.ts b/apps/site/test/traversal.test.ts index 4d545bb..9063c04 100644 --- a/apps/site/test/traversal.test.ts +++ b/apps/site/test/traversal.test.ts @@ -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", () => { @@ -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)", + ); }); });