feat(uix): cropper, image-picker, image-adjustments + reusable zoom-pan layer

Three new components across all 4 layers (morfo/soma/sema/eidos) + demos + langs + READMEs:

- ImageAdjustments — reusable image-filter sliders subcomponent.
- ImagePicker — composes file-upload + image + image-adjustments (fill modes, rotation).
- Cropper — Ark image-cropper anatomy: normalized 0-1 crop rect, rect + round shapes,
  8 resize handles, fixedSize move-only (avatar), canvas Blob output (5-arg drawImage
  for SVG safety), wheel/pan zoom, two-zone gesture (selection moves the area, the
  background pans to set focus; img draggable=false guards native DnD per zag).

New reusable soma/layers/zoom-pan: cursor-centered zoom, clamped pan, transform +
viewport-frac-to-content-frac mapping. Image-viewer / diagram-pan / map can reuse it.

sema: add 'zoom' verb to the handle family (direct manipulation, documented extension).
slider: track visible at zero (gray, not white), thumb lifts on drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 1e6a9df874
commit 7da7285d94

@ -346,7 +346,8 @@
"Bash(awk '/data-palabras-panel-card \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)", "Bash(awk '/data-palabras-panel-card \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)",
"Bash(awk '/data-palabras-panel-foot \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)", "Bash(awk '/data-palabras-panel-foot \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)",
"Bash(awk 'NR>=300 && NR<=345' src/uix/eidos/components/palabras/palabras.css)", "Bash(awk 'NR>=300 && NR<=345' src/uix/eidos/components/palabras/palabras.css)",
"Bash(awk '/\\\\[data-palabras-panel-card\\\\] \\\\{/{f=1} f{print NR\": \"$0} /^\\\\}/{if\\(f\\){f=0}}' src/uix/eidos/components/palabras/palabras.css)" "Bash(awk '/\\\\[data-palabras-panel-card\\\\] \\\\{/{f=1} f{print NR\": \"$0} /^\\\\}/{if\\(f\\){f=0}}' src/uix/eidos/components/palabras/palabras.css)",
"mcp__Claude_in_Chrome__get_page_text"
], ],
"additionalDirectories": [ "additionalDirectories": [
"\\tmp" "\\tmp"

@ -0,0 +1,99 @@
# Cropper (eidos)
The styled, **configured** image cropper — a movable / resizable selection over the
image, with a darken-outside mask, rule-of-thirds grid and four corner handles. It
composes the framework `<Image>`; the soma owns the crop rect, the drag/resize math
and the `<canvas>` extraction.
```svelte
<script>
import Cropper from '$uix/eidos/components/cropper';
let cr; let crop = $state({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });
</script>
<Cropper bind:this={cr} {src} bind:crop aspect={1} shape="round"
onCropComplete={(r) => save(r.blob)} />
<button onclick={() => cr.cropImage()}>Crop</button>
```
## Baseline
**No Air baseline.** Cropper is a new component (2026-06-10); no `air/` predecessor.
Designed fresh against the references below.
## Comparativa
| Capability | Ark image-cropper | react-image-crop | react-easy-crop | Cropper.js | → UIX |
| --- | --- | --- | --- | --- | --- |
| Selection over a fixed image | ✅ | ✅ | — (pan/zoom) | ✅ | ✅ |
| Corner handles + thirds grid | ✅ | ✅ | partial | ✅ | ✅ |
| Aspect lock | ✅ | ✅ | ✅ | ✅ | ✅ |
| Circular crop | — | ✅ | ✅ `cropShape` | partial | ✅ |
| Zoom / pan of the image | — | — | ✅ | ✅ | ✅ (reusable layer) |
| Fixed-size crop (move-only) | — | ✅ (locked) | ✅ | — | ✅ `fixedSize` |
| Blob output (canvas) | headless | you extract | helper | ✅ | ✅ built-in |
| Sound / haptic | ❌ | ❌ | ❌ | ❌ | ✅ sema |
The Ark image-cropper (the reference the user pointed to — `Root · Viewport · Image
· Selection · Grid · Handle`) is headless and emits the rect; UIX mirrors the
anatomy and adds the canvas Blob + the circular mask + the perceptual layer.
## Props
`ProviderProps` (soma) — `src` / `crop` / `aspect` / `shape` / `minSize` /
`maxSize` / `fixedSize` / `minScale` / `maxScale` / `disabled` / `onCropChange` /
`onCropComplete` (see soma README), plus the `cropImage()` method exposed via
`bind:this`.
- **`fixedSize`** pins the crop and renders **no resize handles** (move-only) —
`shape='round'` + `fixedSize` is an avatar picker. Respects `aspect` (use 1:1
for a true circle).
- **Zoom** — wheel / pinch zoom centered on the cursor, drag the background to
pan, plus −/slider/+/reset controls. `minScale`/`maxScale` bound it. Backed by
the reusable `soma/layers/zoom-pan` (`createZoomPan`).
## Decisiones
- **Composition over reimplementation.** Renders the framework `<Image>` for the
preview; never re-declares an image surface.
- **Normalized 0–1 crop rect.** Resolution-independent. The viewport's
aspect-ratio is set to the image's natural aspect (no letterbox) so the rect maps
1:1 to the image for the canvas extraction.
- **Darken-mask is a box-shadow on the Selection** (`0 0 0 9999px`), clipped by the
viewport's `overflow:hidden`. It follows `border-radius`, so a `round` selection
cuts a circular hole — one mechanism for both shapes.
- **`mask-bg` / `grid-line` are recipe tokens carrying literal rgba** (overlay
tints, not theme colors) — kept out of the component CSS to satisfy the raw-color
contract, and themeable.
- **`cropImage()` via `bind:this`** (not an auto-fired event) — the consumer
decides when to extract, the same shape as `react-image-crop`'s `getCroppedImg`.
- **`handle-drag` / `handle-resize` emit once at gesture start**, not per-frame
(no haptic buzz — the virtual-list doctrine). `handle-zoom` is throttled.
- **Zoom is a reusable layer, not cropper-local.** `soma/layers/zoom-pan`
(`createZoomPan`) owns scale + offset + clamp + cursor-centered zoom math, so an
image-viewer / diagram-pan / map can reuse it. The cropper wires wheel / pan /
controls to it and inverts the transform in `cropImage()`.
- **Two zones (Cropper.js model).** Dragging the **selection** moves the crop area
(it captures the gesture). Dragging the **background image** pans it to set the
focus point (when zoomed). The 8 handles resize. The `<img>` is `draggable=false`
(the browser's native image drag would otherwise cancel the pan — the bug Ark's
zag machine guards the same way). Background shows `grab` when zoomed; the
selection keeps `move`.
## Recipe tokens
Public, themeable — `--cropper-*`: `viewport-bg/radius`, `mask-bg`, `grid-line`,
`selection-border(-width)`, `handle-size/bg/border(-width)`.
`eidos-lint cropper` → invalid 0 (13 morfo-backed selectors). CSS imported from the
wrapper (code-split) — intentionally NOT in `eidos/index.css`.
## Gaps
| Gap | Disposition | Notes |
| --- | --- | --- |
| Keyboard move/resize/zoom of the selection | **diferir** | v1 is pointer-driven; arrows-to-move (FloatPanel grab-mode style) is a follow-up. |
| Edge handles (n/e/s/w) | **diferir** | The morfo declares all 8 corners; eidos renders the 4 corners. Edges are a quick add. |
| Aspect exactly at a boundary | **diferir** | Edge clamping may relax the locked ratio at the very edge; drag inward restores it. |
| External-CORS images taint the canvas | **diferir** | Blob URLs (the ImagePicker flow) are fine; cross-origin `<img>` needs CORS headers to export. |
| Baking ImagePicker filter/rotation into the crop | **diferir** | v1 crops the source `src`; applying the upstream filter/rotation in the canvas is a pipeline follow-up. |

@ -0,0 +1,188 @@
/**
* Cropper recipe — the crop UI chrome: framed viewport, darken-outside mask,
* rule-of-thirds grid, and corner handles. The crop geometry is inline style on
* the Selection (set by soma); the image is the composed `<Image>`.
*
* Tokens: `--cropper-*` (public, themeable) from `recipes/base.ts`.
*/
[data-cropper] {
display: block;
inline-size: 100%;
}
[data-cropper][data-disabled] {
opacity: 0.6;
pointer-events: none;
}
[data-cropper-viewport] {
position: relative;
overflow: hidden;
inline-size: 100%;
/* aspect-ratio is set inline from the image's natural size. Fallback 1:1. */
aspect-ratio: 1;
border-radius: var(--cropper-viewport-radius);
background: var(--cropper-viewport-bg);
/* The image fills the viewport; no scrolling while dragging the selection. */
touch-action: none;
user-select: none;
}
.eidos-cropper-image {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
}
/* Stop the browser's native image drag-and-drop, which would cancel the pan. */
[data-cropper-viewport] img {
-webkit-user-drag: none;
user-select: none;
}
/* The crop rectangle. The huge box-shadow darkens everything outside it
(clipped by the viewport's overflow:hidden) — it follows border-radius, so a
round selection cuts a circular hole. */
[data-cropper-selection] {
position: absolute;
box-sizing: border-box;
border: var(--cropper-selection-border-width) solid var(--cropper-selection-border);
box-shadow: 0 0 0 9999px var(--cropper-mask-bg);
cursor: move;
touch-action: none;
}
[data-cropper-selection][data-shape='round'] {
border-radius: 50%;
}
[data-cropper-selection][data-dragging] {
cursor: grabbing;
}
/* When zoomed, the background image shows a `grab` cursor (drag it to pan / set
the focus). The selection keeps `move` — it repositions the crop area. */
.eidos-cropper-viewport.is-zoomed .eidos-cropper-image {
cursor: grab;
}
.eidos-cropper-viewport.is-zoomed .eidos-cropper-image:active {
cursor: grabbing;
}
/* Rule-of-thirds — four thin lines at 1/3 and 2/3 on each axis. */
[data-cropper-grid] {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 33.33% 0 / 1px 100% no-repeat,
linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 66.66% 0 / 1px 100% no-repeat,
linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 0 33.33% / 100% 1px no-repeat,
linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 0 66.66% / 100% 1px no-repeat;
}
/* The thirds grid reads as noise inside a circular crop — hide it there. */
[data-cropper-selection][data-shape='round'] [data-cropper-grid] {
display: none;
}
[data-cropper-handle] {
position: absolute;
box-sizing: border-box;
inline-size: var(--cropper-handle-size);
block-size: var(--cropper-handle-size);
border: var(--cropper-handle-border-width) solid var(--cropper-handle-border);
border-radius: 50%;
background: var(--cropper-handle-bg);
touch-action: none;
}
[data-cropper-handle][data-corner='nw'] {
inset-block-start: 0;
inset-inline-start: 0;
transform: translate(-50%, -50%);
cursor: nwse-resize;
}
[data-cropper-handle][data-corner='ne'] {
inset-block-start: 0;
inset-inline-end: 0;
transform: translate(50%, -50%);
cursor: nesw-resize;
}
[data-cropper-handle][data-corner='se'] {
inset-block-end: 0;
inset-inline-end: 0;
transform: translate(50%, 50%);
cursor: nwse-resize;
}
[data-cropper-handle][data-corner='sw'] {
inset-block-end: 0;
inset-inline-start: 0;
transform: translate(-50%, 50%);
cursor: nesw-resize;
}
/* Edge handles — centered on each side. */
[data-cropper-handle][data-corner='n'] {
inset-block-start: 0;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
cursor: ns-resize;
}
[data-cropper-handle][data-corner='s'] {
inset-block-end: 0;
inset-inline-start: 50%;
transform: translate(-50%, 50%);
cursor: ns-resize;
}
[data-cropper-handle][data-corner='e'] {
inset-block-start: 50%;
inset-inline-end: 0;
transform: translate(50%, -50%);
cursor: ew-resize;
}
[data-cropper-handle][data-corner='w'] {
inset-block-start: 50%;
inset-inline-start: 0;
transform: translate(-50%, -50%);
cursor: ew-resize;
}
/* ── Zoom controls (− slider + reset) ───────────────────────────────── */
.eidos-cropper-zoom {
display: flex;
align-items: center;
gap: var(--cropper-zoom-gap);
margin-block-start: var(--space-2);
}
.eidos-cropper-zoom [data-slider] {
flex: 1 1 auto;
}
.eidos-cropper-zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--cropper-zoom-btn-size);
block-size: var(--cropper-zoom-btn-size);
border: 1px solid var(--cropper-zoom-btn-border);
border-radius: var(--radius-md);
background: var(--cropper-zoom-btn-bg);
color: var(--cropper-zoom-btn-fg);
font-size: var(--font-size-md);
line-height: 1;
cursor: pointer;
}
.eidos-cropper-zoom-btn:hover {
background: var(--color-surface-default);
}
.eidos-cropper-zoom-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 1px;
}

@ -0,0 +1,131 @@
<script lang="ts">
import './cropper.css';
/**
* Eidos `<Cropper>` — crop a region of an image (Ark-style selection over the
* image) with zoom/pan. Composes the framework `<Image>` for the preview and
* `<Slider>` for the zoom control; the soma owns the crop rect, the
* drag/resize/zoom math and the `<canvas>` extraction.
*
* The viewport's aspect-ratio is set to the image's natural aspect (no
* letterbox); the image carries the zoom-pan `transform`; the crop rect maps
* 1:1 to the image (inverting the transform) for the canvas extraction.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Cropper from '$soma/components/cropper';
import { Image } from '$uix/eidos/components/image';
import { Slider } from '$uix/eidos/components/slider';
import type { CropperProps, CropResult } from './types';
let {
src = null,
crop = $bindable({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 }),
...headlessProps
}: CropperProps = $props();
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const;
const eidos = ActiveEidos.require();
let providerComp = $state<{ cropImage: () => Promise<CropResult | null> } | null>(null);
/** Produce the cropped image now (also fires `onCropComplete`). Via `bind:this`. */
export function cropImage(): Promise<CropResult | null> | undefined {
return providerComp?.cropImage();
}
// Track the image natural aspect → set the viewport aspect-ratio (no letterbox).
let aspectRatio = $state<string | null>(null);
$effect(() => {
const s = src;
aspectRatio = null;
if (!s) return;
const win = eidos.dom.getWindow();
if (!win) return;
const ImageCtor = (win as unknown as { Image: { new (): HTMLImageElement } }).Image;
const img = new ImageCtor();
img.onload = () => {
if (img.naturalWidth && img.naturalHeight) {
aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
}
};
img.src = s;
});
</script>
<Cropper.Provider
bind:this={providerComp}
{...headlessProps}
{src}
bind:crop
class="eidos-cropper {headlessProps.class ?? ''}"
>
{#snippet children({ crop: rect, transform, isFixed, isZoomed, scale, minScale, maxScale, zoomTo, resetZoom })}
<Cropper.Viewport
class="eidos-cropper-viewport {isZoomed ? 'is-zoomed' : ''}"
style={aspectRatio ? `aspect-ratio: ${aspectRatio};` : undefined}
>
{#if src}
<Image
{src}
alt=""
fit="fill"
class="eidos-cropper-image"
style="transform: {transform}; transform-origin: 0 0;"
/>
{/if}
<Cropper.Selection
class="eidos-cropper-selection"
style="left: {rect.x * 100}%; top: {rect.y * 100}%; width: {rect.width *
100}%; height: {rect.height * 100}%;"
>
<Cropper.Grid class="eidos-cropper-grid" />
{#if !isFixed}
{#each HANDLES as corner (corner)}
<Cropper.Handle {corner} class="eidos-cropper-handle" />
{/each}
{/if}
</Cropper.Selection>
</Cropper.Viewport>
{#if maxScale > minScale}
<div class="eidos-cropper-zoom">
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Zoom out"
onclick={() => zoomTo(scale / 1.2)}
>
−
</button>
<Slider
value={[scale]}
min={minScale}
max={maxScale}
step={0.01}
size="sm"
aria-label="Zoom"
onValueChange={(v) => zoomTo(v[0])}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Zoom in"
onclick={() => zoomTo(scale * 1.2)}
>
+
</button>
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Reset zoom"
onclick={resetZoom}
>
⟲
</button>
</div>
{/if}
{/snippet}
</Cropper.Provider>

@ -0,0 +1,9 @@
// Cropper — a configured component: crop a region of an image with a movable /
// resizable selection, grid + corner handles. The default export IS the whole
// component (it renders the viewport / image / selection / handles internally).
//
// import Cropper from '$uix/eidos/components/cropper';
// <Cropper {src} bind:crop onCropComplete={(r) => save(r.blob)} />
export { default } from './cropper.svelte';
export type { CropperProps, CropRect, CropShape, CropCorner, CropResult } from './types';

@ -0,0 +1,16 @@
import type { ProviderProps } from '$soma/components/cropper';
/**
* Props for the eidos `<Cropper>`. The cropper is configured — it renders the
* viewport, the composed `<Image>`, the masked selection, the grid and the four
* corner handles internally. All behavior (crop rect, aspect, shape, canvas
* extraction) lives in the soma; eidos adds the visual chrome.
*/
export type CropperProps = ProviderProps;
export type {
CropRect,
CropShape,
CropCorner,
CropResult
} from '$soma/components/cropper';

@ -0,0 +1,88 @@
# ImageAdjustments (eidos)
The styled, **configured** image-filter panel. Renders one labelled `<Slider>` row
per adjustment from the `adjustments` prop — the consumer doesn't compose rows.
```svelte
<script>
import ImageAdjustments from '$uix/eidos/components/image-adjustments';
let value = $state({});
let filter = $state('none');
</script>
<ImageAdjustments bind:value onFilterChange={(f) => (filter = f)} />
<img src={src} style="filter: {filter}" />
```
## Baseline
**No Air baseline.** ImageAdjustments is a new component (2026-06-10); there is no
`air/` predecessor. Designed fresh against the references below.
## Comparativa
| Capability | Untitled UI | Chakra | Radix | MUI | → UIX |
| --- | --- | --- | --- | --- | --- |
| Filter-slider panel primitive | ✅ (image-picker) | ❌ | ❌ | ❌ | ✅ |
| Live CSS `filter` output | ⚠️ canvas | — | — | — | ✅ string |
| Composes the lib's own Slider | ✅ | n/a | n/a | n/a | ✅ |
| Configurable adjustment set | fixed | — | — | — | ✅ subset |
| Reusable outside an image picker | ❌ | — | — | — | ✅ subcomponent |
| Sound / haptic on reset | ❌ | ❌ | ❌ | ❌ | ✅ sema |
Most libraries treat image filtering as app code, not a primitive. Untitled ships
an adjustment-sliders block (the visual reference); UIX makes it a reusable
subcomponent that emits a portable `filter` string and composes the framework's
own Slider.
## Props
`ProviderProps` (soma) + layout knobs:
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `ImageAdjustmentValues` | `{}` | bindable per-adjustment values |
| `onValueChange` | `(v) => void` | — | full values record |
| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` — the real output |
| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | slider + row typography |
| `showReset` | `boolean` | `true` | render the reset control |
| `disabled` | `boolean` | `false` | disable sliders + reset |
## Decisiones
- **Composition over reimplementation.** Imports the framework `<Slider>` and
renders one per row — never reinvents a slider.
- **The `filter` string is the product.** Soma owns the value→CSS-`filter` math;
the consumer applies the streamed string to any surface.
- **`ItemValue` is a plain `<span>`, not a live region.** The Slider thumb already
announces `aria-valuenow`; a live region would double-announce.
- **The `hue` rainbow track is a recipe token** (`--image-adjustments-hue-track`),
not raw CSS — keeps the literal rainbow out of the component CSS (raw-color
contract) and makes it themeable.
- **`temperature` is a CSS approximation** (warm→sepia, cool→hue-rotate+saturate);
there is no native white-balance primitive.
## Recipe tokens
Public, themeable — `--image-adjustments-*`:
| Token | Default |
| --- | --- |
| `gap` / `row-gap` | space-3 / space-1.5 |
| `label-*` | content-secondary · sm · medium |
| `value-*` | content-tertiary · xs · mono (tabular) |
| `reset-*` | space-2 · content-secondary · xs |
| `hue-track` | the rainbow gradient |
`eidos-lint image-adjustments` → invalid 0. CSS imported from the wrapper
(code-split) — intentionally NOT in `eidos/index.css`.
## Gaps
| Gap | Disposition | Notes |
| --- | --- | --- |
| `highlights` / `shadows` tone curves | **diferir** | Need canvas / SVG `feComponentTransfer`; CSS `filter` can't express tone curves. |
| Per-adjustment reset | **descartar** | One global reset matches the reference; per-row reset adds clutter. |
| `temperature` exact white-balance | **diferir** | CSS approximation today; a true Kelvin curve needs canvas. |
| WAI-ARIA APG pattern | **diferir** | A labelled `group` of APG sliders; no single composite pattern. |

@ -0,0 +1,97 @@
/**
* ImageAdjustments recipe — layout for a stack of labelled `<Slider>` rows.
*
* Owns ONLY grouping concerns: the vertical rhythm of the rows, the
* label/value head line, the read-out typography, and the reset control. The
* sliders bring their own recipe (`slider.css`); the only slider override here
* is the `hue` row's rainbow track, keyed off the morfo's `data-adjustment`.
*
* Tokens: `--image-adjustments-*` (public, themeable) from `recipes/base.ts`.
*/
[data-image-adjustments] {
display: flex;
flex-direction: column;
gap: var(--image-adjustments-gap);
}
[data-image-adjustments][data-disabled] {
opacity: 0.55;
}
[data-image-adjustments-item] {
display: flex;
flex-direction: column;
gap: var(--image-adjustments-row-gap);
}
/* Label + live read-out on one baseline-aligned line. */
.eidos-image-adjustments-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
[data-image-adjustments-item-label] {
color: var(--image-adjustments-label-color);
font-size: var(--image-adjustments-label-font-size);
font-weight: var(--image-adjustments-label-font-weight);
line-height: var(--leading-ui);
}
[data-image-adjustments-item-value] {
color: var(--image-adjustments-value-color);
font-size: var(--image-adjustments-value-font-size);
font-family: var(--image-adjustments-value-font-family);
/* Tabular numerals so the read-out doesn't reflow while dragging. */
font-variant-numeric: tabular-nums;
}
/* Reset: a quiet text button, trailing-aligned. */
[data-image-adjustments-reset] {
align-self: flex-end;
margin-block-start: var(--image-adjustments-reset-gap);
padding: 0;
border: 0;
background: none;
color: var(--image-adjustments-reset-color);
font-size: var(--image-adjustments-reset-font-size);
font-family: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
[data-image-adjustments-reset]:hover:not([disabled]) {
color: var(--color-content-primary);
}
[data-image-adjustments-reset]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
[data-image-adjustments-reset][disabled] {
opacity: 0.4;
cursor: default;
text-decoration: none;
}
/* `hue` row: paint the slider track as a hue wheel so the control reads as a
hue picker. Keyed off the morfo's `data-adjustment` value. The range fill
goes transparent so the rainbow shows through. */
[data-image-adjustments-item][data-adjustment='hue'] [data-slider] {
--slider-track-bg: var(--image-adjustments-hue-track);
}
[data-image-adjustments-item][data-adjustment='hue'] [data-slider-range] {
background: transparent;
}
@media (forced-colors: active) {
[data-image-adjustments-reset]:focus-visible {
outline-color: Highlight;
}
}

@ -0,0 +1,75 @@
<script lang="ts">
import './image-adjustments.css';
/**
* Eidos `<ImageAdjustments>` — a labelled `<Slider>` row per image filter.
*
* The soma owns the values, the value→CSS-`filter` math, `data-adjustment`,
* and the `commit-reset` event. Eidos renders the layout: one row per
* adjustment (label + live read-out + a composed `<Slider>`) plus an optional
* reset. It composes the framework's `<Slider>` — it never reinvents one.
*
* The recomputed `filter` reaches the consumer via `onFilterChange` (apply it
* to any surface: an ImagePicker preview, an avatar, a words image, …).
*/
import { ActiveEidos } from '$uix/eidos';
import * as ImageAdjustments from '$soma/components/image-adjustments';
import { DEFAULT_IMAGE_ADJUSTMENTS } from '$soma/components/image-adjustments';
import { Slider } from '$uix/eidos/components/slider';
import type { ImageAdjustmentsProps } from './types';
let {
size = 'md',
showReset = true,
adjustments,
value = $bindable({}),
...headlessProps
}: ImageAdjustmentsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolved = $derived(adjustments ?? [...DEFAULT_IMAGE_ADJUSTMENTS]);
</script>
<ImageAdjustments.Provider
{...headlessProps}
{adjustments}
bind:value
data-size={resolvedSize}
class="eidos-image-adjustments {headlessProps.class ?? ''}"
>
{#each resolved as key (key)}
<ImageAdjustments.Item adjustment={key} class="eidos-image-adjustments-item">
{#snippet children({ label, value: v, formattedValue, def, disabled, setValue })}
<div class="eidos-image-adjustments-head">
<ImageAdjustments.ItemLabel class="eidos-image-adjustments-label">
{label}
</ImageAdjustments.ItemLabel>
<ImageAdjustments.ItemValue class="eidos-image-adjustments-value">
{formattedValue}
</ImageAdjustments.ItemValue>
</div>
<Slider
value={[v]}
min={def.min}
max={def.max}
step={def.step}
{disabled}
size={resolvedSize}
aria-label={label}
onValueChange={(next) => setValue(next[0])}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
{/snippet}
</ImageAdjustments.Item>
{/each}
{#if showReset}
<ImageAdjustments.Reset class="eidos-image-adjustments-reset">
{#snippet children({ label })}
{label}
{/snippet}
</ImageAdjustments.Reset>
{/if}
</ImageAdjustments.Provider>

@ -0,0 +1,14 @@
// ImageAdjustments — a configured component: it renders one labelled `<Slider>`
// per image filter from the `adjustments` prop. The consumer doesn't compose
// rows, so the default export IS the whole component.
//
// import ImageAdjustments from '$uix/eidos/components/image-adjustments';
// <ImageAdjustments bind:value onFilterChange={(f) => (filter = f)} />
export { default } from './image-adjustments.svelte';
export type {
ImageAdjustmentsProps,
ImageAdjustmentsSize,
ImageAdjustmentKey,
ImageAdjustmentValues
} from './types';

@ -0,0 +1,25 @@
import type { ProviderProps } from '$soma/components/image-adjustments';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/**
* Sizing subset ImageAdjustments exposes. Maps to the composed `<Slider>` size
* + row typography. Narrowed per the per-component-subset doctrine.
*/
export type ImageAdjustmentsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/**
* Props for the eidos `<ImageAdjustments>`.
*
* Extends the headless API with **layout** concerns only. The soma owns the
* values, the value→`filter` math, `data-adjustment`, and `commit-reset`; eidos
* renders one labelled `<Slider>` row per adjustment + an optional reset, and
* styles them via the `--image-adjustments-*` recipe tokens.
*/
export type ImageAdjustmentsProps = ProviderProps & {
/** Sizing scale (slider + row typography). @default 'md' */
size?: ResponsiveProp<ImageAdjustmentsSize>;
/** Render the reset control below the rows. @default true */
showReset?: boolean;
};
export type { ImageAdjustmentKey, ImageAdjustmentValues } from '$soma/components/image-adjustments';

@ -0,0 +1,105 @@
# ImagePicker (eidos)
The styled, **configured** image picker — a pure composition of three already-built
framework components: `FileUpload` (drop + click) → `Image` (preview, fit modes) +
a rotate/remove toolbar + `ImageAdjustments` (filters). The consumer uses the
configured root; the empty/ready states + the toolbar are rendered internally.
```svelte
<script>
import ImagePicker from '$uix/eidos/components/image-picker';
let file = $state(null);
</script>
<ImagePicker bind:file onChange={(v) => save(v)} />
```
## Baseline
**No Air baseline.** ImagePicker is a new component (2026-06-10); there is no
`air/` predecessor to recover. It was designed fresh against the references below,
not ported.
## Comparativa
Audited against the visual reference (Untitled UI) + the headless / file references:
| Capability | Untitled UI | Chakra | Ark UI | Radix | MUI | → UIX |
| --- | --- | --- | --- | --- | --- | --- |
| Image picker primitive | ✅ image-pickers | ❌ | ❌ | ❌ | ❌ | ✅ |
| File-upload base | ✅ | ✅ FileUpload | ✅ FileUpload | ❌ | ❌ (base-ui open issue) | ✅ reuse `file-upload` |
| Preview + fill modes | ✅ fill/fit/crop/tile | ❌ | ❌ | ❌ | ❌ | ✅ reuse `image` (cover/contain/fill) |
| 90° rotation | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Filter / effect sliders | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ reuse `ImageAdjustments` |
| Crop | ❌ (separate) | ❌ | ✅ `image-cropper` | ❌ | ❌ | hand-off (next component) |
| Sound / haptic on actions | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ sema |
No mainstream component library ships an image picker as a primitive — it's app
code over a file input. UIX composes three resolved components and emits a
cropper-ready value.
## What it composes
| State | Renders |
| --- | --- |
| empty | `<FileUpload>` (drop + click), `accept="image/*"`, single file |
| ready | `<Image>` preview + a floating rotate/remove toolbar + `<ImageAdjustments>` |
## Props
`ProviderProps` (soma) + composition knobs:
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `file` / `fit` / `rotation` / `adjustments` | — | — | bindable — see soma README |
| `onChange` | `(v: ImagePickerValue) => void` | — | cropper-ready value |
| `disabled` | `boolean` | `false` | → both FileUpload + controls |
| `accept` | `string` | `'image/*'` | → FileUpload |
| `maxSize` | `number` | — | → FileUpload |
| `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | embedded ImageAdjustments scale |
| `showAdjustments` | `boolean` | `true` | render the filters panel |
## Decisiones
- **Composition over reimplementation.** ImagePicker renders `<FileUpload>` /
`<Image>` / `<ImageAdjustments>` — it never re-declares a dropzone, an image
surface or a slider. The morfo declares only the picker's OWN orchestration.
- **`rotate` is a `handle` verb.** Spatial manipulation (with drag / resize /
scroll), even though a discrete button triggers it — that's its canonical
family. select / remove are `commit`.
- **Object-URL lives in soma.** Created on `file` change and revoked via the
effect cleanup (previous URL on replace, last on unmount). `createObjectURL`
is used directly, matching the `file-upload` / `words` precedent (it is not an
ActiveDom concern).
- **`data-fit` is reflected on the picker even though `<Image>` owns the visual
`object-fit`.** It is a picker-level state reflection (parallel to
`data-rotation` / `data-state`) so tooling and consumer CSS can read the
picker's fit from its own DOM. Chosen over removing it (which would hide the
state) and over a contrived CSS rule.
- **Rotation rotates a `.canvas` wrapper, not the toolbar.** The toolbar is a
sibling of the canvas inside the Preview, so it stays upright while the image
rotates.
## Recipe tokens
Public, themeable — `--image-picker-*` (the composed components bring their own):
| Token | Default |
| --- | --- |
| `gap` | space-3 (preview → adjustments) |
| `preview-radius` / `preview-bg` / `preview-border` / `preview-aspect` | rounded box, 4/3 |
| `toolbar-gap` / `toolbar-inset` | floating top-right |
| `button-*` | icon-button chrome (rotate / remove) |
`eidos-lint image-picker` → invalid 0 (15 morfo-backed selectors). CSS is imported
from the wrapper (code-split chunk) — intentionally NOT in `eidos/index.css`.
## Gaps
| Gap | Disposition | Notes |
| --- | --- | --- |
| Crop (region selection) | **descartar** here | Belongs to the separate Ark-style `image-cropper`; ImagePicker hands it `url`/`file`. |
| Rotation letterbox at 90°/270° | **diferir** | The preview box doesn't swap dimensions, so a non-square image letterboxes. Revisit when the cropper lands. |
| `highlights` / `shadows` tone curves | **diferir** | Need canvas / SVG `feComponentTransfer`; tracked in ImageAdjustments. |
| Multiple images / gallery | **descartar** | Single image by design (`multiple={false}`); use `file-upload` directly for multi. |
| WAI-ARIA APG pattern | **diferir** | No single APG pattern covers a file-picker composite; the dropzone is a button, the root a labelled `group`. |

@ -0,0 +1,111 @@
/**
* ImagePicker recipe — layout for the pick / preview / adjust composition.
*
* Owns ONLY the orchestration chrome: the vertical stack, the preview box (with
* the data-rotation transform), and the floating rotate/remove toolbar. The
* dropzone (FileUpload), the image (Image) and the sliders (ImageAdjustments)
* bring their own recipes.
*
* Tokens: `--image-picker-*` (public, themeable) from `recipes/base.ts`.
*/
[data-image-picker] {
display: flex;
flex-direction: column;
gap: var(--image-picker-gap);
}
[data-image-picker][data-disabled] {
opacity: 0.6;
}
[data-image-picker-preview] {
position: relative;
aspect-ratio: var(--image-picker-preview-aspect);
border-radius: var(--image-picker-preview-radius);
background: var(--image-picker-preview-bg);
border: 1px solid var(--image-picker-preview-border);
overflow: hidden;
}
/* The canvas holds the <Image> and carries the rotation transform, so the
floating toolbar (a sibling) stays upright. */
.eidos-image-picker-canvas {
position: absolute;
inset: 0;
transition: transform var(--duration-fast) var(--ease-default);
}
/* The composed <Image> fills the canvas so its object-fit has the full box to
act on. Plain `> *` (NOT `:global(*)` — this is a plain CSS file, where the
Svelte `:global()` wrapper is invalid and would drop the rule). */
.eidos-image-picker-canvas > * {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
}
[data-image-picker-preview][data-rotation='90'] .eidos-image-picker-canvas {
transform: rotate(90deg);
}
[data-image-picker-preview][data-rotation='180'] .eidos-image-picker-canvas {
transform: rotate(180deg);
}
[data-image-picker-preview][data-rotation='270'] .eidos-image-picker-canvas {
transform: rotate(270deg);
}
[data-image-picker-toolbar] {
position: absolute;
inset-block-start: var(--image-picker-toolbar-inset);
inset-inline-end: var(--image-picker-toolbar-inset);
display: flex;
gap: var(--image-picker-toolbar-gap);
}
[data-image-picker-rotate],
[data-image-picker-remove] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--image-picker-button-size);
block-size: var(--image-picker-button-size);
border: 1px solid var(--image-picker-button-border);
border-radius: var(--image-picker-button-radius);
background: var(--image-picker-button-bg);
color: var(--image-picker-button-fg);
font-size: var(--image-picker-button-font-size);
line-height: 1;
cursor: pointer;
}
[data-image-picker-rotate]:hover:not([disabled]),
[data-image-picker-remove]:hover:not([disabled]) {
background: var(--color-surface-default);
}
[data-image-picker-rotate]:focus-visible,
[data-image-picker-remove]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 2px;
}
[data-image-picker-rotate][disabled],
[data-image-picker-remove][disabled] {
opacity: 0.5;
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.eidos-image-picker-canvas {
transition: none;
}
}
@media (forced-colors: active) {
[data-image-picker-rotate]:focus-visible,
[data-image-picker-remove]:focus-visible {
outline-color: Highlight;
}
}

