diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md
index 5569031..48faa0a 100644
--- a/docs/architecture/09-visual-design.md
+++ b/docs/architecture/09-visual-design.md
@@ -179,6 +179,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell.
+- **Color inputs go through `ColorField`** — the one home for the native
+ `type="color"` chrome reset and the optional paired hex field (read/copy/retype
+ a value). A bare `` is the code smell; the Theme Builder
+ swatches (`hex`) and the Chart Builder constant-colour binding (`size="sm"`,
+ inside a pill) share it. Per-context removal is the caller's, not the field's.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill —
so a bordered control on a gray panel goes white, never a darker gray), **ghost** (text-only, borderless; a transparent
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css
index ea168a4..8b87c79 100644
--- a/src/app/components/ChartBuilderModal.module.css
+++ b/src/app/components/ChartBuilderModal.module.css
@@ -921,14 +921,9 @@
outline-offset: -2px;
}
+/* Sizing + chrome come from ColorField (size="sm"); only the pill margin is local. */
.constColor {
- width: 28px;
- height: 22px;
margin: 0 var(--space-2);
- padding: 0;
- border: var(--border-width) solid var(--border-strong);
- background: var(--bg);
- cursor: pointer;
}
.constNumber {
diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx
index 0d60430..539001a 100644
--- a/src/app/components/ChartBuilderModal.tsx
+++ b/src/app/components/ChartBuilderModal.tsx
@@ -76,6 +76,7 @@ import {
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
+import { ColorField } from './ColorField';
import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
@@ -310,12 +311,12 @@ function ChannelPill({
value
{isColor ? (
- setChannelConstant(channel, e.target.value)}
+ onChange={(v) => setChannelConstant(channel, v)}
/>
) : (
/^#[0-9a-f]{6}$/i.test(c);
const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
-/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
-function normalizeHex(raw: string): string | null {
- const v = raw.trim().replace(/^#/, '');
- return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
-}
-
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
@@ -91,7 +87,7 @@ const DIVERGING_OPTIONS: SelectControlOption[] = schemesByKind('divergin
preview: gradientBar(schemeColors(s.name, 12)),
}));
-/** A color picker paired with a copyable, editable hex field; optional remove. */
+/** A ColorField swatch with a hover/focus-revealed remove, for editable lists. */
function SwatchRow({
color,
label,
@@ -103,57 +99,18 @@ function SwatchRow({
onChange: (hex: string) => void;
onRemove?: () => void;
}) {
- // Local text so a partially-typed hex isn't rejected mid-keystroke; commits on
- // a valid value, and reverts to the committed color on blur. Resync to the
- // committed color when it changes from the outside (color picker, materialize,
- // scheme) — but not from this field's own commit, so an in-progress edit isn't
- // clobbered. The render-time adjustment is React's documented alternative to a
- // sync effect.
- const [text, setText] = useState(color);
- const [synced, setSynced] = useState(color);
- if (color !== synced) {
- setSynced(color);
- if (normalizeHex(text) !== color) setText(color);
- }
-
return (
- onChange(e.target.value)}
- />
- {
- setText(e.target.value);
- const norm = normalizeHex(e.target.value);
- if (norm) onChange(norm);
- }}
- onBlur={() => setText(color)}
- />
+
{onRemove && (
- // TODO: icon-only control as a raw
);
@@ -265,7 +222,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
set(MARK_COLOR, hex)}
/>
diff --git a/src/app/components/ColorField.module.css b/src/app/components/ColorField.module.css
new file mode 100644
index 0000000..d812514
--- /dev/null
+++ b/src/app/components/ColorField.module.css
@@ -0,0 +1,43 @@
+/* ColorField — token-styled native color swatch (+ optional hex field). */
+
+.field {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+/* Native color input, sized to a swatch and stripped of its OS chrome. */
+.swatch {
+ width: var(--control-height);
+ height: var(--control-height);
+ padding: 0;
+ border: var(--border-width) solid var(--border-strong);
+ background: none;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+/* Compact: sits inside the 24px-tall Chart Builder constant pill. */
+.sm {
+ width: 28px;
+ height: 22px;
+}
+
+.swatch::-webkit-color-swatch-wrapper {
+ padding: 2px;
+}
+.swatch::-webkit-color-swatch {
+ border: none;
+}
+.swatch::-moz-color-swatch {
+ border: none;
+}
+
+.hex {
+ width: 8ch;
+ height: var(--control-height);
+ padding: 0 var(--space-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
diff --git a/src/app/components/ColorField.test.tsx b/src/app/components/ColorField.test.tsx
new file mode 100644
index 0000000..03ba63b
--- /dev/null
+++ b/src/app/components/ColorField.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * ColorField — the shared color-input primitive (swatch + optional hex field).
+ * Covers the render shapes and the hex-entry commit/normalize behavior; the
+ * Theme Builder and Chart Builder integrations are exercised in their own tests.
+ */
+
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { ColorField } from './ColorField';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => {
+ root = createRoot(container);
+ });
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+/** Drive an input's value through the native setter so React's onChange fires. */
+function setNativeValue(el: HTMLInputElement, value: string) {
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
+ setter.call(el, value);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+const swatch = () => container.querySelector('input[type="color"]')!;
+const hexField = () => container.querySelector('input[type="text"]');
+
+describe('ColorField', () => {
+ test('renders just the swatch by default (no hex field)', () => {
+ act(() => root.render( {}} />));
+ expect(swatch().getAttribute('aria-label')).toBe('Fill');
+ expect(swatch().value).toBe('#112233');
+ expect(hexField()).toBeNull();
+ });
+
+ test('falls back to #000000 in the picker for a non-hex value', () => {
+ act(() => root.render( {}} />));
+ expect(swatch().value).toBe('#000000');
+ });
+
+ test('the picker reports its raw value on change', () => {
+ const onChange = vi.fn();
+ act(() => root.render());
+ act(() => setNativeValue(swatch(), '#ff0000'));
+ expect(onChange).toHaveBeenCalledWith('#ff0000');
+ });
+
+ test('hex mode commits a valid hex and normalizes an unprefixed one', () => {
+ const onChange = vi.fn();
+ act(() => root.render());
+ const hex = hexField()!;
+ expect(hex.getAttribute('aria-label')).toBe('Fill hex value');
+
+ act(() => setNativeValue(hex, '#abcdef'));
+ expect(onChange).toHaveBeenLastCalledWith('#abcdef');
+
+ act(() => setNativeValue(hex, 'ABCDEF'));
+ expect(onChange).toHaveBeenLastCalledWith('#abcdef');
+ });
+
+ test('hex mode does not commit an incomplete value', () => {
+ const onChange = vi.fn();
+ act(() => root.render());
+ act(() => setNativeValue(hexField()!, '#abc'));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/components/ColorField.tsx b/src/app/components/ColorField.tsx
new file mode 100644
index 0000000..f8af5aa
--- /dev/null
+++ b/src/app/components/ColorField.tsx
@@ -0,0 +1,90 @@
+/**
+ * ColorField — the app's color input (arch 09 §5, component primitives).
+ *
+ * A token-styled native color swatch bound to a hex value, optionally paired
+ * with a copyable/editable hex text field. The single place the `type="color"`
+ * chrome reset and the hex-entry behavior live: the Theme Builder swatches
+ * (`hex`) and the Chart Builder constant-colour binding (`size="sm"`, no hex)
+ * both use it. Removal is the caller's concern — it differs per context (a
+ * per-swatch button vs. the pill's own remove) — so this renders only the
+ * input(s).
+ */
+
+import { useState } from 'react';
+import styles from './ColorField.module.css';
+
+/** True for a `#rrggbb` string — what the native picker accepts. */
+function isHexColor(c: string): boolean {
+ return /^#[0-9a-f]{6}$/i.test(c);
+}
+
+/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
+function normalizeHexColor(raw: string): string | null {
+ const v = raw.trim().replace(/^#/, '');
+ return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
+}
+
+export interface ColorFieldProps {
+ value: string;
+ onChange: (hex: string) => void;
+ /** Accessible name for the swatch; the hex field derives its name from it. */
+ label: string;
+ /** Also render the editable, copyable hex text field beside the swatch. */
+ hex?: boolean;
+ /** `md` (32px, default) for forms; `sm` (compact) to sit inside a pill. */
+ size?: 'md' | 'sm';
+ /** Extra class merged onto the swatch (layout only — e.g. a pill's margin). */
+ className?: string;
+}
+
+export function ColorField({
+ value,
+ onChange,
+ label,
+ hex,
+ size = 'md',
+ className,
+}: ColorFieldProps) {
+ // (hex field) Local text so a partially-typed hex isn't rejected mid-keystroke;
+ // commits on a valid value, reverts to the committed color on blur. Resync to a
+ // value changed from the outside (picker, materialize, scheme) but not to this
+ // field's own commit — the render-time adjustment is React's alternative to a
+ // sync effect.
+ const [text, setText] = useState(value);
+ const [synced, setSynced] = useState(value);
+ if (hex && value !== synced) {
+ setSynced(value);
+ if (normalizeHexColor(text) !== value) setText(value);
+ }
+
+ const swatch = (
+ onChange(e.target.value)}
+ />
+ );
+
+ if (!hex) return swatch;
+
+ return (
+
+ {swatch}
+ {
+ setText(e.target.value);
+ const norm = normalizeHexColor(e.target.value);
+ if (norm) onChange(norm);
+ }}
+ onBlur={() => setText(value)}
+ />
+
+ );
+}