feat(gradient-picker): rebuild on the generic Picker core + presets panel + result-commit sema

Rebuilds the GradientPicker on the generic Picker.Provider<Gradient>
transaction core (open / commit / cancel / clear / workingValue + a composed
Popover), like the natural-time-picker — the bespoke coordinator is now a thin
domain provider (morfo runtime + working-value CSS + the presets state).

- deferValue prop (live vs deferred draft-until-Listo). The trigger chip
  reflects the COMMITTED value, so a deferred picker's chip stays put while you
  edit the working draft.
- Clear restores the INITIAL bound value the picker was given, not a hardcoded
  default gradient.
- No tabs: the editor IS the popover body; the presets grid opens in a panel
  from a Layers button next to "Añadir degradado" (the Add <-> Remove toggle) —
  ColorPicker-style "select a saved preset -> Remove", and it reclaims the
  tab-header height.
- Add / Remove presets fire commit.fulfill / commit.loss firmas (new
  gradient-picker sema pack, registered in the demo). Per the Button doctrine the
  press is a neutral contact.activate and the consumer emits the RESULT commit;
  the events target the REGISTERED `provider` part (targeting the unregistered
  `content` part was also why the Clear commit-reset was silent).
- Stop color editor hides the remove-stop button for the endpoint stops
  (first / last by position — they anchor 0% / 100%).
- maxPresets cap (default 10). StopList in a ScrollArea (autosize, slim
  scrollbar with a gap so the rows never sit under it).

Composes the existing components AS-IS (Popover / Button / ScrollArea / the
GradientBuilder parts) without overriding their styles.

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

