diff --git a/src/uix/contracts.test.ts b/src/uix/contracts.test.ts index c211bb0dc..efdd4b20f 100644 --- a/src/uix/contracts.test.ts +++ b/src/uix/contracts.test.ts @@ -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 diff --git a/src/uix/eidos/components/gradient-builder/README.md b/src/uix/eidos/components/gradient-builder/README.md new file mode 100644 index 000000000..fe65bb5d0 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/README.md @@ -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 + +``` + +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 + + + + + + + + +``` + +## 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`. diff --git a/src/uix/eidos/components/gradient-builder/context.svelte.ts b/src/uix/eidos/components/gradient-builder/context.svelte.ts new file mode 100644 index 000000000..09026490c --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/context.svelte.ts @@ -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(GRADIENT_BUILDER_VISUAL_CONTEXT); +} diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-add-stop.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-add-stop.svelte new file mode 100644 index 000000000..7af77c639 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-add-stop.svelte @@ -0,0 +1,32 @@ + + + diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-angle-dial.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-angle-dial.svelte new file mode 100644 index 000000000..8fbf98f28 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-angle-dial.svelte @@ -0,0 +1,41 @@ + + +{#if showDial} +
+
+ {label} + {Math.round(provider.angle)}° +
+ provider.setAngle(v[0] ?? 0)} + onValueCommit={() => provider.commit()} + aria-label={label} + > + + + +
+{/if} diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-kind-switch.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-kind-switch.svelte new file mode 100644 index 000000000..b90732227 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-kind-switch.svelte @@ -0,0 +1,43 @@ + + +
+ { + const next = v[0]; + if (next === 'linear' || next === 'radial' || next === 'conic') provider.setKind(next); + }} + aria-label={groupLabel} + > + {#each kinds as kind (kind.value)} + {kind.label} + {/each} + +
diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-presets.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-presets.svelte new file mode 100644 index 000000000..6d8a9b28e --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-presets.svelte @@ -0,0 +1,85 @@ + + +
+
+ {#each provided as preset, i (i)} + + {/each} + {#each savedPresets as preset, i (i)} + + {/each} +
+ +
diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-preview.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-preview.svelte new file mode 100644 index 000000000..5171851d2 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-preview.svelte @@ -0,0 +1,19 @@ + + + + diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-remove-stop.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-remove-stop.svelte new file mode 100644 index 000000000..47c2ad0d5 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-remove-stop.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-stop-color.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-color.svelte new file mode 100644 index 000000000..704fb59ad --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-color.svelte @@ -0,0 +1,140 @@ + + + + + + {@render children?.()} + + + + + +
+ {eidos.langs.ts('#?components.gradient-builder.stop-label|Color stop')} + {provider.selectedIndex + 1} +
+ +
+ + +
+ + + {#snippet children({ segments })} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + {/snippet} + + +
+ + + + {acceptLabel} + +
+
+
diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list-row.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list-row.svelte new file mode 100644 index 000000000..9a1c6d43a --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list-row.svelte @@ -0,0 +1,59 @@ + + +
+ + {#if !isEndpoint} + + {/if} +
diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list.svelte new file mode 100644 index 000000000..0ed368ed2 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-stop-list.svelte @@ -0,0 +1,38 @@ + + +
+ {#if children} + {@render children()} + {:else} + {#each ordered as i, pos (i)} + + {/each} + {/if} +
diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-stop.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-stop.svelte new file mode 100644 index 000000000..cc1f3f1c4 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-stop.svelte @@ -0,0 +1,19 @@ + + + + diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder-track.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder-track.svelte new file mode 100644 index 000000000..4f6ed5080 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder-track.svelte @@ -0,0 +1,27 @@ + + + + {#if children} + {@render children()} + {:else} + {#each provider.stops as _stop, i (i)} + + {/each} + {/if} + diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder.css b/src/uix/eidos/components/gradient-builder/gradient-builder.css new file mode 100644 index 000000000..c553294f5 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder.css @@ -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 ) ─────────────────────────── */ +[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; +} diff --git a/src/uix/eidos/components/gradient-builder/gradient-builder.svelte b/src/uix/eidos/components/gradient-builder/gradient-builder.svelte new file mode 100644 index 000000000..11d4b7fdb --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/gradient-builder.svelte @@ -0,0 +1,92 @@ + + + + {#if children} + {@render children()} + {:else} + + + + + +
+ + +
+ + + {#if presets !== undefined} + + {/if} + {/if} +
diff --git a/src/uix/eidos/components/gradient-builder/index.ts b/src/uix/eidos/components/gradient-builder/index.ts new file mode 100644 index 000000000..58d09a35e --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/index.ts @@ -0,0 +1,68 @@ +// GradientBuilder — eidos compound API. +// +// import { GradientBuilder } from '$uix/eidos/components/gradient-builder'; +// +// +// +// +// +// +// +// +// +// +// 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'; diff --git a/src/uix/eidos/components/gradient-builder/stop-color.ts b/src/uix/eidos/components/gradient-builder/stop-color.ts new file mode 100644 index 000000000..efa5b819e --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/stop-color.ts @@ -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 }; +} diff --git a/src/uix/eidos/components/gradient-builder/types.ts b/src/uix/eidos/components/gradient-builder/types.ts new file mode 100644 index 000000000..bcae96911 --- /dev/null +++ b/src/uix/eidos/components/gradient-builder/types.ts @@ -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; +export type GradientBuilderVariant = ControlVariant; + +export type GradientBuilderProps = SomaProviderProps & { + /** Visual size for the editor and its child controls. @default 'md' */ + size?: ResponsiveProp; + /** + * 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; + +/** 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; + +/** 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; +}; diff --git a/src/uix/eidos/components/gradient-picker/README.md b/src/uix/eidos/components/gradient-picker/README.md new file mode 100644 index 000000000..5c5ffa167 --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/README.md @@ -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 + + + + + + +``` + +`` 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 ``: 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`. diff --git a/src/uix/eidos/components/gradient-picker/context.svelte.ts b/src/uix/eidos/components/gradient-picker/context.svelte.ts new file mode 100644 index 000000000..ed575d459 --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/context.svelte.ts @@ -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(GRADIENT_PICKER_VISUAL_CONTEXT); +} diff --git a/src/uix/eidos/components/gradient-picker/gradient-picker-content.svelte b/src/uix/eidos/components/gradient-picker/gradient-picker-content.svelte new file mode 100644 index 000000000..fc05822bc --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/gradient-picker-content.svelte @@ -0,0 +1,74 @@ + + + + {#snippet children(snippetProps)} + + {#if bodyContent} + {@render bodyContent(snippetProps)} + {:else} + + provider.setValue(g)} + > + + +
+ + +
+ + +
+
+ + + + + + {/if} +
+ {/snippet} +
diff --git a/src/uix/eidos/components/gradient-picker/gradient-picker-trigger.svelte b/src/uix/eidos/components/gradient-picker/gradient-picker-trigger.svelte new file mode 100644 index 000000000..c440f4bdc --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/gradient-picker-trigger.svelte @@ -0,0 +1,43 @@ + + + + {#if showSwatch} + + {/if} + {#if children} + {@render children()} + {:else} + {label} + {/if} + diff --git a/src/uix/eidos/components/gradient-picker/gradient-picker-value-swatch.svelte b/src/uix/eidos/components/gradient-picker/gradient-picker-value-swatch.svelte new file mode 100644 index 000000000..6b35e2cfe --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/gradient-picker-value-swatch.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/gradient-picker/gradient-picker.css b/src/uix/eidos/components/gradient-picker/gradient-picker.css new file mode 100644 index 000000000..da49cb63d --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/gradient-picker.css @@ -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); +} diff --git a/src/uix/eidos/components/gradient-picker/gradient-picker.svelte b/src/uix/eidos/components/gradient-picker/gradient-picker.svelte new file mode 100644 index 000000000..bd30c2d92 --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/gradient-picker.svelte @@ -0,0 +1,46 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/gradient-picker/index.ts b/src/uix/eidos/components/gradient-picker/index.ts new file mode 100644 index 000000000..3731c5bb8 --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/index.ts @@ -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'; +// +// +// +// +// +// +// +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'; diff --git a/src/uix/eidos/components/gradient-picker/types.ts b/src/uix/eidos/components/gradient-picker/types.ts new file mode 100644 index 000000000..edd88a8f9 --- /dev/null +++ b/src/uix/eidos/components/gradient-picker/types.ts @@ -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; +export type GradientPickerVariant = ControlVariant; + +export type GradientPickerProps = SomaProviderProps & { + /** Visual size for the trigger, popover and editor. @default 'md' */ + size?: ResponsiveProp; + /** Visual treatment of the trigger. @default 'surface' */ + variant?: GradientPickerVariant; +}; + +export type GradientPickerTriggerProps = SomaTriggerProps & { + size?: ResponsiveProp; + variant?: GradientPickerVariant; + /** When true (default) the trigger shows the gradient preview chip. */ + showSwatch?: boolean; +}; + +export type GradientPickerValueSwatchProps = SomaValueSwatchProps; + +export type GradientPickerContentProps = Omit & { + size?: ResponsiveProp; +}; + +/** The builder size matched to a picker size (sm/md/lg → sm/md/lg). */ +export type GradientPickerBuilderSize = GradientBuilderSize; diff --git a/src/uix/langs/components/gradient-builder.ts b/src/uix/langs/components/gradient-builder.ts index dda5db201..2f2daf47e 100644 --- a/src/uix/langs/components/gradient-builder.ts +++ b/src/uix/langs/components/gradient-builder.ts @@ -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', diff --git a/src/uix/langs/components/gradient-picker.ts b/src/uix/langs/components/gradient-picker.ts new file mode 100644 index 000000000..a3f3545c8 --- /dev/null +++ b/src/uix/langs/components/gradient-picker.ts @@ -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; diff --git a/src/uix/morfo/components/gradient-builder.ts b/src/uix/morfo/components/gradient-builder.ts index 277cd8b53..759f68070 100644 --- a/src/uix/morfo/components/gradient-builder.ts +++ b/src/uix/morfo/components/gradient-builder.ts @@ -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; diff --git a/src/uix/morfo/components/gradient-picker.ts b/src/uix/morfo/components/gradient-picker.ts new file mode 100644 index 000000000..ee1927a6c --- /dev/null +++ b/src/uix/morfo/components/gradient-picker.ts @@ -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; diff --git a/src/uix/sema/components/gradient-builder.ts b/src/uix/sema/components/gradient-builder.ts new file mode 100644 index 000000000..9c5255aa4 --- /dev/null +++ b/src/uix/sema/components/gradient-builder.ts @@ -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>[2]) => + semaSelector(gradientBuilderMorfo, 'track', matchers); + +const onProvider = (matchers?: Parameters>[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 } + } + ] +}; diff --git a/src/uix/soma/components/gradient-builder/CONTINUE.md b/src/uix/soma/components/gradient-builder/CONTINUE.md index a6af6d12d..8a04c8a31 100644 --- a/src/uix/soma/components/gradient-builder/CONTINUE.md +++ b/src/uix/soma/components/gradient-builder/CONTINUE.md @@ -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 `…segments…` + (swatch + the framework's styled Select), NOT the old `ChannelInput` snippet that + rendered a native `{disabled ? 'on' : 'off'} + + + +
live value · gradientToCss(value)
+
+
+ $libs/gradient + the bound gradient, serialized + css +
+
background: {css};
+
+
+ +
+
+ eidos + visual · size · variant + svelte +
+
{snippet}
+
+ + {/if} + + {#if tab === 'system'} +
+

eidos System axes

+

+ 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. +

+ +
+ {/if} + + {#if tab === 'motion'} +
+

Motion catalog

+

+ GradientBuilder has no motion prop. Its motion is the direct-manipulation + feedback of the stops (the handle-* firma — see Sema) and the eidos focus ring. The + catalog below teaches the system presets a consuming layout can apply. +

+ +
+ {/if} + + {#if tab === 'sema'} +
+

sema firma + sound

+

+ Four events: grabbing a stop (handle-pick) and dragging it (handle-drag) carry the slider's pickup / drag firma; settling (commit-set) and resetting + (commit-reset) are soft commits. Turn on Semantics → Sound in the + topbar, then ▶ play. +

+ + stageRef?.querySelector('[data-gradient-builder-track]') ?? + stageRef?.querySelector('[data-gradient-builder]') ?? + stageRef} + /> +
+ {/if} + + {#if tab === 'services'} +
+

Services

+

+ GradientBuilder consumes one app service: langs (i18n). Its morfo declares + translatable texts (the track / add-stop / angle / kind labels) that resolve + through uix.langs — switch the language in the topbar to see them follow. +

+
+ + + + + + + + +
Text keyDefaultService
trackGradient stopslangs
add-stopAdd color stoplangs
angle-dialGradient anglelangs
kind-switchGradient typelangs
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + +
PropTypeDefaultDescription
valueGradient2-stop linearThe gradient being edited. Bindable.
size eidos'sm' | 'md' | 'lg''md'Visual size for the editor + child controls.
variant eidos'surface' | 'outline' | 'ghost''surface'The editor card framing.
disabledbooleanfalseDisables all editing.
onValueCommit(g: Gradient) => void—Fires on every settled change.
+
+

+ Compound parts — .Preview · .Track · .Stop · .StopColor · .KindSwitch · .AngleDial · .AddStop — are rendered by default when no children are passed; compose them explicitly to customise + the layout. +

+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+

+ Source: src/uix/morfo/components/gradient-builder.ts. +

+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Events
+
+ + + + {#each actions as action} + {@const sem = action.semantic} + + + + + + + + {/each} + +
namefamilyverbsequencetarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{action.target}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/gradient-builder/gradient-builder.css; + internal tokens --_gradient-builder-* derive from global tokens (self-contained + — gradient-builder is on the active dev track, not yet registered in + recipes/base.ts). The child controls bring their own recipes (Slider / + ToggleGroup / Button / ColorPicker). +

+
+ + + + + + + + + + +
SelectorSource
[data-gradient-builder]morfo
[data-gradient-builder][data-size='X']eidos
[data-gradient-builder][data-variant='X']eidos
[data-gradient-builder][data-kind='X']morfo
[data-gradient-builder-track] / -stopmorfo
[data-gradient-builder-stop][data-selected] / [data-dragging]morfo
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The differentiator: every color stop is a real role="slider" — keyboard + + screen-reader gradient editing. The track is a labelled role="group". +

+
Keyboard (Stop)
+
+ + + + + + + + + +
KeyAction
← / ↓Move stop −1% (Shift −10%)
→ / ↑Move stop +1% (Shift +10%)
Home / EndMove stop to 0% / 100%
Delete / BackspaceRemove the stop (min 2 remain)
Enter / SpaceSelect the stop
+
+
ARIA contract
+
+ + + + + + + + +
PartAttributeValue
Trackrolegroup + aria-label
Stoproleslider
Stoparia-valuenowposition %
Stoparia-valuetext"Stop 2 of 4, 40%"
+
+
+ {/if} + + + diff --git a/web/routes/uix/components/gradient-picker/+page.svelte b/web/routes/uix/components/gradient-picker/+page.svelte new file mode 100644 index 000000000..97ee2212c --- /dev/null +++ b/web/routes/uix/components/gradient-picker/+page.svelte @@ -0,0 +1,410 @@ + + +
+
+
Controls · GradientPicker
+

GradientPicker

+

+ The GradientBuilder 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. +

+
+ parts{partsList.length} + events{actions.length} + sizes{sizes.length} + variants{variants.length} + model$libs/gradient +
+
+ + +
+
+ + + + + + +
+
+ trace + {#if trace.entries.length === 0} + open the picker, edit, then Save / Cancel + {:else} + {#each trace.entries.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {/each} + {/if} + open {open ? 'yes' : 'no'} +
+
+ + +
+ + + + + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ The component API. Like GradientBuilder it has no color prop. Foundation + axes live in the tab. +

+ +
+ eidos size · variant +
+
+ + +
+ +
+ soma behavior +
+
+ + + +
+ +
committed value · gradientToCss(value)
+
+
+ $libs/gradient + the bound gradient, serialized + css +
+
background: {css};
+
+
+ +
+
+ eidos + visual · trigger + popover + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'system'} +
+

eidos System axes

+

+ Foundation knobs applied to the stage. Open the picker and switch mode to preview the + popover in dark. +

+ +
+ {/if} + + {#if tab === 'motion'} +
+

Motion catalog

+

+ 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. +

+ +
+ {/if} + + {#if tab === 'sema'} +
+

sema firma + sound

+

+ The picker declares one value event — commit-reset (the Clear action). The + editing firma (handle / commit) lives on the embedded GradientBuilder; the open / close + firma lives on the composed Popover. Turn on Semantics → Sound, then ▶ play. +

+ stageRef?.querySelector('[data-gradient-picker]') ?? stageRef} /> +
+ {/if} + + {#if tab === 'services'} +
+

Services

+

+ GradientPicker consumes langs (i18n): the trigger / content labels + + the footer Clear / Cancel / Save (from the shared picker-shell). Switch language in the + topbar to see them follow. +

+
+ + + + + + + +
Text keyDefaultService
labelGradient pickerlangs
triggerOpen gradient editorlangs
contentGradient editorlangs
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + +
PropTypeDefaultDescription
valueGradient2-stop linearThe committed gradient. Bindable.
openbooleanfalsePopover open state. Bindable.
mode'inline' | 'modal''inline'Footer layout (Clear/Cancel vs Cancel/Save).
size eidos'sm' | 'md' | 'lg''md'Trigger / popover / editor size.
variant eidos'surface' | 'outline' | 'ghost''surface'Trigger framing.
onValueCommit(g: Gradient) => void—Fires on Save.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/gradient-picker.ts · expression: 'delegated' (the Popover is the dialog).

+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Events
+
+ + + + {#each actions as action} + {@const sem = action.semantic} + + + + + + + + {/each} + +
namefamilyverbsequencetarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{action.target}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/gradient-picker/gradient-picker.css. + The trigger is a field-shaped pill carrying a gradient chip (--gp-current-gradient, + stamped inline by soma); the popover content + editor reuse the Popover + PickerShell + + GradientBuilder recipes. +

+
+ + + + + + + + + +
SelectorSource
[data-gradient-picker]morfo
[data-gradient-picker][data-size='X'] / [data-variant='X']eidos
[data-gradient-picker-trigger][data-state='open']morfo
.gradient-picker-trigger-swatcheidos
[data-gradient-picker-content]morfo + Popover
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The trigger is aria-haspopup="dialog" + aria-expanded; the + Popover provides the role="dialog", focus trap and Escape-to-close. Inside, + the GradientBuilder's keyboard-accessible stops apply. +

+
+ + + + + + + +
KeyAction
Enter / SpaceOpen the editor (on the trigger)
EscapeClose the popover
TabMove focus within the editor
+
+
+ {/if} +
+ +