From 01bc9abf8f4e66732be0e4aa9d2c5faf6498c11d Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Thu, 1 Oct 2026 14:23:48 +0900 Subject: [PATCH] fix: picking Column Drop left the field blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "Column Drop 这个效果似乎现在看不到". It was not faint — the stage came up with ZERO cells. `columns` moves a highlight down a column, and a per-dot `animation-delay` can only OFFSET a dot's phase, never move anything. So `buildCss` returns null for it, which is the correct and documented behaviour. The playground computed `cssAvailable` correctly, DISABLED the CSS switch and printed the reason — and then passed `renderer="css"` anyway. The engine was asked for something it refuses, and `paintCss` returned without painting anything. Disabling a control without honouring it is the worst of the available answers: the switch explains itself while the field shows nothing. The toggle now records a preference that the preset can overrule, and what the field is actually painted with follows: `css` only when `cssAvailable`, and the readout and the copied snippet say `svg` rather than claiming a mode the engine is not in. A paste that said `renderer="css"` would have handed a consumer the same blank field. The engine normalises the same thing for direct callers, which matters more than the site: `paintable()` rewrites the renderer to `svg` when the preset is not expressible, so `handle.options.renderer` tells the truth about what is on screen. Doing it in the option resolution rather than inside `paintCss` is deliberate — a frame callback retrying a build it knows will fail does that once per frame, and `cssGap()` still reports why. SECOND, AND IT WAS A LIE RATHER THAN DEAD WEIGHT: the stylesheet emitted `@keyframes botui-dm-columns`, and that rule animated nothing. `spike` is a gaussian centred on the cycle seam, so sampling it at the two positions its stop table names — 0 and 1 — gives full brightness at BOTH ends: a static, fully-lit field. Every consumer shipped that rule, and it described a motion the renderer had already refused to draw. `keyframesFor` now applies the same `preset.css` rule `buildCss` does, so the document only carries motions that can actually run — and a test walks every renderable preset to prove none went missing. 215 tests (was 209), oxlint 0, registry ok, both changelog ledgers. --- CHANGELOG.md | 3 ++ CHANGELOG.zh.md | 3 ++ apps/site/src/components/Playground.tsx | 12 ++++-- apps/site/test/playground.test.tsx | 49 +++++++++++++++++++++++++ packages/core/src/dot-matrix/field.ts | 6 +++ packages/core/src/dot-matrix/matrix.ts | 30 +++++++++++++-- packages/core/test/dom.test.ts | 35 ++++++++++++++++++ 7 files changed, 131 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac9ad1..86a7e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ ### Fixed +- **Fixed: picking `Column Drop` left the field blank.** A motion the CSS renderer cannot express — one that MOVES a highlight down a column, which a per-dot `animation-delay` cannot do — was still being rendered through it, and `buildCss` returns null for exactly those. The playground disabled the CSS switch and said so, then passed `renderer="css"` anyway, so the stage came up **empty**. Disabling a control without honouring it is the worst of both answers: the switch explains itself while the field shows nothing. The field now renders the one way it can, and the readout and the copied snippet say `svg` rather than claiming a mode the engine is not in. The engine normalises the same thing for direct callers, so `handle.options.renderer` reports what is actually painted instead of leaving the element empty. +- **The CSS renderer no longer publishes keyframes it cannot honour.** It emitted `@keyframes botui-dm-columns`, and that rule animated _nothing_: `spike` is a gaussian centred on the cycle seam, so sampling it at the two positions its stop table names — 0 and 1 — yields full brightness at both ends, i.e. a static fully-lit field. Shipping that in every consumer's stylesheet was a lie about what the motion looks like, not merely dead weight. Only presets the renderer actually accepts get a rule, which is the same rule `buildCss` applies. + - **Fixed: `direction` reached the state but not the CSS-rendered field.** Setting a direction updated the page and the copied snippet, and the field kept running the old way. The cause was not state management: a cell's traversal offset is written into its **inline style** when the CSS host is built, and `applyCssVars` only ever wrote the host's own variables — size, grid tracks, fill — so nothing could re-rank the cells. Direction was the first option to change the RANKING rather than a size, which is why the silhouette and the dials reached the CSS renderer and this did not. Changing `--botui-o` in place fixes it and, unlike replacing the DOM, does not restart the animations. `applyCssVars` now also reports whether the host still holds the cells these options call for, because a different lattice or silhouette is not patchable and was silently left stale for the same reason. - **The test that should have caught it did not exist.** Every direction test forced the SVG renderer, where the field is a pure function of `(options, t)` and repaints every frame — so the SVG path passed while the site's **default** renderer stayed broken. There is now a test that stays on CSS, plus one that walks the live component rather than building a fresh host each time. The address a cell is matched by is now `data-cell` rather than a read-back of `grid-area`: the shorthand round-trips through the CSS parser, and the `grid-row-start` longhands it decomposes into are not implemented everywhere jsdom runs, which made the sync check report "rebuild" on every single update in a test DOM. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index ea37058..35b4240 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -51,6 +51,9 @@ ### 修复 +- **修复:选中 `Column Drop` 后场是空白的。** CSS 渲染器无法表达的动效 —— 那种把高亮**移动**到下一列的动效,而每格 `animation-delay` 做不到这件事 —— 仍然被交给它渲染,而 `buildCss` 对这些 preset 恰好返回 null。Playground 把 CSS 开关禁用了并说明了原因,**然后照样传 `renderer="css"`**,于是舞台**什么都没有**。禁用一个控件却不遵守它是最糟的答案:开关解释了自己,而场什么也不显示。现在场会用它唯一能的方式渲染,readout 和复制出的片段都如实写 `svg`,而不是声称一个引擎并未处于的模式。引擎侧对直接调用方做同样的归一化,于是 `handle.options.renderer` 报告真正被绘制的东西,而不是留一个空元素。 +- **CSS 渲染器不再发布自己无法兑现的 keyframes。** 它一直在输出 `@keyframes botui-dm-columns`,而那条规则**什么都不动**:`spike` 是一个以周期接缝为中心的 gaussian,所以按它的 stop 表所列的两个位置 —— 0 和 1 —— 采样,两端都是全亮度,也就是一块静止的全亮场。把这个规则塞进每个使用者的样式表里,是对那个动效外观的**谎称**,而不只是无用代码。现在只有渲染器真正接受的 preset 才有规则,与 `buildCss` 用的是同一条判据。 + - **修复:`direction` 到达了状态,却到不了 CSS 渲染的场。** 改了方向后页面和复制片段都更新了,但场仍在按旧方式运行。原因不是状态管理:每一格的遍历偏移在构建 CSS host 时就写进了它的 **inline style**,而 `applyCssVars` 只写 host 自身的变量 —— 尺寸、grid 轨道、fill —— 没有任何东西能重新排名这些格子。方向是第一个改变**排名**而非尺寸的选项,所以剪影和各旋钮能到达 CSS 渲染器而它不能。就地改写 `--botui-o` 修好了这一点,而且不同于替换 DOM,它不会重启动画。`applyCssVars` 现在还会报告 host 是否仍持有这些选项所要求的格子,因为换格阵或换剪影无法就地修补,出于同样的原因此前也被静默地留在过期状态。 - **本该抓住它的测试并不存在。** 所有方向测试都强制走 SVG 渲染器,而那里 field 是 `(options, t)` 的纯函数、每帧重绘 —— 于是 SVG 路径通过,而站点的**默认**渲染器一直是坏的。现在有一条测试留在 CSS 上,还有一条走真实组件而不是每次新建 host。格子匹配所用的地址改用 `data-cell`,而不是回读 `grid-area`:这个简写要经过 CSS 解析器往返,而它拆分出的 `grid-row-start` 长写并非在所有运行 jsdom 的环境都实现,这会让同步检查在测试 DOM 里每次更新都报"需要重建"。 diff --git a/apps/site/src/components/Playground.tsx b/apps/site/src/components/Playground.tsx index acc488f..cd32df7 100644 --- a/apps/site/src/components/Playground.tsx +++ b/apps/site/src/components/Playground.tsx @@ -102,6 +102,12 @@ export function Playground({ initial }: { initial?: Partial }) // is false, so without the default the switch claimed the engine refused a motion it // can express — and it was disabled on first paint for the default field. const cssAvailable = cssRenderable(options.preset ?? DEFAULTS.preset); + // What the field will ACTUALLY be painted with. The toggle records a preference; the + // preset can overrule it — `columns` cannot be expressed in CSS at all, and passing + // `renderer="css"` anyway asked the engine for something it refuses, which left the + // stage EMPTY rather than falling back. Disabling the switch without honouring it is + // the worst of both: the control explains itself while the field shows nothing. + const css = cssRenderer && cssAvailable; const sides = options.spec?.sides ?? GLYPH_DEFAULTS.sides; // the dot cards show ONE field so the only difference between them is the dot: a @@ -179,14 +185,14 @@ export function Playground({ initial }: { initial?: Partial }) {/* The way out, under the field rather than in the sidebar: you tuned this THING, and the button that takes it away belongs to the thing. Spans both columns — it is a full-width primary action, not another sidebar control, and a control that reads as primary should look like one rather than like a ghost button. */} - + {/* Shape, as pictures. Two groups because the engine has two shapes: the field's @@ -446,7 +452,7 @@ export function Playground({ initial }: { initial?: Partial }) `${m.readout_pitch()} ${geometry.pitchX.toFixed(1)} × ${geometry.pitchY.toFixed(1)} px`, `${m.readout_dot()} ${geometry.dotPx.toFixed(1)} px`, `${m.readout_silhouette()} ${silhouetteLabel(options.silhouette ?? DEFAULTS.silhouette)} dot ${polygonName(sides)}`, - `${m.readout_renderer()} ${cssRenderer ? "css" : "svg"} ${m.readout_speed()} ${m.value_speed({ value: (options.speed ?? 1).toFixed(2) })}`, + `${m.readout_renderer()} ${css ? "css" : "svg"} ${m.readout_speed()} ${m.value_speed({ value: (options.speed ?? 1).toFixed(2) })}`, ].join("\n")} diff --git a/apps/site/test/playground.test.tsx b/apps/site/test/playground.test.tsx index 0e27327..556c178 100644 --- a/apps/site/test/playground.test.tsx +++ b/apps/site/test/playground.test.tsx @@ -1008,3 +1008,52 @@ describe("direction", () => { expect(writeText.mock.calls[0]![0] as string).toContain('direction={["insideOut"]}'); }); }); + +describe("a motion the CSS renderer cannot express", () => { + it("still shows a field, because blank is worse than either renderer", () => { + // `columns` moves a highlight down a column; a per-dot animation-delay can only + // OFFSET a phase, so buildCss refuses it. The playground disabled the CSS switch and + // explained itself — while still passing renderer="css", so the stage went EMPTY. + pick(card(/motion|动效/i, "columns")); + const stage = container.querySelector(".playground-stage")!; + expect( + stage.querySelector("svg, .botui-dot-matrix"), + "the field is painted one way or the other", + ).toBeTruthy(); + }); + + it("says svg in the readout, rather than claiming a mode it is not in", () => { + // the CSS switch being disabled is not the same as the field being CSS; a readout that + // still said `css` would be the control claiming something the engine is not doing + pick(card(/motion|动效/i, "columns")); + expect(readout()).toMatch(/renderer\s+svg/); + pick(card(/motion|动效/i, "spiral")); + expect(readout()).toMatch(/renderer\s+css/); + }); + + it('copies renderer="css" only when the field really is CSS', () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + pick(card(/motion|动效/i, "columns")); + const button = container.querySelector(".showcase-copy button") as HTMLButtonElement; + return act(async () => button.click()).then(() => { + const copied = writeText.mock.calls[0]![0] as string; + expect(copied, "a paste that claims CSS would render nothing").not.toContain( + 'renderer="css"', + ); + }); + }); + + it("puts the switch back when a motion comes back that CSS can express", () => { + pick(card(/motion|动效/i, "columns")); + expect( + container.querySelector(".stage-toggle input")!.disabled, + "disabled while refused", + ).toBe(true); + pick(card(/motion|动效/i, "spiral")); + expect( + container.querySelector(".stage-toggle input")!.disabled, + "and usable again", + ).toBe(false); + }); +}); diff --git a/packages/core/src/dot-matrix/field.ts b/packages/core/src/dot-matrix/field.ts index 961d6a8..fcf3de9 100644 --- a/packages/core/src/dot-matrix/field.ts +++ b/packages/core/src/dot-matrix/field.ts @@ -279,6 +279,12 @@ export function buildCss(options: DotMatrixOptions): HTMLElement | null { export function keyframesFor(presetName: PresetName, softness = 0): string | null { const preset = presetFor(presetName); if (!preset) return null; + // No keyframes for a preset the CSS renderer refuses, by the same rule `buildCss` + // applies. Publishing them was a LIE rather than dead weight: the spike envelope is a + // gaussian centred on the seam, so sampling it at the two keyframe positions the stop + // table names — 0 and 1 — yields full brightness at BOTH, and the emitted rule animates + // nothing. `spike` cannot be sampled without knowing the cycle. + if (!preset.css) return null; const env = ENVELOPES[preset.env]; const at = (p: number) => { const s = clamp01(softLevel(p, env, softness)); diff --git a/packages/core/src/dot-matrix/matrix.ts b/packages/core/src/dot-matrix/matrix.ts index 0b0a327..debc370 100644 --- a/packages/core/src/dot-matrix/matrix.ts +++ b/packages/core/src/dot-matrix/matrix.ts @@ -1,8 +1,13 @@ -import type { DotMatrixHandle, DotMatrixOptions, DotRecord } from "../types.js"; +import type { + DotMatrixHandle, + DotMatrixOptions, + DotRecord, + ResolvedDotMatrixOptions, +} from "../types.js"; import { resolveOptions } from "./layout.js"; import { applyCssVars, buildCss, buildSvg, field, resolveField } from "./field.js"; import { ensureStylesheet } from "./css.js"; -import { cssRenderGap } from "./presets.js"; +import { cssRenderable, cssRenderGap } from "./presets.js"; /** the frame a reduced-motion field rests on: representative, not the first one */ const REST_FRAME = 0.18; @@ -39,7 +44,24 @@ export function createDotMatrix( element: HTMLElement, options: DotMatrixOptions = {}, ): DotMatrixHandle { - let live = resolveOptions(options); + /** + * The renderer this field can ACTUALLY paint. + * + * `buildCss` returns null for a preset the CSS renderer cannot express — a per-dot delay + * can offset a phase but cannot move a highlight down a column. Returning nothing left + * the element EMPTY, which is what a visitor saw pick `columns` on a site whose default + * renderer is CSS: the field was simply blank, and the control claiming CSS was not + * wrong so much as meaningless. + * + * Normalising here rather than inside `paintCss` matters for two reasons: it keeps + * `handle.options.renderer` telling the truth about what is on screen, and it means + * `paintCss` never has to retry a build it knows will fail — which, from the frame + * callback, is once per frame. `cssGap()` still says why. + */ + const paintable = (o: ResolvedDotMatrixOptions): ResolvedDotMatrixOptions => + o.renderer === "css" && !cssRenderable(o.preset) ? { ...o, renderer: "svg" } : o; + + let live = paintable(resolveOptions(options)); let t = 0; let raf = 0; let last = 0; @@ -109,7 +131,7 @@ export function createDotMatrix( }, set(patch) { const before = reduced(); - live = { ...live, ...patch, spec: { ...live.spec, ...patch.spec } }; + live = paintable({ ...live, ...patch, spec: { ...live.spec, ...patch.spec } }); if (live.renderer === "css") ensureStylesheet(element.ownerDocument, live.softness); if (before !== reduced() && live.renderer === "svg") t = phase(t); paint(); diff --git a/packages/core/test/dom.test.ts b/packages/core/test/dom.test.ts index 28a0946..007effc 100644 --- a/packages/core/test/dom.test.ts +++ b/packages/core/test/dom.test.ts @@ -140,6 +140,41 @@ describe("the CSS renderer", () => { expect(areas).not.toContain("1 / 1"); // a corner, which a circle drops }); + it("renders SVG when asked for CSS on a preset CSS cannot express", () => { + // The field was BLANK here. `columns` moves a highlight down a column, which a + // per-dot delay cannot do, so `buildCss` returns null — and `paintCss` returned + // without painting anything at all. Disabling the site's CSS switch while still + // requesting CSS left the stage empty, which is worse than either renderer. + const element = document.createElement("div"); + const dm = createDotMatrix(element, { ...base, cols: 5, rows: 5, renderer: "css" }); + // spiral IS expressible, so this is a CSS host + expect(element.querySelector(".botui-dot-matrix")).toBeTruthy(); + + dm.set({ preset: "columns" }); + expect(element.querySelector("svg"), "still a field after switching to columns").toBeTruthy(); + expect( + element.querySelector(".botui-dot-matrix"), + "and not a CSS host it cannot build", + ).toBeNull(); + // and the options tell the truth about what is on screen + expect(dm.options.renderer, "reports the renderer it is actually using").toBe("svg"); + expect(dm.cssGap(), "while still saying why CSS was refused").toBeTruthy(); + dm.destroy(); + }); + + it("publishes no keyframes for a preset the CSS renderer refuses", () => { + // Publishing them was a lie, not dead weight: `spike` is a gaussian centred on the + // cycle seam, so sampling it at the two positions the stop table names — 0 and 1 — + // gives full brightness at BOTH, and the rule animates nothing at all. + expect(keyframesFor("columns"), "no rule for a motion CSS cannot express").toBeNull(); + expect(stylesheet(0)).not.toContain("botui-dm-columns"); + // and the renderable ones are all still there + for (const p of PRESET_KEYS) { + if (p === "off" || !PRESETS[p]!.css) continue; + expect(stylesheet(0), p).toContain(`@keyframes botui-dm-${p}`); + } + }); + it("re-ranks the cells IN PLACE when the direction changes on a live host", () => { // This is the regression test for the bug the feature shipped with: direction reached // the state and the copied snippet but not the field, because `applyCssVars` wrote