@ -12,11 +12,24 @@
import { getGradientBuilderVisualContext } from './context.svelte';
import type { GradientBuilderPresetsProps } from './types';
let { provided, savedPresets, canSave, onSave, onDelete }: GradientBuilderPresetsProps = $props();
let {
provided,
savedPresets,
canSave,
onSave,
onDelete,
value,
size,
onApply
}: GradientBuilderPresetsProps = $props();
const provider = GradientBuilderProvider.require();
// Provider-optional: inside a GradientBuilder it drives apply/value; the
// GradientPicker's Presets tab is OUTSIDE that provider and passes `onApply`
// + `value` + `size` instead.
const provider = GradientBuilderProvider.get();
const ctx = getGradientBuilderVisualContext();
const size = $derived(ctx?.getSize() ?? 'md');
const resolvedSize = $derived(size ?? ctx?.getSize() ?? 'md');
const currentValue = $derived(value ?? provider?.value);
const eidos = ActiveEidos.require();
const label = $derived(eidos.langs.ts('#?components.gradient-builder.presets|Gradient presets'));
const saveLabel = $derived(
@ -32,12 +45,16 @@
const showDelete = $derived(
selectedSaved !== null &&
savedPresets[selectedSaved] !== undefined &&
gradientToCss(savedPresets[selectedSaved]) === gradientToCss(provider.value)
currentValue !== undefined &&
gradientToCss(savedPresets[selectedSaved]) === gradientToCss(currentValue)
);
function apply(g: Gradient, savedIndex: number | null): void {
provider.reset(g);
provider.commit();
if (onApply) onApply(g);
else if (provider) {
provider.reset(g);
provider.commit();
}
selectedSaved = savedIndex;
}
function buttonAction(): void {
@ -75,7 +92,7 @@
<Button
variant="outline"
color={showDelete ? 'threat' : 'neutral'}
{size}
size={resolvedSize}
disabled={!showDelete && !canSave}
onclick={buttonAction}
>

@ -62,7 +62,17 @@
provider.commit();
}
const canRemove = $derived(provider.stops.length > 2);
// The first / last stop by position anchor 0% / 100% — they are NOT removable
// (mirrors the StopList). For those the remove action is hidden entirely.
const orderedByPosition = $derived(
provider.stops
.map((_, i) => i)
.sort((a, b) => provider.stopPosition(a) - provider.stopPosition(b))
);
const isSelectedEndpoint = $derived(
provider.selectedIndex === orderedByPosition[0] ||
provider.selectedIndex === orderedByPosition[orderedByPosition.length - 1]
);
function removeThisStop(): void {
provider.removeStop(provider.selectedIndex);
provider.closeColorEdit();
@ -119,19 +129,19 @@
</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>
{#if !isSelectedEndpoint}
<Button
variant="ghost"
color="threat"
{size}
iconOnly
aria-label={removeLabel}
data-gradient-builder-stop-color-remove
onclick={removeThisStop}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
{/if}
<ColorPicker.Cancel onclick={cancelEdit} />
<ColorPicker.Close>{acceptLabel}</ColorPicker.Close>
</ColorPicker.Footer>

@ -28,7 +28,12 @@ export type GradientBuilderProps = SomaProviderProps & {
maxPresets?: number;
};
/** The presets panel (shown when `presets` is defined). */
/**
* The presets panel (shown when `presets` is defined). Reused by the
* GradientPicker's Presets tab: pass `value` / `onApply` / `size` to drive it
* from the picker's provider; omitted, it falls back to the GradientBuilder
* provider + visual context.
*/
export type GradientBuilderPresetsProps = {
/** Provided presets (select-only). */
provided: Gradient[];
@ -40,6 +45,12 @@ export type GradientBuilderPresetsProps = {
onSave?: () => void;
/** Delete the saved preset at index `i`. */
onDelete?: (i: number) => void;
/** Current gradient for the save↔delete compare. @default the builder provider's value */
value?: Gradient;
/** Visual size. @default the builder visual context (→ 'md') */
size?: GradientBuilderSize;
/** Apply a selected preset. @default the builder provider (`reset` + `commit`) */
onApply?: (g: Gradient) => void;
};
export type GradientBuilderTrackProps = SomaTrackProps;

@ -1,8 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import PopoverContent from '../popover/popover-content.svelte';
import { Popover } from '../popover';
import { PickerShell } from '../picker-shell';
import { ScrollArea } from '../scroll-area';
import { Button } from '../button';
import { Layers } from '../icon';
import { GradientBuilder } from '../gradient-builder';
import Presets from './gradient-picker-presets.svelte';
import SaveAction from './gradient-picker-save-action.svelte';
import { GradientPickerProvider } from '$soma/components/gradient-picker';
import { getGradientPickerVisualContext } from './context.svelte';
import type { GradientPickerContentProps } from './types';
@ -23,14 +30,25 @@
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.
// Two-way bridge: the GradientBuilder edits `draft` live; settled changes push
// to the picker's working value. External transitions (Cancel / Clear via the
// generic PickerProvider, or a preset apply) flow back through the effect.
// `untrack` keeps `draft` out of the dependency set so a live drag isn't
// reverted frame-by-frame (the "stops are locked" bug).
let draft = $state<Gradient>(provider.value);
$effect(() => {
const v = provider.value;
if (v !== draft) draft = v;
untrack(() => {
if (v !== draft) draft = v;
});
});
// `presets` defined (even `[]`) → the add-to-presets action + a Layers button
// that opens the presets grid (no tabs — saves the header height).
const hasPresets = $derived(provider.presets !== undefined);
const presetsLabel = $derived(
eidos.langs.ts('#?components.gradient-picker.presets|Gradient presets')
);
</script>
<PopoverContent
@ -54,13 +72,55 @@
onValueCommit={(g) => provider.setValue(g)}
>
<GradientBuilder.Preview />
<GradientBuilder.Track />
<div class="gradient-picker-controls">
<GradientBuilder.KindSwitch />
<GradientBuilder.StopColor>
<GradientBuilder.Track />
</GradientBuilder.StopColor>
<div data-gradient-builder-stop-actions>
<GradientBuilder.AddStop />
<GradientBuilder.KindSwitch />
</div>
<GradientBuilder.AngleDial />
<GradientBuilder.StopColor />
<!-- Stops list content-sizes up to a max, then scrolls. -->
<ScrollArea scrollbars="vertical" size="xs" autosize data-gradient-picker-stoplist-scroll>
<ScrollArea.Viewport>
<GradientBuilder.StopList />
</ScrollArea.Viewport>
</ScrollArea>
{#if hasPresets}
<!-- Add-to-presets (flips to Remove when a saved preset is selected)
+ a Layers button that opens the presets grid in a panel. -->
<div data-gradient-picker-actions-row>
<SaveAction />
<Popover>
<Popover.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button
{...props}
iconOnly
variant="outline"
color="neutral"
size={resolvedSize}
aria-label={presetsLabel}
data-gradient-picker-presets-toggle
>
{#snippet icon()}<Layers />{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="end"
size={resolvedSize}
data-gradient-picker-presets-panel
>
<Presets />
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
{/if}
</GradientBuilder>
</PickerShell.Body>
<PickerShell.Footer>

@ -0,0 +1,36 @@
<script lang="ts">
// The Presets tab body — a grid of swatches (provided + saved). Clicking a
// provided preset applies it; clicking a saved one applies + selects it (so the
// footer Save action flips to Delete). The Save/Delete button itself lives in
// the footer (<GradientPicker.SaveAction>), mirroring the ColorPicker.
import { ActiveEidos } from '$uix/eidos';
import { GradientPickerProvider } from '$soma/components/gradient-picker';
import { gradientToCss } from '$libs/gradient';
const eidos = ActiveEidos.require();
const provider = GradientPickerProvider.require();
const label = $derived(eidos.langs.ts('#?components.gradient-picker.presets|Gradient presets'));
const provided = $derived(provider.presets ?? []);
</script>
<div data-gradient-picker-presets role="group" aria-label={label}>
{#each provided as preset, i (i)}
<button
type="button"
data-gradient-picker-preset
style="background: {gradientToCss(preset)};"
aria-label="{label} {i + 1}"
onclick={() => provider.applyPreset(preset, null)}
></button>
{/each}
{#each provider.savedPresets as preset, i (i)}
<button
type="button"
data-gradient-picker-preset
data-selected={provider.selectedSaved === i || undefined}
style="background: {gradientToCss(preset)};"
aria-label="{label} {provided.length + i + 1}"
onclick={() => provider.applyPreset(preset, i)}
></button>
{/each}
</div>

@ -0,0 +1,35 @@
<script lang="ts">
// "Añadir degradado" — adds the current gradient to the presets; flips to
// "Quitar degradado" (threat) when a saved preset is selected. A framework
// <Button>. Composable; the default Content places it below the stops list.
import { ActiveEidos } from '$uix/eidos';
import { Button } from '$uix/eidos/components/button';
import { GradientPickerProvider } from '$soma/components/gradient-picker';
import { getGradientPickerVisualContext } from './context.svelte';
const eidos = ActiveEidos.require();
const provider = GradientPickerProvider.require();
const visual = getGradientPickerVisualContext();
const size = $derived(visual?.getSize() ?? 'md');
const addLabel = $derived(eidos.langs.ts('#?components.gradient-picker.add-preset|Add gradient'));
const removeLabel = $derived(
eidos.langs.ts('#?components.gradient-picker.remove-preset|Remove gradient')
);
const showRemove = $derived(provider.showDelete);
function action(): void {
if (showRemove) provider.deletePreset();
else provider.savePreset();
}
</script>
<Button
variant="outline"
color={showRemove ? 'threat' : 'neutral'}
{size}
disabled={!showRemove && !provider.canSave}
onclick={action}
data-gradient-picker-save-action
>
{showRemove ? removeLabel : addLabel}
</Button>

@ -87,7 +87,8 @@
background: var(--gp-current-gradient, transparent);
}
/* ── Content — sized to fit the embedded editor (Area + sliders) ─────────── */
/* ── Content — same width as the ColorPicker (the editor + a three-item footer
* fit; the add-to-presets action lives in the body, not the footer) ────────── */
[data-gradient-picker-content] {
inline-size: 20rem;
}
@ -98,9 +99,73 @@
inline-size: 22rem;
}
.gradient-picker-controls {
/* Let the shell + body shrink to the popover width instead of forcing the
min-content of a wide row (tabs / kind switch / footer) to overflow the panel
(mirrors the ColorPicker). Without this the shell sizes to min-content and the
viewport scrolls / clips on the inline-end. */
[data-popover-content][data-gradient-picker-content] [data-picker-shell],
[data-popover-content][data-gradient-picker-content] [data-picker-body] {
min-inline-size: 0;
}
/* No tabs — the editor is the popover body; presets open in a panel from the
* Layers button (saves the tab-header height). */
/* ── Stops list — content-sizes up to a max, then scrolls (ScrollArea autosize),
* so a long list doesn't grow the popover ──────────────────────────────────── */
[data-gradient-picker-stoplist-scroll] {
--scroll-area-auto-max-block: calc(132px * var(--scaling, 1));
/* A slim scrollbar track (thinner than the sm default), no inner padding. */
--scroll-area-scrollbar-size: var(--space-1-5);
--scroll-area-scrollbar-padding: 0;
}
/* Reserve the scrollbar's width PLUS a gap so the stop rows keep clear of it —
the list content stops short of the overlay track with breathing room. */
[data-gradient-picker-stoplist-scroll] [data-scroll-area-viewport] {
padding-inline-end: var(--space-3);
}
/* ── Presets panel (opens from the Layers button) + its swatch grid ─────────── */
[data-gradient-picker-presets-panel] {
inline-size: 15rem;
max-inline-size: min(15rem, calc(100vw - var(--space-4)));
}
[data-gradient-picker-presets] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: var(--space-2);
align-content: start;
}
[data-gradient-picker-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-picker-preset]:hover {
border-color: var(--color-border-strong);
}
[data-gradient-picker-preset]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
[data-gradient-picker-preset][data-selected] {
border-color: var(--color-primary-border);
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: 2px;
}
/* ── Actions row below the stops — [ Añadir degradado (fills) · Layers ] ────── */
[data-gradient-picker-actions-row] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
gap: var(--space-2);
}
[data-gradient-picker-save-action] {
flex: 1 1 auto;
}
[data-gradient-picker-presets-toggle] {
flex: none;
}

@ -18,6 +18,8 @@
open = $bindable(false),
mode = 'inline',
disabled = false,
presets,
maxPresets = 10,
children,
...rest
}: GradientPickerProps = $props();
@ -39,6 +41,8 @@
bind:open
{mode}
{disabled}
{presets}
{maxPresets}
data-size={resolvedSize}
data-variant={variant}
>

@ -13,6 +13,8 @@ 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 Presets from './gradient-picker-presets.svelte';
import SaveAction from './gradient-picker-save-action.svelte';
import Anchor from '../popover/popover-anchor.svelte';
import Overlay from '../popover/popover-overlay.svelte';
import Arrow from '../popover/popover-arrow.svelte';
@ -31,6 +33,8 @@ type GradientPickerNamespace = typeof GradientPickerComponent & {
Anchor: typeof Anchor;
Overlay: typeof Overlay;
Content: typeof Content;
Presets: typeof Presets;
SaveAction: typeof SaveAction;
Arrow: typeof Arrow;
PopoverClose: typeof PopoverClose;
Footer: typeof Footer;
@ -46,6 +50,8 @@ GradientPicker.Portal = Portal;
GradientPicker.Anchor = Anchor;
GradientPicker.Overlay = Overlay;
GradientPicker.Content = Content;
GradientPicker.Presets = Presets;
GradientPicker.SaveAction = SaveAction;
GradientPicker.Arrow = Arrow;
GradientPicker.PopoverClose = PopoverClose;
GradientPicker.Footer = Footer;

@ -10,6 +10,8 @@ import type { GradientBuilderSize } from '../gradient-builder/types';
export type GradientPickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type GradientPickerVariant = ControlVariant;
// `value` / `open` / `deferValue` / `mode` / `presets` / `maxPresets` come from
// SomaProviderProps; eidos only adds the visual axes.
export type GradientPickerProps = SomaProviderProps & {
/** Visual size for the trigger, popover and editor. @default 'md' */
size?: ResponsiveProp<GradientPickerSize>;

@ -19,5 +19,25 @@ export const gradientPickerLangs = {
content: {
es: 'Editor de degradado',
en: 'Gradient editor'
},
'tab-editor': {
es: 'Editor',
en: 'Editor'
},
'tab-presets': {
es: 'Predefinidos',
en: 'Presets'
},
presets: {
es: 'Degradados predefinidos',
en: 'Gradient presets'
},
'add-preset': {
es: 'Añadir degradado',
en: 'Add gradient'
},
'remove-preset': {
es: 'Quitar degradado',
en: 'Remove gradient'
}
} satisfies LangNode;

@ -30,10 +30,34 @@ export const gradientPickerMorfo = {
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('content'),
target: v.partRef('provider'),
sequence: 'post',
intent: 'neutral'
}
},
{
// User saved the current gradient to the presets (the Add action).
// Per the Button doctrine, the consumer emits the RESULT commit —
// the press's `contact.activate` only marks the gesture.
name: 'commit-save',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('provider'),
sequence: 'post',
intent: 'fulfill'
}
},
{
// User removed a saved preset (the Remove action).
name: 'commit-remove',
semantic: {
family: 'commit',
verb: 'remove',
target: v.partRef('provider'),
sequence: 'post',
intent: 'loss'
}
}
],

@ -0,0 +1,47 @@
import { semaSelector } from '$uix/morfo';
import { gradientPickerMorfo } from '$uix/morfo/components/gradient-picker';
import { sound } from '../sounds';
import type { Sema } from '../sema-map';
/**
* GradientPicker perceptual defaults.
*
* The editing firma (handle / commit while dragging stops, recoloring, …)
* lives on the embedded GradientBuilder. The picker owns only its own value
* commits — the RESULT commits the Button doctrine leaves to the consumer
* (the press itself is a neutral `contact.activate`):
*
* • commit-save — a gradient saved to the presets: a fulfilling chime.
* • commit-remove — a saved preset removed: a soft descending "gone" cue.
* • commit-reset — cleared back to the initial value: a neutral reset.
*
* The `fulfill` / `loss` / `neutral` intents further shape pitch / gain /
* contour via the cascade's intent deltas — the base sound just names the body.
*/
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof gradientPickerMorfo>>[2]) =>
semaSelector(gradientPickerMorfo, 'provider', matchers);
export const gradientPickerSema: Sema = {
name: 'gradient-picker',
cascade: [
{
selector: onProvider({ eventName: 'commit-save' }),
channels: ['sound', 'haptic'],
sound: sound('handle.snap.chime', { gain: 0.1 }),
haptic: { kind: 'tick', intensity: 0.2, duration: 8 }
},
{
selector: onProvider({ eventName: 'commit-remove' }),
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,7 +1,18 @@
<script lang="ts">
/**
* Soma `GradientPicker.Provider` — composition root. Creates the generic
* `Picker` transaction core (deferValue / commit / cancel / clear + the
* `pickerShellContext` footer handle), the domain coordinator
* (`GradientPickerProvider`) editing the working value, and a composed
* `Popover`, all bound to one shared gradient. The visual editor lives in
* eidos and binds the working value; the footer PickerShell parts drive the
* generic picker.
*/
import { untrack } from 'svelte';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { PickerProvider } from '../../picker/picker-provider.svelte';
import { GradientPickerProvider, DEFAULT_PICKER_GRADIENT } from '../gradient-picker-provider.svelte';
import { PopoverProvider } from '../../popover/popover-provider.svelte';
import type { GradientPickerProviderProps } from '../types';
@ -13,16 +24,31 @@
ref = $bindable(null),
id = createId(uid, 'gradient-picker'),
value = $bindable<Gradient>(DEFAULT_PICKER_GRADIENT),
onValueCommit = () => {},
onValueChange = () => {},
open = $bindable(false),
onOpenChange = () => {},
onOpenChangeComplete = () => {},
deferValue = false,
mode = 'inline',
disabled = false,
presets,
maxPresets = 10,
children,
child,
...restProps
}: GradientPickerProviderProps = $props();
// The value the picker was initialised with — Clear resets to THIS (the value
// the consumer passed), not a hardcoded default. Captured once at mount.
const initialValue = untrack(() => value);
const sharedValue = writableActive<Gradient>(
() => value,
(v) => {
value = v;
onValueChange(v);
}
);
const sharedOpen = writableActive(
() => open,
(v) => {
@ -31,31 +57,51 @@
}
);
const picker = GradientPickerProvider.create({
// ── Transaction core — no DOM ref; context + workingValue + footer handle ──
// `deferValue` (default false = live) mirrors the ColorPicker when off: the
// chip updates as you edit and Cancel reverts to the value the popover opened
// with. On → edits buffer in a draft until Listo; any other close discards.
const picker = PickerProvider.create<Gradient>({
id: readableActive(() => `${id}-picker`),
value: sharedValue,
open: sharedOpen,
deferValue: readableActive(() => deferValue),
mode: readableActive(() => mode),
// Clear restores the initial value the picker was given.
emptyValue: readableActive(() => initialValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => false),
required: readableActive(() => false),
validate: readableActive(() => undefined),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});
// ── Domain coordinator over the working (draft-or-bound) value ─────────────
const state = GradientPickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
value: picker.workingValue,
open: sharedOpen,
onValueCommit: readableActive(() => onValueCommit),
mode: readableActive(() => mode),
disabled: readableActive(() => disabled)
// The trigger chip reflects the committed (bound) value, so a deferred
// picker's chip doesn't move while editing the working draft.
committedValue: sharedValue,
disabled: readableActive(() => disabled),
presets: readableActive(() => presets),
maxPresets: readableActive(() => maxPresets)
});
// Composed Popover — the picker is an identity wrapper over it (§2).
// ── Composed Popover wired to the shared open state ───────────────────────
PopoverProvider.create({
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
modal: readableActive(() => mode === 'modal'),
onOpenChangeComplete: readableActive(() => () => {})
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => mode === 'modal')
});
const mergedProps = $derived(mergeProps(restProps, picker.props));
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}

@ -1,11 +1,7 @@
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';
@ -25,20 +21,28 @@ export const DEFAULT_PICKER_GRADIENT: Gradient = {
interface GradientPickerOpts
extends ProviderOpts,
StateProps<{ value: Gradient; open: boolean }>,
StateProps<{
/** The WORKING gradient (`picker.workingValue`) — what the editor edits. */
value: Gradient;
open: boolean;
/** The COMMITTED (bound) gradient — what the trigger chip reflects. */
committedValue: Gradient;
}>,
ActiveProps<{
disabled: boolean;
mode: PickerShellMode;
onValueCommit: OnChangeFn<Gradient>;
presets: Gradient[] | undefined;
maxPresets: number;
}> {}
/**
* 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.
* Domain coordinator for GradientPicker. The transaction — open / commit /
* cancel / clear + `deferValue` — lives in the composed generic
* `PickerProvider` (see `components/gradient-picker.svelte`); this class only
* owns the morfo runtime (provider / trigger / value-swatch parts) and the
* working gradient it is handed (bound to `picker.workingValue`), exposing its
* serialized CSS for the trigger swatch + value chip. The embedded
* GradientBuilder edits that working value; the footer `<PickerShell.*>` parts
* drive the generic picker via `pickerShellContext`.
*/
export class GradientPickerProvider {
readonly opts: GradientPickerOpts;
@ -62,10 +66,12 @@ export class GradientPickerProvider {
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 COMMITTED gradient, serialized for the trigger swatch / value chip — so
* a deferred picker's chip stays put until Accept (the editor edits the
* working `value`; in live mode committed === working). */
readonly css = $derived.by<string>(() => gradientToCss(this.opts.committedValue.current));
/** The live gradient — read/written by the composed GradientBuilder body. */
/** The working gradient — read/written by the composed GradientBuilder body. */
get value(): Gradient {
return this.opts.value.current;
}
@ -73,6 +79,67 @@ export class GradientPickerProvider {
this.opts.value.current = next;
}
// ── Presets + saved gradients ─────────────────────────────────────────────
// Mirrors ColorPickerProvider: `presets` is a read-only palette; `savedPresets`
// is the editable list the footer Save/Delete action mutates. A saved preset is
// "selected" (→ Delete) only when the user CLICKS it, not merely when it matches
// the value. State lives here (soma) so it reads the WORKING value — correct in
// both live and deferred modes.
/** The provided preset palette (undefined → no Presets tab). */
readonly presets = $derived.by<Gradient[] | undefined>(() => this.opts.presets.current);
/** User-saved gradients (footer Save appends; Delete removes). */
savedPresets = $state<Gradient[]>([]);
/** The saved preset the user explicitly selected (drives Save↔Delete). */
selectedSaved = $state<number | null>(null);
private get maxPresets(): number {
return this.opts.maxPresets.current;
}
/** Current gradient can be saved: under cap, not already present. */
readonly canSave = $derived.by<boolean>(() => {
if (this.savedPresets.length >= this.maxPresets) return false;
const k = gradientToCss(this.value);
return ![...(this.presets ?? []), ...this.savedPresets].some((g) => gradientToCss(g) === k);
});
/** Show "delete" instead of "save": a saved preset is selected AND is the value. */
readonly showDelete = $derived.by<boolean>(() => {
const i = this.selectedSaved;
return (
i !== null &&
this.savedPresets[i] !== undefined &&
gradientToCss(this.savedPresets[i]) === gradientToCss(this.value)
);
});
/** Apply a preset to the working value; mark it selected when it's a saved one. */
applyPreset(g: Gradient, savedIndex: number | null): void {
this.setValue(g);
this.selectedSaved = savedIndex;
}
/** Mark a saved preset selected (called by the presets grid). */
selectSaved(i: number): void {
this.selectedSaved = i;
}
/** Save the current working gradient to the saved list. */
savePreset(): void {
if (!this.canSave) return;
this.savedPresets = [...this.savedPresets, this.value];
// The result-commit firma (a "saved" cue) — the Button press only emits
// the neutral `contact.activate`; the evaluative commit is ours to fire.
void this.runtime.trigger('commit-save');
}
/** Delete a saved preset (defaults to the selected one). */
deletePreset(i?: number): void {
const target = i ?? this.selectedSaved;
if (target === null || target === undefined) return;
this.savedPresets = this.savedPresets.filter((_, idx) => idx !== target);
this.selectedSaved = null;
void this.runtime.trigger('commit-remove');
}
private constructor(opts: GradientPickerOpts) {
this.opts = opts;
this.soma = Soma.require();
@ -87,52 +154,12 @@ export class GradientPickerProvider {
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();
}

@ -13,16 +13,29 @@ export type GradientPickerProviderProps = WithChild<{
id?: string;
/** The gradient being edited. Bindable. */
value?: Gradient;
/** Called when the user commits (Save / Done). */
onValueCommit?: OnChangeFn<Gradient>;
/** Called when the value changes (live — `deferValue` is off). */
onValueChange?: OnChangeFn<Gradient>;
/** Popover open state. Bindable. @default false */
open?: boolean;
/** Called when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Called after the open/close animation completes. */
onOpenChangeComplete?: OnChangeFn<boolean>;
/**
* Defer edits to an internal draft until the user accepts (Listo). When off
* (default) edits are live and Cancel reverts to the value the popover opened
* with; when on, the bound value only updates on accept and any other close
* discards the draft. @default false
*/
deferValue?: boolean;
/** Footer layout mode — `inline` (clear/cancel) vs `modal` (cancel/save). @default 'inline' */
mode?: PickerShellMode;
/** @default false */
disabled?: boolean;
/** Preset gradients. Defined (even `[]`) → the picker exposes a Presets tab + footer Save action. */
presets?: Gradient[];
/** Max number of user-saved presets. @default 10 */
maxPresets?: number;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -13,6 +13,8 @@
import { fileUploadSema } from '$uix/sema/components/file-upload';
import { floatPanelSema } from '$uix/sema/components/float-panel';
import { formSema } from '$uix/sema/components/form';
import { gradientBuilderSema } from '$uix/sema/components/gradient-builder';
import { gradientPickerSema } from '$uix/sema/components/gradient-picker';
import { imageAdjustmentsSema } from '$uix/sema/components/image-adjustments';
import { imagePickerSema } from '$uix/sema/components/image-picker';
import { knobSema } from '$uix/sema/components/knob';
@ -85,6 +87,8 @@
fileUploadSema,
floatPanelSema,
formSema,
gradientBuilderSema,
gradientPickerSema,
imageAdjustmentsSema,
imagePickerSema,
knobSema,

@ -31,18 +31,77 @@
let size = $state<GradientPickerSize>('md');
let variant = $state<GradientPickerVariant>('surface');
let mode = $state<'inline' | 'modal'>('inline');
let deferValue = $state(false);
let maxPresets = $state(10);
let disabled = $state(false);
let open = $state(false);
// A distinct 3-stop gradient (not one of the presets) so the "Añadir degradado"
// action starts enabled — you can save it, then it disables (already a preset).
let value = $state<Gradient>({
kind: 'linear',
angle: 45,
angle: 120,
stops: [
{ color: { kind: 'css', value: '#ec4899' }, position: 0 },
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
{ color: { kind: 'css', value: '#f97316' }, position: 0 },
{ color: { kind: 'css', value: '#db2777' }, position: 0.5 },
{ color: { kind: 'css', value: '#7c3aed' }, position: 1 }
]
});
const css = $derived(gradientToCss(value));
// Demo presets — passing them splits the popover into two tabs (Editor ·
// Predefinidos), with the save-to-presets action (save↔delete, capped at
// maxPresets), mirroring the ColorPicker's saved swatches.
const presets: Gradient[] = [
{
kind: 'linear',
angle: 45,
stops: [
{ color: { kind: 'css', value: '#ec4899' }, position: 0 },
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#f59e0b' }, position: 0 },
{ color: { kind: 'css', value: '#ef4444' }, position: 1 }
]
},
{
kind: 'linear',
angle: 135,
stops: [
{ color: { kind: 'css', value: '#06b6d4' }, position: 0 },
{ color: { kind: 'css', value: '#3b82f6' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#10b981' }, position: 0 },
{ color: { kind: 'css', value: '#84cc16' }, position: 1 }
]
},
{
kind: 'linear',
angle: 180,
stops: [
{ color: { kind: 'css', value: '#6366f1' }, position: 0 },
{ color: { kind: 'css', value: '#ec4899' }, position: 1 }
]
},
{
kind: 'linear',
angle: 90,
stops: [
{ color: { kind: 'css', value: '#14b8a6' }, position: 0 },
{ color: { kind: 'css', value: '#8b5cf6' }, position: 1 }
]
}
];
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
let scaling = $state(1);
@ -118,7 +177,7 @@
dir={dir}
style={stageStyle}
>
<GradientPicker bind:value bind:open {size} {variant} {mode} {disabled}>
<GradientPicker bind:value bind:open {size} {variant} {mode} {deferValue} {disabled} {presets} {maxPresets}>
<GradientPicker.Trigger />
<GradientPicker.Portal>
<GradientPicker.Content />
@ -203,6 +262,18 @@
<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>deferValue <span data-uix-control-hint>draft until Listo</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={deferValue} /><span data-uix-switch-label>{deferValue ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>maxPresets <span data-uix-control-hint>saved cap</span></span>
<span data-uix-chips role="radiogroup">
{#each [1, 5, 10] as n}
<button data-uix-chip data-active={maxPresets === n} onclick={() => (maxPresets = n)}>{n}</button>
{/each}
</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>
@ -296,10 +367,13 @@
<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">deferValue</td><td class="type">boolean</td><td class="default">false</td><td>Buffer edits in a draft until Listo; other closes discard.</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>
<tr><td class="name">onValueChange</td><td class="type">(g: Gradient) =&gt; void</td><td class="default empty">—</td><td>Fires on every edit (live; deferValue off).</td></tr>
<tr><td class="name">presets <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Gradient[]</td><td class="default empty">—</td><td>Defined → the popover splits into Editor + Predefinidos tabs.</td></tr>
<tr><td class="name">maxPresets</td><td class="type">number</td><td class="default">10</td><td>Cap on user-saved presets — "Añadir degradado" disables at the cap.</td></tr>
</tbody>
</table>
</div>
</section>

Loading…
Cancel
Save

Powered by TurnKey Linux.