feat(gradient): GradientBuilder editor polish + presets; land gradient WIP

GradientBuilder (this session):
- Stop color editor uses the canonical ColorField value row (swatch +
  styled Select), not a native select; rails at 90% width + xl track.
- Composable footer: ColorPicker.Cancel / ColorPicker.Close + trash
  remove-stop, as a sibling of PickerShell.Body.
- Surface-agnostic by default (variant='ghost'): no card, adapts to host.
- '+' add-stop on the kind-switch row; inserts between the selected stop
  and the next; rail click-to-add re-enabled (insert at cursor).
- GradientBuilder.StopList composable stops list (rendered by default).
- presets prop (Gradient[]) rendered below the editor (flex, not tabs);
  save-to-presets with a maxPresets cap; the action button flips
  Save <-> Delete when a saved preset is selected (ColorPicker mechanic).

Also lands the in-progress gradient tree so HEAD is self-consistent
(langs/components/index.ts already imported ./gradient-picker, whose file
was untracked). gradient-builder + gradient-picker stay on ACTIVE_DEV_TRACK;
the GradientPicker 2-tab redesign is next (see gradient-builder/CONTINUE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 40284a3d98
commit b3d0e29037

@ -167,7 +167,13 @@ function collectMorfoComponentNamesByScope(scope: string): string[] {
// Active-dev-track components, excluded from the audited/maintained catalogue
// (their morfo/eidos are still being authored). They are brought into
// compliance when their track lands; until then the catalogue guards skip them.
const ACTIVE_DEV_TRACK = new Set(['words', 'palabras', 'chronos', 'gradient-builder']);
const ACTIVE_DEV_TRACK = new Set([
'words',
'palabras',
'chronos',
'gradient-builder',
'gradient-picker'
]);
// SYS-1 scope-drift guard. `scope` enumerates the layers that IMPLEMENT a
// component, so a component shipping an eidos recipe (a wrapper `.svelte` or a

@ -0,0 +1,74 @@
# Eidos GradientBuilder
The visual layer over the headless [`GradientBuilder`](../../../soma/components/gradient-builder/README.md)
(soma). A framed editor card that composes the framework's existing components —
never re-implementing a primitive: the embedded **ColorPicker** (the selected
stop's color, inline), **Slider** (the angle), **ToggleGroup** (the kind),
**Button** (add stop). Soma owns the state machine + the keyboard-accessible
stops; eidos positions, paints and frames.
## Surface
```svelte
<GradientBuilder bind:value size="md" variant="surface" />
```
With no children, the Provider renders the full default editor:
**Preview → Track → (KindSwitch + AddStop) → AngleDial → StopColor**. Compose the
parts explicitly to customise the layout:
```svelte
<GradientBuilder bind:value>
<GradientBuilder.Preview />
<GradientBuilder.Track />
<GradientBuilder.KindSwitch />
<GradientBuilder.AngleDial />
<GradientBuilder.StopColor />
<GradientBuilder.AddStop />
</GradientBuilder>
```
## Parts
| Part | Composes | Notes |
| ------------ | --------------------- | ----------------------------------------------------------------- |
| `Provider` | soma Provider | The framed card. Sets a size/variant visual context. |
| `Preview` | — | Decorative live gradient bar (checker behind for alpha). |
| `Track` | soma Track | Paints a left→right ramp of the live stops; hosts the Stops. |
| `Stop` | soma Stop | One handle, positioned `left:%` and filled with the stop color. |
| `StopColor` | **ColorPicker** | Inline Area + hue/alpha sliders + hex, bound to the selected stop.|
| `AngleDial` | **Slider** | 0–360° with a label + degree readout (linear / conic only). |
| `KindSwitch` | **ToggleGroup** | linear / radial / conic. |
| `AddStop` | **Button** + `Plus` | Adds a stop at 0.5. |
## Props
| Prop | Type | Default | Description |
| --------- | ----------------------------------- | ----------- | ------------------------------------ |
| `value` | `Gradient` | 2-stop | The gradient being edited. Bindable. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Editor + child control size. |
| `variant` | `'surface' \| 'outline' \| 'ghost'` | `'surface'` | The card framing. |
| `disabled`| `boolean` | `false` | Disables all editing. |
## Stop ↔ ColorPicker bridge
`stop-color.ts` maps `StopColor ↔ ColorValue`: a `css` / `oklch` stop seeds the
picker; the picked color writes back a `css` hex stop. Role stops still RENDER
(via `stopColorToCss`); editing one through the picker re-bakes it to a literal.
The picker is **inline** (Area + sliders, no nested popover) so editing the
selected stop reads as one surface. The seed effect tracks only `selectedIndex`
(untracking the color) so editing the current stop doesn't fight the picker.
## Recipe
Self-contained `--_gradient-builder-*` tokens (gradient-builder is on the active
dev track, not yet registered in `recipes/base.ts`). The stop handle centers with
the `translate` property (not `transform`) so a sema press-squeeze can't fight
the centering. Selectors target only morfo-promised attrs + the eidos-only
composition wrappers stamped by the wrappers.
## Notes
- **No `color` prop** — GradientBuilder paints the user's gradient, not a palette
role. The demo's palette picker is therefore absent by design.
- Demo: `web/routes/uix/components/gradient-builder/+page.svelte`.

@ -0,0 +1,20 @@
import { getContext, setContext } from 'svelte';
import type { GradientBuilderSize, GradientBuilderVariant } from './types';
const GRADIENT_BUILDER_VISUAL_CONTEXT = Symbol('uix.eidos.gradient-builder.visual');
export interface GradientBuilderVisualContext {
getSize(): GradientBuilderSize;
getVariant(): GradientBuilderVariant;
}
export function setGradientBuilderVisualContext(
ctx: GradientBuilderVisualContext
): GradientBuilderVisualContext {
setContext(GRADIENT_BUILDER_VISUAL_CONTEXT, ctx);
return ctx;
}
export function getGradientBuilderVisualContext(): GradientBuilderVisualContext | undefined {
return getContext<GradientBuilderVisualContext | undefined>(GRADIENT_BUILDER_VISUAL_CONTEXT);
}

@ -0,0 +1,32 @@
<script lang="ts">
import { Button } from '$uix/eidos/components/button';
import { Plus } from '$uix/eidos/components/icon';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderAddStopProps } from './types';
let { ...rest }: GradientBuilderAddStopProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.add-stop|Add color stop'));
</script>
<Button
{...rest}
data-gradient-builder-add-stop
variant="outline"
color="neutral"
{size}
iconOnly
disabled={provider.isDisabled}
aria-label={label}
onclick={() => provider.addStop()}
>
{#snippet icon()}<Plus />{/snippet}
{label}
</Button>

@ -0,0 +1,41 @@
<script lang="ts">
import { Slider } from '$uix/eidos/components/slider';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderAngleDialProps } from './types';
let { ...rest }: GradientBuilderAngleDialProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.angle-dial|Gradient angle'));
// Linear `angle` / conic `from`; radial has no angle so the dial is hidden.
const showDial = $derived(provider.kind === 'linear' || provider.kind === 'conic');
</script>
{#if showDial}
<div {...rest} data-gradient-builder-angle-dial>
<div data-gradient-builder-angle-dial-head>
<span data-gradient-builder-angle-dial-label>{label}</span>
<span data-gradient-builder-angle-dial-value>{Math.round(provider.angle)}°</span>
</div>
<Slider
{size}
min={0}
max={360}
step={1}
value={[provider.angle]}
onValueChange={(v) => provider.setAngle(v[0] ?? 0)}
onValueCommit={() => provider.commit()}
aria-label={label}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>
{/if}

@ -0,0 +1,43 @@
<script lang="ts">
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderKindSwitchProps } from './types';
let { ...rest }: GradientBuilderKindSwitchProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const groupLabel = $derived(eidos.langs.ts('#?components.gradient-builder.kind-switch|Gradient type'));
const kinds = $derived([
{ value: 'linear', label: eidos.langs.ts('#?components.gradient-builder.kind.linear|Linear') },
{ value: 'radial', label: eidos.langs.ts('#?components.gradient-builder.kind.radial|Radial') },
{ value: 'conic', label: eidos.langs.ts('#?components.gradient-builder.kind.conic|Conic') }
] as const);
</script>
<div {...rest} data-gradient-builder-kind-switch>
<ToggleGroup
type="single"
variant="outline"
{size}
attached
block
deselectable={false}
value={[provider.kind]}
onValueChange={(v) => {
const next = v[0];
if (next === 'linear' || next === 'radial' || next === 'conic') provider.setKind(next);
}}
aria-label={groupLabel}
>
{#each kinds as kind (kind.value)}
<ToggleGroup.Item value={kind.value}>{kind.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>

@ -0,0 +1,85 @@
<script lang="ts">
// The presets panel — sits below the editor when a `presets` prop is defined.
// A grid of swatches (provided + saved) and one action button that toggles
// Save ↔ Delete: with a SAVED preset selected (and it IS the current value) the
// button deletes it; otherwise it saves the current gradient (capped, no dupes).
// Same mechanic as the ColorPicker's presets + saved swatches.
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { Button } from '$uix/eidos/components/button';
import { Plus, Trash2 } from '$uix/eidos/components/icon';
import { gradientToCss, type Gradient } from '$libs/gradient';
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderPresetsProps } from './types';
let { provided, savedPresets, canSave, onSave, onDelete }: GradientBuilderPresetsProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.presets|Gradient presets'));
const saveLabel = $derived(
eidos.langs.ts('#?components.gradient-builder.save-preset|Save gradient')
);
const deleteLabel = $derived(
eidos.langs.ts('#?components.gradient-builder.delete-preset|Delete gradient')
);
// A saved preset the user explicitly selected — drives Save ↔ Delete on the
// single action button, exactly like the ColorPicker's saved swatches.
let selectedSaved = $state<number | null>(null);
const showDelete = $derived(
selectedSaved !== null &&
savedPresets[selectedSaved] !== undefined &&
gradientToCss(savedPresets[selectedSaved]) === gradientToCss(provider.value)
);
function apply(g: Gradient, savedIndex: number | null): void {
provider.reset(g);
provider.commit();
selectedSaved = savedIndex;
}
function buttonAction(): void {
if (showDelete && selectedSaved !== null) {
onDelete?.(selectedSaved);
selectedSaved = null;
} else {
onSave?.();
}
}
</script>
<div data-gradient-builder-presets role="group" aria-label={label}>
<div class="gb-preset-grid">
{#each provided as preset, i (i)}
<button
type="button"
data-gradient-builder-preset
style="background: {gradientToCss(preset)};"
aria-label="{label} {i + 1}"
onclick={() => apply(preset, null)}
></button>
{/each}
{#each savedPresets as preset, i (i)}
<button
type="button"
data-gradient-builder-preset
data-selected={selectedSaved === i || undefined}
style="background: {gradientToCss(preset)};"
aria-label="{label} {provided.length + i + 1}"
onclick={() => apply(preset, i)}
></button>
{/each}
</div>
<Button
variant="outline"
color={showDelete ? 'threat' : 'neutral'}
{size}
disabled={!showDelete && !canSave}
onclick={buttonAction}
>
{#snippet icon()}{#if showDelete}<Trash2 />{:else}<Plus />{/if}{/snippet}
{showDelete ? deleteLabel : saveLabel}
</Button>
</div>

@ -0,0 +1,19 @@
<script lang="ts">
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { gradientToCss } from '$libs/gradient';
import type { GradientBuilderPreviewProps } from './types';
let { ...rest }: GradientBuilderPreviewProps = $props();
const provider = GradientBuilderProvider.require();
const css = $derived(gradientToCss(provider.value));
</script>
<!-- Decorative live surface — the stops on the Track carry the semantics. -->
<div
{...rest}
data-gradient-builder-preview
data-kind={provider.kind}
aria-hidden="true"
style:background={css}
></div>

@ -0,0 +1,35 @@
<script lang="ts">
import { Button } from '$uix/eidos/components/button';
import { Trash2 } from '$uix/eidos/components/icon';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderAddStopProps } from './types';
let { ...rest }: GradientBuilderAddStopProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop'));
// A gradient needs ≥ 2 stops; disable when there's nothing safe to remove.
const canRemove = $derived(!provider.isDisabled && provider.stops.length > 2);
</script>
<Button
{...rest}
data-gradient-builder-remove-stop
variant="outline"
color="neutral"
{size}
iconOnly
disabled={!canRemove}
aria-label={label}
onclick={() => provider.removeStop(provider.selectedIndex)}
>
{#snippet icon()}<Trash2 />{/snippet}
{label}
</Button>

@ -0,0 +1,140 @@
<script lang="ts">
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { ColorField } from '$uix/eidos/components/color-field';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Button } from '$uix/eidos/components/button';
import { Trash2 } from '$uix/eidos/components/icon';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { DEFAULT_COLOR, type ColorValue } from '$libs/color';
import type { StopColor } from '$libs/gradient';
import { getGradientBuilderVisualContext } from './context.svelte';
import { stopColorToColorValue, colorValueToStopColor } from './stop-color';
import type { GradientBuilderStopColorProps } from './types';
// `children` = the Track. We anchor the color popover to it; clicking a stop
// (handled in soma) opens the popover for that stop.
let {
children,
...rest
}: GradientBuilderStopColorProps & { children?: import('svelte').Snippet } = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const removeLabel = $derived(
eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop')
);
const acceptLabel = $derived(eidos.langs.ts('#?components.gradient-builder.accept|Accept'));
const valueLabel = $derived(
eidos.langs.ts('#?components.gradient-builder.value-label|Color value')
);
// Open state mirrors the provider's colorEditOpen (set by clicking a stop).
let open = $state(provider.colorEditOpen);
$effect(() => {
open = provider.colorEditOpen;
});
function onOpenChange(v: boolean): void {
open = v;
if (!v) provider.closeColorEdit();
}
// Seed from the SELECTED stop up-front; re-seed on selection switch (track
// only `selectedIndex`, untrack the color — the parent-write-loop trap).
function seed(): ColorValue {
const stop = provider.stops[provider.selectedIndex];
return stop ? stopColorToColorValue(stop.color) : DEFAULT_COLOR;
}
let colorValue = $state<ColorValue>(seed());
$effect(() => {
provider.selectedIndex;
colorValue = untrack(() => seed());
});
// Live edit; commit once on release (no per-pixel commit ring on the card).
function onPick(v: ColorValue | undefined): void {
if (v) provider.setStopColor(provider.selectedIndex, colorValueToStopColor(v));
}
function onPickEnd(): void {
provider.commit();
}
const canRemove = $derived(provider.stops.length > 2);
function removeThisStop(): void {
provider.removeStop(provider.selectedIndex);
provider.closeColorEdit();
}
// Snapshot the stop's color when the editor opens, so Cancel restores the exact
// original. The picker's own Cancel reverts its value; this reverts the stop too
// (the editor commits live, so there is no other pending state to roll back).
let stopColorOnOpen = $state<StopColor | undefined>(undefined);
$effect(() => {
if (open) stopColorOnOpen = untrack(() => provider.stops[provider.selectedIndex]?.color);
});
function cancelEdit(): void {
if (stopColorOnOpen) provider.setStopColor(provider.selectedIndex, stopColorOnOpen);
provider.commit();
}
</script>
<ColorPicker
{...rest}
{size}
bind:value={colorValue}
bind:open
{onOpenChange}
format="hex"
onValueChange={onPick}
onValueChangeEnd={onPickEnd}
>
<!-- The Track is the anchor: the popover floats by the stop rail. -->
<ColorPicker.Anchor data-gradient-builder-stop-color-anchor>
{@render children?.()}
</ColorPicker.Anchor>
<ColorPicker.Portal>
<ColorPicker.Content data-gradient-builder-stop-color>
<PickerShell.Body>
<div data-gradient-builder-stop-color-title>
{eidos.langs.ts('#?components.gradient-builder.stop-label|Color stop')}
{provider.selectedIndex + 1}
</div>
<ColorPicker.Area />
<div data-gradient-builder-stop-color-sliders>
<ColorPicker.ChannelSlider channel="hue" />
<ColorPicker.ChannelSlider channel="alpha" />
</div>
<ColorField bind:value={colorValue} format="hex" {size} onValueChange={onPick}>
<ColorField.Input aria-label={valueLabel} swatch>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<ColorField.Segment {part}>{value}</ColorField.Segment>
{/each}
{/snippet}
</ColorField.Input>
</ColorField>
</PickerShell.Body>
<ColorPicker.Footer>
<Button
variant="ghost"
color="threat"
{size}
iconOnly
disabled={!canRemove}
aria-label={removeLabel}
data-gradient-builder-stop-color-remove
onclick={removeThisStop}
>
{#snippet icon()}<Trash2 />{/snippet}
{removeLabel}
</Button>
<ColorPicker.Cancel onclick={cancelEdit} />
<ColorPicker.Close>{acceptLabel}</ColorPicker.Close>
</ColorPicker.Footer>
</ColorPicker.Content>
</ColorPicker.Portal>
</ColorPicker>

@ -0,0 +1,59 @@
<script lang="ts">
// One StopList row: a colorpicker-value-looking button (swatch + hex + position)
// that opens the SAME color editor for its stop, plus a delete icon. The two
// endpoint stops (first / last by position) hide the delete — they anchor the
// gradient at 0 / 1.
import { ColorSwatch } from '$uix/eidos/components/color-swatch';
import { Button } from '$uix/eidos/components/button';
import { Trash2 } from '$uix/eidos/components/icon';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import { stopColorToCss } from '$libs/gradient';
import { getGradientBuilderVisualContext } from './context.svelte';
import { stopColorToColorValue } from './stop-color';
import type { GradientBuilderStopListRowProps } from './types';
let { index, isEndpoint = false }: GradientBuilderStopListRowProps = $props();
const provider = GradientBuilderProvider.require();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const eidos = ActiveEidos.require();
const removeLabel = $derived(
eidos.langs.ts('#?components.gradient-builder.remove-stop|Remove color stop')
);
const stop = $derived(provider.stops[index]);
const swatchColor = $derived(stop ? stopColorToCss(stop.color) : 'transparent');
const hex = $derived(stop ? stopColorToColorValue(stop.color).hex : '');
const selected = $derived(provider.selectedIndex === index);
const position = $derived(Math.round(provider.stopPosition(index) * 100));
</script>
<div data-gradient-builder-stop-list-row role="listitem" data-selected={selected || undefined}>
<button
type="button"
data-gradient-builder-stop-list-value
data-selected={selected || undefined}
disabled={provider.isDisabled}
onclick={() => provider.openColorEdit(index)}
>
<ColorSwatch color={swatchColor} {size} />
<span data-gradient-builder-stop-list-hex>{hex}</span>
<span data-gradient-builder-stop-list-pos>{position}%</span>
</button>
{#if !isEndpoint}
<Button
variant="ghost"
color="threat"
{size}
iconOnly
disabled={provider.isDisabled}
aria-label={removeLabel}
onclick={() => provider.removeStop(index)}
>
{#snippet icon()}<Trash2 />{/snippet}
{removeLabel}
</Button>
{/if}
</div>

@ -0,0 +1,38 @@
<script lang="ts">
// Opt-in stops list — the composable panel the user asked for (Untitled UI /
// cssgradient.io ship the same). Default-renders one row per stop, ordered by
// position so it mirrors the rail. Each row opens the SAME color editor and
// (except the two endpoint stops) can be deleted. The rail stays the primary UI.
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { ActiveEidos } from '$uix/eidos';
import Row from './gradient-builder-stop-list-row.svelte';
import type { GradientBuilderStopListProps } from './types';
let {
children,
...rest
}: GradientBuilderStopListProps & { children?: import('svelte').Snippet } = $props();
const provider = GradientBuilderProvider.require();
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.stop-list|Color stops'));
// Row order = position order (the leftmost / rightmost are the endpoints whose
// delete is disabled). We keep the ORIGINAL index for each row so it maps back
// to `provider.stops`.
const ordered = $derived(
provider.stops
.map((_, i) => i)
.sort((a, b) => provider.stopPosition(a) - provider.stopPosition(b))
);
</script>
<div {...rest} data-gradient-builder-stop-list role="list" aria-label={label}>
{#if children}
{@render children()}
{:else}
{#each ordered as i, pos (i)}
<Row index={i} isEndpoint={pos === 0 || pos === ordered.length - 1} />
{/each}
{/if}
</div>

@ -0,0 +1,19 @@
<script lang="ts">
import * as GradientBuilder from '$soma/components/gradient-builder';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { stopColorToCss } from '$libs/gradient';
import type { GradientBuilderStopProps } from './types';
let { index = 0, ...rest }: GradientBuilderStopProps = $props();
const provider = GradientBuilderProvider.require();
const pos = $derived(provider.stopPosition(index) * 100);
const fill = $derived.by(() => {
const stop = provider.stops[index];
return stop ? stopColorToCss(stop.color, stop.alpha) : 'transparent';
});
</script>
<!-- The handle IS the slider thumb (soma owns drag + keyboard). Eidos positions
it along the rail and paints it with the stop's color via `--gb-stop-fill`. -->
<GradientBuilder.Stop {...rest} {index} style="left: {pos}%; --gb-stop-fill: {fill};" />

@ -0,0 +1,27 @@
<script lang="ts">
import * as GradientBuilder from '$soma/components/gradient-builder';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
import { gradientToCss } from '$libs/gradient';
import Stop from './gradient-builder-stop.svelte';
import type { GradientBuilderTrackProps } from './types';
let { children, ...rest }: GradientBuilderTrackProps = $props();
const provider = GradientBuilderProvider.require();
// The track paints the LIVE gradient as a horizontal ramp so the stops sit
// over the actual colors they edit — independent of the gradient's `angle`
// (the dial controls geometry; the rail is always left→right for editing).
const rail = $derived(
gradientToCss({ kind: 'linear', angle: 90, stops: provider.stops })
);
</script>
<GradientBuilder.Track {...rest} style="background: {rail};">
{#if children}
{@render children()}
{:else}
{#each provider.stops as _stop, i (i)}
<Stop index={i} />
{/each}
{/if}
</GradientBuilder.Track>

@ -0,0 +1,294 @@
/* GradientBuilder — visual layer.
*
* Self-contained recipe (gradient-builder is on the active dev track, so it
* does NOT register tokens in recipes/base.ts yet). Internal `--_gradient-builder-*`
* tokens derive from global tokens; the child controls (Slider / ToggleGroup /
* Button / ColorPicker) bring their own recipes.
*
* Scaling/density: every gap + padding uses `--space-*` (density- AND
* scaling-aware); every fixed dimension is `calc(px * var(--scaling, 1))` so the
* editor zooms with the System `scaling` axis. The color editor for the selected
* stop is opened by clicking a stop (not always visible).
*/
[data-gradient-builder] {
--_gradient-builder-preview-height: calc(128px * var(--scaling, 1));
--_gradient-builder-track-height: calc(24px * var(--scaling, 1));
--_gradient-builder-stop-size: calc(20px * var(--scaling, 1));
--_gradient-builder-radius: var(--radius-md);
--_gradient-builder-border: var(--color-border-default);
--_gradient-builder-stop-ring: var(--color-surface-default);
--_gradient-builder-label: var(--color-content-secondary);
/* Transparency checker revealed under the preview where stops are
translucent. Kept subtle so it doesn't fight the gradient in dark mode. */
--gb-checker-cell: calc(7px * var(--scaling, 1));
--gb-checker:
linear-gradient(45deg, var(--color-border-subtle) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-border-subtle) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-border-subtle) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-border-subtle) 75%);
display: flex;
flex-direction: column;
gap: var(--space-4);
inline-size: 100%;
min-inline-size: 0;
box-sizing: border-box;
padding: var(--space-4);
/* `raised` + a defined border so the editor reads as a distinct panel in
both light and dark (surface-default blended into the page background). */
background: var(--color-surface-raised, var(--color-surface-default));
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-lg);
}
[data-gradient-builder][data-size='sm'] {
--_gradient-builder-preview-height: calc(56px * var(--scaling, 1));
--_gradient-builder-track-height: calc(20px * var(--scaling, 1));
--_gradient-builder-stop-size: calc(18px * var(--scaling, 1));
gap: var(--space-3);
padding: var(--space-3);
}
[data-gradient-builder][data-size='lg'] {
--_gradient-builder-preview-height: calc(88px * var(--scaling, 1));
--_gradient-builder-track-height: calc(28px * var(--scaling, 1));
--_gradient-builder-stop-size: calc(22px * var(--scaling, 1));
gap: var(--space-5);
padding: var(--space-5);
}
/* Variant — the editor card framing. */
[data-gradient-builder][data-variant='outline'] {
background: transparent;
}
[data-gradient-builder][data-variant='ghost'] {
background: transparent;
border-color: transparent;
padding: 0;
}
[data-gradient-builder][data-disabled] {
opacity: var(--opacity-disabled);
pointer-events: none;
}
/* The global `commit` firma pulses a primary box-shadow RING on whatever the
commit event targets. Our `commit-set` targets the provider CARD, so every
angle / kind / color commit flashed a ring around the whole editor. Kill the
VISUAL ring on the card (the sound/haptic firma is unaffected — it runs in JS,
not CSS). The child controls keep their own (small) commit rings. */
[data-gradient-builder][data-event-family='commit'][data-event-phase='active'] {
animation: none;
}
/* ── Preview — the live gradient bar (decorative) ────────────────────────── */
[data-gradient-builder-preview] {
position: relative;
block-size: var(--_gradient-builder-preview-height);
border-radius: var(--_gradient-builder-radius);
border: var(--border-width) solid var(--color-border-subtle);
background-color: var(--color-surface-default);
background-image: var(--gb-checker);
background-size: calc(var(--gb-checker-cell) * 2) calc(var(--gb-checker-cell) * 2);
background-position:
0 0,
0 var(--gb-checker-cell),
var(--gb-checker-cell) calc(-1 * var(--gb-checker-cell)),
calc(-1 * var(--gb-checker-cell)) 0;
isolation: isolate;
}
/* ── Track — the stop rail / drag surface ────────────────────────────────── */
[data-gradient-builder-track] {
position: relative;
block-size: var(--_gradient-builder-track-height);
/* Room so the edge handles (left:0 / 100%, centered by translate) clear the card. */
margin-inline: calc(var(--_gradient-builder-stop-size) / 2);
border-radius: var(--_gradient-builder-radius);
border: var(--border-width) solid var(--color-border-subtle);
touch-action: none;
}
/* ── Stop — the keyboard-accessible slider thumb ─────────────────────────── */
[data-gradient-builder-stop] {
position: absolute;
inset-block-start: 50%;
/* `left` is set inline. Center with `translate` (the property, not
`transform`) so a sema press-squeeze can't fight the centering. */
translate: -50% -50%;
inline-size: var(--_gradient-builder-stop-size);
block-size: var(--_gradient-builder-stop-size);
border-radius: var(--radius-full);
border: calc(2px * var(--scaling, 1)) solid var(--_gradient-builder-stop-ring);
background: var(--gb-stop-fill, var(--color-surface-default));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-neutral-contrast) 45%, transparent),
0 1px 3px color-mix(in srgb, var(--color-neutral-contrast) 30%, transparent);
cursor: grab;
touch-action: none;
outline: none;
}
[data-gradient-builder-stop][data-dragging] {
cursor: grabbing;
}
[data-gradient-builder-stop][data-selected] {
box-shadow:
0 0 0 calc(2px * var(--scaling, 1)) var(--color-primary-solid),
0 1px 3px color-mix(in srgb, var(--color-neutral-contrast) 35%, transparent);
}
[data-gradient-builder-stop]:focus-visible {
box-shadow:
0 0 0 calc(2px * var(--scaling, 1)) var(--color-surface-default),
0 0 0 calc(4px * var(--scaling, 1)) var(--color-primary-solid);
}
/* ── Stop actions — add / remove ─────────────────────────────────────────── */
[data-gradient-builder-stop-actions] {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ── Kind switch — full-width segmented control ──────────────────────────── */
[data-gradient-builder-kind-switch] {
display: block;
}
/* Composed on the same row as the Add button — the switch fills the rest. */
[data-gradient-builder-stop-actions] [data-gradient-builder-kind-switch] {
flex: 1;
min-inline-size: 0;
}
/* ── Angle dial — [label … value] head + full-width slider ───────────────── */
[data-gradient-builder-angle-dial] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-gradient-builder-angle-dial-head] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
[data-gradient-builder-angle-dial-label] {
font-size: var(--font-size-sm);
color: var(--_gradient-builder-label);
}
[data-gradient-builder-angle-dial-value] {
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
color: var(--color-content-primary);
}
/* ── Stop color editor — a popover anchored to the rail, opened by a stop click ── */
/* The anchor wraps the Track; it must not change the rail layout. */
[data-gradient-builder-stop-color-anchor] {
display: block;
}
[data-gradient-builder-stop-color-title] {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium, 500);
color: var(--color-content-primary);
}
[data-gradient-builder-stop-color-sliders] {
display: flex;
flex-direction: column;
gap: var(--space-2);
/* Rails span 90% of the panel, centred; taller track than the md default. */
inline-size: 90%;
margin-inline: auto;
}
[data-gradient-builder-stop-color-sliders] [data-color-picker-channel-slider] {
--_slider-track-size: var(--slider-track-size-xl);
}
/* The remove-stop button sits at the inline-start of the shell footer; the
* canonical Cancel / Close stay end-aligned via the footer's own layout. */
[data-gradient-builder-stop-color-remove] {
margin-inline-end: auto;
}
/* ── Stops list (opt-in <GradientBuilder.StopList>) ─────────────────────────── */
[data-gradient-builder-stop-list] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
[data-gradient-builder-stop-list-row] {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* The value button reads like a ColorField value: swatch + hex + position. */
[data-gradient-builder-stop-list-value] {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: var(--space-2);
min-inline-size: 0;
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-default);
color: var(--color-content-primary);
font: inherit;
font-size: var(--font-size-sm);
text-align: start;
cursor: pointer;
}
[data-gradient-builder-stop-list-value]:hover:not(:disabled) {
background-image: linear-gradient(var(--state-hover), var(--state-hover));
}
[data-gradient-builder-stop-list-value][data-selected] {
border-color: var(--color-primary-border);
}
[data-gradient-builder-stop-list-value]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
[data-gradient-builder-stop-list-hex] {
font-variant-numeric: tabular-nums;
}
[data-gradient-builder-stop-list-pos] {
margin-inline-start: auto;
color: var(--color-content-secondary);
font-variant-numeric: tabular-nums;
}
/* ── Presets panel (below the editor when a `presets` prop is defined) ──────── */
[data-gradient-builder-presets] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-gradient-builder-presets] .gb-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: var(--space-2);
}
[data-gradient-builder-preset] {
inline-size: 100%;
block-size: calc(32px * var(--scaling, 1));
padding: 0;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
}
[data-gradient-builder-preset]:hover {
border-color: var(--color-border-strong);
}
[data-gradient-builder-preset]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* The selected saved preset — highlighted; it flips the action button to Delete. */
[data-gradient-builder-preset][data-selected] {
border-color: var(--color-primary-border);
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: 2px;
}

@ -0,0 +1,92 @@
<script lang="ts">
// Child controls compose Slider / ToggleGroup / Button / ColorPicker, so load
// their recipes here first (self-import architecture, mirrors color-picker).
import '$uix/eidos/components/slider/slider.css';
import '$uix/eidos/components/toggle-group/toggle-group.css';
import '$uix/eidos/components/button/button.css';
import '$uix/eidos/components/color-picker/color-picker.css';
import './gradient-builder.css';
import { ActiveEidos } from '$uix/eidos';
import * as GradientBuilder from '$soma/components/gradient-builder';
import { setGradientBuilderVisualContext } from './context.svelte';
import Preview from './gradient-builder-preview.svelte';
import Track from './gradient-builder-track.svelte';
import KindSwitch from './gradient-builder-kind-switch.svelte';
import AddStop from './gradient-builder-add-stop.svelte';
import AngleDial from './gradient-builder-angle-dial.svelte';
import StopColor from './gradient-builder-stop-color.svelte';
import StopList from './gradient-builder-stop-list.svelte';
import Presets from './gradient-builder-presets.svelte';
import { gradientToCss, type Gradient } from '$libs/gradient';
import type { GradientBuilderProps } from './types';
let {
size = 'md',
// Surface-agnostic by default: the builder adapts to the container's surface
// and paints none of its own. `surface` / `outline` opt INTO a framed card.
variant = 'ghost',
value = $bindable(),
disabled = false,
presets,
maxPresets = 10,
children,
...rest
}: GradientBuilderProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
setGradientBuilderVisualContext({
getSize: () => resolvedSize,
getVariant: () => variant
});
// Saved presets — the user adds the current gradient (mirrors the ColorPicker's
// saved swatches). Ephemeral per-instance; shown alongside the provided presets.
let savedPresets = $state<Gradient[]>([]);
const canSave = $derived.by(() => {
if (value === undefined || savedPresets.length >= maxPresets) return false;
const k = gradientToCss(value);
return ![...(presets ?? []), ...savedPresets].some((g) => gradientToCss(g) === k);
});
function savePreset(): void {
if (canSave && value) savedPresets = [...savedPresets, value];
}
function deletePreset(i: number): void {
savedPresets = savedPresets.filter((_, idx) => idx !== i);
}
</script>
<GradientBuilder.Provider
{...rest}
bind:value
{disabled}
data-size={resolvedSize}
data-variant={variant}
>
{#if children}
{@render children()}
{:else}
<!-- Editor: preview · stop rail (click a stop → color popover, with
delete-this-stop inside) · add · type · angle · presets. -->
<Preview />
<StopColor>
<Track />
</StopColor>
<div data-gradient-builder-stop-actions>
<AddStop />
<KindSwitch />
</div>
<AngleDial />
<StopList />
{#if presets !== undefined}
<Presets
provided={presets}
{savedPresets}
{canSave}
onSave={savePreset}
onDelete={deletePreset}
/>
{/if}
{/if}
</GradientBuilder.Provider>

@ -0,0 +1,68 @@
// GradientBuilder — eidos compound API.
//
// import { GradientBuilder } from '$uix/eidos/components/gradient-builder';
//
// <GradientBuilder bind:value size="md">
// <GradientBuilder.Preview />
// <GradientBuilder.Track />
// <GradientBuilder.StopColor />
// <GradientBuilder.KindSwitch />
// <GradientBuilder.AngleDial />
// <GradientBuilder.AddStop />
// </GradientBuilder>
//
// The Track default-renders one Stop per gradient stop. Behaviour (drag,
// keyboard, the role=slider stops) lives in soma; eidos composes Slider /
// ToggleGroup / Button / ColorPicker and paints the surfaces.
import GradientBuilderComponent from './gradient-builder.svelte';
import Preview from './gradient-builder-preview.svelte';
import Track from './gradient-builder-track.svelte';
import Stop from './gradient-builder-stop.svelte';
import StopColor from './gradient-builder-stop-color.svelte';
import AngleDial from './gradient-builder-angle-dial.svelte';
import KindSwitch from './gradient-builder-kind-switch.svelte';
import AddStop from './gradient-builder-add-stop.svelte';
import RemoveStop from './gradient-builder-remove-stop.svelte';
import StopList from './gradient-builder-stop-list.svelte';
type GradientBuilderNamespace = typeof GradientBuilderComponent & {
Preview: typeof Preview;
Track: typeof Track;
Stop: typeof Stop;
StopColor: typeof StopColor;
AngleDial: typeof AngleDial;
KindSwitch: typeof KindSwitch;
AddStop: typeof AddStop;
RemoveStop: typeof RemoveStop;
StopList: typeof StopList;
};
const GradientBuilder = GradientBuilderComponent as GradientBuilderNamespace;
GradientBuilder.Preview = Preview;
GradientBuilder.Track = Track;
GradientBuilder.Stop = Stop;
GradientBuilder.StopColor = StopColor;
GradientBuilder.AngleDial = AngleDial;
GradientBuilder.KindSwitch = KindSwitch;
GradientBuilder.AddStop = AddStop;
GradientBuilder.RemoveStop = RemoveStop;
GradientBuilder.StopList = StopList;
export { GradientBuilder };
export default GradientBuilder;
export type {
GradientBuilderProps,
GradientBuilderTrackProps as TrackProps,
GradientBuilderStopProps as StopProps,
GradientBuilderPreviewProps as PreviewProps,
GradientBuilderStopColorProps as StopColorProps,
GradientBuilderAngleDialProps as AngleDialProps,
GradientBuilderKindSwitchProps as KindSwitchProps,
GradientBuilderAddStopProps as AddStopProps,
GradientBuilderStopListProps as StopListProps,
GradientBuilderStopListRowProps as StopListRowProps,
GradientBuilderSize,
GradientBuilderVariant
} from './types';

@ -0,0 +1,37 @@
/**
* Bridge between the gradient model's {@link StopColor} and the ColorPicker's
* {@link ColorValue} — the one non-obvious seam in the GradientBuilder eidos.
*
* The ColorPicker is an sRGB HSV editor; the gradient model stores OKLCH /
* role-refs / raw CSS. v1 reads any stop into the picker (best-effort) and
* writes the picked color back as a `css` hex stop (lossless for what the
* picker can express, and `gradientToCss` emits css stops verbatim). Role and
* OKLCH stops still RENDER correctly via `stopColorToCss`; editing one through
* the picker re-bakes it to a `css` hex literal.
*/
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
import { oklchToHex } from '$color';
import type { StopColor } from '$libs/gradient';
/**
* Seed a {@link ColorValue} from a {@link StopColor} for the embedded picker.
* `css` stops are parsed; `oklch` stops are converted to hex first; `role`
* stops can't be resolved statically (they're `var(--color-…)`) so the picker
* falls back to the default until the user picks — which bakes a css stop.
*/
export function stopColorToColorValue(c: StopColor): ColorValue {
if (c.kind === 'oklch') {
const hsv = parseColor(oklchToHex(c.value));
return hsv ? colorValueFromHsv(hsv) : DEFAULT_COLOR;
}
if (c.kind === 'css') {
const hsv = parseColor(c.value);
return hsv ? colorValueFromHsv(hsv) : DEFAULT_COLOR;
}
return DEFAULT_COLOR;
}
/** Write a picked {@link ColorValue} back as a `css` hex stop (alpha baked in). */
export function colorValueToStopColor(v: ColorValue): StopColor {
return { kind: 'css', value: v.hex };
}

@ -0,0 +1,84 @@
import type {
ProviderProps as SomaProviderProps,
TrackProps as SomaTrackProps,
StopProps as SomaStopProps
} from '$soma/components/gradient-builder';
import type { ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { Gradient } from '$libs/gradient';
export type GradientBuilderSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type GradientBuilderVariant = ControlVariant;
export type GradientBuilderProps = SomaProviderProps & {
/** Visual size for the editor and its child controls. @default 'md' */
size?: ResponsiveProp<GradientBuilderSize>;
/**
* Surface treatment. Surface-agnostic (`ghost`) by default so the editor
* adapts to its container; `surface` / `outline` opt into a framed card.
* @default 'ghost'
*/
variant?: GradientBuilderVariant;
/**
* Preset gradients. When defined (even `[]`), the editor shows a presets
* panel: the provided presets (select-only) + the user's saved ones (with a
* "save current" action, capped at `maxPresets`). Unset → no presets panel.
*/
presets?: Gradient[];
/** Max number of user-saved presets. @default 10 */
maxPresets?: number;
};
/** The presets panel (shown when `presets` is defined). */
export type GradientBuilderPresetsProps = {
/** Provided presets (select-only). */
provided: Gradient[];
/** User-saved presets (select + delete). */
savedPresets: Gradient[];
/** Whether the current gradient can be saved (under cap, not already present). */
canSave: boolean;
/** Save the current gradient to the saved list. */
onSave?: () => void;
/** Delete the saved preset at index `i`. */
onDelete?: (i: number) => void;
};
export type GradientBuilderTrackProps = SomaTrackProps;
export type GradientBuilderStopProps = SomaStopProps;
/** The decorative gradient bar above the editor. */
export type GradientBuilderPreviewProps = {
id?: string;
} & Record<string, unknown>;
/** The inline ColorPicker bound to the selected stop. */
export type GradientBuilderStopColorProps = {
id?: string;
};
/** The 0–360° angle dial (linear / conic only). */
export type GradientBuilderAngleDialProps = {
id?: string;
};
/** The linear / radial / conic switch. */
export type GradientBuilderKindSwitchProps = {
id?: string;
};
/** The "add color stop" button. */
export type GradientBuilderAddStopProps = {
id?: string;
};
/** The opt-in vertical list of stops (composable). Default-renders one row per stop. */
export type GradientBuilderStopListProps = {
id?: string;
} & Record<string, unknown>;
/** One row of the StopList — a stop's swatch/value + delete. */
export type GradientBuilderStopListRowProps = {
/** The stop's array index. */
index: number;
/** First/last stop by position — its delete is hidden (anchors 0 / 1). */
isEndpoint?: boolean;
};

@ -0,0 +1,64 @@
# Eidos GradientPicker
The visual layer over the headless
[`GradientPicker`](../../../soma/components/gradient-picker/README.md) — a
field-shaped trigger carrying a live gradient chip that opens the
[`GradientBuilder`](../gradient-builder/README.md) in a Popover, with a
Clear / Cancel / Save footer. Identity wrapper over the composed Popover.
## Surface
```svelte
<GradientPicker bind:value size="md" variant="surface">
<GradientPicker.Trigger />
<GradientPicker.Portal>
<GradientPicker.Content />
</GradientPicker.Portal>
</GradientPicker>
```
`<GradientPicker.Content />` renders a default body — the GradientBuilder editor
+ the `Clear / Cancel / Save` footer — so it works out of the box. Pass children
to the Content to customise it.
## Parts
| Part | Composes | Notes |
| ---------------------------- | ---------------------- | ---------------------------------------------------- |
| `Provider` | soma Provider | Sets a size/variant visual context. |
| `Trigger` | soma Trigger + Popover | Field-shaped pill with the gradient chip. |
| `ValueSwatch` | soma ValueSwatch | Standalone gradient chip (paints `--gp-current-gradient`). |
| `Content` | Popover + PickerShell | The floating editor: GradientBuilder + footer. |
| `Footer` / `Clear` / `Cancel` / `Close` | PickerShell | The shared footer actions (read `pickerShellContext`).|
| `Portal` / `Anchor` / `Overlay` / `Arrow` | Popover | Re-exports. |
## Props
| Prop | Type | Default | Description |
| --------- | ----------------------------------- | ----------- | ---------------------------------------- |
| `value` | `Gradient` | 2-stop | The committed gradient. Bindable. |
| `open` | `boolean` | `false` | Popover open state. Bindable. |
| `mode` | `'inline' \| 'modal'` | `'inline'` | Footer layout (Clear/Cancel vs Cancel/Save). |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Trigger / popover / editor size. |
| `variant` | `'surface' \| 'outline' \| 'ghost'` | `'surface'` | Trigger framing. |
| `disabled`| `boolean` | `false` | Disables the trigger. |
## The value bridge (the one subtle bit)
`Content` renders `<GradientBuilder bind:value={draft} onValueCommit=…>`: the
embedded builder edits a local `draft` ($state); its `onValueCommit` pushes
settled values to `provider.setValue`; an `$effect` (`if (v !== draft) draft = v`)
flows external resets (Cancel / Clear) back. Commit-only push + the ref guard =
no loop.
## Recipe
Self-contained (gradient-picker is on the active dev track). The trigger is a
field-shaped pill; the chip + standalone swatch paint `--gp-current-gradient`
(stamped inline by soma). The popover content + editor reuse the Popover +
PickerShell + GradientBuilder recipes.
## Notes
- **No `color` prop** (like GradientBuilder). Demo:
`web/routes/uix/components/gradient-picker/+page.svelte`.

@ -0,0 +1,20 @@
import { getContext, setContext } from 'svelte';
import type { GradientPickerSize, GradientPickerVariant } from './types';
const GRADIENT_PICKER_VISUAL_CONTEXT = Symbol('uix.eidos.gradient-picker.visual');
export interface GradientPickerVisualContext {
getSize(): GradientPickerSize;
getVariant(): GradientPickerVariant;
}
export function setGradientPickerVisualContext(
ctx: GradientPickerVisualContext
): GradientPickerVisualContext {
setContext(GRADIENT_PICKER_VISUAL_CONTEXT, ctx);
return ctx;
}
export function getGradientPickerVisualContext(): GradientPickerVisualContext | undefined {
return getContext<GradientPickerVisualContext | undefined>(GRADIENT_PICKER_VISUAL_CONTEXT);
}

@ -0,0 +1,74 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import PopoverContent from '../popover/popover-content.svelte';
import { PickerShell } from '../picker-shell';
import { GradientBuilder } from '../gradient-builder';
import { GradientPickerProvider } from '$soma/components/gradient-picker';
import { getGradientPickerVisualContext } from './context.svelte';
import type { GradientPickerContentProps } from './types';
import type { Gradient } from '$libs/gradient';
let {
size,
align = 'start',
sideOffset,
collisionPadding = 12,
children: bodyContent,
...rest
}: GradientPickerContentProps = $props();
const eidos = ActiveEidos.require();
const visual = getGradientPickerVisualContext();
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
const provider = GradientPickerProvider.require();
// Two-way bridge to the picker's value: the GradientBuilder edits `draft`
// live; settled changes commit to the provider. External resets (Cancel /
// Clear) flow back through the effect. Commit-only push avoids a loop.
let draft = $state<Gradient>(provider.value);
$effect(() => {
const v = provider.value;
if (v !== draft) draft = v;
});
</script>
<PopoverContent
{...rest}
{align}
{sideOffset}
{collisionPadding}
size={resolvedSize}
data-picker-size={resolvedSize}
data-gradient-picker-content=""
>
{#snippet children(snippetProps)}
<PickerShell>
{#if bodyContent}
{@render bodyContent(snippetProps)}
{:else}
<PickerShell.Body>
<GradientBuilder
bind:value={draft}
size={resolvedSize}
onValueCommit={(g) => provider.setValue(g)}
>
<GradientBuilder.Preview />
<GradientBuilder.Track />
<div class="gradient-picker-controls">
<GradientBuilder.KindSwitch />
<GradientBuilder.AddStop />
</div>
<GradientBuilder.AngleDial />
<GradientBuilder.StopColor />
</GradientBuilder>
</PickerShell.Body>
<PickerShell.Footer>
<PickerShell.Clear />
<PickerShell.Cancel />
<PickerShell.Close />
</PickerShell.Footer>
{/if}
</PickerShell>
{/snippet}
</PopoverContent>

@ -0,0 +1,43 @@
<script lang="ts">
/**
* Eidos `<GradientPicker.Trigger>` — paint wrapper. Soma owns the value and
* stamps `--gp-current-gradient` inline; this wrapper adds the visual
* data-attrs + the gradient preview chip. CSS reads the var to paint it.
*/
import { ActiveEidos } from '$uix/eidos';
import * as GradientPicker from '$soma/components/gradient-picker';
import { getGradientPickerVisualContext } from './context.svelte';
import type { GradientPickerTriggerProps } from './types';
let {
size,
variant,
showSwatch = true,
children,
style,
...rest
}: GradientPickerTriggerProps = $props();
const eidos = ActiveEidos.require();
const visual = getGradientPickerVisualContext();
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'surface');
const label = $derived(eidos.langs.ts('#?components.gradient-picker.trigger|Open gradient editor'));
</script>
<GradientPicker.Trigger
{...rest}
data-size={resolvedSize}
data-variant={resolvedVariant}
data-show-swatch={showSwatch ? '' : undefined}
{style}
>
{#if showSwatch}
<span class="gradient-picker-trigger-swatch" aria-hidden="true"></span>
{/if}
{#if children}
{@render children()}
{:else}
<span class="gradient-picker-trigger-label">{label}</span>
{/if}
</GradientPicker.Trigger>

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

@ -0,0 +1,106 @@
/* GradientPicker — visual layer.
*
* Self-contained recipe (gradient-picker is on the active dev track, so it
* does NOT register tokens in recipes/base.ts yet). The trigger is a
* field-shaped pill carrying a gradient preview chip; the popover content +
* editor layout reuse the shared Popover + PickerShell + GradientBuilder
* recipes. Internal `--_gradient-picker-*` tokens derive from global tokens.
*
* Soma stamps `--gp-current-gradient` inline on the trigger + value swatch, so
* the chip paints purely from CSS — soma owns the value, eidos owns the paint.
*/
[data-gradient-picker] {
--_gradient-picker-trigger-height: var(--control-height-md);
--_gradient-picker-trigger-px: var(--space-2-5);
--_gradient-picker-trigger-font-size: var(--font-size-md);
--_gradient-picker-chip-size: calc(var(--control-height-md) - var(--space-2));
display: inline-flex;
flex-direction: column;
gap: var(--space-1-5);
min-inline-size: 0;
}
[data-gradient-picker][data-size='sm'] {
--_gradient-picker-trigger-height: var(--control-height-sm);
--_gradient-picker-trigger-px: var(--space-2);
--_gradient-picker-trigger-font-size: var(--font-size-sm);
--_gradient-picker-chip-size: calc(var(--control-height-sm) - var(--space-2));
}
[data-gradient-picker][data-size='lg'] {
--_gradient-picker-trigger-height: var(--control-height-lg);
--_gradient-picker-trigger-px: var(--space-3);
--_gradient-picker-trigger-font-size: var(--font-size-lg);
--_gradient-picker-chip-size: calc(var(--control-height-lg) - var(--space-2));
}
/* ── Trigger — field-shaped pill with a gradient chip ────────────────────── */
[data-gradient-picker-trigger] {
display: inline-flex;
align-items: center;
gap: var(--space-2);
block-size: var(--_gradient-picker-trigger-height);
padding-inline: var(--_gradient-picker-trigger-px);
font-size: var(--_gradient-picker-trigger-font-size);
color: var(--color-content-primary);
background: var(--color-surface-default);
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default);
}
[data-gradient-picker-trigger]:hover {
background: var(--color-surface-overlay);
border-color: var(--color-border-strong);
}
[data-gradient-picker-trigger]:focus-visible {
outline: 2px solid var(--color-primary-solid);
outline-offset: 1px;
}
[data-gradient-picker-trigger][data-disabled] {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
.gradient-picker-trigger-swatch {
flex: none;
inline-size: var(--_gradient-picker-chip-size);
block-size: var(--_gradient-picker-chip-size);
border-radius: var(--radius-sm);
border: var(--border-width) solid var(--color-border-subtle);
background: var(--gp-current-gradient, transparent);
}
.gradient-picker-trigger-label {
white-space: nowrap;
}
/* ── Standalone value swatch ─────────────────────────────────────────────── */
[data-gradient-picker-value-swatch] {
inline-size: var(--_gradient-picker-chip-size);
block-size: var(--_gradient-picker-chip-size);
border-radius: var(--radius-sm);
border: var(--border-width) solid var(--color-border-subtle);
background: var(--gp-current-gradient, transparent);
}
/* ── Content — sized to fit the embedded editor (Area + sliders) ─────────── */
[data-gradient-picker-content] {
inline-size: 20rem;
}
[data-gradient-picker-content][data-picker-size='sm'] {
inline-size: 18rem;
}
[data-gradient-picker-content][data-picker-size='lg'] {
inline-size: 22rem;
}
.gradient-picker-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}

@ -0,0 +1,46 @@
<script lang="ts">
// The trigger composes Button-shaped chrome and the content composes Popover +
// PickerShell + the GradientBuilder, so load their recipes here first.
import '$uix/eidos/components/popover/popover.css';
import '$uix/eidos/components/picker-shell/picker-shell.css';
import '$uix/eidos/components/gradient-builder/gradient-builder.css';
import './gradient-picker.css';
import { ActiveEidos } from '$uix/eidos';
import * as GradientPicker from '$soma/components/gradient-picker';
import { setGradientPickerVisualContext } from './context.svelte';
import { setPickerShellVisual } from '$uix/eidos/components/picker-shell/visual-context';
import type { GradientPickerProps } from './types';
let {
size = 'md',
variant = 'surface',
value = $bindable(),
open = $bindable(false),
mode = 'inline',
disabled = false,
children,
...rest
}: GradientPickerProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
setGradientPickerVisualContext({
getSize: () => resolvedSize,
getVariant: () => variant
});
// Footer controls follow the picker size.
setPickerShellVisual({ getSize: () => resolvedSize });
</script>
<GradientPicker.Provider
{...rest}
bind:value
bind:open
{mode}
{disabled}
data-size={resolvedSize}
data-variant={variant}
>
{@render children?.()}
</GradientPicker.Provider>

@ -0,0 +1,67 @@
// GradientPicker — eidos compound API. Trigger + Popover wrapping the
// interactive GradientBuilder. Identity wrapper over the composed Popover.
//
// import { GradientPicker } from '$uix/eidos/components/gradient-picker';
//
// <GradientPicker bind:value bind:open>
// <GradientPicker.Trigger><GradientPicker.ValueSwatch /></GradientPicker.Trigger>
// <GradientPicker.Portal>
// <GradientPicker.Content />
// </GradientPicker.Portal>
// </GradientPicker>
import GradientPickerComponent from './gradient-picker.svelte';
import Trigger from './gradient-picker-trigger.svelte';
import ValueSwatch from './gradient-picker-value-swatch.svelte';
import Content from './gradient-picker-content.svelte';
import Anchor from '../popover/popover-anchor.svelte';
import Overlay from '../popover/popover-overlay.svelte';
import Arrow from '../popover/popover-arrow.svelte';
import PopoverClose from '../popover/popover-close.svelte';
// Footer / Clear / Cancel / Close are the canonical PickerShell parts.
import Footer from '../picker-shell/picker-shell-footer.svelte';
import Clear from '../picker-shell/picker-shell-clear.svelte';
import Cancel from '../picker-shell/picker-shell-cancel.svelte';
import PickerCloseAction from '../picker-shell/picker-shell-close.svelte';
import { Portal } from '$soma/components/internal';
type GradientPickerNamespace = typeof GradientPickerComponent & {
Trigger: typeof Trigger;
ValueSwatch: typeof ValueSwatch;
Portal: typeof Portal;
Anchor: typeof Anchor;
Overlay: typeof Overlay;
Content: typeof Content;
Arrow: typeof Arrow;
PopoverClose: typeof PopoverClose;
Footer: typeof Footer;
Clear: typeof Clear;
Cancel: typeof Cancel;
Close: typeof PickerCloseAction;
};
const GradientPicker = GradientPickerComponent as GradientPickerNamespace;
GradientPicker.Trigger = Trigger;
GradientPicker.ValueSwatch = ValueSwatch;
GradientPicker.Portal = Portal;
GradientPicker.Anchor = Anchor;
GradientPicker.Overlay = Overlay;
GradientPicker.Content = Content;
GradientPicker.Arrow = Arrow;
GradientPicker.PopoverClose = PopoverClose;
GradientPicker.Footer = Footer;
GradientPicker.Clear = Clear;
GradientPicker.Cancel = Cancel;
GradientPicker.Close = PickerCloseAction;
export { GradientPicker };
export default GradientPicker;
export type {
GradientPickerProps,
GradientPickerTriggerProps as TriggerProps,
GradientPickerValueSwatchProps as ValueSwatchProps,
GradientPickerContentProps as ContentProps,
GradientPickerSize,
GradientPickerVariant
} from './types';

@ -0,0 +1,34 @@
import type {
ProviderProps as SomaProviderProps,
TriggerProps as SomaTriggerProps,
ValueSwatchProps as SomaValueSwatchProps
} from '$soma/components/gradient-picker';
import type { PopoverContentProps } from '../popover/types';
import type { ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { GradientBuilderSize } from '../gradient-builder/types';
export type GradientPickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type GradientPickerVariant = ControlVariant;
export type GradientPickerProps = SomaProviderProps & {
/** Visual size for the trigger, popover and editor. @default 'md' */
size?: ResponsiveProp<GradientPickerSize>;
/** Visual treatment of the trigger. @default 'surface' */
variant?: GradientPickerVariant;
};
export type GradientPickerTriggerProps = SomaTriggerProps & {
size?: ResponsiveProp<GradientPickerSize>;
variant?: GradientPickerVariant;
/** When true (default) the trigger shows the gradient preview chip. */
showSwatch?: boolean;
};
export type GradientPickerValueSwatchProps = SomaValueSwatchProps;
export type GradientPickerContentProps = Omit<PopoverContentProps, 'size'> & {
size?: ResponsiveProp<GradientPickerSize>;
};
/** The builder size matched to a picker size (sm/md/lg → sm/md/lg). */
export type GradientPickerBuilderSize = GradientBuilderSize;

@ -40,6 +40,34 @@ export const gradientBuilderLangs = {
es: 'Tipo de degradado',
en: 'Gradient type'
},
'stop-list': {
es: 'Paradas de color',
en: 'Color stops'
},
'value-label': {
es: 'Valor de color',
en: 'Color value'
},
presets: {
es: 'Degradados predefinidos',
en: 'Gradient presets'
},
'save-preset': {
es: 'Guardar degradado',
en: 'Save gradient'
},
'delete-preset': {
es: 'Borrar degradado',
en: 'Delete gradient'
},
'tab-editor': {
es: 'Editor',
en: 'Editor'
},
'tab-presets': {
es: 'Predefinidos',
en: 'Presets'
},
kind: {
linear: {
es: 'Lineal',

@ -0,0 +1,23 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the gradient-picker component. Merged under
* `components.gradient-picker.*` by `ActiveUix` (via `componentLangs` barrel).
*
* Morfo + eidos reference entries by absolute idlangref:
* `'#?components.gradient-picker.{key}|fallback'`.
*/
export const gradientPickerLangs = {
label: {
es: 'Selector de degradado',
en: 'Gradient picker'
},
trigger: {
es: 'Abrir editor de degradado',
en: 'Open gradient editor'
},
content: {
es: 'Editor de degradado',
en: 'Gradient editor'
}
} satisfies LangNode;

@ -68,7 +68,9 @@ export const gradientBuilderMorfo = {
track: '#?components.gradient-builder.track|Gradient stops',
'add-stop': '#?components.gradient-builder.add-stop|Add color stop',
'angle-dial': '#?components.gradient-builder.angle-dial|Gradient angle',
'kind-switch': '#?components.gradient-builder.kind-switch|Gradient type'
'kind-switch': '#?components.gradient-builder.kind-switch|Gradient type',
'stop-list': '#?components.gradient-builder.stop-list|Color stops',
presets: '#?components.gradient-builder.presets|Gradient presets'
},
parts: [
@ -195,6 +197,69 @@ export const gradientBuilderMorfo = {
severity: 'recommended'
}
]
},
{
// A vertical list of the stops — each row = a colorpicker-value swatch
// (click opens the same color editor) + a delete button. Opt-in richness
// (Untitled UI / cssgradient.io ship this); the rail stays the primary UI.
name: 'StopList',
kebab: 'stop-list',
archetype: 'group',
role: 'list',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-builder.stop-list|Color stops'),
severity: 'recommended'
}
]
},
{
// One row of the StopList — a stop's swatch/value + delete. The endpoint
// stops keep delete disabled (first/last anchor the gradient at 0 / 1).
name: 'StopListRow',
kebab: 'stop-list-row',
role: 'listitem',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-selected', severity: 'optional' }],
aria: []
},
{
// The presets gallery — shown as a second tab when a `presets` prop is
// passed. A grid of preset gradients; picking one replaces the value.
name: 'Presets',
kebab: 'presets',
archetype: 'group',
role: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-builder.presets|Gradient presets'),
severity: 'recommended'
}
]
},
{
// One preset swatch — a button painted with the preset gradient.
name: 'Preset',
kebab: 'preset',
archetype: 'trigger',
role: 'button',
kind: 'public',
defaultElement: 'button',
optional: true,
data: [{ attr: 'data-selected', severity: 'optional' }],
aria: [{ attr: 'type', value: v.literal('button') }]
}
]
} as const satisfies Morfo;

@ -0,0 +1,140 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* GradientPicker — composite popover containing the interactive GradientBuilder.
* The morfo declares the picker's own surface (trigger + value swatch + the
* content shell + the clear value-semantic); the editing events live on the
* `gradient-builder` morfo inside.
*
* Identity wrapper over a composed Popover (§2): the Popover IS the dialog.
* This morfo declares no dialog semantics (no role:dialog, no focus trap, no
* open/close events) — they belong to the composed Popover. Footer / Clear /
* Cancel / Close are the shared `picker-shell` chrome.
*/
export const gradientPickerMorfo = {
name: 'GradientPicker',
kebab: 'gradient-picker',
scope: ['soma', 'sema', 'eidos'],
expression: 'delegated',
texts: {
label: '#?components.gradient-picker.label|Gradient Picker',
trigger: '#?components.gradient-picker.trigger|Open gradient editor',
content: '#?components.gradient-picker.content|Gradient editor'
},
events: [
{
// User reset the gradient to its default (the Clear action).
// Picker-specific value semantic — NOT a dialog event.
name: 'commit-reset',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('content'),
sequence: 'post',
intent: 'neutral'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'Control',
kebab: 'control',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-haspopup', value: v.literal('dialog') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-picker.trigger|Open gradient editor'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Enter', action: 'open' },
{ key: ' ', action: 'open' }
]
},
{
// The gradient preview chip on the trigger — decorative.
name: 'ValueSwatch',
kebab: 'value-swatch',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-last-action',
values: ['committed', 'cancelled', 'dismissed', 'dismissed-outside'],
severity: 'optional'
}
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-picker.content|Gradient editor'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,56 @@
import { semaSelector } from '$uix/morfo';
import { gradientBuilderMorfo } from '$uix/morfo/components/gradient-builder';
import { sound } from '../sounds';
import type { Sema } from '../sema-map';
/**
* GradientBuilder perceptual defaults.
*
* A color stop on the track is a direct-manipulation surface — it mirrors the
* slider's `handle-pick / handle-drag` shape so dragging a stop carries the
* same auditory / tactile vocabulary as moving a slider thumb.
*
* • handle-pick — single pluck when a stop is grabbed (pickup cue)
* • handle-drag — continuous stream while dragging (sound + haptic
* channels open; per-emit payload owns pitch / gain)
* • commit-set — final pluck at release / stop add·remove / recolor
* • commit-reset — same release cue when the gradient is reset
*
* The embedded ColorPicker + the angle Slider + the kind ToggleGroup bring
* their own `color-picker:* / slider:* / toggle-group:*` events automatically —
* no extra rules needed here.
*/
const onTrack = (matchers?: Parameters<typeof semaSelector<typeof gradientBuilderMorfo>>[2]) =>
semaSelector(gradientBuilderMorfo, 'track', matchers);
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof gradientBuilderMorfo>>[2]) =>
semaSelector(gradientBuilderMorfo, 'provider', matchers);
export const gradientBuilderSema: Sema = {
name: 'gradient-builder',
cascade: [
{
selector: onTrack({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: sound('handle.pickup.air', { gain: 0.12 }),
haptic: { kind: 'tick', intensity: 0.22, duration: 6 }
},
{
selector: onTrack({ eventName: 'handle-drag' }),
channels: ['sound', 'haptic']
},
{
selector: onProvider({ eventName: 'commit-set' }),
channels: ['sound', 'haptic'],
sound: sound('handle.release.soft', { gain: 0.1 }),
haptic: { kind: 'tick', intensity: 0.18, duration: 8 }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
channels: ['sound', 'haptic'],
sound: sound('handle.release.soft', { gain: 0.1 }),
haptic: { kind: 'tick', intensity: 0.18, duration: 8 }
}
]
};

@ -1,11 +1,180 @@
# GradientBuilder — continuation handoff (Phase 2, eidos + picker + demo)
# GradientBuilder — continuation handoff (Phase 2)
Read this first, then `docs/README.md` for the framework. This component is the
interactive gradient editor over the canonical `Gradient` model. The **declarative
(morfo) and headless (soma) layers are DONE, verified, committed**. What remains is
the **eidos visual editor**, the **GradientPicker** wrapper, the **sema** pack, the
**demo**, and **translations** — then remove `gradient-builder` from
`ACTIVE_DEV_TRACK` in `src/uix/contracts.test.ts`.
interactive gradient editor over the canonical `Gradient` model.
## Session 2026-07-04 — footer, surface-agnostic, stops-list, add-stop, presets
Polished the GradientBuilder toward "done". All type-clean (`npm run check` = 59,
baseline — nothing new). Still on `ACTIVE_DEV_TRACK`. Every item below verified
in-browser.
**Done:**
- **Stop color editor** (`gradient-builder-stop-color.svelte`, the per-stop Popover
ColorPicker):
- Value row = the canonical `<ColorField><ColorField.Input swatch>…segments…</>`
(swatch + the framework's styled Select), NOT the old `ChannelInput` snippet that
rendered a native `<select>`.
- Rails (hue/alpha) at 90% width + taller track (`--slider-track-size-xl`).
- **Composable footer**: `<ColorPicker.Footer>` = `<ColorPicker.Cancel>` (reverts the
stop to its open-edge colour via a `stopColorOnOpen` snapshot) + `<ColorPicker.Close>`
("Aceptar") + the gradient-specific trash (remove-stop). The Footer is a SIBLING of
`PickerShell.Body`, not inside it.
- **Surface-agnostic**: default `variant` is now `ghost` (transparent, no card) so the
builder adapts to its container; `surface`/`outline` opt into a card. The DEMO must
NOT wrap it in a card (user was explicit).
- **`+` add-stop on the same row as the kind switch** (Lineal/Radial/Cónico).
- **Add-stop placement**: `provider.betweenSelectedAndNext()` (between the selected stop
and the next; if last, penultimate↔last). Rail **click-to-add re-enabled** (inserts at
the cursor) as the primary path; `+` = coordinate-less fallback.
- **StopList** (`gradient-builder-stop-list.svelte` + `-row`) — morfo parts
`stop-list`/`stop-list-row`. Row = a ColorField-value-looking swatch (click → the same
color editor) + position % + delete (except the two endpoint stops). **Rendered in the
DEFAULT editor** (always visible).
- **Presets** (`gradient-builder-presets.svelte`) — `presets` prop (`Gradient[]`; defined
even `[]` shows the panel). Panel BELOW the editor (flex, NOT tabs — tabs are for the
GradientPicker). A preset is a full `Gradient`, so selecting it (`provider.reset`) sets
the kind too. **Save mechanic (ColorPicker saved-swatch style)**: one action button —
"Guardar degradado" (saves current; disabled if already present or at `maxPresets`,
default 10), flips to "Borrar degradado" (threat) when a SAVED preset is selected
(`data-selected`). `savedPresets` = ephemeral `$state` in the root. Morfo parts
`presets`/`preset`.
**Platform reference scan (background workflow, 2026-07-04):** linear/radial/conic IS the
standard complete set (don't add "angular" = conic; diamond = optional radial sub-mode;
mesh = separate-tool, correctly deferred). Add-stop: every tool inserts at the clicked
rail position. Stops-list: legit opt-in (cssgradient.io / Untitled UI). Our
keyboard-accessible role=slider stops + shared OKLCH token model exceed every incumbent.
**PENDING — start here next session:**
1. **GradientPicker** (the popover wrapper — NOT built this session). Design (user's
words): a Popover + PickerShell with **two tabs [Builder | Presets]** + a footer
(Clear/Cancel/Close, already in `gradient-picker-content.svelte`). The Builder tab
uses `<GradientBuilder presets={…} />` **whole** (black box); the Presets tab is the
picker's. The tabs live in the PICKER, not the builder. NOTE: the current
`gradient-picker-content.svelte` re-composes the builder's parts (Preview/Track/…) —
change it to render `<GradientBuilder>` whole.
2. Numeric editable position field on the stop rows.
3. Reverse/flip control (trivial on the provider).
4. READMEs (eidos builder README stale) → then remove gradient-builder / gradient-picker
from `ACTIVE_DEV_TRACK` and make the catalogue guards green.
**Gotchas:** `ColorPicker.ChannelInput` is a render-prop (empty without children).
The stop-color popover inherits the shared `spin-field.css` box — strip it with compound
`[data-…][data-spin-field]` selectors. A `{#snippet}` as a DIRECT child of a component
becomes a snippet PROP — define it at markup top level, render inside. Callback props must
be camelCase (`onApply`, not `onapply`). `variant='ghost'` = surface-agnostic; the demo
must NOT add its own card.
## Redesign pass #3 (2026-06-27 — interaction + scaling feedback)
User feedback (all addressed, verified by EYE in the user's Chrome):
- **Color editing is now ON-CLICK, not always visible**: clicking a stop opens a
color editor panel (Area + hue + alpha + hex, with a header "Parada de color N"
+ × close) for that stop. Removed the always-visible area/sliders.
`provider.colorEditOpen` + `openColorEdit`/`closeColorEdit`; `startStopDrag`
opens it. The StopColor component self-hides via `{#if provider.colorEditOpen}`.
- **Add-stop lands in the WIDEST GAP** (`largestGapPosition()`), never stacking on
0.5 over existing stops. `addStop()` (no arg) = widest gap; `addStop(pos)` =
explicit (track click). Verified: 3 stops [0,.5,1] → new at .75.
- **Delete affordance**: a `RemoveStop` trash Button in the stop-actions row
(removes the selected stop; disabled at 2 stops). Keyboard Delete still works.
- **Kind switch is always-selected + full width**: `deselectable={false}` + `block`
on the ToggleGroup. The current kind is always highlighted.
- **Preview bar taller** (72px md, was 44).
- **Scaling**: dimensions now `calc(px * var(--scaling, 1))` so the editor zooms
with GLOBAL `--scaling`. Verified: global :root --scaling 1.4 → button 36→50,
preview 72→101, gap 16→22.4 (all scale). **CAVEAT — the demo's System
density/scaling axes are applied LOCALLY on the stage (`style="--scaling"` /
`data-density`), which does NOT recompute the root-baked `--space-*` /
`--control-height-*` tokens — so the local sliders don't resize ANY component
(universal harness limitation, not gradient-builder). Confirmed: control-height
reads `calc(36px * 1 * 1)` regardless of local --scaling. Fix would be a shared
harness change (e.g. `zoom` on the stage) — deferred, not unilateral.**
- **Dark mode**: card is now `--color-surface-raised` + `--color-border-default`
so it reads as a distinct panel (was blending into the page surface).
- **Commit-set ring fix** (the "border on slider move"): the `commit` firma
(`commit-settle` keyframe) pulses a primary box-shadow ring on the
`[data-event-family='commit'][data-event-phase='active']` target. The builder's
`commit-set` targets the provider CARD and was firing on EVERY angle/color tick
(`setAngle`/`setStopColor` called `commit()`). Fix: those setters are now
live-update-only; the angle dial commits on `onValueCommit` (release), the color
editor on `onValueChangeEnd` — one ring pulse on release, not per pixel.
## Redesign pass #2 (2026-06-27 — after "no funciona / mal diseñado / no sigue la guía")
User feedback was correct: the first eidos pass shipped an ungrouped pile of
controls with a broken color editor and a demo that ignored the v2 demo guide.
Fixed:
- **StopColor is now INLINE** (`gradient-builder-stop-color.svelte`): the
ColorPicker Area + hue/alpha sliders + hex render directly (no nested popover).
The seed effect initialises `colorValue` from the selected stop UP FRONT (was
showing the black default), tracks only `selectedIndex`. NOTE: the segmented
`ChannelInput` recurses in SSR outside a Popover Content — use `ValueText`
(read-only hex) inline; the segmented editor only works inside the popover.
- **Default full-editor layout**: `<GradientBuilder bind:value />` with no
children renders Preview → Track → (KindSwitch + AddStop) → AngleDial →
StopColor. The Provider is now a **framed card** (bg + border + padding,
size-aware); `variant` (surface/outline/ghost) changes the framing (was inert).
AngleDial gained a label + degree readout. Verified by EYE in light + dark.
- **Both demos rewritten to the v2 guide** (`web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md`,
canary = Button): `data-uix-canvas-inner` + header/meta-pills + always-on stage
with System axes + the 9 tabs (Live · System · Motion · Sema · Services · API ·
Morfo · Recipe · A11y) + the shared harness (SystemAxes / MotionPanel /
SemaPanel / DemoTrace). No PalettePicker — neither component has a `color` prop
(they paint the user's gradient), justified in the demo + README. Now at
`gradient-builder/+page.svelte` and `gradient-picker/+page.svelte` (split).
- **4 READMEs written** (soma + eidos for both). svelte-check clean, contracts
test unchanged (12 pre-existing, none gradient-*).
- Verified in the user's real browser (Chrome MCP — `preview_screenshot` hangs):
builder editor coherent in light+dark; Sema tab renders all 4 firmas; picker
popover opens showing the builder + Borrar/Cancelar footer; selected-stop color
correct (#7c3aed / #ec489a, not black).
## Status (updated 2026-06-27 — GradientBuilder + GradientPicker DONE)
| Layer | GradientBuilder | GradientPicker |
|---|---|---|
| morfo | committed (`c474fc6b`) | **DONE (uncommitted)** `morfo/components/gradient-picker.ts` (`expression:'delegated'`, identity wrapper over Popover) |
| soma | committed (`8ddd5530`) + `value` getter | **DONE (uncommitted)** `soma/components/gradient-picker/` (Provider/Trigger/ValueSwatch + composed PopoverProvider + `pickerShellContext` commit/cancel/clear) |
| eidos | **DONE (uncommitted)** 12 files | **DONE (uncommitted)** `eidos/components/gradient-picker/` (Trigger+chip, Content = Popover+PickerShell+GradientBuilder body) |
| sema | **DONE (uncommitted)** + wired in `/uix` layout | n/a (only `commit-reset`; editing sounds come from the embedded builder) |
| translations | **DONE** `langs/components/gradient-builder.ts` | **DONE** `langs/components/gradient-picker.ts` |
| demo (testbed) | **DONE** `web/routes/uix/components/gradient-builder/+page.svelte` (both shown there) | included in the same testbed |
Both are in `ACTIVE_DEV_TRACK` in `src/uix/contracts.test.ts` (catalogue guards skip them while WIP).
**What remains:**
1. **READMEs** — soma + eidos component READMEs for both (needed before the catalogue guards go green).
2. **Canonical-depth demos** — split into `gradient-builder/` + `gradient-picker/` routes at full DEMO_AUTHORING_GUIDE 6-tab depth (the current single `+page.svelte` is a working testbed).
3. **Remove both from `ACTIVE_DEV_TRACK`** once 1 + 2 land and the soma/eidos guards pass.
### GradientPicker — how it works (the value bridge is the one subtle bit)
- Identity wrapper over a composed Popover (`PopoverProvider` shares the `open` writable). `GradientPickerProvider` exposes `commit()/cancel()/clear()` via `pickerShellContext` so `<PickerShell.Clear/Cancel/Close>` drive it. `valueOnOpen` snapshot (captured on the open edge via `watch`) powers `cancel()`'s revert.
- The eidos **Content** composes `PopoverContent → PickerShell → GradientBuilder body + Footer(Clear/Cancel/Close)`. The GradientBuilder edits a local `draft` ($state); its `onValueCommit` pushes settled values to `provider.setValue`; an `$effect` (`if (v !== draft) draft = v`) flows external resets (cancel/clear) back. Commit-only push + the ref guard = no loop.
- Trigger + ValueSwatch paint `--gp-current-gradient` (stamped inline by soma) — the chip shows the committed gradient.
- Verified in-browser: one click opens → builder renders inside (2 stops) → footer `Borrar/Cancelar/Listo` → AddStop commits live (chip updates) → Cancel reverts chip to the open-edge value + closes. `aria-expanded`/`data-state` correct, no console errors. svelte-check clean, contracts.test unchanged (12 pre-existing fails, none gradient-*).
### What the eidos layer ships (the build composed, never re-implemented)
- `gradient-builder.svelte` (Provider, sets a size/variant visual context + self-imports the child recipes)
- `gradient-builder-preview.svelte` (decorative live bar; checker behind for alpha)
- `gradient-builder-track.svelte` (paints a left→right ramp of the live stops; default-renders one Stop per stop)
- `gradient-builder-stop.svelte` (positions the soma slider-thumb at `left:%`, fills via `--gb-stop-fill`, centers with the `translate` property so a press-squeeze transform doesn't fight it)
- `gradient-builder-stop-color.svelte` (embeds the full eidos **ColorPicker** bound to the selected stop; bridge in `stop-color.ts`)
- `gradient-builder-angle-dial.svelte` (eidos **Slider** 0–360 → `setAngle`; shown for linear/conic only)
- `gradient-builder-kind-switch.svelte` (eidos **ToggleGroup** linear/radial/conic → `setKind`)
- `gradient-builder-add-stop.svelte` (eidos **Button** iconOnly + `Plus` → `addStop(0.5)`)
- `stop-color.ts` (the `StopColor ↔ ColorValue` bridge — css/oklch read, writes back a `css` hex stop)
- `context.svelte.ts`, `types.ts`, `index.ts`, `gradient-builder.css` (self-contained `--_gradient-builder-*` recipe — gradient-builder is on the active dev track, so it deliberately does NOT register tokens in `recipes/base.ts` yet; migrate when the foreign base.ts palette WIP is committed)
### Verification done (2026-06-27)
- `svelte-check`: gradient-builder eidos/soma/sema/langs files all clean (0 errors).
- SSR (`curl` against vite): HTTP 200, every part present, stops positioned + colored, `linear-gradient(90deg in oklch, …)` serialized, `aria-valuetext="Stop n of N, p%"`, translated kind labels (`Lineal/Radial/Cónico`), no `no_context` / SSR errors.
- Computed styles (preview eval): track `position:relative` + live gradient bg; stop `position:absolute left:0 width:16px` round + `#7c3aed` fill + `translate:-50% -50%`; preview h48px.
- Interaction (preview eval): AddStop adds a stop; KindSwitch flips `data-kind` and hides the AngleDial for radial / shows it for linear+conic. No console errors.
- **`preview_screenshot` hangs in this environment** (known headless-browser flakiness, see Gotchas) — verified via SSR DOM + computed styles + eval interaction instead of a literal screenshot.
- Tests: contracts.test.ts (12 pre-existing fails: chronos/metrics/menu-dial/float-panel) + compile.test.ts `hoists role into staticAttrs` (pre-existing, from the committed picker/dialog reconciliation `cae3d2e0`) — **none gradient-builder; zero regressions added**.
## Where it sits — the gradient initiative

@ -0,0 +1,122 @@
# GradientBuilder
Headless interactive editor over the canonical `Gradient` model
(`$libs/gradient`) — the SAME model the theming axis (`buildGradient`) derives,
so a gradient built here is also a themeable token. Soma owns the state machine
(add / move / remove / recolor stops, kind, angle, drag); the eidos layer paints
the surfaces.
The differentiator nobody ships: every color stop is a real `role="slider"` with
`aria-valuetext` and arrow / Home / End / Delete keys — gradient editing that
works from the keyboard and a screen reader. v1 edits linear / radial / conic
(mesh editing is the fast-follow).
## Anatomy
```svelte
<GradientBuilder.Provider bind:value>
<GradientBuilder.Track>
{#each stops as _stop, i (i)}
<GradientBuilder.Stop index={i} />
{/each}
</GradientBuilder.Track>
</GradientBuilder.Provider>
```
The `Provider` exposes its state machine via `GradientBuilderProvider.require()`
so the eidos controls (angle dial, kind switch, add-stop, the embedded
ColorPicker) read / write it without a soma part of their own.
## Parts
| Part | Element | Description |
| ---------- | -------- | --------------------------------------------------------------------- |
| `Provider` | `<div>` | State machine. Owns `value`, `selectedIndex`, `kind`, `angle`, drag. |
| `Track` | `<div>` | Stop rail / drag surface. Click empty rail to add a stop at that x. |
| `Stop` | `<div>` | One color stop — `role="slider"` thumb. Keyboard + pointer drag. |
## Provider API (via `GradientBuilderProvider.require()`)
`stops`, `kind`, `angle`, `selectedIndex`, `activeDragIndex`, `value` ·
`stopPosition(i)`, `selectStop(i)`, `moveStop(i, 0..1)`, `nudgeStop(i, Δ)` ·
`addStop(0..1)`, `removeStop(i)`, `setStopColor(i, StopColor)`,
`setStopAlpha(i, 0..1)` · `setKind('linear'|'radial'|'conic')`, `setAngle(deg)`,
`commit()`, `reset(g)` · `positionFromPointer(clientX)`.
## ARIA
| Part | Attribute | Value |
| ----- | ----------------- | ---------------------------------------------- |
| Track | `role` | `group` |
| Track | `aria-label` | "Gradient stops" (translatable) |
| Stop | `role` | `slider` |
| Stop | `aria-valuemin` | `0` |
| Stop | `aria-valuemax` | `100` |
| Stop | `aria-valuenow` | Stop position as a percentage |
| Stop | `aria-valuetext` | "Stop 2 of 4, 40%" |
| Stop | `aria-label` | "Color stop 2" |
## Data Attributes
| Part | Attribute | Values |
| -------- | -------------------------- | ---------------------------- |
| Provider | `data-gradient-builder` | Always present |
| Provider | `data-kind` | `linear` \| `radial` \| `conic` |
| Provider | `data-disabled` | Present when disabled |
| Track | `data-gradient-builder-track` | Always present |
| Stop | `data-gradient-builder-stop` | Always present |
| Stop | `data-selected` | Present on the active stop |
| Stop | `data-dragging` | Present while dragging |
## Keyboard (Stop)
| Key | Action |
| ------------------------- | ------------------------------- |
| `ArrowLeft` / `ArrowDown` | Move stop −1% (Shift: −10%) |
| `ArrowRight` / `ArrowUp` | Move stop +1% (Shift: +10%) |
| `Home` | Move stop to 0% |
| `End` | Move stop to 100% |
| `Delete` / `Backspace` | Remove the stop (min 2 remain) |
| `Enter` / `Space` | Select the stop |
## Sema events
| Event | Family | Verb | Target | When |
| -------------- | -------- | ------- | ---------- | ------------------------------------------------- |
| `handle-pick` | `handle` | `pick` | `track` | A stop is grabbed (pointerdown) — pickup cue. |
| `handle-drag` | `handle` | `drag` | `track` | While a stop is dragged — haptic stream. |
| `commit-set` | `commit` | `set` | `provider` | Pointer release / keyboard nudge end / add / remove / recolor / kind / angle. |
| `commit-reset` | `commit` | `reset` | `provider` | `reset(g)` replaces the whole gradient. |
The pack lives at `src/uix/sema/components/gradient-builder.ts` (handle pickup /
drag on the track, soft release on commit). Mirrors the `slider` pack.
## Usage
```svelte
<script>
import * as GradientBuilder from '$soma/components/gradient-builder';
import { GradientBuilderProvider } from '$soma/components/gradient-builder';
let value = $state({
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#000000' } },
{ color: { kind: 'css', value: '#ffffff' } }
]
});
</script>
<GradientBuilder.Provider bind:value>
<GradientBuilder.Track>
{#each value.stops as _stop, i (i)}
<GradientBuilder.Stop index={i} />
{/each}
</GradientBuilder.Track>
</GradientBuilder.Provider>
```
The eidos `<GradientBuilder>` (`$uix/eidos/components/gradient-builder`) composes
this with a preview bar, the embedded ColorPicker, a kind switch, an angle dial
and an add-stop button — see its README.

@ -18,7 +18,8 @@ const COARSE_STEP = 0.1;
// ── Root ─────────────────────────────────────────────────────────────────────
interface GradientBuilderOpts
extends WithRefOpts,
extends
WithRefOpts,
StateProps<{ value: Gradient }>,
ActiveProps<{
disabled: boolean;
@ -53,9 +54,19 @@ export class GradientBuilderProvider {
selectedIndex = $state(0);
/** The stop currently being dragged, or null. */
activeDragIndex = $state<number | null>(null);
/** Whether the color editor for the selected stop is open (opened by clicking a stop). */
colorEditOpen = $state(false);
/** The track element (the drag surface) — registered by the Track part. */
trackRef = $state<HTMLElement | null>(null);
openColorEdit(i: number): void {
this.selectStop(i);
this.colorEditOpen = true;
}
closeColorEdit(): void {
this.colorEditOpen = false;
}
get isDisabled(): boolean {
return this.opts.disabled.current;
}
@ -68,6 +79,11 @@ export class GradientBuilderProvider {
readonly kind = $derived.by<GradientKind>(() => this.opts.value.current.kind);
/** The live gradient being edited — read by the eidos preview / track background. */
get value(): Gradient {
return this.opts.value.current;
}
private constructor(opts: GradientBuilderOpts) {
this.opts = opts;
this.soma = Soma.require();
@ -124,10 +140,31 @@ export class GradientBuilderProvider {
this.commit();
}
/** Insert a stop at `position01`, colored from the nearest neighbour. */
addStop(position01: number): void {
/**
* Midpoint between the SELECTED stop and the next one (by position). When the
* selected stop is the last (rightmost), the midpoint of the penultimate and
* the last — so the "+" button always inserts right next to the selection.
*/
private betweenSelectedAndNext(): number {
const sel = this.stopPosition(this.selectedIndex);
const ps = this.stops.map((_, i) => this.stopPosition(i)).sort((a, b) => a - b);
const next = ps.find((p) => p > sel);
if (next !== undefined) return (sel + next) / 2;
// The selection is the last stop — insert between the penultimate and it.
const last = ps[ps.length - 1] ?? 1;
const penult = ps[ps.length - 2] ?? 0;
return (penult + last) / 2;
}
/**
* Insert a stop, colored from the nearest neighbour. With no position, the
* stop lands BETWEEN the selected stop and the next one — or, if the selected
* is the last, between the penultimate and the last. The explicit position is
* used only for click-on-track.
*/
addStop(position01?: number): void {
if (this.isDisabled) return;
const pos = clamp01(position01);
const pos = clamp01(position01 ?? this.betweenSelectedAndNext());
const stops = [...this.stops];
let nearest = 0;
let best = Infinity;
@ -155,20 +192,25 @@ export class GradientBuilderProvider {
this.commit();
}
/**
* Set stop `i`'s color. Live update only — does NOT commit (the inline color
* editor calls this on every change and commits on release via `commit()`).
* Committing per-change would fire the `commit-set` firma — a primary ring
* pulse on the provider card — on every dragged pixel.
*/
setStopColor(i: number, color: StopColor): void {
if (this.isDisabled || i < 0 || i >= this.stops.length) return;
const stops = [...this.stops];
stops[i] = { ...stops[i], color };
this.writeStops(stops);
this.commit();
}
/** Set stop `i`'s alpha (0..1). Live update only — see {@link setStopColor}. */
setStopAlpha(i: number, alpha: number): void {
if (this.isDisabled || i < 0 || i >= this.stops.length) return;
const stops = [...this.stops];
stops[i] = { ...stops[i], alpha: clamp01(alpha) };
this.writeStops(stops);
this.commit();
}
/** Switch geometry, preserving the stops. */
@ -185,14 +227,15 @@ export class GradientBuilderProvider {
this.commit();
}
/** Set the linear angle / conic from-angle (degrees). */
/**
* Set the linear angle / conic from-angle (degrees). Live update only — the
* angle dial commits on release (see {@link setStopColor} for why).
*/
setAngle(deg: number): void {
if (this.isDisabled) return;
const v = this.opts.value.current;
if (v.kind === 'linear') this.opts.value.current = { ...v, angle: wrap360(deg) };
else if (v.kind === 'conic') this.opts.value.current = { ...v, from: wrap360(deg) };
else return;
this.commit();
}
/** The current angle for the dial (linear `angle` / conic `from`, else 0). */
@ -348,19 +391,29 @@ export class GradientStopProvider {
}
};
/** Pointer x at press — distinguishes a click (opens the color picker) from a
* drag (moves the stop). */
private pressX = 0;
private didDrag = false;
readonly onpointerdown = (e: PointerEvent): void => {
const el = e.currentTarget as HTMLElement | null;
el?.setPointerCapture?.(e.pointerId);
this.pressX = e.clientX;
this.didDrag = false;
this.provider.startStopDrag(this.index);
};
readonly onpointermove = (e: PointerEvent): void => {
if (this.provider.activeDragIndex == null) return;
if (Math.abs(e.clientX - this.pressX) > 3) this.didDrag = true;
this.provider.dragStopTo(e.clientX);
};
readonly onpointerup = (e: PointerEvent): void => {
const el = e.currentTarget as HTMLElement | null;
el?.releasePointerCapture?.(e.pointerId);
this.provider.endStopDrag();
// A click (no drag) on the stop opens its color picker; a drag just moved it.
if (!this.didDrag) this.provider.openColorEdit(this.index);
};
}
@ -392,14 +445,18 @@ export class GradientTrackProvider {
});
}
/** Click on empty track → add a stop at that position. */
readonly onpointerdown = (e: PointerEvent): void => {
// Only the bare track adds — clicks on a stop are handled by the Stop.
if (e.target !== e.currentTarget) return;
this.provider.addStop(this.provider.positionFromPointer(e.clientX));
};
get props() {
return { ...this.runtimePart.renderProps(), onpointerdown: this.onpointerdown };
// Click-to-add on the rail — the reference-standard PRIMARY add path: a
// click on the bare track inserts a stop at the pointer position. Clicks
// that land on a Stop are ignored here (the Stop owns select / edit / drag);
// the coordinate-less Add button stays as the fallback.
return { ...this.runtimePart.renderProps(), onclick: this.onclick };
}
private onclick = (e: MouseEvent): void => {
if (this.provider.isDisabled) return;
const target = e.target as Element | null;
if (target?.closest('[data-gradient-builder-stop]')) return;
this.provider.addStop(this.provider.positionFromPointer(e.clientX));
};
}

@ -0,0 +1,85 @@
# GradientPicker
Headless popover-picker shell around the
[`GradientBuilder`](../gradient-builder/README.md). An **identity wrapper over a
composed Popover** (§2 — the Popover is the dialog): this provider owns the
`open` state and the commit / cancel / clear lifecycle, exposed to
`<PickerShell.*>` via `pickerShellContext`. The gradient editing flows through
the embedded GradientBuilder, which binds the SAME `value` — so `cancel()`
reverts to the value the popover opened with.
## Anatomy
```svelte
<GradientPicker.Provider bind:value bind:open>
<GradientPicker.Trigger>
<GradientPicker.ValueSwatch />
</GradientPicker.Trigger>
<!-- eidos composes the Popover content + PickerShell + GradientBuilder body -->
</GradientPicker.Provider>
```
The root wrapper creates a `PopoverProvider` sharing the same `open` writable, so
the Trigger composes `PopoverTriggerProvider` for free.
## Parts
| Part | Element | Description |
| ------------- | ---------- | ---------------------------------------------------------------- |
| `Provider` | `<div>` | Owns `open` + commit/cancel/clear (via `pickerShellContext`). |
| `Trigger` | `<button>` | Opens the popover. `aria-haspopup="dialog"` + `aria-expanded`. |
| `ValueSwatch` | `<div>` | The gradient preview chip (paints `--gp-current-gradient`). |
`Content` / footer (`Clear` / `Cancel` / `Close`) are the composed **Popover** +
shared **PickerShell** parts — re-exported under the eidos namespace.
## Provider API (via `GradientPickerProvider.require()`)
`value` (getter), `setValue(g)`, `css` (derived `gradientToCss`), `isDisabled`,
`triggerNode` · `commit()` (save + close), `cancel()` (revert to open-edge value
+ close), `clear()` (reset to the default gradient). `valueOnOpen` is snapshotted
on the opening edge to power `cancel()`.
## Data Attributes
| Part | Attribute | Values |
| -------- | ----------------------------- | ------------------ |
| Provider | `data-gradient-picker` | Always present |
| Provider | `data-state` | `open` \| `closed` |
| Trigger | `data-gradient-picker-trigger`| Always present |
| Trigger | `data-state` | `open` \| `closed` |
| Content | `data-gradient-picker-content`| Present when open |
## Keyboard
| Key | Action |
| ------------------ | --------------------------------- |
| `Enter` / `Space` | Open the editor (on the trigger) |
| `Escape` | Close the popover |
| `Tab` | Move focus within the editor |
## Sema events
| Event | Family | Verb | Target | When |
| -------------- | -------- | ------- | --------- | ----------------------------- |
| `commit-reset` | `commit` | `reset` | `content` | The Clear action resets it. |
`expression: 'delegated'` — no dialog semantics here (open / close / focus trap
belong to the composed Popover). The editing firma (handle / commit) lives on the
embedded GradientBuilder; the open / close firma lives on the Popover.
## Usage
```svelte
<script>
import { GradientPicker } from '$uix/eidos/components/gradient-picker';
let value = $state({ kind: 'linear', angle: 45, stops: [/* … */] });
</script>
<GradientPicker bind:value mode="inline">
<GradientPicker.Trigger />
<GradientPicker.Portal>
<GradientPicker.Content />
</GradientPicker.Portal>
</GradientPicker>
```

@ -0,0 +1,51 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { PopoverTriggerProvider } from '../../popover/popover-provider.svelte';
import { GradientPickerTriggerProvider } from '../gradient-picker-provider.svelte';
import type { GradientPickerTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'gradient-picker-trigger'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: GradientPickerTriggerProps = $props();
const triggerState = GradientPickerTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel ?? undefined)
});
const popoverTriggerProps = PopoverTriggerProvider.create({
id: readableActive(() => `${id}-popover-trigger`),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
openOnHover: readableActive(() => false),
openDelay: readableActive(() => 0),
closeDelay: readableActive(() => 0)
}).props;
const mergedProps = $derived(
mergeProps(restProps, triggerState.props, popoverTriggerProps as Record<string, unknown>)
);
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { GradientPickerValueSwatchProvider } from '../gradient-picker-provider.svelte';
import type { GradientPickerValueSwatchProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'gradient-picker-value-swatch'),
children,
child,
...restProps
}: GradientPickerValueSwatchProps = $props();
const state = GradientPickerValueSwatchProvider.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}

@ -0,0 +1,67 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { GradientPickerProvider, DEFAULT_PICKER_GRADIENT } from '../gradient-picker-provider.svelte';
import { PopoverProvider } from '../../popover/popover-provider.svelte';
import type { GradientPickerProviderProps } from '../types';
import type { Gradient } from '$libs/gradient';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'gradient-picker'),
value = $bindable<Gradient>(DEFAULT_PICKER_GRADIENT),
onValueCommit = () => {},
open = $bindable(false),
onOpenChange = () => {},
mode = 'inline',
disabled = false,
children,
child,
...restProps
}: GradientPickerProviderProps = $props();
const sharedOpen = writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
);
const picker = GradientPickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
open: sharedOpen,
onValueCommit: readableActive(() => onValueCommit),
mode: readableActive(() => mode),
disabled: readableActive(() => disabled)
});
// Composed Popover — the picker is an identity wrapper over it (§2).
PopoverProvider.create({
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
modal: readableActive(() => mode === 'modal'),
onOpenChangeComplete: readableActive(() => () => {})
});
const mergedProps = $derived(mergeProps(restProps, picker.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,16 @@
export { default as Provider } from './components/gradient-picker.svelte';
export { default as Trigger } from './components/gradient-picker-trigger.svelte';
export { default as ValueSwatch } from './components/gradient-picker-value-swatch.svelte';
export {
GradientPickerProvider,
GradientPickerTriggerProvider,
GradientPickerValueSwatchProvider,
DEFAULT_PICKER_GRADIENT
} from './gradient-picker-provider.svelte';
export type {
GradientPickerProviderProps as ProviderProps,
GradientPickerTriggerProps as TriggerProps,
GradientPickerValueSwatchProps as ValueSwatchProps
} from './types';

@ -0,0 +1,206 @@
import { untrack } from 'svelte';
import { watch } from '$libs/reactive';
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef } from '$adom';
import { type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn } from '../../types';
import { gradientToCss, type Gradient } from '$libs/gradient';
import { pickerShellContext, type PickerShellHandle, type PickerShellMode } from '../picker-shell';
import { gradientPickerMorfo } from '../../../morfo/components/gradient-picker';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
/** The fallback gradient the Clear action restores (matches GradientBuilder's default). */
export const DEFAULT_PICKER_GRADIENT: Gradient = {
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#000000' } },
{ color: { kind: 'css', value: '#ffffff' } }
]
};
// ── Root ─────────────────────────────────────────────────────────────────────
interface GradientPickerOpts
extends ProviderOpts,
StateProps<{ value: Gradient; open: boolean }>,
ActiveProps<{
disabled: boolean;
mode: PickerShellMode;
onValueCommit: OnChangeFn<Gradient>;
}> {}
/**
* Headless GradientPicker — the popover-picker shell around the interactive
* GradientBuilder. Owns the `open` state and the commit / cancel / clear
* lifecycle (exposed to `<PickerShell.*>` via `pickerShellContext`). The
* gradient editing itself flows through the composed GradientBuilder, which
* binds the SAME `value` — so `cancel()` reverts to the value the popover
* opened with.
*/
export class GradientPickerProvider {
readonly opts: GradientPickerOpts;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
static readonly ctx = context<GradientPickerProvider>('GradientPicker');
static get(): GradientPickerProvider | undefined {
return this.ctx.getOr(undefined) as GradientPickerProvider | undefined;
}
static require(): GradientPickerProvider {
return this.ctx.get();
}
static create(opts: GradientPickerOpts) {
return new GradientPickerProvider(opts);
}
triggerNode = $state<HTMLElement | null>(null);
get isDisabled(): boolean {
return this.opts.disabled.current;
}
/** The committed gradient, serialized for the trigger swatch / value chip. */
readonly css = $derived.by<string>(() => gradientToCss(this.opts.value.current));
/** The live gradient — read/written by the composed GradientBuilder body. */
get value(): Gradient {
return this.opts.value.current;
}
setValue(next: Gradient): void {
this.opts.value.current = next;
}
private constructor(opts: GradientPickerOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(gradientPickerMorfo, {
states: { open: () => this.opts.open.current },
props: { disabled: () => this.isDisabled }
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: GradientPickerProvider.ctx,
syncAttrs: true
});
// Snapshot the value on the opening edge so `cancel()` can revert.
watch(
() => this.opts.open.current,
(now, prev) => {
if (now && !prev) this.valueOnOpen = untrack(() => this.opts.value.current);
}
);
// Expose commit / cancel / clear to descendant `<PickerShell.*>` parts.
pickerShellContext.set(this.pickerShellHandle);
}
setTriggerNode(el: HTMLElement | null): void {
this.triggerNode = el;
}
// ── PickerShell lifecycle ────────────────────────────────────────────────
private valueOnOpen: Gradient | undefined = undefined;
/** Save the current gradient and close (drives `<PickerShell.Close>`). */
commit(): void {
this.opts.open.current = false;
this.opts.onValueCommit.current(this.opts.value.current);
}
/** Revert to the value the popover opened with and close (`<PickerShell.Cancel>`). */
cancel(): void {
if (this.valueOnOpen !== undefined) this.opts.value.current = this.valueOnOpen;
this.opts.open.current = false;
}
/** Reset the gradient to the default without closing (`<PickerShell.Clear>`). */
clear(): void {
if (this.isDisabled) return;
this.opts.value.current = DEFAULT_PICKER_GRADIENT;
void this.runtime.trigger('commit-reset');
}
readonly pickerShellHandle: PickerShellHandle = {
getMode: () => this.opts.mode.current,
commit: () => this.commit(),
cancel: () => this.cancel(),
clear: () => this.clear()
};
get props() {
return this.runtimePart.renderProps();
}
}
// ── Trigger ───────────────────────────────────────────────────────────────────
interface GradientPickerTriggerOpts
extends WithRefOpts,
ActiveProps<{ ariaLabel: string | undefined }> {}
export class GradientPickerTriggerProvider {
readonly opts: GradientPickerTriggerOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider = GradientPickerProvider.require();
static create(opts: GradientPickerTriggerOpts) {
return new GradientPickerTriggerProvider(opts);
}
private constructor(opts: GradientPickerTriggerOpts) {
this.opts = opts;
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (v) => this.provider.setTriggerNode(v)
});
}
/** Stamp the live gradient so eidos paints the swatch purely via CSS. */
readonly inlineStyle = $derived.by<string>(() => `--gp-current-gradient: ${this.provider.css}`);
get props() {
const ariaLabel = this.opts.ariaLabel.current;
return {
...this.runtimePart.renderProps(),
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
disabled: this.provider.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
style: this.inlineStyle
};
}
}
// ── ValueSwatch ───────────────────────────────────────────────────────────────
export class GradientPickerValueSwatchProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider = GradientPickerProvider.require();
static create(opts: WithRefOpts) {
return new GradientPickerValueSwatchProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
this.runtimePart = this.provider.runtime.part('value-swatch', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly backgroundStyle = $derived.by<string>(
() => `background: ${this.provider.css}; --gp-current-gradient: ${this.provider.css};`
);
get props() {
return { ...this.runtimePart.renderProps(), style: this.backgroundStyle };
}
}

@ -0,0 +1,37 @@
import type {
WithChild,
Without,
OnChangeFn,
PrimitiveDivAttributes,
PrimitiveButtonAttributes
} from '../../types';
import type { Gradient } from '$libs/gradient';
import type { PickerShellMode } from '../picker-shell';
export type GradientPickerProviderProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** The gradient being edited. Bindable. */
value?: Gradient;
/** Called when the user commits (Save / Done). */
onValueCommit?: OnChangeFn<Gradient>;
/** Popover open state. Bindable. @default false */
open?: boolean;
/** Called when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Footer layout mode — `inline` (clear/cancel) vs `modal` (cancel/save). @default 'inline' */
mode?: PickerShellMode;
/** @default false */
disabled?: boolean;
}> &
Without<PrimitiveDivAttributes, {}>;
export type GradientPickerTriggerProps = WithChild<{
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type GradientPickerValueSwatchProps = WithChild<{
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -0,0 +1,605 @@
<script lang="ts">
/**
* GradientBuilder — interactive editor over the canonical `Gradient` model.
* Follows the v2 demo layout (shared harness): the always-on stage carries
* the System axes; Motion / Sema / Services / API / Morfo / Recipe / A11y
* tabs teach the rest. The component has no `color` prop (it is not a
* palette-tinted surface — it paints the user's gradient), so the Live tab
* exposes `size` / `variant` and the palette picker is intentionally absent.
*/
import {
GradientBuilder,
type GradientBuilderSize,
type GradientBuilderVariant
} from '$uix/eidos/components/gradient-builder';
import { gradientToCss, type Gradient } from '$libs/gradient';
import { compileMorfo } from '$uix/morfo';
import { gradientBuilderMorfo } from '@/uix/morfo/components/gradient-builder';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
type Tab =
| 'live'
| 'system'
| 'motion'
| 'sema'
| 'services'
| 'api'
| 'morfo'
| 'recipe'
| 'a11y';
let tab = $state<Tab>('live');
const sizes: GradientBuilderSize[] = ['sm', 'md', 'lg'];
const variants: GradientBuilderVariant[] = ['surface', 'outline', 'ghost'];
// ── Live (component API) state ──────────────────────────────────────────
let size = $state<GradientBuilderSize>('md');
let variant = $state<GradientBuilderVariant>('ghost');
let disabled = $state(false);
let value = $state<Gradient>({
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#7c3aed' }, position: 0 },
{ color: { kind: 'css', value: '#06b6d4' }, position: 0.5 },
{ color: { kind: 'css', value: '#f59e0b' }, position: 1 }
]
});
const css = $derived(gradientToCss(value));
// Demo presets — passing them turns the editor into two tabs (Editor · Presets).
const presets: Gradient[] = [
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#f43f5e' }, position: 0 },
{ color: { kind: 'css', value: '#f59e0b' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#6366f1' }, position: 0 },
{ color: { kind: 'css', value: '#06b6d4' }, position: 1 }
]
},
{
kind: 'linear',
angle: 135,
stops: [
{ color: { kind: 'css', value: '#8b5cf6' }, position: 0 },
{ color: { kind: 'css', value: '#ec4899' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#22c55e' }, position: 0 },
{ color: { kind: 'css', value: '#eab308' }, position: 0.5 },
{ color: { kind: 'css', value: '#ef4444' }, position: 1 }
]
},
{
kind: 'linear',
angle: 45,
stops: [
{ color: { kind: 'css', value: '#0ea5e9' }, position: 0 },
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#1e293b' }, position: 0 },
{ color: { kind: 'css', value: '#64748b' }, position: 1 }
]
}
];
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
// ── Live trace (data-event) ─────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
// ── Compiled morfo ──────────────────────────────────────────────────────
const compiled = compileMorfo(gradientBuilderMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const actions = $derived([...compiled.actions.byName.values()]);
// ── Snippet (parity with the live preview) ──────────────────────────────
const snippet = $derived(
[
"<script lang='ts'>",
" import { GradientBuilder } from '$uix/eidos/components/gradient-builder';",
' let value = $state({ kind: "linear", angle: 90, stops: [...] });',
'</' + 'script>',
'',
'<GradientBuilder',
' bind:value',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
disabled && ' disabled',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · GradientBuilder</div>
<h1 data-uix-page-title>GradientBuilder</h1>
<p data-uix-page-lede>
Interactive editor over the canonical <code>Gradient</code> model (the same model the theming
axis derives) — so a gradient built here is also a themeable token. Every color stop is a real
<code>role="slider"</code>
with <code>aria-valuetext</code> and arrow / Home / End / Delete keys: gradient editing that works
from the keyboard and a screen reader, which no incumbent ships.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{actions.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>{sizes.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>{variants.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>model</span>$libs/gradient</span>
</div>
</header>
<!-- Live preview always rendered, with the System axes applied to the stage -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{dir}
style={stageStyle}
>
<div style="inline-size: min(22rem, 100%);">
<GradientBuilder bind:value {size} {variant} {disabled} {presets} />
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.entries.length === 0}
<span>drag a stop, add one, or ▶ play in Sema to see events</span>
{:else}
{#each trace.entries.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
{/each}
{/if}
<span style="margin-inline-start: auto;"
><span data-uix-stage-trace-key>kind</span> {value.kind}</span
>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}
>Motion</button
>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span><span data-uix-tab-count>{actions.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}
>Services</button
>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span><span data-uix-tab-count
>{partsList.length}p · {actions.length}e</span
>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
The component API. GradientBuilder has <strong>no <code>color</code> prop</strong> — it
paints the user's gradient, not a palette role — so the palette picker is absent (by
design). Foundation axes live in the
<button data-uix-linklike onclick={() => (tab = 'system')}>System</button> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> size · variant
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>card framing</span></span
>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}
>{v}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch
><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label
>{disabled ? 'on' : 'off'}</span
></span
>
</label>
</div>
<div data-uix-subsection-head>live value · <code>gradientToCss(value)</code></div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">$libs/gradient</span>
<span>the bound gradient, serialized</span>
<span data-uix-code-lang>css</span>
</div>
<pre><code>background: {css};</code></pre>
</div>
<div
style={`block-size: 3rem; border-radius: var(--uix-radius-md, 8px); border: 1px solid var(--color-border-subtle); background: ${css};`}
></div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · size · variant</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
<p data-uix-section-desc>
The foundation knobs that are NOT component props, applied live to the stage above. Change
density / mode / scaling / border and watch the editor card respond. The gradient itself is
the user's data — it doesn't retint with the theme.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion catalog</h2>
<p data-uix-section-desc>
GradientBuilder has no <code>motion</code> prop. Its motion is the direct-manipulation
feedback of the stops (the <code>handle-*</code> firma — see Sema) and the eidos focus ring. The
catalog below teaches the system presets a consuming layout can apply.
</p>
<MotionPanel
note="The stop thumbs are pure direct manipulation: drag = handle-drag haptic stream, release = commit-set. No mount/exit animation of its own."
/>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> firma + sound</h2>
<p data-uix-section-desc>
Four events: grabbing a stop (<code>handle-pick</code>) and dragging it (<code
>handle-drag</code
>) carry the slider's pickup / drag firma; settling (<code>commit-set</code>) and resetting
(<code>commit-reset</code>) are soft commits. Turn on <strong>Semantics → Sound</strong> in the
topbar, then ▶ play.
</p>
<SemaPanel
{actions}
{uix}
getTarget={() =>
stageRef?.querySelector('[data-gradient-builder-track]') ??
stageRef?.querySelector('[data-gradient-builder]') ??
stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
GradientBuilder consumes one app service: <strong>langs</strong> (i18n). Its morfo declares
translatable <code>texts</code> (the track / add-stop / angle / kind labels) that resolve
through <code>uix.langs</code> — switch the language in the topbar to see them follow.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Text key</th><th>Default</th><th>Service</th></tr></thead>
<tbody>
<tr><td class="name">track</td><td class="type">Gradient stops</td><td>langs</td></tr>
<tr><td class="name">add-stop</td><td class="type">Add color stop</td><td>langs</td></tr
>
<tr
><td class="name">angle-dial</td><td class="type">Gradient angle</td><td>langs</td
></tr
>
<tr
><td class="name">kind-switch</td><td class="type">Gradient type</td><td>langs</td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr
><td class="name">value</td><td class="type">Gradient</td><td class="default"
>2-stop linear</td
><td>The gradient being edited. Bindable.</td></tr
>
<tr
><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'sm' | 'md' | 'lg'</td
><td class="default">'md'</td><td>Visual size for the editor + child controls.</td
></tr
>
<tr
><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'surface' | 'outline' | 'ghost'</td
><td class="default">'surface'</td><td>The editor card framing.</td></tr
>
<tr
><td class="name">disabled</td><td class="type">boolean</td><td class="default"
>false</td
><td>Disables all editing.</td></tr
>
<tr
><td class="name">onValueCommit</td><td class="type">(g: Gradient) =&gt; void</td><td
class="default empty">—</td
><td>Fires on every settled change.</td></tr
>
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-3);">
Compound parts — <code
>.Preview · .Track · .Stop · .StopColor · .KindSwitch · .AngleDial · .AddStop</code
> — are rendered by default when no children are passed; compose them explicitly to customise
the layout.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/gradient-builder.ts</code>.
</p>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr
><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th
>Optional</th
></tr
></thead
>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>target</th></tr
></thead
>
<tbody>
{#each actions as action}
{@const sem = action.semantic}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/gradient-builder/gradient-builder.css</code>;
internal tokens <code>--_gradient-builder-*</code> derive from global tokens (self-contained
— gradient-builder is on the active dev track, not yet registered in
<code>recipes/base.ts</code>). The child controls bring their own recipes (Slider /
ToggleGroup / Button / ColorPicker).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr
><td class="name">[data-gradient-builder]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
></tr
>
<tr
><td class="name">[data-gradient-builder][data-size='X']</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
></tr
>
<tr
><td class="name">[data-gradient-builder][data-variant='X']</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
></tr
>
<tr
><td class="name">[data-gradient-builder][data-kind='X']</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
></tr
>
<tr
><td class="name">[data-gradient-builder-track] / -stop</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
></tr
>
<tr
><td class="name">[data-gradient-builder-stop][data-selected] / [data-dragging]</td
><td><span data-uix-tag data-kind="soma">morfo</span></td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The differentiator: every color stop is a real <code>role="slider"</code> — keyboard +
screen-reader gradient editing. The track is a labelled <code>role="group"</code>.
</p>
<div data-uix-subsection-head>Keyboard (Stop)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr
><td><span data-uix-kbd>←</span> / <span data-uix-kbd>↓</span></td><td
>Move stop −1% (Shift −10%)</td
></tr
>
<tr
><td><span data-uix-kbd>→</span> / <span data-uix-kbd>↑</span></td><td
>Move stop +1% (Shift +10%)</td
></tr
>
<tr
><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td
>Move stop to 0% / 100%</td
></tr
>
<tr
><td><span data-uix-kbd>Delete</span> / <span data-uix-kbd>Backspace</span></td><td
>Remove the stop (min 2 remain)</td
></tr
>
<tr
><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td
>Select the stop</td
></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">Track</td><td class="type">role</td><td>group + aria-label</td></tr
>
<tr><td class="name">Stop</td><td class="type">role</td><td>slider</td></tr>
<tr><td class="name">Stop</td><td class="type">aria-valuenow</td><td>position %</td></tr
>
<tr
><td class="name">Stop</td><td class="type">aria-valuetext</td><td
>"Stop 2 of 4, 40%"</td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
[data-uix-linklike] {
border: 0;
background: none;
padding: 0;
color: var(--uix-accent-text);
font: inherit;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
</style>

@ -0,0 +1,410 @@
<script lang="ts">
/**
* GradientPicker — the GradientBuilder wrapped in a Popover (trigger + chip +
* Clear/Cancel/Save footer). Identity wrapper over the composed Popover.
* Follows the v2 demo layout (shared harness).
*/
import {
GradientPicker,
type GradientPickerSize,
type GradientPickerVariant
} from '$uix/eidos/components/gradient-picker';
import { gradientToCss, type Gradient } from '$libs/gradient';
import { compileMorfo } from '$uix/morfo';
import { gradientPickerMorfo } from '@/uix/morfo/components/gradient-picker';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const sizes: GradientPickerSize[] = ['sm', 'md', 'lg'];
const variants: GradientPickerVariant[] = ['surface', 'outline', 'ghost'];
// ── Live (component API) state ──────────────────────────────────────────
let size = $state<GradientPickerSize>('md');
let variant = $state<GradientPickerVariant>('surface');
let mode = $state<'inline' | 'modal'>('inline');
let disabled = $state(false);
let open = $state(false);
let value = $state<Gradient>({
kind: 'linear',
angle: 45,
stops: [
{ color: { kind: 'css', value: '#ec4899' }, position: 0 },
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
]
});
const css = $derived(gradientToCss(value));
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
let scaling = $state(1);
let stageMode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
// ── Live trace (data-event) ─────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
// ── Compiled morfo ──────────────────────────────────────────────────────
const compiled = compileMorfo(gradientPickerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const actions = $derived([...compiled.actions.byName.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { GradientPicker } from '$uix/eidos/components/gradient-picker';",
' let value = $state({ kind: "linear", angle: 45, stops: [...] });',
'</' + 'script>',
'',
'<GradientPicker',
' bind:value',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
mode !== 'inline' && ` mode="${mode}"`,
disabled && ' disabled',
'>',
' <GradientPicker.Trigger />',
' <GradientPicker.Portal>',
' <GradientPicker.Content />',
' </GradientPicker.Portal>',
'</GradientPicker>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · GradientPicker</div>
<h1 data-uix-page-title>GradientPicker</h1>
<p data-uix-page-lede>
The <a href="/uix/components/gradient-builder">GradientBuilder</a> wrapped in a Popover —
a field-shaped trigger carrying a live gradient chip opens the editor, with a
Clear / Cancel / Save footer. An identity wrapper over the composed Popover (the Popover is
the dialog); Cancel reverts to the value the popover opened with.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{actions.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>{sizes.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>{variants.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>model</span>$libs/gradient</span>
</div>
</header>
<!-- Live preview always rendered, with the System axes applied to the stage -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={stageMode === 'inherit' ? undefined : stageMode}
data-mode={stageMode === 'inherit' ? undefined : stageMode}
dir={dir}
style={stageStyle}
>
<GradientPicker bind:value bind:open {size} {variant} {mode} {disabled}>
<GradientPicker.Trigger />
<GradientPicker.Portal>
<GradientPicker.Content />
</GradientPicker.Portal>
</GradientPicker>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.entries.length === 0}
<span>open the picker, edit, then Save / Cancel</span>
{:else}
{#each trace.entries.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>open</span> {open ? 'yes' : 'no'}</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>Motion</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span><span data-uix-tab-count>{actions.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>Services</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span><span data-uix-tab-count>{partsList.length}p · {actions.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
The component API. Like GradientBuilder it has no <code>color</code> prop. Foundation
axes live in the <button data-uix-linklike onclick={() => (tab = 'system')}>System</button> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> size · variant
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>trigger framing</span></span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>mode <span data-uix-control-hint>footer layout</span></span>
<span data-uix-chips role="radiogroup">
{#each ['inline', 'modal'] as m}
<button data-uix-chip data-active={mode === m} onclick={() => (mode = m as typeof mode)}>{m}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>{disabled ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>open</span>
<span data-uix-switch><input type="checkbox" bind:checked={open} /><span data-uix-switch-label>{open ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-subsection-head>committed value · <code>gradientToCss(value)</code></div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">$libs/gradient</span>
<span>the bound gradient, serialized</span>
<span data-uix-code-lang>css</span>
</div>
<pre><code>background: {css};</code></pre>
</div>
<div style={`block-size: 3rem; border-radius: var(--uix-radius-md, 8px); border: 1px solid var(--color-border-subtle); background: ${css};`}></div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · trigger + popover</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
<p data-uix-section-desc>
Foundation knobs applied to the stage. Open the picker and switch mode to preview the
popover in dark.
</p>
<SystemAxes bind:density bind:scaling bind:mode={stageMode} bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion catalog</h2>
<p data-uix-section-desc>
The picker's motion is the composed Popover's enter / exit (the emerge firma) plus the
editor's direct-manipulation feedback. The catalog teaches the system presets.
</p>
<MotionPanel note="The popover open/close is owned by the composed Popover (emerge family). The editor inside is pure direct manipulation." />
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> firma + sound</h2>
<p data-uix-section-desc>
The picker declares one value event — <code>commit-reset</code> (the Clear action). The
editing firma (handle / commit) lives on the embedded GradientBuilder; the open / close
firma lives on the composed Popover. Turn on <strong>Semantics → Sound</strong>, then ▶ play.
</p>
<SemaPanel {actions} {uix} getTarget={() => stageRef?.querySelector('[data-gradient-picker]') ?? stageRef} />
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
GradientPicker consumes <strong>langs</strong> (i18n): the trigger / content labels +
the footer Clear / Cancel / Save (from the shared picker-shell). Switch language in the
topbar to see them follow.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Text key</th><th>Default</th><th>Service</th></tr></thead>
<tbody>
<tr><td class="name">label</td><td class="type">Gradient picker</td><td>langs</td></tr>
<tr><td class="name">trigger</td><td class="type">Open gradient editor</td><td>langs</td></tr>
<tr><td class="name">content</td><td class="type">Gradient editor</td><td>langs</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td class="type">Gradient</td><td class="default">2-stop linear</td><td>The committed gradient. Bindable.</td></tr>
<tr><td class="name">open</td><td class="type">boolean</td><td class="default">false</td><td>Popover open state. Bindable.</td></tr>
<tr><td class="name">mode</td><td class="type">'inline' | 'modal'</td><td class="default">'inline'</td><td>Footer layout (Clear/Cancel vs Cancel/Save).</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Trigger / popover / editor size.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Trigger framing.</td></tr>
<tr><td class="name">onValueCommit</td><td class="type">(g: Gradient) =&gt; void</td><td class="default empty">—</td><td>Fires on Save.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/gradient-picker.ts</code> · <code>expression: 'delegated'</code> (the Popover is the dialog).</p>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>target</th></tr></thead>
<tbody>
{#each actions as action}
{@const sem = action.semantic}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/gradient-picker/gradient-picker.css</code>.
The trigger is a field-shaped pill carrying a gradient chip (<code>--gp-current-gradient</code>,
stamped inline by soma); the popover content + editor reuse the Popover + PickerShell +
GradientBuilder recipes.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-gradient-picker]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-gradient-picker][data-size='X'] / [data-variant='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-gradient-picker-trigger][data-state='open']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">.gradient-picker-trigger-swatch</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-gradient-picker-content]</td><td><span data-uix-tag data-kind="soma">morfo</span> + Popover</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The trigger is <code>aria-haspopup="dialog"</code> + <code>aria-expanded</code>; the
Popover provides the <code>role="dialog"</code>, focus trap and Escape-to-close. Inside,
the GradientBuilder's keyboard-accessible stops apply.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Open the editor (on the trigger)</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>Close the popover</td></tr>
<tr><td><span data-uix-kbd>Tab</span></td><td>Move focus within the editor</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
[data-uix-linklike] {
border: 0;
background: none;
padding: 0;
color: var(--uix-accent-text);
font: inherit;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.