fix(color-picker): el area 2D y las rampas de canal no se espejaban en RTL

El eje X del area lleva un CANAL de color repartido por la dimension inline —
un eje de lectura— asi que espeja, igual que el `ColorArea` de react-aria
(«orientation of the gradient background, positioning of the thumb, and
dragging behavior is automatically mirrored»). Una rueda radial no lo haria:
es el mismo corte lectura-vs-radial que ya usa la regla de las graficas. Mi
analogia previa con el `cropper` era mala y queda retirada — alli paneas una
imagen, aqui recorres un canal.

Medido antes: pinchar el MISMO punto fisico daba el mismo valor en las dos
direcciones, y el degradado no seguia al thumb.

Lo que faltaba, por pieza:

  area · puntero    la fraccion fisica se espeja sobre el canal
  area · thumb      ancla PHYSICA (`left`) alimentada ya espejada
  area · flechas    ArrowRight mueve el thumb a la derecha ⇒ BAJA el canal
  area · degradados saturacion y arcoiris de hue voltean con `:dir(rtl)`
  sliders de canal  solo el degradado

⚠️ El thumb conserva `left` FISICO a proposito: el recipe lo centra con
`translate(-50%,-50%)`, y ancla logica + translate fisico es justo la trampa
que el contrato de RTL prohibe. Soma le pasa la fraccion ya espejada
(`physicalXProgress`).

Los sliders de canal casi no necesitaban nada: desde el refactor de 2026-05-21
componen el `SliderProvider` generico, que ya espeja thumb, puntero y teclado.
Pero su rampa estaba clavada a `to right`, asi que el color BAJO el thumb
dejaba de ser el valor — medido con hue 210: thumb a 123px del borde derecho y
degradado contando aun desde la izquierda.

Verificado en Chrome en las dos direcciones: pinchando al 10% del borde
izquierdo fisico, LTR da saturation 10 y RTL da 90; el blanco del area y el
rojo del hue quedan a la derecha en RTL; ArrowRight mueve el thumb a la derecha
en ambas. Los dos tests nuevos salen ROJOS con el provider anterior.

`Home`/`End` se quedan en min/max del CANAL (un valor, no un borde fisico),
igual que en un slider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent ac2ca97444
commit 46ffe1c74b

@ -290,6 +290,15 @@
background: linear-gradient(to right, white, transparent);
}
/* The x axis carries a colour CHANNEL across the inline dimension — a reading
axis — so the area mirrors in RTL and soma already mirrors the thumb and the
drag with it (see `physicalXProgress`). The ramp has to follow, or the
gradient stops matching the value under the thumb. `to right` has no logical
form, hence the `:dir()` pair. The block-axis overlays below never flip. */
[data-color-picker-area-background]:dir(rtl)::before {
background: linear-gradient(to left, 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);
@ -322,6 +331,21 @@
) !important; /* important: bypasses soma's inline base background */
}
/* Mirrored rainbow — same reason as the saturation ramp above. */
[data-color-picker-area][data-x-channel='hue'] [data-color-picker-area-background]:dir(rtl) {
background:
linear-gradient(
to left,
var(--color-picker-hue-0) 0%,
var(--color-picker-hue-60) 17%,
var(--color-picker-hue-120) 33%,
var(--color-picker-hue-180) 50%,
var(--color-picker-hue-240) 67%,
var(--color-picker-hue-300) 83%,
var(--color-picker-hue-360) 100%
) !important; /* important: bypasses soma's inline base background */
}
/* y=saturation: top vivid, bottom gray (desaturated). The ::before slot is
the only overlay; ::after (brightness) is suppressed. */
[data-color-picker-area][data-x-channel='hue'][data-y-channel='saturation']

@ -185,6 +185,26 @@ The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via t
- **EyeDropper**: Enter / Space invokes the browser picker.
- **Escape** inside the Popover: closes and returns focus to the Trigger.
## RTL
The x axis carries a colour **channel** across the inline dimension — a reading
axis — so **the Area mirrors in RTL**, together with the channel sliders. This
follows react-aria's `ColorArea` ("orientation of the gradient background,
positioning of the thumb, and dragging behavior is automatically mirrored"); a
radial wheel would not mirror, the same reading-vs-radial split the chart rule
uses. The Y axis is the block axis and never flips.
| Piece | In RTL |
| --- | --- |
| Area pointer | the physical fraction is mirrored onto the channel: clicking 25% from the left edge yields channel 75 |
| Area thumb | anchor stays **physical** (`left`) — the recipe centres it with `translate(-50%,-50%)`, and a logical anchor paired with a physical translate is the trap the RTL contract forbids. Soma feeds it the already-mirrored fraction (`physicalXProgress`) |
| Area arrows | `ArrowRight` moves the thumb physically right, so it **decreases** the channel |
| Area gradients | the saturation ramp and the hue rainbow flip via `:dir(rtl)` in the recipe — `to right` has no logical form |
| Channel sliders | the generic `SliderProvider` underneath already mirrors thumb, pointer and keyboard; only `channelGradient` needed the direction, or the swatch under the thumb showed the wrong colour |
`Home`/`End` stay **min/max of the channel** (a value, not a physical edge), the
same way a slider's do.
## Data attributes
All parts emit their canonical `data-color-picker-{part}` attribute. State-bearing attributes:

