You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/morfo/components/gradient-builder.ts

301 lines
9.8 KiB

import type { Morfo } from '../types';
import { v } from '../types';
/**
* GradientBuilder — the standalone interactive gradient editor (the GradientPicker
* wraps this in a Popover). Edits the canonical `Gradient` model from
* `$libs/gradient` — the SAME model the theming axis (`buildGradient`) derives —
* so a gradient built here is also a themeable token.
*
* The differentiator nobody ships: every color stop, the angle dial, and (later)
* each mesh point is a real `role="slider"` with `aria-valuetext`, roving focus,
* and live announcements — gradient editing that works from the keyboard and a
* screen reader. The whole field (Figma / Photoshop / web pickers) is mouse-only.
*
* v1: linear / radial / conic, alpha-in-stop, explicit per-kind geometry. Mesh
* editing + color hints (midpoints) are the fast-follow.
*/
export const gradientBuilderMorfo = {
name: 'GradientBuilder',
kebab: 'gradient-builder',
scope: ['soma', 'sema', 'eidos'],
// Every stop (and the angle dial) is a real slider thumb — the pattern the
// keyboard interaction implements; composed pieces (ColorPicker, ToggleGroup,
// Button) carry their own patterns.
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/slider/',
expression: 'pack',
events: [
// Grabbing / dragging a stop along the track — same `handle` family as the
// slider's drag, so a stop drag carries the slider's perceptual signature.
{
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
target: v.partRef('track'),
sequence: 'coincident'
}
},
{
name: 'handle-drag',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('track'),
sequence: 'coincident'
}
},
// The "save" signal — fires on pointer release, a keyboard nudge end, or a
// stop add / remove / color change. Canonical non-shell commit.
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
},
{
name: 'commit-reset',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
}
],
texts: {
label: '#?components.gradient-builder.label|Gradient builder',
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',
'stop-list': '#?components.gradient-builder.stop-list|Color stops',
presets: '#?components.gradient-builder.presets|Gradient presets'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-kind', values: ['linear', 'radial', 'conic'] },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: []
},
{
// The live gradient surface — decorative; the stops carry the semantics.
name: 'Preview',
kebab: 'preview',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-kind', values: ['linear', 'radial', 'conic'] }],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// The stop rail — a composite of `role="slider"` stops, navigated by a
// roving focus group (arrow to move, +/Delete to add/remove).
name: 'Track',
kebab: 'track',
archetype: 'group',
role: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{ attr: 'data-kind', values: ['linear', 'radial', 'conic'] },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-builder.track|Gradient stops'),
severity: 'recommended'
}
]
},
{
// One color stop — a real slider thumb. `aria-valuenow` = position %,
// `aria-valuetext` = "Stop 2 of 4, teal, 40%" (the a11y differentiator).
name: 'Stop',
kebab: 'stop',
archetype: 'thumb',
role: 'slider',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-dragging', severity: 'optional' }
],
aria: [
{ attr: 'aria-valuemin', value: v.literal('0') },
{ attr: 'aria-valuemax', value: v.literal('100') },
{ attr: 'aria-valuenow', value: v.propRef('position') },
{ attr: 'aria-valuetext', value: v.propRef('valueText'), severity: 'recommended' },
{ attr: 'aria-label', value: v.propRef('stopLabel'), severity: 'recommended' }
],
// Declared in the P1·CONTRATO keyboard wave (catch-up B.1): the
// «a11y differentiator» this part advertises above has shipped
// undeclared since it existed (gradient-builder-provider :356-391).
// A stop is a VALUE control, so Home / End are `set-min` / `set-max`
// (colour-field D6), not the list vocabulary; Delete / Backspace take
// tag-group's `remove-item`, which is what removing a stop is. Both
// axes move the same value — the track is one-dimensional, and the
// vertical pair is an ergonomic alias, not a second axis.
//
// PHYSICALITY (measured 2026-08-27; the acta knob :112-115 signs for
// its own exception): the rail does NOT mirror in RTL, so Left/Right
// stay absolute instead of going through getDirectionalKeys(). All
// three surfaces of a stop are physical and agree — the paint is a
// `linear-gradient(90deg, …)` ramp the track fixes left→right
// «independent of the gradient's angle» (gradient-builder-track
// :11-16), the placement is an inline `left: {pos}%` centred by a
// physical `translate` (gradient-builder-stop :22 +
// gradient-builder.css :104-108), and the drag reads
// `(clientX - rect.left) / rect.width` (gradient-builder-provider
// :263-269). Position 0 is the physical left edge under every
// direction, so a mirrored arrow pair would move the stop AGAINST
// what the user sees.
keyboard: [
{ key: 'ArrowRight', action: 'increment' },
{ key: 'ArrowUp', action: 'increment' },
{ key: 'ArrowLeft', action: 'decrement' },
{ key: 'ArrowDown', action: 'decrement' },
{ key: 'Home', action: 'set-min' },
{ key: 'End', action: 'set-max' },
{ key: 'Delete', action: 'remove-item' },
{ key: 'Backspace', action: 'remove-item' },
{ key: 'Enter', action: 'select' },
{ key: ' ', action: 'select' }
]
},
{
name: 'AddStop',
kebab: 'add-stop',
archetype: 'trigger',
role: 'button',
kind: 'public',
defaultElement: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
attrs: [{ attr: 'type', value: v.literal('button') }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-builder.add-stop|Add color stop'),
severity: 'recommended'
}
]
},
{
// Linear angle / conic from-angle. A LAYOUT WRAPPER around a composed
// system <Slider> — the slider semantics (role=slider, the value trio,
// keyboard) live on the Slider's OWN parts, not here (fila cero
// 2026-08-27: this part declared role:'slider' + the trio for an
// element eidos renders as a plain div CONTAINING the slider; the
// dossier measured the mismatch — form of the part, not a missing
// source). Numeric input pairs in eidos.
name: 'AngleDial',
kebab: 'angle-dial',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: []
},
{
// linear / radial / conic switch — composes ToggleGroup in eidos.
name: 'KindSwitch',
kebab: 'kind-switch',
archetype: 'group',
role: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.gradient-builder.kind-switch|Gradient type'),
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' }],
attrs: [{ attr: 'type', value: v.literal('button') }],
aria: []
}
]
} as const satisfies Morfo;

Powered by TurnKey Linux.