diff --git a/CHANGELOG.md b/CHANGELOG.md index 499a564..629ce55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ ### Added +- **The dot colour is selectable, and it copies.** A picker in the sticky pane beside the field it colours — a native swatch with the OS colour well and the eyedropper, a text field, and HEX / RGB / HSL chips. The colour reaches the copied snippet, which it did not before: `changedOptions` never emitted `color`, so a visitor who picked a colour and pasted the result got a component that drew in `currentColor` and no indication why. +- **The notation chips are a VIEW, never an edit.** hsl is quantised — `#2f5bff` is `hsl(227 100% 59%)`, and that spelled back out is `#2d5bff` — so a chip that rewrote the value would shift the swatch by a couple of 255ths on every click and twice would be visibly a different colour. Clicking a chip changes only how the value is shown. A test clicks every chip twice and requires the original hex back. +- **Commits are coalesced into one frame.** A colour input fires `input` on every pointer move, far more often than the screen refreshes, and each event would re-render the playground and repaint the field. One write per frame is what makes dragging feel like the thing under your finger is the thing on screen. Getting there found a bug worth naming: the pending colour was held in a ref that was **also assigned on every render**, so the parent's stale value wiped the choice before the frame fired and the picker snapped back. A render is not consent to overwrite. +- **`currentColor` is resolved against the picker, not the document.** It has no pixel value of its own, and the swatch resolved it against ``, which sets no colour — so the swatch showed black for dots that were not black. A swatch that reports the wrong colour is worse than no swatch. A value the picker genuinely cannot edit (`var(--brand)`, `color-mix(...)`) is now passed through untouched and **says so**, rather than being mangled. +- **The generated JSX is now checked by the repo's own compiler.** A new test hands the snippet to `tsc` and requires no syntax error. The TypeScript 7 runtime package exports no compiler API, so this shells out to the same binary `pnpm typecheck` runs — the snippet is validated by the toolchain that will compile it, not by a stand-in. It catches what a brace-balance check cannot, and it was verified to fail when the generator is broken. + - The site opens on the playground, directly under the install command. It is the page's subject, so making you scroll past a component index to reach it was a detour; the island is `client:load` rather than `client:visible` so it is interactive on arrival. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 537f968..080bcae 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -23,6 +23,12 @@ ### 新增 +- **点的颜色现在可选择,并且会被复制。** sticky 面板里、紧挨着它所染色的场有一个拾色器 —— 原生色块(带系统取色面板和吸管)、文本框,以及 HEX / RGB / HSL 三个 chip。颜色会进入复制片段,而此前不会:`changedOptions` 从不输出 `color`,所以选了颜色再粘贴的人拿到的是一个用 `currentColor` 绘制的组件,且没有任何提示为什么。 +- **notation chip 是"视图",绝不是"编辑"。** hsl 是量化的 —— `#2f5bff` 是 `hsl(227 100% 59%)`,而它再写出来是 `#2d5bff` —— 所以一个会改写值的 chip 每次点击都会让色块偏移几个 255th,点两次就是一个肉眼可见的不同颜色。点 chip 只改变值的显示方式。有测试把每个 chip 点两遍,并要求回到原来的 hex。 +- **提交被合并进一帧。** 颜色输入在每次指针移动时都触发 `input`,远快于屏幕刷新,每个事件都会重渲染 playground 并重绘场。每帧一次写入,正是让拖动感觉像"手指下的东西就是屏幕上的东西"的原因。做到这一点时抓到一个值得点名的 bug:待提交的颜色放在一个 ref 里,而那个 ref **每次渲染都会被赋值**,于是父组件的旧值在帧执行前就抹掉了刚选的颜色,拾色器弹回原处。一次渲染不等于同意覆盖。 +- **`currentColor` 是相对拾色器解析的,不是相对文档。** 它自己没有像素值,而色块原先相对 `` 解析 —— body 没有设颜色 —— 于是色块为一群并非黑色的点显示黑色。报错颜色的色块比没有色块更糟。拾色器确实无法编辑的值(`var(--brand)`、`color-mix(...)`)现在原样透传并**明确说明**,而不是被改写掉。 +- **生成的 JSX 现在由仓库自己的编译器检查。** 新测试把片段交给 `tsc` 并要求没有语法错误。TypeScript 7 的运行时包不导出编译器 API,所以这里调用的是 `pnpm typecheck` 所用的同一个二进制 —— 片段由将要编译它的工具链验证,而不是某个替身。它能抓到花括号配平检查抓不到的东西,并且已验证:生成器坏掉时它确实会失败。 + - 站点现在直接以 playground 开头,紧接在安装命令下面。它是页面的主题,让你先滚过组件索引才能 到它是一次多余的绕路;island 用 `client:load` 而不是 `client:visible`,落地即可交互。 - Playground 控件分组:**shape**(silhouette、点的 `sides`、点预设、圆角、拉伸)、**size** diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json index a4fd02d..4a5bcbf 100644 --- a/apps/site/messages/en.json +++ b/apps/site/messages/en.json @@ -123,5 +123,12 @@ "dir_clockwise": "Clockwise", "dir_counterClockwise": "Counter-clockwise", "dir_outsideIn": "Outside → in", - "dir_insideOut": "Inside → out" + "dir_insideOut": "Inside → out", + "ctl_color": "Dot colour", + "ctl_color_notation": "Colour notation", + "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." } diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json index b152822..7e1f84b 100644 --- a/apps/site/messages/zh.json +++ b/apps/site/messages/zh.json @@ -123,5 +123,12 @@ "dir_clockwise": "顺时针", "dir_counterClockwise": "逆时针", "dir_outsideIn": "外 → 内", - "dir_insideOut": "内 → 外" + "dir_insideOut": "内 → 外", + "ctl_color": "点的颜色", + "ctl_color_notation": "颜色规范", + "ctl_color_swatch": "选择颜色", + "color_hex": "HEX", + "color_rgb": "RGB", + "color_hsl": "HSL", + "color_passthrough": "{value} 会原样传给引擎 —— 这里无法编辑它。" } diff --git a/apps/site/src/color.ts b/apps/site/src/color.ts new file mode 100644 index 0000000..e198704 --- /dev/null +++ b/apps/site/src/color.ts @@ -0,0 +1,207 @@ +/** + * Colour notation, for a picker that has to speak more than one. + * + * The engine takes any string CSS accepts — it is handed to `color` on the CSS host and + * to `fill` on the SVG root, with no parsing of its own. That is deliberate: a component + * that validated colour would refuse `var(--brand)` and `color-mix()`, both of which are + * things a real design system hands it. + * + * So the parsing lives HERE, on the page, where it exists only to drive the control. Three + * notations, and the conversions between them: + * + * hex #rgb / #rrggbb / #rrggbbaa what a colour input speaks + * rgb rgb(r g b) / rgb(r g b / a) what people read off a design tool + * hsl hsl(h s% l%) what people actually pick in + * + * WHY NOT A LIBRARY: this is ~90 lines of well-specified arithmetic, the site has no runtime + * dependencies by policy, and a picker whose round-trip loses a colour is worse than no + * picker. The round-trip is asserted rather than assumed. + */ + +/** what a control can show and a user can edit */ +export type Notation = "hex" | "rgb" | "hsl"; + +export interface Rgba { + r: number; + g: number; + b: number; + a: number; +} + +export const isHex = (v: string): boolean => + /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v.trim()); + +/** `#abc` and `#abcd` are shorthand; expanding them is lossless */ +export function parseHex(v: string): Rgba | null { + const t = v.trim().replace(/^#/, ""); + if (![3, 4, 6, 8].includes(t.length)) return null; + if (!/^[0-9a-f]+$/i.test(t)) return null; + const wide = t.length <= 4 ? [...t].map((c) => c + c).join("") : t; + return { + r: parseInt(wide.slice(0, 2), 16), + g: parseInt(wide.slice(2, 4), 16), + b: parseInt(wide.slice(4, 6), 16), + a: wide.length === 8 ? round2(parseInt(wide.slice(6, 8), 16) / 255) : 1, + }; +} + +/** `rgb(12 34 56)`, `rgb(12, 34, 56)`, with an optional alpha — every spelling in use */ +export function parseRgb(v: string): Rgba | null { + const m = /^rgba?\(\s*([^)]+)\s*\)$/i.exec(v.trim()); + if (!m) return null; + // comma or space separated, and `12 34 56 / 0.5` for the alpha + const [body, alpha] = m[1]!.split("/"); + const parts = body! + .trim() + .split(/[\s,]+/) + .filter(Boolean); + if (parts.length < 3) return null; + const num = (s: string) => { + const n = s!.endsWith("%") ? (parseFloat(s!) / 100) * 255 : parseFloat(s!); + return Number.isFinite(n) ? clamp255(n) : NaN; + }; + const r = num(parts[0]!); + const g = num(parts[1]!); + const b = num(parts[2]!); + if (![r, g, b].every(Number.isFinite)) return null; + return { + r, + g, + b, + a: alpha === undefined ? 1 : clamp01(parseFloat(alpha.trim())), + }; +} + +export function parseHsl(v: string): Rgba | null { + const m = /^hsla?\(\s*([^)]+)\s*\)$/i.exec(v.trim()); + if (!m) return null; + const [body, alpha] = m[1]!.split("/"); + const parts = body! + .trim() + .split(/[\s,]+/) + .filter(Boolean); + if (parts.length < 3) return null; + const h = ((parseFloat(parts[0]!) % 360) + 360) % 360; + const s = clamp01(parseFloat(parts[1]!) / 100); + const l = clamp01(parseFloat(parts[2]!) / 100); + if (![h, s, l].every(Number.isFinite)) return null; + const { r, g, b } = hslToRgb(h, s, l); + return { r, g, b, a: alpha === undefined ? 1 : clamp01(parseFloat(alpha.trim())) }; +} + +/** parse whichever notation it is, in the order a browser would */ +export function parseColor(v: string): Rgba | null { + const t = v.trim(); + if (isHex(t)) return parseHex(t); + if (/^rgba?\(/i.test(t)) return parseRgb(t); + if (/^hsla?\(/i.test(t)) return parseHsl(t); + return null; +} + +// ── formatting ─────────────────────────────────────────────────────────────────────────── + +/** six digits, or eight when the colour is not opaque — dropping the alpha would lie */ +export function toHex({ r, g, b, a }: Rgba): string { + const h = (n: number) => Math.round(clamp255(n)).toString(16).padStart(2, "0"); + const base = `#${h(r)}${h(g)}${h(b)}`; + return a >= 1 + ? base + : `${base}${Math.round(clamp01(a) * 255) + .toString(16) + .padStart(2, "0")}`; +} + +/** the modern space-separated form, which is what current CSS tooling emits */ +export function toRgb({ r, g, b, a }: Rgba): string { + const n = (x: number) => Math.round(clamp255(x)); + const base = `rgb(${n(r)} ${n(g)} ${n(b)}`; + return a >= 1 ? `${base})` : `${base} / ${round2(clamp01(a))})`; +} + +export function toHsl({ r, g, b, a }: Rgba): string { + const { h, s, l } = rgbToHsl(r, g, b); + const base = `hsl(${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`; + return a >= 1 ? base : `${base.replace(")", ` / ${round2(clamp01(a))})`)}`; +} + +/** render a colour in the requested notation, or null when it cannot be parsed */ +export function format(v: string, notation: Notation): string | null { + const c = parseColor(v); + if (!c) return null; + return notation === "hex" ? toHex(c) : notation === "rgb" ? toRgb(c) : toHsl(c); +} + +// ── conversions ───────────────────────────────────────────────────────────────────────── + +/** sRGB → HSL. The achromatic case has no hue, and reporting 0 rather than NaN matters: + * a grey picker that shows hue 0 would claim the grey is red. */ +export function rgbToHsl(r: number, g: number, b: number) { + const rn = clamp255(r) / 255; + const gn = clamp255(g) / 255; + const bn = clamp255(b) / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return { h: 0, s: 0, l }; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h: number; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60; + else if (max === gn) h = ((bn - rn) / d + 2) * 60; + else h = ((rn - gn) / d + 4) * 60; + return { h, s, l }; +} + +export function hslToRgb(h: number, s: number, l: number) { + const hh = (((h % 360) + 360) % 360) / 360; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((hh * 6) % 2) - 1)); + const m = l - c / 2; + const seg = Math.floor(hh * 6) % 6; + const [r, g, b] = [ + [c, x, 0], + [x, c, 0], + [0, c, x], + [0, x, c], + [x, 0, c], + [c, 0, x], + ][seg]!; + return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 }; +} + +/** + * What the browser actually resolved a colour to. + * + * `currentColor` — the engine's default — has no pixel value of its own, so a picker + * cannot show it as a hex. Resolving it against a real element is the only honest answer, + * and it means the swatch opens on the colour the page is already using rather than on a + * default the visitor never asked for. + */ +export function resolveComputed(value: string, against?: Element | null): string | null { + if (typeof document === "undefined") return null; + if (!value.trim() || value === "none" || value === "transparent") return null; + if (value === "currentColor") { + // no probe of its own: `currentColor` IS the element's colour, so read it there. + // Resolving it against the document body shows a colour from somewhere else on the + // page — or black, where nothing sets one, which is how a swatch ends up lying. + if (!against) return null; + const own = getComputedStyle(against).color; + const parsed = parseColor(own); + return parsed ? toHex(parsed) : null; + } + const probe = document.createElement("span"); + probe.style.color = value; + // off-screen rather than `display: none`, which would make the computed value useless + probe.style.position = "absolute"; + probe.style.opacity = "0"; + probe.style.pointerEvents = "none"; + document.body.appendChild(probe); + const computed = getComputedStyle(probe).color; + probe.remove(); + const parsed = parseColor(computed); + return parsed ? toHex(parsed) : null; +} + +const clamp255 = (n: number) => (Number.isFinite(n) ? Math.min(255, Math.max(0, n)) : 0); +const clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0); +const round2 = (n: number) => Math.round(n * 100) / 100; diff --git a/apps/site/src/components/ColorPicker.tsx b/apps/site/src/components/ColorPicker.tsx new file mode 100644 index 0000000..c4963ee --- /dev/null +++ b/apps/site/src/components/ColorPicker.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from "react"; +import { m, notationLabel } from "../i18n.js"; +import { format, isHex, parseColor, resolveComputed, toHex, type Notation } from "../color.js"; + +const NOTATIONS: Notation[] = ["hex", "rgb", "hsl"]; + +/** + * The dot colour. + * + * THREE THINGS THIS GETS RIGHT, and all three were decided by what the colour actually is + * rather than by what a colour picker usually looks like. + * + * 1. THE NOTATION CHIPS DO NOT REWRITE THE COLOUR. hsl is quantised — `#2f5bff` is + * `hsl(227 100% 59%)`, and that spelled back out is `#2d5bff` — so a chip that rewrote + * the value would shift the swatch by a couple of 255ths every time it was clicked, and + * twice would be a visibly different colour. The chips are a VIEW; the canonical value + * only changes when somebody edits it. + * + * 2. THE SWATCH OPENS ON THE COLOUR THE PAGE ALREADY USES. `currentColor` — the engine's + * default — has no pixel value of its own, so it is resolved against the document. A + * picker that opened on a default nobody asked for would be picking a colour the + * visitor did not choose. + * + * 3. THE UPDATES ARE COALESCED INTO A FRAME. A colour input fires `input` on every pointer + * move — far more often than the screen refreshes — and each one would re-render the + * playground and repaint the field. One write per frame is what makes dragging feel + * like the thing under your finger is the thing on screen, which is the whole + * requirement of a "what you see is what you get" control. + */ +export function ColorPicker({ + value, + onChange, +}: { + /** the canonical colour, exactly as the engine will receive it */ + value: string | undefined; + onChange: (color: string) => void; +}) { + const [notation, setNotation] = useState("hex"); + // The EDITABLE text, which is the source of truth for the field. Kept separate from + // `value` so a half-typed string like `hsl(2` is not thrown away on every keystroke. + const [draft, setDraft] = useState(null); + + /** the canonical value, or the resolved `currentColor` when there is nothing set */ + /** this element, so `currentColor` can be resolved against where it is actually read */ + const root = useRef(null); + + const resolved = value ?? "currentColor"; + const shown = draft ?? format(resolved, notation) ?? resolved; + // What the native swatch should show. It only speaks hex, so it cannot show + // `var(--brand)` or a `color-mix()` — and the engine's `currentColor` means "whatever + // THIS element's colour is", which is why the fallback resolves against the picker and + // not against the document body: the body has no colour set, so resolving there showed + // black, and a swatch that reports black for a colour the dots are not painted black is + // simply lying. + const swatch = format(resolved, "hex") ?? resolveComputed(resolved, root.current) ?? "#000000"; + + /** the pending frame, so a burst of events costs one write */ + const frame = useRef(0); + /** + * The colour the booked frame will write. + * + * A ref, because the frame must not close over a stale `value` the way a captured + * `onChange` would — and NOT assigned during render, because a render is not consent to + * overwrite: the parent still holds the previous value until the frame runs, so + * `latest.current = value` on every render wiped the colour the moment someone picked + * one, and the picker snapped back to `currentColor` before the frame ever fired. + */ + const pending = useRef(null); + + const commit = (color: string) => { + pending.current = color; + if (frame.current) return; // a frame is already booked; it will pick this value up + frame.current = requestAnimationFrame(() => { + frame.current = 0; + const next = pending.current; + pending.current = null; + if (next != null) onChange(next); + }); + }; + + // A booked frame must not survive unmount, or it fires into a dead tree. + useEffect(() => () => cancelAnimationFrame(frame.current), []); + + return ( +
+
+ +
+ {NOTATIONS.map((n) => ( + + ))} +
+
+ +
+ {/* the native swatch: a real colour well, with the eyedropper and the OS picker */} + { + setDraft(null); + commit(e.target.value); + }} + /> + { + 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)); + }} + // on blur, snap an abbreviated form to its full spelling: `#abc` → `#aabbcc` + onBlur={() => { + const parsed = parseColor(draft ?? shown); + setDraft(null); + if (parsed && isHex(draft ?? shown)) commit(toHex(parsed)); + }} + /> +
+ + {/* 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 })}

+ )} +
+ ); +} diff --git a/apps/site/src/components/Playground.tsx b/apps/site/src/components/Playground.tsx index cd32df7..f176be0 100644 --- a/apps/site/src/components/Playground.tsx +++ b/apps/site/src/components/Playground.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { LiveCard, Matrix, Still } from "./Matrix.js"; import { CardGroup, SilhouetteCard } from "./Cards.js"; +import { ColorPicker } from "./ColorPicker.js"; import { ShowcaseCopy } from "./ShowcaseCopy.js"; import { directionAxisLabel, @@ -192,6 +193,12 @@ export function Playground({ initial }: { initial?: Partial }) 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. */} + {/* The colour, in the sticky pane beside the field it colours. It belongs here + for the same reason the copy button does: it is a property of the field you + are looking at, and a colour picked in a sidebar 300px away is a colour you + have to look away to check. */} + set({ color })} /> + diff --git a/apps/site/src/components/snippet.ts b/apps/site/src/components/snippet.ts index 2d00bd4..9de3231 100644 --- a/apps/site/src/components/snippet.ts +++ b/apps/site/src/components/snippet.ts @@ -60,6 +60,12 @@ function changedOptions(t: Tuning): [string, string][] { // direction is a LIST of axis tokens, and an empty one is the motion's own direction — // so the default stays silent and a visitor who set two axes gets both, in one prop const dir = o.direction ?? []; + // `color` is added here rather than in the list above, because the value can be ANY + // string CSS accepts — `var(--brand)`, `color-mix(...)`, a named colour — so it must not + // go through a formatter that would rewrite it. Silent when unset, because + // `currentColor` is what the engine already resolves on its own. + if (o.color != null && o.color !== DEFAULTS.color) out.push(["color", str(o.color)]); + // no braces around the value: the renderer already emits `name={value}` if (dir.length) out.push(["direction", `[${dir.map((d) => `"${d}"`).join(", ")}]`]); if ((o.speed ?? DEFAULTS.speed) !== DEFAULTS.speed) out.push(["speed", num(o.speed)]); diff --git a/apps/site/src/i18n.ts b/apps/site/src/i18n.ts index 12f4a91..d35d82f 100644 --- a/apps/site/src/i18n.ts +++ b/apps/site/src/i18n.ts @@ -96,6 +96,17 @@ const DIRECTION_LABEL: Record string> = { insideOut: m.dir_insideOut, }; +/** the name of a colour notation, as a chip */ +const NOTATION_LABEL: Record string> = { + hex: m.color_hex, + rgb: m.color_rgb, + hsl: m.color_hsl, +}; + +export function notationLabel(n: string): string { + return NOTATION_LABEL[n]?.() ?? n; +} + export function directionLabel(d: string): string { return DIRECTION_LABEL[d]?.() ?? d; } diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index 9db479b..be83dda 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -167,6 +167,103 @@ h3 { cursor: pointer; } +/* The dot colour. Full width like the copy button, and in the sticky pane beside the + field, because a colour you have to look away from to check is not a colour you are + choosing. */ +.color-picker { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 4px; +} + +.color-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.color-picker-head label { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.color-notation { + display: flex; + gap: 2px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 2px; +} + +.color-notation button { + font: inherit; + font-size: 11px; + letter-spacing: 0.04em; + border: 0; + background: transparent; + color: var(--muted); + border-radius: 6px; + padding: 3px 8px; + cursor: pointer; +} + +/* the active notation, not the active COLOUR: these chips are a view of the value and + never rewrite it, so this is deliberately quieter than the primary button */ +.color-notation button.is-on { + background: var(--accent); + color: #fff; +} + +.color-picker-row { + display: flex; + gap: 10px; + align-items: center; +} + +.color-swatch { + width: 40px; + height: 34px; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: transparent; + cursor: pointer; + flex: none; +} + +.color-swatch::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.color-swatch::-webkit-color-swatch { + border: 0; + border-radius: 6px; +} + +.color-text { + flex: 1; + min-width: 0; + font: inherit; + font-size: 13px; + font-family: ui-monospace, monospace; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; + 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 diff --git a/apps/site/test/color.test.ts b/apps/site/test/color.test.ts new file mode 100644 index 0000000..6552b57 --- /dev/null +++ b/apps/site/test/color.test.ts @@ -0,0 +1,226 @@ +/** + * Colour notation. + * + * The failure here is silent and one-directional: a picker that round-trips a colour + * through hex → rgb → hsl and back loses a bit each time, so switching the notation chips + * twice leaves the swatch on a colour the visitor never chose — and nothing looks broken. + * So the round-trip is the assertion, over a spread of colours chosen to hit the edges: + * the achromatics, the primaries, and the mid-tones where the arithmetic is most lossy. + */ +import { describe, expect, it } from "vitest"; +import { + format, + hslToRgb, + isHex, + parseColor, + parseHex, + parseHsl, + parseRgb, + resolveComputed, + rgbToHsl, + toHex, + toHsl, + toRgb, + type Notation, +} from "../src/color.js"; + +/** far enough apart that any loss is visible */ +const COLORS = [ + "#000000", + "#ffffff", + "#ff0000", + "#00ff00", + "#0000ff", + "#808080", + "#2f5bff", + "#7d9bff", + "#f2a93b", + "#1a1a2e", +]; + +describe("parsing", () => { + it("reads every hex form, including the shorthand", () => { + expect(parseHex("#f00")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + expect(parseHex("#f00f")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + expect(parseHex("#ff0000")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + expect(parseHex("#ff000080")!.a).toBeCloseTo(0.502, 2); + }); + + it("reads every rgb spelling, because they are all still in use", () => { + const want = { r: 18, g: 52, b: 86, a: 1 }; + expect(parseRgb("rgb(18 52 86)")).toEqual(want); + expect(parseRgb("rgb(18, 52, 86)")).toEqual(want); + expect(parseRgb("rgba(18, 52, 86, 1)")).toEqual(want); + expect(parseRgb("rgb(18 52 86 / 0.5)")!.a).toBe(0.5); + expect(parseRgb("rgb(100% 0% 0%)")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it("reads hsl, wrapping hue and clamping the rest", () => { + // c = (1-|2l-1|)s = 0.4, x = 0.2, m = 0.2 → (51, 102, 153), within float noise + const p = parseHsl("hsl(210 50% 40%)")!; + expect(p.r).toBeCloseTo(51, 6); + expect(p.g).toBeCloseTo(102, 6); + expect(p.b).toBeCloseTo(153, 6); + // -150 wraps to 210, which is cyan — a red channel of 0 would mean the wrap did not + // happen at all. The assertion is that it wrapped, not that it stayed red. + expect(Number.isNaN(parseHsl("hsl(-150 50% 50%)")!.r), "wraps, not NaN").toBe(false); + expect(parseHsl("hsl(-150 50% 50%)")!.r).toBeCloseTo(63.75, 6); + expect(parseHsl("hsl(210 50% 40% / 0.25)")!.a).toBe(0.25); + }); + + it("refuses what it cannot represent, rather than guessing", () => { + // `var(--brand)` is what a design system hands a component. The engine passes it + // straight through and must keep doing so; the picker simply cannot edit it. + expect(parseColor("var(--brand)")).toBeNull(); + expect(parseColor("color-mix(in oklch, red, blue)")).toBeNull(); + expect(parseColor("currentColor")).toBeNull(); + expect(parseColor("#12345")).toBeNull(); + expect(isHex("#12345")).toBe(false); + }); + + it("picks the notation by shape", () => { + expect(parseColor("#2f5bff")).toMatchObject({ r: 47, g: 91, b: 255 }); + expect(parseColor("rgb(47 91 255)")).toMatchObject({ r: 47, g: 91, b: 255 }); + expect(parseColor("hsl(227 100% 59%)")!.b, "to the last bit").toBeCloseTo(255, 6); + }); +}); + +describe("round-tripping", () => { + it("is EXACT through hex and rgb, which are lossless re-spellings", () => { + for (const hex of COLORS) { + expect(toHex(parseRgb(toRgb(parseHex(hex)!))!), `${hex} via rgb`).toBe(hex); + expect(toHex(parseHex(toHex(parseHex(hex)!))!), `${hex} via hex`).toBe(hex); + } + }); + + it("converts to hsl and back EXACTLY when nothing is quantised", () => { + // rgbToHsl → hslToRgb are float-in/float-out and must be the identity on a colour. + // This isolates the ALGORITHM from the FORMATTING. + for (const hex of COLORS) { + const { r, g, b } = parseHex(hex)!; + const { h, s, l } = rgbToHsl(r, g, b); + const back = hslToRgb(h, s, l); + expect(back.r, `${hex} r`).toBeCloseTo(r, 6); + expect(back.g, `${hex} g`).toBeCloseTo(g, 6); + expect(back.b, `${hex} b`).toBeCloseTo(b, 6); + } + }); + + it("loses at most a couple of 255ths through the INTEGER hsl triple, and no more", () => { + // An integer hsl triple cannot name every sRGB colour: #2f5bff is hsl(227 100% 59%), + // and that back is #2d5bff. This is arithmetic, not a bug — hue, saturation and + // lightness are each rounded to a whole number — and it is exactly why the control + // never REWRITES the canonical value when the notation chips change: hsl is a VIEW of + // the colour, not a new source of truth. Were the chips to rewrite it, this rounding + // would become a visible drift every time somebody clicked one twice. + let worst = 0; + for (const hex of COLORS) { + const back = parseHsl(toHsl(parseHex(hex)!))!; + const want = parseHex(hex)!; + for (const ch of ["r", "g", "b"] as const) { + worst = Math.max(worst, Math.abs(back[ch] - want[ch])); + } + } + // 3/255, measured over the palette: rounding lightness, hue and saturation each + // contribute, and the three compound. The bound is the POINT — it is what a control + // must not treat as an error, and what it must also not present as a change. + expect(worst, "quantisation stays inside three 255ths, measured").toBeLessThanOrEqual(3); + }); + + it("keeps the alpha channel, and adds a digit only when there is alpha to carry", () => { + const translucent = { r: 18, g: 52, b: 86, a: 0.4 }; + expect(toHex(translucent)).toHaveLength(9); + expect(toHex({ ...translucent, a: 1 }), "opaque stays six digits").toHaveLength(7); + expect(parseHex(toHex(translucent))!.a).toBeCloseTo(0.4, 1); + expect(toRgb(translucent)).toContain("/"); + expect(toRgb({ ...translucent, a: 1 }), "opaque stays the 3-arg form").not.toContain("/"); + }); +}); + +describe("achromatics", () => { + it("reports hue 0 for a grey rather than NaN, and round-trips it", () => { + // A grey that claims to be hue 0 is a grey that claims to be red. The hue is + // meaningless here and must be a number so the round trip survives. + for (const grey of ["#000000", "#808080", "#ffffff"]) { + const { h, s } = rgbToHsl( + ...([parseHex(grey)!.r, parseHex(grey)!.g, parseHex(grey)!.b] as const), + ); + expect(Number.isNaN(h), `${grey} hue`).toBe(false); + expect(s, `${grey} saturation`).toBe(0); + expect(toHex(parseHsl(toHsl(parseHex(grey)!)!)!)).toBe(grey); + } + }); + + it("puts a pure black and a pure white at the ends lightness can reach", () => { + expect(rgbToHsl(0, 0, 0).l).toBe(0); + expect(rgbToHsl(255, 255, 255).l).toBe(1); + }); +}); + +describe("hsl arithmetic", () => { + it("covers all six hue segments", () => { + // 0/60/120/180/240/300 are where a segment table goes wrong, and the classic bug is + // the 300–360 wrap + for (const h of [0, 60, 120, 180, 240, 300, 359]) { + const { r, g, b } = hslToRgb(h, 1, 0.5); + const back = rgbToHsl(r, g, b).h; + expect(Math.abs(back - h), `hue ${h}`).toBeLessThan(1.5); + } + }); + + it("treats lightness 0.5 with full saturation as the pure hue", () => { + const pure = (h: number) => hslToRgb(h, 1, 0.5); + expect(pure(0)).toMatchObject({ r: 255, g: 0, b: 0 }); + expect(pure(120)).toMatchObject({ r: 0, g: 255, b: 0 }); + expect(pure(240)).toMatchObject({ r: 0, g: 0, b: 255 }); + }); +}); + +describe("formatting for a control", () => { + it("converts between notations without changing the colour", () => { + expect(format("#2f5bff", "rgb")).toBe("rgb(47 91 255)"); + expect(format("#2f5bff", "hsl")).toBe("hsl(227 100% 59%)"); + expect(format("rgb(47 91 255)", "hex")).toBe("#2f5bff"); + // and the near-miss, stated rather than hidden + // the reverse spelling lands one 255th away, which is the quantisation stated above + expect(format("hsl(227 100% 59%)", "hex")).toMatch(/^#2[de]5bff$/i); + }); + + it("returns null for a notation it cannot render, so the field can stay untouched", () => { + // The dangerous alternative is echoing the input back, which looks like it worked. + expect(format("var(--brand)", "hex")).toBeNull(); + expect(format("currentColor", "hex")).toBeNull(); + }); + + it("emits the modern space-separated syntax, which is what tooling writes", () => { + const all: Notation[] = ["hex", "rgb", "hsl"]; + for (const n of all) expect(format("#2f5bff", n)).toMatch(/^[#a-z]/i); + }); +}); + +describe("currentColor", () => { + it("resolves `currentColor` against the element it is read on, not the page", () => { + // A swatch showing black because nothing set `color` on is a swatch lying about + // the colour the dots are actually painted. + if (typeof document === "undefined") return; + const host = document.createElement("div"); + host.style.color = "rgb(47 91 255)"; + document.body.appendChild(host); + expect(resolveComputed("currentColor", host)).toBe("#2f5bff"); + host.remove(); + // and with nowhere to read it from, it declines rather than inventing black + expect(resolveComputed("currentColor")).toBeNull(); + }); + + it("resolves a literal against a throwaway probe", () => { + if (typeof document === "undefined") return; + expect(resolveComputed("rgb(47 91 255)")).toBe("#2f5bff"); + expect(resolveComputed("#2f5bff")).toBe("#2f5bff"); + }); + + it("has nothing to resolve for `none` or an empty value", () => { + expect(resolveComputed("none")).toBeNull(); + expect(resolveComputed("")).toBeNull(); + expect(resolveComputed("transparent")).toBeNull(); + }); +}); diff --git a/apps/site/test/i18n.test.ts b/apps/site/test/i18n.test.ts index 21cd21b..01d7f73 100644 --- a/apps/site/test/i18n.test.ts +++ b/apps/site/test/i18n.test.ts @@ -66,6 +66,10 @@ describe("the message catalogue", () => { "ctl_cols", "ctl_rows", "ctl_preset", + // colour notations are the same token in every language, like an option identifier + "color_hex", + "color_rgb", + "color_hsl", // a stylesheet import is code, not prose — it is the same bytes in both languages // for the same reason `install_command` is: the copy has to paste and run "showcase_style", @@ -92,6 +96,7 @@ describe("the message catalogue", () => { // a NEW component must join this list, or its messages read as unrendered and the // scan silently stops covering half the site "components/ShowcaseCopy.tsx", + "components/ColorPicker.tsx", "i18n.ts", "components/Home.astro", "layouts/Base.astro", diff --git a/apps/site/test/playground.test.tsx b/apps/site/test/playground.test.tsx index 556c178..24858c7 100644 --- a/apps/site/test/playground.test.tsx +++ b/apps/site/test/playground.test.tsx @@ -1057,3 +1057,184 @@ describe("a motion the CSS renderer cannot express", () => { ).toBe(false); }); }); + +describe("the dot colour", () => { + const swatch = () => container.querySelector(".color-swatch")!; + const text = () => container.querySelector(".color-text")!; + const chips = () => [...container.querySelectorAll(".color-notation button")]; + + /** a frame, awaited: the picker commits inside requestAnimationFrame */ + /** + * A picker commit, observed. + * + * The write happens inside a requestAnimationFrame — that is the feature — and the React + * state update it triggers needs its own flush after it. So this waits a frame, and then + * lets React settle, and a bare `frame()` was not enough for the value to come back out. + */ + /** one frame — the unit the picker coalesces into */ + const frame = () => + act(async () => { + await new Promise((r) => requestAnimationFrame(() => r(null))); + }); + + const committed = async () => { + await frame(); + await frame(); + await act(async () => {}); + }; + + it("lives in the sticky pane, beside the field it colours", () => { + const picker = container.querySelector(".color-picker")!; + expect( + container.querySelector(".playground-stage")!.contains(picker), + "a colour you have to look away from to check is not one you are choosing", + ).toBe(true); + }); + + it("paints the field, not just its own label", async () => { + useSvgRenderer(); + const before = container.querySelector(".playground-stage svg")!.getAttribute("fill"); + act(() => { + setNativeValue(swatch(), "#ff0000"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + expect(container.querySelector(".playground-stage svg")!.getAttribute("fill")).toBe("#ff0000"); + expect(before, "and it really changed").not.toBe("#ff0000"); + }); + + it("reaches the CSS renderer too, which reads the colour off the host", async () => { + const readColor = () => + container.querySelector(".playground-stage .botui-dot-matrix")!.style.color; + act(() => { + setNativeValue(swatch(), "#00ff00"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + expect(readColor()).toBe("rgb(0, 255, 0)"); + }); + + it("coalesces a burst of pointer moves into ONE commit per frame", async () => { + // A colour input fires `input` on every pointer move — far more often than the screen + // refreshes. This is what makes dragging feel like the thing under your finger is the + // thing on screen, and it is measurable: N events, one write. + const commits: string[] = []; + act(() => { + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + setNativeValue(swatch(), "#111111"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + setNativeValue(swatch(), "#222222"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + setNativeValue(swatch(), "#333333"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + // whatever the engine received, the LAST value won and there was one frame's worth + const after = container.querySelector(".color-text")!.value; + expect(["#111111", "#222222", "#333333"]).toContain(after); + }); + + it("does NOT rewrite the colour when the notation changes", async () => { + // hsl is quantised: #2f5bff is hsl(227 100% 59%), and that spelled back out is #2d5bff. + // A chip that rewrote the value would shift the swatch by a couple of 255ths on every + // click, and twice would be visibly a different colour. The chips are a VIEW. + act(() => { + setNativeValue(swatch(), "#2f5bff"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + const hexBefore = text().value; + expect(hexBefore).toBe("#2f5bff"); + + // walk HEX → RGB → HSL → HEX, clicking each chip TWICE on the way, and require the + // original hex back. A chip that rewrote the value would have drifted by now. + for (const chip of chips()) { + act(() => chip.click()); + act(() => chip.click()); + } + const hexChip = chips().find((c) => /HEX/.test(c.textContent ?? ""))!; + act(() => hexChip.click()); + expect(text().value, "hex survives a round trip through the other notations").toBe(hexBefore); + }); + + it("shows each notation as a different SPELLING of the same colour", async () => { + act(() => { + setNativeValue(swatch(), "#2f5bff"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + const byLabel = (name: RegExp) => { + const chip = chips().find((c) => name.test(c.textContent ?? ""))!; + act(() => chip.click()); + return text().value; + }; + expect(byLabel(/HEX/)).toBe("#2f5bff"); + expect(byLabel(/RGB/)).toBe("rgb(47 91 255)"); + expect(byLabel(/HSL/)).toBe("hsl(227 100% 59%)"); + }); + + it("accepts a typed value in ANY notation, not only the one on screen", async () => { + // A person who typed hsl into a field that happens to be showing HEX meant hsl. + // Asking the current notation to interpret their input would reject their own typing. + act(() => { + setNativeValue(text(), "hsl(0 100% 50%)"); + text().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await committed(); + await committed(); + expect(text().value, "committed, and shown back in the notation on screen").toBe( + "hsl(0 100% 50%)", + ); + const hex = chips().find((c) => /HEX/.test(c.textContent ?? ""))!; + act(() => hex.click()); + expect(text().value, "and it really was red").toBe("#ff0000"); + }); + + it("keeps a half-typed value out of the field, because CSS would discard it", async () => { + const before = text().value; + act(() => { + setNativeValue(text(), "hsl(2"); + text().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await frame(); + // the FIELD is showing the old colour, so the visitor blames their typing rather than + // the control — which is the whole point + expect(text().value, "the draft is kept in the field").toBe("hsl(2"); + const hex = chips().find((c) => /HEX/.test(c.textContent ?? ""))!; + act(() => hex.click()); + 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. + 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); + }); + expect(container.querySelector(".color-note"), "and says why").toBeTruthy(); + }); + + it("copies the colour into the snippet, because a snippet without it is a lie", async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + act(() => { + setNativeValue(swatch(), "#2f5bff"); + swatch().dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await committed(); + expect( + container.querySelector(".color-text")!.value, + "the picker holds the colour it committed", + ).toBe("#2f5bff"); + const button = container.querySelector(".showcase-copy button") as HTMLButtonElement; + await act(async () => button.click()); + const copied = writeText.mock.calls[0]![0] as string; + expect( + copied.includes('color={"#2f5bff"}'), + `copied snippet has no colour prop:\n${copied}`, + ).toBe(true); + }); +}); diff --git a/apps/site/test/snippet.test.ts b/apps/site/test/snippet.test.ts index e68b72b..85ea9fd 100644 --- a/apps/site/test/snippet.test.ts +++ b/apps/site/test/snippet.test.ts @@ -7,7 +7,22 @@ * discovers it when the build breaks. So the assertions here are mostly about the * generated text being real rather than plausible — including compiling it. */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; + +/** the same binary `pnpm typecheck` runs */ +const tscBin = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "node_modules", + ".bin", + "tsc", +); import { snippet } from "../src/components/snippet.js"; import { PRESETS, type DotMatrixOptions } from "@botharness/botui-core"; @@ -178,6 +193,91 @@ describe("the copied snippet", () => { expect(out).toContain("softness={0.333}"); }); + it("produces JSX the repo's own compiler accepts", () => { + // The strongest form of "it compiles": hand the generated element to `tsc`. A + // brace-balance check catches the obvious mistake; this catches the ones nobody + // thought to look for. The TypeScript 7 runtime package exports no compiler API — + // `import ts from "typescript"` gives back a version string and nothing else — so this + // shells out to the same binary `pnpm typecheck` runs, which is the point: the snippet + // is checked by the toolchain that will compile it, not by a stand-in. + const out = snippet( + { + options: { + size: 260, + cols: 5, + rows: 5, + silhouette: "square", + dotSize: 1.98, + preset: "spiral", + direction: ["counterClockwise", "insideOut"], + stagger: 0.7, + speed: 1.5, + color: "#2f5bff", + spec: { sides: 7, radius: 0.5 }, + }, + css: true, + }, + LOCALE, + ); + const element = //.exec(out)?.[0]; + expect(element, "the snippet contains an element").toBeTruthy(); + + const dir = mkdtempSync(join(tmpdir(), "botui-snippet-")); + try { + writeFileSync( + join(dir, "snippet.tsx"), + `import { DotMatrix } from "@botharness/botui-react";\nexport const x = (\n${element}\n);\n`, + ); + const r = spawnSync( + process.execPath, + [ + tscBin, + "--noEmit", + "--jsx", + "react-jsx", + "--target", + "esnext", + "--moduleResolution", + "bundler", + "--module", + "esnext", + join(dir, "snippet.tsx"), + ], + { encoding: "utf8", cwd: join(dirname(fileURLToPath(import.meta.url)), "..", "..") }, + ); + // a missing module is not what this asserts; a SYNTAX error in the element is + const syntax = `${r.stdout}${r.stderr}` + .split("\n") + .filter((l) => /error TS1\d\d\d/.test(l) && !/TS2307|TS2305|TS7016|TS2686/.test(l)); + expect(syntax, "the generated JSX has no syntax error").toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("emits a colour as a JSX string attribute, not a nested literal", () => { + // `color={"#2f5bff"}` — braces around a quoted string. The obvious mistake here is + // running the value through `str()` twice, which nests the quotes and emits JSX that + // does not compile. + expect(build({ color: "#2f5bff" })).toContain('color={"#2f5bff"}'); + }); + + it("passes a colour the engine cannot be shown through the picker verbatim", () => { + // `var(--brand)` is what a design system hands this component. Re-spelling it would + // destroy the reference, and the snippet is what a visitor pastes. + for (const c of ["var(--brand)", "oklch(0.7 0.1 250)", "color-mix(in oklch, red, blue)"]) { + expect(build({ color: c }), c).toContain(`color={"${c}"}`); + } + }); + + it("says nothing about a colour that is the engine's own default", () => { + expect( + build({ color: "currentColor" }), + "currentColor is what it already resolves", + ).not.toContain("color"); + expect(build({})).not.toContain("color="); + }); + it("emits the direction as ONE list prop, so two axes survive together", () => { // The form matters: `direction="counterClockwise"` and a second prop would drop one // axis, and direction is defined as a composition of axis tokens.