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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 里每次更新都报"需要重建"。

Expand Down
12 changes: 9 additions & 3 deletions apps/site/src/components/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export function Playground({ initial }: { initial?: Partial<DotMatrixOptions> })
// 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
Expand Down Expand Up @@ -179,14 +185,14 @@ export function Playground({ initial }: { initial?: Partial<DotMatrixOptions> })
</div>
<Matrix
{...options}
renderer={cssRenderer ? "css" : "svg"}
renderer={css ? "css" : "svg"}
key={`${options.preset ?? DEFAULTS.preset}-${options.silhouette}-${options.cols}x${options.rows}-${options.dot}`}
/>
{/* 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. */}
<ShowcaseCopy tuning={{ options, css: cssRenderer }} />
<ShowcaseCopy tuning={{ options, css }} />
</div>

{/* Shape, as pictures. Two groups because the engine has two shapes: the field's
Expand Down Expand Up @@ -446,7 +452,7 @@ export function Playground({ initial }: { initial?: Partial<DotMatrixOptions> })
`${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")}
</pre>
</div>
Expand Down
49 changes: 49 additions & 0 deletions apps/site/test/playground.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(".stage-toggle input")!.disabled,
"disabled while refused",
).toBe(true);
pick(card(/motion|动效/i, "spiral"));
expect(
container.querySelector<HTMLInputElement>(".stage-toggle input")!.disabled,
"and usable again",
).toBe(false);
});
});
6 changes: 6 additions & 0 deletions packages/core/src/dot-matrix/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
30 changes: 26 additions & 4 deletions packages/core/src/dot-matrix/matrix.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading