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-foot \\\\{/,/^\\\\}/' 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": [
"\\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
* block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`.
* A framework `<Popover>` anchored to the active block on its LEFT (opposite
* the main panel), `align='center'` → centred on the block's mid-height; the
* Popover owns positioning + portal + dismissal. Colours use our `<ColorPicker>`,
* numbers our `<NumberField>`, alignment our `<ToggleGroup>`.
* It's the framework `<FloatPanel>` anchored to the active block on its LEFT
* (opposite the main panel); a draggable free window (closes on click-outside /
* Esc). Its CONTENT is organised in `<Tabs>` by style group — Tipografía (only
* 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 { Button } from '$uix/eidos/components/button';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
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 { Popover } from '$uix/eidos/components/popover';
import { clampedBlockAnchor } from './palabras-anchor';
import { FloatPanel } from '$uix/eidos/components/float-panel';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
const PANEL_WIDTH = 256;
const PANEL_W = 284;
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 {
api,
@ -32,27 +37,55 @@
} = $props();
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
// LEFT of the block, opposite the main panel). The Popover owns positioning
// (flip / shift / fit) + portal + dismissal.
let anchorEl = $state<HTMLElement | null>(null);
$effect(() => {
void api.activeBlockId;
// The active block's element (resolved by its stable `data-words-id`, like the
// main panel). FloatPanel seeds its open position from this on the LEFT.
const anchorEl = $derived.by(() => {
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.
const anchor = $derived.by(() => {
// Re-anchor to the LEFT of the block: FloatPanel seeds on the first open; when
// 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;
if (!anchorEl || !win) return null;
return clampedBlockAnchor(anchorEl, win, {
side: 'left',
panelWidth: PANEL_WIDTH,
gap: 8,
padding: 12
});
const id = api.activeBlockId;
if (!el || !win || !id) return;
if (posBlock === null) {
posBlock = id;
return;
}
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';
@ -109,79 +142,104 @@
];
</script>
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}>
<Popover.Portal>
<Popover.Content
customAnchor={anchor as unknown as HTMLElement | null}
side="left"
align="center"
sideOffset={8}
collisionPadding={12}
hideWhenDetached={false}
width={PANEL_WIDTH}
data-palabras-appearance
data-words-external-tool
>
<header data-palabras-panel-head>
<span data-palabras-panel-title>Apariencia</span>
<Button variant="ghost" size="xs" iconOnly aria-label="Cerrar" onclick={onClose}>
{#snippet icon()}<X />{/snippet}
</Button>
</header>
<FloatPanel
{open}
onOpenChange={(v) => !v && onClose()}
bind:position={panelPos}
anchor={anchorEl}
side="left"
align="center"
offset={16}
draggable
closeOnInteractOutside
variant="surface"
color="neutral"
defaultSize={{ width: PANEL_W, height: PANEL_H }}
>
<FloatPanel.Content data-palabras-appearance data-words-external-tool>
<FloatPanel.Header closable={false}>
<FloatPanel.Title>Apariencia</FloatPanel.Title>
</FloatPanel.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-field data-inline>
<span data-palabras-field-label>Tamaño (px)</span>
<NumberField
size="sm"
min={8}
value={fontSizeNum()}
onValueChange={(v) =>
setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</div>
<div data-palabras-field data-inline>
<span data-palabras-field-label>Interlineado</span>
<NumberField
size="sm"
min={1}
step={0.1}
value={lineHeightNum()}
onValueChange={(v) =>
setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</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>
<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>
<span data-palabras-field-label>Tamaño (px)</span>
<NumberField
size="sm"
min={8}
value={fontSizeNum()}
onValueChange={(v) =>
setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</div>
<div data-palabras-field data-inline>
<span data-palabras-field-label>Interlineado</span>
<NumberField
size="sm"
min={1}
step={0.1}
value={lineHeightNum()}
onValueChange={(v) =>
setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</div>
<div data-palabras-field>
<span data-palabras-field-label>Alineación</span>
<ToggleGroup
type="single"
size="sm"
value={[effectiveAlign()]}
onValueChange={(v) => {
const a = v[0] as Align;
if (a) setVisual({ align: a });
}}
>
{#each ALIGNS as a (a.v)}
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
<Tabs.Content value="layout">
<div data-palabras-panel-fields>
<div data-palabras-field>
<span data-palabras-field-label>Alineación</span>
<ToggleGroup
type="single"
size="sm"
value={[effectiveAlign()]}
onValueChange={(v) => {
const a = v[0] as Align;
if (a) setVisual({ align: a });
}}
>
{#each ALIGNS as a (a.v)}
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Tabs.Content>
</Tabs>
</FloatPanel.Body>
</FloatPanel.Content>
</FloatPanel>

@ -1,51 +1,44 @@
<script lang="ts">
/**
* Generated block panel — a floating inspector built from the block's schema
* (`PALABRAS_PANEL_REGISTRY`). It's a framework `<Popover>` anchored to the
* active block (`customAnchor`, `side='right'` + `align='center'` → centred on
* the block's mid-height); the Popover owns positioning (flip / shift / fit),
* the portal and dismissal. Field edits write the active block's prop via the
* path-aware `updateBlockAtPath` command.
* Generated block panel — the framework `<FloatPanel>` anchored to the active
* block (opens to its right, centred on its mid-height; draggable from the
* header; closes on click-outside / Esc). Its body is THREE fixed tabs —
* `Contenido · Diseño · Avanzado` (open on Contenido) — whose fields come from
* the block's schema (`PALABRAS_PANEL_REGISTRY`). Height fits the content up to
* a max (then the body scrolls).
*
* Controls are FRAMEWORK components (coherence): `ToggleGroup` (pills),
* `Switch` (toggle), `NumberField` (number), `Slider` (slider — photo
* adjustments), `CssField` (length — unit-aware, px/%/auto), native input
* (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`).
* Field edits write the active block via `updateBlockAtPath` (structural) or
* `setBlockVisualAtPath` (style, `field.visual`). The `textarea` field edits
* the block's TEXT (its inline `children`). Controls are FRAMEWORK components.
*/
import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words';
import { CssField } from '$uix/eidos/components/css-field';
import { Switch } from '$uix/eidos/components/switch';
import { Slider } from '$uix/eidos/components/slider';
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 { Trash2, Upload } from '$uix/eidos/components/icon';
import { getPanelSchema } from './panel-schema';
import { blockTypeIcon } from './palabras-block-types';
import PalabrasSegmented from './palabras-segmented.svelte';
import PalabrasColorRow from './palabras-color-row.svelte';
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 { clampedBlockAnchor } from './palabras-anchor';
import type { PalabrasFieldDef } from './types';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
const PANEL_WIDTH = 272;
let {
api,
content,
open,
onEditAppearance,
onClose
}: {
api: ProviderSnippetProps;
content: HTMLElement | null;
open: boolean;
onEditAppearance: () => void;
onClose: () => void;
} = $props();
@ -55,61 +48,88 @@
const HeadIcon = $derived(blockTypeIcon(loc?.node.type));
const canDelete = $derived(!!loc && loc.path.length === 1);
// The active block element drives the Popover's `customAnchor`. The Popover
// owns positioning (flip / shift / fit), the portal + dismissal.
// Reactive to `activeBlockId` so the panel re-anchors when another block opens.
let anchorEl = $state<HTMLElement | null>(null);
$effect(() => {
// Re-resolve on `open` too: the block may already be active (marker stamped)
// before the panel opens, so `activeBlockId` doesn't change to re-trigger us.
void api.activeBlockId;
// The active block's ELEMENT — resolved by its STABLE `data-words-id` (the same
// way WordsActivate stamps the active marker). `api.activeBlockId` is a
// materialized path (`cols/col/child`); the element carries only its LAST
// segment as `data-words-id`. FloatPanel seeds its open position from this
// (`side='right'` + `align='center'` → right of the block, centred on its
// mid-height, viewport-clamped).
const anchorEl = $derived.by(() => {
void open;
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? 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
// (it slides inward to overlap the content's edge). `align='center'` centres
// it on the block's mid-height. See palabras-anchor.ts for the why + the cast.
const anchor = $derived.by(() => {
const win = content?.ownerDocument?.defaultView;
if (!anchorEl || !win) return null;
return clampedBlockAnchor(anchorEl, win, {
side: 'right',
panelWidth: PANEL_WIDTH,
gap: 8,
padding: 12
});
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;
});
const pinnedSections = $derived((schema?.sections ?? []).filter((s) => s.pinned));
const accordionSections = $derived((schema?.sections ?? []).filter((s) => !s.pinned));
// Open accordion items (section titles). Seeded per schema: `defaultCollapsed`
// groups start closed, the rest open. Re-seeded when the block TYPE changes.
let openSections = $state<string[]>([]);
let seededFor: unknown;
// FloatPanel is a FREE window once open (drag + resize). FloatPanel seeds the
// position from `anchor` on the first open; when the active block CHANGES while
// the panel stays open, re-anchor next to the new block (right, centred on its
// 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(() => {
const s = schema;
if (s === seededFor) return;
seededFor = s;
openSections = (s?.sections ?? [])
.filter((sec) => !sec.pinned && !sec.defaultCollapsed)
.map((sec) => sec.title);
if (!open) {
panelPos = undefined;
posBlock = null;
return;
}
const el = anchorEl;
const win = content?.ownerDocument?.defaultView;
const id = api.activeBlockId;
if (!el || !win || !id) return;
if (posBlock === null) {
posBlock = id; // first open — FloatPanel's own anchor seed positions it
return;
}
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 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));
// Three fixed tabs (Contenido open by default). Their fields come from the
// 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');
/** Plain text of the block's inline `children` — for the Texto textarea. */
function blockText(): string {
const ch = (loc?.node as { children?: readonly unknown[] } | undefined)?.children;
if (!Array.isArray(ch)) return '';
return ch
.map((n) => {
const node = n as { type?: string; text?: string; children?: { text?: string }[] };
if (node.type === 'text') return node.text ?? '';
if (node.type === 'link' && Array.isArray(node.children))
return node.children.map((t) => t.text ?? '').join('');
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 }] }
});
}
/** Raw stored value of a prop, or undefined. */
function valueOf(key: string): unknown {
@ -135,14 +155,39 @@
if (!path) return;
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) {
// Deselecting (re-click the active item) clears the prop → restores the default.
if (v == null) {
write(field.key, undefined);
commit(field, undefined);
return;
}
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) {
// clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store
@ -151,7 +196,7 @@
if (v == null || !Number.isFinite(v)) out = undefined;
else if (field.clearWhenZero && v <= 0) out = undefined;
else out = v;
write(field.key, out);
commit(field, out);
}
function onUpload() {
const doc = content?.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
@ -219,7 +264,7 @@
size="xs"
{disabled}
checked={!!effective(field)}
onCheckedChange={(c) => write(field.key, c)}
onCheckedChange={(c) => commit(field, c)}
/>
</div>
{:else if field.type === 'number'}
@ -252,7 +297,7 @@
min={field.min ?? 0}
max={field.max ?? 100}
step={field.step ?? 1}
onValueChange={(v) => write(field.key, v[0])}
onValueChange={(v) => commit(field, v[0])}
aria-label={field.label}
>
<Slider.Range />
@ -272,124 +317,154 @@
max={field.max}
step={field.step}
{disabled}
onValueChange={(v) => write(field.key, v)}
onValueChange={(v) => commit(field, v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder={field.placeholder ?? 'auto'} />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</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'}
<div data-palabras-field data-inline={field.wide ? undefined : ''}>
<div data-palabras-field>
<span data-palabras-field-label>{field.label}</span>
{#if field.upload}
<span data-palabras-src-group>
<input
data-palabras-text
data-wide
<Field size="sm" variant="surface">
<Field.Control>
<Field.Input
type="text"
value={String(valueOf(field.key) ?? '')}
placeholder={field.placeholder ?? '#…'}
oninput={(e) => e.currentTarget.removeAttribute('data-invalid')}
onchange={(e) => onSrcCommit(field, e.currentTarget)}
placeholder={field.placeholder}
oninput={field.upload
? (e) => e.currentTarget.removeAttribute('data-invalid')
: (e) => commit(field, e.currentTarget.value)}
onchange={field.upload
? (e) => onSrcCommit(field, e.currentTarget)
: undefined}
/>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Subir imagen"
title="Subir imagen"
onclick={onUpload}
>
{#snippet icon()}<Upload />{/snippet}
</Button>
</span>
{: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.upload}
<Field.Suffix>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Subir imagen"
title="Subir imagen"
onclick={onUpload}
>
{#snippet icon()}<Upload />{/snippet}
</Button>
</Field.Suffix>
{/if}
</Field.Control>
</Field>
</div>
{:else if field.type === 'color'}
<PalabrasColorRow
label={field.label}
current={effectiveColor(field)}
onPick={(hex) => commit(field, hex)}
/>
{/if}
{/snippet}
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}>
<Popover.Portal>
<Popover.Content
customAnchor={anchor as unknown as HTMLElement | null}
side="right"
align="center"
sideOffset={8}
collisionPadding={12}
hideWhenDetached={false}
width={PANEL_WIDTH}
data-palabras-panel-card
data-words-external-tool
>
{#if loc && schema}
<header data-palabras-panel-head>
<FloatPanel
{open}
onOpenChange={(v) => !v && onClose()}
bind:position={panelPos}
anchor={anchorEl}
side="right"
align="center"
offset={16}
draggable
closeOnInteractOutside
variant="surface"
color="neutral"
defaultSize={{ width: PANEL_W, height: PANEL_H }}
>
<FloatPanel.Content data-palabras-panel data-words-external-tool>
{#if loc && schema}
<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-title>{schema.label}</span>
<Button
variant="ghost"
size="xs"
iconOnly
<FloatPanel.Title>{schema.label}</FloatPanel.Title>
</span>
<FloatPanel.Controls>
<FloatPanel.Action
aria-label="Eliminar bloque"
disabled={!canDelete}
onclick={deleteActive}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
</header>
<Trash2 />
</FloatPanel.Action>
</FloatPanel.Controls>
</FloatPanel.Header>
<FloatPanel.Body>
<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>
<div data-palabras-panel-body>
{#each pinnedSections as section (section.title)}
<Tabs.Content value="contenido">
<div data-palabras-panel-fields>
{#each section.fields as field (field.key)}
{#each contenidoFields as field (field.key)}
{@render fieldControl(field)}
{/each}
</div>
{/each}
</Tabs.Content>
{#if accordionSections.length}
<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>
{#each section.fields as field (field.key)}
{@render fieldControl(field)}
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion>
{/if}
</div>
<Tabs.Content value="diseño">
<div data-palabras-panel-fields>
{#if disenoFields.length}
{#each disenoFields as field (field.key)}
{@render fieldControl(field)}
{/each}
{:else}
<p data-palabras-tab-empty>Pendiente</p>
{/if}
</div>
</Tabs.Content>
<footer data-palabras-panel-foot>
<span>Apariencia · {hasOverride ? 'personalizada' : 'heredada'}</span>
<button type="button" data-palabras-panel-edit onclick={onEditAppearance}>Editar</button>
</footer>
{:else}
<Tabs.Content value="avanzado">
<div data-palabras-panel-fields>
{#if avanzadoFields.length}
{#each avanzadoFields as field (field.key)}
{@render fieldControl(field)}
{/each}
{:else}
<p data-palabras-tab-empty>Pendiente</p>
{/if}
</div>
</Tabs.Content>
</Tabs>
</FloatPanel.Body>
{:else}
<FloatPanel.Body>
<p data-palabras-panel-empty>Selecciona un bloque</p>
{/if}
</Popover.Content>
</Popover.Portal>
</Popover>
</FloatPanel.Body>
{/if}
</FloatPanel.Content>
</FloatPanel>

@ -1,7 +1,7 @@
<script lang="ts">
/**
* 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
* name + tooltip) when it carries an `icon`, otherwise as its text label.
*

@ -365,42 +365,105 @@
}
/* ── Floating inspector panel ─────────────────────────────────────────────── */
/* The floating surface IS a framework `<Popover.Content>` — positioning (side /
align / flip / shift / fit-to-viewport), the portal, dismissal AND the surface
(border / bg / shadow / max-height) all come from the popover recipe. Here we
only RE-SHAPE the content's inner layout into a flex column with a pinned
header, a scrollable body and a pinned footer — overriding the popover recipe's
`display: grid` / padding / gap / `overflow: auto`. Doubled attribute (0,2,0)
beats `[data-popover-content]` (0,1,0) regardless of stylesheet load order. */
[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
/* The floating surface IS the framework `<FloatPanel>` — positioning (anchor →
side / align, viewport-clamped on open), drag, resize, stacking, focus,
dismissal AND the surface (border / bg / shadow / flex column with a pinned
header + scrolling body) all come from FloatPanel. Here we only force the UI
sans across the panel and style the palabras-specific bits (header icon group,
footer, fields). */
/* Force the UI SANS across the whole panel + every descendant — the framework
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
single-attribute component font rule regardless of code-split load order —
same technique as Words' chrome typeface guard. */
[data-palabras-panel-card][data-palabras-panel-card],
[data-palabras-panel-card][data-palabras-panel-card] * {
otherwise leak into the field labels. A DOUBLED scope attribute = (0,2,0) wins
regardless of code-split load order — same technique as Words' chrome guard. */
[data-palabras-panel][data-palabras-panel],
[data-palabras-panel][data-palabras-panel] * {
font-family: var(--font-ui);
}
/* Header: block-defining icon · title (grows) · trash */
[data-palabras-panel-head] {
flex: 0 0 auto;
display: flex;
/* Height: fit the content between a MIN and a MAX, then the body scrolls.
FloatPanel writes an inline FIXED height on the content element; override it to
`auto` (+ a floor + a cap) so the panel is as tall as its content. */
[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;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) var(--space-3);
/* The header is the drag handle (PalabrasPanel). */
cursor: move;
user-select: none;
touch-action: none;
min-inline-size: 0;
}
/* Body — header + footer stay pinned, this holds the scrollable region. It CLIPS
(overflow hidden) rather than scrolls: the panel is compact (single open
@ -605,6 +668,22 @@
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 — 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

@ -19,7 +19,6 @@
import { Flex } from '$uix/eidos/components/flex';
import WordsActivate from '$uix/eidos/components/words/words-activate.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 PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte';
@ -54,9 +53,6 @@
// The properties panel is TRIGGERED (block grip) and dismissed (Esc /
// click-away) — hidden by default so the editor is a clean full-width surface.
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>
{#if mounted}
@ -89,11 +85,9 @@
onOpen={(id) => {
api.setActiveBlock(id);
panelOpen = true;
appearanceOpen = false;
}}
onClose={() => {
panelOpen = false;
appearanceOpen = false;
}}
/>
{/if}
@ -111,7 +105,6 @@
// its interaction, so a click opens it.
if (type === 'image') {
panelOpen = true;
appearanceOpen = false;
}
}}
/>
@ -127,29 +120,20 @@
</div>
</div>
</Card>
<!-- Panel + appearance are floating <Popover>s anchored to the active
block; they own their own show/hide via `open`, the portal, and
dismissal (click-outside / Esc). No manual nav layer needed. -->
<!-- The SINGLE properties panel — a floating <FloatPanel> anchored to
the active block. Structure + style live in ONE panel (Tipografía
direct for text, the rest in the accordion). Owns its show/hide,
portal and dismissal (click-outside / Esc). -->
<PalabrasPanel
{api}
content={contentEl}
open={panelOpen}
onEditAppearance={() => (appearanceOpen = true)}
onClose={() => {
panelOpen = false;
appearanceOpen = false;
}}
/>
<PalabrasAppearance
{api}
content={contentEl}
open={appearanceOpen}
onClose={() => (appearanceOpen = false)}
onClose={() => (panelOpen = false)}
/>
</div>
<p data-palabras-help>
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>
</Flex>
{/snippet}

@ -3,40 +3,36 @@
*
* 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
* the panel presentation. Adding a block's panel is one entry here — the panel
* + breadcrumb never switch on a concrete type.
* the panel presentation.
*
* Pilot scope: `heading` (the working semantic field) + `paragraph` (header
* only). The rest land once the architecture is verified end-to-end.
* Model: every panel has THREE fixed tabs — `Contenido · Diseño · Avanzado`
* (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';
const heading: PalabrasBlockSchema = {
type: 'heading',
label: 'Encabezado',
dotToken: 'var(--color-primary-solid)',
sections: [
contenido: [
{
title: 'QUÉ ES',
pinned: true,
fields: [
{
key: 'level',
type: 'pills',
label: 'Nivel estructural',
options: [
{ value: 1, label: 'Sección' },
{ value: 2, label: 'Subsección' },
{ value: 3, label: 'Apartado' }
]
},
{ key: 'index', type: 'toggle', label: 'Aparece en el índice' },
{ key: 'fold', type: 'toggle', label: 'Plegable' },
{ key: 'anchor', type: 'text', label: 'Ancla de enlace' }
key: 'level',
type: 'pills',
label: 'Nivel',
options: [
{ value: 1, label: 'Sección' },
{ value: 2, label: 'Subsección' },
{ value: 3, label: 'Apartado' }
]
}
},
// `textarea` edits the block's TEXT (its inline `children`), not a prop.
{ key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del encabezado' },
{ key: 'link', type: 'text', label: 'Enlace', placeholder: 'https://…', wide: true }
]
};
@ -44,172 +40,32 @@ const paragraph: PalabrasBlockSchema = {
type: 'paragraph',
label: 'Párrafo',
dotToken: 'var(--color-content-muted)',
sections: []
contenido: [
{ key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del párrafo' }
]
};
const image: PalabrasBlockSchema = {
type: 'image',
label: 'Imagen',
dotToken: 'var(--color-secondary-solid)',
sections: [
// Pinned header — always visible: URL (+ upload), alt, caption.
contenido: [
{
title: 'Origen',
pinned: true,
fields: [
{
key: 'src',
type: 'text',
label: 'URL o ruta',
placeholder: 'https://… o /ruta/imagen.png',
wide: true,
upload: true
},
{
key: 'alt',
type: 'text',
label: 'Texto alternativo',
placeholder: 'Describe la imagen (accesibilidad)',
wide: true
},
{
key: 'caption',
type: 'text',
label: 'Pie de foto',
placeholder: 'Opcional',
wide: true
}
]
key: 'src',
type: 'text',
label: 'URL o ruta',
placeholder: 'https://… o /ruta/imagen.png',
wide: true,
upload: 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'
}
]
key: 'alt',
type: 'text',
label: 'Texto alternativo',
placeholder: 'Describe la imagen (accesibilidad)',
wide: true
},
// 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
}
]
}
{ key: 'caption', type: 'text', label: 'Pie de foto', placeholder: 'Opcional', wide: true }
]
};

@ -39,9 +39,11 @@ export type PalabrasFieldType =
| 'select'
| 'chip'
| 'text'
| 'textarea'
| 'number'
| 'slider'
| 'length';
| 'length'
| 'color';
/** One choice in a `pills` / `select` field. */
export interface PalabrasFieldOption {
@ -83,6 +85,13 @@ export interface PalabrasFieldDef {
/** 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). */
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
* IS the per-block-property defaults schema. The control displays
* `node[key] ?? default`; the renderer treats the neutral value as identity. */
@ -110,14 +119,24 @@ export interface PalabrasSectionDef {
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 {
readonly type: string;
/** Shown in the panel header + breadcrumb. */
readonly label: string;
/** Colour token for the header dot. */
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. */

@ -100,14 +100,21 @@
box-shadow: var(--slider-thumb-shadow);
cursor: grab;
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:
background 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);
}
/* Pickup lift on grab: scale toward the viewer + a higher shadow. */
[data-slider-thumb]:active {
cursor: grabbing;
scale: var(--slider-thumb-scale-active);
box-shadow: var(--slider-thumb-shadow-active);
}
[data-slider-thumb]:focus-visible {
@ -144,4 +151,8 @@
[data-slider-thumb] {
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-radius: var(--radius-md);
--_textarea-border: var(--color-neutral-border);
--_textarea-border-hover: var(--color-neutral-border-strong);
--_textarea-border-focus: var(--color-primary-border-strong);
/* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary
(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-color: var(--color-content);
--_textarea-placeholder: var(--color-content-muted);
@ -47,27 +51,29 @@
--_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'] {
--_textarea-border-focus: var(--color-secondary-border-strong);
--_textarea-border-focus: var(--color-secondary-solid);
}
[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'] {
--_textarea-border-focus: var(--color-affirm-border-strong);
--_textarea-border-focus: var(--color-affirm-solid);
}
[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'] {
--_textarea-border-focus: var(--color-risk-border-strong);
--_textarea-border-focus: var(--color-risk-solid);
}
[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'] {
--_textarea-border-focus: var(--color-loss-border-strong);
--_textarea-border-focus: var(--color-loss-solid);
}
/* The real input. */
@ -101,7 +107,7 @@
}
[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] {

@ -1337,6 +1337,46 @@
--float-panel-accent: var(--color-neutral-border);
--float-panel-transition-duration: var(--duration-fast);
--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-height-xs: var(--control-height-xs);
--date-field-height-sm: var(--control-height-sm);
@ -2734,13 +2774,15 @@
--slider-min-inline-size: 12rem;
--slider-min-block-size: 10rem;
--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-thumb-radius: var(--radius-full);
--slider-thumb-border-width: var(--border-width);
--slider-thumb-border: var(--color-border-default);
--slider-thumb-bg: var(--color-surface-default);
--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-tick-radius: var(--radius-full);
--slider-tick-bg: var(--color-neutral-border);

@ -1178,6 +1178,66 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-duration': 'var(--duration-fast)',
'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': {
'stack-gap': 'var(--space-1-5)',
'height-xs': 'var(--control-height-xs)',
@ -3124,13 +3184,23 @@ export const THEME_BASE_RECIPE_TOKENS = {
'min-inline-size': '12rem',
'min-block-size': '10rem',
'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)',
'thumb-radius': 'var(--radius-full)',
'thumb-border-width': 'var(--border-width)',
'thumb-border': 'var(--color-border-default)',
'thumb-bg': 'var(--color-surface-default)',
'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':
'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)',

@ -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 { commandLangs } from './command';
import { contextMenuLangs } from './context-menu';
import { cropperLangs } from './cropper';
import { dateFieldLangs } from './date-field';
import { datePickerLangs } from './date-picker';
import { dateRangeFieldLangs } from './date-range-field';
@ -32,6 +33,8 @@ import { floatPanelLangs } from './float-panel';
import { formLangs } from './form';
import { gridListLangs } from './grid-list';
import { imageLangs } from './image';
import { imageAdjustmentsLangs } from './image-adjustments';
import { imagePickerLangs } from './image-picker';
import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox';
import { menubarLangs } from './menubar';
@ -100,6 +103,7 @@ export const componentLangs = {
combobox: comboboxLangs,
command: commandLangs,
'context-menu': contextMenuLangs,
cropper: cropperLangs,
'date-field': dateFieldLangs,
'date-picker': datePickerLangs,
'date-range-field': dateRangeFieldLangs,
@ -116,6 +120,8 @@ export const componentLangs = {
form: formLangs,
'grid-list': gridListLangs,
image: imageLangs,
'image-adjustments': imageAdjustmentsLangs,
'image-picker': imagePickerLangs,
'link-preview': linkPreviewLangs,
listbox: listboxLangs,
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 { comboboxSema } from './combobox';
export { contextMenuSema } from './context-menu';
export { cropperSema } from './cropper';
export { cssFieldSema } from './css-field';
export { dateFieldSema } from './date-field';
export { dialogSema } from './dialog';
@ -24,6 +25,8 @@ export { editableSema } from './editable';
export { fileUploadSema } from './file-upload';
export { floatPanelSema } from './float-panel';
export { formSema } from './form';
export { imageAdjustmentsSema } from './image-adjustments';
export { imagePickerSema } from './image-picker';
export { menubarSema } from './menubar';
export { navigationMenuSema } from './navigation-menu';
export { numberFieldSema } from './number-field';

@ -114,7 +114,9 @@ export const SEMA_VERBS = {
'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: [
'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 Command from './command';
export * as ContextMenu from './context-menu';
export * as Cropper from './cropper';
export * as CssField from './css-field';
export * as DateField from './date-field';
export * as DatePicker from './date-picker';
@ -29,6 +30,8 @@ export * as FileUpload from './file-upload';
export * as FloatPanel from './float-panel';
export * as Form from './form';
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 Listbox from './listbox';
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 { ScrollLock, type ScrollLockOption } from './scroll-lock.svelte';
export { ResizeObserver$ } from './resize-observer.svelte';
export { ZoomPan, type ZoomPanConfig } from './zoom-pan.svelte';
export {
ImageProvider,
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 { floatPanelSema } from '$uix/sema/components/float-panel';
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 { paginationSema } from '$uix/sema/components/pagination';
import { passwordFieldSema } from '$uix/sema/components/password-field';
@ -35,6 +37,7 @@
import { calendarSema } from '$uix/sema/components/calendar';
import { colorPickerSema } from '$uix/sema/components/color-picker';
import { comboboxSema } from '$uix/sema/components/combobox';
import { cropperSema } from '$uix/sema/components/cropper';
import { cssFieldSema } from '$uix/sema/components/css-field';
import { dateFieldSema } from '$uix/sema/components/date-field';
import { Popover } from '$uix/eidos/components/popover';
@ -71,6 +74,7 @@
checkboxSema,
colorPickerSema,
comboboxSema,
cropperSema,
cssFieldSema,
dateFieldSema,
dialogSema,
@ -79,6 +83,8 @@
fileUploadSema,
floatPanelSema,
formSema,
imageAdjustmentsSema,
imagePickerSema,
numberFieldSema,
paginationSema,
passwordFieldSema,
@ -202,6 +208,9 @@
{ slug: '/uix/components/avatar-group', label: 'AvatarGroup' },
{ slug: '/uix/components/icon', label: 'Icon' },
{ 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' }
]
},

@ -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.