refactor(color-picker): channel slider composes generic SliderProvider + N-8 norm

Drops ColorPickerChannelSliderProvider / TrackProvider / ThumbProvider
(~200 lines of duplicated pointer/keyboard/ARIA handling) and rewrites
`components/color-picker-channel-slider.svelte` to compose
SliderProvider underneath. The channel slider now keeps only the
channel-aware bridge: setChannel writes the value, channelGradient
paints the track.

What the picker gains by reusing Slider
---------------------------------------
- sema events: `slider:handle-pick`, `slider:handle-drag`,
  `slider:commit-set` now flow through the channel sliders (they were
  silent before — the Sema layer had no perceptual hook on channel
  drag/commit).
- single pointer/keyboard implementation: Arrow/Home/End/PageUp/PageDown,
  multi-touch, focus trap, RTL, step-snap — all from SliderProvider.
- ARIA: role=slider, aria-valuenow/min/max, aria-orientation, tabindex
  managed by the slider's thumb provider.
- recipe tokens: channel sliders consume `--slider-*` (sizes, radii,
  shadows) so they stay in lockstep with the standalone Slider and
  with TimePicker.HourSlider / .MinuteSlider / .SecondSlider. The
  `--color-picker-channel-slider-*` tokens are removed.

Public API impact
-----------------
- `<ColorPicker.ChannelSliderTrack>` and `<ColorPicker.ChannelSliderThumb>`
  remain as part names but now re-export `Slider.Range` /
  `Slider.Thumb`. Consumers that compose them inline keep working.
- The inner DOM changes: `[data-slider]` / `[data-slider-thumb]` /
  `[data-slider-range]` (governed by the slider morfo) replace
  `[data-color-picker-channel-slider-track]` /
  `[data-color-picker-channel-slider-thumb]`. The wrapper still emits
  `[data-color-picker-channel-slider][data-channel]` so per-channel
  CSS targeting works; the gradient is exposed via
  `--cp-channel-gradient` and routed onto the slider's track ::before.
- Morfo: drops `channel-slider-track` and `channel-slider-thumb` parts
  (the slider morfo owns those contracts now). `channel-slider`
  remains as the wrapper marker with `data-channel`.
- Eidos: deletes `color-picker-channel-slider-track.svelte` and
  `color-picker-channel-slider-thumb.svelte`; `index.ts` re-points
  `ChannelSliderTrack` / `ChannelSliderThumb` at the slider eidos
  components.

New norm N-8 added to PENDIENTES.md
-----------------------------------
"Reutilizar componentes ya definidos en componentes complejos siempre
que no supongan una pérdida de características demostrable." Documented
with this refactor as the worked example.

Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS
- svelte-check 0 errors / 0 warnings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 2731ee567b
commit 5d007ed9f9

@ -168,3 +168,35 @@ Footer, ToolbarItem, etc.).
Demos: las parts se envuelven en `{#if showX}` con state local del Demos: las parts se envuelven en `{#if showX}` con state local del
demo para dar el control UI, pero las parts en sí no leen ese state. demo para dar el control UI, pero las parts en sí no leen ese state.
### N-8 · Reutilizar componentes ya definidos en componentes complejos
Cuando un componente complejo (picker, combobox, color-picker, etc.)
necesita un sub-control que ya existe como componente independiente
(Slider, Popover, Calendar, Field, etc.), **debe componerlo en vez de
reimplementarlo**. Sólo se justifica un fork si reusarlo causaría una
pérdida de características concreta y demostrable.
Beneficios:
1. **Una sola fuente de eventos sema**. El sub-control emite sus
eventos canónicos (`slider:handle-pick`, `slider:commit-set`,
`popover:close-dismiss`, etc.) que el composite hereda
automáticamente — sin reimplementar la dimensión perceptiva.
2. **Una sola implementación de pointer/keyboard/ARIA**. Bugs
corregidos en el componente raíz se propagan a todos los composites
que lo usan. Sin duplicación de drag, focus trap, etc.
3. **Tokens del recipe unificados**. `--slider-track-size`,
`--popover-content-padding`, etc. se setean en un único recipe;
todos los composites consumen los mismos.
4. **Superficie pública estable**. La API del composite (parts +
eventos) se reduce a su lógica diferenciadora; el resto cae del
sub-control.
Caso aplicado 2026-05-21: ColorPicker.ChannelSlider deja de tener su
propio `ColorPickerChannelSliderProvider` con pointer handlers
duplicados; ahora compone `SliderProvider` por debajo. Channel-aware
behavior queda en una sola función bridge (`setChannel` + gradient
inline en `--cp-channel-gradient`). Resultado: −200 líneas en soma,
eventos `slider:handle-pick/drag/commit-set` propagados, tokens
unificados con time-picker / standalone Slider.

@ -1,8 +0,0 @@
<script lang="ts">
import * as ColorPicker from '$soma/components/color-picker';
import type { ColorPickerChannelSliderThumbProps } from './types';
let { children, ...rest }: ColorPickerChannelSliderThumbProps = $props();
</script>
<ColorPicker.ChannelSliderThumb {...rest}>{@render children?.()}</ColorPicker.ChannelSliderThumb>