@ -26,6 +26,7 @@ import {
ColorPickerTriggerProvider,
ColorPickerValueSwatchProvider,
ColorPickerValueTextProvider,
channelGradient,
getChannelRange,
getColorChannel,
setColorChannel
@ -234,6 +235,64 @@ describe('ColorPickerProvider', () => {
dom.dispose();
});
// Anchors the RTL mirroring of the 2D area. The x axis carries a colour
// CHANNEL across the inline dimension — a reading axis — so it mirrors, the
// way react-aria's ColorArea does; a radial wheel would not. Before this,
// clicking the same physical spot produced the same channel value in both
// directions and the gradient never followed the thumb.
it('mirrors the 2D area in RTL — pointer, thumb, arrows and gradient', () => {
const { dom } = installSomaHarness();
const opts = createColorPickerOpts();
opts.dir.current = 'rtl';
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
const { result, cleanup } = withEffectRoot(() => {
const provider = ColorPickerProvider.create(opts);
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
const area = ColorPickerAreaProvider.create({
id: state('color-picker-area'),
ref: state<HTMLElement | null>(document.createElement('div')),
xChannel: state<ColorChannel | undefined>(undefined),
yChannel: state<ColorChannel | undefined>(undefined)
});
vi.spyOn(ColorPickerAreaProvider.ctx, 'get').mockReturnValue(area);
const thumb = ColorPickerAreaThumbProvider.create({
id: state('color-picker-area-thumb'),
ref: state<HTMLElement | null>(null)
});
return { provider, area, thumb };
});
// Same physical point as the LTR test above (25% from the left edge), but
// the channel now grows leftwards: saturation reads 75, not 25.
result.area.handlePointerMove(25, 150, rect(0, 0, 100, 200));
expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(75);
// Y is the block axis and is untouched by direction.
expect(Math.round(result.provider.effectiveColor.hsv.v)).toBe(25);
// The thumb still lands under the cursor: the anchor stays PHYSICAL so it
// composes with the recipe's `translate(-50%, -50%)`.
expect(result.area.thumbStyle).toEqual({ left: '25%', top: '75%' });
// ArrowRight moves the thumb physically right ⇒ the channel DECREASES.
result.thumb.props.onkeydown(keyEvent('ArrowRight') as never);
expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(74);
cleanup();
dom.dispose();
});
it('mirrors the channel gradient on the inline axis only', () => {
const red = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
expect(channelGradient(red, 'hue', 'horizontal', 'ltr')).toContain('to right');
expect(channelGradient(red, 'hue', 'horizontal', 'rtl')).toContain('to left');
// Vertical is the block axis — same ramp in both directions.
expect(channelGradient(red, 'hue', 'vertical', 'ltr')).toContain('to top');
expect(channelGradient(red, 'hue', 'vertical', 'rtl')).toContain('to top');
// Default keeps the LTR ramp, so an un-updated caller is unaffected.
expect(channelGradient(red, 'alpha', 'horizontal')).toContain('to right');
});
it('lets each area override the root channel mapping', () => {
const { dom } = installSomaHarness();
const opts = createColorPickerOpts();

@ -816,8 +816,26 @@ export class ColorPickerAreaProvider {
return `hsl(${hue}, 100%, 50%)`;
});
/**
* `xProgress` as a fraction of the area's PHYSICAL width, from its left
* edge. The x axis carries a colour CHANNEL mapped across the inline
* dimension — a reading axis — so an RTL area mirrors: the channel's
* minimum sits on the right. react-aria states it outright for `ColorArea`
* ("orientation of the gradient background, positioning of the thumb, and
* dragging behavior is automatically mirrored"); a radial `ColorWheel` does
* not, which is the same reading-vs-radial split the chart rule uses.
*
* Kept PHYSICAL rather than switching the anchor to `inset-inline-start`
* because the recipe centres the thumb with `translate(-50%, -50%)`, and a
* logical anchor paired with a physical translate is exactly the trap the
* RTL contract forbids. The Y axis is the block axis and never flips.
*/
readonly physicalXProgress: number = $derived.by(() =>
this.provider.resolvedDir === 'rtl' ? 1 - this.xProgress : this.xProgress
);
readonly thumbStyle: { left: string; top: string } = $derived.by(() => ({
left: `${this.xProgress * 100}%`,
left: `${this.physicalXProgress * 100}%`,
// Invert Y — top = max (top of area), bottom = min.
top: `${(1 - this.yProgress) * 100}%`
}));
@ -831,7 +849,10 @@ export class ColorPickerAreaProvider {
// readonly / disabled lock the value: no pointer-driven edit (mirrors the
// guarded keyboard path). readonly keeps focus/visibility; disabled also dims.
if (this.provider.isDisabled || this.provider.isReadonly) return;
const xProgress = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
// The pointer arrives in PHYSICAL coordinates; mirror it back onto the
// channel's own axis so dragging tracks the gradient under the cursor.
const physicalX = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const xProgress = this.provider.resolvedDir === 'rtl' ? 1 - physicalX : physicalX;
const yProgress = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const xRange = getChannelRange(this.xChannel);
const yRange = getChannelRange(this.yChannel);
@ -960,12 +981,16 @@ export class ColorPickerAreaThumbProvider {
let nextX = x;
let nextY = y;
// The area mirrors in RTL, so the arrow that moves the thumb physically
// right is the one that DECREASES the channel. react-aria's rule: the
// left arrow always moves to what is physically on the left.
const xStep = this.provider.resolvedDir === 'rtl' ? -step : step;
switch (e.key) {
case KEYS.ARROW_RIGHT:
nextX = Math.min(xRange.max, x + step);
nextX = Math.max(xRange.min, Math.min(xRange.max, x + xStep));
break;
case KEYS.ARROW_LEFT:
nextX = Math.max(xRange.min, x - step);
nextX = Math.max(xRange.min, Math.min(xRange.max, x - xStep));
break;
case KEYS.ARROW_UP:
nextY = Math.min(yRange.max, y + step);
@ -1002,7 +1027,8 @@ export class ColorPickerAreaThumbProvider {
};
readonly props = $derived.by(() => {
const x = this.area.xProgress * 100;
// Physical fraction: the area mirrors in RTL (see `physicalXProgress`).
const x = this.area.physicalXProgress * 100;
const y = (1 - this.area.yProgress) * 100;
const ariaText = this.ariaValueText;
return this.runtimePart.assert({
@ -1032,41 +1058,51 @@ export class ColorPickerAreaThumbProvider {
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.
*
* The horizontal ramp follows the READING axis: the generic `SliderProvider`
* underneath already mirrors its thumb in RTL, so a gradient pinned to `to
* right` would leave the swatch under the thumb showing the wrong colour —
* measured at hue 210: thumb 123px from the right, gradient still counting
* from the left. The vertical one is the block axis and never flips.
*/
export function channelGradient(
current: ColorValue,
channel: ColorChannel,
orientation: Orientation
orientation: Orientation,
dir: Direction = 'ltr'
): string {
const dir = orientation === 'horizontal' ? 'to right' : 'to top';
const inlineEnd = dir === 'rtl' ? 'to left' : 'to right';
const gradientDir = orientation === 'horizontal' ? inlineEnd : 'to top';
switch (channel) {
case 'hue':
return `linear-gradient(${dir}, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)`;
return `linear-gradient(${gradientDir}, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)`;
case 'alpha': {
const { r, g, b } = current.rgb;
return `linear-gradient(${dir}, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1))`;
return `linear-gradient(${gradientDir}, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1))`;
}
case 'saturation': {
const h = current.hsv.h;
const v = current.hsv.v;
return `linear-gradient(${dir}, hsl(${h},0%,${v / 2}%) 0%, hsl(${h},100%,${v / 2}%) 100%)`;
return `linear-gradient(${gradientDir}, hsl(${h},0%,${v / 2}%) 0%, hsl(${h},100%,${v / 2}%) 100%)`;
}
case 'brightness': {
const h = current.hsv.h;
const s = current.hsv.s;
return `linear-gradient(${dir}, #000 0%, hsl(${h},${s}%,50%) 100%)`;
return `linear-gradient(${gradientDir}, #000 0%, hsl(${h},${s}%,50%) 100%)`;
}
case 'red': {
const { g, b } = current.rgb;
return `linear-gradient(${dir}, rgb(0,${g},${b}), rgb(255,${g},${b}))`;
return `linear-gradient(${gradientDir}, rgb(0,${g},${b}), rgb(255,${g},${b}))`;
}
case 'green': {
const { r, b } = current.rgb;
return `linear-gradient(${dir}, rgb(${r},0,${b}), rgb(${r},255,${b}))`;
return `linear-gradient(${gradientDir}, rgb(${r},0,${b}), rgb(${r},255,${b}))`;
}
case 'blue': {
const { r, g } = current.rgb;
return `linear-gradient(${dir}, rgb(${r},${g},0), rgb(${r},${g},255))`;
return `linear-gradient(${gradientDir}, rgb(${r},${g},0), rgb(${r},${g},255))`;
}
}
}

@ -37,7 +37,11 @@
const range = $derived(getChannelRange(channel));
const channelValue = $derived(getColorChannel(cp.effectiveColor, channel));
const gradient = $derived(channelGradient(cp.effectiveColor, channel, orientation));
// The slider underneath already mirrors its thumb in RTL, so the ramp has to
// mirror with it or the swatch under the thumb shows the wrong colour.
const gradient = $derived(
channelGradient(cp.effectiveColor, channel, orientation, cp.resolvedDir)
);
// 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

Loading…
Cancel
Save

Powered by TurnKey Linux.