diff --git a/CHANGELOG.md b/CHANGELOG.md index 4333ceb..e52d7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Two lines of small grey text are gone.** T + ## [0.2.0] — 2026-10-01 This release adds `direction` and fixes three defects the new option exposed. It is a minor @@ -41,8 +45,7 @@ worth stating. The playground, the bilingual docs and the registry moved to Astro + React islands on Cloudflare Pages. Site-only work — the copy-with-your-settings button, the colour picker, the showcase defaults, and the layout fixes — does not appear in any package. - -### Changed +he copy button said "Your settings, as props — only what you changed", and the colour picker said "currentColor is passed to the engine as-is — it cannot be edited here". Both restated something visible one line away: the snippet is the snippet, and the field already shows whatever value it holds. A second, smaller copy of a fact the reader can see is the copy people stop reading, and it sat between the controls and the button they were about to press. - **Every motion can now be told which way to go.** `direction` is a list of axis tokens — `leftToRight`, `topToBottom`, `clockwise`, `outsideIn` and their opposites — and a spiral winds _and_ chooses an end, so `direction={["counterClockwise", "insideOut"]}` is one motion rather than two presets. It is a **list** because a row-major snake has two independent axes: which end of the first row leads, and whether the rows run top to bottom. Its four corners are four distinct drawings, and a single `reverse` flag could only ever reach two of them. The token names are relative to the motion rather than the screen, which is why `bottomToTop` flips a column-snake's travel but reverses a row-snake's rows. - **Each traversal is offered only the directions it can honour.** A spiral gets a winding and a radius; a ring gets a radius and **no winding**, because a Chebyshev distance is not a walk around a circle — a `clockwise` card there would be a control claiming something the engine cannot do. `ORDER_DIRECTION_AXES` is the table a UI reads, so a control and the engine cannot disagree about what exists. @@ -183,6 +186,8 @@ All notable changes to BotUI are recorded here. The format follows ### Changed +### Changed + - **The playground opens on the CSS renderer**, as a switch on the stage beside the field it renders. It is the default because it costs zero JS per frame and SVG is the escape hatch for the motions CSS cannot express — so the field you see on arrival is the one most people will actually ship. The refusal note rides on the switch, so the reason a motion has no CSS form is where you would turn it on. - **The picture cards scroll sideways instead of wrapping.** A wrapped grid grows taller as options are added, and the column it lives in is already taller than the viewport, so every extra shape pushed the sliders further down. A row costs the column nothing. `SHAPE` leads them, then `MOTION`, then `DOT SHAPE`. - The playground opens at a denser dot/cell than the engine's default. At 55% a field reads as scattered pixels, and the page's subject is what a dot IS; the engine's own default is untouched, because that is the library's choice. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 36323c8..9a8d682 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 变更 + +- **两行灰色小字已移除。** 复制按钮上方写着"你调过的参数,只多不少",颜色拾色器写着"{value} 会原样传给引擎 —— 这里无法编辑它"。两句都是在复述一行之外就能看到的东西:片段就是片段,而字段本来就显示着它持有的值。对读者已经能看到的同一件事再做一份更小的复述,就是那种人们不再读的文字,而它正好夹在控件和即将按下的按钮之间。 + ## [0.2.0] — 2026-10-01 本次发布加入 `direction`,并修复了新选项暴露出的三个缺陷。这是 minor 号:**现有使用者的任何用法都不会坏**,但有两处行为变化值得说明。 diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json index 4a5bcbf..353f097 100644 --- a/apps/site/messages/en.json +++ b/apps/site/messages/en.json @@ -110,7 +110,6 @@ "showcase_manual": "select it", "showcase_intro": "Add BotUI's loading indicator to this project.", "showcase_style": "import \"@botharness/botui-core/style.css\";", - "showcase_note": "Your settings, as props — only what you changed.", "dir_group": "Direction", "dir_axis_along": "Direction of travel", "dir_axis_series": "Which way the rows are visited", @@ -129,6 +128,5 @@ "ctl_color_swatch": "Pick a colour", "color_hex": "HEX", "color_rgb": "RGB", - "color_hsl": "HSL", - "color_passthrough": "{value} is passed to the engine as-is — it cannot be edited here." + "color_hsl": "HSL" } diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json index 7e1f84b..4ff6d60 100644 --- a/apps/site/messages/zh.json +++ b/apps/site/messages/zh.json @@ -110,7 +110,6 @@ "showcase_manual": "请手动选中", "showcase_intro": "把这个加载指示器用到当前项目。", "showcase_style": "import \"@botharness/botui-core/style.css\";", - "showcase_note": "你调过的参数,只多不少。", "dir_group": "方向", "dir_axis_along": "行进方向", "dir_axis_series": "行列被访问的顺序", @@ -129,6 +128,5 @@ "ctl_color_swatch": "选择颜色", "color_hex": "HEX", "color_rgb": "RGB", - "color_hsl": "HSL", - "color_passthrough": "{value} 会原样传给引擎 —— 这里无法编辑它。" + "color_hsl": "HSL" } diff --git a/apps/site/src/components/ColorPicker.tsx b/apps/site/src/components/ColorPicker.tsx index 06f6878..4b86e35 100644 --- a/apps/site/src/components/ColorPicker.tsx +++ b/apps/site/src/components/ColorPicker.tsx @@ -139,12 +139,26 @@ export function ColorPicker({ setDraft(e.target.value); // `parseColor`, NOT `format(…, notation)`: a person who typed hsl into a // field that happens to be showing HEX meant hsl, and asking the current - // notation to interpret it would reject their own input. Only a PARSEABLE - // value reaches the engine — committing `hsl(2` would hand CSS a value it - // discards, the field would go blank, and the visitor would blame the picker - // rather than their typing. - const parsed = parseColor(e.target.value); - if (parsed) commit(toHex(parsed)); + // notation to interpret it would reject their own input. + const raw = e.target.value.trim(); + const parsed = parseColor(raw); + if (parsed) { + commit(toHex(parsed)); + return; + } + // A value this picker cannot parse is still a value the ENGINE can take. + // `var(--brand)`, `oklch(...)` and `color-mix(...)` all reach this control and + // all work, because the engine hands the string to CSS untouched — so refusing + // to commit them would make the control narrower than the component, and a + // visitor who pasted a token from their design system would get nothing. + // + // What must NOT be committed is a HALF-TYPED value: `hsl(2` is a parse failure + // that happens to be one of the shapes we pass through. The difference is that + // a real CSS value has balanced brackets and is followed by nothing stray, so + // an unclosed one is the signal. A visitor who typed half a token then sees the + // field keep their text and the field keep its colour, which is the correct + // reading of "not yet a value". + if (looksLikeCompleteCss(raw)) commit(raw); }} // on blur, snap an abbreviated form to its full spelling: `#abc` → `#aabbcc` onBlur={() => { @@ -155,12 +169,33 @@ export function ColorPicker({ /> - {/* A value the picker cannot edit, said plainly. `var(--brand)` is a legitimate - thing to hand this component and it must keep working — so the control reports - that it is showing rather than quietly mangling it. */} - {!parseColor(resolved) && ( -
{m.color_passthrough({ value: resolved })}
- )} + {/* No note for a value the picker cannot edit. `var(--brand)` is a legitimate thing to + hand this component and it must keep working, which it does — the field shows the + value verbatim. A line of small grey text explaining that the thing on screen is + the thing it was given is the definition of redundant: the field already says it, + and a second, smaller copy of the same fact is the one people stop reading. */} ); } + +/** + * Whether an unparseable string is plausibly a COMPLETE CSS colour rather than a + * half-typed one. + * + * The picker passes values it cannot parse straight through, because the engine takes + * them. That is only safe if a half-typed token is distinguishable — and it is, because + * every real CSS colour value closes what it opens. `hsl(2` does not, so it stays a draft + * and the field keeps the colour it had. This is a heuristic and is documented as one: it + * would mis-classify a construct with balanced brackets that CSS then rejects, and the + * consequence is a field that briefly shows an unparsed colour, which is what the engine + * does with any string it is given. + */ +function looksLikeCompleteCss(v: string): boolean { + if (!v) return false; + const opens = (v.match(/\(/g) ?? []).length; + const closes = (v.match(/\)/g) ?? []).length; + if (opens !== closes) return false; + // a bare function call is only complete if it is followed by something, not nothing: + // `var(--x` has balanced brackets too, and is not a value + return !/^[a-z-]+\([^()]*$/i.test(v.trim()); +} diff --git a/apps/site/src/components/ShowcaseCopy.tsx b/apps/site/src/components/ShowcaseCopy.tsx index 297e82b..0b60337 100644 --- a/apps/site/src/components/ShowcaseCopy.tsx +++ b/apps/site/src/components/ShowcaseCopy.tsx @@ -49,7 +49,6 @@ export function ShowcaseCopy({ tuning }: { tuning: Tuning }) { > {label} -{m.showcase_note()}
{/* Always rendered, never conditionally: a `manual` state has to have something to select. Hidden with CSS rather than unmounted so the text stays in the DOM. */} diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index be83dda..8093de3 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -258,12 +258,6 @@ h3 { padding: 7px 9px; } -.color-note { - margin: 0; - font-size: 11px; - color: var(--muted); -} - /* The carried-away settings: the primary action of the playground, so it reads as one. It sits inside .playground-stage, which is a two-column grid (controls | field), so it @@ -280,7 +274,6 @@ h3 { .showcase-copy-head { display: flex; flex-direction: column; - gap: 7px; } /* PRIMARY, and the only primary control on the page. Copying is the one thing a visitor diff --git a/apps/site/test/playground.test.tsx b/apps/site/test/playground.test.tsx index 75cf961..1c88ffa 100644 --- a/apps/site/test/playground.test.tsx +++ b/apps/site/test/playground.test.tsx @@ -1221,16 +1221,47 @@ describe("the dot colour", () => { expect(text().value, "and the engine never saw it", before).not.toBe("hsl(2"); }); - it("says so when the colour is something it cannot edit, rather than mangling it", async () => { - // `var(--brand)` is a legitimate thing to hand this component and the engine passes it - // straight through. Editing it here would destroy the reference. + it("passes a complete-but-unparseable value through, and refuses a half-typed one", async () => { + // The picker cannot parse `var(--brand)` and the ENGINE can, so it is passed straight + // through — otherwise the control would be narrower than the component and a token + // pasted from a design system would do nothing. The risk in doing that is committing + // half a token, and the difference between the two is that a real value closes what + // it opens. act(() => { - const evt = new window.Event("input", { bubbles: true }); - // reach the playground state through the text field, which is the only honest door setNativeValue(text(), "var(--brand)"); - text().dispatchEvent(evt); + text().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await committed(); + expect(text().value, "a complete value is kept").toBe("var(--brand)"); + + act(() => { + setNativeValue(text(), "var(--bra"); + text().dispatchEvent(new window.Event("input", { bubbles: true })); }); - expect(container.querySelector(".color-note"), "and says why").toBeTruthy(); + await committed(); + expect(text().value, "and the half-typed draft is still just a draft").toBe("var(--bra"); + }); + + it("passes a colour it cannot edit through UNCHANGED, rather than mangling it", async () => { + // `var(--brand)` is a legitimate thing to hand this component, and the engine passes it + // straight to CSS. This used to be accompanied by a line of small grey text explaining + // that — which is redundant: the field already shows the value verbatim, and a second, + // smaller copy of the same fact is the one people stop reading. The behaviour it was + // describing is what is asserted here, not the note. + act(() => { + setNativeValue(text(), "var(--brand)"); + text().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await committed(); + expect(text().value, "shown exactly as given").toBe("var(--brand)"); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + const button = container.querySelector(".showcase-copy button") as HTMLButtonElement; + await act(async () => button.click()); + expect( + writeText.mock.calls[0]![0] as string, + "and copied exactly as given, so the reference survives", + ).toContain('color={"var(--brand)"}'); }); it("copies the colour into the snippet, because a snippet without it is a lie", async () => {