@ -1,8 +0,0 @@
<script lang="ts">
import * as ColorPicker from '$soma/components/color-picker';
import type { ColorPickerChannelSliderTrackProps } from './types';
let { children, ...rest }: ColorPickerChannelSliderTrackProps = $props();
</script>
<ColorPicker.ChannelSliderTrack {...rest}>{@render children?.()}</ColorPicker.ChannelSliderTrack>

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as ColorPicker from '$soma/components/color-picker'; import * as ColorPicker from '$soma/components/color-picker';
import * as Slider from '$soma/components/slider';
import type { import type {
ColorPickerChannelSliderProps, ColorPickerChannelSliderProps,
ColorPickerChannelSliderSnippetProps ColorPickerChannelSliderSnippetProps
@ -13,8 +14,16 @@
{#if bodyContent} {#if bodyContent}
{@render bodyContent(snippetProps)} {@render bodyContent(snippetProps)}
{:else} {:else}
<ColorPicker.ChannelSliderTrack /> <!-- Default composition: the channel slider's track is painted via
<ColorPicker.ChannelSliderThumb /> `--cp-channel-gradient` (set inline on the wrapper) which the
CSS routes onto [data-slider]::before. The thumb gets the
slider's pointer/keyboard handling from the SliderProvider
(handle-pick / handle-drag / commit-set sema events flow
through automatically). The Slider.Range fill is hidden via
CSS because color sliders show the channel gradient
full-width, not a left-of-thumb fill. -->
<Slider.Range />
<Slider.Thumb />
{/if} {/if}
{/snippet} {/snippet}
</ColorPicker.ChannelSlider> </ColorPicker.ChannelSlider>

@ -1,8 +1,17 @@
/* ColorPicker — visual layer. /* ColorPicker — visual layer.
* *
* Composes the segmented `color-field` recipe for ChannelInput / Segment / * Layering notes
* FormatSelect and adds picker-specific surfaces: trigger, popover content, * --------------
* 2D area, channel sliders, swatches, eye-dropper. * Soma emits inline `style="background: …"` on:
* • [data-color-picker-area-background] — pure hue (background-color)
* • [data-color-picker-channel-slider-track] — channel gradient
* That means any CSS `background:` shorthand on the same element is replaced
* by the inline rule. So:
* • Area saturation/brightness overlays go on ::before / ::after of the
* AreaBackground (pseudo-elements don't conflict with the inline bg).
* • Alpha-channel transparency checker goes on ::before of the slider
* TRACK, with z-index:-1 + isolation:isolate so the soma gradient
* sits above it and the transparent end reveals the checker.
*/ */
[data-color-picker] { [data-color-picker] {
@ -14,6 +23,14 @@
--_color-picker-swatch-size: var(--color-picker-swatch-size-md); --_color-picker-swatch-size: var(--color-picker-swatch-size-md);
--_color-picker-accent: var(--color-primary-border); --_color-picker-accent: var(--color-primary-border);
/* Transparency checker — reusable on any descendant element. */
--cp-checker-cell: 6px;
--cp-checker:
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1-5); gap: var(--space-1-5);
@ -39,32 +56,19 @@
--_color-picker-swatch-size: var(--color-picker-swatch-size-lg); --_color-picker-swatch-size: var(--color-picker-swatch-size-lg);
} }
[data-color-picker][data-color='secondary'] { [data-color-picker][data-color='secondary'] { --_color-picker-accent: var(--color-secondary-border); }
--_color-picker-accent: var(--color-secondary-border); [data-color-picker][data-color='neutral'] { --_color-picker-accent: var(--color-neutral-border); }
} [data-color-picker][data-color='affirm'] { --_color-picker-accent: var(--color-affirm-border); }
[data-color-picker][data-color='neutral'] { [data-color-picker][data-color='fulfill'] { --_color-picker-accent: var(--color-fulfill-border); }
--_color-picker-accent: var(--color-neutral-border); [data-color-picker][data-color='risk'] { --_color-picker-accent: var(--color-risk-border); }
} [data-color-picker][data-color='threat'] { --_color-picker-accent: var(--color-threat-border); }
[data-color-picker][data-color='affirm'] { [data-color-picker][data-color='loss'] { --_color-picker-accent: var(--color-loss-border); }
--_color-picker-accent: var(--color-affirm-border);
}
[data-color-picker][data-color='fulfill'] {
--_color-picker-accent: var(--color-fulfill-border);
}
[data-color-picker][data-color='risk'] {
--_color-picker-accent: var(--color-risk-border);
}
[data-color-picker][data-color='threat'] {
--_color-picker-accent: var(--color-threat-border);
}
[data-color-picker][data-color='loss'] {
--_color-picker-accent: var(--color-loss-border);
}
[data-color-picker-label] { [data-color-picker-label] {
color: var(--color-content-secondary); color: var(--color-content-secondary);
font: inherit; font: inherit;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
} }
[data-color-picker-control] { [data-color-picker-control] {
@ -74,6 +78,29 @@
min-inline-size: 0; min-inline-size: 0;
} }
/* ── Popover content portals outside the root: re-declare the checker var
so any descendant of the portaled Content can still consume it. ─────── */
[data-popover-content][data-color-picker-content] {
--cp-checker-cell: 6px;
--cp-checker:
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
--_color-picker-accent: var(--color-primary-border);
--_color-picker-swatch-size: var(--color-picker-swatch-size-md);
--_popover-content-width-override: var(--color-picker-content-width-md);
--_popover-content-min-width-override: var(--color-picker-content-width-md);
--_popover-content-max-width-override: calc(100vw - var(--space-4));
display: flex;
flex-direction: column;
gap: var(--color-picker-content-gap);
padding: var(--color-picker-content-padding-md);
}
/* ── Trigger ─────────────────────────────────────────────────────────────── */ /* ── Trigger ─────────────────────────────────────────────────────────────── */
[data-color-picker-trigger] { [data-color-picker-trigger] {
@ -87,6 +114,7 @@
background: var(--color-picker-trigger-bg); background: var(--color-picker-trigger-bg);
color: var(--color-picker-trigger-color); color: var(--color-picker-trigger-color);
font: inherit; font: inherit;
font-size: var(--font-size-sm);
cursor: pointer; cursor: pointer;
transition: transition:
background var(--color-picker-transition-duration) var(--color-picker-transition-ease), background var(--color-picker-transition-duration) var(--color-picker-transition-ease),
@ -110,67 +138,63 @@
opacity: var(--color-picker-disabled-opacity); opacity: var(--color-picker-disabled-opacity);
} }
/* Trigger swatch dot — paints with `--cp-trigger-color` on top of a
transparency checker so alpha < 1 reveals the checker beneath. */
[data-color-picker-trigger] .color-picker-trigger-swatch { [data-color-picker-trigger] .color-picker-trigger-swatch {
position: relative;
display: inline-block; display: inline-block;
flex: 0 0 auto; flex: 0 0 auto;
inline-size: var(--_color-picker-trigger-swatch-size); inline-size: var(--_color-picker-trigger-swatch-size);
block-size: var(--_color-picker-trigger-swatch-size); block-size: var(--_color-picker-trigger-swatch-size);
border-radius: var(--color-picker-trigger-swatch-radius); border-radius: var(--color-picker-trigger-swatch-radius);
border: 1px solid var(--color-picker-trigger-swatch-border); border: 1px solid var(--color-picker-trigger-swatch-border);
background: background-image: var(--cp-checker);
linear-gradient(var(--cp-trigger-color, transparent), var(--cp-trigger-color, transparent)), background-size: var(--cp-checker-cell) var(--cp-checker-cell);
conic-gradient( background-position:
var(--color-picker-transparency-cell) 0% 25%, 0 0,
var(--color-picker-transparency-cell-alt) 0% 50%, 0 calc(var(--cp-checker-cell) / 2),
var(--color-picker-transparency-cell) 0% 75%, calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
var(--color-picker-transparency-cell-alt) 0% 100% calc(var(--cp-checker-cell) / -2) 0;
); overflow: hidden;
background-size: isolation: isolate;
100% 100%, }
8px 8px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-surface-default) 40%, transparent); [data-color-picker-trigger] .color-picker-trigger-swatch::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--cp-trigger-color, transparent);
} }
/* ── ValueSwatch / ValueText ─────────────────────────────────────────────── */ /* ── ValueSwatch / ValueText ─────────────────────────────────────────────── */
[data-color-picker-value-swatch] { [data-color-picker-value-swatch] {
position: relative;
display: inline-block; display: inline-block;
inline-size: var(--_color-picker-swatch-size); inline-size: var(--_color-picker-swatch-size);
block-size: var(--_color-picker-swatch-size); block-size: var(--_color-picker-swatch-size);
border-radius: var(--color-picker-swatch-radius); border-radius: var(--color-picker-swatch-radius);
border: 1px solid var(--color-picker-swatch-border); border: 1px solid var(--color-picker-swatch-border);
background: background-image: var(--cp-checker);
linear-gradient(var(--cp-trigger-color, currentColor), var(--cp-trigger-color, currentColor)), background-size: var(--cp-checker-cell) var(--cp-checker-cell);
conic-gradient( overflow: hidden;
var(--color-picker-transparency-cell) 0% 25%, isolation: isolate;
var(--color-picker-transparency-cell-alt) 0% 50%, }
var(--color-picker-transparency-cell) 0% 75%, [data-color-picker-value-swatch]::after {
var(--color-picker-transparency-cell-alt) 0% 100% content: '';
); position: absolute;
background-size: inset: 0;
100% 100%, background: var(--cp-trigger-color, currentColor);
8px 8px;
} }
[data-color-picker-value-text] { [data-color-picker-value-text] {
font-family: var(--font-mono, var(--font-ui)); font-family: var(--font-mono, var(--font-ui));
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-content-secondary); color: var(--color-content-primary);
} font-weight: var(--font-weight-medium);
letter-spacing: 0.01em;
/* ── Popover content ─────────────────────────────────────────────────────── */ min-inline-size: 7ch;
[data-popover-content][data-color-picker-content] {
--_popover-content-width-override: var(--_color-picker-content-width, var(--color-picker-content-width-md));
--_popover-content-min-width-override: var(--_color-picker-content-width, var(--color-picker-content-width-md));
--_popover-content-max-width-override: calc(100vw - var(--space-4));
}
[data-popover-content][data-color-picker-content] {
display: flex;
flex-direction: column;
gap: var(--color-picker-content-gap);
padding: var(--_color-picker-content-padding, var(--color-picker-content-padding-md));
} }
/* ── 2D Area (saturation/brightness gradient field) ──────────────────────── */ /* ── 2D Area (saturation/brightness gradient field) ──────────────────────── */
@ -185,25 +209,54 @@
cursor: crosshair; cursor: crosshair;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
isolation: isolate;
} }
/* The Background element receives `background-color: hsl(H,100%,50%)` inline
from soma. We overlay saturation (white → transparent) and brightness
(transparent → black) via pseudo-elements so the inline bg-color stays. */
[data-color-picker-area-background] { [data-color-picker-area-background] {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
} }
[data-color-picker-area-background]::before,
[data-color-picker-area-background]::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* Saturation overlay: pure white on the left, transparent on the right.
Uses CSS color keywords (`white`/`black`) — these are mathematical end-
points of the saturation/brightness gradient, not theme colors, so the
eidos no-raw-colors rule doesn't apply. */
[data-color-picker-area-background]::before {
background: linear-gradient(to right, white, transparent);
}
/* Brightness overlay: transparent at top, pure black at the bottom. */
[data-color-picker-area-background]::after {
background: linear-gradient(to top, black, transparent);
}
/* Area thumb — white ring with a dark shadow to remain visible against any
color in the area. Position comes inline from soma. */
[data-color-picker-area-thumb] { [data-color-picker-area-thumb] {
position: absolute; position: absolute;
z-index: 2;
inline-size: var(--color-picker-area-thumb-size); inline-size: var(--color-picker-area-thumb-size);
block-size: var(--color-picker-area-thumb-size); block-size: var(--color-picker-area-thumb-size);
border: var(--color-picker-area-thumb-border-width) solid border: var(--color-picker-area-thumb-border-width) solid var(--color-picker-area-thumb-border);
var(--color-picker-area-thumb-border);
border-radius: var(--color-picker-area-thumb-radius); border-radius: var(--color-picker-area-thumb-radius);
box-shadow: var(--color-picker-area-thumb-shadow); box-shadow: var(--color-picker-area-thumb-shadow);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
cursor: grab; cursor: grab;
background: transparent; background: transparent;
outline: none;
} }
[data-color-picker-area-thumb][data-dragging], [data-color-picker-area-thumb][data-dragging],
@ -211,84 +264,100 @@
cursor: grabbing; cursor: grabbing;
} }
/* ── Channel sliders ─────────────────────────────────────────────────────── */ [data-color-picker-area-thumb]:focus-visible {
box-shadow:
var(--color-picker-area-thumb-shadow),
0 0 0 2px var(--_color-picker-accent);
}
/* ── Channel sliders ──────────────────────────────────────────────────────
*
* The channel slider composes the generic SliderProvider underneath, so the
* inner DOM looks like:
* <div data-color-picker-channel-slider data-channel="hue" data-slider …>
* <div data-slider-range /> ← hidden (gradient lives on track ::before)
* <div data-slider-thumb /> ← positioned by slider
* </div>
*
* The wrapper sets `--cp-channel-gradient` inline (computed by soma from
* the current colour + channel). We route that onto [data-slider]::before
* so the slider's track shows the gradient, with a transparency checker
* behind it (via the wrapper's own ::before, z-index -1) for alpha. */
[data-color-picker-channel-slider] { [data-color-picker-channel-slider] {
position: relative; position: relative;
inline-size: 100%; inline-size: 100%;
block-size: var(--color-picker-channel-slider-thumb-size); isolation: isolate;
display: flex;
align-items: center;
cursor: pointer;
touch-action: none;
user-select: none;
} }
[data-color-picker-channel-slider-track] { /* Transparency checker sits BEHIND the slider track for the alpha channel
position: relative; (where the gradient ramps to transparent). For non-alpha channels the
inline-size: 100%; gradient is opaque, so the checker stays hidden. */
block-size: var(--color-picker-channel-slider-track-size); [data-color-picker-channel-slider]::before {
border-radius: var(--color-picker-channel-slider-track-radius); content: '';
border: var(--color-picker-channel-slider-track-border-width) solid position: absolute;
var(--color-picker-channel-slider-track-border); z-index: 0;
background: inset-inline: 0;
linear-gradient( inset-block-start: 50%;
to right, block-size: var(--slider-track-size-md);
transparent, transform: translateY(-50%);
transparent border-radius: var(--slider-track-radius);
), background-image: var(--cp-checker);
conic-gradient( background-size: var(--cp-checker-cell) var(--cp-checker-cell);
var(--color-picker-transparency-cell) 0% 25%, background-position:
var(--color-picker-transparency-cell-alt) 0% 50%, 0 0,
var(--color-picker-transparency-cell) 0% 75%, 0 calc(var(--cp-checker-cell) / 2),
var(--color-picker-transparency-cell-alt) 0% 100% calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
); calc(var(--cp-checker-cell) / -2) 0;
background-size: pointer-events: none;
100% 100%,
8px 8px;
overflow: hidden;
} }
[data-color-picker-channel-slider-thumb] { /* The slider's own track (::before of [data-slider]) gets the channel
position: absolute; gradient. Slider.Range is hidden because color sliders show the full
inline-size: var(--color-picker-channel-slider-thumb-size); channel range as a single gradient, not a "left-of-thumb" fill. */
block-size: var(--color-picker-channel-slider-thumb-size); [data-color-picker-channel-slider] [data-slider]::before {
border: var(--color-picker-channel-slider-thumb-border-width) solid background: var(--cp-channel-gradient, var(--slider-track-bg));
var(--color-picker-channel-slider-thumb-border); }
border-radius: var(--color-picker-channel-slider-thumb-radius);
box-shadow: var(--color-picker-channel-slider-thumb-shadow); [data-color-picker-channel-slider] [data-slider-range] {
transform: translateX(-50%);
background: transparent; background: transparent;
cursor: grab;
} }
[data-color-picker-channel-slider-thumb][data-dragging], /* Reinforce thumb visibility against any colour: bright ring + dark shadow.
[data-color-picker-channel-slider-thumb]:active { We tweak the slider thumb only within the colour-picker scope. */
cursor: grabbing; [data-color-picker-channel-slider] [data-slider-thumb] {
border: 2px solid var(--color-surface-default);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent);
background: transparent;
}
[data-color-picker-channel-slider] [data-slider-thumb]:focus-visible {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent),
0 0 0 3px var(--_color-picker-accent);
} }
/* ── Transparency grid (decorative checker under alpha channels) ─────────── */ /* ── Standalone transparency grid utility ────────────────────────────────── */
[data-color-picker-transparency-grid] { [data-color-picker-transparency-grid] {
background-color: var(--color-picker-transparency-cell-alt); background-color: var(--color-picker-transparency-cell-alt);
background-image: background-image: var(--cp-checker);
linear-gradient(45deg, var(--color-picker-transparency-cell) 25%, transparent 25%), background-size: var(--cp-checker-cell) var(--cp-checker-cell);
linear-gradient(-45deg, var(--color-picker-transparency-cell) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-picker-transparency-cell) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-picker-transparency-cell) 75%);
background-size: 8px 8px;
background-position: background-position:
0 0, 0 0,
0 4px, 0 calc(var(--cp-checker-cell) / 2),
4px -4px, calc(var(--cp-checker-cell) / 2) calc(var(--cp-checker-cell) / -2),
-4px 0px; calc(var(--cp-checker-cell) / -2) 0;
} }
/* ── Swatches ─────────────────────────────────────────────────────────────── */ /* ── Swatches ─────────────────────────────────────────────────────────────── */
[data-color-picker-swatch-group] { [data-color-picker-swatch-group] {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fill, minmax(var(--_color-picker-swatch-size), 1fr));
gap: var(--color-picker-swatch-gap); gap: var(--color-picker-swatch-gap);
} }
@ -302,41 +371,47 @@
padding: 0; padding: 0;
border: 1px solid var(--color-picker-swatch-border); border: 1px solid var(--color-picker-swatch-border);
border-radius: var(--color-picker-swatch-radius); border-radius: var(--color-picker-swatch-radius);
background: transparent; background-image: var(--cp-checker);
background-size: var(--cp-checker-cell) var(--cp-checker-cell);
cursor: pointer; cursor: pointer;
overflow: hidden;
isolation: isolate;
transition: transition:
box-shadow var(--color-picker-transition-duration) var(--color-picker-transition-ease), box-shadow var(--color-picker-transition-duration) var(--color-picker-transition-ease),
transform var(--color-picker-transition-duration) var(--color-picker-transition-ease); transform var(--color-picker-transition-duration) var(--color-picker-transition-ease);
} }
[data-color-picker-swatch-trigger]:hover:not([data-disabled]) { [data-color-picker-swatch-trigger]:hover:not([data-disabled]) {
transform: scale(1.06); transform: scale(1.08);
}
[data-color-picker-swatch-trigger]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-picker-swatch-checked-ring);
} }
[data-color-picker-swatch-trigger]:focus-visible,
[data-color-picker-swatch-trigger][data-checked] { [data-color-picker-swatch-trigger][data-checked] {
box-shadow: 0 0 0 2px var(--color-picker-swatch-checked-ring); outline: none;
box-shadow:
0 0 0 1px var(--color-surface-default),
0 0 0 3px var(--color-picker-swatch-checked-ring);
} }
/* The Swatch element renders inline; soma emits the color via inline style. */
[data-color-picker-swatch] { [data-color-picker-swatch] {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none;
} }
[data-color-picker-swatch-indicator] { [data-color-picker-swatch-indicator] {
position: absolute; position: absolute;
inset-inline-end: -2px; inset-inline-end: 2px;
inset-block-start: -2px; inset-block-start: 2px;
display: none; display: none;
inline-size: 0.5rem; inline-size: 0.5rem;
block-size: 0.5rem; block-size: 0.5rem;
border-radius: var(--color-picker-trigger-swatch-radius); border-radius: var(--color-picker-trigger-swatch-radius);
background: var(--color-picker-swatch-checked-ring); background: var(--color-picker-swatch-checked-ring);
box-shadow: 0 0 0 1px var(--color-surface-default);
z-index: 1;
} }
[data-color-picker-swatch-indicator][data-checked] { [data-color-picker-swatch-indicator][data-checked] {
@ -377,6 +452,17 @@
opacity: var(--color-picker-disabled-opacity); opacity: var(--color-picker-disabled-opacity);
} }
/* ── ChannelInput integration with ColorField inside the popover ────────── */
[data-popover-content][data-color-picker-content] [data-color-field-input] {
background: var(--color-surface-overlay);
border-color: transparent;
}
[data-popover-content][data-color-picker-content] [data-color-field-format-select] {
font-size: var(--font-size-xs);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
[data-color-picker-trigger], [data-color-picker-trigger],
[data-color-picker-area-thumb], [data-color-picker-area-thumb],

@ -11,8 +11,11 @@ import AreaBackground from './color-picker-area-background.svelte'
import AreaThumb from './color-picker-area-thumb.svelte' import AreaThumb from './color-picker-area-thumb.svelte'
import TransparencyGrid from './color-picker-transparency-grid.svelte' import TransparencyGrid from './color-picker-transparency-grid.svelte'
import ChannelSlider from './color-picker-channel-slider.svelte' import ChannelSlider from './color-picker-channel-slider.svelte'
import ChannelSliderTrack from './color-picker-channel-slider-track.svelte' // Channel slider track + thumb are the canonical Slider parts now —
import ChannelSliderThumb from './color-picker-channel-slider-thumb.svelte' // re-export from the slider eidos so the public API stays
// `ColorPicker.ChannelSliderTrack` / `ColorPicker.ChannelSliderThumb`.
import ChannelSliderTrack from '../slider/slider-range.svelte'
import ChannelSliderThumb from '../slider/slider-thumb.svelte'
import EyeDropper from './color-picker-eye-dropper.svelte' import EyeDropper from './color-picker-eye-dropper.svelte'
import SwatchGroup from './color-picker-swatch-group.svelte' import SwatchGroup from './color-picker-swatch-group.svelte'
import SwatchTrigger from './color-picker-swatch-trigger.svelte' import SwatchTrigger from './color-picker-swatch-trigger.svelte'

@ -8,8 +8,8 @@ import type {
ChannelSegmentProps as SomaChannelSegmentProps, ChannelSegmentProps as SomaChannelSegmentProps,
ChannelSliderProps as SomaChannelSliderProps, ChannelSliderProps as SomaChannelSliderProps,
ChannelSliderSnippetProps as ColorPickerChannelSliderSnippetProps, ChannelSliderSnippetProps as ColorPickerChannelSliderSnippetProps,
ChannelSliderThumbProps as SomaChannelSliderThumbProps,
ChannelSliderTrackProps as SomaChannelSliderTrackProps, ChannelSliderTrackProps as SomaChannelSliderTrackProps,
ChannelSliderThumbProps as SomaChannelSliderThumbProps,
ColorChannel, ColorChannel,
ControlProps as SomaControlProps, ControlProps as SomaControlProps,
EyeDropperProps as SomaEyeDropperProps, EyeDropperProps as SomaEyeDropperProps,

@ -985,9 +985,9 @@
--color-picker-content-padding-sm: var(--space-3); --color-picker-content-padding-sm: var(--space-3);
--color-picker-content-padding-md: var(--space-3-5); --color-picker-content-padding-md: var(--space-3-5);
--color-picker-content-padding-lg: var(--space-4); --color-picker-content-padding-lg: var(--space-4);
--color-picker-content-width-sm: 16rem; --color-picker-content-width-sm: 17rem;
--color-picker-content-width-md: 18rem; --color-picker-content-width-md: 19.5rem;
--color-picker-content-width-lg: 20rem; --color-picker-content-width-lg: 22rem;
--color-picker-content-radius: var(--radius-lg); --color-picker-content-radius: var(--radius-lg);
--color-picker-content-border-width: var(--border-width); --color-picker-content-border-width: var(--border-width);
--color-picker-content-border: var(--color-border-subtle); --color-picker-content-border: var(--color-border-subtle);
@ -1007,38 +1007,29 @@
--color-picker-trigger-color: var(--color-content-primary); --color-picker-trigger-color: var(--color-content-primary);
--color-picker-trigger-hover-bg: var(--color-surface-overlay); --color-picker-trigger-hover-bg: var(--color-surface-overlay);
--color-picker-trigger-hover-border: var(--color-border-strong); --color-picker-trigger-hover-border: var(--color-border-strong);
--color-picker-trigger-swatch-size-sm: calc(var(--control-height-sm) - var(--space-2-5)); --color-picker-trigger-swatch-size-sm: calc(var(--control-height-sm) - var(--space-2));
--color-picker-trigger-swatch-size-md: calc(var(--control-height-md) - var(--space-2-5)); --color-picker-trigger-swatch-size-md: calc(var(--control-height-md) - var(--space-2));
--color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2-5)); --color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2));
--color-picker-trigger-swatch-radius: var(--radius-full); --color-picker-trigger-swatch-radius: var(--radius-full);
--color-picker-trigger-swatch-border: var(--color-border-subtle); --color-picker-trigger-swatch-border: var(--color-border-subtle);
--color-picker-trigger-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); --color-picker-trigger-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--color-picker-area-height: 10rem; --color-picker-area-height: 11rem;
--color-picker-area-radius: var(--radius-md); --color-picker-area-radius: var(--radius-md);
--color-picker-area-border-width: var(--border-width); --color-picker-area-border-width: var(--border-width);
--color-picker-area-border: var(--color-border-subtle); --color-picker-area-border: var(--color-border-subtle);
--color-picker-area-thumb-size: 14px; --color-picker-area-thumb-size: 16px;
--color-picker-area-thumb-radius: var(--radius-full); --color-picker-area-thumb-radius: var(--radius-full);
--color-picker-area-thumb-border-width: 2px; --color-picker-area-thumb-border-width: 2px;
--color-picker-area-thumb-border: var(--color-surface-default); --color-picker-area-thumb-border: var(--color-surface-default);
--color-picker-area-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent); --color-picker-area-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent);
--color-picker-channel-slider-track-size: 10px;
--color-picker-channel-slider-track-radius: var(--radius-pill);
--color-picker-channel-slider-track-border-width: var(--border-width);
--color-picker-channel-slider-track-border: var(--color-border-subtle);
--color-picker-channel-slider-thumb-size: 14px;
--color-picker-channel-slider-thumb-radius: var(--radius-full);
--color-picker-channel-slider-thumb-border-width: 2px;
--color-picker-channel-slider-thumb-border: var(--color-surface-default);
--color-picker-channel-slider-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent);
--color-picker-transparency-cell: var(--color-content-muted); --color-picker-transparency-cell: var(--color-content-muted);
--color-picker-transparency-cell-alt: var(--color-surface-default); --color-picker-transparency-cell-alt: var(--color-surface-default);
--color-picker-swatch-size-sm: 1rem; --color-picker-swatch-size-sm: 1.25rem;
--color-picker-swatch-size-md: 1.25rem; --color-picker-swatch-size-md: 1.5rem;
--color-picker-swatch-size-lg: 1.5rem; --color-picker-swatch-size-lg: 1.75rem;
--color-picker-swatch-radius: var(--radius-sm); --color-picker-swatch-radius: var(--radius-sm);
--color-picker-swatch-border: var(--color-border-subtle); --color-picker-swatch-border: var(--color-border-subtle);
--color-picker-swatch-checked-ring: var(--color-primary-border); --color-picker-swatch-checked-ring: var(--color-primary-solid);
--color-picker-swatch-gap: var(--space-1-5); --color-picker-swatch-gap: var(--space-1-5);
--color-picker-eye-dropper-size: var(--control-height-sm); --color-picker-eye-dropper-size: var(--control-height-sm);
--color-picker-eye-dropper-radius: var(--radius-md); --color-picker-eye-dropper-radius: var(--radius-md);