@ -0,0 +1,79 @@
<script lang="ts">
import './image-picker.css';
/**
* Eidos `<ImagePicker>` — pick / preview / adjust an image.
*
* Pure composition of three already-built framework components:
* - empty state → `<FileUpload>` (drop + click to select)
* - ready state → `<Image>` preview (fit modes) + a rotate/remove toolbar
* + `<ImageAdjustments>` (filter sliders)
*
* The soma provider owns the file, the object-URL lifecycle, fit, rotation
* and the composed CSS `filter`. The streamed value is cropper-ready.
*/
import * as ImagePicker from '$soma/components/image-picker';
import { FileUpload } from '$uix/eidos/components/file-upload';
import { Image } from '$uix/eidos/components/image';
import ImageAdjustments from '$uix/eidos/components/image-adjustments';
import type { ImagePickerProps } from './types';
let {
file = $bindable(null),
fit = $bindable('cover'),
rotation = $bindable(0),
adjustments = $bindable({}),
adjustmentKeys,
disabled = false,
accept = 'image/*',
maxSize,
size = 'sm',
showAdjustments = true,
...headlessProps
}: ImagePickerProps = $props();
</script>
<ImagePicker.Provider
{...headlessProps}
bind:file
bind:fit
bind:rotation
bind:adjustments
{adjustmentKeys}
{disabled}
class="eidos-image-picker {headlessProps.class ?? ''}"
>
{#snippet children({ state, url, filter, fit: previewFit, prompt, setFile })}
{#if state === 'empty'}
<FileUpload
{accept}
{maxSize}
{disabled}
multiple={false}
onFilesChange={(files) => setFile(files[0] ?? null)}
>
<FileUpload.Dropzone>
<FileUpload.HiddenInput />
<FileUpload.Trigger>{prompt}</FileUpload.Trigger>
</FileUpload.Dropzone>
</FileUpload>
{:else}
<ImagePicker.Preview class="eidos-image-picker-preview">
<div class="eidos-image-picker-canvas">
<Image
src={url}
alt=""
fit={previewFit}
style="filter: {filter === 'none' ? '' : filter};"
/>
</div>
<ImagePicker.Toolbar class="eidos-image-picker-toolbar">
<ImagePicker.Rotate class="eidos-image-picker-btn">⟳</ImagePicker.Rotate>
<ImagePicker.Remove class="eidos-image-picker-btn">✕</ImagePicker.Remove>
</ImagePicker.Toolbar>
</ImagePicker.Preview>
{#if showAdjustments}
<ImageAdjustments bind:value={adjustments} adjustments={adjustmentKeys} {size} {disabled} />
{/if}
{/if}
{/snippet}
</ImagePicker.Provider>

@ -0,0 +1,15 @@
// ImagePicker — a composition component: pick / preview / adjust an image by
// wiring together the framework's FileUpload + Image + ImageAdjustments. The
// consumer uses the configured root; the default export IS the whole component.
//
// import ImagePicker from '$uix/eidos/components/image-picker';
// <ImagePicker bind:file onChange={(v) => save(v)} />
export { default } from './image-picker.svelte';
export type {
ImagePickerProps,
ImagePickerSize,
ImagePickerFit,
ImagePickerRotation,
ImagePickerValue
} from './types';

@ -0,0 +1,31 @@
import type { ProviderProps } from '$soma/components/image-picker';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/** Sizing subset for the embedded ImageAdjustments panel. */
export type ImagePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/**
* Props for the eidos `<ImagePicker>`.
*
* Extends the headless API with composition concerns: `accept`/`maxSize` flow to
* the composed `<FileUpload>`, `size` to the composed `<ImageAdjustments>`, and
* `showAdjustments` toggles the filters panel. The soma owns the file, the
* transforms (fit/rotation) and the composed filter; eidos wires the three
* already-built components together.
*/
export type ImagePickerProps = ProviderProps & {
/** Accepted file types (native `accept` syntax) → FileUpload. @default 'image/*' */
accept?: string;
/** Max file size in bytes → FileUpload. */
maxSize?: number;
/** Scale of the embedded adjustments panel. @default 'sm' */
size?: ResponsiveProp<ImagePickerSize>;
/** Render the ImageAdjustments panel below the preview. @default true */
showAdjustments?: boolean;
};
export type {
ImagePickerFit,
ImagePickerRotation,
ImagePickerValue
} from '$soma/components/image-picker';

@ -2,22 +2,27 @@
/** /**
* Appearance popover — opened by the panel's "Editar" button. Edits THIS * Appearance popover — opened by the panel's "Editar" button. Edits THIS
* block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`. * block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`.
* A framework `<Popover>` anchored to the active block on its LEFT (opposite * It's the framework `<FloatPanel>` anchored to the active block on its LEFT
* the main panel), `align='center'` → centred on the block's mid-height; the * (opposite the main panel); a draggable free window (closes on click-outside /
* Popover owns positioning + portal + dismissal. Colours use our `<ColorPicker>`, * Esc). Its CONTENT is organised in `<Tabs>` by style group — Tipografía (only
* numbers our `<NumberField>`, alignment our `<ToggleGroup>`. * for text blocks), Colores, Disposición — Elementor-style. Colours use our
* `<ColorPicker>`, numbers our `<NumberField>`, alignment our `<ToggleGroup>`.
*/ */
import type { ProviderSnippetProps } from '$soma/components/words'; import type { ProviderSnippetProps } from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
import { ToggleGroup } from '$uix/eidos/components/toggle-group'; import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { NumberField } from '$uix/eidos/components/number-field'; import { NumberField } from '$uix/eidos/components/number-field';
import { X } from '$uix/eidos/components/icon'; import { Tabs } from '$uix/eidos/components/tabs';
import PalabrasColorRow from './palabras-color-row.svelte'; import PalabrasColorRow from './palabras-color-row.svelte';
import { Popover } from '$uix/eidos/components/popover'; import { FloatPanel } from '$uix/eidos/components/float-panel';
import { clampedBlockAnchor } from './palabras-anchor';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp. const PANEL_W = 284;
const PANEL_WIDTH = 256; const PANEL_H = 300;
// Style-group tabs. Tipografía only shows for text-bearing blocks ("la
// tipografía solo aparece en las partes que son tipográficas").
const TEXT_TYPES = new Set(['heading', 'paragraph']);
// `tab` = the user's explicit pick (null → use the default). See `activeTab`.
let tab = $state<string | null>(null);
let { let {
api, api,
@ -32,27 +37,55 @@
} = $props(); } = $props();
const loc = $derived(api.activeBlockLocation); const loc = $derived(api.activeBlockLocation);
const isText = $derived(TEXT_TYPES.has(loc?.node?.type ?? ''));
// Active tab: the user's pick if still valid ('type'/Tipografía only on text
// blocks), else the default — Tipografía for text, Colores otherwise. A derived
// (not an effect) so it's robust to `isText` settling AFTER mount: no transient
// 'color' sticks the way an effect-set value would.
const activeTab = $derived(
tab && (tab !== 'type' || isText) ? tab : isText ? 'type' : 'color'
);
// The active block element drives the Popover's `customAnchor` (placed on the // The active block's element (resolved by its stable `data-words-id`, like the
// LEFT of the block, opposite the main panel). The Popover owns positioning // main panel). FloatPanel seeds its open position from this on the LEFT.
// (flip / shift / fit) + portal + dismissal. const anchorEl = $derived.by(() => {
let anchorEl = $state<HTMLElement | null>(null);
$effect(() => {
void api.activeBlockId;
void open; void open;
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null; const idPath = api.activeBlockId;
if (!content || !idPath) return null;
const segments = String(idPath).split('/').filter(Boolean);
const localId = segments[segments.length - 1];
if (!localId) return null;
return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null;
}); });
// Clamped virtual anchor (side LEFT): keeps the appearance popover from
// flipping across the block when the left gutter is narrow. See palabras-anchor.ts. // Re-anchor to the LEFT of the block: FloatPanel seeds on the first open; when
const anchor = $derived.by(() => { // the active block changes while open, reposition next to the new block. A drag
// within one block is preserved. Cleared on close so the next open re-seeds.
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
let posBlock: string | null = null;
$effect(() => {
if (!open) {
panelPos = undefined;
posBlock = null;
return;
}
const el = anchorEl;
const win = content?.ownerDocument?.defaultView; const win = content?.ownerDocument?.defaultView;
if (!anchorEl || !win) return null; const id = api.activeBlockId;
return clampedBlockAnchor(anchorEl, win, { if (!el || !win || !id) return;
side: 'left', if (posBlock === null) {
panelWidth: PANEL_WIDTH, posBlock = id;
gap: 8, return;
padding: 12 }
}); if (id === posBlock) return;
posBlock = id;
const r = el.getBoundingClientRect();
const gap = 16;
const pad = 16;
panelPos = {
x: Math.min(Math.max(r.left - PANEL_W - gap, pad), win.innerWidth - PANEL_W - pad),
y: Math.min(Math.max(r.top + r.height / 2 - PANEL_H / 2, pad), win.innerHeight - PANEL_H - pad)
};
}); });
type Align = 'left' | 'center' | 'right' | 'justify'; type Align = 'left' | 'center' | 'right' | 'justify';
@ -109,37 +142,39 @@
]; ];
</script> </script>
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}> <FloatPanel
<Popover.Portal> {open}
<Popover.Content onOpenChange={(v) => !v && onClose()}
customAnchor={anchor as unknown as HTMLElement | null} bind:position={panelPos}
anchor={anchorEl}
side="left" side="left"
align="center" align="center"
sideOffset={8} offset={16}
collisionPadding={12} draggable
hideWhenDetached={false} closeOnInteractOutside
width={PANEL_WIDTH} variant="surface"
data-palabras-appearance color="neutral"
data-words-external-tool defaultSize={{ width: PANEL_W, height: PANEL_H }}
> >
<header data-palabras-panel-head> <FloatPanel.Content data-palabras-appearance data-words-external-tool>
<span data-palabras-panel-title>Apariencia</span> <FloatPanel.Header closable={false}>
<Button variant="ghost" size="xs" iconOnly aria-label="Cerrar" onclick={onClose}> <FloatPanel.Title>Apariencia</FloatPanel.Title>
{#snippet icon()}<X />{/snippet} </FloatPanel.Header>
</Button>
</header>
<FloatPanel.Body>
<Tabs value={activeTab} onValueChange={(v) => (tab = v)} variant="line" size="sm" scrollable>
<Tabs.List aria-label="Grupos de estilo" data-palabras-style-tabs>
{#if isText}
<Tabs.Trigger value="type">Tipografía</Tabs.Trigger>
{/if}
<Tabs.Trigger value="color">Colores</Tabs.Trigger>
<Tabs.Trigger value="layout">Disposición</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
{#if isText}
<Tabs.Content value="type">
<div data-palabras-panel-fields> <div data-palabras-panel-fields>
<PalabrasColorRow
label="Color de texto"
current={effectiveColor('color')}
onPick={(hex) => setVisual({ color: hex })}
/>
<PalabrasColorRow
label="Fondo"
current={effectiveColor('background')}
onPick={(hex) => setVisual({ background: hex })}
/>
<div data-palabras-field data-inline> <div data-palabras-field data-inline>
<span data-palabras-field-label>Tamaño (px)</span> <span data-palabras-field-label>Tamaño (px)</span>
<NumberField <NumberField
@ -165,6 +200,27 @@
<NumberField.Input /> <NumberField.Input />
</NumberField> </NumberField>
</div> </div>
</div>
</Tabs.Content>
{/if}
<Tabs.Content value="color">
<div data-palabras-panel-fields>
<PalabrasColorRow
label="Color de texto"
current={effectiveColor('color')}
onPick={(hex) => setVisual({ color: hex })}
/>
<PalabrasColorRow
label="Fondo"
current={effectiveColor('background')}
onPick={(hex) => setVisual({ background: hex })}
/>
</div>
</Tabs.Content>
<Tabs.Content value="layout">
<div data-palabras-panel-fields>
<div data-palabras-field> <div data-palabras-field>
<span data-palabras-field-label>Alineación</span> <span data-palabras-field-label>Alineación</span>
<ToggleGroup <ToggleGroup
@ -182,6 +238,8 @@
</ToggleGroup> </ToggleGroup>
</div> </div>
</div> </div>
</Popover.Content> </Tabs.Content>
</Popover.Portal> </Tabs>
</Popover> </FloatPanel.Body>
</FloatPanel.Content>
</FloatPanel>

@ -1,51 +1,44 @@
<script lang="ts"> <script lang="ts">
/** /**
* Generated block panel — a floating inspector built from the block's schema * Generated block panel — the framework `<FloatPanel>` anchored to the active
* (`PALABRAS_PANEL_REGISTRY`). It's a framework `<Popover>` anchored to the * block (opens to its right, centred on its mid-height; draggable from the
* active block (`customAnchor`, `side='right'` + `align='center'` → centred on * header; closes on click-outside / Esc). Its body is THREE fixed tabs —
* the block's mid-height); the Popover owns positioning (flip / shift / fit), * `Contenido · Diseño · Avanzado` (open on Contenido) — whose fields come from
* the portal and dismissal. Field edits write the active block's prop via the * the block's schema (`PALABRAS_PANEL_REGISTRY`). Height fits the content up to
* path-aware `updateBlockAtPath` command. * a max (then the body scrolls).
* *
* Controls are FRAMEWORK components (coherence): `ToggleGroup` (pills), * Field edits write the active block via `updateBlockAtPath` (structural) or
* `Switch` (toggle), `NumberField` (number), `Slider` (slider — photo * `setBlockVisualAtPath` (style, `field.visual`). The `textarea` field edits
* adjustments), `CssField` (length — unit-aware, px/%/auto), native input * the block's TEXT (its inline `children`). Controls are FRAMEWORK components.
* (text). Collapsible groups use the framework
* `Accordion` (animated). PINNED sections (url / alt / caption) sit above the
* accordion, always visible. The header shows the block's DEFINING icon.
* Control DEFAULTS come from the field schema (`field.default`).
*/ */
import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words'; import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words';
import { CssField } from '$uix/eidos/components/css-field'; import { CssField } from '$uix/eidos/components/css-field';
import { Switch } from '$uix/eidos/components/switch'; import { Switch } from '$uix/eidos/components/switch';
import { Slider } from '$uix/eidos/components/slider'; import { Slider } from '$uix/eidos/components/slider';
import { NumberField } from '$uix/eidos/components/number-field'; import { NumberField } from '$uix/eidos/components/number-field';
import { Accordion } from '$uix/eidos/components/accordion'; import { Tabs } from '$uix/eidos/components/tabs';
import { TextArea } from '$uix/eidos/components/textarea';
import { Field } from '$uix/eidos/components/field';
import { Button } from '$uix/eidos/components/button'; import { Button } from '$uix/eidos/components/button';
import { Trash2, Upload } from '$uix/eidos/components/icon'; import { Trash2, Upload } from '$uix/eidos/components/icon';
import { getPanelSchema } from './panel-schema'; import { getPanelSchema } from './panel-schema';
import { blockTypeIcon } from './palabras-block-types'; import { blockTypeIcon } from './palabras-block-types';
import PalabrasSegmented from './palabras-segmented.svelte'; import PalabrasSegmented from './palabras-segmented.svelte';
import PalabrasColorRow from './palabras-color-row.svelte';
import { pickImageFile } from '$uix/eidos/components/words/words-image-file'; import { pickImageFile } from '$uix/eidos/components/words/words-image-file';
import { Popover } from '$uix/eidos/components/popover'; import { FloatPanel } from '$uix/eidos/components/float-panel';
import { deleteBlockAndReanchor } from './palabras-block-actions'; import { deleteBlockAndReanchor } from './palabras-block-actions';
import { clampedBlockAnchor } from './palabras-anchor';
import type { PalabrasFieldDef } from './types'; import type { PalabrasFieldDef } from './types';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
const PANEL_WIDTH = 272;
let { let {
api, api,
content, content,
open, open,
onEditAppearance,
onClose onClose
}: { }: {
api: ProviderSnippetProps; api: ProviderSnippetProps;
content: HTMLElement | null; content: HTMLElement | null;
open: boolean; open: boolean;
onEditAppearance: () => void;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
@ -55,61 +48,88 @@
const HeadIcon = $derived(blockTypeIcon(loc?.node.type)); const HeadIcon = $derived(blockTypeIcon(loc?.node.type));
const canDelete = $derived(!!loc && loc.path.length === 1); const canDelete = $derived(!!loc && loc.path.length === 1);
// The active block element drives the Popover's `customAnchor`. The Popover // The active block's ELEMENT — resolved by its STABLE `data-words-id` (the same
// owns positioning (flip / shift / fit), the portal + dismissal. // way WordsActivate stamps the active marker). `api.activeBlockId` is a
// Reactive to `activeBlockId` so the panel re-anchors when another block opens. // materialized path (`cols/col/child`); the element carries only its LAST
let anchorEl = $state<HTMLElement | null>(null); // segment as `data-words-id`. FloatPanel seeds its open position from this
$effect(() => { // (`side='right'` + `align='center'` → right of the block, centred on its
// Re-resolve on `open` too: the block may already be active (marker stamped) // mid-height, viewport-clamped).
// before the panel opens, so `activeBlockId` doesn't change to re-trigger us. const anchorEl = $derived.by(() => {
void api.activeBlockId;
void open; void open;
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null; const idPath = api.activeBlockId;
if (!content || !idPath) return null;
const segments = String(idPath).split('/').filter(Boolean);
const localId = segments[segments.length - 1];
if (!localId) return null;
return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null;
}); });
// A CLAMPED virtual anchor instead of the bare block: keeps the panel to the
// RIGHT and stops it flipping over the doc sidebar when the gutter is narrow // FloatPanel is a FREE window once open (drag + resize). FloatPanel seeds the
// (it slides inward to overlap the content's edge). `align='center'` centres // position from `anchor` on the first open; when the active block CHANGES while
// it on the block's mid-height. See palabras-anchor.ts for the why + the cast. // the panel stays open, re-anchor next to the new block (right, centred on its
const anchor = $derived.by(() => { // mid-height, viewport-clamped) so the panel follows the selection. A drag
// within one block is preserved — we only reposition when the block id changes.
// Cleared on close so the next open re-seeds from the anchor.
const PANEL_W = 288;
const PANEL_H = 380;
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
let posBlock: string | null = null;
$effect(() => {
if (!open) {
panelPos = undefined;
posBlock = null;
return;
}
const el = anchorEl;
const win = content?.ownerDocument?.defaultView; const win = content?.ownerDocument?.defaultView;
if (!anchorEl || !win) return null; const id = api.activeBlockId;
return clampedBlockAnchor(anchorEl, win, { if (!el || !win || !id) return;
side: 'right', if (posBlock === null) {
panelWidth: PANEL_WIDTH, posBlock = id; // first open — FloatPanel's own anchor seed positions it
gap: 8, return;
padding: 12 }
}); if (id === posBlock) return; // same block — don't fight a drag
posBlock = id;
const r = el.getBoundingClientRect();
const gap = 16;
const pad = 16;
panelPos = {
x: Math.min(Math.max(r.right + gap, pad), win.innerWidth - PANEL_W - pad),
y: Math.min(Math.max(r.top + r.height / 2 - PANEL_H / 2, pad), win.innerHeight - PANEL_H - pad)
};
}); });
const pinnedSections = $derived((schema?.sections ?? []).filter((s) => s.pinned)); // Three fixed tabs (Contenido open by default). Their fields come from the
const accordionSections = $derived((schema?.sections ?? []).filter((s) => !s.pinned)); // block's schema; Diseño / Avanzado may be empty for now.
const contenidoFields = $derived(schema?.contenido ?? []);
const disenoFields = $derived(schema?.diseño ?? []);
const avanzadoFields = $derived(schema?.avanzado ?? []);
let tab = $state('contenido');
// Open accordion items (section titles). Seeded per schema: `defaultCollapsed` /** Plain text of the block's inline `children` — for the Texto textarea. */
// groups start closed, the rest open. Re-seeded when the block TYPE changes. function blockText(): string {
let openSections = $state<string[]>([]); const ch = (loc?.node as { children?: readonly unknown[] } | undefined)?.children;
let seededFor: unknown; if (!Array.isArray(ch)) return '';
$effect(() => { return ch
const s = schema; .map((n) => {
if (s === seededFor) return; const node = n as { type?: string; text?: string; children?: { text?: string }[] };
seededFor = s; if (node.type === 'text') return node.text ?? '';
openSections = (s?.sections ?? []) if (node.type === 'link' && Array.isArray(node.children))
.filter((sec) => !sec.pinned && !sec.defaultCollapsed) return node.children.map((t) => t.text ?? '').join('');
.map((sec) => sec.title); return '';
})
.join('');
}
/** Write the block's text — replaces the inline `children` with one text node. */
function writeBlockText(value: string) {
const path = loc?.path;
if (!path) return;
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: path,
patch: { children: [{ type: 'text', text: value }] }
}); });
}
const VISUAL_KEYS = [
'color',
'background',
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight',
'align',
'margin',
'padding',
'border'
] as const;
const hasOverride = $derived(!!loc && VISUAL_KEYS.some((k) => loc?.node?.[k] != null));
/** Raw stored value of a prop, or undefined. */ /** Raw stored value of a prop, or undefined. */
function valueOf(key: string): unknown { function valueOf(key: string): unknown {
@ -135,14 +155,39 @@
if (!path) return; if (!path) return;
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [key]: value } }); api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [key]: value } });
} }
/** Write a field's value, routing by `field.visual`: a STYLE override goes to
* `setBlockVisualAtPath` (the engine's STYLE_KEYS), a STRUCTURAL prop to
* `updateBlockAtPath`. The single panel now hosts both kinds. */
function commit(field: PalabrasFieldDef, value: string | number | boolean | undefined) {
const path = loc?.path;
if (!path) return;
if (field.visual) {
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: { [field.key]: value } });
} else {
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [field.key]: value } });
}
}
function activeEl(): HTMLElement | null {
return (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
}
/** Display colour for a `color` field: the stored override hex, else the block's
* COMPUTED colour (so the swatch reflects the effective value, like Apariencia did). */
function effectiveColor(field: PalabrasFieldDef): string | undefined {
const o = loc?.node?.[field.key];
if (typeof o === 'string') return o;
const el = activeEl();
if (!el) return undefined;
const cs = getComputedStyle(el);
return field.key === 'background' ? cs.backgroundColor : cs.color;
}
function onPills(field: PalabrasFieldDef, v: string | undefined) { function onPills(field: PalabrasFieldDef, v: string | undefined) {
// Deselecting (re-click the active item) clears the prop → restores the default. // Deselecting (re-click the active item) clears the prop → restores the default.
if (v == null) { if (v == null) {
write(field.key, undefined); commit(field, undefined);
return; return;
} }
const opt = field.options?.find((o) => String(o.value) === v); const opt = field.options?.find((o) => String(o.value) === v);
if (opt) write(field.key, opt.value); if (opt) commit(field, opt.value);
} }
function onNumberChange(field: PalabrasFieldDef, v: number | undefined) { function onNumberChange(field: PalabrasFieldDef, v: number | undefined) {
// clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store // clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store
@ -151,7 +196,7 @@
if (v == null || !Number.isFinite(v)) out = undefined; if (v == null || !Number.isFinite(v)) out = undefined;
else if (field.clearWhenZero && v <= 0) out = undefined; else if (field.clearWhenZero && v <= 0) out = undefined;
else out = v; else out = v;
write(field.key, out); commit(field, out);
} }
function onUpload() { function onUpload() {
const doc = content?.ownerDocument ?? (typeof document !== 'undefined' ? document : null); const doc = content?.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
@ -219,7 +264,7 @@
size="xs" size="xs"
{disabled} {disabled}
checked={!!effective(field)} checked={!!effective(field)}
onCheckedChange={(c) => write(field.key, c)} onCheckedChange={(c) => commit(field, c)}
/> />
</div> </div>
{:else if field.type === 'number'} {:else if field.type === 'number'}
@ -252,7 +297,7 @@
min={field.min ?? 0} min={field.min ?? 0}
max={field.max ?? 100} max={field.max ?? 100}
step={field.step ?? 1} step={field.step ?? 1}
onValueChange={(v) => write(field.key, v[0])} onValueChange={(v) => commit(field, v[0])}
aria-label={field.label} aria-label={field.label}
> >
<Slider.Range /> <Slider.Range />
@ -272,27 +317,45 @@
max={field.max} max={field.max}
step={field.step} step={field.step}
{disabled} {disabled}
onValueChange={(v) => write(field.key, v)} onValueChange={(v) => commit(field, v)}
> >
<CssField.DecrementTrigger>−</CssField.DecrementTrigger> <CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder={field.placeholder ?? 'auto'} /> <CssField.Input placeholder={field.placeholder ?? 'auto'} />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger> <CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField> </CssField>
</div> </div>
{:else if field.type === 'textarea'}
<div data-palabras-field>
<span data-palabras-field-label>{field.label}</span>
<TextArea
size="sm"
value={blockText()}
onValueChange={(v) => writeBlockText(v)}
autosize
minRows={2}
maxRows={6}
>
<TextArea.Input placeholder={field.placeholder} />
</TextArea>
</div>
{:else if field.type === 'text'} {:else if field.type === 'text'}
<div data-palabras-field data-inline={field.wide ? undefined : ''}> <div data-palabras-field>
<span data-palabras-field-label>{field.label}</span> <span data-palabras-field-label>{field.label}</span>
{#if field.upload} <Field size="sm" variant="surface">
<span data-palabras-src-group> <Field.Control>
<input <Field.Input
data-palabras-text
data-wide
type="text" type="text"
value={String(valueOf(field.key) ?? '')} value={String(valueOf(field.key) ?? '')}
placeholder={field.placeholder ?? '#…'} placeholder={field.placeholder}
oninput={(e) => e.currentTarget.removeAttribute('data-invalid')} oninput={field.upload
onchange={(e) => onSrcCommit(field, e.currentTarget)} ? (e) => e.currentTarget.removeAttribute('data-invalid')
: (e) => commit(field, e.currentTarget.value)}
onchange={field.upload
? (e) => onSrcCommit(field, e.currentTarget)
: undefined}
/> />
{#if field.upload}
<Field.Suffix>
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
@ -303,93 +366,105 @@
> >
{#snippet icon()}<Upload />{/snippet} {#snippet icon()}<Upload />{/snippet}
</Button> </Button>
</span> </Field.Suffix>
{:else}
<input
data-palabras-text
data-wide={field.wide ? '' : undefined}
type="text"
value={String(valueOf(field.key) ?? '')}
placeholder={field.placeholder ?? '#…'}
oninput={(e) => write(field.key, e.currentTarget.value)}
/>
{/if} {/if}
</Field.Control>
</Field>
</div> </div>
{:else if field.type === 'color'}
<PalabrasColorRow
label={field.label}
current={effectiveColor(field)}
onPick={(hex) => commit(field, hex)}
/>
{/if} {/if}
{/snippet} {/snippet}
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}> <FloatPanel
<Popover.Portal> {open}
<Popover.Content onOpenChange={(v) => !v && onClose()}
customAnchor={anchor as unknown as HTMLElement | null} bind:position={panelPos}
anchor={anchorEl}
side="right" side="right"
align="center" align="center"
sideOffset={8} offset={16}
collisionPadding={12} draggable
hideWhenDetached={false} closeOnInteractOutside
width={PANEL_WIDTH} variant="surface"
data-palabras-panel-card color="neutral"
data-words-external-tool defaultSize={{ width: PANEL_W, height: PANEL_H }}
> >
<FloatPanel.Content data-palabras-panel data-words-external-tool>
{#if loc && schema} {#if loc && schema}
<header data-palabras-panel-head> <FloatPanel.Header closable={false}>
<span data-palabras-panel-head-main>
<span data-palabras-panel-icon style:color={schema.dotToken}><HeadIcon /></span> <span data-palabras-panel-icon style:color={schema.dotToken}><HeadIcon /></span>
<span data-palabras-panel-title>{schema.label}</span> <FloatPanel.Title>{schema.label}</FloatPanel.Title>
<Button </span>
variant="ghost" <FloatPanel.Controls>
size="xs" <FloatPanel.Action
iconOnly
aria-label="Eliminar bloque" aria-label="Eliminar bloque"
disabled={!canDelete} disabled={!canDelete}
onclick={deleteActive} onclick={deleteActive}
> >
{#snippet icon()}<Trash2 />{/snippet} <Trash2 />
</Button> </FloatPanel.Action>
</header> </FloatPanel.Controls>
</FloatPanel.Header>
<div data-palabras-panel-body> <FloatPanel.Body>
{#each pinnedSections as section (section.title)} <Tabs
value={tab}
onValueChange={(v) => (tab = v)}
variant="line"
size="sm"
fitted
data-palabras-tabs
>
<Tabs.List aria-label="Secciones del bloque">
<Tabs.Trigger value="contenido">Contenido</Tabs.Trigger>
<Tabs.Trigger value="diseño">Diseño</Tabs.Trigger>
<Tabs.Trigger value="avanzado">Avanzado</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="contenido">
<div data-palabras-panel-fields> <div data-palabras-panel-fields>
{#each section.fields as field (field.key)} {#each contenidoFields as field (field.key)}
{@render fieldControl(field)} {@render fieldControl(field)}
{/each} {/each}
</div> </div>
{/each} </Tabs.Content>
{#if accordionSections.length} <Tabs.Content value="diseño">
<Accordion
type="single"
collapsible
bind:value={openSections}
variant="ghost"
size="sm"
data-palabras-panel-accordion
>
{#each accordionSections as section (section.title)}
<Accordion.Item value={section.title}>
<Accordion.Header>
<Accordion.Trigger>{section.title}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-palabras-panel-fields> <div data-palabras-panel-fields>
{#each section.fields as field (field.key)} {#if disenoFields.length}
{#each disenoFields as field (field.key)}
{@render fieldControl(field)} {@render fieldControl(field)}
{/each} {/each}
{:else}
<p data-palabras-tab-empty>Pendiente</p>
{/if}
</div> </div>
</Accordion.Content> </Tabs.Content>
</Accordion.Item>
<Tabs.Content value="avanzado">
<div data-palabras-panel-fields>
{#if avanzadoFields.length}
{#each avanzadoFields as field (field.key)}
{@render fieldControl(field)}
{/each} {/each}
</Accordion> {:else}
<p data-palabras-tab-empty>Pendiente</p>
{/if} {/if}
</div> </div>
</Tabs.Content>
<footer data-palabras-panel-foot> </Tabs>
<span>Apariencia · {hasOverride ? 'personalizada' : 'heredada'}</span> </FloatPanel.Body>
<button type="button" data-palabras-panel-edit onclick={onEditAppearance}>Editar</button>
</footer>
{:else} {:else}
<FloatPanel.Body>
<p data-palabras-panel-empty>Selecciona un bloque</p> <p data-palabras-panel-empty>Selecciona un bloque</p>
</FloatPanel.Body>
{/if} {/if}
</Popover.Content> </FloatPanel.Content>
</Popover.Portal> </FloatPanel>
</Popover>

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
/** /**
* Reusable segmented control for the palabras panel — a single-select * Reusable segmented control for the palabras panel — a single-select
* `ToggleGroup` with the demo's appearance (`outline` + `attached`, `xs`). * `ToggleGroup` with the demo's appearance (`solid` + `attached`, `sm`).
* Each option renders as an ICON (icon-only, the label becomes the accessible * Each option renders as an ICON (icon-only, the label becomes the accessible
* name + tooltip) when it carries an `icon`, otherwise as its text label. * name + tooltip) when it carries an `icon`, otherwise as its text label.
* *

@ -365,42 +365,105 @@
} }
/* ── Floating inspector panel ─────────────────────────────────────────────── */ /* ── Floating inspector panel ─────────────────────────────────────────────── */
/* The floating surface IS a framework `<Popover.Content>` — positioning (side / /* The floating surface IS the framework `<FloatPanel>` — positioning (anchor →
align / flip / shift / fit-to-viewport), the portal, dismissal AND the surface side / align, viewport-clamped on open), drag, resize, stacking, focus,
(border / bg / shadow / max-height) all come from the popover recipe. Here we dismissal AND the surface (border / bg / shadow / flex column with a pinned
only RE-SHAPE the content's inner layout into a flex column with a pinned header + scrolling body) all come from FloatPanel. Here we only force the UI
header, a scrollable body and a pinned footer — overriding the popover recipe's sans across the panel and style the palabras-specific bits (header icon group,
`display: grid` / padding / gap / `overflow: auto`. Doubled attribute (0,2,0) footer, fields). */
beats `[data-popover-content]` (0,1,0) regardless of stylesheet load order. */ /* Force the UI SANS across the whole panel + every descendant — the framework
[data-palabras-panel-card][data-palabras-panel-card] {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
overflow: hidden;
font-family: var(--font-ui);
}
/* Force the UI SANS across the whole panel + every descendant. The framework
Accordion's content archetype maps to the READING serif (Lora), which would Accordion's content archetype maps to the READING serif (Lora), which would
leak into the field labels. A DOUBLED scope attribute = (0,2,0) beats any otherwise leak into the field labels. A DOUBLED scope attribute = (0,2,0) wins
single-attribute component font rule regardless of code-split load order — regardless of code-split load order — same technique as Words' chrome guard. */
same technique as Words' chrome typeface guard. */ [data-palabras-panel][data-palabras-panel],
[data-palabras-panel-card][data-palabras-panel-card], [data-palabras-panel][data-palabras-panel] * {
[data-palabras-panel-card][data-palabras-panel-card] * {
font-family: var(--font-ui); font-family: var(--font-ui);
} }
/* Header: block-defining icon · title (grows) · trash */ /* Height: fit the content between a MIN and a MAX, then the body scrolls.
[data-palabras-panel-head] { FloatPanel writes an inline FIXED height on the content element; override it to
flex: 0 0 auto; `auto` (+ a floor + a cap) so the panel is as tall as its content. */
display: flex; [data-palabras-panel][data-palabras-panel] {
height: auto !important;
min-height: 400px;
max-height: min(85vh, 40rem);
}
/* The 3 tabs (Contenido / Diseño / Avanzado) fill the body width. Their content
has NO padding of its own (the body already insets horizontally); the fields
sit just under the tab bar with a small gap. */
[data-palabras-tabs] {
inline-size: 100%;
}
[data-palabras-tabs] [data-tabs-content] {
padding: 0;
}
[data-palabras-tabs] [data-palabras-panel-fields] {
margin-block-start: var(--space-2);
}
[data-palabras-tab-empty] {
margin: 0;
padding: var(--space-2) 0;
font-size: var(--font-size-sm);
color: var(--color-content-muted);
}
/* Toggle pills (Nivel): the ToggleGroup recipe renders `xs` at 10px, but the
theme's `--font-size-xs` is 12px (10px is `xxs`). Force the correct 12px. */
[data-palabras-panel][data-palabras-panel] [data-toggle-group-item] {
font-size: var(--font-size-xs);
}
/* Both panel text inputs — the multi-line `TextArea` and the single-line `Field` —
must read the SAME. The TextArea recipe now consumes real tokens (it hovers to
`--color-border-strong` and focuses to `--color-focus-ring`, exactly like the
Field), so the rest / hover / focus border COLOURS already match natively. Here we
only harmonise what still differs between the two recipes: background, font, the
Field's lighter rest border, and the focus-ring formula. */
/* Resting parity: transparent background + `sm` font on both. */
[data-palabras-panel] [data-textarea-input],
[data-palabras-panel] [data-field-control] {
background: transparent;
}
/* The Field rests on a lighter `--field-control-border` than the TextArea's
`--color-neutral-border`; align it (rest token only — the focus rule overrides
`border-color` directly, so this never fights the focus state). */
[data-palabras-panel] [data-field] {
--_field-control-border: var(--color-neutral-border);
}
[data-palabras-panel] [data-textarea-input],
[data-palabras-panel] [data-field] input {
font-size: var(--font-size-sm);
}
/* Identical focus ring on both — the recipes ship different ring formulas, so unify
it here. Each rule uses its component's OWN focus selector, which already outranks
the recipe (no `!important` needed). The active border colour comes from each recipe
natively — both now resolve to `--color-focus-ring`. */
[data-palabras-panel] [data-textarea][data-focused] [data-textarea-input],
[data-palabras-panel] [data-field-control]:focus-within {
box-shadow:
0 0 0 var(--focus-ring-offset) var(--color-surface-default),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
}
/* Both recipes paint the HOVER border at a HIGHER specificity than the FOCUS border,
so focus-while-hovering would show the hover grey instead of the active colour.
Re-assert the focus border above each recipe's hover rule (specificity only — no
`!important`): TextArea hover is (0,4,0) → doubled panel attr wins at (0,5,0); Field
hover is (0,5,0) → quadrupled panel attr wins at (0,6,0). Focus is now ALWAYS the
purple active colour, hovering or not. */
[data-palabras-panel][data-palabras-panel] [data-textarea][data-focused] [data-textarea-input],
[data-palabras-panel][data-palabras-panel][data-palabras-panel][data-palabras-panel] [data-field-control]:focus-within {
border-color: var(--color-focus-ring);
}
/* Header — the FloatPanel.Header is the drag handle (grab cursor + accent rule).
This is the icon + title group on its leading edge (Controls sit at the trailing
edge via the header's space-between). */
[data-palabras-panel-head-main] {
display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-4) var(--space-4) var(--space-3); min-inline-size: 0;
/* The header is the drag handle (PalabrasPanel). */
cursor: move;
user-select: none;
touch-action: none;
} }
/* Body — header + footer stay pinned, this holds the scrollable region. It CLIPS /* Body — header + footer stay pinned, this holds the scrollable region. It CLIPS
(overflow hidden) rather than scrolls: the panel is compact (single open (overflow hidden) rather than scrolls: the panel is compact (single open
@ -605,6 +668,22 @@
pointer-events: none; pointer-events: none;
} }
/* Content typography — one scale up (xs → sm) for the BODY text: field labels,
values, inputs and section titles. The header (Title) and footer keep their
chrome sizes. Scoped under both panels so it beats the base field rules. */
:is([data-palabras-panel], [data-palabras-appearance])
:is(
[data-palabras-field-label],
[data-palabras-field-todo],
[data-palabras-text],
[data-palabras-number-input],
[data-palabras-number-unit],
[data-palabras-slider-value],
[data-palabras-panel-section-title]
) {
font-size: var(--font-size-sm);
}
/* Footer: themed-appearance line */ /* Footer: themed-appearance line */
/* Footer — pinned band at the card bottom (full width, muted surface, rounded /* Footer — pinned band at the card bottom (full width, muted surface, rounded
bottom corners). A flex child of the card, so it always spans edge-to-edge bottom corners). A flex child of the card, so it always spans edge-to-edge

@ -19,7 +19,6 @@
import { Flex } from '$uix/eidos/components/flex'; import { Flex } from '$uix/eidos/components/flex';
import WordsActivate from '$uix/eidos/components/words/words-activate.svelte'; import WordsActivate from '$uix/eidos/components/words/words-activate.svelte';
import WordsBlockDrag from '$uix/eidos/components/words/words-block-drag.svelte'; import WordsBlockDrag from '$uix/eidos/components/words/words-block-drag.svelte';
import PalabrasAppearance from './palabras-appearance.svelte';
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte'; import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
import PalabrasColumnInserter from './palabras-column-inserter.svelte'; import PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte'; import PalabrasHandle from './palabras-handle.svelte';
@ -54,9 +53,6 @@
// The properties panel is TRIGGERED (block grip) and dismissed (Esc / // The properties panel is TRIGGERED (block grip) and dismissed (Esc /
// click-away) — hidden by default so the editor is a clean full-width surface. // click-away) — hidden by default so the editor is a clean full-width surface.
let panelOpen = $state(false); let panelOpen = $state(false);
// The appearance editor (block.visual override) is a second popover opened
// from the panel's "Editar" button.
let appearanceOpen = $state(false);
</script> </script>
{#if mounted} {#if mounted}
@ -89,11 +85,9 @@
onOpen={(id) => { onOpen={(id) => {
api.setActiveBlock(id); api.setActiveBlock(id);
panelOpen = true; panelOpen = true;
appearanceOpen = false;
}} }}
onClose={() => { onClose={() => {
panelOpen = false; panelOpen = false;
appearanceOpen = false;
}} }}
/> />
{/if} {/if}
@ -111,7 +105,6 @@
// its interaction, so a click opens it. // its interaction, so a click opens it.
if (type === 'image') { if (type === 'image') {
panelOpen = true; panelOpen = true;
appearanceOpen = false;
} }
}} }}
/> />
@ -127,29 +120,20 @@
</div> </div>
</div> </div>
</Card> </Card>
<!-- Panel + appearance are floating <Popover>s anchored to the active <!-- The SINGLE properties panel — a floating <FloatPanel> anchored to
block; they own their own show/hide via `open`, the portal, and the active block. Structure + style live in ONE panel (Tipografía
dismissal (click-outside / Esc). No manual nav layer needed. --> direct for text, the rest in the accordion). Owns its show/hide,
portal and dismissal (click-outside / Esc). -->
<PalabrasPanel <PalabrasPanel
{api} {api}
content={contentEl} content={contentEl}
open={panelOpen} open={panelOpen}
onEditAppearance={() => (appearanceOpen = true)} onClose={() => (panelOpen = false)}
onClose={() => {
panelOpen = false;
appearanceOpen = false;
}}
/>
<PalabrasAppearance
{api}
content={contentEl}
open={appearanceOpen}
onClose={() => (appearanceOpen = false)}
/> />
</div> </div>
<p data-palabras-help> <p data-palabras-help>
haz clic en un bloque y pulsa su tirador <kbd>⋮</kbd> para ver sus propiedades · haz clic en un bloque y pulsa su tirador <kbd>⋮</kbd> para ver sus propiedades ·
<kbd>Esc</kbd> o clic fuera para cerrar arrastra la cabecera para moverlo · clic fuera o <kbd>Esc</kbd> para cerrar
</p> </p>
</Flex> </Flex>
{/snippet} {/snippet}

@ -3,40 +3,36 @@
* *
* Keyed by block `type`. This is a SIDECAR to the engine's block registry: the * Keyed by block `type`. This is a SIDECAR to the engine's block registry: the
* engine spec stays pure (render / validate / serialize), palabras (eidos) owns * engine spec stays pure (render / validate / serialize), palabras (eidos) owns
* the panel presentation. Adding a block's panel is one entry here — the panel * the panel presentation.
* + breadcrumb never switch on a concrete type.
* *
* Pilot scope: `heading` (the working semantic field) + `paragraph` (header * Model: every panel has THREE fixed tabs — `Contenido · Diseño · Avanzado`
* only). The rest land once the architecture is verified end-to-end. * (open on Contenido). Each block type declares the fields of each tab. Adding a
* block's panel is one entry here.
*
* Pilot scope: `heading` Contenido tab (Texto / Nivel / Enlace). Diseño +
* Avanzado, and the other block types, land incrementally.
*/ */
import { TextAlignStart, TextAlignCenter, TextAlignEnd } from '$uix/eidos/components/icon';
import type { PalabrasBlockSchema, PalabrasPanelRegistry } from './types'; import type { PalabrasBlockSchema, PalabrasPanelRegistry } from './types';
const heading: PalabrasBlockSchema = { const heading: PalabrasBlockSchema = {
type: 'heading', type: 'heading',
label: 'Encabezado', label: 'Encabezado',
dotToken: 'var(--color-primary-solid)', dotToken: 'var(--color-primary-solid)',
sections: [ contenido: [
{
title: 'QUÉ ES',
pinned: true,
fields: [
{ {
key: 'level', key: 'level',
type: 'pills', type: 'pills',
label: 'Nivel estructural', label: 'Nivel',
options: [ options: [
{ value: 1, label: 'Sección' }, { value: 1, label: 'Sección' },
{ value: 2, label: 'Subsección' }, { value: 2, label: 'Subsección' },
{ value: 3, label: 'Apartado' } { value: 3, label: 'Apartado' }
] ]
}, },
{ key: 'index', type: 'toggle', label: 'Aparece en el índice' }, // `textarea` edits the block's TEXT (its inline `children`), not a prop.
{ key: 'fold', type: 'toggle', label: 'Plegable' }, { key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del encabezado' },
{ key: 'anchor', type: 'text', label: 'Ancla de enlace' } { key: 'link', type: 'text', label: 'Enlace', placeholder: 'https://…', wide: true }
]
}
] ]
}; };
@ -44,19 +40,16 @@ const paragraph: PalabrasBlockSchema = {
type: 'paragraph', type: 'paragraph',
label: 'Párrafo', label: 'Párrafo',
dotToken: 'var(--color-content-muted)', dotToken: 'var(--color-content-muted)',
sections: [] contenido: [
{ key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del párrafo' }
]
}; };
const image: PalabrasBlockSchema = { const image: PalabrasBlockSchema = {
type: 'image', type: 'image',
label: 'Imagen', label: 'Imagen',
dotToken: 'var(--color-secondary-solid)', dotToken: 'var(--color-secondary-solid)',
sections: [ contenido: [
// Pinned header — always visible: URL (+ upload), alt, caption.
{
title: 'Origen',
pinned: true,
fields: [
{ {
key: 'src', key: 'src',
type: 'text', type: 'text',
@ -72,144 +65,7 @@ const image: PalabrasBlockSchema = {
placeholder: 'Describe la imagen (accesibilidad)', placeholder: 'Describe la imagen (accesibilidad)',
wide: true wide: true
}, },
{ { key: 'caption', type: 'text', label: 'Pie de foto', placeholder: 'Opcional', wide: true }
key: 'caption',
type: 'text',
label: 'Pie de foto',
placeholder: 'Opcional',
wide: true
}
]
},
{
title: 'Tamaño y posición',
defaultCollapsed: true,
fields: [
{
key: 'align',
type: 'pills',
label: 'Alineación',
gatedBy: 'fullWidth',
default: 'center',
options: [
{ value: 'left', label: 'Izquierda', icon: TextAlignStart },
{ value: 'center', label: 'Centro', icon: TextAlignCenter },
{ value: 'right', label: 'Derecha', icon: TextAlignEnd }
]
},
{
key: 'fit',
type: 'pills',
label: 'Ajuste',
options: [
{ value: 'fill', label: 'Fill' },
{ value: 'fit', label: 'Fit' },
{ value: 'crop', label: 'Crop' },
{ value: 'tile', label: 'Tile' }
]
},
{ key: 'fullWidth', type: 'toggle', label: 'Ancho completo', default: false },
{
key: 'width',
type: 'length',
label: 'Ancho',
placeholder: 'auto',
unit: 'px',
units: ['px', '%', 'rem', 'em', 'vw', 'vh'],
gatedBy: 'fullWidth'
},
{
key: 'height',
type: 'length',
label: 'Alto',
placeholder: 'auto',
unit: 'px',
// Same units as width, `%` included (per request). NOTE: a percentage
// height only takes effect with a definite-height ancestor — otherwise
// the browser ignores it — but we accept it rather than block the input.
units: ['px', 'rem', 'em', 'vh', 'vw', '%'],
gatedBy: 'fullWidth'
}
]
},
// Photo adjustments (CSS filters). Defaults are the neutral values; the
// renderer treats them as identity, so an untouched image carries no filter.
{
title: 'Apariencia',
defaultCollapsed: true,
fields: [
{
key: 'saturate',
type: 'slider',
label: 'Saturación',
unit: '%',
min: 0,
max: 200,
step: 5,
default: 100
},
{
key: 'brightness',
type: 'slider',
label: 'Brillo',
unit: '%',
min: 0,
max: 200,
step: 5,
default: 100
},
{
key: 'contrast',
type: 'slider',
label: 'Contraste',
unit: '%',
min: 0,
max: 200,
step: 5,
default: 100
},
{
key: 'hueRotate',
type: 'slider',
label: 'Tono',
unit: '°',
min: 0,
max: 360,
step: 5,
default: 0
},
{
key: 'grayscale',
type: 'slider',
label: 'Escala de grises',
unit: '%',
min: 0,
max: 100,
step: 5,
default: 0
},
{
key: 'sepia',
type: 'slider',
label: 'Sepia',
unit: '%',
min: 0,
max: 100,
step: 5,
default: 0
},
{
key: 'blur',
type: 'slider',
label: 'Desenfoque',
unit: 'px',
min: 0,
max: 20,
step: 1,
default: 0
}
]
}
] ]
}; };

@ -39,9 +39,11 @@ export type PalabrasFieldType =
| 'select' | 'select'
| 'chip' | 'chip'
| 'text' | 'text'
| 'textarea'
| 'number' | 'number'
| 'slider' | 'slider'
| 'length'; | 'length'
| 'color';
/** One choice in a `pills` / `select` field. */ /** One choice in a `pills` / `select` field. */
export interface PalabrasFieldOption { export interface PalabrasFieldOption {
@ -83,6 +85,13 @@ export interface PalabrasFieldDef {
/** Disable this control when the active block's `node[gatedBy]` is truthy — e.g. /** Disable this control when the active block's `node[gatedBy]` is truthy — e.g.
* width / height / align gated by `fullWidth` (the engine ignores them then). */ * width / height / align gated by `fullWidth` (the engine ignores them then). */
readonly gatedBy?: string; readonly gatedBy?: string;
/** When true, this field edits a VISUAL style override — written via
* `setBlockVisualAtPath` (the engine's STYLE_KEYS: color / background /
* fontSize / fontFamily / fontWeight / lineHeight / align / border / margin /
* padding) — instead of a structural prop (`updateBlockAtPath`). The style
* groups (Tipografía / Colores / Disposición / Bordes) set this; structural
* groups (Origen / Avanzado / Tamaño) leave it off. */
readonly visual?: boolean;
/** Default VALUE shown when the block hasn't set this prop yet — this field-def /** Default VALUE shown when the block hasn't set this prop yet — this field-def
* IS the per-block-property defaults schema. The control displays * IS the per-block-property defaults schema. The control displays
* `node[key] ?? default`; the renderer treats the neutral value as identity. */ * `node[key] ?? default`; the renderer treats the neutral value as identity. */
@ -110,14 +119,24 @@ export interface PalabrasSectionDef {
readonly fields: readonly PalabrasFieldDef[]; readonly fields: readonly PalabrasFieldDef[];
} }
/** The panel definition for one block type. */ /** The panel definition for one block type — THREE fixed tabs.
* The panel always shows `Contenido · Diseño · Avanzado` at the top (open on
* Contenido). Each tab's content is per-block-type:
* - `contenido` — the block's OWN content props (e.g. heading: text / level / link).
* - `diseño` — visual style (block.visual.*). Populated incrementally.
* - `avanzado` — advanced / structural extras. Populated incrementally. */
export interface PalabrasBlockSchema { export interface PalabrasBlockSchema {
readonly type: string; readonly type: string;
/** Shown in the panel header + breadcrumb. */ /** Shown in the panel header + breadcrumb. */
readonly label: string; readonly label: string;
/** Colour token for the header dot. */ /** Colour token for the header dot. */
readonly dotToken?: string; readonly dotToken?: string;
readonly sections: readonly PalabrasSectionDef[]; /** Tab "Contenido" (default). */
readonly contenido: readonly PalabrasFieldDef[];
/** Tab "Diseño". */
readonly diseño?: readonly PalabrasFieldDef[];
/** Tab "Avanzado". */
readonly avanzado?: readonly PalabrasFieldDef[];
} }
/** Block type → panel schema. */ /** Block type → panel schema. */

@ -100,14 +100,21 @@
box-shadow: var(--slider-thumb-shadow); box-shadow: var(--slider-thumb-shadow);
cursor: grab; cursor: grab;
outline: none; outline: none;
/* `scale` is its own property, independent of the `transform: translate()`
the soma uses to centre the thumb — they compose, so the lift scales the
thumb in place. */
transition: transition:
background var(--slider-transition-duration) var(--slider-transition-ease), background var(--slider-transition-duration) var(--slider-transition-ease),
border-color var(--slider-transition-duration) var(--slider-transition-ease), border-color var(--slider-transition-duration) var(--slider-transition-ease),
scale var(--slider-transition-duration) var(--slider-transition-ease),
box-shadow var(--slider-transition-duration) var(--slider-transition-ease); box-shadow var(--slider-transition-duration) var(--slider-transition-ease);
} }
/* Pickup lift on grab: scale toward the viewer + a higher shadow. */
[data-slider-thumb]:active { [data-slider-thumb]:active {
cursor: grabbing; cursor: grabbing;
scale: var(--slider-thumb-scale-active);
box-shadow: var(--slider-thumb-shadow-active);
} }
[data-slider-thumb]:focus-visible { [data-slider-thumb]:focus-visible {
@ -144,4 +151,8 @@
[data-slider-thumb] { [data-slider-thumb] {
transition: none; transition: none;
} }
/* No pickup zoom under reduced motion — the raised shadow still conveys grab. */
[data-slider-thumb]:active {
scale: 1;
}
} }

@ -8,8 +8,12 @@
--_textarea-line-height: var(--leading-normal); --_textarea-line-height: var(--leading-normal);
--_textarea-radius: var(--radius-md); --_textarea-radius: var(--radius-md);
--_textarea-border: var(--color-neutral-border); --_textarea-border: var(--color-neutral-border);
--_textarea-border-hover: var(--color-neutral-border-strong); /* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary
--_textarea-border-focus: var(--color-primary-border-strong); (only `--color-{role}-border` exists). Consume the SAME tokens the other inputs
use: the Field hovers to `--color-border-strong` and focuses to
`--color-focus-ring`. Per-color focus accents below use each role's `-solid`. */
--_textarea-border-hover: var(--color-border-strong);
--_textarea-border-focus: var(--color-focus-ring);
--_textarea-bg: var(--color-surface); --_textarea-bg: var(--color-surface);
--_textarea-color: var(--color-content); --_textarea-color: var(--color-content);
--_textarea-placeholder: var(--color-content-muted); --_textarea-placeholder: var(--color-content-muted);
@ -47,27 +51,29 @@
--_textarea-radius: var(--radius-lg); --_textarea-radius: var(--radius-lg);
} }
/* Color accents — applied on focus + when invalid. */ /* Color accents — applied on focus + when invalid. Each role's `-solid` (the
saturated accent, step 9) is the existing token closest to the intended strong
focus border. */
[data-textarea][data-color='secondary'] { [data-textarea][data-color='secondary'] {
--_textarea-border-focus: var(--color-secondary-border-strong); --_textarea-border-focus: var(--color-secondary-solid);
} }
[data-textarea][data-color='neutral'] { [data-textarea][data-color='neutral'] {
--_textarea-border-focus: var(--color-neutral-border-strong); --_textarea-border-focus: var(--color-neutral-solid);
} }
[data-textarea][data-color='affirm'] { [data-textarea][data-color='affirm'] {
--_textarea-border-focus: var(--color-affirm-border-strong); --_textarea-border-focus: var(--color-affirm-solid);
} }
[data-textarea][data-color='fulfill'] { [data-textarea][data-color='fulfill'] {
--_textarea-border-focus: var(--color-fulfill-border-strong); --_textarea-border-focus: var(--color-fulfill-solid);
} }
[data-textarea][data-color='risk'] { [data-textarea][data-color='risk'] {
--_textarea-border-focus: var(--color-risk-border-strong); --_textarea-border-focus: var(--color-risk-solid);
} }
[data-textarea][data-color='threat'] { [data-textarea][data-color='threat'] {
--_textarea-border-focus: var(--color-threat-border-strong); --_textarea-border-focus: var(--color-threat-solid);
} }
[data-textarea][data-color='loss'] { [data-textarea][data-color='loss'] {
--_textarea-border-focus: var(--color-loss-border-strong); --_textarea-border-focus: var(--color-loss-solid);
} }
/* The real input. */ /* The real input. */
@ -101,7 +107,7 @@
} }
[data-textarea-input][data-invalid] { [data-textarea-input][data-invalid] {
border-color: var(--color-threat-border-strong); border-color: var(--color-threat-solid);
} }
[data-textarea][data-variant='outline'] [data-textarea-input] { [data-textarea][data-variant='outline'] [data-textarea-input] {

@ -1337,6 +1337,46 @@
--float-panel-accent: var(--color-neutral-border); --float-panel-accent: var(--color-neutral-border);
--float-panel-transition-duration: var(--duration-fast); --float-panel-transition-duration: var(--duration-fast);
--float-panel-transition-ease: var(--ease-default); --float-panel-transition-ease: var(--ease-default);
--image-adjustments-gap: var(--space-3);
--image-adjustments-row-gap: var(--space-1-5);
--image-adjustments-label-color: var(--color-content-secondary);
--image-adjustments-label-font-size: var(--font-size-sm);
--image-adjustments-label-font-weight: var(--font-weight-medium);
--image-adjustments-value-color: var(--color-content-tertiary);
--image-adjustments-value-font-size: var(--font-size-xs);
--image-adjustments-value-font-family: var(--font-mono);
--image-adjustments-reset-gap: var(--space-2);
--image-adjustments-reset-color: var(--color-content-secondary);
--image-adjustments-reset-font-size: var(--font-size-xs);
--image-adjustments-hue-track: linear-gradient(to right, hsl(0 85% 60%), hsl(60 85% 60%), hsl(120 85% 60%), hsl(180 85% 60%), hsl(240 85% 60%), hsl(300 85% 60%), hsl(360 85% 60%));
--image-picker-gap: var(--space-3);
--image-picker-preview-radius: var(--radius-lg);
--image-picker-preview-bg: var(--color-surface-muted);
--image-picker-preview-border: var(--color-border-default);
--image-picker-preview-aspect: 4 / 3;
--image-picker-toolbar-gap: var(--space-1-5);
--image-picker-toolbar-inset: var(--space-2);
--image-picker-button-size: var(--space-8);
--image-picker-button-bg: color-mix(in srgb, var(--color-surface-default) 88%, transparent);
--image-picker-button-fg: var(--color-content-primary);
--image-picker-button-border: var(--color-border-default);
--image-picker-button-radius: var(--radius-md);
--image-picker-button-font-size: var(--font-size-md);
--cropper-viewport-bg: var(--color-surface-muted);
--cropper-viewport-radius: var(--radius-lg);
--cropper-mask-bg: rgb(0 0 0 / 0.55);
--cropper-grid-line: rgb(255 255 255 / 0.4);
--cropper-selection-border: var(--color-surface-default);
--cropper-selection-border-width: var(--border-width);
--cropper-handle-size: var(--space-3-5);
--cropper-handle-bg: var(--color-surface-default);
--cropper-handle-border: var(--color-primary-solid);
--cropper-handle-border-width: var(--border-width);
--cropper-zoom-gap: var(--space-2);
--cropper-zoom-btn-size: var(--space-7);
--cropper-zoom-btn-bg: var(--color-surface-muted);
--cropper-zoom-btn-fg: var(--color-content-primary);
--cropper-zoom-btn-border: var(--color-border-default);
--date-field-stack-gap: var(--space-1-5); --date-field-stack-gap: var(--space-1-5);
--date-field-height-xs: var(--control-height-xs); --date-field-height-xs: var(--control-height-xs);
--date-field-height-sm: var(--control-height-sm); --date-field-height-sm: var(--control-height-sm);
@ -2734,13 +2774,15 @@
--slider-min-inline-size: 12rem; --slider-min-inline-size: 12rem;
--slider-min-block-size: 10rem; --slider-min-block-size: 10rem;
--slider-track-radius: var(--radius-full); --slider-track-radius: var(--radius-full);
--slider-track-bg: var(--color-neutral-track); --slider-track-bg: color-mix(in srgb, var(--color-content-primary) 12%, var(--color-surface-default));
--slider-range-bg: var(--color-primary-solid); --slider-range-bg: var(--color-primary-solid);
--slider-thumb-radius: var(--radius-full); --slider-thumb-radius: var(--radius-full);
--slider-thumb-border-width: var(--border-width); --slider-thumb-border-width: var(--border-width);
--slider-thumb-border: var(--color-border-default); --slider-thumb-border: var(--color-border-default);
--slider-thumb-bg: var(--color-surface-default); --slider-thumb-bg: var(--color-surface-default);
--slider-thumb-shadow: var(--shadow-subtle); --slider-thumb-shadow: var(--shadow-subtle);
--slider-thumb-scale-active: 1.15;
--slider-thumb-shadow-active: var(--shadow-3);
--slider-thumb-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow); --slider-thumb-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow);
--slider-tick-radius: var(--radius-full); --slider-tick-radius: var(--radius-full);
--slider-tick-bg: var(--color-neutral-border); --slider-tick-bg: var(--color-neutral-border);

@ -1178,6 +1178,66 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-duration': 'var(--duration-fast)', 'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)' 'transition-ease': 'var(--ease-default)'
}, },
'image-adjustments': {
// Vertical rhythm: gap between adjustment rows, and within a row
// (label/value line → slider).
gap: 'var(--space-3)',
'row-gap': 'var(--space-1-5)',
'label-color': 'var(--color-content-secondary)',
'label-font-size': 'var(--font-size-sm)',
'label-font-weight': 'var(--font-weight-medium)',
// Read-out: tabular numerals so the value doesn't jitter while dragging.
'value-color': 'var(--color-content-tertiary)',
'value-font-size': 'var(--font-size-xs)',
'value-font-family': 'var(--font-mono)',
'reset-gap': 'var(--space-2)',
'reset-color': 'var(--color-content-secondary)',
'reset-font-size': 'var(--font-size-xs)',
// The `hue` row paints its slider track as a hue wheel so the control
// reads as a hue picker. A literal rainbow (the hues ARE the value) — kept
// as a token so it's themeable and stays out of the component CSS.
'hue-track':
'linear-gradient(to right, hsl(0 85% 60%), hsl(60 85% 60%), hsl(120 85% 60%), hsl(180 85% 60%), hsl(240 85% 60%), hsl(300 85% 60%), hsl(360 85% 60%))'
},
'image-picker': {
// Vertical stack: preview → adjustments.
gap: 'var(--space-3)',
// Preview box (ready state). The composed <Image> fills it.
'preview-radius': 'var(--radius-lg)',
'preview-bg': 'var(--color-surface-muted)',
'preview-border': 'var(--color-border-default)',
'preview-aspect': '4 / 3',
// Toolbar floats over the preview's top-right corner.
'toolbar-gap': 'var(--space-1-5)',
'toolbar-inset': 'var(--space-2)',
// Icon buttons (rotate / remove).
'button-size': 'var(--space-8)',
'button-bg': 'color-mix(in srgb, var(--color-surface-default) 88%, transparent)',
'button-fg': 'var(--color-content-primary)',
'button-border': 'var(--color-border-default)',
'button-radius': 'var(--radius-md)',
'button-font-size': 'var(--font-size-md)'
},
cropper: {
'viewport-bg': 'var(--color-surface-muted)',
'viewport-radius': 'var(--radius-lg)',
// The darken-outside mask + the thirds grid carry literal rgba/rgb (an
// overlay tint, not a theme color) — kept as tokens so they stay out of
// the component CSS (raw-color contract) and remain themeable.
'mask-bg': 'rgb(0 0 0 / 0.55)',
'grid-line': 'rgb(255 255 255 / 0.4)',
'selection-border': 'var(--color-surface-default)',
'selection-border-width': 'var(--border-width)',
'handle-size': 'var(--space-3-5)',
'handle-bg': 'var(--color-surface-default)',
'handle-border': 'var(--color-primary-solid)',
'handle-border-width': 'var(--border-width)',
'zoom-gap': 'var(--space-2)',
'zoom-btn-size': 'var(--space-7)',
'zoom-btn-bg': 'var(--color-surface-muted)',
'zoom-btn-fg': 'var(--color-content-primary)',
'zoom-btn-border': 'var(--color-border-default)'
},
'date-field': { 'date-field': {
'stack-gap': 'var(--space-1-5)', 'stack-gap': 'var(--space-1-5)',
'height-xs': 'var(--control-height-xs)', 'height-xs': 'var(--control-height-xs)',
@ -3124,13 +3184,23 @@ export const THEME_BASE_RECIPE_TOKENS = {
'min-inline-size': '12rem', 'min-inline-size': '12rem',
'min-block-size': '10rem', 'min-block-size': '10rem',
'track-radius': 'var(--radius-full)', 'track-radius': 'var(--radius-full)',
'track-bg': 'var(--color-neutral-track)', // The unfilled groove. `--color-neutral-track` equals the surface in the
// base theme (invisible against panels), so the track uses the same
// visible-track mix as Progress: a light tint of content-primary on the
// surface. Reads on white AND dark, and follows the theme.
'track-bg': 'color-mix(in srgb, var(--color-content-primary) 12%, var(--color-surface-default))',
'range-bg': 'var(--color-primary-solid)', 'range-bg': 'var(--color-primary-solid)',
'thumb-radius': 'var(--radius-full)', 'thumb-radius': 'var(--radius-full)',
'thumb-border-width': 'var(--border-width)', 'thumb-border-width': 'var(--border-width)',
'thumb-border': 'var(--color-border-default)', 'thumb-border': 'var(--color-border-default)',
'thumb-bg': 'var(--color-surface-default)', 'thumb-bg': 'var(--color-surface-default)',
'thumb-shadow': 'var(--shadow-subtle)', 'thumb-shadow': 'var(--shadow-subtle)',
// Pickup lift WHILE grabbed (pointer :active): the thumb scales toward the
// viewer + rides a higher shadow — the draggable-surface lift doctrine
// (THEMING §14), scaled up from the surface `--motion-scale-lift` (1.02)
// because a ~20px thumb needs a larger multiplier to read as a lift.
'thumb-scale-active': '1.15',
'thumb-shadow-active': 'var(--shadow-3)',
'thumb-focus-shadow': 'thumb-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow)', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow)',
'tick-radius': 'var(--radius-full)', 'tick-radius': 'var(--radius-full)',

@ -0,0 +1,16 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the Cropper component. Merged under
* `components.cropper.*` by `ActiveUix` (via the `componentLangs` barrel).
*/
export const cropperLangs = {
label: {
es: 'Recortar imagen',
en: 'Crop image'
},
selection: {
es: 'Región de recorte',
en: 'Crop region'
}
} satisfies LangNode;

@ -0,0 +1,52 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the ImageAdjustments component. Merged under
* `components.image-adjustments.*` by `ActiveUix` (via the `componentLangs`
* barrel). The morfo references `label` + `reset`; the soma provider resolves
* per-adjustment labels under `adjustments.*` via `langs.ts(...)`.
*/
export const imageAdjustmentsLangs = {
label: {
es: 'Ajustes de imagen',
en: 'Image adjustments'
},
reset: {
es: 'Restablecer',
en: 'Reset'
},
adjustments: {
brightness: {
es: 'Brillo',
en: 'Brightness'
},
contrast: {
es: 'Contraste',
en: 'Contrast'
},
saturation: {
es: 'Saturación',
en: 'Saturation'
},
temperature: {
es: 'Temperatura',
en: 'Temperature'
},
hue: {
es: 'Tono',
en: 'Hue'
},
blur: {
es: 'Desenfoque',
en: 'Blur'
},
grayscale: {
es: 'Escala de grises',
en: 'Grayscale'
},
sepia: {
es: 'Sepia',
en: 'Sepia'
}
}
} satisfies LangNode;

@ -0,0 +1,24 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the ImagePicker component. Merged under
* `components.image-picker.*` by `ActiveUix` (via the `componentLangs` barrel).
*/
export const imagePickerLangs = {
label: {
es: 'Selector de imagen',
en: 'Image picker'
},
prompt: {
es: 'Suelta una imagen o haz clic para explorar',
en: 'Drop an image or click to browse'
},
remove: {
es: 'Quitar imagen',
en: 'Remove image'
},
rotate: {
es: 'Girar 90°',
en: 'Rotate 90°'
}
} satisfies LangNode;

@ -16,6 +16,7 @@ import { colorPickerLangs } from './color-picker';
import { comboboxLangs } from './combobox'; import { comboboxLangs } from './combobox';
import { commandLangs } from './command'; import { commandLangs } from './command';
import { contextMenuLangs } from './context-menu'; import { contextMenuLangs } from './context-menu';
import { cropperLangs } from './cropper';
import { dateFieldLangs } from './date-field'; import { dateFieldLangs } from './date-field';
import { datePickerLangs } from './date-picker'; import { datePickerLangs } from './date-picker';
import { dateRangeFieldLangs } from './date-range-field'; import { dateRangeFieldLangs } from './date-range-field';
@ -32,6 +33,8 @@ import { floatPanelLangs } from './float-panel';
import { formLangs } from './form'; import { formLangs } from './form';
import { gridListLangs } from './grid-list'; import { gridListLangs } from './grid-list';
import { imageLangs } from './image'; import { imageLangs } from './image';
import { imageAdjustmentsLangs } from './image-adjustments';
import { imagePickerLangs } from './image-picker';
import { linkPreviewLangs } from './link-preview'; import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox'; import { listboxLangs } from './listbox';
import { menubarLangs } from './menubar'; import { menubarLangs } from './menubar';
@ -100,6 +103,7 @@ export const componentLangs = {
combobox: comboboxLangs, combobox: comboboxLangs,
command: commandLangs, command: commandLangs,
'context-menu': contextMenuLangs, 'context-menu': contextMenuLangs,
cropper: cropperLangs,
'date-field': dateFieldLangs, 'date-field': dateFieldLangs,
'date-picker': datePickerLangs, 'date-picker': datePickerLangs,
'date-range-field': dateRangeFieldLangs, 'date-range-field': dateRangeFieldLangs,
@ -116,6 +120,8 @@ export const componentLangs = {
form: formLangs, form: formLangs,
'grid-list': gridListLangs, 'grid-list': gridListLangs,
image: imageLangs, image: imageLangs,
'image-adjustments': imageAdjustmentsLangs,
'image-picker': imagePickerLangs,
'link-preview': linkPreviewLangs, 'link-preview': linkPreviewLangs,
listbox: listboxLangs, listbox: listboxLangs,
menubar: menubarLangs, menubar: menubarLangs,

@ -0,0 +1,148 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Cropper — crop a region of an image (Ark-style: a movable / resizable
* selection over a fixed image, with a rule-of-thirds grid + corner handles).
*
* Declarative DNA only. The crop rect geometry (x/y/width/height) is continuous
* and lives as inline style on the Selection (set by soma, like FloatPanel's
* position) — NOT as data-attrs. The morfo declares the parts, the shape
* (rect / round), the corner of each handle, and three events. The drag/resize
* math + the `<canvas>` extraction to a Blob live in soma.
*
* Consumes an image `src` — typically the `url` an ImagePicker emits — and
* produces a cropped Blob, closing the pick → adjust → crop pipeline.
*/
export const cropperMorfo = {
name: 'Cropper',
kebab: 'cropper',
scope: ['soma', 'sema'],
texts: {
label: '#?components.cropper.label|Crop image',
selection: '#?components.cropper.selection|Crop region'
},
events: [
{
// The user starts dragging the selection to move it. Emitted once at
// gesture start (NOT per-frame — that would buzz haptics; the move
// itself is visual). family `handle`, verb `drag`.
name: 'handle-drag',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('selection'),
sequence: 'pre'
}
},
{
// The user starts resizing via a corner handle. Emitted once at gesture
// start. family `handle`, verb `resize`.
name: 'handle-resize',
semantic: {
family: 'handle',
verb: 'resize',
target: v.partRef('handle'),
sequence: 'pre'
}
},
{
// Zoom the image (wheel / pinch / the +/− controls). Emitted throttled
// (NOT per wheel tick). family `handle`, verb `zoom`.
name: 'handle-zoom',
semantic: {
family: 'handle',
verb: 'zoom',
target: v.partRef('viewport'),
sequence: 'pre'
}
},
{
// The crop is applied — the cropped Blob is produced. family `commit`,
// verb `apply` (the canonical commit verb for applying a transform).
name: 'commit-crop',
semantic: {
family: 'commit',
verb: 'apply',
target: v.partRef('provider'),
intent: 'affirm',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'group',
optional: false,
data: [{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }],
aria: [{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }]
},
{
// The framed crop area — the image + the selection live here.
name: 'Viewport',
kebab: 'viewport',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The crop rectangle. `data-shape` drives the rectangular vs circular
// mask; `data-dragging` reflects the active move/resize gesture. The
// x/y/width/height geometry is inline style (continuous), not data-attrs.
name: 'Selection',
kebab: 'selection',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{
attr: 'data-shape',
values: ['rect', 'round'],
value: v.propRef('shape'),
severity: 'optional'
},
{ attr: 'data-dragging', value: v.propRef('dragging'), severity: 'optional' }
],
aria: []
},
{
// Rule-of-thirds overlay inside the selection.
name: 'Grid',
kebab: 'grid',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// A resize handle (repeatable). `data-corner` names which corner / edge
// it drives, so eidos positions it + soma maps the drag axis.
name: 'Handle',
kebab: 'handle',
archetype: 'thumb',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{
attr: 'data-corner',
values: ['nw', 'ne', 'sw', 'se', 'n', 'e', 's', 'w'],
value: v.propRef('corner'),
severity: 'optional'
}
],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,116 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* ImageAdjustments — a reusable set of image-filter sliders.
*
* Declarative DNA only: the parts (a group of adjustment rows + a reset). The
* adjustment definitions, ranges and the value→CSS-`filter` math live in soma
* (logic), and eidos composes a `<Slider>` per row. The component emits a live
* CSS `filter` string + the per-adjustment values, so ANY surface can apply it
* (the ImagePicker preview, an avatar editor, a words image, the cropper…).
*
* Reuses the framework's `Slider` (one per `Item`); never reinvents a slider.
*/
export const imageAdjustmentsMorfo = {
name: 'ImageAdjustments',
kebab: 'image-adjustments',
scope: ['soma', 'sema'],
texts: {
label: '#?components.image-adjustments.label|Image adjustments',
reset: '#?components.image-adjustments.reset|Reset'
},
events: [
{
// Reset every adjustment back to its neutral default.
name: 'commit-reset',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'group',
optional: false,
data: [{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }
]
},
{
// One adjustment row (repeatable). `data-adjustment` names which
// adjustment it drives (brightness / contrast / …) so eidos can style a
// row per-adjustment (e.g. a rainbow track for `hue`). It's a string
// value, so it MUST declare `values` + `value` — a bare propRef without
// `values` would emit empty-string presence instead of the key.
name: 'Item',
kebab: 'item',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{
attr: 'data-adjustment',
values: [
'brightness',
'contrast',
'saturation',
'temperature',
'hue',
'blur',
'grayscale',
'sepia'
],
value: v.propRef('adjustment'),
severity: 'optional'
}
],
aria: []
},
{
name: 'ItemLabel',
kebab: 'item-label',
archetype: 'label',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
// Value read-out for an adjustment. A plain `<span>` (not `<output>`):
// the composed Slider's thumb already exposes `aria-valuenow`, so a
// live region here would double-announce on every step.
name: 'ItemValue',
kebab: 'item-value',
archetype: 'description',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
name: 'Reset',
kebab: 'reset',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [{ attr: 'type', value: v.literal('button') }]
}
]
} as const satisfies Morfo;

@ -0,0 +1,143 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* ImagePicker — pick / preview / adjust an image.
*
* A composition component: it orchestrates the framework's already-built
* `file-upload` (drop + click to select), `image` (preview with fit modes) and
* `ImageAdjustments` (filter sliders). This morfo declares only what is NEW —
* the picker's own orchestration: the empty↔ready state, the fill `fit`, the 90°
* `rotation`, and the select / remove / rotate events. The dropzone + the
* sliders carry their own morfos; ImagePicker never re-declares them.
*
* Output is cropper-ready: the provider exposes the `File` + an object-URL +
* the transform state, so the (separate) image cropper can consume it directly.
*/
export const imagePickerMorfo = {
name: 'ImagePicker',
kebab: 'image-picker',
scope: ['soma', 'sema'],
texts: {
label: '#?components.image-picker.label|Image picker',
prompt: '#?components.image-picker.prompt|Drop an image or click to browse',
remove: '#?components.image-picker.remove|Remove image',
rotate: '#?components.image-picker.rotate|Rotate 90°'
},
events: [
{
// An image was selected (or replaced) — the affirmative pickup.
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('provider'),
intent: 'affirm',
sequence: 'post'
}
},
{
// The image was removed — back to empty.
name: 'commit-remove',
semantic: {
family: 'commit',
verb: 'remove',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
},
{
// Rotate the preview 90° clockwise. `rotate` is canonically a `handle`
// verb (spatial manipulation, alongside drag / resize / scroll) — even
// though it's triggered by a discrete button here.
name: 'handle-rotate',
semantic: {
family: 'handle',
verb: 'rotate',
target: v.partRef('preview'),
intent: 'neutral',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'group',
optional: false,
data: [
{
attr: 'data-state',
values: ['empty', 'ready'],
value: v.propRef('state'),
severity: 'required'
},
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }]
},
{
// The preview area. Carries the transform state eidos reacts to:
// `data-fit` (fill mode) + `data-rotation` (0/90/180/270).
name: 'Preview',
kebab: 'preview',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{
attr: 'data-fit',
values: ['cover', 'contain', 'fill'],
value: v.propRef('fit'),
severity: 'optional'
},
{
attr: 'data-rotation',
values: ['0', '90', '180', '270'],
value: v.propRef('rotation'),
severity: 'optional'
}
],
aria: []
},
{
// Controls container (rotate / remove). Structural.
name: 'Toolbar',
kebab: 'toolbar',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Rotate',
kebab: 'rotate',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [{ attr: 'type', value: v.literal('button') }]
},
{
name: 'Remove',
kebab: 'remove',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [{ attr: 'type', value: v.literal('button') }]
}
]
} as const satisfies Morfo;

@ -0,0 +1,47 @@
import { semaSelector } from '$uix/morfo';
import { cropperMorfo } from '$uix/morfo/components/cropper';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* Cropper perceptual defaults — HAPTIC + VERY SOFT SOUND.
*
* - `handle-drag` (handle · drag) — grabbing the selection to move it. Emitted
* once at gesture start (the move is visual); family `handle` activates a tick.
* - `handle-resize` (handle · resize) — grabbing a corner handle. Tick.
* - `commit-crop` (commit · apply · affirm) — the crop is produced. The affirm
* profile comes from intent.deltas (capa 2); the pack adds a soft chime + tap
* (NEVER overrides pitch/gain/contour).
*/
const onSelection = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
semaSelector(cropperMorfo, 'selection', matchers);
const onHandle = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
semaSelector(cropperMorfo, 'handle', matchers);
const onViewport = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
semaSelector(cropperMorfo, 'viewport', matchers);
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
semaSelector(cropperMorfo, 'provider', matchers);
export const cropperSema: Sema = {
name: 'cropper',
cascade: [
{
selector: onSelection({ eventName: 'handle-drag' }),
haptic: { kind: 'tick' }
},
{
selector: onHandle({ eventName: 'handle-resize' }),
haptic: { kind: 'tick' }
},
{
selector: onViewport({ eventName: 'handle-zoom' }),
haptic: { kind: 'tick' }
},
{
selector: onProvider({ eventName: 'commit-crop' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
}
]
};

@ -0,0 +1,31 @@
import { semaSelector } from '$uix/morfo';
import { imageAdjustmentsMorfo } from '$uix/morfo/components/image-adjustments';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* ImageAdjustments perceptual defaults — HAPTIC + VERY SOFT SOUND.
*
* The only declared event is `commit-reset` (family `commit`, verb `reset`,
* intent `neutral`): the user wipes every adjustment back to neutral. That's a
* real, deliberate commit — but a quiet one, so it rides the soft form-commit
* tuning + a single tap. The per-slider drag feedback is owned by the composed
* `<Slider>` pack (`handle-drag`), not here.
*
* NEVER override `pitch` / `gain` / `contour` — the neutral intent's profile
* comes from the family base + intent.deltas (capa 2).
*/
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof imageAdjustmentsMorfo>>[2]) =>
semaSelector(imageAdjustmentsMorfo, 'provider', matchers);
export const imageAdjustmentsSema: Sema = {
name: 'image-adjustments',
cascade: [
{
selector: onProvider({ eventName: 'commit-reset' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
}
]
};

@ -0,0 +1,43 @@
import { semaSelector } from '$uix/morfo';
import { imagePickerMorfo } from '$uix/morfo/components/image-picker';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* ImagePicker perceptual defaults — HAPTIC + VERY SOFT SOUND.
*
* Three events:
* - `commit-select` (commit · affirm) — the affirmative pickup of a chosen
* image. Affirm's evaluative profile comes from intent.deltas (capa 2); the
* pack only adds a soft chime + tap (NEVER overrides pitch/gain/contour).
* - `commit-remove` (commit · neutral) — clearing the image; even softer.
* - `handle-rotate` (handle · neutral) — a spatial nudge; family `handle`
* activates HAPTIC, so a single tick conveys the 90° step.
*
* The per-slider drag feedback is owned by ImageAdjustments / Slider, not here.
*/
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof imagePickerMorfo>>[2]) =>
semaSelector(imagePickerMorfo, 'provider', matchers);
const onPreview = (matchers?: Parameters<typeof semaSelector<typeof imagePickerMorfo>>[2]) =>
semaSelector(imagePickerMorfo, 'preview', matchers);
export const imagePickerSema: Sema = {
name: 'image-picker',
cascade: [
{
selector: onProvider({ eventName: 'commit-select' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-remove' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
{
selector: onPreview({ eventName: 'handle-rotate' }),
haptic: { kind: 'tick' }
}
]
};

@ -15,6 +15,7 @@ export { collapsibleSema } from './collapsible';
export { colorPickerSema } from './color-picker'; export { colorPickerSema } from './color-picker';
export { comboboxSema } from './combobox'; export { comboboxSema } from './combobox';
export { contextMenuSema } from './context-menu'; export { contextMenuSema } from './context-menu';
export { cropperSema } from './cropper';
export { cssFieldSema } from './css-field'; export { cssFieldSema } from './css-field';
export { dateFieldSema } from './date-field'; export { dateFieldSema } from './date-field';
export { dialogSema } from './dialog'; export { dialogSema } from './dialog';
@ -24,6 +25,8 @@ export { editableSema } from './editable';
export { fileUploadSema } from './file-upload'; export { fileUploadSema } from './file-upload';
export { floatPanelSema } from './float-panel'; export { floatPanelSema } from './float-panel';
export { formSema } from './form'; export { formSema } from './form';
export { imageAdjustmentsSema } from './image-adjustments';
export { imagePickerSema } from './image-picker';
export { menubarSema } from './menubar'; export { menubarSema } from './menubar';
export { navigationMenuSema } from './navigation-menu'; export { navigationMenuSema } from './navigation-menu';
export { numberFieldSema } from './number-field'; export { numberFieldSema } from './number-field';

@ -114,7 +114,9 @@ export const SEMA_VERBS = {
'remind' 'remind'
], ],
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], // `zoom` (extensión del autor 2026-06-10): manipulación directa de la escala
// del contenido (rueda / pinch), junto a drag / resize / rotate / scroll.
handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll', 'zoom'],
emerge: [ emerge: [
'present', 'present',

@ -0,0 +1,72 @@
# Cropper (soma)
Headless image cropper — a movable / resizable **selection** over a fixed image
(Ark model), with the drag/resize math + the `<canvas>` extraction to a Blob. It
consumes an image `src` (typically the `url` an ImagePicker emits) and produces a
cropped PNG, closing the pick → adjust → crop pipeline.
## Anatomy
```svelte
<Cropper.Provider {src} bind:crop onCropComplete={…}>
{#snippet children({ crop, cropImage })}
<Cropper.Viewport>
<!-- compose <Image src fit="fill" /> -->
<Cropper.Selection style="left:{crop.x*100}% …">
<Cropper.Grid />
<Cropper.Handle corner="nw" /> <!-- ne / se / sw -->
</Cropper.Selection>
</Cropper.Viewport>
{/snippet}
</Cropper.Provider>
```
| Part | Element | Role |
| --- | --- | --- |
| `Provider` | `div` | `role="group"`, `data-cropper`, `data-disabled` |
| `Viewport` | `div` | the framed area; its px size converts pointer deltas → normalized |
| `Selection` | `div` | the crop rect; `data-shape` (rect/round), `data-dragging`. Geometry = inline style |
| `Grid` | `div` | rule-of-thirds overlay |
| `Handle` | `div` | resize handle (repeatable); `data-corner` |
The crop rect is **normalized 0–1** (fraction of the image), so it's
resolution-independent. The viewport's aspect-ratio matches the image (no
letterbox), so the rect maps 1:1 to the image for the canvas extraction.
## Sema events
| Event | Family · verb | Target | Intent | When |
| --- | --- | --- | --- | --- |
| `handle-drag` | handle · drag | selection | — | start moving the selection |
| `handle-resize` | handle · resize | handle | — | start resizing via a handle |
| `handle-zoom` | handle · zoom | viewport | — | zoom the image (wheel / controls) |
| `commit-crop` | commit · apply | provider | affirm | the cropped Blob is produced |
`handle-drag` / `handle-resize` fire **once at gesture start** + `handle-zoom` is
throttled (not per-frame — that would buzz haptics; the move itself is visual).
`zoom` is an author-added `handle` verb (2026-06-10). Pack:
`src/uix/sema/components/cropper.ts`.
## API
| Prop / method | Type | Default | Notes |
| --- | --- | --- | --- |
| `src` | `string \| null` | — | image to crop |
| `crop` | `CropRect` | centered 80% | bindable, normalized 0–1 |
| `aspect` | `number` | free | lock width/height ratio (display px) |
| `shape` | `'rect' \| 'round'` | `'rect'` | circular = alpha-masked Blob |
| `minSize` / `maxSize` | `number` | `0.05` / `1` | crop size range, fraction of the image |
| `fixedSize` | `number` | — | pin the crop size → move-only (no handles); avatar = round + fixedSize |
| `minScale` / `maxScale` | `number` | `1` / `5` | zoom limits |
| `disabled` | `boolean` | `false` | |
| `onCropChange` | `(rect) => void` | — | live |
| `onCropComplete` | `(r: CropResult) => void` | — | on `cropImage()` |
| `cropImage()` | `() => Promise<CropResult>` | — | via `bind:this` / snippet; canvas → Blob |
| `zoomTo()` / `resetZoom()` | snippet methods | — | drive zoom from a custom control |
Zoom is the reusable **`soma/layers/zoom-pan`** (`createZoomPan`) — scale + offset
+ clamp + cursor-centered math, applicable to any image-viewer / pan surface.
`moveRect` / `resizeRect` are exported pure functions (the geometry — fully
unit-tested). The canvas extraction uses `dom.getDocument()` / `dom.getWindow()`
(iframe-safe).

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CropperGridProvider } from '../cropper-provider.svelte';
import type { CropperGridProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'cropper-grid'),
children,
child,
...restProps
}: CropperGridProps = $props();
const state = CropperGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CropperHandleProvider } from '../cropper-provider.svelte';
import type { CropperHandleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'cropper-handle'),
corner,
children,
child,
...restProps
}: CropperHandleProps = $props();
const state = CropperHandleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
corner: readableActive(() => corner)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CropperSelectionProvider } from '../cropper-provider.svelte';
import type { CropperSelectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'cropper-selection'),
children,
child,
...restProps
}: CropperSelectionProps = $props();
const state = CropperSelectionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CropperViewportProvider } from '../cropper-provider.svelte';
import type { CropperViewportProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'cropper-viewport'),
children,
child,
...restProps
}: CropperViewportProps = $props();
const state = CropperViewportProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,69 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CropperProvider } from '../cropper-provider.svelte';
import type { CropperProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'cropper'),
src = null,
crop = $bindable({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 }),
aspect,
shape = 'rect',
minSize = 0.05,
maxSize = 1,
fixedSize,
minScale = 1,
maxScale = 5,
disabled = false,
onCropChange,
onCropComplete,
'aria-label': ariaLabel,
children,
child,
...restProps
}: CropperProps = $props();
const state = CropperProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
crop: writableActive(
() => crop,
(v) => (crop = v)
),
src: readableActive(() => src),
aspect: readableActive(() => aspect),
shape: readableActive(() => shape),
minSize: readableActive(() => minSize),
maxSize: readableActive(() => maxSize),
fixedSize: readableActive(() => fixedSize),
minScale: readableActive(() => minScale),
maxScale: readableActive(() => maxScale),
disabled: readableActive(() => disabled),
onCropChange: readableActive(() => onCropChange),
onCropComplete: readableActive(() => onCropComplete),
ariaLabel: readableActive(() => ariaLabel ?? undefined)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
/** Produce the cropped image now. Accessible via `bind:this`. */
export function cropImage() {
return state.cropImage();
}
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,224 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { CropperProvider, moveRect, resizeRect } from './cropper-provider.svelte';
import { ZoomPan } from '../../layers/zoom-pan.svelte';
import type { CropRect } from './types';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: { ts: (ref: string) => ref },
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(CropperProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function cropperOpts(crop: CropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }) {
return {
id: state('cr-root'),
ref: state<HTMLElement | null>(document.createElement('div')),
crop: state<CropRect>(crop),
src: state<string | null | undefined>('blob:x'),
aspect: state<number | undefined>(undefined),
shape: state<'rect' | 'round'>('rect'),
minSize: state(0.05),
maxSize: state(1),
fixedSize: state<number | undefined>(undefined),
minScale: state(1),
maxScale: state(5),
disabled: state(false),
onCropChange: state<((c: CropRect) => void) | undefined>(undefined),
onCropComplete: state<undefined>(undefined),
ariaLabel: state<string | undefined>(undefined)
};
}
const approx = (v: number, t: number) => Math.abs(v - t) < 1e-6;
describe('moveRect', () => {
it('translates and clamps inside [0,1]', () => {
const r = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 };
const moved = moveRect(r, 0.1, -0.1);
expect(approx(moved.x, 0.3)).toBe(true);
expect(approx(moved.y, 0.1)).toBe(true);
expect(moved.width).toBe(0.4);
// clamp at the right/bottom edge: x cannot exceed 1 - width
const clamped = moveRect(r, 1, 1);
expect(approx(clamped.x, 0.6)).toBe(true);
expect(approx(clamped.y, 0.6)).toBe(true);
// clamp at the top/left edge
expect(moveRect(r, -1, -1)).toMatchObject({ x: 0, y: 0 });
});
});
describe('resizeRect', () => {
const base = { x: 0.1, y: 0.1, width: 0.4, height: 0.4 };
it('se corner grows width + height from the top-left anchor', () => {
const r = resizeRect(base, 'se', 0.2, 0.1, undefined, 100, 100, 0.05);
expect(approx(r.x, 0.1)).toBe(true);
expect(approx(r.y, 0.1)).toBe(true);
expect(approx(r.width, 0.6)).toBe(true);
expect(approx(r.height, 0.5)).toBe(true);
});
it('nw corner moves the top-left, keeping the bottom-right anchor', () => {
const r = resizeRect(base, 'nw', 0.1, 0.1, undefined, 100, 100, 0.05);
expect(approx(r.x, 0.2)).toBe(true);
expect(approx(r.y, 0.2)).toBe(true);
expect(approx(r.width, 0.3)).toBe(true);
expect(approx(r.height, 0.3)).toBe(true);
});
it('respects minSize', () => {
const r = resizeRect(base, 'se', -1, -1, undefined, 100, 100, 0.05);
expect(approx(r.width, 0.05)).toBe(true);
expect(approx(r.height, 0.05)).toBe(true);
});
it('locks aspect (square viewport, aspect=1 → width === height)', () => {
const r = resizeRect(base, 'se', 0.2, 0, 1, 100, 100, 0.05);
expect(approx(r.width, 0.6)).toBe(true);
expect(approx(r.height, 0.6)).toBe(true);
});
it('keeps the rect inside [0,1]', () => {
const r = resizeRect({ x: 0.8, y: 0.8, width: 0.15, height: 0.15 }, 'se', 0.5, 0.5, undefined, 100, 100, 0.05);
expect(r.x + r.width).toBeLessThanOrEqual(1.0000001);
expect(r.y + r.height).toBeLessThanOrEqual(1.0000001);
});
it('respects maxSize', () => {
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 'se', 1, 1, undefined, 100, 100, 0.05, 0.5);
expect(r.width).toBeLessThanOrEqual(0.5 + 1e-6);
expect(r.height).toBeLessThanOrEqual(0.5 + 1e-6);
});
it('edge handle "e" resizes width only', () => {
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 'e', 0.2, 0.1, undefined, 100, 100, 0.05);
expect(approx(r.width, 0.6)).toBe(true);
expect(approx(r.height, 0.4)).toBe(true);
expect(approx(r.y, 0.1)).toBe(true);
});
it('edge handle "s" resizes height only', () => {
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 's', 0.2, 0.1, undefined, 100, 100, 0.05);
expect(approx(r.width, 0.4)).toBe(true);
expect(approx(r.height, 0.5)).toBe(true);
expect(approx(r.x, 0.1)).toBe(true);
});
it('edge handle "n" with aspect derives width from height', () => {
// square viewport + aspect 1 → height change pulls width to match.
const r = resizeRect({ x: 0.2, y: 0.2, width: 0.4, height: 0.4 }, 'n', 0, -0.1, 1, 100, 100, 0.05);
expect(approx(r.height, 0.5)).toBe(true);
expect(approx(r.width, 0.5)).toBe(true);
});
});
describe('ZoomPan', () => {
it('clamps scale to [min,max]', () => {
const z = ZoomPan.create({ minScale: 1, maxScale: 3 });
z.setViewport(100, 100);
z.zoomTo(5);
expect(z.scale).toBe(3);
z.zoomTo(0.5);
expect(z.scale).toBe(1);
});
it('clamps pan so the content keeps covering the viewport', () => {
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
z.setViewport(100, 100);
z.panBy(50, 50); // at scale 1 the offset must stay 0
expect(z.offset).toEqual({ x: 0, y: 0 });
z.zoomTo(2); // content = 200px → offset ∈ [-100, 0]
z.panBy(-40, -40);
expect(z.offset.x).toBeLessThanOrEqual(0);
expect(z.offset.x).toBeGreaterThanOrEqual(-100);
});
it('viewportFracToContentFrac is identity at scale 1', () => {
const z = ZoomPan.create();
z.setViewport(100, 100);
expect(z.viewportFracToContentFrac(0.3, 0.7)).toEqual({ x: 0.3, y: 0.7 });
});
it('maps fractions under zoom (from the corner)', () => {
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
z.setViewport(100, 100);
z.zoomTo(2, 0, 0); // zoom centered on the corner → offset stays 0
expect(z.viewportFracToContentFrac(0.5, 0.5)).toEqual({ x: 0.25, y: 0.25 });
});
it('reset returns to scale 1 / no offset', () => {
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
z.setViewport(100, 100);
z.zoomTo(3);
z.reset();
expect(z.scale).toBe(1);
expect(z.offset).toEqual({ x: 0, y: 0 });
});
});
describe('CropperProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('setCrop updates the rect + fires onCropChange', () => {
installSomaHarness();
const opts = cropperOpts();
const onCropChange = vi.fn();
opts.onCropChange.current = onCropChange;
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
const next = { x: 0, y: 0, width: 0.5, height: 0.5 };
p.setCrop(next);
expect(opts.crop.current).toEqual(next);
expect(onCropChange).toHaveBeenCalledWith(next);
cleanup();
});
it('disabled blocks setCrop', () => {
installSomaHarness();
const opts = cropperOpts();
opts.disabled.current = true;
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
p.setCrop({ x: 0, y: 0, width: 0.2, height: 0.2 });
expect(opts.crop.current).toEqual({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 });
cleanup();
});
it('exposes crop + shape + dragging in snippetProps', () => {
installSomaHarness();
const opts = cropperOpts();
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
expect(p.snippetProps).toMatchObject({ shape: 'rect', dragging: false, disabled: false });
expect(typeof p.snippetProps.cropImage).toBe('function');
cleanup();
});
});

