Guard Soma component README links

active-uix
dev 5 months ago
parent 8baa422974
commit 2fb1dd78c9

@ -96,6 +96,11 @@ Actualizacion 2026-05-16:
wrapper raiz.
- Documentacion corregida: `AlertDialog` ya importa `createForm` desde
`$libs/forms`, no desde `$soma/components/form`.
- Documentacion corregida: `ColorField` ya no marca `ColorPicker` como
planned, y los links a docs planned inexistentes de `ColorRange*` quedan
como texto sin enlace.
- Guardia nueva: los links relativos entre README de componentes Soma deben
apuntar a documentos existentes.
Actualizacion 2026-05-15:

@ -167,6 +167,26 @@ function collectLangCatalogKeyViolations(node: unknown, prefix = 'components'):
});
}
function collectBrokenSomaComponentReadmeLinks(): string[] {
const root = join(HERE, 'soma', 'components');
return collectPublicSomaComponentDirs().flatMap((dir) => {
const file = join(root, dir, 'README.md');
const source = readFileSync(file, 'utf8');
const violations: string[] = [];
const linkRe = /\]\((\.\.\/[^)]+README\.md)\)/g;
let match: RegExpExecArray | null;
while ((match = linkRe.exec(source))) {
const target = join(root, dir, match[1]);
if (!existsSync(target)) {
violations.push(`${dir}/README.md -> ${match[1]}`);
}
}
return violations;
});
}
describe('UIX layer contracts', () => {
it('pins ActiveUix public service names and rejects legacy aliases', () => {
const uix = createActiveUix({ langs: minimalLang });
@ -359,6 +379,10 @@ describe('UIX layer contracts', () => {
expect(missingReadmes).toEqual([]);
});
it('guards Soma component README links to existing component docs', () => {
expect(collectBrokenSomaComponentReadmeLinks()).toEqual([]);
});
it('guards Soma from external re-export facades', () => {
expect(existsSync(join(HERE, 'soma', 'external'))).toBe(false);
const violations = grepSources(join(HERE, 'soma'), /external\/(?:dates|colors)/);

@ -32,14 +32,14 @@ The shell layout follows the expected pattern: `[Format ▾] [channels…] [alph
## Parts
| Part | Description |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Provider` | Root. Holds `value: ColorValue`, `placeholder`, `format`, `allowedFormats`, `enableAlpha`, + validation + Field merge. |
| `Label` | Single label. Clicking focuses the first segment. |
| `FormatSelect` | Native `<select>` that switches `format`. When `allowedFormats.length === 1` it renders as a read-only `<span>` (same `data-*` contract + `data-locked`) — no interactive element, the format is just labelled. |
| `Input` | `role="group"` container. Exposes `segments` via snippet props. |
| `Segment` | One segment — `r / g / b / h / s / l / alpha / literal`. Numeric channels use `role="spinbutton"`. |
| `HiddenInput` | Rendered automatically by `Input` when `name` is set. Value is the canonical formatted string (`format` determines shape). |
| Part | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Provider` | Root. Holds `value: ColorValue`, `placeholder`, `format`, `allowedFormats`, `enableAlpha`, + validation + Field merge. |
| `Label` | Single label. Clicking focuses the first segment. |
| `FormatSelect` | Native `<select>` that switches `format`. When `allowedFormats.length === 1` it renders as a read-only `<span>` (same `data-*` contract + `data-locked`) — no interactive element, the format is just labelled. |
| `Input` | `role="group"` container. Exposes `segments` via snippet props. |
| `Segment` | One segment — `r / g / b / h / s / l / alpha / literal`. Numeric channels use `role="spinbutton"`. |
| `HiddenInput` | Rendered automatically by `Input` when `name` is set. Value is the canonical formatted string (`format` determines shape). |
## ARIA
@ -51,51 +51,51 @@ The shell layout follows the expected pattern: `[Format ▾] [channels…] [alph
## Data Attributes
| Part | Attribute | Values |
| -------------- | ---------------------------------- | -------------------------------------------- |
| Root | `data-color-field` | Always present |
| Root | `data-format` | `hex` \| `rgb` \| `hsl` |
| Root | `data-invalid` | Present when invalid |
| Root | `data-disabled` | Present when disabled |
| Root | `data-readonly` | Present when readonly |
| Root | `data-required` | Present when required |
| Label | `data-color-field-label` | Always present |
| Label | `data-invalid` / `data-disabled` | Propagated |
| Input | `data-color-field-input` | Always present |
| Input | `data-format` | Propagated from root |
| Input | `data-invalid` / `data-disabled` | Propagated |
| Segment | `data-color-field-segment` | Always present |
| Segment | `data-segment` | `hex \| r \| g \| b \| h \| s \| l \| alpha \| literal` |
| Segment | `data-placeholder` | Present when the segment has no committed value |
| Segment | `data-invalid` / `data-disabled` / `data-readonly` | Per-segment state |
| FormatSelect | `data-color-field-format-select` | Always present |
| FormatSelect | `data-locked` | Present when `allowedFormats.length === 1` |
| FormatSelect | `data-disabled` | Propagated |
| Part | Attribute | Values |
| ------------ | -------------------------------------------------- | ------------------------------------------------------- |
| Root | `data-color-field` | Always present |
| Root | `data-format` | `hex` \| `rgb` \| `hsl` |
| Root | `data-invalid` | Present when invalid |
| Root | `data-disabled` | Present when disabled |
| Root | `data-readonly` | Present when readonly |
| Root | `data-required` | Present when required |
| Label | `data-color-field-label` | Always present |
| Label | `data-invalid` / `data-disabled` | Propagated |
| Input | `data-color-field-input` | Always present |
| Input | `data-format` | Propagated from root |
| Input | `data-invalid` / `data-disabled` | Propagated |
| Segment | `data-color-field-segment` | Always present |
| Segment | `data-segment` | `hex \| r \| g \| b \| h \| s \| l \| alpha \| literal` |
| Segment | `data-placeholder` | Present when the segment has no committed value |
| Segment | `data-invalid` / `data-disabled` / `data-readonly` | Per-segment state |
| FormatSelect | `data-color-field-format-select` | Always present |
| FormatSelect | `data-locked` | Present when `allowedFormats.length === 1` |
| FormatSelect | `data-disabled` | Propagated |
## Keyboard (editable segments)
| Key | Behaviour |
| ----------------- | ------------------------------------------------------------------------------------ |
| `ArrowUp` | Increment channel by 1 (or `+1` on the parsed numeric hex value). Wraps on cyclic channels. |
| `ArrowDown` | Decrement by 1. Wraps at `min` for cyclic channels. |
| `0–9` | Append digit. Auto-advances when the segment fills (6 chars for `hex`, `max`-digit count for decimals). |
| `A–F` / `a–f` | Hex digit — only accepted on the `hex` segment. |
| `Home` | Jump to `min` (0). |
| `End` | Jump to `max` (`0xffffff` for hex, 255 / 359 / 100 for others). |
| `Backspace` | Remove the last typed digit. If already empty, focus moves to previous segment. |
| `Tab` / `ArrowRight` / `ArrowLeft` | Navigate between segments. Respects `dir` via `getDirectionalKeys`. |
| Key | Behaviour |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `ArrowUp` | Increment channel by 1 (or `+1` on the parsed numeric hex value). Wraps on cyclic channels. |
| `ArrowDown` | Decrement by 1. Wraps at `min` for cyclic channels. |
| `0–9` | Append digit. Auto-advances when the segment fills (6 chars for `hex`, `max`-digit count for decimals). |
| `A–F` / `a–f` | Hex digit — only accepted on the `hex` segment. |
| `Home` | Jump to `min` (0). |
| `End` | Jump to `max` (`0xffffff` for hex, 255 / 359 / 100 for others). |
| `Backspace` | Remove the last typed digit. If already empty, focus moves to previous segment. |
| `Tab` / `ArrowRight` / `ArrowLeft` | Navigate between segments. Respects `dir` via `getDirectionalKeys`. |
Literal segments (`#`, `,`, `%`, ` / `) are not focusable.
Literal segments (`#`, `,`, `%`, `/`) are not focusable.
## Channel ranges
| Channel | Format | Min | Max | Radix | Padding | Cycles |
| --------- | ------ | --- | ---------- | ----- | ------------ | ------ |
| `hex` | `hex` | 0 | `0xffffff` | 16 | 6 ("ff6600") | yes |
| `r` / `g` / `b` | `rgb` | 0 | 255 | 10 | — | yes |
| `h` | `hsl` | 0 | 359 | 10 | — | yes |
| `s` / `l` | `hsl` | 0 | 100 | 10 | — | no |
| `alpha` | all | 0 | 100 | 10 | — | no |
| Channel | Format | Min | Max | Radix | Padding | Cycles |
| --------------- | ------ | --- | ---------- | ----- | ------------ | ------ |
| `hex` | `hex` | 0 | `0xffffff` | 16 | 6 ("ff6600") | yes |
| `r` / `g` / `b` | `rgb` | 0 | 255 | 10 | — | yes |
| `h` | `hsl` | 0 | 359 | 10 | — | yes |
| `s` / `l` | `hsl` | 0 | 100 | 10 | — | no |
| `alpha` | all | 0 | 100 | 10 | — | no |
Alpha is always expressed as **percent** in the UI, regardless of the active format.
@ -103,25 +103,25 @@ Alpha is always expressed as **percent** in the UI, regardless of the active for
See `types.ts` for the JSDoc-documented surface. Summary:
| Prop | Type | Notes |
| ------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ |
| `value` | `ColorValue \| undefined` | Bindable. Canonical structured value (hex / rgb / hsl / hsv views). |
| `onValueChange` | `(v) => void` | Fires on every commit. |
| `placeholder` | `ColorValue` | Anchor for arrow-up on empty segments; defaults to `DEFAULT_COLOR`. |
| `onPlaceholderChange` | `(v) => void` | Fires when `placeholder` changes. |
| `format` | `'hex' \| 'rgb' \| 'hsl'` | Bindable. @default `'hex'`. |
| `onFormatChange` | `(format) => void` | Fires when `format` changes. |
| `allowedFormats` | `ColorFormat[]` | Restricts `FormatSelect` options. Single entry → locked. @default all three. |
| `enableAlpha` | `boolean` | Show alpha segment. @default `true`. |
| `readonlySegments` | `EditableColorSegmentPart[]` | Per-channel lock (needs a concrete `value` — else warns via logger). |
| `validate` | `(ColorValue) => string \| void` | Custom validator on committed values. |
| `onInvalid` | `(reason, msg?) => void` | `reason`: `'custom'` \| `'incomplete'`. |
| `disabled` | `boolean` | Merged with parent Field. |
| `readonly` | `boolean` | Merged with parent Field. |
| `required` | `boolean` | Merged with parent Field. |
| `invalid` | `boolean` | External invalid flag. ORed with internal validation. |
| `errorMessageId` | `string` | External error element id (linked via `aria-describedby`). |
| `dir` | `'ltr' \| 'rtl'` | Falls back to `soma.prefs.getDir()`. |
| Prop | Type | Notes |
| --------------------- | -------------------------------- | ---------------------------------------------------------------------------- |
| `value` | `ColorValue \| undefined` | Bindable. Canonical structured value (hex / rgb / hsl / hsv views). |
| `onValueChange` | `(v) => void` | Fires on every commit. |
| `placeholder` | `ColorValue` | Anchor for arrow-up on empty segments; defaults to `DEFAULT_COLOR`. |
| `onPlaceholderChange` | `(v) => void` | Fires when `placeholder` changes. |
| `format` | `'hex' \| 'rgb' \| 'hsl'` | Bindable. @default `'hex'`. |
| `onFormatChange` | `(format) => void` | Fires when `format` changes. |
| `allowedFormats` | `ColorFormat[]` | Restricts `FormatSelect` options. Single entry → locked. @default all three. |
| `enableAlpha` | `boolean` | Show alpha segment. @default `true`. |
| `readonlySegments` | `EditableColorSegmentPart[]` | Per-channel lock (needs a concrete `value` — else warns via logger). |
| `validate` | `(ColorValue) => string \| void` | Custom validator on committed values. |
| `onInvalid` | `(reason, msg?) => void` | `reason`: `'custom'` \| `'incomplete'`. |
| `disabled` | `boolean` | Merged with parent Field. |
| `readonly` | `boolean` | Merged with parent Field. |
| `required` | `boolean` | Merged with parent Field. |
| `invalid` | `boolean` | External invalid flag. ORed with internal validation. |
| `errorMessageId` | `string` | External error element id (linked via `aria-describedby`). |
| `dir` | `'ltr' \| 'rtl'` | Falls back to `soma.prefs.getDir()`. |
### Field integration
@ -129,36 +129,36 @@ Wrapping in `Field.Provider` inherits `disabled`/`readonly`/`required`/`invalid`
## Validation
| Reason | Condition |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `custom` | `validate(ColorValue)` returned a message. |
| `incomplete` | Reserved for future use (e.g. when `required=true` + unfilled segments). |
| Reason | Condition |
| ------------ | ------------------------------------------------------------------------ |
| `custom` | `validate(ColorValue)` returned a message. |
| `incomplete` | Reserved for future use (e.g. when `required=true` + unfilled segments). |
An unfilled segment is **not** invalid on its own — it keeps `value=undefined` until all required channels commit, mirroring DateField / TimeField semantics.
## Snippet props (`Input`)
| Snippet prop | Type | Description |
| ------------ | ----------------------------- | --------------------------------------------------------- |
| `segments` | `ColorSegmentContentObj[]` | Ordered segments for the active format + alpha (if on). |
| Snippet prop | Type | Description |
| ------------ | -------------------------- | ------------------------------------------------------- |
| `segments` | `ColorSegmentContentObj[]` | Ordered segments for the active format + alpha (if on). |
Each element: `{ part: ColorSegmentPart; value: string }` where `value` is the display string (formatted channel value, or placeholder like `'rr'` / `'hhh'` when unset, or a literal like `','` / `'%'`).
## Comparison with reference libraries
| Feature | React Aria `ColorField` | Ark UI `ChannelInput` | Zag.js `channel-input` | **Soma `ColorField`** |
| -------------------------------------- | ----------------------- | ---------------------- | ---------------------- | ------------------------------- |
| Standalone (no picker required) | ✓ | ✗ (part of ColorPicker) | ✗ | ✓ |
| Segmented channels (arrow keys cycle) | ✓ (per-channel) | ✓ (per-channel) | ✓ | ✓ |
| Multiple channels in one field | ✗ (one per component) | ✗ | ✗ | ✓ (native across r/g/b or h/s/l) |
| `FormatSelect` built in | ✗ | separate part | separate part | ✓ |
| `allowedFormats` (lockable) | ✗ | ✗ | ✗ | ✓ **Soma innovation** |
| Alpha as first-class channel | partial | partial | partial | ✓ (percent, always available) |
| HEX `A–F` input | ✓ | ✓ | ✓ | ✓ |
| `readonlySegments` per channel | ✗ | ✗ | ✗ | ✓ |
| `beforeinput` blocks IME / paste | ✗ | ✗ | ✗ | ✓ (A26) |
| Parent Field integration | via Form | ✗ | ✗ | ✓ (soma Field) |
| Anatomy | 2 parts (Label, Input) | 3 parts | 3 parts | 6 parts |
| Feature | React Aria `ColorField` | Ark UI `ChannelInput` | Zag.js `channel-input` | **Soma `ColorField`** |
| ------------------------------------- | ----------------------- | ----------------------- | ---------------------- | -------------------------------- |
| Standalone (no picker required) | ✓ | ✗ (part of ColorPicker) | ✗ | ✓ |
| Segmented channels (arrow keys cycle) | ✓ (per-channel) | ✓ (per-channel) | ✓ | ✓ |
| Multiple channels in one field | ✗ (one per component) | ✗ | ✗ | ✓ (native across r/g/b or h/s/l) |
| `FormatSelect` built in | ✗ | separate part | separate part | ✓ |
| `allowedFormats` (lockable) | ✗ | ✗ | ✗ | ✓ **Soma innovation** |
| Alpha as first-class channel | partial | partial | partial | ✓ (percent, always available) |
| HEX `A–F` input | ✓ | ✓ | ✓ | ✓ |
| `readonlySegments` per channel | ✗ | ✗ | ✗ | ✓ |
| `beforeinput` blocks IME / paste | ✗ | ✗ | ✗ | ✓ (A26) |
| Parent Field integration | via Form | ✗ | ✗ | ✓ (soma Field) |
| Anatomy | 2 parts (Label, Input) | 3 parts | 3 parts | 6 parts |
No mainstream headless library packs all channels into a single segmented field with a built-in format switcher — Ark / Zag split it into `ChannelInput` instances the consumer composes manually, React Aria ships one `ColorField` per channel. Soma unifies them and adds the `FormatSelect` + `allowedFormats` lock.
@ -191,5 +191,5 @@ No mainstream headless library packs all channels into a single segmented field
## Related
- [`ColorPicker`](../color-picker/README.md) — visual picker with area + sliders + swatches (planned). Will compose `ColorField` as its `ChannelInput` equivalent.
- [`ColorRangeField`](../color-range-field/README.md) — paired `ColorField`s for gradient ranges (planned).
- [`ColorPicker`](../color-picker/README.md) — visual picker with area + sliders + swatches. It composes `ColorField` through `ChannelInput`.
- `ColorRangeField` — paired `ColorField`s for gradient ranges (planned).

@ -40,7 +40,9 @@ Full-featured headless color picker — 22 parts, composes `Popover` + `ColorFie
<ColorPicker.ChannelInput>
{#snippet children({ segments })}
{#each segments as segment, i (`${segment.part}:${i}`)}
<ColorPicker.ChannelSegment part={segment.part}>{segment.value}</ColorPicker.ChannelSegment>
<ColorPicker.ChannelSegment part={segment.part}
>{segment.value}</ColorPicker.ChannelSegment
>
{/each}
{/snippet}
</ColorPicker.ChannelInput>
@ -67,32 +69,32 @@ Set `inline` on `Provider` to skip the `Popover` and render the content surface
## Parts (22)
| Part | Source | Notes |
| --------------------- | ------------ | ------------------------------------------------------------------------------- |
| `Provider` | picker | Shared state: `value`, `placeholder`, `format`, `open`, flags. |
| `Label` | picker | Clicking focuses the Trigger. |
| `Control` | picker | Wraps Trigger + inline value display. |
| `Trigger` | picker | Opens the Popover (composes `PopoverTriggerProvider` when not inline). |
| `ValueSwatch` | picker | Current color preview inside the Trigger. |
| `ValueText` | picker | Textual representation of the current color (per `format` prop). |
| `Content` | Popover | Re-export. The floating surface (skipped when `inline=true`). |
| `Arrow`, `Close`, `Overlay`, `Anchor` | Popover | Re-exports. |
| `Area` | picker | 2D channel field (default: saturation x brightness). |
| `AreaBackground` | picker | The colored base plane (hue-hue by default). |
| `AreaThumb` | picker | Draggable handle. `role="slider"` + keyboard. |
| `ChannelSlider` | picker | 1D slider driving a single channel (`channel: ColorChannel`). |
| `ChannelSliderTrack` | picker | Visual gradient for the channel. |
| `ChannelSliderThumb` | picker | `role="slider"` focusable handle. |
| `TransparencyGrid` | picker | Checkerboard for alpha visualization (behind the alpha slider or area). |
| `FormatSelect` | ColorField | Re-export. Switches between allowed formats. |
| `ChannelInput` | ColorField | Re-export. Segmented value editor. |
| `ChannelSegment` | ColorField | Re-export. Single segment of ChannelInput. |
| `SwatchGroup` | picker | `role="radiogroup"` of preset colors. |
| `SwatchTrigger` | picker | `role="radio"` focusable swatch (takes `color: string`). |
| `Swatch` | picker | Visual color block (takes `color` or inherits from `SwatchTrigger`). |
| `SwatchIndicator` | picker | Indicator shown when `SwatchTrigger[data-checked]`. |
| `EyeDropper` | picker | Invokes the browser's native EyeDropper API. `data-unsupported` when missing. |
| `HiddenInput` | picker | Rendered form input (needs `name` on Provider via `restProps`). |
| Part | Source | Notes |
| ------------------------------------- | ---------- | ----------------------------------------------------------------------------- |
| `Provider` | picker | Shared state: `value`, `placeholder`, `format`, `open`, flags. |
| `Label` | picker | Clicking focuses the Trigger. |
| `Control` | picker | Wraps Trigger + inline value display. |
| `Trigger` | picker | Opens the Popover (composes `PopoverTriggerProvider` when not inline). |
| `ValueSwatch` | picker | Current color preview inside the Trigger. |
| `ValueText` | picker | Textual representation of the current color (per `format` prop). |
| `Content` | Popover | Re-export. The floating surface (skipped when `inline=true`). |
| `Arrow`, `Close`, `Overlay`, `Anchor` | Popover | Re-exports. |
| `Area` | picker | 2D channel field (default: saturation x brightness). |
| `AreaBackground` | picker | The colored base plane (hue-hue by default). |
| `AreaThumb` | picker | Draggable handle. `role="slider"` + keyboard. |
| `ChannelSlider` | picker | 1D slider driving a single channel (`channel: ColorChannel`). |
| `ChannelSliderTrack` | picker | Visual gradient for the channel. |
| `ChannelSliderThumb` | picker | `role="slider"` focusable handle. |
| `TransparencyGrid` | picker | Checkerboard for alpha visualization (behind the alpha slider or area). |
| `FormatSelect` | ColorField | Re-export. Switches between allowed formats. |
| `ChannelInput` | ColorField | Re-export. Segmented value editor. |
| `ChannelSegment` | ColorField | Re-export. Single segment of ChannelInput. |
| `SwatchGroup` | picker | `role="radiogroup"` of preset colors. |
| `SwatchTrigger` | picker | `role="radio"` focusable swatch (takes `color: string`). |
| `Swatch` | picker | Visual color block (takes `color` or inherits from `SwatchTrigger`). |
| `SwatchIndicator` | picker | Indicator shown when `SwatchTrigger[data-checked]`. |
| `EyeDropper` | picker | Invokes the browser's native EyeDropper API. `data-unsupported` when missing. |
| `HiddenInput` | picker | Rendered form input (needs `name` on Provider via `restProps`). |
## Composition
@ -110,11 +112,11 @@ The three providers share `value`, `placeholder`, and `open` via the same `writa
The picker exposes the color in several places at once:
| Consumer | Source of truth |
| ----------------------- | ----------------------- |
| `Trigger` / `ValueText` | external `format` |
| `HiddenInput` (form) | external `format` |
| `onFormatChange` | external `format` |
| Consumer | Source of truth |
| ------------------------ | ----------------------- |
| `Trigger` / `ValueText` | external `format` |
| `HiddenInput` (form) | external `format` |
| `onFormatChange` | external `format` |
| `FormatSelect` (popover) | popover-internal format |
| `ChannelInput` (popover) | popover-internal format |
@ -122,29 +124,29 @@ The two formats are **decoupled by default** so the user can inspect / edit the
### `autoFormat` prop
| `autoFormat` | Popover `FormatSelect` → external `format` | External `format` → popover |
| --------------- | ------------------------------------------ | --------------------------- |
| `false` (default) | ✗ Decoupled | ✓ Always syncs |
| `true` | ✓ Propagates outward | ✓ Always syncs |
| `autoFormat` | Popover `FormatSelect` → external `format` | External `format` → popover |
| ----------------- | ------------------------------------------ | --------------------------- |
| `false` (default) | ✗ Decoupled | ✓ Always syncs |
| `true` | ✓ Propagates outward | ✓ Always syncs |
### Behaviour matrix
Given `<ColorPicker.Provider bind:format>`:
| Action | `autoFormat=false` (default) | `autoFormat=true` |
| ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |
| Consumer sets `format="rgb"` | Trigger = RGB, popover = RGB | Same |
| User opens popover, picks HSL in `FormatSelect` | Trigger = **RGB** (unchanged), popover = HSL | Trigger = HSL, popover = HSL |
| Consumer changes `format` to HEX externally while popover is open | Trigger = HEX, popover = HEX (`watch.pre` resync) | Same |
| User closes popover and reopens it | popover stays HSL (internal format persists across open/close) | popover stays at whatever external is now |
| `onFormatChange` callback | Fires only on external changes | Fires on popover changes too (propagated) |
| Action | `autoFormat=false` (default) | `autoFormat=true` |
| ----------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------- |
| Consumer sets `format="rgb"` | Trigger = RGB, popover = RGB | Same |
| User opens popover, picks HSL in `FormatSelect` | Trigger = **RGB** (unchanged), popover = HSL | Trigger = HSL, popover = HSL |
| Consumer changes `format` to HEX externally while popover is open | Trigger = HEX, popover = HEX (`watch.pre` resync) | Same |
| User closes popover and reopens it | popover stays HSL (internal format persists across open/close) | popover stays at whatever external is now |
| `onFormatChange` callback | Fires only on external changes | Fires on popover changes too (propagated) |
### When to use `autoFormat`
- **Default (`autoFormat=false`)** fits most cases where the consumer wants a stable canonical format (e.g. always submit `#rrggbb` to the backend) and the popover is just a rich editor that shouldn't drift that contract.
- **`autoFormat=true`** is the right mode when the consumer wants the user's *last picked* format to survive — e.g. a design-tool-like experience where opening the popover and switching to HSL should stick after closing.
- **`autoFormat=true`** is the right mode when the consumer wants the user's _last picked_ format to survive — e.g. a design-tool-like experience where opening the popover and switching to HSL should stick after closing.
If you want the *popover* format to reset to the external one every time the popover opens (a third flavour), override it by writing the external `format` to the popover state on `onOpenChange(true)` — soma intentionally does not do this automatically because many apps prefer the persistence.
If you want the _popover_ format to reset to the external one every time the popover opens (a third flavour), override it by writing the external `format` to the popover state on `onOpenChange(true)` — soma intentionally does not do this automatically because many apps prefer the persistence.
## Channels
@ -152,13 +154,13 @@ If you want the *popover* format to reset to the external one every time the pop
The `ChannelSlider` takes `channel` as a required prop. Supported channel ranges:
| Channel | Min | Max | Unit |
| ------------ | --- | --- | ------- |
| `hue` | 0 | 360 | degrees |
| `saturation` | 0 | 100 | % |
| `brightness` | 0 | 100 | % |
| `red / green / blue` | 0 | 255 | 0-255 |
| `alpha` | 0 | 1 | 0-1 |
| Channel | Min | Max | Unit |
| -------------------- | --- | --- | ------- |
| `hue` | 0 | 360 | degrees |
| `saturation` | 0 | 100 | % |
| `brightness` | 0 | 100 | % |
| `red / green / blue` | 0 | 255 | 0-255 |
| `alpha` | 0 | 1 | 0-1 |
The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via the Provider's `areaChannels` prop (soma innovation — React Aria inspired).
@ -187,41 +189,41 @@ The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via t
All parts emit their canonical `data-color-picker-{part}` attribute. State-bearing attributes:
| Part | Attribute | Values |
| --------------------- | --------------------------- | ------------------------------------- |
| Root | `data-format` | `hex` \| `rgb` \| `hsl` |
| Root | `data-invalid / -disabled / -readonly / -required` | flags |
| Trigger | `data-state` | `open` \| `closed` (from Popover) |
| Area | `data-x-channel / -y-channel` | Current axis channels |
| AreaThumb | `data-dragging` | Active drag state |
| ChannelSlider | `data-channel` | `hue` \| `saturation` \| … |
| ChannelSlider | `data-orientation` | `horizontal` \| `vertical` |
| SwatchTrigger | `data-checked` | Present when matches current value |
| SwatchIndicator | `data-checked` | Propagated |
| EyeDropper | `data-unsupported` | Browser lacks the API |
| Part | Attribute | Values |
| --------------- | -------------------------------------------------- | ---------------------------------- |
| Root | `data-format` | `hex` \| `rgb` \| `hsl` |
| Root | `data-invalid / -disabled / -readonly / -required` | flags |
| Trigger | `data-state` | `open` \| `closed` (from Popover) |
| Area | `data-x-channel / -y-channel` | Current axis channels |
| AreaThumb | `data-dragging` | Active drag state |
| ChannelSlider | `data-channel` | `hue` \| `saturation` \| … |
| ChannelSlider | `data-orientation` | `horizontal` \| `vertical` |
| SwatchTrigger | `data-checked` | Present when matches current value |
| SwatchIndicator | `data-checked` | Propagated |
| EyeDropper | `data-unsupported` | Browser lacks the API |
## Props (Provider)
| Prop | Type | Notes |
| ------------------------------------- | ----------------------------- | ------------------------------------------------ |
| `value` | `ColorValue` | Bindable. Canonical structured value. |
| `onValueChange` | `(v) => void` | Every mid-drag update. |
| `onValueChangeEnd` | `(v) => void` | Only on commit (drag end, swatch click, …). |
| `placeholder` | `ColorValue` | Anchor for empty-state; defaults to `DEFAULT_COLOR`. |
| `format` | `'hex' \| 'rgb' \| 'hsl'` | Bindable. **External** format (Trigger / ValueText / HiddenInput / `onFormatChange`). See *Format dynamics*. |
| `onFormatChange` | `(format) => void` | Fires only on external format changes — not on popover-internal switches (unless `autoFormat=true`). |
| `allowedFormats` | `ColorFormat[]` | Restricts `FormatSelect`. Single entry → locked. |
| `autoFormat` | `boolean` | When `true`, the popover's `FormatSelect` propagates its choice to the external `format`. @default `false`. |
| `open` | `boolean` | Bindable. |
| `inline` | `boolean` | Render content without Popover. |
| `closeOnSelect` | `boolean` | Auto-close on swatch / EyeDropper commit. |
| `enableAlpha` | `boolean` | @default `true`. |
| `areaChannels` | `{ x: ColorChannel; y: ColorChannel }` | 2D area mapping (default S x V). |
| `readonlySegments` | `EditableColorSegmentPart[]` | Per-segment lock on `ChannelInput`. |
| `validate` | `(ColorValue) => string \| void` | Custom validator. |
| `onInvalid` | `(reason, msg?) => void` | `'custom'`. |
| `disabled / readonly / required / invalid` | `boolean` | Merged with parent `Field`. |
| `locale / dir / errorMessageId` | — | Standard. |
| Prop | Type | Notes |
| ------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `value` | `ColorValue` | Bindable. Canonical structured value. |
| `onValueChange` | `(v) => void` | Every mid-drag update. |
| `onValueChangeEnd` | `(v) => void` | Only on commit (drag end, swatch click, …). |
| `placeholder` | `ColorValue` | Anchor for empty-state; defaults to `DEFAULT_COLOR`. |
| `format` | `'hex' \| 'rgb' \| 'hsl'` | Bindable. **External** format (Trigger / ValueText / HiddenInput / `onFormatChange`). See _Format dynamics_. |
| `onFormatChange` | `(format) => void` | Fires only on external format changes — not on popover-internal switches (unless `autoFormat=true`). |
| `allowedFormats` | `ColorFormat[]` | Restricts `FormatSelect`. Single entry → locked. |
| `autoFormat` | `boolean` | When `true`, the popover's `FormatSelect` propagates its choice to the external `format`. @default `false`. |
| `open` | `boolean` | Bindable. |
| `inline` | `boolean` | Render content without Popover. |
| `closeOnSelect` | `boolean` | Auto-close on swatch / EyeDropper commit. |
| `enableAlpha` | `boolean` | @default `true`. |
| `areaChannels` | `{ x: ColorChannel; y: ColorChannel }` | 2D area mapping (default S x V). |
| `readonlySegments` | `EditableColorSegmentPart[]` | Per-segment lock on `ChannelInput`. |
| `validate` | `(ColorValue) => string \| void` | Custom validator. |
| `onInvalid` | `(reason, msg?) => void` | `'custom'`. |
| `disabled / readonly / required / invalid` | `boolean` | Merged with parent `Field`. |
| `locale / dir / errorMessageId` | — | Standard. |
### Field integration
@ -229,33 +231,33 @@ Wrapping in `Field.Provider` inherits its `disabled`/`readonly`/`required`/`inva
## Comparison with reference libraries
> **Format-independence is unique to soma.** Every other mainstream headless picker ties the `format` prop to a *single* reactive state — changing format anywhere inside the picker immediately flips what the `value` text / hidden input / callbacks report. Soma splits this into an external format (canonical) and a popover-internal format (view) so the user can inspect / edit in any representation without mutating the committed output unless `autoFormat=true`.
| Feature | React Aria | Ark UI | Zag.js | Chakra (v3) | **Soma** |
| ------------------------------------------------- | -------------------- | -------------------- | -------------------- | -------------------- | ------------------------------------------- |
| Composed parts | 8 (split hooks) | 22 | 22 | 22 (via Ark) | **22** |
| `value` / `onValueChange` | ✓ (Color obj) | ✓ | ✓ | ✓ | ✓ (`ColorValue` with `hex / rgb / hsl / hsv` views) |
| `onValueChangeEnd` (drag end) | ✓ | ✓ | ✓ | ✓ | ✓ |
| `format` / `defaultFormat` / `onFormatChange` | per-field only | ✓ | ✓ | ✓ | ✓ |
| **External ↔ popover format decoupling** | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✓ **Soma design (`autoFormat` toggle)** |
| `allowedFormats` restriction | ✗ | ✗ | ✗ | ✗ | ✓ (list; single entry renders a label) |
| 2D Area + `xChannel / yChannel` configurable | ✓ | fixed (S × V) | fixed (S × V) | fixed (S × V) | ✓ (configurable — React Aria parity) |
| ChannelSlider (any channel) | ✓ | ✓ | ✓ | ✓ | ✓ |
| ChannelInput — unified segmented hex / rgb / hsl | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✓ (single component; composes `ColorField`) |
| Segmented HEX (6-char single segment + `A–F` keys) | ✗ (text) | ✗ (text) | ✗ (text) | ✗ (text) | ✓ **Soma design** |
| FormatSelect | ✗ | ✓ | ✓ | ✓ | ✓ (auto-renders as label when locked) |
| Swatches (`role="radiogroup"`) | ✓ `ColorSwatchPicker` | ✓ | ✓ | ✓ | ✓ |
| EyeDropper (native browser API) | ✗ | ✓ | ✓ | ✓ | ✓ |
| TransparencyGrid (checkerboard) | ✗ | ✓ | ✓ | ✓ | ✓ |
| Inline vs popover | compose yourself | `inline` prop | `inline` prop | `inline` prop | ✓ (`inline` prop — skips `Popover` entirely) |
| `closeOnSelect` | ✗ | ✓ | ✓ | ✓ | ✓ |
| Lossless round-trip preserving edited channel | ✓ | partial | partial | partial | ✓ (`colorValueFromRgb / Hsl` bypass HSV) |
| Parent Field integration (OR-merge flags) | via `<Form>` | via `FieldRoot` | ✗ | via Ark | ✓ (soma `Field`) |
| ColorWheel (circular hue) | ✓ | ✗ | ✗ | ✗ | ✗ (deliberately omitted — per study §16.6) |
> **Format-independence is unique to soma.** Every other mainstream headless picker ties the `format` prop to a _single_ reactive state — changing format anywhere inside the picker immediately flips what the `value` text / hidden input / callbacks report. Soma splits this into an external format (canonical) and a popover-internal format (view) so the user can inspect / edit in any representation without mutating the committed output unless `autoFormat=true`.
| Feature | React Aria | Ark UI | Zag.js | Chakra (v3) | **Soma** |
| -------------------------------------------------- | --------------------- | ------------------- | ------------------- | ------------------- | --------------------------------------------------- |
| Composed parts | 8 (split hooks) | 22 | 22 | 22 (via Ark) | **22** |
| `value` / `onValueChange` | ✓ (Color obj) | ✓ | ✓ | ✓ | ✓ (`ColorValue` with `hex / rgb / hsl / hsv` views) |
| `onValueChangeEnd` (drag end) | ✓ | ✓ | ✓ | ✓ | ✓ |
| `format` / `defaultFormat` / `onFormatChange` | per-field only | ✓ | ✓ | ✓ | ✓ |
| **External ↔ popover format decoupling** | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✓ **Soma design (`autoFormat` toggle)** |
| `allowedFormats` restriction | ✗ | ✗ | ✗ | ✗ | ✓ (list; single entry renders a label) |
| 2D Area + `xChannel / yChannel` configurable | ✓ | fixed (S × V) | fixed (S × V) | fixed (S × V) | ✓ (configurable — React Aria parity) |
| ChannelSlider (any channel) | ✓ | ✓ | ✓ | ✓ | ✓ |
| ChannelInput — unified segmented hex / rgb / hsl | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✓ (single component; composes `ColorField`) |
| Segmented HEX (6-char single segment + `A–F` keys) | ✗ (text) | ✗ (text) | ✗ (text) | ✗ (text) | ✓ **Soma design** |
| FormatSelect | ✗ | ✓ | ✓ | ✓ | ✓ (auto-renders as label when locked) |
| Swatches (`role="radiogroup"`) | ✓ `ColorSwatchPicker` | ✓ | ✓ | ✓ | ✓ |
| EyeDropper (native browser API) | ✗ | ✓ | ✓ | ✓ | ✓ |
| TransparencyGrid (checkerboard) | ✗ | ✓ | ✓ | ✓ | ✓ |
| Inline vs popover | compose yourself | `inline` prop | `inline` prop | `inline` prop | ✓ (`inline` prop — skips `Popover` entirely) |
| `closeOnSelect` | ✗ | ✓ | ✓ | ✓ | ✓ |
| Lossless round-trip preserving edited channel | ✓ | partial | partial | partial | ✓ (`colorValueFromRgb / Hsl` bypass HSV) |
| Parent Field integration (OR-merge flags) | via `<Form>` | via `FieldRoot` | ✗ | via Ark | ✓ (soma `Field`) |
| ColorWheel (circular hue) | ✓ | ✗ | ✗ | ✗ | ✗ (deliberately omitted — per study §16.6) |
### Format-independence — why soma does it differently
Every other library treats the picker's format as a **single reactive knob**. The consumer passes `format="hex"` → the `ChannelInput` shows hex, the textual output reads hex, form submission reads hex. If the user clicks `FormatSelect` and picks RGB, *all three* switch. The consumer has no way to keep "the value the user picked is committed to my backend as hex, regardless of what format they chose to edit it in."
Every other library treats the picker's format as a **single reactive knob**. The consumer passes `format="hex"` → the `ChannelInput` shows hex, the textual output reads hex, form submission reads hex. If the user clicks `FormatSelect` and picks RGB, _all three_ switch. The consumer has no way to keep "the value the user picked is committed to my backend as hex, regardless of what format they chose to edit it in."
Soma splits this into two concerns:
@ -314,5 +316,5 @@ The precedent for this split in the broader ecosystem is the common "display for
## Related
- [`ColorField`](../color-field/README.md) — the segmented text editor that ColorPicker composes as `ChannelInput`.
- [`ColorRangeField`](../color-range-field/README.md) — planned.
- [`ColorRangePicker`](../color-range-picker/README.md) — planned (Soma innovation).
- `ColorRangeField` — planned.
- `ColorRangePicker` — planned (Soma innovation).

Loading…
Cancel
Save

Powered by TurnKey Linux.