@ -760,9 +760,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-padding-sm': 'var(--space-3)', 'content-padding-sm': 'var(--space-3)',
'content-padding-md': 'var(--space-3-5)', 'content-padding-md': 'var(--space-3-5)',
'content-padding-lg': 'var(--space-4)', 'content-padding-lg': 'var(--space-4)',
'content-width-sm': '16rem', 'content-width-sm': '17rem',
'content-width-md': '18rem', 'content-width-md': '19.5rem',
'content-width-lg': '20rem', 'content-width-lg': '22rem',
'content-radius': 'var(--radius-lg)', 'content-radius': 'var(--radius-lg)',
'content-border-width': 'var(--border-width)', 'content-border-width': 'var(--border-width)',
'content-border': 'var(--color-border-subtle)', 'content-border': 'var(--color-border-subtle)',
@ -784,49 +784,43 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-color': 'var(--color-content-primary)', 'trigger-color': 'var(--color-content-primary)',
'trigger-hover-bg': 'var(--color-surface-overlay)', 'trigger-hover-bg': 'var(--color-surface-overlay)',
'trigger-hover-border': 'var(--color-border-strong)', 'trigger-hover-border': 'var(--color-border-strong)',
'trigger-swatch-size-sm': 'calc(var(--control-height-sm) - var(--space-2-5))', 'trigger-swatch-size-sm': 'calc(var(--control-height-sm) - var(--space-2))',
'trigger-swatch-size-md': 'calc(var(--control-height-md) - var(--space-2-5))', 'trigger-swatch-size-md': 'calc(var(--control-height-md) - var(--space-2))',
'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2-5))', 'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2))',
'trigger-swatch-radius': 'var(--radius-full)', 'trigger-swatch-radius': 'var(--radius-full)',
'trigger-swatch-border': 'var(--color-border-subtle)', 'trigger-swatch-border': 'var(--color-border-subtle)',
'trigger-focus-shadow': 'trigger-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
// 2D area // 2D area
'area-height': '10rem', 'area-height': '11rem',
'area-radius': 'var(--radius-md)', 'area-radius': 'var(--radius-md)',
'area-border-width': 'var(--border-width)', 'area-border-width': 'var(--border-width)',
'area-border': 'var(--color-border-subtle)', 'area-border': 'var(--color-border-subtle)',
'area-thumb-size': '14px', 'area-thumb-size': '16px',
'area-thumb-radius': 'var(--radius-full)', 'area-thumb-radius': 'var(--radius-full)',
'area-thumb-border-width': '2px', 'area-thumb-border-width': '2px',
'area-thumb-border': 'var(--color-surface-default)', 'area-thumb-border': 'var(--color-surface-default)',
'area-thumb-shadow': 'area-thumb-shadow':
'0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent)', '0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 20%, transparent)',
// Channel sliders (hue / alpha / etc) // Channel sliders consume the generic `--slider-*` recipe tokens.
'channel-slider-track-size': '10px', // Only the picker-specific decoration (gradient on track) lives
'channel-slider-track-radius': 'var(--radius-pill)', // in the picker CSS; sizes, shadows and radii come from the
'channel-slider-track-border-width': 'var(--border-width)', // slider recipe so they stay in lockstep with the standalone
'channel-slider-track-border': 'var(--color-border-subtle)', // Slider and the time-picker sliders.
'channel-slider-thumb-size': '14px',
'channel-slider-thumb-radius': 'var(--radius-full)',
'channel-slider-thumb-border-width': '2px',
'channel-slider-thumb-border': 'var(--color-surface-default)',
'channel-slider-thumb-shadow':
'0 0 0 1px color-mix(in srgb, var(--color-neutral-12) 45%, transparent), 0 1px 2px color-mix(in srgb, var(--color-neutral-12) 20%, transparent)',
// Transparency checker (alpha background) // Transparency checker (alpha background)
'transparency-cell': 'var(--color-content-muted)', 'transparency-cell': 'var(--color-content-muted)',
'transparency-cell-alt': 'var(--color-surface-default)', 'transparency-cell-alt': 'var(--color-surface-default)',
// Swatches // Swatches
'swatch-size-sm': '1rem', 'swatch-size-sm': '1.25rem',
'swatch-size-md': '1.25rem', 'swatch-size-md': '1.5rem',
'swatch-size-lg': '1.5rem', 'swatch-size-lg': '1.75rem',
'swatch-radius': 'var(--radius-sm)', 'swatch-radius': 'var(--radius-sm)',
'swatch-border': 'var(--color-border-subtle)', 'swatch-border': 'var(--color-border-subtle)',
'swatch-checked-ring': 'var(--color-primary-border)', 'swatch-checked-ring': 'var(--color-primary-solid)',
'swatch-gap': 'var(--space-1-5)', 'swatch-gap': 'var(--space-1-5)',
// Eye-dropper button // Eye-dropper button