@ -0,0 +1,639 @@
import { context, type WithRefOpts } from '../../provider';
import { type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn } from '../../types';
import { cropperMorfo } from '../../../morfo/components/cropper';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { ZoomPan } from '../../layers/zoom-pan.svelte';
import { CROPPER_LANGS } from './langs';
import type { CropCorner, CropRect, CropResult, CropShape } from './types';
// ── Pure geometry (normalized 0–1 fractions of the image / viewport) ─────────
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
/** Translate the rect, keeping it inside [0,1]. */
export function moveRect(r: CropRect, dxN: number, dyN: number): CropRect {
return {
...r,
x: clamp(r.x + dxN, 0, 1 - r.width),
y: clamp(r.y + dyN, 0, 1 - r.height)
};
}
/**
* Resize the rect by dragging `corner` by (dxN, dyN). Enforces `minSize`, the
* optional pixel `aspect` (needs the viewport px size W×H to relate normalized
* width↔height), and the [0,1] bounds. Edge clamping may relax aspect exactly at
* a boundary (documented v1 limitation).
*/
export function resizeRect(
r: CropRect,
corner: CropCorner,
dxN: number,
dyN: number,
aspect: number | undefined,
W: number,
H: number,
minSize: number,
maxSize = 1
): CropRect {
let x = r.x;
let y = r.y;
let right = r.x + r.width;
let bottom = r.y + r.height;
if (corner.includes('e')) right += dxN;
if (corner.includes('w')) x += dxN;
if (corner.includes('s')) bottom += dyN;
if (corner.includes('n')) y += dyN;
let width = right - x;
let height = bottom - y;
// Min size — push the moving edge back if it crossed the anchor.
if (width < minSize) {
if (corner.includes('w')) x = right - minSize;
width = minSize;
}
if (height < minSize) {
if (corner.includes('n')) y = bottom - minSize;
height = minSize;
}
// Aspect lock — derive the dependent dimension, keeping the anchor edge.
if (aspect && aspect > 0 && W > 0 && H > 0) {
if (corner.includes('e') || corner.includes('w')) {
// A horizontal edge drove the change → derive height from width.
const targetHeight = (width * W) / (aspect * H);
if (corner.includes('n')) y = y + height - targetHeight;
height = targetHeight;
} else {
// A vertical-only edge (n / s) drove it → derive width from height.
const targetWidth = (height * aspect * H) / W;
width = targetWidth;
}
}
// Max size — clamp from the anchor edge (the corner not being dragged).
if (corner.includes('w') && width > maxSize) x = x + width - maxSize;
if (corner.includes('n') && height > maxSize) y = y + height - maxSize;
width = Math.min(width, maxSize);
height = Math.min(height, maxSize);
// Bounds.
x = clamp(x, 0, 1 - minSize);
y = clamp(y, 0, 1 - minSize);
width = clamp(width, minSize, 1 - x);
height = clamp(height, minSize, 1 - y);
return { x, y, width, height };
}
const DEFAULT_CROP: CropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
function loadImage(win: Window, src: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const ImageCtor = (win as unknown as { Image: typeof Image }).Image;
if (!ImageCtor) return resolve(null);
const img = new ImageCtor();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
// ── Root provider ────────────────────────────────────────────────────────────
interface CropperOpts
extends WithRefOpts,
StateProps<{ crop: CropRect }>,
ActiveProps<{
src: string | null | undefined;
aspect: number | undefined;
shape: CropShape;
minSize: number;
maxSize: number;
fixedSize: number | undefined;
minScale: number;
maxScale: number;
disabled: boolean;
onCropChange: OnChangeFn<CropRect> | undefined;
onCropComplete: OnChangeFn<CropResult> | undefined;
}> {
ariaLabel?: Active<string | undefined>;
}
export class CropperProvider {
readonly opts: CropperOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
/** Active move/resize gesture flag — drives Selection's `data-dragging`. */
dragging = $state(false);
/** The Viewport element, registered by the Viewport provider — its px size
* converts pointer deltas to normalized space. */
viewportEl = $state<HTMLElement | null>(null);
/** Reusable zoom/pan state for the image (scale + offset). */
readonly zoom: ZoomPan;
static readonly ctx = context<CropperProvider>('Cropper');
static get(): CropperProvider | undefined {
return this.ctx.getOr(undefined) as CropperProvider | undefined;
}
static require(): CropperProvider {
return this.ctx.get();
}
static create(opts: CropperOpts) {
return new CropperProvider(opts);
}
private constructor(opts: CropperOpts) {
this.opts = opts;
this.soma = Soma.require();
this.zoom = ZoomPan.create({
minScale: opts.minScale.current,
maxScale: opts.maxScale.current
});
this.runtime = this.soma.runtime(cropperMorfo, {
props: {
disabled: () => this.opts.disabled.current,
ariaLabel: () => this.resolvedAriaLabel
},
events: {
'handle-drag': () => {},
'handle-resize': () => {},
'handle-zoom': () => {},
'commit-crop': () => {}
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: CropperProvider.ctx,
syncAttrs: true
});
// Fixed-size crop: pin the selection to `fixedSize` (respecting `aspect`),
// re-centering. Resize is then suppressed (no handles) — move-only.
$effect(() => {
const fixed = this.opts.fixedSize.current;
if (fixed == null) return;
const { w, h } = this.viewportSize();
const aspect = this.opts.aspect.current;
const width = fixed;
const height = aspect && aspect > 0 ? (fixed * w) / (aspect * h) : fixed;
const c = this.opts.crop.current;
const cx = c.x + c.width / 2;
const cy = c.y + c.height / 2;
const next: CropRect = {
width,
height,
x: clamp(cx - width / 2, 0, 1 - width),
y: clamp(cy - height / 2, 0, 1 - height)
};
if (
Math.abs(next.width - c.width) > 1e-6 ||
Math.abs(next.height - c.height) > 1e-6
) {
this.opts.crop.current = next;
}
});
}
/** True when the crop is a fixed size (move-only, no resize handles). */
readonly isFixed = $derived.by(() => this.opts.fixedSize.current != null);
readonly resolvedAriaLabel = $derived.by(
() => this.opts.ariaLabel?.current || this.soma.langs.ts(CROPPER_LANGS.LABEL)
);
/** Viewport px size, for pointer-delta → normalized conversion. */
viewportSize(): { w: number; h: number } {
const el = this.viewportEl;
if (!el) return { w: 1, h: 1 };
const r = el.getBoundingClientRect();
return { w: r.width || 1, h: r.height || 1 };
}
/** Commit a new crop rect + fire `onCropChange`. */
setCrop(rect: CropRect): void {
if (this.opts.disabled.current) return;
this.opts.crop.current = rect;
this.opts.onCropChange.current?.(rect);
}
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
/**
* Produce the cropped image as a PNG Blob via an offscreen `<canvas>` (alpha-
* masked when `shape='round'`). Fires `onCropComplete` + the `commit-crop`
* perceptual event. Returns null when there's no source / canvas support.
*/
async cropImage(): Promise<CropResult | null> {
const src = this.opts.src.current;
if (!src) return null;
const doc = this.soma.dom.getDocument();
const win = this.soma.dom.getWindow();
if (!doc || !win) return null;
const img = await loadImage(win, src);
if (!img) return null;
const nw = img.naturalWidth || img.width;
const nh = img.naturalHeight || img.height;
if (!nw || !nh) return null;
// Map the crop (viewport fractions) → image fractions, inverting the
// current zoom/pan. At scale 1 / offset 0 this is the identity.
const c = this.opts.crop.current;
const tl = this.zoom.viewportFracToContentFrac(c.x, c.y);
const br = this.zoom.viewportFracToContentFrac(c.x + c.width, c.y + c.height);
const sx = Math.round(clamp(tl.x, 0, 1) * nw);
const sy = Math.round(clamp(tl.y, 0, 1) * nh);
const sw = Math.max(1, Math.round((clamp(br.x, 0, 1) - clamp(tl.x, 0, 1)) * nw));
const sh = Math.max(1, Math.round((clamp(br.y, 0, 1) - clamp(tl.y, 0, 1)) * nh));
const canvas = doc.createElement('canvas');
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const shape = this.opts.shape.current;
if (shape === 'round') {
ctx.save();
ctx.beginPath();
ctx.ellipse(sw / 2, sh / 2, sw / 2, sh / 2, 0, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
}
// 5-arg form (full image, offset) — NOT the 9-arg source-rect form, which
// silently draws nothing for SVGs that lack intrinsic width/height (Chrome).
// Drawing the whole image at natural size, offset by -(sx,sy), clips the
// (sx,sy,sw,sh) region into the canvas. Works for SVG + raster alike.
ctx.drawImage(img, -sx, -sy, nw, nh);
if (shape === 'round') ctx.restore();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/png')
);
if (!blob) return null;
const result: CropResult = { blob, crop: { ...c }, shape };
this.opts.onCropComplete.current?.(result);
void this.runtime.trigger('commit-crop');
return result;
}
// ── Zoom ─────────────────────────────────────────────────────────────────
private zoomEmitThrottled = false;
private emitZoom(): void {
// Throttle the perceptual cue — the wheel fires fast.
if (this.zoomEmitThrottled) return;
this.zoomEmitThrottled = true;
void this.runtime.trigger('handle-zoom');
const win = this.soma.dom.getWindow();
(win ?? globalThis).setTimeout(() => (this.zoomEmitThrottled = false), 120);
}
private syncZoomViewport(): void {
const { w, h } = this.viewportSize();
this.zoom.setViewport(w, h);
}
/** Zoom by a multiplicative factor, centered on a viewport point (px). */
zoomBy(factor: number, cx?: number, cy?: number): void {
if (this.opts.disabled.current) return;
this.syncZoomViewport();
this.zoom.zoomBy(factor, cx, cy);
this.emitZoom();
}
/** Set the absolute scale (clamped). */
zoomTo(scale: number): void {
if (this.opts.disabled.current) return;
this.syncZoomViewport();
this.zoom.zoomTo(scale);
this.emitZoom();
}
/** Back to scale 1, no pan. */
resetZoom(): void {
this.zoom.reset();
}
/** Pan the image by a viewport-px delta. */
panBy(dxPx: number, dyPx: number): void {
if (this.opts.disabled.current) return;
this.syncZoomViewport();
this.zoom.panBy(dxPx, dyPx);
}
readonly snippetProps = $derived.by(() => ({
crop: this.opts.crop.current,
shape: this.opts.shape.current,
dragging: this.dragging,
disabled: this.opts.disabled.current,
scale: this.zoom.scale,
isZoomed: this.zoom.isZoomed,
isFixed: this.isFixed,
minScale: this.zoom.minScale,
maxScale: this.zoom.maxScale,
transform: this.zoom.transform,
cropImage: () => this.cropImage(),
zoomTo: (s: number) => this.zoomTo(s),
resetZoom: () => this.resetZoom()
}));
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Viewport ───────────────────────────────────────────────────────────────
interface CropperViewportOpts extends WithRefOpts {}
export class CropperViewportProvider {
readonly opts: CropperViewportOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: CropperProvider;
static create(opts: CropperViewportOpts) {
return new CropperViewportProvider(opts);
}
private constructor(opts: CropperViewportOpts) {
this.opts = opts;
this.provider = CropperProvider.require();
this.runtimePart = this.provider.runtime.part('viewport', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
// Register the viewport element with the root for px-size lookups.
this.provider.viewportEl = opts.ref.current;
$effect(() => {
this.provider.viewportEl = opts.ref.current;
});
}
private panLastX = 0;
private panLastY = 0;
private panning = false;
private localPoint(e: PointerEvent | WheelEvent): { x: number; y: number } {
const r = this.opts.ref?.current?.getBoundingClientRect();
return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0) };
}
readonly onwheel = (e: WheelEvent) => {
if (this.provider.isDisabled) return;
e.preventDefault();
const p = this.localPoint(e);
this.provider.zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, p.x, p.y);
};
// Pan the image. Reaches here only on a background pointerdown — the Selection
// and Handles stop propagation. (At scale 1 the clamp keeps offset at 0.)
readonly onpointerdown = (e: PointerEvent) => {
if (this.provider.isDisabled) return;
const el = this.opts.ref?.current;
if (!el) return;
this.panning = true;
this.panLastX = e.clientX;
this.panLastY = e.clientY;
this.provider.dragging = true;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* no active pointer (synthetic) */
}
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.panning) return;
this.provider.panBy(e.clientX - this.panLastX, e.clientY - this.panLastY);
this.panLastX = e.clientX;
this.panLastY = e.clientY;
};
readonly onpointerup = (e: PointerEvent) => {
if (!this.panning) return;
this.panning = false;
this.provider.dragging = false;
this.opts.ref?.current?.releasePointerCapture(e.pointerId);
};
// The image is a native `<img>` → the browser starts its own drag-and-drop on
// pointerdown, which cancels the pan. Suppress it (bubbles up from the image).
readonly ondragstart = (e: Event) => e.preventDefault();
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onwheel: this.onwheel,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onpointercancel: this.onpointerup,
ondragstart: this.ondragstart
}));
}
// ── Selection (move) ─────────────────────────────────────────────────────────
interface CropperSelectionOpts extends WithRefOpts {}
export class CropperSelectionProvider {
readonly opts: CropperSelectionOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: CropperProvider;
private startCrop: CropRect | null = null;
private startX = 0;
private startY = 0;
private size = { w: 1, h: 1 };
static create(opts: CropperSelectionOpts) {
return new CropperSelectionProvider(opts);
}
private constructor(opts: CropperSelectionOpts) {
this.opts = opts;
this.provider = CropperProvider.require();
this.runtimePart = this.provider.runtime.part('selection', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
shape: () => this.provider.opts.shape.current,
dragging: () => this.provider.dragging
},
syncAttrs: true
});
}
readonly onpointerdown = (e: PointerEvent) => {
if (this.provider.isDisabled) return;
const el = this.opts.ref?.current;
if (!el) return;
e.preventDefault();
// The selection CAPTURES the drag and moves the crop area (Cropper.js model)
// — stop it bubbling to the Viewport's pan handler. The image is panned only
// by dragging the background; the handles resize.
e.stopPropagation();
this.startCrop = { ...this.provider.opts.crop.current };
this.startX = e.clientX;
this.startY = e.clientY;
this.size = this.provider.viewportSize();
this.provider.dragging = true;
void this.provider.runtime.trigger('handle-drag');
// Best-effort — capture keeps moves flowing here even outside the element.
try {
el.setPointerCapture(e.pointerId);
} catch {
/* no active pointer (synthetic events) */
}
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.startCrop) return;
const dxN = (e.clientX - this.startX) / this.size.w;
const dyN = (e.clientY - this.startY) / this.size.h;
this.provider.setCrop(moveRect(this.startCrop, dxN, dyN));
};
readonly onpointerup = (e: PointerEvent) => {
if (!this.startCrop) return;
this.startCrop = null;
this.provider.dragging = false;
this.opts.ref?.current?.releasePointerCapture(e.pointerId);
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onpointercancel: this.onpointerup
}));
}
// ── Grid ─────────────────────────────────────────────────────────────────────
interface CropperGridOpts extends WithRefOpts {}
export class CropperGridProvider {
readonly opts: CropperGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: CropperProvider;
static create(opts: CropperGridOpts) {
return new CropperGridProvider(opts);
}
private constructor(opts: CropperGridOpts) {
this.opts = opts;
this.provider = CropperProvider.require();
this.runtimePart = this.provider.runtime.part('grid', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Handle (resize) ──────────────────────────────────────────────────────────
interface CropperHandleOpts extends WithRefOpts, ActiveProps<{ corner: CropCorner }> {}
export class CropperHandleProvider {
readonly opts: CropperHandleOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: CropperProvider;
private startCrop: CropRect | null = null;
private startX = 0;
private startY = 0;
private size = { w: 1, h: 1 };
static create(opts: CropperHandleOpts) {
return new CropperHandleProvider(opts);
}
private constructor(opts: CropperHandleOpts) {
this.opts = opts;
this.provider = CropperProvider.require();
this.runtimePart = this.provider.runtime.part('handle', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
corner: () => this.opts.corner.current
},
syncAttrs: true
});
}
readonly onpointerdown = (e: PointerEvent) => {
if (this.provider.isDisabled || this.provider.isFixed) return;
const el = this.opts.ref?.current;
if (!el) return;
// Stop the Selection's move handler from also firing on this pointerdown —
// must run BEFORE setPointerCapture (which can throw on synthetic events).
e.preventDefault();
e.stopPropagation();
this.startCrop = { ...this.provider.opts.crop.current };
this.startX = e.clientX;
this.startY = e.clientY;
this.size = this.provider.viewportSize();
this.provider.dragging = true;
void this.provider.runtime.trigger('handle-resize');
try {
el.setPointerCapture(e.pointerId);
} catch {
/* no active pointer (synthetic events) */
}
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.startCrop) return;
const dxN = (e.clientX - this.startX) / this.size.w;
const dyN = (e.clientY - this.startY) / this.size.h;
this.provider.setCrop(
resizeRect(
this.startCrop,
this.opts.corner.current,
dxN,
dyN,
this.provider.opts.aspect.current,
this.size.w,
this.size.h,
this.provider.opts.minSize.current,
this.provider.opts.maxSize.current
)
);
};
readonly onpointerup = (e: PointerEvent) => {
if (!this.startCrop) return;
this.startCrop = null;
this.provider.dragging = false;
this.opts.ref?.current?.releasePointerCapture(e.pointerId);
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onpointercancel: this.onpointerup
}));
}

