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.
301 lines
9.8 KiB
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;
|