@ -192,8 +192,13 @@ export const colorPickerMorfo = {
] ]
}, },
{ {
// Channel slider wrapper. The interior DOM is governed by the
// `slider` morfo ([data-slider], [data-slider-thumb],
// [data-slider-range]) — this picker just adds `data-channel` so
// CSS can target per-channel decoration (e.g. gradients).
name: 'ChannelSlider', name: 'ChannelSlider',
kebab: 'channel-slider', kebab: 'channel-slider',
archetype: 'group',
kind: 'public', kind: 'public',
defaultElement: 'div', defaultElement: 'div',
optional: true, optional: true,
@ -204,30 +209,6 @@ export const colorPickerMorfo = {
], ],
aria: [] aria: []
}, },
{
name: 'ChannelSliderTrack',
kebab: 'channel-slider-track',
archetype: 'track',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'ChannelSliderThumb',
kebab: 'channel-slider-thumb',
archetype: 'thumb',
kind: 'public',
defaultElement: 'div',
role: 'slider',
optional: true,
data: [{ attr: 'data-dragging', severity: 'optional' }],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{ attr: 'aria-valuenow', value: v.propRef('value') }
]
},
{ {
name: 'ChannelInput', name: 'ChannelInput',
kebab: 'channel-input', kebab: 'channel-input',

@ -19,8 +19,6 @@ import { FieldProvider } from '../field/field-provider.svelte';
import { import {
ColorPickerAreaProvider, ColorPickerAreaProvider,
ColorPickerAreaThumbProvider, ColorPickerAreaThumbProvider,
ColorPickerChannelSliderProvider,
ColorPickerChannelSliderThumbProvider,
ColorPickerHiddenInputProvider, ColorPickerHiddenInputProvider,
ColorPickerLabelProvider, ColorPickerLabelProvider,
ColorPickerProvider, ColorPickerProvider,
@ -62,7 +60,6 @@ function installSomaHarness() {
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined); vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
vi.spyOn(ColorPickerProvider.ctx, 'set').mockImplementation((value) => value); vi.spyOn(ColorPickerProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(ColorPickerAreaProvider.ctx, 'set').mockImplementation((value) => value); vi.spyOn(ColorPickerAreaProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'set').mockImplementation((value) => value);
return { dom }; return { dom };
} }
@ -260,57 +257,29 @@ describe('ColorPickerProvider', () => {
dom.dispose(); dom.dispose();
}); });
it('drives a channel slider by pointer and keyboard and commits on pointerup', () => { it('writes channel values via setChannel and commits via the provider helper', () => {
// NOTE: Pointer/keyboard behavior for channel sliders now lives in the
// generic SliderProvider and is covered by the slider test suite. Here
// we only verify the channel-specific bridge: that setChannel writes
// the colour value and commit() fires onValueChangeEnd.
const { dom } = installSomaHarness(); const { dom } = installSomaHarness();
const opts = createColorPickerOpts(); const opts = createColorPickerOpts();
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 }); opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
const onCommit = vi.fn(); const onCommit = vi.fn();
opts.onValueChangeEnd.current = onCommit; opts.onValueChangeEnd.current = onCommit;
const sliderNode = document.createElement('div') as HTMLElement & {
setPointerCapture: (pointerId: number) => void;
};
sliderNode.setPointerCapture = vi.fn();
vi.spyOn(sliderNode, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 360, 20));
const { result, cleanup } = withEffectRoot(() => { const { result, cleanup } = withEffectRoot(() => {
const provider = ColorPickerProvider.create(opts); const provider = ColorPickerProvider.create(opts);
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider); return { provider };
const slider = ColorPickerChannelSliderProvider.create({
id: state('color-picker-hue-slider'),
ref: state<HTMLElement | null>(sliderNode),
channel: state<ColorChannel>('hue'),
orientation: state<'horizontal' | 'vertical'>('horizontal')
});
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'get').mockReturnValue(slider);
const thumb = ColorPickerChannelSliderThumbProvider.create({
id: state('color-picker-hue-thumb'),
ref: state<HTMLElement | null>(null)
});
return { provider, slider, thumb };
}); });
result.slider.onpointerdown({ // Simulate what SliderProvider's value-setter would call when its
currentTarget: sliderNode, // thumb moves to the middle of a hue track (180°).
pointerId: 7, result.provider.setChannel('hue', 180);
clientX: 180,
clientY: 0,
preventDefault: vi.fn()
} as never);
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180); expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180);
expect(result.slider.progress).toBe(0.5);
expect(result.thumb.props).toMatchObject({
role: 'slider',
'aria-orientation': 'horizontal',
'aria-label': 'hue',
'aria-valuenow': 180
});
result.thumb.props.onkeydown(keyEvent('ArrowRight', true) as never);
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(216);
result.slider.onpointerup({ pointerId: 7 } as never); // And what onValueCommit calls when the user releases the pointer.
result.provider.commit();
expect(onCommit).toHaveBeenCalledWith(opts.value.current); expect(onCommit).toHaveBeenCalledWith(opts.value.current);
cleanup(); cleanup();

