import { context, type WithRefOpts } from '../../provider'; import { type Active, type ActiveProps, type StateProps } from '$libs/reactive'; import { boolToEmptyStrOrUndef } from '$adom'; import type { OnChangeFn } from '../../types'; import { KEYS } from '../../keyboard'; import type { Gradient, GradientStop, StopColor, GradientKind } from '$libs/gradient'; import { gradientBuilderMorfo } from '../../../morfo/components/gradient-builder'; import { Soma } from '../../core/soma.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const clamp01 = (n: number): number => Math.min(1, Math.max(0, n)); const wrap360 = (deg: number): number => ((deg % 360) + 360) % 360; /** Position step for a keyboard nudge (1%); Shift uses the coarse step. */ const STEP = 0.01; const COARSE_STEP = 0.1; // ── Root ───────────────────────────────────────────────────────────────────── interface GradientBuilderOpts extends WithRefOpts, StateProps<{ value: Gradient }>, ActiveProps<{ disabled: boolean; onValueCommit: OnChangeFn; }> {} /** * Headless GradientBuilder — the state machine over the canonical `Gradient` * model (`$libs/gradient`). Owns stop add / move / remove / recolor, the kind * (linear/radial/conic), and the angle. Every mutation flows through here so the * Stop slider thumbs, the track, and the controls all read one source of truth. * v1 edits the `stops` of a linear/radial/conic gradient (mesh editing is the * fast-follow). */ export class GradientBuilderProvider { readonly opts: GradientBuilderOpts; readonly runtime: SomaRuntime; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; static readonly ctx = context('GradientBuilder'); static get(): GradientBuilderProvider | undefined { return this.ctx.getOr(undefined) as GradientBuilderProvider | undefined; } static require(): GradientBuilderProvider { return this.ctx.get(); } static create(opts: GradientBuilderOpts) { return new GradientBuilderProvider(opts); } /** The stop the controls edit (its color, etc.). */ selectedIndex = $state(0); /** The stop currently being dragged, or null. */ activeDragIndex = $state(null); /** Whether the color editor for the selected stop is open (opened by clicking a stop). */ colorEditOpen = $state(false); /** The track element (the drag surface) — registered by the Track part. */ trackRef = $state(null); openColorEdit(i: number): void { this.selectStop(i); this.colorEditOpen = true; } closeColorEdit(): void { this.colorEditOpen = false; } get isDisabled(): boolean { return this.opts.disabled.current; } /** The current gradient's stops (linear/radial/conic). Mesh has none → empty. */ readonly stops = $derived.by(() => { const v = this.opts.value.current; return v.kind === 'mesh' ? [] : v.stops; }); readonly kind = $derived.by(() => this.opts.value.current.kind); /** The live gradient being edited — read by the eidos preview / track background. */ get value(): Gradient { return this.opts.value.current; } private constructor(opts: GradientBuilderOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(gradientBuilderMorfo, { props: { disabled: () => this.isDisabled }, events: { 'commit-set': () => this.opts.onValueCommit.current(this.opts.value.current) } }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: GradientBuilderProvider.ctx }); } setTrackRef(el: HTMLElement | null): void { this.trackRef = el; } /** Position 0..1 of stop `i` — its explicit position or the even distribution. */ stopPosition(i: number): number { const s = this.stops; const p = s[i]?.position; if (p != null) return p; return s.length > 1 ? i / (s.length - 1) : 0; } selectStop(i: number): void { if (i >= 0 && i < this.stops.length) this.selectedIndex = i; } private writeStops(next: GradientStop[]): void { const v = this.opts.value.current; if (v.kind === 'mesh') return; this.opts.value.current = { ...v, stops: next }; } /** Set stop `i`'s position (0..1), no commit (the live drag / keyboard step). */ moveStop(i: number, position01: number): void { if (this.isDisabled || i < 0 || i >= this.stops.length) return; const stops = [...this.stops]; stops[i] = { ...stops[i], position: clamp01(position01) }; this.writeStops(stops); } /** Keyboard nudge — move + commit. */ nudgeStop(i: number, delta: number): void { this.moveStop(i, this.stopPosition(i) + delta); this.commit(); } /** * Midpoint between the SELECTED stop and the next one (by position). When the * selected stop is the last (rightmost), the midpoint of the penultimate and * the last — so the "+" button always inserts right next to the selection. */ private betweenSelectedAndNext(): number { const sel = this.stopPosition(this.selectedIndex); const ps = this.stops.map((_, i) => this.stopPosition(i)).sort((a, b) => a - b); const next = ps.find((p) => p > sel); if (next !== undefined) return (sel + next) / 2; // The selection is the last stop — insert between the penultimate and it. const last = ps[ps.length - 1] ?? 1; const penult = ps[ps.length - 2] ?? 0; return (penult + last) / 2; } /** * Insert a stop, colored from the nearest neighbour. With no position, the * stop lands BETWEEN the selected stop and the next one — or, if the selected * is the last, between the penultimate and the last. The explicit position is * used only for click-on-track. */ addStop(position01?: number): void { if (this.isDisabled) return; const pos = clamp01(position01 ?? this.betweenSelectedAndNext()); const stops = [...this.stops]; let nearest = 0; let best = Infinity; stops.forEach((_, idx) => { const d = Math.abs(this.stopPosition(idx) - pos); if (d < best) { best = d; nearest = idx; } }); const color: StopColor = stops[nearest]?.color ?? { kind: 'css', value: 'currentColor' }; stops.push({ color, position: pos }); stops.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); this.writeStops(stops); this.selectedIndex = stops.findIndex((s) => s.position === pos); this.commit(); } /** Remove stop `i` (a gradient needs ≥ 2 stops). */ removeStop(i: number): void { if (this.isDisabled || this.stops.length <= 2 || i < 0 || i >= this.stops.length) return; const stops = this.stops.filter((_, idx) => idx !== i); this.writeStops(stops); this.selectedIndex = Math.min(this.selectedIndex, stops.length - 1); this.commit(); } /** * Set stop `i`'s color. Live update only — does NOT commit (the inline color * editor calls this on every change and commits on release via `commit()`). * Committing per-change would fire the `commit-set` firma — a primary ring * pulse on the provider card — on every dragged pixel. */ setStopColor(i: number, color: StopColor): void { if (this.isDisabled || i < 0 || i >= this.stops.length) return; const stops = [...this.stops]; stops[i] = { ...stops[i], color }; this.writeStops(stops); } /** Set stop `i`'s alpha (0..1). Live update only — see {@link setStopColor}. */ setStopAlpha(i: number, alpha: number): void { if (this.isDisabled || i < 0 || i >= this.stops.length) return; const stops = [...this.stops]; stops[i] = { ...stops[i], alpha: clamp01(alpha) }; this.writeStops(stops); } /** Switch geometry, preserving the stops. */ setKind(kind: 'linear' | 'radial' | 'conic'): void { if (this.isDisabled) return; const v = this.opts.value.current; if (v.kind === kind || v.kind === 'mesh') return; this.opts.value.current = { kind, stops: [...this.stops], interpolation: v.interpolation, hints: v.hints } as Gradient; this.commit(); } /** * Set the linear angle / conic from-angle (degrees). Live update only — the * angle dial commits on release (see {@link setStopColor} for why). */ setAngle(deg: number): void { if (this.isDisabled) return; const v = this.opts.value.current; if (v.kind === 'linear') this.opts.value.current = { ...v, angle: wrap360(deg) }; else if (v.kind === 'conic') this.opts.value.current = { ...v, from: wrap360(deg) }; } /** The current angle for the dial (linear `angle` / conic `from`, else 0). */ get angle(): number { const v = this.opts.value.current; if (v.kind === 'linear') return v.angle ?? 0; if (v.kind === 'conic') return v.from ?? 0; return 0; } commit(): void { void this.runtime.trigger('commit-set'); } reset(value: Gradient): void { this.opts.value.current = value; void this.runtime.trigger('commit-reset'); } /** Provider element attrs — morfo-resolved + `data-kind` (set imperatively). */ get props() { return { ...this.runtimePart.props, 'data-kind': this.kind }; } /** Map a pointer x to a position 0..1 along the registered track. */ positionFromPointer(clientX: number): number { const el = this.trackRef; if (!el) return 0; const rect = el.getBoundingClientRect(); if (rect.width === 0) return 0; return clamp01((clientX - rect.left) / rect.width); } // ── Drag (driven by a Stop via pointer capture) ────────────────────────── startStopDrag(i: number): void { this.selectStop(i); this.activeDragIndex = i; void this.runtime.trigger('handle-pick'); } dragStopTo(clientX: number): void { const i = this.activeDragIndex; if (i == null) return; this.moveStop(i, this.positionFromPointer(clientX)); void this.runtime.trigger('handle-drag'); } endStopDrag(): void { if (this.activeDragIndex == null) return; this.activeDragIndex = null; this.commit(); } } // ── Stop (one slider thumb — the keyboard-accessible color stop) ────────────── interface GradientStopOpts extends WithRefOpts { index: Active; } export class GradientStopProvider { readonly opts: GradientStopOpts; readonly runtimePart: SomaRuntimePart; readonly provider = GradientBuilderProvider.require(); static readonly ctx = context('GradientStop'); static create(opts: GradientStopOpts) { return new GradientStopProvider(opts); } private constructor(opts: GradientStopOpts) { this.opts = opts; this.runtimePart = this.provider.runtime.part('stop', { id: opts.id, ref: opts.ref, owner: this, context: GradientStopProvider.ctx, // Per-stop sources for the morfo's value-bound aria (aria-valuenow / // aria-valuetext / aria-label) — resolved via `the render bag`. props: { position: () => this.position, valueText: () => this.valueText, stopLabel: () => this.stopLabel } }); } get index(): number { return this.opts.index.current; } /** Position % for `aria-valuenow`. */ get position(): number { return Math.round(this.provider.stopPosition(this.index) * 100); } get isSelected(): boolean { return this.provider.selectedIndex === this.index; } /** Live-announced description: "Stop 2 of 4, 40%". */ get valueText(): string { return `Stop ${this.index + 1} of ${this.provider.stops.length}, ${this.position}%`; } get stopLabel(): string { return `Color stop ${this.index + 1}`; } /** Element attrs — morfo-resolved aria + state attrs + the drag/keyboard handlers. */ get props() { return { ...this.runtimePart.props, tabindex: 0, 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-dragging': boolToEmptyStrOrUndef(this.provider.activeDragIndex === this.index), onkeydown: this.onkeydown, onpointerdown: this.onpointerdown, onpointermove: this.onpointermove, onpointerup: this.onpointerup }; } /** The a11y differentiator: every stop is a real slider with arrow/Home/End/Delete. */ readonly onkeydown = (e: KeyboardEvent): void => { const i = this.index; const step = e.shiftKey ? COARSE_STEP : STEP; switch (e.key) { case KEYS.ARROW_LEFT: case KEYS.ARROW_DOWN: e.preventDefault(); this.provider.nudgeStop(i, -step); break; case KEYS.ARROW_RIGHT: case KEYS.ARROW_UP: e.preventDefault(); this.provider.nudgeStop(i, step); break; case KEYS.HOME: e.preventDefault(); this.provider.moveStop(i, 0); this.provider.commit(); break; case KEYS.END: e.preventDefault(); this.provider.moveStop(i, 1); this.provider.commit(); break; case KEYS.DELETE: case KEYS.BACKSPACE: e.preventDefault(); this.provider.removeStop(i); break; case KEYS.ENTER: case KEYS.SPACE: e.preventDefault(); this.provider.selectStop(i); break; } }; /** Pointer x at press — distinguishes a click (opens the color picker) from a * drag (moves the stop). */ private pressX = 0; private didDrag = false; readonly onpointerdown = (e: PointerEvent): void => { const el = e.currentTarget as HTMLElement | null; el?.setPointerCapture?.(e.pointerId); this.pressX = e.clientX; this.didDrag = false; this.provider.startStopDrag(this.index); }; readonly onpointermove = (e: PointerEvent): void => { if (this.provider.activeDragIndex == null) return; if (Math.abs(e.clientX - this.pressX) > 3) this.didDrag = true; this.provider.dragStopTo(e.clientX); }; readonly onpointerup = (e: PointerEvent): void => { const el = e.currentTarget as HTMLElement | null; el?.releasePointerCapture?.(e.pointerId); this.provider.endStopDrag(); // A click (no drag) on the stop opens its color picker; a drag just moved it. if (!this.didDrag) this.provider.openColorEdit(this.index); }; } // ── Track (the stop rail / drag surface — click empty space to add a stop) ──── interface GradientTrackOpts extends WithRefOpts {} export class GradientTrackProvider { readonly opts: GradientTrackOpts; readonly runtimePart: SomaRuntimePart; readonly provider = GradientBuilderProvider.require(); static readonly ctx = context('GradientTrack'); static create(opts: GradientTrackOpts) { return new GradientTrackProvider(opts); } private constructor(opts: GradientTrackOpts) { this.opts = opts; this.runtimePart = this.provider.runtime.part('track', { id: opts.id, ref: opts.ref, owner: this, context: GradientTrackProvider.ctx }); // The track IS the drag surface the provider measures against. $effect(() => { this.provider.setTrackRef(this.opts.ref?.current ?? null); }); } get props() { // Click-to-add on the rail — the reference-standard PRIMARY add path: a // click on the bare track inserts a stop at the pointer position. Clicks // that land on a Stop are ignored here (the Stop owns select / edit / drag); // the coordinate-less Add button stays as the fallback. return { ...this.runtimePart.props, // The morfo declares `data-kind` WITHOUT a value — soma owns it, same // idiom as preview's hand stamp. It was never written; morfo:check // caught the required-attr gap (P0 fase A, audit 2026-08-26). 'data-kind': this.provider.kind, onclick: this.onclick }; } private onclick = (e: MouseEvent): void => { if (this.provider.isDisabled) return; const target = e.target as Element | null; if (target?.closest('[data-gradient-builder-stop]')) return; this.provider.addStop(this.provider.positionFromPointer(e.clientX)); }; }