22 KiB
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.
Session 2026-07-04 — footer, surface-agnostic, stops-list, add-stop, presets
Polished the GradientBuilder toward "done". All type-clean (npm run check = 59,
baseline — nothing new). Still on ACTIVE_DEV_TRACK. Every item below verified
in-browser.
Done:
- Stop color editor (
gradient-builder-stop-color.svelte, the per-stop Popover ColorPicker):- Value row = the canonical
<ColorField><ColorField.Input swatch>…segments…</>(swatch + the framework's styled Select), NOT the oldChannelInputsnippet that rendered a native<select>. - Rails (hue/alpha) at 90% width + taller track (
--slider-track-size-xl). - Composable footer:
<ColorPicker.Footer>=<ColorPicker.Cancel>(reverts the stop to its open-edge colour via astopColorOnOpensnapshot) +<ColorPicker.Close>("Aceptar") + the gradient-specific trash (remove-stop). The Footer is a SIBLING ofPickerShell.Body, not inside it.
- Value row = the canonical
- Surface-agnostic: default
variantis nowghost(transparent, no card) so the builder adapts to its container;surface/outlineopt into a card. The DEMO must NOT wrap it in a card (user was explicit). +add-stop on the same row as the kind switch (Lineal/Radial/Cónico).- Add-stop placement:
provider.betweenSelectedAndNext()(between the selected stop and the next; if last, penultimate↔last). Rail click-to-add re-enabled (inserts at the cursor) as the primary path;+= coordinate-less fallback. - StopList (
gradient-builder-stop-list.svelte+-row) — morfo partsstop-list/stop-list-row. Row = a ColorField-value-looking swatch (click → the same color editor) + position % + delete (except the two endpoint stops). Rendered in the DEFAULT editor (always visible). - Presets (
gradient-builder-presets.svelte) —presetsprop (Gradient[]; defined even[]shows the panel). Panel BELOW the editor (flex, NOT tabs — tabs are for the GradientPicker). A preset is a fullGradient, so selecting it (provider.reset) sets the kind too. Save mechanic (ColorPicker saved-swatch style): one action button — "Guardar degradado" (saves current; disabled if already present or atmaxPresets, default 10), flips to "Borrar degradado" (threat) when a SAVED preset is selected (data-selected).savedPresets= ephemeral$statein the root. Morfo partspresets/preset.
Platform reference scan (background workflow, 2026-07-04): linear/radial/conic IS the standard complete set (don't add "angular" = conic; diamond = optional radial sub-mode; mesh = separate-tool, correctly deferred). Add-stop: every tool inserts at the clicked rail position. Stops-list: legit opt-in (cssgradient.io / Untitled UI). Our keyboard-accessible role=slider stops + shared OKLCH token model exceed every incumbent.
PENDING — start here next session:
- GradientPicker (the popover wrapper — NOT built this session). Design (user's
words): a Popover + PickerShell with two tabs [Builder | Presets] + a footer
(Clear/Cancel/Close, already in
gradient-picker-content.svelte). The Builder tab uses<GradientBuilder presets={…} />whole (black box); the Presets tab is the picker's. The tabs live in the PICKER, not the builder. NOTE: the currentgradient-picker-content.sveltere-composes the builder's parts (Preview/Track/…) — change it to render<GradientBuilder>whole. - Numeric editable position field on the stop rows.
- Reverse/flip control (trivial on the provider).
- READMEs (eidos builder README stale) → then remove gradient-builder / gradient-picker
from
ACTIVE_DEV_TRACKand make the catalogue guards green.
Gotchas: ColorPicker.ChannelInput is a render-prop (empty without children).
The stop-color popover inherits the shared spin-field.css box — strip it with compound
[data-…][data-spin-field] selectors. A {#snippet} as a DIRECT child of a component
becomes a snippet PROP — define it at markup top level, render inside. Callback props must
be camelCase (onApply, not onapply). variant='ghost' = surface-agnostic; the demo
must NOT add its own card.
Redesign pass #3 (2026-06-27 — interaction + scaling feedback)
User feedback (all addressed, verified by EYE in the user's Chrome):
- Color editing is now ON-CLICK, not always visible: clicking a stop opens a
color editor panel (Area + hue + alpha + hex, with a header "Parada de color N"
- × close) for that stop. Removed the always-visible area/sliders.
provider.colorEditOpen+openColorEdit/closeColorEdit;startStopDragopens it. The StopColor component self-hides via{#if provider.colorEditOpen}.
- × close) for that stop. Removed the always-visible area/sliders.
- Add-stop lands in the WIDEST GAP (
largestGapPosition()), never stacking on 0.5 over existing stops.addStop()(no arg) = widest gap;addStop(pos)= explicit (track click). Verified: 3 stops [0,.5,1] → new at .75. - Delete affordance: a
RemoveStoptrash Button in the stop-actions row (removes the selected stop; disabled at 2 stops). Keyboard Delete still works. - Kind switch is always-selected + full width:
deselectable={false}+blockon the ToggleGroup. The current kind is always highlighted. - Preview bar taller (72px md, was 44).
- Scaling: dimensions now
calc(px * var(--scaling, 1))so the editor zooms with GLOBAL--scaling. Verified: global :root --scaling 1.4 → button 36→50, preview 72→101, gap 16→22.4 (all scale). CAVEAT — the demo's System density/scaling axes are applied LOCALLY on the stage (style="--scaling"/data-density), which does NOT recompute the root-baked--space-*/--control-height-*tokens — so the local sliders don't resize ANY component (universal harness limitation, not gradient-builder). Confirmed: control-height readscalc(36px * 1 * 1)regardless of local --scaling. Fix would be a shared harness change (e.g.zoomon the stage) — deferred, not unilateral. - Dark mode: card is now
--color-surface-raised+--color-border-defaultso it reads as a distinct panel (was blending into the page surface). - Commit-set ring fix (the "border on slider move"): the
commitfirma (commit-settlekeyframe) pulses a primary box-shadow ring on the[data-event-family='commit'][data-event-phase='active']target. The builder'scommit-settargets the provider CARD and was firing on EVERY angle/color tick (setAngle/setStopColorcalledcommit()). Fix: those setters are now live-update-only; the angle dial commits ononValueCommit(release), the color editor ononValueChangeEnd— one ring pulse on release, not per pixel.
Redesign pass #2 (2026-06-27 — after "no funciona / mal diseñado / no sigue la guía")
User feedback was correct: the first eidos pass shipped an ungrouped pile of controls with a broken color editor and a demo that ignored the v2 demo guide. Fixed:
- StopColor is now INLINE (
gradient-builder-stop-color.svelte): the ColorPicker Area + hue/alpha sliders + hex render directly (no nested popover). The seed effect initialisescolorValuefrom the selected stop UP FRONT (was showing the black default), tracks onlyselectedIndex. NOTE: the segmentedChannelInputrecurses in SSR outside a Popover Content — useValueText(read-only hex) inline; the segmented editor only works inside the popover. - Default full-editor layout:
<GradientBuilder bind:value />with no children renders Preview → Track → (KindSwitch + AddStop) → AngleDial → StopColor. The Provider is now a framed card (bg + border + padding, size-aware);variant(surface/outline/ghost) changes the framing (was inert). AngleDial gained a label + degree readout. Verified by EYE in light + dark. - Both demos rewritten to the v2 guide (
web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md, canary = Button):data-uix-canvas-inner+ header/meta-pills + always-on stage with System axes + the 9 tabs (Live · System · Motion · Sema · Services · API · Morfo · Recipe · A11y) + the shared harness (SystemAxes / MotionPanel / SemaPanel / DemoTrace). No PalettePicker — neither component has acolorprop (they paint the user's gradient), justified in the demo + README. Now atgradient-builder/+page.svelteandgradient-picker/+page.svelte(split). - 4 READMEs written (soma + eidos for both). svelte-check clean, contracts test unchanged (12 pre-existing, none gradient-*).
- Verified in the user's real browser (Chrome MCP —
preview_screenshothangs): builder editor coherent in light+dark; Sema tab renders all 4 firmas; picker popover opens showing the builder + Borrar/Cancelar footer; selected-stop color correct (#7c3aed / #ec489a, not black).
Status (updated 2026-06-27 — GradientBuilder + GradientPicker DONE)
| Layer | GradientBuilder | GradientPicker |
|---|---|---|
| morfo | committed (c474fc6b) |
DONE (uncommitted) morfo/components/gradient-picker.ts (expression:'delegated', identity wrapper over Popover) |
| soma | committed (8ddd5530) + value getter |
DONE (uncommitted) soma/components/gradient-picker/ (Provider/Trigger/ValueSwatch + composed PopoverProvider + pickerShellContext commit/cancel/clear) |
| eidos | DONE (uncommitted) 12 files | DONE (uncommitted) eidos/components/gradient-picker/ (Trigger+chip, Content = Popover+PickerShell+GradientBuilder body) |
| sema | DONE (uncommitted) + wired in /uix layout |
n/a (only commit-reset; editing sounds come from the embedded builder) |
| translations | DONE langs/components/gradient-builder.ts |
DONE langs/components/gradient-picker.ts |
| demo (testbed) | DONE web/routes/uix/components/gradient-builder/+page.svelte (both shown there) |
included in the same testbed |
Both are in ACTIVE_DEV_TRACK in src/uix/contracts.test.ts (catalogue guards skip them while WIP).
What remains:
- READMEs — soma + eidos component READMEs for both (needed before the catalogue guards go green).
- Canonical-depth demos — split into
gradient-builder/+gradient-picker/routes at full DEMO_AUTHORING_GUIDE 6-tab depth (the current single+page.svelteis a working testbed). - Remove both from
ACTIVE_DEV_TRACKonce 1 + 2 land and the soma/eidos guards pass.
GradientPicker — how it works (the value bridge is the one subtle bit)
- Identity wrapper over a composed Popover (
PopoverProvidershares theopenwritable).GradientPickerProviderexposescommit()/cancel()/clear()viapickerShellContextso<PickerShell.Clear/Cancel/Close>drive it.valueOnOpensnapshot (captured on the open edge viawatch) powerscancel()'s revert. - The eidos Content composes
PopoverContent → PickerShell → GradientBuilder body + Footer(Clear/Cancel/Close). The GradientBuilder edits a localdraft($state); itsonValueCommitpushes settled values toprovider.setValue; an$effect(if (v !== draft) draft = v) flows external resets (cancel/clear) back. Commit-only push + the ref guard = no loop. - Trigger + ValueSwatch paint
--gp-current-gradient(stamped inline by soma) — the chip shows the committed gradient. - Verified in-browser: one click opens → builder renders inside (2 stops) → footer
Borrar/Cancelar/Listo→ AddStop commits live (chip updates) → Cancel reverts chip to the open-edge value + closes.aria-expanded/data-statecorrect, no console errors. svelte-check clean, contracts.test unchanged (12 pre-existing fails, none gradient-*).
What the eidos layer ships (the build composed, never re-implemented)
gradient-builder.svelte(Provider, sets a size/variant visual context + self-imports the child recipes)gradient-builder-preview.svelte(decorative live bar; checker behind for alpha)gradient-builder-track.svelte(paints a left→right ramp of the live stops; default-renders one Stop per stop)gradient-builder-stop.svelte(positions the soma slider-thumb atleft:%, fills via--gb-stop-fill, centers with thetranslateproperty so a press-squeeze transform doesn't fight it)gradient-builder-stop-color.svelte(embeds the full eidos ColorPicker bound to the selected stop; bridge instop-color.ts)gradient-builder-angle-dial.svelte(eidos Slider 0–360 →setAngle; shown for linear/conic only)gradient-builder-kind-switch.svelte(eidos ToggleGroup linear/radial/conic →setKind)gradient-builder-add-stop.svelte(eidos Button iconOnly +Plus→addStop(0.5))stop-color.ts(theStopColor ↔ ColorValuebridge — css/oklch read, writes back acsshex stop)context.svelte.ts,types.ts,index.ts,gradient-builder.css(self-contained--_gradient-builder-*recipe — gradient-builder is on the active dev track, so it deliberately does NOT register tokens inrecipes/base.tsyet; migrate when the foreign base.ts palette WIP is committed)
Verification done (2026-06-27)
svelte-check: gradient-builder eidos/soma/sema/langs files all clean (0 errors).- SSR (
curlagainst vite): HTTP 200, every part present, stops positioned + colored,linear-gradient(90deg in oklch, …)serialized,aria-valuetext="Stop n of N, p%", translated kind labels (Lineal/Radial/Cónico), nono_context/ SSR errors. - Computed styles (preview eval): track
position:relative+ live gradient bg; stopposition:absolute left:0 width:16pxround +#7c3aedfill +translate:-50% -50%; preview h48px. - Interaction (preview eval): AddStop adds a stop; KindSwitch flips
data-kindand hides the AngleDial for radial / shows it for linear+conic. No console errors. preview_screenshothangs in this environment (known headless-browser flakiness, see Gotchas) — verified via SSR DOM + computed styles + eval interaction instead of a literal screenshot.- Tests: contracts.test.ts (12 pre-existing fails: chronos/metrics/menu-dial/float-panel) + compile.test.ts
hoists role into staticAttrs(pre-existing, from the committed picker/dialog reconciliationcae3d2e0) — none gradient-builder; zero regressions added.
Where it sits — the gradient initiative
- Axis (Phase 1) — DONE + committed (
c474fc6b):$libs/gradient(model +gradientToCss) +eidos/lib/build-gradient.ts(role factories +applyGradients, the 6th builder) + docs. The axis DERIVES gradients from color roles. - Component (Phase 2) — morfo + soma DONE + committed (
c474fc6bmorfo,8ddd5530soma): the editor over the SAMEGradientmodel, so a built gradient is also a themeable token. ← you are here; build the eidos.
The shared model — $libs/gradient (already built)
type Gradient = LinearGradient | RadialGradient | ConicGradient | MeshGradient
interface GradientStop { color: StopColor; position?: number /*0..1*/; alpha?: number }
type StopColor =
| { kind: 'role'; role: string; slot?: string; step?: number } // → var(--color-…) — RE-TINTS
| { kind: 'oklch'; value: Oklch }
| { kind: 'css'; value: string } // transparent / currentColor
// gradientToCss(g) → a CSS <gradient> string. See $libs/gradient/README.md.
v1 edits linear/radial/conic (.stops). Mesh editing is the fast-follow.
The soma API you compose (already built) — $soma/components/gradient-builder
import * as GradientBuilder from '$soma/components/gradient-builder'
// <GradientBuilder.Provider bind:value> · .Track · .Stop (wrappers)
import { GradientBuilderProvider } from '$soma/components/gradient-builder' // for controls
GradientBuilderProvider (get via .require() inside the subtree) exposes the
state machine — call these from the eidos controls:
stops(derived),kind,angle,selectedIndex,activeDragIndexstopPosition(i),selectStop(i),moveStop(i, 0..1),nudgeStop(i, Δ)addStop(0..1),removeStop(i),setStopColor(i, StopColor),setStopAlpha(i, 0..1)setKind('linear'|'radial'|'conic'),setAngle(deg),commit(),reset(g)positionFromPointer(clientX)(against the registered track)
Each Stop is already a keyboard-accessible role=slider (arrows ±1% / Shift ±10%,
Home/End, Delete removes, Enter selects) + pointer drag — the a11y differentiator.
The eidos just RENDERS them positioned + colored; the soma owns behaviour.
Build the EIDOS — src/uix/eidos/components/gradient-builder/
Mirror the color-picker eidos (src/uix/eidos/components/color-picker/ — the
closest template: how it composes the soma, reuses ColorPicker.Area/ChannelSlider,
sets a visual context, and writes the recipe). Eidos rule: compose existing
components (Slider, ColorPicker, ToggleGroup, Button), never re-implement.
Files to create:
| File | Composes / does |
|---|---|
gradient-builder.svelte (Provider) |
<GradientBuilder.Provider> + sets a visual context (size/variant); the root |
gradient-builder-preview.svelte |
the gradient bar — background: {gradientToCss(value)} (or a --gb-preview var); decorative |
gradient-builder-track.svelte |
<GradientBuilder.Track>; background = the live gradient; hosts the stops |
gradient-builder-stop.svelte |
<GradientBuilder.Stop {index}> positioned left: {position}%, filled with the stop colour; the draggable handle |
gradient-builder-stop-color.svelte |
embed ColorPicker bound to the SELECTED stop's colour (read provider.stops[selectedIndex].color, write provider.setStopColor). Reuse the whole ColorPicker. |
gradient-builder-angle-dial.svelte |
<Slider> 0–360 → provider.setAngle; show for linear/conic only |
gradient-builder-kind-switch.svelte |
<ToggleGroup> linear/radial/conic → provider.setKind |
gradient-builder-add-stop.svelte |
<Button> → provider.addStop(0.5) |
gradient-builder.css |
the recipe (track height, stop handle, preview, focus ring). Bare --gradient-builder-* / --_gradient-builder-* tokens. |
types.ts + index.ts |
props + the compound namespace (Provider/Preview/Track/Stop/StopColor/AngleDial/KindSwitch/AddStop) |
Stop ↔ ColorPicker bridge is the one non-obvious bit: the selected stop's colour
opens a ColorPicker; its onValueChange calls provider.setStopColor(selectedIndex, …).
Map StopColor ↔ the ColorPicker value (a {kind:'oklch'} or {kind:'css'} stop ↔ a
color string). Role-stops ({kind:'role'}) can show a swatch row of theme roles
(reuse ColorPicker.SwatchGroup) instead of/alongside the area.
Then — GradientPicker (mirror ColorPicker exactly)
A SEPARATE component (gradient-picker.ts morfo + soma + eidos) = trigger + Popover
wrapping the GradientBuilder, identical structure to color-picker (Trigger /
ValueSwatch / Content via PopoverContent + PickerShellRoot / the shell
Footer/Clear/Cancel/Close). The ValueSwatch shows gradientToCss(value).
Sema pack — src/uix/sema/components/gradient-builder.ts
Cascade rules via semaSelector(gradientBuilderMorfo, part, matchers) (NEVER
hand-written selector strings). The morfo events are handle-pick/handle-drag
(family handle → haptic on drag) + commit-set/commit-reset. Mirror slider.ts /
color-picker.ts sema packs. Wire it into the demo layout's events.components.
Translations + finish
- Add the
#?components.gradient-builder.*keys (track / add-stop / angle-dial / kind-switch) to the langs catalog (find where color-picker's keys live). - Demo at
web/routes/uix/components/gradient-builder/+page.svelte(readDEMO_AUTHORING_GUIDE+ a reference demo first — canonical depth, every prop a live control). Or fold a showcase into/demos/cristal. - When the eidos + sema + translations exist, remove
'gradient-builder'fromACTIVE_DEV_TRACKinsrc/uix/contracts.test.tsand make the catalogue guards green for it (soma module ↔ morfo scope, eidos recipe ↔ scope, README, etc.).
Design notes from the editor research (don't re-derive)
The competitive finding: every incumbent (Figma/Photoshop/web pickers/gradient.style) stores RGBA + geometry and exports a dead string; nobody stores an OKLCH model shared between a token engine and an editor, nor edits gradients from the keyboard. Eidos owns both. Decisions already taken:
- Geometry = explicit per-kind fields (angle / shape+size+at / from+at), not an affine matrix — round-trips losslessly to CSS, trivial keyboard mapping.
- Alpha = per-stop in v1 (a separate opacity rail is reserved, not shipped).
- Mesh editor = fast-follow (the axis already ships
aurora()); diamond dropped. - Dual surfaces (a precise stop bar + an on-canvas gizmo) is the target; v1 = the bar.
- Midpoints (color hints) +
parseGradient(paste-to-edit) = Phase-2.5.
Gotchas (cost real cycles this session)
- JS spring / rAF freezes in the automated browser tab (throttled to 0fps when
unfocused) — you CANNOT see a
uix.motionspring complete via the preview/Chrome MCP unless the tab is focused. CSS animations (compositor) DO render. Verify motion in the user's FOCUSED tab. (The cristal scroll-reveal hit this.) - Dev server is flaky this branch: ports 5173/5180 come and go;
preview_startmay conflict with another chat's server.preview_screenshothangs on heavybackdrop-filter— use the Claude-in-Chrome MCP against the user's real tab. - An eidos-component demo under
web/routesneeds a+layout@.sveltethat bootscreateActiveUix+Soma.create()+ActiveEidos.create()(mirrorweb/routes/temas/grafito/+layout@.svelte), else SSR 500no_context. contracts.test.tshas ~12 PRE-EXISTING failures from other sessions' WIP (metrics,chronos,menu-dial,float-panel) — NOT yours. gradient-builder is excluded viaACTIVE_DEV_TRACK(both soma collectors now filter it uniformly).- Don't touch
words/,palabras/,chronos/, or the other session's date/time-picker + picker-shell WIP. Stage explicit paths aftergit reset -q.