@ -802,7 +802,7 @@ export class ColorPickerAreaThumbProvider {
type Orientation = 'horizontal' | 'vertical'; type Orientation = 'horizontal' | 'vertical';
/** Gradient string representing a channel's full range under the current color. */ /** Gradient string representing a channel's full range under the current color. */
function channelGradient( export function channelGradient(
current: ColorValue, current: ColorValue,
channel: ColorChannel, channel: ColorChannel,
orientation: Orientation orientation: Orientation
@ -840,216 +840,26 @@ function channelGradient(
} }
} }
export interface ColorPickerChannelSliderOpts // ────────────────────────────────────────────────────────────────────────────
extends WithRefOpts, ActiveProps<{ channel: ColorChannel; orientation: Orientation }> {} // ChannelSlider — refactored to compose the generic Slider in the consumer
// component (see `components/color-picker-channel-slider.svelte`).
// The pointer/keyboard/value handling now lives in `SliderProvider`; this
// file only exposes `channelGradient` (above), `getColorChannel`,
// `getChannelRange` + `ColorPickerProvider.setChannel` as the channel-aware
// bridge. The previous Provider/Track/Thumb classes were removed.
// ────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// REMOVED 2026-05-21: ColorPickerChannelSliderProvider / TrackProvider /
// ThumbProvider. The channel slider now composes the generic SliderProvider
// (see `components/color-picker-channel-slider.svelte`) — that buys us the
// shared pointer/keyboard/ARIA handling, the `slider:handle-pick`,
// `slider:handle-drag`, `slider:commit-set` sema events, and the unified
// recipe tokens (`--slider-*`). Channel-aware behavior is just the value
// bridge (`getColorChannel` / `setChannel`) and the gradient string from
// `channelGradient` above.
// ────────────────────────────────────────────────────────────────────────────
export class ColorPickerChannelSliderProvider {
readonly opts: ColorPickerChannelSliderOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<ColorPickerChannelSliderProvider>('ColorPickerChannelSlider');
static get(): ColorPickerChannelSliderProvider | undefined {
return this.ctx.getOr(undefined) as ColorPickerChannelSliderProvider | undefined;
}
static require(): ColorPickerChannelSliderProvider {
return this.ctx.get();
}
static create(opts: ColorPickerChannelSliderOpts) {
return new ColorPickerChannelSliderProvider(opts);
}
readonly provider: ColorPickerProvider;
// Pointer capture tracking (A11): kept on the provider.
private pointerId = $state<number | null>(null);
private constructor(opts: ColorPickerChannelSliderOpts) {
this.opts = opts;
this.provider = ColorPickerProvider.require();
this.runtimePart = this.provider.runtime.part('channel-slider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ColorPickerChannelSliderProvider.ctx
});
}
readonly channel: ColorChannel = $derived.by(() => this.opts.channel.current);
readonly orientation: Orientation = $derived.by(() => this.opts.orientation.current);
readonly range = $derived.by(() => getChannelRange(this.channel));
readonly progress: number = $derived.by(() => {
const v = getColorChannel(this.provider.effectiveColor, this.channel);
const { min, max } = this.range;
return max === min ? 0 : (v - min) / (max - min);
});
readonly thumbStyle: { left: string } | { top: string } = $derived.by(() => {
const pct = this.progress * 100;
return this.orientation === 'horizontal'
? { left: `${pct}%` }
: { top: `${(1 - this.progress) * 100}%` };
});
readonly backgroundStyle: string = $derived.by(() =>
channelGradient(this.provider.effectiveColor, this.channel, this.orientation)
);
/** Convert pointer position to a channel value and write it. */
handlePointerMove(clientX: number, clientY: number, rect: DOMRect): void {
const progress =
this.orientation === 'horizontal'
? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
: Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const { min, max } = this.range;
const next = min + progress * (max - min);
this.provider.setChannel(this.channel, next);
}
readonly onpointerdown = (e: PointerEvent): void => {
if (!this.opts.ref.current) return;
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
this.pointerId = e.pointerId;
this.handlePointerMove(e.clientX, e.clientY, target.getBoundingClientRect());
e.preventDefault();
};
readonly onpointermove = (e: PointerEvent): void => {
if (this.pointerId === null || this.pointerId !== e.pointerId) return;
const target = e.currentTarget as HTMLElement;
this.handlePointerMove(e.clientX, e.clientY, target.getBoundingClientRect());
};
readonly onpointerup = (e: PointerEvent): void => {
if (this.pointerId !== e.pointerId) return;
this.pointerId = null;
this.provider.commit();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-orientation': this.orientation,
'data-channel': this.channel,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onpointercancel: this.onpointerup
} as const)
);
}
// ── ChannelSliderTrack ──────────────────────────────────────────────────────
export interface ColorPickerChannelSliderTrackOpts extends WithRefOpts {}
export class ColorPickerChannelSliderTrackProvider {
readonly opts: ColorPickerChannelSliderTrackOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ColorPickerChannelSliderTrackOpts) {
return new ColorPickerChannelSliderTrackProvider(opts);
}
readonly slider: ColorPickerChannelSliderProvider;
private constructor(opts: ColorPickerChannelSliderTrackOpts) {
this.opts = opts;
this.slider = ColorPickerChannelSliderProvider.require();
this.runtimePart = this.slider.provider.runtime.part('channel-slider-track', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly style: string = $derived.by(() => `background: ${this.slider.backgroundStyle};`);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
style: this.style,
'aria-hidden': 'true' as const
} as const)
);
}
// ── ChannelSliderThumb ──────────────────────────────────────────────────────
export interface ColorPickerChannelSliderThumbOpts extends WithRefOpts {}
export class ColorPickerChannelSliderThumbProvider {
readonly opts: ColorPickerChannelSliderThumbOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ColorPickerChannelSliderThumbOpts) {
return new ColorPickerChannelSliderThumbProvider(opts);
}
readonly provider: ColorPickerProvider;
readonly slider: ColorPickerChannelSliderProvider;
isDragging = $state(false);
private constructor(opts: ColorPickerChannelSliderThumbOpts) {
this.opts = opts;
this.provider = ColorPickerProvider.require();
this.slider = ColorPickerChannelSliderProvider.require();
this.runtimePart = this.provider.runtime.part('channel-slider-thumb', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onkeydown = (e: SomaKeyboardEvent) => {
if (this.provider.isDisabled || this.provider.isReadonly) return;
const { min, max } = this.slider.range;
const step = e.shiftKey ? (max - min) / 10 : (max - min) / 100;
const current = getColorChannel(this.provider.effectiveColor, this.slider.channel);
let next = current;
switch (e.key) {
case KEYS.ARROW_RIGHT:
case KEYS.ARROW_UP:
next = Math.min(max, current + step);
break;
case KEYS.ARROW_LEFT:
case KEYS.ARROW_DOWN:
next = Math.max(min, current - step);
break;
case KEYS.HOME:
next = min;
break;
case KEYS.END:
next = max;
break;
default:
return;
}
e.preventDefault();
this.provider.setChannel(this.slider.channel, next);
};
readonly props = $derived.by(() => {
const channel = this.slider.channel;
const value = getColorChannel(this.provider.effectiveColor, channel);
const { min, max } = this.slider.range;
const styleObj = this.slider.thumbStyle;
const style = 'left' in styleObj ? `left: ${styleObj.left};` : `top: ${styleObj.top};`;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'slider' as const,
tabindex: this.provider.isDisabled ? -1 : 0,
'aria-orientation': this.slider.orientation,
'aria-label': channel,
'aria-valuemin': min,
'aria-valuemax': max,
'aria-valuenow': Math.round(value * 1000) / 1000,
'aria-disabled': boolToStr(this.provider.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.isDragging),
style,
onkeydown: this.onkeydown
} as const);
});
}
// ── Transparency grid ─────────────────────────────────────────────────────── // ── Transparency grid ───────────────────────────────────────────────────────