@ -0,0 +1,19 @@
export { default as Provider } from './components/cropper.svelte';
export { default as Viewport } from './components/cropper-viewport.svelte';
export { default as Selection } from './components/cropper-selection.svelte';
export { default as Grid } from './components/cropper-grid.svelte';
export { default as Handle } from './components/cropper-handle.svelte';
export { moveRect, resizeRect } from './cropper-provider.svelte';
export type {
CropRect,
CropShape,
CropCorner,
CropResult,
CropperProps as ProviderProps,
CropperViewportProps as ViewportProps,
CropperSelectionProps as SelectionProps,
CropperGridProps as GridProps,
CropperHandleProps as HandleProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,8 @@
/**
* Lang refs for Cropper. Resolved via `soma.langs.ts(ref)`. Catalog leaves live
* in `src/uix/langs/components/cropper.ts`.
*/
export const CROPPER_LANGS = {
LABEL: '#?components.cropper.label|Crop image',
SELECTION: '#?components.cropper.selection|Crop region'
} as const;

@ -0,0 +1,106 @@
import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes } from '../../types';
/** Crop rectangle, NORMALIZED to the image (0–1 fractions). Resolution-independent. */
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
/** Crop shape — rectangular or circular (avatar). */
export type CropShape = 'rect' | 'round';
/** A resize handle position. v1 renders the four corners. */
export type CropCorner = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 'e' | 's' | 'w';
/** Payload of `onCropComplete` / the `crop()` method — the produced image. */
export interface CropResult {
/** The cropped image as a PNG Blob (alpha-masked when `shape='round'`). */
blob: Blob;
/** The normalized crop rect that produced it. */
crop: CropRect;
/** The shape used. */
shape: CropShape;
}
export type CropperProps = WithChild<
{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Image source to crop — typically the `url` an ImagePicker emits. */
src?: string | null;
/** The crop rect, normalized 0–1. Bindable. @default centered 80% */
crop?: CropRect;
/** Lock the crop to this width/height ratio (display pixels). Omit for free. */
aspect?: number;
/** Crop shape. @default 'rect' */
shape?: CropShape;
/** Minimum crop size as a fraction of the image (0–1). @default 0.05 */
minSize?: number;
/** Maximum crop size as a fraction of the image (0–1). @default 1 */
maxSize?: number;
/**
* Fix the crop to this size (fraction of the image) — the selection becomes
* move-only (no resize handles). Combine with `shape='round'` for a
* fixed-radius avatar crop. Respects `aspect` for the height.
*/
fixedSize?: number;
/** Minimum zoom scale. @default 1 */
minScale?: number;
/** Maximum zoom scale. @default 5 */
maxScale?: number;
/** Disable interaction. @default false */
disabled?: boolean;
/** Fired live as the crop rect changes (move / resize). */
onCropChange?: OnChangeFn<CropRect>;
/** Fired when `crop()` produces the cropped image. */
onCropComplete?: OnChangeFn<CropResult>;
},
CropperSnippetProps
> &
Without<PrimitiveDivAttributes, { crop?: unknown }>;
/** Snippet props exposed by the Provider's `children` / `child`. */
export type CropperSnippetProps = {
/** The current normalized crop rect. */
crop: CropRect;
/** The crop shape. */
shape: CropShape;
/** Whether a move / resize gesture is active. */
dragging: boolean;
/** Whether interaction is disabled. */
disabled: boolean;
/** Current zoom scale. */
scale: number;
/** Whether the image is zoomed past the minimum. */
isZoomed: boolean;
/** Whether the crop is a fixed size (no resize handles). */
isFixed: boolean;
/** Min / max zoom scale. */
minScale: number;
maxScale: number;
/** CSS transform for the image element. */
transform: string;
/** Produce the cropped image now (also fires `onCropComplete`). */
cropImage: () => Promise<CropResult | null>;
/** Set the absolute zoom scale. */
zoomTo: (scale: number) => void;
/** Reset zoom to 1 / no pan. */
resetZoom: () => void;
};
export type CropperViewportProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CropperSelectionProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CropperGridProps = WithChild<{ id?: string }> & Without<PrimitiveDivAttributes, {}>;
export type CropperHandleProps = WithChild<{
id?: string;
/** Which corner / edge this handle drives. */
corner: CropCorner;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -0,0 +1,80 @@
# ImageAdjustments (soma)
Headless provider for a panel of **image-filter sliders**. Holds the per-adjustment
values, computes a live CSS `filter` string, and exposes a reset. It does **not**
render sliders — it composes the framework's `<Slider>` one per row in eidos, and
streams the recomputed `filter` out so any surface can apply it.
> The component's real product is a portable CSS `filter` string. Wire
> `onFilterChange` to a preview `<img>`, an avatar, a Words image, or a future
> cropper — the math lives here once.
## Anatomy
```svelte
<ImageAdjustments.Provider bind:value onFilterChange={…} adjustments={…}>
<ImageAdjustments.Item adjustment="brightness">
<ImageAdjustments.ItemLabel>…</ImageAdjustments.ItemLabel>
<!-- compose <Slider> here, bound to the Item snippet's value/setValue -->
<ImageAdjustments.ItemValue>…</ImageAdjustments.ItemValue>
</ImageAdjustments.Item>
…
<ImageAdjustments.Reset />
</ImageAdjustments.Provider>
```
| Part | Element | Role |
| --- | --- | --- |
| `Provider` | `div` | `role="group"`, `data-image-adjustments`, `data-disabled` |
| `Item` | `div` | one adjustment row; `data-adjustment="{key}"` |
| `ItemLabel` | `span` | the adjustment's localized label |
| `ItemValue` | `span` | the formatted read-out (`+20`, `60°`, `4px`, …) |
| `Reset` | `button` | resets every adjustment to neutral |
`Item`/`ItemLabel`/`ItemValue`/`Reset` are the **internal** parts the configured
eidos component renders in a loop — consumers normally use the eidos
`<ImageAdjustments>` and never compose them by hand.
## Adjustment catalog
`IMAGE_ADJUSTMENTS` (exported) maps each key to its range + CSS `filter()` fragment.
`computeImageFilter(values, order)` is a pure function — `'none'` when neutral.
| Key | Range | CSS |
| --- | --- | --- |
| `brightness` | −100…100 | `brightness((100+v)/100)` |
| `contrast` | −100…100 | `contrast((100+v)/100)` |
| `saturation` | −100…100 | `saturate((100+v)/100)` |
| `temperature` | −100…100 | approx — warm→`sepia`, cool→`hue-rotate`+`saturate` |
| `hue` | −180…180° | `hue-rotate(v deg)` |
| `blur` | 0…20px | `blur(v px)` |
| `grayscale` | 0…100% | `grayscale(v%)` |
| `sepia` | 0…100% | `sepia(v%)` |
`temperature` is a documented CSS-only approximation (no native white-balance).
Tone curves (`highlights`/`shadows`) need canvas / SVG `feComponentTransfer` and are
intentionally out of scope.
## Sema events
| Event | Family · verb | Target | Intent | When |
| --- | --- | --- | --- | --- |
| `commit-reset` | commit · reset | provider | neutral | user resets every adjustment to neutral |
Per-slider drag feedback (`handle-drag`) is owned by the composed `<Slider>` pack —
ImageAdjustments doesn't re-emit it. Pack: `src/uix/sema/components/image-adjustments.ts`
(`form.commit.soft` + a `tap`). `sequence: 'post'` — the values mutate first, then the
pulse.
## Props (Provider)
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `ImageAdjustmentValues` | `{}` | bindable; missing keys = neutral |
| `onValueChange` | `(v) => void` | — | full values record on any change |
| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` string |
| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order |
| `disabled` | `boolean` | `false` | blocks sliders + reset |
| `aria-label` | `string` | "Image adjustments" | names the group |
Reuses `Slider` (one per `Item`). Never reinvents a slider.

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImageAdjustmentsItemLabelProvider } from '../image-adjustments-provider.svelte';
import type { ImageAdjustmentsItemLabelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-adjustments-item-label'),
children,
child,
...restProps
}: ImageAdjustmentsItemLabelProps = $props();
const state = ImageAdjustmentsItemLabelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImageAdjustmentsItemValueProvider } from '../image-adjustments-provider.svelte';
import type { ImageAdjustmentsItemValueProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-adjustments-item-value'),
children,
child,
...restProps
}: ImageAdjustmentsItemValueProps = $props();
const state = ImageAdjustmentsItemValueProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,47 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImageAdjustmentsItemProvider } from '../image-adjustments-provider.svelte';
import type { ImageAdjustmentsItemProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-adjustments-item'),
adjustment,
children,
child,
...restProps
}: ImageAdjustmentsItemProps = $props();
const state = ImageAdjustmentsItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
adjustment: readableActive(() => adjustment)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
adjustment,
value: state.value,
label: state.label,
formattedValue: state.formattedValue,
def: state.def,
disabled: state.isDisabled,
setValue: (v: number) => state.setValue(v)
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImageAdjustmentsResetProvider } from '../image-adjustments-provider.svelte';
import type { ImageAdjustmentsResetProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-adjustments-reset'),
children,
child,
...restProps
}: ImageAdjustmentsResetProps = $props();
const state = ImageAdjustmentsResetProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ disabled: state.isDisabled, label: state.label });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.(snippetProps)}
</button>
{/if}

@ -0,0 +1,50 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImageAdjustmentsProvider } from '../image-adjustments-provider.svelte';
import type { ImageAdjustmentsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-adjustments'),
value = $bindable({}),
onValueChange = () => {},
onFilterChange = () => {},
adjustments,
disabled = false,
'aria-label': ariaLabel,
children,
child,
...restProps
}: ImageAdjustmentsProps = $props();
const state = ImageAdjustmentsProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
onValueChange: readableActive(() => onValueChange),
onFilterChange: readableActive(() => onFilterChange),
adjustments: readableActive(() => adjustments),
disabled: readableActive(() => disabled),
ariaLabel: readableActive(() => ariaLabel ?? undefined)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,22 @@
export { default as Provider } from './components/image-adjustments.svelte';
export { default as Item } from './components/image-adjustments-item.svelte';
export { default as ItemLabel } from './components/image-adjustments-item-label.svelte';
export { default as ItemValue } from './components/image-adjustments-item-value.svelte';
export { default as Reset } from './components/image-adjustments-reset.svelte';
export {
IMAGE_ADJUSTMENTS,
DEFAULT_IMAGE_ADJUSTMENTS,
computeImageFilter
} from './image-adjustments-provider.svelte';
export type {
AdjustmentDef,
ImageAdjustmentKey,
ImageAdjustmentValues,
ImageAdjustmentsProps as ProviderProps,
ImageAdjustmentsItemProps as ItemProps,
ImageAdjustmentsItemLabelProps as ItemLabelProps,
ImageAdjustmentsItemValueProps as ItemValueProps,
ImageAdjustmentsResetProps as ResetProps
} from './types';

@ -0,0 +1,207 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import {
ImageAdjustmentsProvider,
IMAGE_ADJUSTMENTS,
DEFAULT_IMAGE_ADJUSTMENTS,
computeImageFilter
} from './image-adjustments-provider.svelte';
import type { ImageAdjustmentKey, ImageAdjustmentValues } from './types';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
async function flushRuntimeTrigger() {
await Promise.resolve();
await tick();
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
// Passthrough langs stub (mirrors the toggle harness's `translate: key => key`).
// The real catalog is validated by `translations:check`, not here.
langs: { ts: (ref: string) => ref },
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
// `runtime.part` sets the provider context; setContext can't run inside an
// effect-root, so bypass it (same shim as the slider provider test).
vi.spyOn(ImageAdjustmentsProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function adjustmentsOpts(id = 'ia-root') {
return {
id: state(id),
ref: state<HTMLElement | null>(document.createElement('div')),
value: state<ImageAdjustmentValues>({}),
onValueChange: state<((v: ImageAdjustmentValues) => void) | undefined>(undefined),
onFilterChange: state<((f: string) => void) | undefined>(undefined),
adjustments: state<ImageAdjustmentKey[] | undefined>(undefined),
disabled: state(false),
ariaLabel: state<string | undefined>(undefined)
};
}
describe('computeImageFilter', () => {
it('returns "none" when every adjustment is neutral', () => {
expect(computeImageFilter({}, DEFAULT_IMAGE_ADJUSTMENTS)).toBe('none');
expect(computeImageFilter({ brightness: 0, contrast: 0 }, DEFAULT_IMAGE_ADJUSTMENTS)).toBe(
'none'
);
});
it('maps tone adjustments to their CSS functions', () => {
expect(computeImageFilter({ brightness: 20 }, ['brightness'])).toBe('brightness(1.2)');
expect(computeImageFilter({ contrast: -50 }, ['contrast'])).toBe('contrast(0.5)');
expect(computeImageFilter({ saturation: 100 }, ['saturation'])).toBe('saturate(2)');
expect(computeImageFilter({ hue: 90 }, ['hue'])).toBe('hue-rotate(90deg)');
});
it('maps the 0-based adjustments and skips them at zero', () => {
expect(computeImageFilter({ blur: 4 }, ['blur'])).toBe('blur(4px)');
expect(computeImageFilter({ blur: 0 }, ['blur'])).toBe('none');
expect(computeImageFilter({ grayscale: 60 }, ['grayscale'])).toBe('grayscale(60%)');
expect(computeImageFilter({ sepia: 100 }, ['sepia'])).toBe('sepia(100%)');
});
it('approximates temperature: warm leans sepia, cool hue-rotates + saturates', () => {
expect(computeImageFilter({ temperature: 50 }, ['temperature'])).toBe('sepia(0.250)');
expect(computeImageFilter({ temperature: -50 }, ['temperature'])).toBe(
'hue-rotate(-17.5deg) saturate(1.200)'
);
});
it('composes multiple adjustments in declared order', () => {
expect(
computeImageFilter({ contrast: 10, brightness: 20 }, ['brightness', 'contrast'])
).toBe('brightness(1.2) contrast(1.1)');
});
});
describe('ImageAdjustmentsProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('computes the live filter from current values', () => {
installSomaHarness();
const opts = adjustmentsOpts();
const { result: provider, cleanup } = withEffectRoot(() =>
ImageAdjustmentsProvider.create(opts)
);
expect(provider.filter).toBe('none');
expect(provider.isModified).toBe(false);
opts.value.current = { brightness: 30 };
expect(provider.filter).toBe('brightness(1.3)');
expect(provider.isModified).toBe(true);
cleanup();
});
it('setAdjustment mutates value + fires onValueChange/onFilterChange', () => {
installSomaHarness();
const opts = adjustmentsOpts();
const onValueChange = vi.fn();
const onFilterChange = vi.fn();
opts.onValueChange.current = onValueChange;
opts.onFilterChange.current = onFilterChange;
const { result: provider, cleanup } = withEffectRoot(() =>
ImageAdjustmentsProvider.create(opts)
);
provider.setAdjustment('contrast', -40);
expect(opts.value.current).toEqual({ contrast: -40 });
expect(onValueChange).toHaveBeenCalledWith({ contrast: -40 });
expect(onFilterChange).toHaveBeenLastCalledWith('contrast(0.6)');
expect(provider.getAdjustment('contrast')).toBe(-40);
cleanup();
});
it('reset() clears values + fires callbacks through the runtime', async () => {
installSomaHarness();
const opts = adjustmentsOpts();
opts.value.current = { brightness: 20, blur: 5 };
const onFilterChange = vi.fn();
opts.onFilterChange.current = onFilterChange;
const { result: provider, cleanup } = withEffectRoot(() =>
ImageAdjustmentsProvider.create(opts)
);
expect(provider.isModified).toBe(true);
provider.reset();
await flushRuntimeTrigger();
expect(opts.value.current).toEqual({});
expect(provider.filter).toBe('none');
expect(onFilterChange).toHaveBeenLastCalledWith('none');
cleanup();
});
it('honours a custom adjustments subset for filter + isModified', () => {
installSomaHarness();
const opts = adjustmentsOpts();
opts.adjustments.current = ['grayscale'];
// brightness is set but NOT in the active subset → ignored by filter.
opts.value.current = { grayscale: 50, brightness: 20 };
const { result: provider, cleanup } = withEffectRoot(() =>
ImageAdjustmentsProvider.create(opts)
);
expect(provider.filter).toBe('grayscale(50%)');
expect(provider.isModified).toBe(true);
cleanup();
});
it('disabled blocks setAdjustment', () => {
installSomaHarness();
const opts = adjustmentsOpts();
opts.disabled.current = true;
const { result: provider, cleanup } = withEffectRoot(() =>
ImageAdjustmentsProvider.create(opts)
);
provider.setAdjustment('brightness', 50);
expect(opts.value.current).toEqual({});
cleanup();
});
it('exposes the canonical catalog + default set', () => {
expect(DEFAULT_IMAGE_ADJUSTMENTS).toContain('brightness');
expect(IMAGE_ADJUSTMENTS.hue).toMatchObject({ min: -180, max: 180, unit: '°' });
expect(IMAGE_ADJUSTMENTS.blur).toMatchObject({ min: 0, max: 20, unit: 'px' });
});
});

@ -0,0 +1,392 @@
import { context, type WithRefOpts } from '../../provider';
import { type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn } from '../../types';
import { imageAdjustmentsMorfo } from '../../../morfo/components/image-adjustments';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { IMAGE_ADJUSTMENTS_LANGS, IMAGE_ADJUSTMENT_LABELS } from './langs';
import type { AdjustmentDef, ImageAdjustmentKey, ImageAdjustmentValues } from './types';
// ── Adjustment catalog ─────────────────────────────────────────────────────
//
// Each adjustment maps a numeric value to a CSS `filter()` fragment (logic lives
// here in soma — never in morfo). `toFilter` returns '' at the neutral default so
// the composed string only carries non-neutral terms. `temperature` has no native
// CSS primitive, so it's a documented approximation (warm → sepia, cool → blue
// hue-rotate + slight saturation lift). Tone curves (highlights/shadows) need
// canvas / SVG and are deliberately NOT here.
const round = (v: number) => Math.round(v);
const toneFormat = (v: number) => (v > 0 ? `+${round(v)}` : String(round(v)));
/** The canonical adjustment catalog. Exported so eidos / demos can introspect ranges. */
export const IMAGE_ADJUSTMENTS: Record<ImageAdjustmentKey, AdjustmentDef> = {
brightness: {
min: -100,
max: 100,
default: 0,
step: 1,
unit: '',
toFilter: (v) => (v === 0 ? '' : `brightness(${(100 + v) / 100})`),
format: toneFormat
},
contrast: {
min: -100,
max: 100,
default: 0,
step: 1,
unit: '',
toFilter: (v) => (v === 0 ? '' : `contrast(${(100 + v) / 100})`),
format: toneFormat
},
saturation: {
min: -100,
max: 100,
default: 0,
step: 1,
unit: '',
toFilter: (v) => (v === 0 ? '' : `saturate(${(100 + v) / 100})`),
format: toneFormat
},
temperature: {
min: -100,
max: 100,
default: 0,
step: 1,
unit: '',
// CSS has no white-balance primitive — approximate: warm (+) leans sepia,
// cool (−) hue-rotates toward blue and lifts saturation slightly.
toFilter: (v) => {
if (v === 0) return '';
if (v > 0) return `sepia(${((v / 100) * 0.5).toFixed(3)})`;
return `hue-rotate(${(v * 0.35).toFixed(1)}deg) saturate(${(1 + -v / 250).toFixed(3)})`;
},
format: toneFormat
},
hue: {
min: -180,
max: 180,
default: 0,
step: 1,
unit: '°',
toFilter: (v) => (v === 0 ? '' : `hue-rotate(${round(v)}deg)`),
format: (v) => `${round(v)}°`
},
blur: {
min: 0,
max: 20,
default: 0,
step: 0.5,
unit: 'px',
toFilter: (v) => (v <= 0 ? '' : `blur(${v}px)`),
format: (v) => `${v}px`
},
grayscale: {
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%',
toFilter: (v) => (v <= 0 ? '' : `grayscale(${round(v)}%)`),
format: (v) => `${round(v)}%`
},
sepia: {
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%',
toFilter: (v) => (v <= 0 ? '' : `sepia(${round(v)}%)`),
format: (v) => `${round(v)}%`
}
};
/** Default adjustment set + order — the core tone controls. */
export const DEFAULT_IMAGE_ADJUSTMENTS: readonly ImageAdjustmentKey[] = [
'brightness',
'contrast',
'saturation',
'temperature',
'hue',
'blur'
];
/** Pure value → CSS `filter` string. 'none' when every adjustment is neutral. */
export function computeImageFilter(
values: ImageAdjustmentValues,
order: readonly ImageAdjustmentKey[]
): string {
const parts: string[] = [];
for (const key of order) {
const def = IMAGE_ADJUSTMENTS[key];
const frag = def.toFilter(values[key] ?? def.default);
if (frag) parts.push(frag);
}
return parts.length ? parts.join(' ') : 'none';
}
// ── Root provider ────────────────────────────────────────────────────────────
interface ImageAdjustmentsOpts
extends WithRefOpts,
StateProps<{ value: ImageAdjustmentValues }>,
ActiveProps<{
adjustments: ImageAdjustmentKey[] | undefined;
disabled: boolean;
onValueChange: OnChangeFn<ImageAdjustmentValues> | undefined;
onFilterChange: OnChangeFn<string> | undefined;
}> {
ariaLabel?: Active<string | undefined>;
}
export class ImageAdjustmentsProvider {
readonly opts: ImageAdjustmentsOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<ImageAdjustmentsProvider>('ImageAdjustments');
static get(): ImageAdjustmentsProvider | undefined {
return this.ctx.getOr(undefined) as ImageAdjustmentsProvider | undefined;
}
static require(): ImageAdjustmentsProvider {
return this.ctx.get();
}
static create(opts: ImageAdjustmentsOpts) {
return new ImageAdjustmentsProvider(opts);
}
private constructor(opts: ImageAdjustmentsOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(imageAdjustmentsMorfo, {
props: {
disabled: () => this.opts.disabled.current,
ariaLabel: () => this.resolvedAriaLabel
},
events: {
// Reset is post-sequence: mutate first, then the perceptual pulse.
'commit-reset': () => {
const next: ImageAdjustmentValues = {};
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
this.opts.onFilterChange.current?.(
computeImageFilter(next, this.resolvedAdjustments)
);
}
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ImageAdjustmentsProvider.ctx,
syncAttrs: true
});
}
/** Resolved adjustment set + order (the prop, or the default core set). */
readonly resolvedAdjustments = $derived.by<readonly ImageAdjustmentKey[]>(
() => this.opts.adjustments.current ?? DEFAULT_IMAGE_ADJUSTMENTS
);
readonly resolvedAriaLabel = $derived.by(
() => this.opts.ariaLabel?.current || this.soma.langs.ts(IMAGE_ADJUSTMENTS_LANGS.LABEL)
);
/** Live CSS `filter` string for the current values. */
readonly filter = $derived.by(() =>
computeImageFilter(this.opts.value.current, this.resolvedAdjustments)
);
/** True when any adjustment differs from its neutral default. */
readonly isModified = $derived.by(() =>
this.resolvedAdjustments.some(
(key) => (this.opts.value.current[key] ?? IMAGE_ADJUSTMENTS[key].default) !== IMAGE_ADJUSTMENTS[key].default
)
);
/** Current value for an adjustment (its neutral default when unset). */
getAdjustment(key: ImageAdjustmentKey): number {
return this.opts.value.current[key] ?? IMAGE_ADJUSTMENTS[key].default;
}
/** Set one adjustment; fires `onValueChange` + `onFilterChange`. */
setAdjustment(key: ImageAdjustmentKey, value: number): void {
if (this.opts.disabled.current) return;
const next: ImageAdjustmentValues = { ...this.opts.value.current, [key]: value };
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
this.opts.onFilterChange.current?.(computeImageFilter(next, this.resolvedAdjustments));
}
/** Reset every adjustment to its neutral default. Drives `commit-reset`. */
reset(): void {
if (this.opts.disabled.current) return;
void this.runtime.trigger('commit-reset');
}
readonly snippetProps = $derived.by(() => ({
filter: this.filter,
isModified: this.isModified,
values: this.opts.value.current
}));
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Item ───────────────────────────────────────────────────────────────────
interface ImageAdjustmentsItemOpts
extends WithRefOpts,
ActiveProps<{ adjustment: ImageAdjustmentKey }> {}
export class ImageAdjustmentsItemProvider {
readonly opts: ImageAdjustmentsItemOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImageAdjustmentsProvider;
static readonly ctx = context<ImageAdjustmentsItemProvider>('ImageAdjustmentsItem');
static require(): ImageAdjustmentsItemProvider {
return this.ctx.get();
}
static create(opts: ImageAdjustmentsItemOpts) {
return new ImageAdjustmentsItemProvider(opts);
}
private constructor(opts: ImageAdjustmentsItemOpts) {
this.opts = opts;
this.provider = ImageAdjustmentsProvider.require();
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ImageAdjustmentsItemProvider.ctx,
props: {
adjustment: () => this.opts.adjustment.current
},
syncAttrs: true
});
}
/** This row's adjustment definition (range / step / unit / mapping). */
readonly def = $derived.by(() => IMAGE_ADJUSTMENTS[this.opts.adjustment.current]);
/** Current value (neutral default when unset). */
readonly value = $derived.by(() => this.provider.getAdjustment(this.opts.adjustment.current));
/** Localized label for this adjustment. */
readonly label = $derived.by(() =>
this.provider.soma.langs.ts(IMAGE_ADJUSTMENT_LABELS[this.opts.adjustment.current])
);
/** Formatted read-out of the current value. */
readonly formattedValue = $derived.by(() => this.def.format(this.value));
readonly isDisabled = $derived.by(() => this.provider.opts.disabled.current);
/** Set this row's value (used by the composed `<Slider>`). */
setValue(value: number): void {
this.provider.setAdjustment(this.opts.adjustment.current, value);
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── ItemLabel ────────────────────────────────────────────────────────────────
interface ImageAdjustmentsItemLabelOpts extends WithRefOpts {}
export class ImageAdjustmentsItemLabelProvider {
readonly opts: ImageAdjustmentsItemLabelOpts;
readonly runtimePart: SomaRuntimePart;
readonly item: ImageAdjustmentsItemProvider;
static create(opts: ImageAdjustmentsItemLabelOpts) {
return new ImageAdjustmentsItemLabelProvider(opts);
}
private constructor(opts: ImageAdjustmentsItemLabelOpts) {
this.opts = opts;
this.item = ImageAdjustmentsItemProvider.require();
this.runtimePart = this.item.provider.runtime.part('item-label', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── ItemValue ────────────────────────────────────────────────────────────────
interface ImageAdjustmentsItemValueOpts extends WithRefOpts {}
export class ImageAdjustmentsItemValueProvider {
readonly opts: ImageAdjustmentsItemValueOpts;
readonly runtimePart: SomaRuntimePart;
readonly item: ImageAdjustmentsItemProvider;
static create(opts: ImageAdjustmentsItemValueOpts) {
return new ImageAdjustmentsItemValueProvider(opts);
}
private constructor(opts: ImageAdjustmentsItemValueOpts) {
this.opts = opts;
this.item = ImageAdjustmentsItemProvider.require();
this.runtimePart = this.item.provider.runtime.part('item-value', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Reset ────────────────────────────────────────────────────────────────────
interface ImageAdjustmentsResetOpts extends WithRefOpts {}
export class ImageAdjustmentsResetProvider {
readonly opts: ImageAdjustmentsResetOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImageAdjustmentsProvider;
static create(opts: ImageAdjustmentsResetOpts) {
return new ImageAdjustmentsResetProvider(opts);
}
private constructor(opts: ImageAdjustmentsResetOpts) {
this.opts = opts;
this.provider = ImageAdjustmentsProvider.require();
this.runtimePart = this.provider.runtime.part('reset', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly isDisabled = $derived.by(
() => this.provider.opts.disabled.current || !this.provider.isModified
);
readonly onclick = () => {
if (this.isDisabled) return;
this.provider.reset();
};
readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_ADJUSTMENTS_LANGS.RESET));
readonly props = $derived.by(() => ({
...this.runtimePart.props,
disabled: this.isDisabled || undefined,
'aria-label': this.label,
onclick: this.onclick
}));
}

@ -0,0 +1,22 @@
import type { ImageAdjustmentKey } from './types';
/**
* Lang refs for ImageAdjustments. Resolved via `soma.langs.ts(ref)`. Catalog
* leaves live in `src/uix/langs/components/image-adjustments.ts`.
*/
export const IMAGE_ADJUSTMENTS_LANGS = {
LABEL: '#?components.image-adjustments.label|Image adjustments',
RESET: '#?components.image-adjustments.reset|Reset'
} as const;
/** Per-adjustment label refs, keyed by adjustment. */
export const IMAGE_ADJUSTMENT_LABELS: Record<ImageAdjustmentKey, string> = {
brightness: '#?components.image-adjustments.adjustments.brightness|Brightness',
contrast: '#?components.image-adjustments.adjustments.contrast|Contrast',
saturation: '#?components.image-adjustments.adjustments.saturation|Saturation',
temperature: '#?components.image-adjustments.adjustments.temperature|Temperature',
hue: '#?components.image-adjustments.adjustments.hue|Hue',
blur: '#?components.image-adjustments.adjustments.blur|Blur',
grayscale: '#?components.image-adjustments.adjustments.grayscale|Grayscale',
sepia: '#?components.image-adjustments.adjustments.sepia|Sepia'
};

@ -0,0 +1,111 @@
import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
/**
* The CSS-mappable adjustment keys. Each maps to a CSS `filter()` function (or a
* best-effort approximation for `temperature`). `highlights`/`shadows` are NOT
* here — they need tone curves (canvas / SVG `feComponentTransfer`), deferred.
*/
export type ImageAdjustmentKey =
| 'brightness'
| 'contrast'
| 'saturation'
| 'temperature'
| 'hue'
| 'blur'
| 'grayscale'
| 'sepia';
/** Per-adjustment values. Missing keys fall back to each adjustment's neutral default. */
export type ImageAdjustmentValues = Partial<Record<ImageAdjustmentKey, number>>;
/** Definition of one adjustment: range, neutral default, CSS mapping, read-out format. */
export interface AdjustmentDef {
min: number;
max: number;
default: number;
step: number;
/** Unit suffix for the read-out (e.g. '°', 'px', '%'); '' for unitless tone. */
unit: string;
/** CSS `filter()` fragment for a value; '' when at neutral default. */
toFilter: (v: number) => string;
/** Human read-out for the value (e.g. '+20', '-15', '90°', '4px', '60%'). */
format: (v: number) => string;
}
/** Snippet props exposed by the Provider's `children` / `child`. */
export type ImageAdjustmentsSnippetProps = {
/** Live CSS `filter` string for the current values ('none' when neutral). */
filter: string;
/** True when any adjustment differs from its neutral default. */
isModified: boolean;
/** The current values record. */
values: ImageAdjustmentValues;
};
/** Snippet props exposed by an Item's `children` / `child`. */
export type ImageAdjustmentsItemSnippetProps = {
adjustment: ImageAdjustmentKey;
/** Current value (neutral default when unset). */
value: number;
/** Localized adjustment label. */
label: string;
/** Formatted read-out of the current value. */
formattedValue: string;
/** This adjustment's definition (range / step / unit / mapping). */
def: AdjustmentDef;
/** Whether the group is disabled. */
disabled: boolean;
/** Set this row's value (wire to the composed slider). */
setValue: (value: number) => void;
};
/** Snippet props exposed by the Reset button's `children` / `child`. */
export type ImageAdjustmentsResetSnippetProps = {
disabled: boolean;
/** Localized reset label. */
label: string;
};
export type ImageAdjustmentsProps = WithChild<
{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Current adjustment values. Bindable. */
value?: ImageAdjustmentValues;
/** Callback when any adjustment changes (the full values record). */
onValueChange?: OnChangeFn<ImageAdjustmentValues>;
/** Callback with the recomputed CSS `filter` string (e.g. `brightness(1.1) contrast(0.9)`). */
onFilterChange?: OnChangeFn<string>;
/**
* Which adjustments to show, in order. Defaults to the core tone set
* (brightness, contrast, saturation, temperature, hue, blur).
*/
adjustments?: ImageAdjustmentKey[];
/** Disable all sliders + reset. @default false */
disabled?: boolean;
},
ImageAdjustmentsSnippetProps
> &
Without<PrimitiveDivAttributes, { value?: unknown }>;
export type ImageAdjustmentsItemProps = WithChild<
{
id?: string;
/** Which adjustment this row drives. */
adjustment: ImageAdjustmentKey;
},
ImageAdjustmentsItemSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
export type ImageAdjustmentsItemLabelProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ImageAdjustmentsItemValueProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ImageAdjustmentsResetProps = WithChild<
{ id?: string },
ImageAdjustmentsResetSnippetProps
> &
Without<PrimitiveButtonAttributes, {}>;

@ -0,0 +1,68 @@
# ImagePicker (soma)
Headless orchestrator for **pick / preview / adjust an image**. It composes the
framework's already-built `file-upload`, `image` and `ImageAdjustments` — it
declares only its OWN concerns: the empty↔ready state, the `fit` fill mode, the
90° `rotation`, the object-URL lifecycle, and the composed CSS `filter`.
> Output is **cropper-ready**: `onChange` streams
> `{ file, url, fit, rotation, filter, adjustments }`. Feed `url`/`file` to the
> image cropper as the next step.
## Anatomy
```svelte
<ImagePicker.Provider bind:file onChange={…}>
{#snippet children({ state, url, filter, fit, prompt, setFile })}
{#if state === 'empty'}
<!-- compose <FileUpload> → setFile(files[0]) -->
{:else}
<ImagePicker.Preview>
<!-- compose <Image src={url} {fit} style="filter:{filter}" /> -->
<ImagePicker.Toolbar>
<ImagePicker.Rotate /> <ImagePicker.Remove />
</ImagePicker.Toolbar>
</ImagePicker.Preview>
<!-- compose <ImageAdjustments bind:value={adjustments} /> -->
{/if}
{/snippet}
</ImagePicker.Provider>
```
| Part | Element | Role |
| --- | --- | --- |
| `Provider` | `div` | `role="group"`, `data-state` (empty/ready), `data-disabled` |
| `Preview` | `div` | `data-fit` (cover/contain/fill), `data-rotation` (0/90/180/270) |
| `Toolbar` | `div` | controls container (structural) |
| `Rotate` | `button` | rotate 90° clockwise |
| `Remove` | `button` | remove + reset transforms |
## Sema events
| Event | Family · verb | Target | Intent | When |
| --- | --- | --- | --- | --- |
| `commit-select` | commit · select | provider | affirm | an image is selected / replaced |
| `commit-remove` | commit · remove | provider | neutral | the image is removed |
| `handle-rotate` | handle · rotate | preview | neutral | the preview is rotated 90° |
`rotate` is canonically a `handle` verb (spatial manipulation, with drag / resize
/ scroll) — even though a discrete button triggers it. Pack:
`src/uix/sema/components/image-picker.ts`. All `sequence: 'post'`.
## Props (Provider)
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `file` | `File \| null` | `null` | bindable; the selected file |
| `fit` | `'cover' \| 'contain' \| 'fill'` | `'cover'` | bindable; fill mode |
| `rotation` | `0 \| 90 \| 180 \| 270` | `0` | bindable |
| `adjustments` | `ImageAdjustmentValues` | `{}` | bindable; filter values |
| `adjustmentKeys` | `ImageAdjustmentKey[]` | core 6 | which sliders the panel shows |
| `disabled` | `boolean` | `false` | gates picking + controls |
| `onChange` | `(v: ImagePickerValue) => void` | — | full value on any change |
| `onSelect` / `onRemove` | `(file) => void` / `() => void` | — | select / remove hooks |
| `aria-label` | `string` | "Image picker" | names the group |
The object-URL is created on `file` change and revoked via the effect cleanup
(previous URL on replace, last URL on unmount). Reuses `file-upload` / `image` /
`ImageAdjustments` — never reinvents them.

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImagePickerPreviewProvider } from '../image-picker-provider.svelte';
import type { ImagePickerPreviewProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-picker-preview'),
children,
child,
...restProps
}: ImagePickerPreviewProps = $props();
const state = ImagePickerPreviewProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImagePickerRemoveProvider } from '../image-picker-provider.svelte';
import type { ImagePickerRemoveProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-picker-remove'),
children,
child,
...restProps
}: ImagePickerRemoveProps = $props();
const state = ImagePickerRemoveProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ disabled: state.isDisabled });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.(snippetProps)}
</button>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImagePickerRotateProvider } from '../image-picker-provider.svelte';
import type { ImagePickerRotateProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-picker-rotate'),
children,
child,
...restProps
}: ImagePickerRotateProps = $props();
const state = ImagePickerRotateProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ disabled: state.isDisabled });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.(snippetProps)}
</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImagePickerToolbarProvider } from '../image-picker-provider.svelte';
import type { ImagePickerToolbarProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-picker-toolbar'),
children,
child,
...restProps
}: ImagePickerToolbarProps = $props();
const state = ImagePickerToolbarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,67 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ImagePickerProvider } from '../image-picker-provider.svelte';
import type { ImagePickerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'image-picker'),
file = $bindable(null),
fit = $bindable('cover'),
rotation = $bindable(0),
adjustments = $bindable({}),
adjustmentKeys,
disabled = false,
onChange,
onSelect,
onRemove,
'aria-label': ariaLabel,
children,
child,
...restProps
}: ImagePickerProps = $props();
const state = ImagePickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
file: writableActive(
() => file,
(v) => (file = v)
),
fit: writableActive(
() => fit,
(v) => (fit = v)
),
rotation: writableActive(
() => rotation,
(v) => (rotation = v)
),
adjustments: writableActive(
() => adjustments,
(v) => (adjustments = v)
),
adjustmentKeys: readableActive(() => adjustmentKeys),
disabled: readableActive(() => disabled),
onChange: readableActive(() => onChange),
onSelect: readableActive(() => onSelect),
onRemove: readableActive(() => onRemove),
ariaLabel: readableActive(() => ariaLabel ?? undefined)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,16 @@
export { default as Provider } from './components/image-picker.svelte';
export { default as Preview } from './components/image-picker-preview.svelte';
export { default as Toolbar } from './components/image-picker-toolbar.svelte';
export { default as Rotate } from './components/image-picker-rotate.svelte';
export { default as Remove } from './components/image-picker-remove.svelte';
export type {
ImagePickerFit,
ImagePickerRotation,
ImagePickerValue,
ImagePickerProps as ProviderProps,
ImagePickerPreviewProps as PreviewProps,
ImagePickerToolbarProps as ToolbarProps,
ImagePickerRotateProps as RotateProps,
ImagePickerRemoveProps as RemoveProps
} from './types';

@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { ImagePickerProvider } from './image-picker-provider.svelte';
import type { ImagePickerFit, ImagePickerRotation, ImagePickerValue } from './types';
import type { ImageAdjustmentValues } from '../image-adjustments';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
async function flush() {
await Promise.resolve();
await tick();
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: { ts: (ref: string) => ref },
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(ImagePickerProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function pickerOpts() {
return {
id: state('ip-root'),
ref: state<HTMLElement | null>(document.createElement('div')),
file: state<File | null>(null),
fit: state<ImagePickerFit>('cover'),
rotation: state<ImagePickerRotation>(0),
adjustments: state<ImageAdjustmentValues>({}),
adjustmentKeys: state<undefined>(undefined),
disabled: state(false),
onChange: state<((v: ImagePickerValue) => void) | undefined>(undefined),
onSelect: state<((f: File) => void) | undefined>(undefined),
onRemove: state<(() => void) | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined)
};
}
const png = () => new File(['x'], 'photo.png', { type: 'image/png' });
describe('ImagePickerProvider', () => {
beforeEach(() => {
// jsdom has no object-URL support — stub it.
(URL as unknown as { createObjectURL: (f: File) => string }).createObjectURL = () =>
'blob:mock-url';
(URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = () => {};
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('starts empty and reports ready once a file is set', async () => {
installSomaHarness();
const opts = pickerOpts();
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
expect(p.state).toBe('empty');
expect(p.value.url).toBeNull();
p.setFile(png());
await flush();
expect(p.state).toBe('ready');
expect(opts.file.current?.name).toBe('photo.png');
expect(p.url).toBe('blob:mock-url');
cleanup();
});
it('setFile fires onSelect + onChange and triggers commit-select', async () => {
installSomaHarness();
const opts = pickerOpts();
const onSelect = vi.fn();
const onChange = vi.fn();
opts.onSelect.current = onSelect;
opts.onChange.current = onChange;
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
const f = png();
p.setFile(f);
await flush();
expect(onSelect).toHaveBeenCalledWith(f);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.lastCall?.[0]).toMatchObject({ file: f, fit: 'cover', rotation: 0 });
cleanup();
});
it('rotate cycles 90° clockwise and wraps at 360', async () => {
installSomaHarness();
const opts = pickerOpts();
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
p.setFile(png());
await flush();
p.rotate();
expect(opts.rotation.current).toBe(90);
p.rotate();
p.rotate();
expect(opts.rotation.current).toBe(270);
p.rotate();
expect(opts.rotation.current).toBe(0);
cleanup();
});
it('remove clears the file + resets transforms', async () => {
installSomaHarness();
const opts = pickerOpts();
const onRemove = vi.fn();
opts.onRemove.current = onRemove;
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
p.setFile(png());
await flush();
p.rotate();
opts.adjustments.current = { brightness: 30 };
p.remove();
await flush();
expect(opts.file.current).toBeNull();
expect(opts.rotation.current).toBe(0);
expect(opts.adjustments.current).toEqual({});
expect(p.state).toBe('empty');
expect(onRemove).toHaveBeenCalled();
cleanup();
});
it('composes the CSS filter from the adjustment values', async () => {
installSomaHarness();
const opts = pickerOpts();
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
expect(p.filter).toBe('none');
opts.adjustments.current = { brightness: 20, blur: 4 };
// blur is in the default set; brightness too → both contribute.
expect(p.filter).toContain('brightness(1.2)');
expect(p.filter).toContain('blur(4px)');
cleanup();
});
it('disabled blocks setFile / rotate / remove', async () => {
installSomaHarness();
const opts = pickerOpts();
opts.disabled.current = true;
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
p.setFile(png());
await flush();
expect(opts.file.current).toBeNull();
expect(p.state).toBe('empty');
cleanup();
});
});

@ -0,0 +1,329 @@
import { context, type WithRefOpts } from '../../provider';
import { type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn } from '../../types';
import { imagePickerMorfo } from '../../../morfo/components/image-picker';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import {
computeImageFilter,
DEFAULT_IMAGE_ADJUSTMENTS,
type ImageAdjustmentKey,
type ImageAdjustmentValues
} from '../image-adjustments';
import { IMAGE_PICKER_LANGS } from './langs';
import type { ImagePickerFit, ImagePickerRotation, ImagePickerValue } from './types';
// ── Root provider ────────────────────────────────────────────────────────────
interface ImagePickerOpts
extends WithRefOpts,
StateProps<{
file: File | null;
fit: ImagePickerFit;
rotation: ImagePickerRotation;
adjustments: ImageAdjustmentValues;
}>,
ActiveProps<{
adjustmentKeys: ImageAdjustmentKey[] | undefined;
disabled: boolean;
onChange: OnChangeFn<ImagePickerValue> | undefined;
onSelect: OnChangeFn<File> | undefined;
onRemove: (() => void) | undefined;
}> {
ariaLabel?: Active<string | undefined>;
}
export class ImagePickerProvider {
readonly opts: ImagePickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
/** Object-URL for the selected file. Lifecycle-managed by the effect below. */
url = $state<string | null>(null);
static readonly ctx = context<ImagePickerProvider>('ImagePicker');
static get(): ImagePickerProvider | undefined {
return this.ctx.getOr(undefined) as ImagePickerProvider | undefined;
}
static require(): ImagePickerProvider {
return this.ctx.get();
}
static create(opts: ImagePickerOpts) {
return new ImagePickerProvider(opts);
}
private constructor(opts: ImagePickerOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(imagePickerMorfo, {
props: {
state: () => this.state,
disabled: () => this.opts.disabled.current,
ariaLabel: () => this.resolvedAriaLabel
},
events: {
'commit-select': () => {},
'commit-remove': () => {},
'handle-rotate': () => {}
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ImagePickerProvider.ctx,
syncAttrs: true
});
// Object-URL lifecycle: create on file change, revoke the previous one
// (and the last one on unmount) via the effect's cleanup.
$effect(() => {
const file = this.opts.file.current;
if (!file || typeof URL === 'undefined' || !URL.createObjectURL) {
this.url = null;
return;
}
const u = URL.createObjectURL(file);
this.url = u;
return () => {
try {
URL.revokeObjectURL(u);
} catch {
/* noop — already revoked / unsupported */
}
};
});
}
readonly state = $derived.by<'empty' | 'ready'>(() =>
this.opts.file.current ? 'ready' : 'empty'
);
readonly resolvedAriaLabel = $derived.by(
() => this.opts.ariaLabel?.current || this.soma.langs.ts(IMAGE_PICKER_LANGS.LABEL)
);
readonly resolvedPrompt = $derived.by(() => this.soma.langs.ts(IMAGE_PICKER_LANGS.PROMPT));
readonly adjustmentOrder = $derived.by<readonly ImageAdjustmentKey[]>(
() => this.opts.adjustmentKeys.current ?? DEFAULT_IMAGE_ADJUSTMENTS
);
/** Composed CSS `filter` from the current adjustment values. */
readonly filter = $derived.by(() =>
computeImageFilter(this.opts.adjustments.current, this.adjustmentOrder)
);
/** The full picker value — the onChange payload + cropper hand-off. */
get value(): ImagePickerValue {
return {
file: this.opts.file.current,
url: this.url,
fit: this.opts.fit.current,
rotation: this.opts.rotation.current,
filter: this.filter,
adjustments: this.opts.adjustments.current
};
}
private emitChange(): void {
this.opts.onChange.current?.(this.value);
}
/** Select (or replace) the image. Passing `null` removes it. */
setFile(file: File | null): void {
if (this.opts.disabled.current) return;
if (!file) {
this.remove();
return;
}
this.opts.file.current = file;
this.opts.onSelect.current?.(file);
this.emitChange();
void this.runtime.trigger('commit-select');
}
/** Remove the image and reset its transforms. */
remove(): void {
if (this.opts.disabled.current) return;
if (!this.opts.file.current) return;
this.opts.file.current = null;
this.opts.rotation.current = 0;
this.opts.adjustments.current = {};
this.opts.onRemove.current?.();
this.emitChange();
void this.runtime.trigger('commit-remove');
}
/** Rotate the preview 90° clockwise. */
rotate(): void {
if (this.opts.disabled.current || !this.opts.file.current) return;
this.opts.rotation.current = (((this.opts.rotation.current + 90) % 360) as ImagePickerRotation);
this.emitChange();
void this.runtime.trigger('handle-rotate');
}
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly snippetProps = $derived.by(() => ({
state: this.state,
url: this.url,
filter: this.filter,
fit: this.opts.fit.current,
rotation: this.opts.rotation.current,
disabled: this.opts.disabled.current,
prompt: this.resolvedPrompt,
setFile: (file: File | null) => this.setFile(file),
remove: () => this.remove(),
rotate: () => this.rotate()
}));
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Preview ────────────────────────────────────────────────────────────────
interface ImagePickerPreviewOpts extends WithRefOpts {}
export class ImagePickerPreviewProvider {
readonly opts: ImagePickerPreviewOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImagePickerProvider;
static create(opts: ImagePickerPreviewOpts) {
return new ImagePickerPreviewProvider(opts);
}
private constructor(opts: ImagePickerPreviewOpts) {
this.opts = opts;
this.provider = ImagePickerProvider.require();
this.runtimePart = this.provider.runtime.part('preview', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
fit: () => this.provider.opts.fit.current,
// data-rotation values are strings ('0' / '90' / …) — stringify.
rotation: () => String(this.provider.opts.rotation.current)
},
syncAttrs: true
});
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Toolbar ────────────────────────────────────────────────────────────────
interface ImagePickerToolbarOpts extends WithRefOpts {}
export class ImagePickerToolbarProvider {
readonly opts: ImagePickerToolbarOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImagePickerProvider;
static create(opts: ImagePickerToolbarOpts) {
return new ImagePickerToolbarProvider(opts);
}
private constructor(opts: ImagePickerToolbarOpts) {
this.opts = opts;
this.provider = ImagePickerProvider.require();
this.runtimePart = this.provider.runtime.part('toolbar', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
}
// ── Rotate ─────────────────────────────────────────────────────────────────
interface ImagePickerRotateOpts extends WithRefOpts {}
export class ImagePickerRotateProvider {
readonly opts: ImagePickerRotateOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImagePickerProvider;
static create(opts: ImagePickerRotateOpts) {
return new ImagePickerRotateProvider(opts);
}
private constructor(opts: ImagePickerRotateOpts) {
this.opts = opts;
this.provider = ImagePickerProvider.require();
this.runtimePart = this.provider.runtime.part('rotate', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly isDisabled = $derived.by(
() => this.provider.opts.disabled.current || !this.provider.opts.file.current
);
readonly onclick = () => {
if (this.isDisabled) return;
this.provider.rotate();
};
readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_PICKER_LANGS.ROTATE));
readonly props = $derived.by(() => ({
...this.runtimePart.props,
disabled: this.isDisabled || undefined,
'aria-label': this.label,
onclick: this.onclick
}));
}
// ── Remove ─────────────────────────────────────────────────────────────────
interface ImagePickerRemoveOpts extends WithRefOpts {}
export class ImagePickerRemoveProvider {
readonly opts: ImagePickerRemoveOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: ImagePickerProvider;
static create(opts: ImagePickerRemoveOpts) {
return new ImagePickerRemoveProvider(opts);
}
private constructor(opts: ImagePickerRemoveOpts) {
this.opts = opts;
this.provider = ImagePickerProvider.require();
this.runtimePart = this.provider.runtime.part('remove', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly isDisabled = $derived.by(
() => this.provider.opts.disabled.current || !this.provider.opts.file.current
);
readonly onclick = () => {
if (this.isDisabled) return;
this.provider.remove();
};
readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_PICKER_LANGS.REMOVE));
readonly props = $derived.by(() => ({
...this.runtimePart.props,
disabled: this.isDisabled || undefined,
'aria-label': this.label,
onclick: this.onclick
}));
}

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,10 @@
/**
* Lang refs for ImagePicker. Resolved via `soma.langs.ts(ref)`. Catalog leaves
* live in `src/uix/langs/components/image-picker.ts`.
*/
export const IMAGE_PICKER_LANGS = {
LABEL: '#?components.image-picker.label|Image picker',
PROMPT: '#?components.image-picker.prompt|Drop an image or click to browse',
REMOVE: '#?components.image-picker.remove|Remove image',
ROTATE: '#?components.image-picker.rotate|Rotate 90°'
} as const;

@ -0,0 +1,87 @@
import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type { ImageAdjustmentKey, ImageAdjustmentValues } from '../image-adjustments';
/** Fill mode for the preview — maps to the composed `<Image>` `fit`. */
export type ImagePickerFit = 'cover' | 'contain' | 'fill';
/** Rotation in degrees, clockwise, 90° steps. */
export type ImagePickerRotation = 0 | 90 | 180 | 270;
/** The full picker output — cropper-ready (the `file`/`url` feed the cropper). */
export interface ImagePickerValue {
/** The selected file, or null when empty. */
file: File | null;
/** Object-URL for the selected file, or null. Lifecycle-managed by the provider. */
url: string | null;
/** Current fill mode. */
fit: ImagePickerFit;
/** Current rotation (deg, clockwise). */
rotation: ImagePickerRotation;
/** The composed CSS `filter` string from the adjustments ('none' when neutral). */
filter: string;
/** Raw per-adjustment values. */
adjustments: ImageAdjustmentValues;
}
export type ImagePickerProps = WithChild<
{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** The selected file. Bindable. @default null */
file?: File | null;
/** Fill mode. Bindable. @default 'cover' */
fit?: ImagePickerFit;
/** Rotation (deg, clockwise, 90° steps). Bindable. @default 0 */
rotation?: ImagePickerRotation;
/** Per-adjustment filter values. Bindable. @default {} */
adjustments?: ImageAdjustmentValues;
/** Which adjustments the panel exposes (passed to ImageAdjustments). */
adjustmentKeys?: ImageAdjustmentKey[];
/** Disable picking + all controls. @default false */
disabled?: boolean;
/** Fired with the full picker value whenever anything changes. */
onChange?: OnChangeFn<ImagePickerValue>;
/** Fired when an image is selected (or replaced). */
onSelect?: OnChangeFn<File>;
/** Fired when the image is removed. */
onRemove?: () => void;
},
ImagePickerSnippetProps
> &
Without<PrimitiveDivAttributes, { value?: unknown }>;
/** Snippet props exposed by the Provider's `children` / `child`. */
export type ImagePickerSnippetProps = {
/** 'empty' (no image) or 'ready' (image selected). */
state: 'empty' | 'ready';
/** Object-URL for the selected file, or null. */
url: string | null;
/** The composed CSS `filter` string. */
filter: string;
/** Current fill mode. */
fit: ImagePickerFit;
/** Current rotation. */
rotation: ImagePickerRotation;
/** Whether the picker is disabled. */
disabled: boolean;
/** Localized empty-state prompt. */
prompt: string;
/** Select (or replace) the image. Pass `null` to remove. Bridge for file-upload. */
setFile: (file: File | null) => void;
/** Remove the image + reset its transforms. */
remove: () => void;
/** Rotate the preview 90° clockwise. */
rotate: () => void;
};
export type ImagePickerPreviewProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ImagePickerToolbarProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type ImagePickerRotateProps = WithChild<{ id?: string }, { disabled: boolean }> &
Without<PrimitiveButtonAttributes, {}>;
export type ImagePickerRemoveProps = WithChild<{ id?: string }, { disabled: boolean }> &
Without<PrimitiveButtonAttributes, {}>;

@ -13,6 +13,7 @@ export * as ColorPicker from './color-picker';
export * as Combobox from './combobox'; export * as Combobox from './combobox';
export * as Command from './command'; export * as Command from './command';
export * as ContextMenu from './context-menu'; export * as ContextMenu from './context-menu';
export * as Cropper from './cropper';
export * as CssField from './css-field'; export * as CssField from './css-field';
export * as DateField from './date-field'; export * as DateField from './date-field';
export * as DatePicker from './date-picker'; export * as DatePicker from './date-picker';
@ -29,6 +30,8 @@ export * as FileUpload from './file-upload';
export * as FloatPanel from './float-panel'; export * as FloatPanel from './float-panel';
export * as Form from './form'; export * as Form from './form';
export * as GridList from './grid-list'; export * as GridList from './grid-list';
export * as ImageAdjustments from './image-adjustments';
export * as ImagePicker from './image-picker';
export * as LinkPreview from './link-preview'; export * as LinkPreview from './link-preview';
export * as Listbox from './listbox'; export * as Listbox from './listbox';
export * as Menubar from './menubar'; export * as Menubar from './menubar';

@ -5,6 +5,7 @@ export { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismiss
export { TextSelection, type TextSelectionOpts } from './text-selection.svelte'; export { TextSelection, type TextSelectionOpts } from './text-selection.svelte';
export { ScrollLock, type ScrollLockOption } from './scroll-lock.svelte'; export { ScrollLock, type ScrollLockOption } from './scroll-lock.svelte';
export { ResizeObserver$ } from './resize-observer.svelte'; export { ResizeObserver$ } from './resize-observer.svelte';
export { ZoomPan, type ZoomPanConfig } from './zoom-pan.svelte';
export { export {
ImageProvider, ImageProvider,
type ImageProviderOptions, type ImageProviderOptions,

@ -0,0 +1,109 @@
// ── Zoom-Pan Layer ───────────────────────────────────────────────────────────
//
// Reusable scale + pan state for a content element inside a fixed viewport.
// Consumed by Cropper today (zoom the image while cropping); applicable to any
// image-viewer / diagram-pan / map surface. Pure reactive state + math — the
// consumer wires wheel / pointer / button gestures to the methods and applies
// `transform` to the content element.
//
// Coordinate model: offset is in VIEWPORT PIXELS with `transform-origin: 0 0`.
// At scale 1 the content exactly fills the viewport (offset 0). At scale > 1 the
// offset is clamped so the content keeps covering the viewport (no gutters).
export interface ZoomPanConfig {
/** Minimum scale. @default 1 */
minScale?: number;
/** Maximum scale. @default 5 */
maxScale?: number;
}
export class ZoomPan {
/** Current scale (≥ minScale). */
scale = $state(1);
/** Offset in viewport px (transform-origin 0 0). */
offset = $state<{ x: number; y: number }>({ x: 0, y: 0 });
readonly minScale: number;
readonly maxScale: number;
private vw = 1;
private vh = 1;
static create(config?: ZoomPanConfig) {
return new ZoomPan(config);
}
constructor(config?: ZoomPanConfig) {
this.minScale = config?.minScale ?? 1;
this.maxScale = config?.maxScale ?? 5;
}
/** Tell the layer the viewport px size (for offset clamping). */
setViewport(w: number, h: number): void {
this.vw = w || 1;
this.vh = h || 1;
this.clamp();
}
private clampScale(s: number): number {
return Math.min(Math.max(s, this.minScale), this.maxScale);
}
/** Keep the (scaled) content covering the viewport — no empty gutters. */
private clamp(): void {
const minX = this.vw * (1 - this.scale); // ≤ 0 when scale ≥ 1
const minY = this.vh * (1 - this.scale);
this.offset = {
x: Math.min(0, Math.max(minX, this.offset.x)),
y: Math.min(0, Math.max(minY, this.offset.y))
};
}
/** Multiply the scale by `factor`, keeping the viewport point (cx,cy) fixed. */
zoomBy(factor: number, cx = this.vw / 2, cy = this.vh / 2): void {
const next = this.clampScale(this.scale * factor);
if (next === this.scale) return;
// The content coord under the cursor stays put: cx = coord*scale + offset.
const coordX = (cx - this.offset.x) / this.scale;
const coordY = (cy - this.offset.y) / this.scale;
this.scale = next;
this.offset = { x: cx - coordX * next, y: cy - coordY * next };
this.clamp();
}
/** Set the absolute scale (clamped), centered on (cx,cy) or the viewport. */
zoomTo(scale: number, cx?: number, cy?: number): void {
const target = this.clampScale(scale);
if (target === this.scale) return;
this.zoomBy(target / this.scale, cx, cy);
}
/** Pan by a viewport-px delta. */
panBy(dx: number, dy: number): void {
this.offset = { x: this.offset.x + dx, y: this.offset.y + dy };
this.clamp();
}
/** Back to scale 1, no offset. */
reset(): void {
this.scale = 1;
this.offset = { x: 0, y: 0 };
}
readonly isZoomed = $derived.by(() => this.scale > this.minScale + 1e-6);
/** CSS transform for the content element. */
readonly transform = $derived.by(
() => `translate(${this.offset.x}px, ${this.offset.y}px) scale(${this.scale})`
);
/**
* Map a viewport-fraction point (0–1) to a content-fraction point (0–1),
* inverting the current zoom/pan. Used by the cropper's canvas extraction.
*/
viewportFracToContentFrac(fx: number, fy: number): { x: number; y: number } {
return {
x: (fx - this.offset.x / this.vw) / this.scale,
y: (fy - this.offset.y / this.vh) / this.scale
};
}
}

@ -13,6 +13,8 @@
import { fileUploadSema } from '$uix/sema/components/file-upload'; import { fileUploadSema } from '$uix/sema/components/file-upload';
import { floatPanelSema } from '$uix/sema/components/float-panel'; import { floatPanelSema } from '$uix/sema/components/float-panel';
import { formSema } from '$uix/sema/components/form'; import { formSema } from '$uix/sema/components/form';
import { imageAdjustmentsSema } from '$uix/sema/components/image-adjustments';
import { imagePickerSema } from '$uix/sema/components/image-picker';
import { numberFieldSema } from '$uix/sema/components/number-field'; import { numberFieldSema } from '$uix/sema/components/number-field';
import { paginationSema } from '$uix/sema/components/pagination'; import { paginationSema } from '$uix/sema/components/pagination';
import { passwordFieldSema } from '$uix/sema/components/password-field'; import { passwordFieldSema } from '$uix/sema/components/password-field';
@ -35,6 +37,7 @@
import { calendarSema } from '$uix/sema/components/calendar'; import { calendarSema } from '$uix/sema/components/calendar';
import { colorPickerSema } from '$uix/sema/components/color-picker'; import { colorPickerSema } from '$uix/sema/components/color-picker';
import { comboboxSema } from '$uix/sema/components/combobox'; import { comboboxSema } from '$uix/sema/components/combobox';
import { cropperSema } from '$uix/sema/components/cropper';
import { cssFieldSema } from '$uix/sema/components/css-field'; import { cssFieldSema } from '$uix/sema/components/css-field';
import { dateFieldSema } from '$uix/sema/components/date-field'; import { dateFieldSema } from '$uix/sema/components/date-field';
import { Popover } from '$uix/eidos/components/popover'; import { Popover } from '$uix/eidos/components/popover';
@ -71,6 +74,7 @@
checkboxSema, checkboxSema,
colorPickerSema, colorPickerSema,
comboboxSema, comboboxSema,
cropperSema,
cssFieldSema, cssFieldSema,
dateFieldSema, dateFieldSema,
dialogSema, dialogSema,
@ -79,6 +83,8 @@
fileUploadSema, fileUploadSema,
floatPanelSema, floatPanelSema,
formSema, formSema,
imageAdjustmentsSema,
imagePickerSema,
numberFieldSema, numberFieldSema,
paginationSema, paginationSema,
passwordFieldSema, passwordFieldSema,
@ -202,6 +208,9 @@
{ slug: '/uix/components/avatar-group', label: 'AvatarGroup' }, { slug: '/uix/components/avatar-group', label: 'AvatarGroup' },
{ slug: '/uix/components/icon', label: 'Icon' }, { slug: '/uix/components/icon', label: 'Icon' },
{ slug: '/uix/components/image', label: 'Image' }, { slug: '/uix/components/image', label: 'Image' },
{ slug: '/uix/components/image-adjustments', label: 'Image adjustments' },
{ slug: '/uix/components/image-picker', label: 'Image picker' },
{ slug: '/uix/components/cropper', label: 'Cropper' },
{ slug: '/uix/components/carousel', label: 'Carousel' } { slug: '/uix/components/carousel', label: 'Carousel' }
] ]
}, },

@ -0,0 +1,572 @@
<script lang="ts">
import Cropper, {
type CropRect,
type CropShape,
type CropResult
} from '$uix/eidos/components/cropper';
import { compileMorfo } from '$uix/morfo';
import { cropperMorfo } from '@/uix/morfo/components/cropper';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
type AspectKey = 'free' | '1:1' | '4:3' | '16:9';
let aspectKey = $state<AspectKey>('free');
let shape = $state<CropShape>('rect');
let disabled = $state(false);
let sizeMode = $state<'free' | 'fixed'>('free');
const fixedSize = $derived(sizeMode === 'fixed' ? 0.45 : undefined);
let crop = $state<CropRect>({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });
let lastResult = $state<CropResult | null>(null);
let resultUrl = $state<string | null>(null);
const ASPECTS: Record<AspectKey, number | undefined> = {
free: undefined,
'1:1': 1,
'4:3': 4 / 3,
'16:9': 16 / 9
};
// Fixed size implies a square (1:1) crop so `round` is a true circle.
const aspect = $derived(sizeMode === 'fixed' ? 1 : ASPECTS[aspectKey]);
let cropperEl = $state<{ cropImage: () => Promise<CropResult | null> | undefined } | null>(null);
async function doCrop() {
const r = await cropperEl?.cropImage();
if (r) {
lastResult = r;
if (resultUrl) URL.revokeObjectURL(resultUrl);
resultUrl = URL.createObjectURL(r.blob);
}
}
// Wide sample (16:9) so the crop reads clearly against the viewport.
const sample =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4ea8ff"/><stop offset="1" stop-color="#bfe3ff"/></linearGradient></defs>
<rect width="640" height="360" fill="url(#s)"/>
<circle cx="500" cy="92" r="48" fill="#ffd24a"/>
<path d="M0 250 Q160 190 320 240 T640 220 V360 H0 Z" fill="#2f9e57"/>
<path d="M0 296 Q190 250 400 286 T640 276 V360 H0 Z" fill="#1f7a42"/>
</svg>`
);
// The image to crop — the sample by default, or a file the user picks.
let src = $state(sample);
let uploadedUrl = $state<string | null>(null);
function onPickFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
if (uploadedUrl) URL.revokeObjectURL(uploadedUrl);
uploadedUrl = URL.createObjectURL(file);
src = uploadedUrl;
// reset the crop for the new image's aspect
crop = { x: 0.15, y: 0.15, width: 0.7, height: 0.7 };
resultUrl = null;
}
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const compiled = compileMorfo(cropperMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const r2 = (n: number) => Math.round(n * 100) / 100;
const cropStr = $derived(`${r2(crop.x)}, ${r2(crop.y)} · ${r2(crop.width)}×${r2(crop.height)}`);
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Cropper from '$soma/components/cropper';",
" import { Image } from '$uix/eidos/components/image';",
' let crop = $state({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });',
'</' + 'script>',
'',
'<Cropper.Provider {src} bind:crop onCropComplete={(r) => save(r.blob)}>',
' {#snippet children({ crop })}',
' <Cropper.Viewport>',
' <Image {src} alt="" fit="fill" />',
' <Cropper.Selection style="left:{crop.x*100}%; …">',
' <Cropper.Grid />',
' <Cropper.Handle corner="nw" /> <!-- …ne / se / sw -->',
' </Cropper.Selection>',
' </Cropper.Viewport>',
' {/snippet}',
'</Cropper.Provider>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import Cropper from '$uix/eidos/components/cropper';",
' let cr; let crop = $state({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });',
'</' + 'script>',
'',
'<Cropper',
' bind:this={cr}',
' {src}',
' bind:crop',
aspectKey !== 'free' && ` aspect={${r2(aspect ?? 0)}}`,
shape !== 'rect' && ` shape="${shape}"`,
disabled && ' disabled',
' onCropComplete={(r) => save(r.blob)}',
'/>',
'',
'<button onclick={() => cr.cropImage()}>Crop</button>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · Cropper</div>
<h1 data-uix-page-title>Cropper</h1>
<p data-uix-page-lede>
Crop a region of an image — a movable / resizable selection over the image, with a
rule-of-thirds grid, corner handles and <strong>zoom/pan</strong> (rueda + controles). Soma
owns the crop rect, the drag/resize/zoom math and the <code>&lt;canvas&gt;</code> extraction to
a Blob; the zoom is a reusable <code>createZoomPan</code> layer. Consumes the <code>url</code>
an ImagePicker emits. Five parts, four events.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>shapes</span>rect · round</span>
<span data-uix-meta-pill><span data-uix-meta-key>output</span>Blob</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div class="cr-stage">
<div class="cr-editor">
<Cropper bind:this={cropperEl} {src} bind:crop {aspect} {shape} {fixedSize} {disabled} />
<button data-uix-chip onclick={doCrop} disabled={disabled}>Recortar</button>
</div>
<div class="cr-result">
<span data-uix-control-label>resultado</span>
{#if resultUrl}
<img class="cr-result-img" src={resultUrl} alt="Cropped result" data-shape={shape} />
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">
{Math.round((lastResult?.blob.size ?? 0) / 1024)} KB · png
</code>
{:else}
<span style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">
arrastra el recorte y pulsa «Recortar»
</span>
{/if}
</div>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>drag the selection / a handle, then crop to see events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>crop</span>
{cropStr}
</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Props grouped by the architectural layer that owns them: <span data-uix-layer-badge="soma">soma</span>
headless behavior, <span data-uix-layer-badge="eidos">eidos</span> visual. Arrastra la
<strong>cuadrícula</strong> para mover el área de recorte; arrastra el <strong>fondo (la
imagen)</strong> para moverla y fijar el foco (con zoom); los handles (esquinas/lados)
redimensionan. Zoom con rueda/controles. Luego «Recortar». Events in the
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>imagen <span data-uix-control-hint>src · sube un archivo</span></span>
<input type="file" accept="image/*" onchange={onPickFile} />
{#if uploadedUrl}
<button data-uix-chip onclick={() => { src = sample; uploadedUrl = null; crop = { x: 0.15, y: 0.15, width: 0.7, height: 0.7 }; resultUrl = null; }}>volver a la muestra</button>
{/if}
</label>
<label data-uix-control>
<span data-uix-control-label>aspect <span data-uix-control-hint>lock ratio</span></span>
<span data-uix-chips role="radiogroup">
{#each ['free', '1:1', '4:3', '16:9'] as const as a}
<button data-uix-chip data-active={aspectKey === a} onclick={() => (aspectKey = a)}>{a}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>shape</span>
<span data-uix-chips role="radiogroup">
{#each ['rect', 'round'] as const as s}
<button data-uix-chip data-active={shape === s} onclick={() => (shape = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>tamaño <span data-uix-control-hint>fixedSize → solo mover</span></span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={sizeMode === 'free'} onclick={() => (sizeMode = 'free')}>libre</button>
<button data-uix-chip data-active={sizeMode === 'fixed'} onclick={() => (sizeMode = 'fixed')}>fijo (avatar)</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
<div data-uix-control>
<span data-uix-control-label>crop <span data-uix-control-hint>bindable · 0–1</span></span>
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{cropStr}</code>
</div>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · selection / grid / handles over the image</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · the configured cropper + crop() via bind:this</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>All public props on the Provider.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">src</td><td class="type">{`string | null`}</td><td class="default empty">—</td><td>Image to crop — typically an ImagePicker `url`.</td></tr>
<tr><td class="name">crop <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`{ x, y, width, height }`}</td><td class="default">centered 80%</td><td>Crop rect, normalized 0–1.</td></tr>
<tr><td class="name">aspect</td><td class="type">number</td><td class="default empty">free</td><td>Lock to a width/height ratio (display px).</td></tr>
<tr><td class="name">shape</td><td class="type">{`'rect' | 'round'`}</td><td class="default">'rect'</td><td>Rectangular or circular (alpha-masked) crop.</td></tr>
<tr><td class="name">minSize</td><td class="type">number</td><td class="default">0.05</td><td>Minimum crop size, fraction of the image.</td></tr>
<tr><td class="name">maxSize</td><td class="type">number</td><td class="default">1</td><td>Maximum crop size, fraction of the image.</td></tr>
<tr><td class="name">fixedSize</td><td class="type">number</td><td class="default empty">—</td><td>Fix the crop to this size → move-only (no handles). Avatar = round + fixedSize.</td></tr>
<tr><td class="name">minScale / maxScale</td><td class="type">number</td><td class="default">1 / 5</td><td>Zoom limits (wheel / pinch / the −/slider/+ controls).</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable interaction.</td></tr>
<tr><td class="name">onCropChange</td><td class="type">{`(rect) => void`}</td><td class="default empty">—</td><td>Live, as the rect moves / resizes.</td></tr>
<tr><td class="name">onCropComplete</td><td class="type">{`(r: CropResult) => void`}</td><td class="default empty">—</td><td>{`{ blob, crop, shape }`} when `cropImage()` runs.</td></tr>
<tr><td class="name">cropImage() <span data-uix-tag data-kind="eidos">method</span></td><td class="type">{`() => Promise<CropResult>`}</td><td class="default empty">via bind:this</td><td>Produce the cropped Blob via canvas.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Capability</th><th>Ark</th><th>react-image-crop</th><th>react-easy-crop</th><th>Cropper.js</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Selection over image</td><td>✅</td><td>✅</td><td>— (pan/zoom)</td><td>✅</td><td>✅</td></tr>
<tr><td class="name">Corner handles + grid</td><td>✅</td><td>✅</td><td>partial</td><td>✅</td><td>✅</td></tr>
<tr><td class="name">Aspect lock</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr>
<tr><td class="name">Circular crop</td><td>—</td><td>✅</td><td>✅</td><td>partial</td><td>✅</td></tr>
<tr><td class="name">Blob output (canvas)</td><td>headless</td><td>you extract</td><td>helper</td><td>✅</td><td>✅ built-in</td></tr>
<tr><td class="name">Sound / haptic</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td><td>✅ sema</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>
The crop geometry is inline style on the Selection (continuous, like FloatPanel's
position); the morfo declares the parts, the shape, each handle's corner, and the events.
Source: <code>src/uix/morfo/components/cropper.ts</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{cropperMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{cropperMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{cropperMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{cropperMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{cropperMorfo.events?.length ?? 0}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each cropperMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as { kebab: string }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{#if dataAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
<code>handle-drag</code> / <code>handle-resize</code> (handle · tick) fire once at gesture
start — the move itself is visual, not per-frame (no haptic buzz). <code>commit-crop</code>
(commit · apply · affirm) when the Blob is produced. Click <strong>play</strong> to fire.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const sel = `[data-cropper-${action.target === 'handle' ? 'handle' : action.target === 'selection' ? 'selection' : 'provider'}]`;
const target = (stageRef?.querySelector(sel) ?? stageRef?.querySelector('[data-cropper]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({ name: action.name, family: action.semantic.family, target, ...(effectiveIntent ? { intent: effectiveIntent } : {}) });
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/cropper/cropper.css</code>. Tokens
bare-prefixed (<code>--cropper-*</code>). The darken-mask is a box-shadow on the Selection
(follows border-radius → circular cutout for <code>round</code>).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-cropper] / [data-cropper][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-cropper-viewport]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-cropper-selection]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-cropper-selection][data-shape='round']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-cropper-grid]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-cropper-handle][data-corner='…']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The cropper is a labelled <code>role="group"</code>. v1 is pointer-driven; keyboard
move/resize of the selection is a documented gap (see the README).
</p>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">"Crop image" (or prop)</td></tr>
<tr><td class="name">selection</td><td>data-shape</td><td class="type">"rect" | "round"</td></tr>
<tr><td class="name">selection</td><td>data-dragging</td><td class="type">while moving / resizing</td></tr>
<tr><td class="name">handle</td><td>data-corner</td><td class="type">nw | ne | se | sw</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.cr-stage {
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: var(--uix-space-4);
align-items: start;
inline-size: 100%;
}
@media (max-width: 720px) {
.cr-stage {
grid-template-columns: 1fr;
}
}
.cr-editor {
display: flex;
flex-direction: column;
gap: var(--uix-space-2);
align-items: flex-start;
max-inline-size: 460px;
}
.cr-result {
display: flex;
flex-direction: column;
gap: var(--uix-space-1);
}
.cr-result-img {
inline-size: 140px;
block-size: auto;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-default);
background: var(--color-surface-muted);
}
.cr-result-img[data-shape='round'] {
border-radius: 50%;
}
</style>

@ -0,0 +1,649 @@
<script lang="ts">
import ImageAdjustments, {
type ImageAdjustmentKey,
type ImageAdjustmentValues,
type ImageAdjustmentsSize
} from '$uix/eidos/components/image-adjustments';
import { compileMorfo } from '$uix/morfo';
import { imageAdjustmentsMorfo } from '@/uix/morfo/components/image-adjustments';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
type Preset = 'core' | 'photo' | 'artistic' | 'all';
let preset = $state<Preset>('core');
let size = $state<ImageAdjustmentsSize>('md');
let showReset = $state(true);
let disabled = $state(false);
let value = $state<ImageAdjustmentValues>({});
let filter = $state('none');
const PRESETS: Record<Preset, ImageAdjustmentKey[]> = {
core: ['brightness', 'contrast', 'saturation', 'temperature', 'hue', 'blur'],
photo: ['brightness', 'contrast', 'saturation', 'temperature'],
artistic: ['grayscale', 'sepia', 'hue', 'blur'],
all: ['brightness', 'contrast', 'saturation', 'temperature', 'hue', 'blur', 'grayscale', 'sepia']
};
const adjustments = $derived(PRESETS[preset]);
// A synthetic "photo" (inline SVG) — works offline and exercises every
// filter (gradient sky, sun, layered hills, trees).
const photo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 320">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4ea8ff"/><stop offset="1" stop-color="#bfe3ff"/></linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#fff6c0"/><stop offset="1" stop-color="#ffd24a"/></radialGradient>
</defs>
<rect width="480" height="320" fill="url(#sky)"/>
<circle cx="370" cy="80" r="46" fill="url(#sun)"/>
<path d="M0 230 Q120 170 240 220 T480 200 V320 H0 Z" fill="#2f9e57"/>
<path d="M0 270 Q140 220 300 260 T480 250 V320 H0 Z" fill="#1f7a42"/>
<g fill="#5a3a1b"><rect x="90" y="208" width="10" height="40"/><rect x="360" y="226" width="9" height="34"/></g>
<g fill="#176b39"><circle cx="95" cy="200" r="26"/><circle cx="364" cy="220" r="20"/></g>
</svg>`
);
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(imageAdjustmentsMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
// Soma snippet — headless, ARIA + behavior only. Eidos renders the rows in a
// loop; this shows the composition shape for ONE row.
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as ImageAdjustments from '$soma/components/image-adjustments';",
" import { Slider } from '$uix/eidos/components/slider';",
' let value = $state({});',
'</' + 'script>',
'',
'<ImageAdjustments.Provider',
' bind:value',
' onFilterChange={(f) => apply(f)}',
preset !== 'core' && ` adjustments={[${PRESETS[preset].map((k) => `'${k}'`).join(', ')}]}`,
disabled && ' disabled',
'>',
' <ImageAdjustments.Item adjustment="brightness">',
' {#snippet children({ label, value: v, formattedValue, def, setValue })}',
' <ImageAdjustments.ItemLabel>{label}</ImageAdjustments.ItemLabel>',
' <Slider value={[v]} min={def.min} max={def.max} step={def.step}',
' aria-label={label} onValueChange={(n) => setValue(n[0])}>',
' <Slider.Range /><Slider.Thumb />',
' </Slider>',
' <ImageAdjustments.ItemValue>{formattedValue}</ImageAdjustments.ItemValue>',
' {/snippet}',
' </ImageAdjustments.Item>',
' <!-- …one Item per adjustment… -->',
showReset && ' <ImageAdjustments.Reset />',
'</ImageAdjustments.Provider>'
]
.filter(Boolean)
.join('\n')
);
// Eidos snippet — configured component: renders the labelled Slider rows
// internally from `adjustments`. The consumer applies the streamed filter.
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import ImageAdjustments from '$uix/eidos/components/image-adjustments';",
' let value = $state({});',
" let filter = $state('none');",
'</' + 'script>',
'',
'<ImageAdjustments',
' bind:value',
' onFilterChange={(f) => (filter = f)}',
preset !== 'core' && ` adjustments={[${PRESETS[preset].map((k) => `'${k}'`).join(', ')}]}`,
size !== 'md' && ` size="${size}"`,
!showReset && ' showReset={false}',
disabled && ' disabled',
'/>',
'',
'<img src={src} style="filter: {filter}" />'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · Image adjustments</div>
<h1 data-uix-page-title>ImageAdjustments</h1>
<p data-uix-page-lede>
A reusable panel of image-filter sliders. Soma owns the values and the value→CSS-<code
>filter</code
> math; it composes the framework's <code>&lt;Slider&gt;</code> per row and streams a live
<code>filter</code> string via <code>onFilterChange</code> — apply it to any surface (an
ImagePicker preview, an avatar, a Words image, a future cropper). Five parts, one event
(<code>commit-reset</code>).
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>adjustments</span>8
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>3
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>output</span>CSS filter
</span>
</div>
</header>
<!-- Live preview always rendered (between header and tablist) -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div class="ia-stage-grid">
<div data-uix-surface class="ia-panel">
<ImageAdjustments
bind:value
{adjustments}
{size}
{showReset}
{disabled}
onFilterChange={(f) => (filter = f)}
aria-label="Photo adjustments"
/>
</div>
<div class="ia-preview">
<img
class="ia-photo"
src={photo}
alt="Adjustable sample landscape"
style="filter: {filter === 'none' ? 'none' : filter};"
/>
<button data-uix-chip onclick={() => (value = {})}>Clear values</button>
</div>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>drag a slider, then press Reset to see the commit-reset event</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>filter</span>
{filter}
</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Props grouped by the architectural layer that owns them: <span
data-uix-layer-badge="soma">soma</span
>
headless behavior, <span data-uix-layer-badge="eidos">eidos</span> visual treatment. Drag
any slider — the preview updates live. The one Sema event lives in the dedicated
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
</p>
<!-- ── Soma props ──────────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>adjustments <span data-uix-control-hint>subset + order</span></span>
<span data-uix-chips role="radiogroup">
{#each ['core', 'photo', 'artistic', 'all'] as const as p}
<button data-uix-chip data-active={preset === p} onclick={() => (preset = p)}>{p}</button>
{/each}
</span>
</label>
<div data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>bindable · driven by the sliders</span></span>
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{Object.keys(value).length ? JSON.stringify(value) : '{}'}</code>
</div>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
</div>
<!-- ── Eidos props ─────────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size <span data-uix-control-hint>slider + row scale</span></span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as const as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>showReset <span data-uix-control-hint>composition</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showReset} />
<span data-uix-switch-label>{showReset ? 'on' : 'off'}</span>
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · composes Slider per Item</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · configured rows, adds size + showReset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>All public props. Eidos additions are tagged.</p>
<div data-uix-subsection-head>Provider</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`ImageAdjustmentValues`}</td><td class="default">{`{}`}</td><td>Per-adjustment values. Missing keys = neutral default.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">{`(v: ImageAdjustmentValues) => void`}</td><td class="default empty">—</td><td>Full values record on any change.</td></tr>
<tr><td class="name">onFilterChange</td><td class="type">{`(filter: string) => void`}</td><td class="default empty">—</td><td>The recomputed CSS filter string. The component's real output.</td></tr>
<tr><td class="name">adjustments</td><td class="type">ImageAdjustmentKey[]</td><td class="default">core 6</td><td>Which adjustments + order. Subset of the 8 keys.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable all sliders + reset.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default">"Image adjustments"</td><td>Names the role=group.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">{`ResponsiveProp<'sm' | 'md' | 'lg'>`}</td><td class="default">'md'</td><td>Slider + row typography scale.</td></tr>
<tr><td class="name">showReset <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Render the reset control below the rows.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Adjustment catalog</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Range</th><th>CSS mapping</th></tr></thead>
<tbody>
<tr><td class="name">brightness</td><td class="default">−100…100</td><td class="type">{`brightness((100+v)/100)`}</td></tr>
<tr><td class="name">contrast</td><td class="default">−100…100</td><td class="type">{`contrast((100+v)/100)`}</td></tr>
<tr><td class="name">saturation</td><td class="default">−100…100</td><td class="type">{`saturate((100+v)/100)`}</td></tr>
<tr><td class="name">temperature</td><td class="default">−100…100</td><td class="type">approx: warm→sepia, cool→hue-rotate+saturate</td></tr>
<tr><td class="name">hue</td><td class="default">−180…180°</td><td class="type">{`hue-rotate(v deg)`}</td></tr>
<tr><td class="name">blur</td><td class="default">0…20px</td><td class="type">{`blur(v px)`}</td></tr>
<tr><td class="name">grayscale</td><td class="default">0…100%</td><td class="type">{`grayscale(v%)`}</td></tr>
<tr><td class="name">sepia</td><td class="default">0…100%</td><td class="type">{`sepia(v%)`}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Capability</th><th>Untitled</th><th>Chakra</th><th>Radix</th><th>MUI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Filter-slider panel primitive</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
<tr><td class="name">Live CSS filter output</td><td>⚠️ canvas</td><td>—</td><td>—</td><td>—</td><td>✅ string</td></tr>
<tr><td class="name">Composes the lib's own Slider</td><td>✅</td><td>n/a</td><td>n/a</td><td>n/a</td><td>✅</td></tr>
<tr><td class="name">Reusable outside an image picker</td><td>❌</td><td>—</td><td>—</td><td>—</td><td>✅</td></tr>
<tr><td class="name">Sound/haptic on reset</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>
The morfo is the single source of truth: parts + the one event. Soma transcribes it to
runtime behavior; eidos targets the data-attrs it emits; sema dispatches the event
signature. Source: <code>src/uix/morfo/components/image-adjustments.ts</code>.
</p>
<!-- ── Component header ───────────────────────────────────────── -->
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{imageAdjustmentsMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{imageAdjustmentsMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{imageAdjustmentsMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{imageAdjustmentsMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{imageAdjustmentsMorfo.events?.length ?? 0}</td></tr>
</tbody>
</table>
</div>
<!-- ── Parts overview ────────────────────────────────────────── -->
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- ── Per-part contracts (data + aria) ──────────────────────── -->
{#each imageAdjustmentsMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }> }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<!-- ── Events declaration ────────────────────────────────────── -->
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
One event: <code>commit-reset</code> (family <code>commit</code>, verb <code>reset</code>,
neutral, <code>sequence: post</code>) — the deliberate wipe back to neutral, riding the
soft form-commit tuning + a tap. Per-slider drag feedback is owned by the composed
<code>&lt;Slider&gt;</code> pack, not here. Click <strong>play</strong> to fire the signal
on the live group.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-image-adjustments]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/image-adjustments/image-adjustments.css</code>.
Tokens bare-prefixed (<code>--image-adjustments-*</code>). The only slider override is the
<code>hue</code> row's rainbow track, keyed off the morfo's <code>data-adjustment</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-image-adjustments]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-adjustments][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-adjustments-item]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-adjustments-item-label]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-adjustments-item-value]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-adjustments-reset]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-adjustment='hue'] [data-slider]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The group is a labelled <code>role="group"</code>; each row is an APG-pattern slider via
the composed <code>&lt;Slider&gt;</code> (full keyboard + <code>aria-valuenow</code>). The
value read-out is a plain <code>&lt;span&gt;</code> (not a live region) — the slider thumb
already announces, so a live region would double-announce.
</p>
<div data-uix-subsection-head>Keyboard (per slider row)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Tab</span></td><td>Move between sliders and the reset control.</td></tr>
<tr><td><span data-uix-kbd>←</span> / <span data-uix-kbd>→</span></td><td>Decrease / increase by one step.</td></tr>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>↓</span></td><td>Increase / decrease by one step.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>Larger step (×10).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>Jump to min / max.</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Activate the reset control.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">"Image adjustments" (or prop)</td></tr>
<tr><td class="name">provider</td><td>data-disabled</td><td class="type">when disabled</td></tr>
<tr><td class="name">thumb (slider)</td><td>role</td><td class="type">"slider"</td></tr>
<tr><td class="name">thumb (slider)</td><td>aria-valuemin / max / now</td><td class="type">the adjustment range + value</td></tr>
<tr><td class="name">thumb (slider)</td><td>aria-label</td><td class="type">the adjustment label</td></tr>
<tr><td class="name">reset</td><td>aria-label</td><td class="type">"Reset"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.ia-stage-grid {
display: grid;
grid-template-columns: minmax(220px, 320px) 1fr;
gap: var(--uix-space-3);
align-items: start;
inline-size: 100%;
}
@media (max-width: 720px) {
.ia-stage-grid {
grid-template-columns: 1fr;
}
}
.ia-panel {
padding: var(--uix-space-3);
border-radius: var(--radius-lg);
}
.ia-preview {
display: flex;
flex-direction: column;
gap: var(--uix-space-2);
align-items: flex-start;
}
.ia-photo {
inline-size: 100%;
block-size: auto;
aspect-ratio: 3 / 2;
object-fit: cover;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-default);
background: var(--color-surface-muted);
}
</style>

@ -0,0 +1,562 @@
<script lang="ts">
import ImagePicker, {
type ImagePickerFit,
type ImagePickerValue
} from '$uix/eidos/components/image-picker';
import type { ImageAdjustmentValues } from '$uix/eidos/components/image-adjustments';
import { compileMorfo } from '$uix/morfo';
import { imagePickerMorfo } from '@/uix/morfo/components/image-picker';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
let file = $state<File | null>(null);
let fit = $state<ImagePickerFit>('cover');
let rotation = $state<0 | 90 | 180 | 270>(0);
let adjustments = $state<ImageAdjustmentValues>({});
let size = $state<'sm' | 'md' | 'lg'>('sm');
let showAdjustments = $state(true);
let disabled = $state(false);
let lastValue = $state<ImagePickerValue | null>(null);
const pickerState = $derived(file ? 'ready' : 'empty');
// A synthetic sample image (inline SVG → File) so the "ready" state is
// demoable without a real upload. Deliberately WIDE (16:9) so it differs from
// the 4:3 preview box — that's what makes the `fit` modes visibly distinct:
// cover crops the sides, contain letterboxes, fill stretches. The real
// dropzone also accepts files.
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4ea8ff"/><stop offset="1" stop-color="#bfe3ff"/></linearGradient></defs>
<rect width="640" height="360" fill="url(#s)"/>
<circle cx="510" cy="86" r="46" fill="#ffd24a"/>
<path d="M0 250 Q160 190 320 240 T640 220 V360 H0 Z" fill="#2f9e57"/>
<path d="M0 296 Q190 250 400 286 T640 276 V360 H0 Z" fill="#1f7a42"/>
</svg>`;
function loadSample() {
file = new File([sampleSvg], 'sample.svg', { type: 'image/svg+xml' });
}
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(imagePickerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as ImagePicker from '$soma/components/image-picker';",
" import { FileUpload } from '$uix/eidos/components/file-upload';",
" import { Image } from '$uix/eidos/components/image';",
" import ImageAdjustments from '$uix/eidos/components/image-adjustments';",
' let file = $state(null);',
'</' + 'script>',
'',
'<ImagePicker.Provider bind:file onChange={(v) => save(v)}>',
' {#snippet children({ state, url, filter, fit, prompt, setFile })}',
" {#if state === 'empty'}",
' <FileUpload accept="image/*" multiple={false}',
' onFilesChange={(f) => setFile(f[0] ?? null)}>',
' <FileUpload.Dropzone><FileUpload.HiddenInput />',
' <FileUpload.Trigger>{prompt}</FileUpload.Trigger>',
' </FileUpload.Dropzone>',
' </FileUpload>',
' {:else}',
' <ImagePicker.Preview>',
' <Image src={url} alt="" {fit} style="filter: {filter}" />',
' <ImagePicker.Toolbar>',
' <ImagePicker.Rotate>⟳</ImagePicker.Rotate>',
' <ImagePicker.Remove>✕</ImagePicker.Remove>',
' </ImagePicker.Toolbar>',
' </ImagePicker.Preview>',
' <ImageAdjustments bind:value={adjustments} />',
' {/if}',
' {/snippet}',
'</ImagePicker.Provider>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import ImagePicker from '$uix/eidos/components/image-picker';",
' let file = $state(null);',
'</' + 'script>',
'',
'<ImagePicker',
' bind:file',
' onChange={(v) => save(v)}',
fit !== 'cover' && ` fit="${fit}"`,
size !== 'sm' && ` size="${size}"`,
!showAdjustments && ' showAdjustments={false}',
disabled && ' disabled',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · Image picker</div>
<h1 data-uix-page-title>ImagePicker</h1>
<p data-uix-page-lede>
Pick, preview and adjust an image. A pure composition — it wires the framework's
<code>FileUpload</code> (drop + click), <code>Image</code> (fit modes),
<code>ImageAdjustments</code> (filter sliders) and adds 90° rotation. The soma owns the file,
the object-URL lifecycle and the composed CSS <code>filter</code>; its streamed value is
cropper-ready. Five parts, three events.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>composes</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>output</span>File + filter</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div class="ip-stage">
<ImagePicker
bind:file
bind:fit
bind:rotation
bind:adjustments
{size}
{showAdjustments}
{disabled}
onChange={(v) => (lastValue = v)}
aria-label="Photo picker"
/>
<button data-uix-chip onclick={loadSample} style="align-self: flex-start;">
Load sample image
</button>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>select / rotate / remove an image to see events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>state</span>
{pickerState} · {rotation}°
</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>9</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Props grouped by the architectural layer that owns them: <span data-uix-layer-badge="soma">soma</span>
headless behavior, <span data-uix-layer-badge="eidos">eidos</span> composition. Load the
sample (or drop a real image), then rotate / adjust / remove. Events live in the
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>fit <span data-uix-control-hint>fill mode</span></span>
<span data-uix-chips role="radiogroup">
{#each ['cover', 'contain', 'fill'] as const as f}
<button data-uix-chip data-active={fit === f} onclick={() => (fit = f)}>{f}</button>
{/each}
</span>
</label>
<div data-uix-control>
<span data-uix-control-label>rotation <span data-uix-control-hint>via Rotate button</span></span>
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{rotation}°</code>
</div>
<div data-uix-control>
<span data-uix-control-label>file <span data-uix-control-hint>bindable</span></span>
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{file ? file.name : 'null'}</code>
</div>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · composition
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size <span data-uix-control-hint>adjustments scale</span></span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as const as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>showAdjustments</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showAdjustments} />
<span data-uix-switch-label>{showAdjustments ? 'on' : 'off'}</span>
</span>
</label>
<div data-uix-control>
<span data-uix-control-label>filter <span data-uix-control-hint>onChange output</span></span>
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted); max-inline-size: 16rem; overflow: hidden; text-overflow: ellipsis;">{lastValue?.filter ?? 'none'}</code>
</div>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · composes FileUpload + Image + ImageAdjustments</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · the configured composition</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>All public props. Eidos additions are tagged.</p>
<div data-uix-subsection-head>Provider</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">file <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`File | null`}</td><td class="default">null</td><td>The selected file.</td></tr>
<tr><td class="name">fit <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`'cover' | 'contain' | 'fill'`}</td><td class="default">'cover'</td><td>Fill mode → the composed Image.</td></tr>
<tr><td class="name">rotation <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">0 | 90 | 180 | 270</td><td class="default">0</td><td>Clockwise rotation, driven by the Rotate button.</td></tr>
<tr><td class="name">adjustments <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">ImageAdjustmentValues</td><td class="default">{`{}`}</td><td>Filter values → the composed ImageAdjustments.</td></tr>
<tr><td class="name">onChange</td><td class="type">{`(v: ImagePickerValue) => void`}</td><td class="default empty">—</td><td>{`{ file, url, fit, rotation, filter, adjustments }`} on any change. Cropper-ready.</td></tr>
<tr><td class="name">onSelect / onRemove</td><td class="type">{`(file) => void`} / {`() => void`}</td><td class="default empty">—</td><td>Fire on select / remove.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable picking + all controls.</td></tr>
<tr><td class="name">accept <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string</td><td class="default">'image/*'</td><td>→ FileUpload.</td></tr>
<tr><td class="name">maxSize <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">number</td><td class="default empty">—</td><td>Max bytes → FileUpload.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">{`'sm' | 'md' | 'lg'`}</td><td class="default">'sm'</td><td>Adjustments panel scale.</td></tr>
<tr><td class="name">showAdjustments <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Render the filters panel.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Capability</th><th>Untitled</th><th>Chakra</th><th>Radix</th><th>MUI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Image picker primitive</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
<tr><td class="name">File upload base</td><td>✅</td><td>✅</td><td>❌</td><td>❌</td><td>✅ reuse</td></tr>
<tr><td class="name">Fill modes + 90° rotation</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
<tr><td class="name">Built-in filter sliders</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅ reuse</td></tr>
<tr><td class="name">Cropper-ready output</td><td>⚠️</td><td>—</td><td>—</td><td>—</td><td>✅ File + url</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
No mainstream component library ships an image picker as a primitive — it's app code on top
of a file input. UIX composes three resolved components and emits a cropper-ready value.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>
The morfo declares only the picker's OWN orchestration — the empty↔ready state, the
<code>fit</code> + <code>rotation</code> transforms, and the select / remove / rotate
events. The dropzone, image and sliders carry their own morfos. Source:
<code>src/uix/morfo/components/image-picker.ts</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{imagePickerMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{imagePickerMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{imagePickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{imagePickerMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{imagePickerMorfo.events?.length ?? 0}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each imagePickerMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string }; severity?: string }> }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
Three events: <code>commit-select</code> (commit · affirm — the pickup of a chosen image),
<code>commit-remove</code> (commit · neutral), <code>handle-rotate</code> (handle · neutral
— a spatial nudge, haptic tick). The per-slider feedback is owned by ImageAdjustments.
Click <strong>play</strong> to fire on the live picker.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector(`[data-image-picker-${action.target === 'preview' ? 'preview' : 'provider'}]`) ?? stageRef?.querySelector('[data-image-picker]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({ name: action.name, family: action.semantic.family, target, ...(effectiveIntent ? { intent: effectiveIntent } : {}) });
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/image-picker/image-picker.css</code>. Tokens
bare-prefixed (<code>--image-picker-*</code>). The recipe owns only the orchestration chrome
— the preview box, the rotation transform, the floating toolbar; the composed components
bring their own recipes.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-image-picker]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-picker][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-picker-preview]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-picker-preview][data-rotation]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-picker-toolbar]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-image-picker-rotate] / [data-image-picker-remove]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The picker is a labelled <code>role="group"</code>. The dropzone (FileUpload) and the
sliders (ImageAdjustments) carry their own APG-pattern keyboard + ARIA; the toolbar buttons
are plain buttons with explicit <code>aria-label</code>s.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Tab</span></td><td>Move between the dropzone / preview / toolbar / sliders.</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Activate the focused control (browse, rotate, remove).</td></tr>
<tr><td><span data-uix-kbd>←</span> <span data-uix-kbd>→</span> <span data-uix-kbd>↑</span> <span data-uix-kbd>↓</span></td><td>Adjust the focused filter slider.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">"Image picker" (or prop)</td></tr>
<tr><td class="name">provider</td><td>data-state</td><td class="type">"empty" | "ready"</td></tr>
<tr><td class="name">rotate</td><td>aria-label</td><td class="type">"Rotate 90°"</td></tr>
<tr><td class="name">remove</td><td>aria-label</td><td class="type">"Remove image"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.ip-stage {
display: flex;
flex-direction: column;
gap: var(--uix-space-2);
inline-size: 100%;
max-inline-size: 420px;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.