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{m.color_passthrough({ value: resolved })}
+ )} +