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)", + ); }); });