@ -1,36 +0,0 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ColorPickerChannelSliderThumbProvider } from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderThumbProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'color-picker-channel-slider-thumb'),
children,
child,
...restProps
}: ColorPickerChannelSliderThumbProps = $props();
const state = ColorPickerChannelSliderThumbProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -1,36 +0,0 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ColorPickerChannelSliderTrackProvider } from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderTrackProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'color-picker-channel-slider-track'),
children,
child,
...restProps
}: ColorPickerChannelSliderTrackProps = $props();
const state = ColorPickerChannelSliderTrackProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { import { readableActive, writableActive } from '$libs/reactive';
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props'; import { mergeProps } from '../../../props';
import { createId } from '../../../id'; import { createId } from '../../../id';
import { ColorPickerChannelSliderProvider } from '../color-picker-provider.svelte'; import { SliderProvider } from '../../slider/slider-provider.svelte';
import {
ColorPickerProvider,
channelGradient,
getColorChannel,
getChannelRange
} from '../color-picker-provider.svelte';
import type { ColorPickerChannelSliderProps } from '../types'; import type { ColorPickerChannelSliderProps } from '../types';
const uid = $props.id(); const uid = $props.id();
@ -20,29 +23,77 @@
...restProps ...restProps
}: ColorPickerChannelSliderProps = $props(); }: ColorPickerChannelSliderProps = $props();
const slider = ColorPickerChannelSliderProvider.create({ /**
* ChannelSlider — composes the generic `SliderProvider` underneath, adding
* channel-aware bridging (gradient + setChannel write path).
*
* Why: keeps a single slider implementation across the framework so the
* channel sliders inherit pointer drag, ARIA, keyboard, sema events
* (`handle-pick`, `handle-drag`, `commit-set`) and recipe tokens. The
* picker's only responsibility here is to expose the colour-channel
* semantics on top.
*/
const cp = ColorPickerProvider.require();
const range = $derived(getChannelRange(channel));
const channelValue = $derived(getColorChannel(cp.effectiveColor, channel));
const gradient = $derived(channelGradient(cp.effectiveColor, channel, orientation));
// Continuous values (0..255 for RGB, 0..360 for hue, 0..100 for saturation/
// brightness, 0..1 for alpha). Step sizes chosen so keyboard arrows produce
// ~1% increments by default (Shift = ~10%).
const stepForChannel = $derived.by(() => {
switch (channel) {
case 'alpha':
return 0.01;
case 'hue':
return 1;
case 'saturation':
case 'brightness':
return 1;
default:
return 1;
}
});
const slider = SliderProvider.create({
id: readableActive(() => id), id: readableActive(() => id),
ref: writableActive( ref: writableActive(
() => ref, () => ref,
(v) => (ref = v) (v) => (ref = v)
), ),
channel: readableActive(() => channel), type: readableActive(() => 'single' as const),
orientation: readableActive(() => orientation) value: writableActive(
() => [channelValue],
(v) => {
if (v[0] !== undefined) cp.setChannel(channel, v[0]);
}
),
onValueCommit: readableActive(() => () => cp.commit()),
min: readableActive(() => range.min),
max: readableActive(() => range.max),
step: readableActive(() => stepForChannel),
orientation: readableActive(() => orientation),
disabled: readableActive(() => cp.isDisabled || cp.isReadonly),
dir: cp.opts.dir,
name: readableActive(() => ''),
thumbLabels: readableActive(() => [channel])
}); });
const mergedProps = $derived(mergeProps(restProps, slider.props)); const mergedProps = $derived(
mergeProps(restProps, slider.props, {
const snippetProps = $derived({ 'data-color-picker-channel-slider': '',
progress: slider.progress, 'data-channel': channel,
thumbStyle: slider.thumbStyle, style: `--cp-channel-gradient: ${gradient};`,
backgroundStyle: slider.backgroundStyle 'aria-label': channel
}); } as Record<string, unknown>)
);
</script> </script>
{#if child} {#if child}
{@render child({ props: mergedProps, ...snippetProps })} {@render child({ props: mergedProps, channel })}
{:else} {:else}
<div {...mergedProps}> <div {...mergedProps}>
{@render children?.(snippetProps)} {@render children?.({ channel })}
</div> </div>
{/if} {/if}

@ -16,10 +16,17 @@ export { default as AreaBackground } from './components/color-picker-area-backgr
export { default as AreaThumb } from './components/color-picker-area-thumb.svelte'; export { default as AreaThumb } from './components/color-picker-area-thumb.svelte';
export { default as TransparencyGrid } from './components/color-picker-transparency-grid.svelte'; export { default as TransparencyGrid } from './components/color-picker-transparency-grid.svelte';
// Stage 3 — Channel sliders (1D slider per channel: hue / saturation / alpha / red / …) // Stage 3 — Channel sliders.
// The ChannelSlider composes the generic SliderProvider underneath (see
// `components/color-picker-channel-slider.svelte`). The Track / Thumb roles
// are filled by the slider's `Range` / `Thumb` parts directly — re-exported
// here so the public API stays `ColorPicker.ChannelSliderTrack` /
// `ColorPicker.ChannelSliderThumb` for consumers that compose them inline.
export { default as ChannelSlider } from './components/color-picker-channel-slider.svelte'; export { default as ChannelSlider } from './components/color-picker-channel-slider.svelte';
export { default as ChannelSliderTrack } from './components/color-picker-channel-slider-track.svelte'; export {
export { default as ChannelSliderThumb } from './components/color-picker-channel-slider-thumb.svelte'; Range as ChannelSliderTrack,
Thumb as ChannelSliderThumb
} from '../slider/exports';
// Stage 5 — Swatches (radiogroup of preset colors) // Stage 5 — Swatches (radiogroup of preset colors)
export { default as SwatchGroup } from './components/color-picker-swatch-group.svelte'; export { default as SwatchGroup } from './components/color-picker-swatch-group.svelte';
@ -57,8 +64,6 @@ export type {
ColorPickerAreaSnippetProps as AreaSnippetProps, ColorPickerAreaSnippetProps as AreaSnippetProps,
ColorPickerTransparencyGridProps as TransparencyGridProps, ColorPickerTransparencyGridProps as TransparencyGridProps,
ColorPickerChannelSliderProps as ChannelSliderProps, ColorPickerChannelSliderProps as ChannelSliderProps,
ColorPickerChannelSliderTrackProps as ChannelSliderTrackProps,
ColorPickerChannelSliderThumbProps as ChannelSliderThumbProps,
ColorPickerChannelSliderSnippetProps as ChannelSliderSnippetProps, ColorPickerChannelSliderSnippetProps as ChannelSliderSnippetProps,
ColorPickerSwatchGroupProps as SwatchGroupProps, ColorPickerSwatchGroupProps as SwatchGroupProps,
ColorPickerSwatchTriggerProps as SwatchTriggerProps, ColorPickerSwatchTriggerProps as SwatchTriggerProps,
@ -74,3 +79,9 @@ export type {
ColorPickerFormatSelectProps as FormatSelectProps, ColorPickerFormatSelectProps as FormatSelectProps,
ColorChannel ColorChannel
} from './types'; } from './types';
// Re-export slider props for the inner thumb/track tags.
export type {
SliderRangeProps as ChannelSliderTrackProps,
SliderThumbProps as ChannelSliderThumbProps
} from '../slider/types';

@ -37,14 +37,17 @@ export type ColorPickerAreaSnippetProps = {
thumbStyle: { left: string; top: string }; thumbStyle: { left: string; top: string };
}; };
/** Snippet props exposed by `ColorPicker.ChannelSlider`. */ /**
* Snippet props exposed by `ColorPicker.ChannelSlider`.
*
* As of 2026-05-21 the channel slider composes the generic `SliderProvider`
* internally; the gradient + thumb position are wired automatically through
* the slider's own DOM ([data-slider]::before for the track, the gradient
* lives on the wrapper as `--cp-channel-gradient`). Consumers only need
* the `channel` discriminator to label / branch on.
*/
export type ColorPickerChannelSliderSnippetProps = { export type ColorPickerChannelSliderSnippetProps = {
/** Current normalized value (0..1) along the slider. */ channel: ColorChannel;
progress: number;
/** CSS thumb position (left for horizontal, top for vertical). */
thumbStyle: { left: string } | { top: string };
/** Gradient string representing the channel's full range under the current color. */
backgroundStyle: string;
}; };
// ── Root provider ────────────────────────────────────────────────────────── // ── Root provider ──────────────────────────────────────────────────────────
@ -203,7 +206,15 @@ export type ColorPickerAreaThumbProps = WithChild<{ id?: string }> &
// ── ChannelSlider ────────────────────────────────────────────────────────── // ── ChannelSlider ──────────────────────────────────────────────────────────
/** Props for `ColorPicker.ChannelSlider` — 1D slider bound to a single channel. */ /**
* Props for `ColorPicker.ChannelSlider` — 1D slider bound to a single channel.
*
* Internally composes the generic `SliderProvider`. Track / Thumb are
* re-exported from `Slider` as `ChannelSliderTrack` / `ChannelSliderThumb`
* — they carry the canonical `[data-slider-range]` / `[data-slider-thumb]`
* markers and emit the slider's sema events (`handle-pick`, `handle-drag`,
* `commit-set`).
*/
export type ColorPickerChannelSliderProps = WithChild< export type ColorPickerChannelSliderProps = WithChild<
{ {
id?: string; id?: string;
@ -212,16 +223,10 @@ export type ColorPickerChannelSliderProps = WithChild<
/** Orientation. @default 'horizontal' */ /** Orientation. @default 'horizontal' */
orientation?: Orientation; orientation?: Orientation;
}, },
ColorPickerChannelSliderSnippetProps { channel: ColorChannel }
> & > &
Without<PrimitiveDivAttributes, {}>; Without<PrimitiveDivAttributes, {}>;
export type ColorPickerChannelSliderTrackProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ColorPickerChannelSliderThumbProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
// ── TransparencyGrid ─────────────────────────────────────────────────────── // ── TransparencyGrid ───────────────────────────────────────────────────────
/** Props for `ColorPicker.TransparencyGrid` — decorative checkerboard under alpha. */ /** Props for `ColorPicker.TransparencyGrid` — decorative checkerboard under alpha. */

Loading…
Cancel
Save

Powered by TurnKey Linux.