sium: color demo widgets inline + queue CDX-212 (services registry)

- color demo now registers a ColorPicker-based widget inline so
  kind:color fields render properly instead of showing [object Object]
  in the fallback text input. Temporary — CDX-212 moves defaults to
  Soma.services.sium.widgets where they belong.

- CDX-212 shard drafted with full v2.3 design (Soma services registry,
  SiumService.widgets, 3-layer override: instance > subtree > global).
  Includes all conversation context up to 2026-04-21 so tomorrow's
  session resumes without paginating chat history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
dev 6 months ago
parent a722d443b2
commit 6efbfeb795

@ -0,0 +1,320 @@
# CDX-212 — `Soma.services` registry + `Sium.widgets` registry con 3-layer override
> v2.3 refactor estructural de Soma hacia un **service registry tipado** (preparatorio para futuras extensiones) + introducción de **`SiumService.widgets`** como home de los widget defaults que hoy cada demo reinventa. Cierra el bug `[object Object]` en color/booking/kitchen-sink y hace que cualquier consumer futuro de `colorValue()`/`dateValue()`/`timeValue()` obtenga el UI correcto sin declarar widgets.
- **Owner**: Codex | **Phase**: fase-14 (primera de v2.3)
- **Allowed files** (scope grande, unifica dos ideas en una entrega):
- **Soma services refactor**:
- `src/uix/soma/core/services.ts` (NUEVO — interfaces `SomaServices`, `SomaService`, `SiumService`)
- `src/uix/soma/core/soma.svelte.ts` (refactor — `readonly services: SomaServices` + getters retrocompat)
- `src/uix/soma/components/internal/soma.svelte` (props pasan por a través de `services`)
- `src/uix/soma/test/soma.test.ts` (si existe, actualizar; si no, crear mínimos)
- **Sium widgets**:
- `src/lib/sium/svelte/widgets.ts` (NUEVO — `WidgetRegistry`, `defaultSiumWidgets`, `createDefaultSiumService`)
- `src/lib/sium/svelte/sium.svelte.ts` (extender `SiumOpts` con `widgets?: WidgetRegistry`)
- `src/lib/sium/svelte/sium-provider.svelte` (prop `widgets` opcional)
- `src/lib/sium/svelte/index.ts` (re-export `defaultSiumWidgets`, `WidgetRegistry`, `SiumService` si procede)
- **Form.AutoFields resolution**:
- `src/uix/soma/components/form/components/form-auto-fields.svelte` (3-layer `getWidget()`: instance → subtree → global)
- `src/uix/soma/components/form/components/form-auto-fields.svelte.test.ts` (tests por capa)
- **Demo sweep**:
- `src/routes/test/sium/+layout.svelte` (si hace falta configurar Soma con services custom — probablemente no)
- `src/routes/test/sium/color/+page.svelte` (quitar widget snippets inline; los defaults de Soma.services.sium los cubren)
- `src/routes/test/sium/booking/+page.svelte` (igual — date/time se renderizan desde default)
- `src/routes/test/sium/kitchen-sink/+page.svelte` (quitar widget snippets salvo los `-preview` que son demo-específicos; usar los canónicos)
- **Docs**:
- `src/lib/sium/IMPLEMENTATION_STATUS.md` (añadir fase-14 + decisiones nuevas 11-13)
- `src/lib/sium/GUIDE.md` (sección nueva: "Widget registry + override chain")
- `src/lib/sium/COOKBOOK.md` (receta: "Override a default widget for one kind")
- `src/uix/soma/SOMA_ARCHITECTURE.md` (añadir sección "Services registry")
- **Forbidden**:
- `src/lib/ext/app/**` — App se queda flat (primitivas universales). El registry vive en Soma, no en App.
- `src/lib/sium/core/**` — sium core no conoce widgets (son presentation concern).
## Contexto: qué está mal hoy
### Gap 1 — Widget registration es per-call, no per-framework
Cada `<Form.AutoFields>` recibe `widgets={...}` como prop. No hay registry global. Resultado:
- `kitchen-sink` y `color` declaran `colorWidgetSnippet` inline.
- `booking` (con `CalendarDate` + `Time`) debería declarar date/time snippets — no lo hace, muestra `[object Object]`.
- Cualquier consumer del framework que use `colorValue()` fuera de los demos sufre lo mismo.
Es un anti-patrón idéntico al que CDX-210 cerró para `isColorValue` / `isCalendarDate`: laguna de framework que los consumers rellenan con copia-pega.
### Gap 2 — Soma's service bag es flat y hardcoded
```ts
// uix/soma/core/soma.svelte.ts — estado actual
export class Soma {
readonly app: App;
get langs() { return this.app.langs; }
get presentation() { return this.app.presentation; }
get logger() { return this.app.logger; }
// ... 7 getters más
}
```
Cada service añadido requiere:
1. Prop getter nuevo.
2. Probablemente también en `App` (si delega).
3. Callsites que los consumen tienen que conocer el getter exacto.
No es extensible: un plugin de terceros no puede añadir `Soma.analytics` sin patchear `Soma` directamente.
## Goal
### 1. `SomaServices` como interface ABIERTA (typed registry, pero extensible via module augmentation)
```ts
// uix/soma/core/services.ts — NUEVO
export interface SomaService {
// marker base — cada service la extiende con sus métodos
readonly __type?: string; // opcional, útil para debug
}
export interface SomaServices {
langs: AppLangs;
presentation: AppPresentation;
logger: AppLogger;
nums?: AppNums;
money?: AppMoney;
dates?: AppDates;
units?: AppUnits;
sium: SiumService;
// ↑ OPEN INTERFACE. Librerías extienden vía:
// declare module '$soma/core/services' {
// interface SomaServices { analytics: AnalyticsService }
// }
}
export interface SiumService extends SomaService {
readonly widgets: WidgetRegistry;
// Futuro: defaultValidationBehaviour?, errorDisplayPrefs?, etc.
}
```
### 2. Soma refactor: `services` interno + getters retrocompat
```ts
// uix/soma/core/soma.svelte.ts
import type { SomaServices, SomaOptions } from './services';
import { createDefaultSiumService } from '$lib/sium/svelte/widgets';
export class Soma {
readonly services: SomaServices;
readonly portalTo: string | HTMLElement | undefined;
readonly app: App;
private constructor(app: App, opts: SomaOptions) {
this.app = app;
this.portalTo = opts.portalTo;
this.services = {
langs: app.langs,
presentation: app.presentation,
logger: app.logger,
nums: app.nums,
money: app.money,
dates: app.dates,
units: app.units,
sium: opts.services?.sium ?? createDefaultSiumService(),
...opts.services // consumer override + additions
};
}
// ── Backward-compat shortcuts — no breaking change ──
get langs() { return this.services.langs; }
get presentation() { return this.services.presentation; }
get logger() { return this.services.logger; }
get nums() { return this.services.nums; }
get money() { return this.services.money; }
get dates() { return this.services.dates; }
get units() { return this.services.units; }
get sium() { return this.services.sium; }
static create(opts: SomaOptions = {}): Soma {
const app = App.get();
const instance = new Soma(app, opts);
return _ctx.set(instance);
}
// ... get() / require() sin cambios
}
```
### 3. `WidgetRegistry` + `defaultSiumWidgets`
```ts
// lib/sium/svelte/widgets.ts — NUEVO
import type { Snippet, Component } from 'svelte';
import type { Schema } from '../core';
export type WidgetProps = {
value: unknown;
errors: string[];
required: boolean;
path: Array<string | number>;
schema: Schema<unknown, unknown>;
onValueChange: (next: unknown) => void;
};
export type WidgetEntry =
| string
| Component<Record<string, unknown>>
| Snippet<[WidgetProps]>
| { snippet: Snippet<[WidgetProps]> };
export type WidgetRegistry = Record<string, WidgetEntry>;
/**
* Canonical widget mappings from sium composite kinds to their Soma components.
* Soma.services.sium.widgets defaults to these; consumers can override via
* `<Sium.Provider widgets={...}>` (subtree) or `<Form.AutoFields widgets={...}>`
* (per-instance).
*/
export const defaultSiumWidgets: WidgetRegistry = {
'kind:color': colorWidget, // wraps <ColorPicker.Provider>
'kind:date': dateWidget, // wraps <DateField.Provider>
'kind:time': timeWidget, // wraps <TimeField.Provider>
// TODO post-v2.3: 'kind:date-range', 'kind:time-range' cuando los composites estén
};
export interface SiumService {
readonly widgets: WidgetRegistry;
}
export function createDefaultSiumService(
overrides?: { widgets?: WidgetRegistry }
): SiumService {
return {
widgets: { ...defaultSiumWidgets, ...overrides?.widgets }
};
}
```
**Dónde viven los widget snippets concretos** (los `colorWidget`, `dateWidget`, `timeWidget`): en `lib/sium/svelte/widgets.ts` al principio. Si crecen, extraer a `lib/sium/svelte/widgets/` dir con un file por widget.
### 4. `<Sium.Provider>` acepta override de widgets
```ts
// lib/sium/svelte/sium.svelte.ts
interface SiumOpts {
schema: StandardSchemaV1;
widgets?: WidgetRegistry; // subtree-level override
}
class SiumProvider {
readonly schema: StandardSchemaV1;
readonly widgets: WidgetRegistry; // merged: global defaults ∪ subtree
private constructor(opts: SiumOpts) {
this.schema = opts.schema;
// Merge subtree widgets onto global Soma.services.sium.widgets
const globalWidgets = Soma.get()?.services.sium.widgets ?? {};
this.widgets = { ...globalWidgets, ...(opts.widgets ?? {}) };
}
}
```
### 5. `Form.AutoFields` resuelve widgets en 3 capas
```ts
// form-auto-fields.svelte
function getWidget(schemaInfo) {
// 1. Per-instance: <Form.AutoFields widgets={...}>
const instanceWidgets = propsWidgets ?? {};
// 2. Subtree: <Sium.Provider widgets={...}>
const siumProvider = SiumProvider.get();
const subtreeWidgets = siumProvider?.widgets ?? {};
// 3. Global: Soma.services.sium.widgets
const globalWidgets = Soma.get()?.services.sium.widgets ?? {};
const merged = { ...globalWidgets, ...subtreeWidgets, ...instanceWidgets };
// Existing 3-key lookup: meta.widget → channel:X → kind:X
if (resolved.widgetKey && merged[resolved.widgetKey]) return merged[resolved.widgetKey];
if (resolved.channelKey && merged[resolved.channelKey]) return merged[resolved.channelKey];
return merged[resolved.kindKey];
}
```
## Tests
### Services registry (soma.test.ts)
1. `Soma.create()` sin opts → `services.sium` existe con `defaultSiumWidgets`.
2. `Soma.create({ services: { sium: customService } })` → `services.sium` es el custom.
3. `Soma.create({ services: { sium: {...}, analytics: mockAnalytics } })` — con module augmentation en test, `services.analytics` se accede tipado.
4. Backward-compat: `soma.langs.t(...)` sigue funcionando = `soma.services.langs.t(...)`.
### Widget registry (form-auto-fields.svelte.test.ts nuevos)
1. Schema con `colorValue()` sin widgets prop ni Sium.Provider widgets → renderiza ColorPicker (default global aplica).
2. `<Form.AutoFields widgets={{ 'kind:color': customColorWidget }}>` → override per-instance aplica sobre default.
3. `<Sium.Provider widgets={{ 'kind:color': subtreeWidget }}>` + `<Form.AutoFields>` sin widgets → subtree gana sobre default.
4. Los tres niveles coexisten con orden correcto: instance > subtree > global.
## Constraints
- **No breaking**. Los getters viejos `Soma.langs`, etc. siguen funcionando como shortcuts a `soma.services.langs`. Demos existentes que pasan `widgets={...}` siguen igual.
- **`App` se queda flat**. No meter services en App. App es la capa de primitivas universales (langs, presentation, logger); Soma es UI framework layer que añade abstracciones.
- **Default widgets son snippets Svelte**, no Components. Los widgets actuales de kitchen-sink/color son snippets — seguimos con ese patrón para los defaults.
- **Imports circulares**: `lib/sium/svelte/widgets.ts` importa de `$uix/air` (ColorPicker) y `$soma/components/color-field` (DateField/TimeField). Verificar que no crea ciclo con soma/core.
## Impacto en demos tras aterrizar
**Antes** (color demo):
```svelte
<Form.AutoFields
schema={colorCustomizerFormSchema}
widgets={{
'kind:color': colorWidget,
'color-picker': colorWidget
}}
/>
```
+ 25 líneas de snippet inline con ColorPicker.Provider etc.
**Después**:
```svelte
<Form.AutoFields schema={colorCustomizerFormSchema} />
```
Cero widgets locales. El default de `Soma.services.sium.widgets['kind:color']` lo cubre.
Ganancia: color demo -35 líneas. kitchen-sink similar. booking ahora funciona correctamente sin cambios por su parte.
## Run
```bash
node src/lib/sium/status/audit.mjs CDX-212
```
## Delivery
Append `[Codex <ISO>] — Entrega CDX-212` con archivos, resumen ≤ 8 bullets, output audit + tests + `npm run check`. Bump state.json + Dia.
**Nota de release**: v2.3 = "extensibility refactor". Zero breaking pero abre la puerta a que librerías terceras extiendan Soma vía declaration merging. Primer caso de uso: sium widgets. Futuros casos: theming, telemetry, state machines, etc.
---
## Contexto de conversación donde quedamos (2026-04-21, final del día)
**Decisiones tomadas** (no revisar, ir al código):
1. Services registry tipado via interface abierta (no `Record<string, unknown>`).
2. Declaration merging (`declare module '$soma/core/services' { interface SomaServices { ... } }`) como mecanismo canónico de extensión — NO `soma.registerService(key, svc)` imperativo.
3. `Soma.create({ services })` como único punto de registro. Por-instancia. Nada dinámico post-mount.
4. Backward-compat shortcuts (`Soma.langs` sigue funcionando como getter sobre `services.langs`).
5. `App` se queda flat — el registry es concepto Soma-level, no framework-level.
6. Widget registry es un service más (`SiumService.widgets`) dentro del mismo patrón.
7. 3-layer resolution para widgets: instance (`<AutoFields widgets>`) > subtree (`<Sium.Provider widgets>`) > global (`Soma.services.sium.widgets`).
8. Default widgets para `kind:color`/`kind:date`/`kind:time` en `lib/sium/svelte/widgets.ts`.
**Decisión diferida** (no en este task):
- `soma.registerService(key, impl)` imperativo — útil para plugins autoregistrados, pero añade riesgo de orden/race. No hay caso concreto hoy. Si emerge, es add-on no-breaking.
**Estado del código al cerrar**:
- Color demo tiene widget snippets inline (ver `src/routes/test/sium/color/+page.svelte`). Commiteado hoy. Cuando CDX-212 aterrice, estos desaparecen porque el default los cubre.
- Tree/settings/signup demos estaban "rotas" — fue slowness del dev server en cold start, no bug real. Todas funcionan.
- v2.2 cerrada en commit `8a4bbb4a` + `a722d443`.

