soma: 10 new components + reactivity audit + guide rules A30–A33
Components shipped:
- AlertDialog (thin Dialog specialisation with Escape=close default)
- Breadcrumb (+ interactive Ellipsis composing DropdownMenu)
- Carousel (gesture + autoplay + indicators)
- Listbox (roving tabindex + typeahead + Ctrl+A multi-select)
- NavigationMenu (hover-intent + data-motion direction + Indicator CSS vars)
- PinInput (Field-aware segmented input with paste transformer)
- RatingGroup (half-step + clearable + keyboard digits)
- Toggle (standalone two-state with Field OR-merge)
- VirtualGrid (2D windowing for spreadsheets / thumbnail grids)
- VirtualList (fixed + dynamic heights, ResizeObserver anti-jump, WindowViewport)
Guide rules (src/uix/soma/COMPONENT_GUIDE.md):
- A30 + item 32: id registration via direct assign, never $effect — the
$effect(() => parent.id = opts.id) pattern causes reactive loops that
freeze the page. Ref: PinInput + Listbox incidents.
- A31 + item 33: per-entity $derived must not call provider methods that
read global state — lift to single provider $derived, per-entity
derivations pointer-compare. Ref: Command (2026-04-17), Listbox
rovingTarget.
- A32 + items 34–35: present comparison table in conversation before
declaring done. Every ❌/⚠️ gets an explicit decision (implement /
defer to v2 / drop); deferred features become their own README section.
- A33 + item 36: reactive collections use SvelteMap / SvelteSet from
svelte/reactivity — $state(new Map()) only tracks field reassignment,
not per-entry .set/.get/.has reads inside $derived.
Reactivity audits + fixes:
- VirtualList sizeCache → SvelteMap (dynamic heights were silently stuck).
- Form touched + registry → SvelteMap (isTouched / isDirty / onBlur
validation $derived never re-ran with plain Map).
- Combobox labelRegistry → SvelteMap (removed clone-and-reassign
workaround, O(N)→O(1) per registration).
Other fixes this pass:
- ColorField/ColorPicker RGB co-increment bug (HSV round-trip was lossy).
- 24 state_referenced_locally warnings swept across date/time/color
field+picker wrappers (removed `void X;` placeholders that Svelte 5
flagged, added svelte-ignore for intentional mount-time reads).
- AlertDialog Content wrapper forces escapeKeydownBehavior=close
(overrides Dialog's alertdialog→ignore default → matches WAI-ARIA).
- Breadcrumb Ellipsis interactive prop renders <button aria-haspopup>
so DropdownMenu composition works without manual trigger plumbing.
- VirtualList Viewport drops `contain: strict` (was breaking programmatic
scrollTo in some engines).
- Memory entries: feedback_id_effect_assign_directly,
feedback_scope_approval, feedback_svelte_reactivity_collections.
Sium library (delegated session, landed in tree):
- Core schema + primitives + combinators + pipe + refines + modifiers.
- Langs module with idlangref resolver.
- Svelte form adapter.
- Protocol docs + status ledger for Codex coordination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
# VirtualGrid
2D windowing primitive. Virtualises rows **and** columns — renders only the cells inside the visible rectangle plus row / column overscan. Keeps DOM size constant regardless of `rowCount × columnCount` . A 1000 × 1000 grid (1M cells) renders ~100 DOM nodes, not a million.
Scope is fixed-size per axis (`rowSize` + `columnSize` ). Dynamic per-cell sizing in 2D is genuinely ambiguous — a measured cell tells you about its row AND column at the same time, and the delta has to be attributed somehow. Most virtualisers sidestep this by forcing fixed axis sizes. Follow the same convention.
## Anatomy
```svelte
< VirtualGrid.Provider
rowCount={1000}
columnCount={50}
rowSize={32}
columnSize={120}
>
{#snippet children({ virtualCells, totalHeight, totalWidth })}
< VirtualGrid.Viewport style:height = "500px" style:width = "100%" >
< div style:height = "{totalHeight}px" style:width = "{totalWidth}px" style:position = "relative" >
{#each virtualCells as cell (cell.key)}
< VirtualGrid.Cell { . . . cell } >
{data[cell.rowIndex][cell.columnIndex]}
< / VirtualGrid.Cell >
{/each}
< / div >
< / VirtualGrid.Viewport >
{/snippet}
< / VirtualGrid.Provider >
```
## Parts
| Part | Element | Description |
| ---------- | ---------- | --------------------------------------------------------------------------- |
| `Provider` | `<div>` | Root context. Tracks both scroll axes, viewport size, 2D range derivation. |
| `Viewport` | `<div>` | 2D scroll container (`overflow: auto` on both axes). |
| `Cell` | `<div>` | One virtualised cell. Absolutely positioned via `translate3d(col, row, 0)` . |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ---------------- | ------------------------------------ | ------------- | --------------------------------------------- |
| `rowCount` | `number` | required | Total number of rows. |
| `columnCount` | `number` | required | Total number of columns. |
| `rowSize` | `number` | `40` | Fixed row height (px). |
| `columnSize` | `number` | `120` | Fixed column width (px). |
| `rowOverscan` | `number` | `2` | Extra rows above / below the visible range. |
| `columnOverscan` | `number` | `2` | Extra columns left / right of the visible range.|
| `getRowKey` | `(index: number) => string \| number` | `(i) => i` | Stable key per row. |
| `getColumnKey` | `(index: number) => string \| number` | `(i) => i` | Stable key per column. |
### `Cell`
Pass every field of the `virtualCell` snippet value through to the Cell — `rowIndex` , `columnIndex` , `rowStart` , `columnStart` , `rowSize` , `columnSize` .
### Snippet props
```ts
{
virtualCells: Array< {
rowIndex: number;
columnIndex: number;
rowStart: number;
columnStart: number;
rowSize: number;
columnSize: number;
key: string | number;
}>;
totalHeight: number; // sum of row sizes
totalWidth: number; // sum of column sizes
isEmpty: boolean;
scrollToCell: (
rowIndex: number,
columnIndex: number,
opts?: {
rowAlign?: 'start' | 'center' | 'end' | 'auto';
columnAlign?: 'start' | 'center' | 'end' | 'auto';
behavior?: ScrollBehavior;
}
) => void;
}
```
## Data Attributes
| Part | Attribute | Values |
| ---------- | --------------------------- | ---------------- |
| Provider | `data-virtual-grid` | Always present |
| Viewport | `data-virtual-grid-viewport` | Always present |
| Cell | `data-virtual-grid-cell` | Always present |
| Cell | `data-row-index` | Row index |
| Cell | `data-column-index` | Column index |
## ARIA
VirtualGrid is a rendering optimisation; it carries no ARIA roles. For a semantic table / grid, wrap the `Provider` in the appropriate outer element (e.g. `role="grid"` ) and set `role="row"` / `role="gridcell"` on `Cell` yourself.
docs(soma): add Sema events section to 23 component READMEs
Documenta los cambios doctrinales del sprint Lectura C + cabling
soma. Cada README añade una sección `## Sema events` con tabla
(event / family / verb / target / intent / when) + referencia al
pack si existe + notas doctrinales de las correcciones aplicadas.
Componentes:
- Packs creados este sprint (11): switch, toggle, toggle-group,
menubar, navigation-menu, dropdown-menu, context-menu, tree-view,
tree-grid, tooltip, collapsible.
- Componentes con emisión cableada sin pack (8): listbox, grid-list,
table, feed, command, carousel, announce, clipboard.
- Componentes gestuales con notas sobre handle-scroll no-emitido
(3): drag-drop, virtual-list, virtual-grid.
- Checkbox: documenta la doctrina de dos eventos direccionales
(commit-toggle-check/uncheck con intent affirm/neutral).
Correcciones doctrinales documentadas:
- announce: commit-announce-* → signal.announce / signal.alert
- tree-view: target item → branch para emerge-expand/collapse
- table: shift.sort → commit.set; shift.expand-row → emerge.expand
- drag-drop: commit.drag-start/drop → handle.pick/drop
- clipboard: commit.copy → commit.save; commit.copy-error +
threat → commit.fail + risk
- virtual-list/grid: handle-scroll declarado pero NO emitido
(decisión consciente — buzz nonstop con family.handle haptic-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
## Sema events
| Event | Family | Verb | Target | Intent | Emitted? | When |
| ------------------------ | -------- | ---------- | ---------- | --------- | -------- | -------------------------------------------------- |
| `handle-scroll-row` | `handle` | `scroll` | `viewport` | — | NO | Row-axis scroll. Same buzz reasoning as virtual-list — soma does NOT emit. |
| `handle-scroll-column` | `handle` | `scroll` | `viewport` | — | NO | Column-axis scroll. Same as above. |
| `shift-navigate-to-cell` | `shift` | `navigate` | `viewport` | — | YES | `scrollToCell(rowIndex, columnIndex)` — programmatic discrete navigation. |
| `commit-set-resize` | `commit` | `set` | `provider` | `neutral` | YES | `rowCount` or `columnCount` changed (real dimension change, not initial). |
See virtual-list README for the rationale on skipping `handle-scroll*` emission. No per-component pack — falls back to family bases.
soma: 10 new components + reactivity audit + guide rules A30–A33
Components shipped:
- AlertDialog (thin Dialog specialisation with Escape=close default)
- Breadcrumb (+ interactive Ellipsis composing DropdownMenu)
- Carousel (gesture + autoplay + indicators)
- Listbox (roving tabindex + typeahead + Ctrl+A multi-select)
- NavigationMenu (hover-intent + data-motion direction + Indicator CSS vars)
- PinInput (Field-aware segmented input with paste transformer)
- RatingGroup (half-step + clearable + keyboard digits)
- Toggle (standalone two-state with Field OR-merge)
- VirtualGrid (2D windowing for spreadsheets / thumbnail grids)
- VirtualList (fixed + dynamic heights, ResizeObserver anti-jump, WindowViewport)
Guide rules (src/uix/soma/COMPONENT_GUIDE.md):
- A30 + item 32: id registration via direct assign, never $effect — the
$effect(() => parent.id = opts.id) pattern causes reactive loops that
freeze the page. Ref: PinInput + Listbox incidents.
- A31 + item 33: per-entity $derived must not call provider methods that
read global state — lift to single provider $derived, per-entity
derivations pointer-compare. Ref: Command (2026-04-17), Listbox
rovingTarget.
- A32 + items 34–35: present comparison table in conversation before
declaring done. Every ❌/⚠️ gets an explicit decision (implement /
defer to v2 / drop); deferred features become their own README section.
- A33 + item 36: reactive collections use SvelteMap / SvelteSet from
svelte/reactivity — $state(new Map()) only tracks field reassignment,
not per-entry .set/.get/.has reads inside $derived.
Reactivity audits + fixes:
- VirtualList sizeCache → SvelteMap (dynamic heights were silently stuck).
- Form touched + registry → SvelteMap (isTouched / isDirty / onBlur
validation $derived never re-ran with plain Map).
- Combobox labelRegistry → SvelteMap (removed clone-and-reassign
workaround, O(N)→O(1) per registration).
Other fixes this pass:
- ColorField/ColorPicker RGB co-increment bug (HSV round-trip was lossy).
- 24 state_referenced_locally warnings swept across date/time/color
field+picker wrappers (removed `void X;` placeholders that Svelte 5
flagged, added svelte-ignore for intentional mount-time reads).
- AlertDialog Content wrapper forces escapeKeydownBehavior=close
(overrides Dialog's alertdialog→ignore default → matches WAI-ARIA).
- Breadcrumb Ellipsis interactive prop renders <button aria-haspopup>
so DropdownMenu composition works without manual trigger plumbing.
- VirtualList Viewport drops `contain: strict` (was breaking programmatic
scrollTo in some engines).
- Memory entries: feedback_id_effect_assign_directly,
feedback_scope_approval, feedback_svelte_reactivity_collections.
Sium library (delegated session, landed in tree):
- Core schema + primitives + combinators + pipe + refines + modifiers.
- Langs module with idlangref resolver.
- Svelte form adapter.
- Protocol docs + status ledger for Codex coordination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
## Comparison
| Feature | Soma | Ark UI | TanStack | React Aria |
| ----------------------------------------- | :--: | :----: | :------: | :--------: |
| Dedicated 2D component | ✅ | ❌ | ✅ | ⚠️ |
| Fixed row / column sizes | ✅ | — | ✅ | ✅ |
| Dynamic row heights | ❌¹ | — | ✅ | ⚠️ |
| Dynamic column widths | ❌¹ | — | ✅ | ⚠️ |
| Row + column overscan | ✅ | — | ✅ | ✅ |
| `scrollToCell(row, col, align)` | ✅ | — | ✅ | ✅ |
| Independent getRowKey / getColumnKey | ✅ | — | ✅ | ✅ |
| ARIA-agnostic | ✅ | — | ✅ | ❌ |
| Zero external dependencies | ✅ | — | ✅ | ❌ |
¹ Fixed-size only. See notes at the top — dynamic 2D is ambiguous enough that most virtualisers don't offer it. TanStack does, at the cost of complex axis-delta attribution. Not planned.
## Usage
### 100 × 100 spreadsheet
```svelte
< script lang = "ts" >
import { VirtualGrid } from '$soma/components';
const rows = 100;
const cols = 100;
const cell = (r: number, c: number) => `R${r}·C${c}` ;
< / script >
< VirtualGrid.Provider
rowCount={rows}
columnCount={cols}
rowSize={32}
columnSize={100}
>
{#snippet children({ virtualCells, totalHeight, totalWidth })}
< VirtualGrid.Viewport style:height = "400px" style:width = "600px" >
< div style:height = "{totalHeight}px" style:width = "{totalWidth}px" style:position = "relative" >
{#each virtualCells as c (c.key)}
< VirtualGrid.Cell { . . . c } >
{cell(c.rowIndex, c.columnIndex)}
< / VirtualGrid.Cell >
{/each}
< / div >
< / VirtualGrid.Viewport >
{/snippet}
< / VirtualGrid.Provider >
```
### Thumbnail grid with `scrollToCell`
```svelte
< button onclick = {() = > api?.(500, 3, { rowAlign: 'center' })}>
Jump to image 500-3
< / button >
< VirtualGrid.Provider rowCount = {1000} columnCount = {5} rowSize = {180} columnSize = {200} >
{#snippet children({ virtualCells, totalHeight, totalWidth, scrollToCell })}
{(() => { api = scrollToCell; return ''; })()}
< VirtualGrid.Viewport style:height = "600px" >
< div style:height = "{totalHeight}px" style:width = "{totalWidth}px" style:position = "relative" >
{#each virtualCells as c (c.key)}
< VirtualGrid.Cell { . . . c } >
< img src = {images[c.rowIndex * 5 + c . columnIndex ] } alt = "" / >
< / VirtualGrid.Cell >
{/each}
< / div >
< / VirtualGrid.Viewport >
{/snippet}
< / VirtualGrid.Provider >
```
## Implementation notes
- **Total size** is `rowCount × rowSize` × `columnCount × columnSize` — the inner canvas sets its height/width to these values so the `Viewport` 's native scrollbar reflects the real scrollable area.
- **Visible range** is computed per axis independently (two 1D problems). The cartesian product of visible rows × visible columns gives the cells to render.
- **No ResizeObserver on Cell** — fixed sizes mean we never need to measure. That's the main reason VirtualGrid skips dynamic — measuring would change the axes non-orthogonally.
- **One `Viewport` only** — unlike VirtualList, there's no window-scroll variant for VirtualGrid. 2D window-scroll is niche and the scroll semantics are confusing.