@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"lastUpdated": "2026-04-21T02:40:00+02:00",
"lastUpdated": "2026-04-21T03:00:00+02:00",
"protocol": {
"version": "2.1",
"doc": "./README.md",
@ -176,6 +176,13 @@
"CDX-210",
"CDX-211"
]
},
"fase-14": {
"title": "v2.3 — Services registry + widget defaults",
"state": "pending",
"tasks": [
"CDX-212"
]
}
},
"tasks": {
@ -1914,6 +1921,38 @@
"tsc": false
},
"notes": "Reassigned from Codex to claude-1 (supervisor) — small breaking change in sium territory"
},
"CDX-212": {
"title": "Soma.services registry + SiumService.widgets + 3-layer resolution",
"owner": "Codex",
"state": "pending",
"phase": "fase-14",
"shard": "./CDX-212.md",
"deps": [
"CDX-207",
"CDX-210",
"CDX-211"
],
"updatedAt": "2026-04-21T03:00:00+02:00",
"audit": {
"filesExist": [
"src/uix/soma/core/services.ts",
"src/lib/sium/svelte/widgets.ts"
],
"grepPos": [
{
"pattern": "SomaServices|readonly services",
"in": "src/uix/soma/core/soma.svelte.ts",
"minFiles": 1
},
{
"pattern": "defaultSiumWidgets|WidgetRegistry",
"in": "src/lib/sium/svelte/widgets.ts",
"minFiles": 1
}
],
"tsc": false
}
}
},
"v2_status": "accepted",
@ -1926,5 +1965,6 @@
},
"v2_1_status": "accepted",
"v2_2_status": "accepted",
"version": "2.2"
"version": "2.2",
"next_session_entry_point": "CDX-212 — read src/lib/sium/status/CDX-212.md for full design. Color demo has inline widgets to delete after CDX-212 lands."
}

@ -1,10 +1,21 @@
<script lang="ts">
import { colorValueFromRgb } from '$lib/util/colors';
import { ColorPicker } from '$uix/air';
import type { Schema } from '$lib/sium/core';
import { colorValueFromRgb, isColorValue, type ColorValue } from '$lib/util/colors';
import { App } from '$lib/ext/app';
import type { LangNode } from '$langs';
import { colorCustomizerFormSchema } from '$lib/sium/examples';
import * as Form from '$soma/components/form';
type WidgetProps = {
value: unknown;
errors: string[];
required: boolean;
path: Array<string | number>;
schema: Schema<unknown, unknown>;
onValueChange: (next: unknown) => void;
};
const app = App.get();
const langs = app.langs;
@ -14,7 +25,10 @@
es: 'Tres colores compuestos (HSV/HSL/RGB con segmentos) más un canal numérico y opacidad. Demuestra que ColorValue se valida nativamente sin z.custom.',
en: 'Three composite colors (HSV/HSL/RGB with segments) plus a numeric channel and opacity. Shows ColorValue validated natively without z.custom.'
},
shapeHint: { es: 'object · 5 campos · ColorValue nativo', en: 'object · 5 fields · native ColorValue' },
shapeHint: {
es: 'object · 5 campos · ColorValue nativo',
en: 'object · 5 fields · native ColorValue'
},
plates: {
values: { es: 'form.values', en: 'form.values' },
submit: { es: 'Último submit', en: 'Last submit' }
@ -31,6 +45,8 @@
customHue: 210
};
const fallbackColor: ColorValue = colorValueFromRgb({ r: 0, g: 0, b: 0, a: 1 });
let submitted = $state('');
const form = Form.createForm({
@ -48,6 +64,31 @@
<title>colorCustomizerFormSchema · sium</title>
</svelte:head>
{#snippet colorWidget(props: WidgetProps)}
{@const current = isColorValue(props.value) ? props.value : fallbackColor}
<div class="widget widget--color">
<ColorPicker.Provider
value={current}
onValueChange={(next) => props.onValueChange(next)}
>
<ColorPicker.Content size="sm">
<ColorPicker.Area>
<ColorPicker.AreaThumb />
</ColorPicker.Area>
<div class="widget-slider-stack">
<ColorPicker.HueSlider />
<ColorPicker.AlphaSlider />
</div>
<ColorPicker.Input format="hex" size="sm" variant="outline" />
</ColorPicker.Content>
</ColorPicker.Provider>
{#if props.errors.length > 0}
<p class="widget-error" role="alert">{props.errors[0]}</p>
{/if}
</div>
{/snippet}
<div class="sium-page">
<header class="masthead">
<p class="eyebrow">{langs.t('demo.eyebrow')}</p>
@ -79,7 +120,13 @@
<main class="form-column">
<Form.Provider {form}>
<div class="form-stack">
<Form.AutoFields schema={colorCustomizerFormSchema} />
<Form.AutoFields
schema={colorCustomizerFormSchema}
widgets={{
'kind:color': colorWidget,
'color-picker': colorWidget
}}
/>
<div class="actions">
<Form.Submit>{langs.t('demo.actions.submit')}</Form.Submit>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.