From e7e32a83555b19c88e14d28b4113122a5f3c8e24 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 26 Apr 2026 14:48:52 +0200 Subject: [PATCH] soma: Avatar migrated to runtime + V2 props bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First component of the bulk migration phase (step 2 of the 1-3-2 plan). Pattern is now well-rehearsed across Toggle / Collapsible / Toast / Switch: - morfo: add `value` sources to data entries that were enum-only - provider: replace manual Opts interface with `OptsFromProps<...>`, create runtime in constructor, register parts, drop manual data-* / aria attrs from props (runtime emits via dom.apply) - wrapper: replace per-prop `readableActive(() => x)` list with one `bindProps({ ... })` call - keep manual: HTML primitives (style, src, etc.) and event handlers Avatar specifics - 3 parts (provider/image/fallback) all share `data-status` from the root's internal `status` State (idle/loading/loaded/error). morfo's `propRef('status')` reads from runtime sources where the root provider exposes it. - `status` is not a public Props field — it's an internal $state in the wrapper. Manually intersected into `AvatarOpts` rather than derived from AvatarProps. - Image's preload logic, src/crossorigin/referrerpolicy passthrough, and the style-based show/hide on each part stay in the provider's render — none of these are morfo-managed. - Wrapper cast uses `as unknown as Parameters[0]` for parts whose Opts have only `id`+`ref` (TS can't extract the specific fields from the generic `Record>` return of `bindProps`). Toggle/Switch don't need this because their richer Opts shape provides enough overlap. Bulk migration paused at this point — Progress / Meter need a design decision around `aria-label` priority logic (the legacy provider has "consumer-passed wins over translated default", which conflicts with the "dom.apply is the only writer of mutable attrs" rule). Either: (a) Drop aria-label from those morfos and keep provider-managed (legacy pattern preserved, morfo loses one declaration). (b) Extend the resolver with a priority chain (propRef → translationRef fallback) so the morfo can express "consumer wins, translation otherwise". Verification - 66/66 morfo-check (Avatar passes; all data-status emitted by runtime). - 68/68 smoke routes. - svelte-check unchanged at 155. --- src/uix/morfo/components/avatar.ts | 25 ++++++- .../avatar/avatar-provider.svelte.ts | 74 +++++++++++-------- .../avatar/components/avatar-fallback.svelte | 15 ++-- .../avatar/components/avatar-image.svelte | 21 +++--- .../avatar/components/avatar.svelte | 26 +++---- 5 files changed, 94 insertions(+), 67 deletions(-) diff --git a/src/uix/morfo/components/avatar.ts b/src/uix/morfo/components/avatar.ts index 65ba05c80..e596e3d51 100644 --- a/src/uix/morfo/components/avatar.ts +++ b/src/uix/morfo/components/avatar.ts @@ -1,4 +1,5 @@ import type { Morfo } from '../types'; +import { v } from '../types'; export const avatarMorfo = { name: 'Avatar', @@ -11,7 +12,13 @@ export const avatarMorfo = { kind: 'public', defaultElement: 'span', optional: false, - data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }], + data: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + value: v.propRef('status') + } + ], aria: [] }, { @@ -20,7 +27,13 @@ export const avatarMorfo = { kind: 'public', defaultElement: 'img', optional: false, - data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }], + data: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + value: v.propRef('status') + } + ], aria: [] }, { @@ -29,7 +42,13 @@ export const avatarMorfo = { kind: 'public', defaultElement: 'span', optional: false, - data: [{ attr: 'data-status', values: ['idle', 'loading', 'loaded', 'error'] }], + data: [ + { + attr: 'data-status', + values: ['idle', 'loading', 'loaded', 'error'], + value: v.propRef('status') + } + ], aria: [] } ] diff --git a/src/uix/soma/components/avatar/avatar-provider.svelte.ts b/src/uix/soma/components/avatar/avatar-provider.svelte.ts index e2b705b96..81f4d6d46 100644 --- a/src/uix/soma/components/avatar/avatar-provider.svelte.ts +++ b/src/uix/soma/components/avatar/avatar-provider.svelte.ts @@ -1,14 +1,16 @@ import { watch } from 'runed'; import { Provider, context, type WithRefOpts } from '../../provider'; import { createAttrs, registerContract } from '../../attrs'; -import { state, type ActiveProps, type StateProps } from '../../reactive'; +import type { OptsFromProps, State } from '../../reactive'; import type { OnChangeFn } from '../../types'; +import { Soma } from '../../core/soma.svelte'; +import { createMorfoRuntime, type MorfoRuntime } from '../../morfo'; import type { HTMLImgAttributes } from 'svelte/elements'; -import type { AvatarLoadingStatus } from './types'; +import type { AvatarLoadingStatus, AvatarProps, AvatarImageProps } from './types'; import { avatarMorfo } from '../../../morfo/components/avatar'; -const attrs = createAttrs(avatarMorfo); +createAttrs(avatarMorfo); registerContract(avatarMorfo); type CrossOrigin = HTMLImgAttributes['crossorigin']; @@ -16,13 +18,13 @@ type ReferrerPolicy = HTMLImgAttributes['referrerpolicy']; // ── Root provider ────────────────────────────────────────────────────────── -interface AvatarOpts - extends WithRefOpts, - StateProps<{ status: AvatarLoadingStatus }>, - ActiveProps<{ - delayMs: number; - onStatusChange: OnChangeFn | undefined; - }> {} +/** + * `status` is internal state owned by the wrapper (not part of AvatarProps), + * so it's intersected manually rather than derived from Props. + */ +type AvatarOpts = OptsFromProps & { + status: State; +}; export class AvatarProvider extends Provider { static readonly ctx = context('Avatar'); @@ -37,8 +39,19 @@ export class AvatarProvider extends Provider { return new AvatarProvider(opts); } + readonly runtime: MorfoRuntime; + private constructor(opts: AvatarOpts) { - super(opts, 'Avatar', 'provider', attrs.provider, AvatarProvider.ctx); + super(opts, { morfo: avatarMorfo, part: 'provider' }, AvatarProvider.ctx); + + const soma = Soma.require(); + this.runtime = createMorfoRuntime(avatarMorfo, { + dom: soma.dom, + props: { + status: () => opts.status.current + } + }); + this.runtime.registerPart('provider', { id: opts.id, ref: opts.ref }); // Fire onStatusChange whenever status transitions. let previous = opts.status.current; @@ -103,21 +116,18 @@ export class AvatarProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - 'data-status': this.opts.status.current + ...this.runtime.partProps('provider') } as const) ); } // ── Image ────────────────────────────────────────────────────────────────── -interface AvatarImageOpts - extends WithRefOpts, - ActiveProps<{ - src: string | null | undefined; - crossOrigin: CrossOrigin; - referrerPolicy: ReferrerPolicy; - }> {} +type AvatarImageOpts = OptsFromProps< + AvatarImageProps, + 'src' | 'crossorigin' | 'referrerpolicy', + never +>; export class AvatarImageProvider extends Provider { static create(opts: AvatarImageOpts) { @@ -127,16 +137,17 @@ export class AvatarImageProvider extends Provider { readonly provider: AvatarProvider; private constructor(opts: AvatarImageOpts) { - super(opts, 'Avatar', 'image', attrs.image); + super(opts, { morfo: avatarMorfo, part: 'image' }); this.provider = AvatarProvider.require(); + this.provider.runtime.registerPart('image', { id: opts.id, ref: opts.ref }); // Preload on src change. `watch.pre` runs synchronously so the // status flips to 'loading' before the first paint. watch.pre( [ () => this.opts.src.current, - () => this.opts.crossOrigin.current, - () => this.opts.referrerPolicy.current + () => this.opts.crossorigin.current, + () => this.opts.referrerpolicy.current ], ([src, crossOrigin, referrerPolicy]) => { if (!src) { @@ -151,16 +162,15 @@ export class AvatarImageProvider extends Provider { readonly props = $derived.by(() => { const status = this.provider.opts.status.current; return this.assertProps({ - ...this.baseProps, + ...this.provider.runtime.partProps('image'), // The img element exists in DOM for all states, but is hidden // until `loaded` so preloading doesn't flicker. style: { display: status === 'loaded' ? 'block' : 'none' }, src: this.opts.src.current || undefined, - crossorigin: this.opts.crossOrigin.current, - referrerpolicy: this.opts.referrerPolicy.current, - 'data-status': status + crossorigin: this.opts.crossorigin.current, + referrerpolicy: this.opts.referrerpolicy.current } as const); }); } @@ -177,17 +187,19 @@ export class AvatarFallbackProvider extends Provider { readonly provider: AvatarProvider; private constructor(opts: AvatarFallbackOpts) { - super(opts, 'Avatar', 'fallback', attrs.fallback); + super(opts, { morfo: avatarMorfo, part: 'fallback' }); this.provider = AvatarProvider.require(); + this.provider.runtime.registerPart('fallback', { id: opts.id, ref: opts.ref }); } readonly props = $derived.by(() => { const status = this.provider.opts.status.current; // Visible while idle / loading / error. Hidden once image loaded. return this.assertProps({ - ...this.baseProps, - style: status === 'loaded' ? { display: 'none' } : undefined, - 'data-status': status + ...this.provider.runtime.partProps('fallback'), + style: status === 'loaded' ? { display: 'none' } : undefined } as const); }); } + +export type { OnChangeFn }; diff --git a/src/uix/soma/components/avatar/components/avatar-fallback.svelte b/src/uix/soma/components/avatar/components/avatar-fallback.svelte index 5bf9abb3e..6a874e6e2 100644 --- a/src/uix/soma/components/avatar/components/avatar-fallback.svelte +++ b/src/uix/soma/components/avatar/components/avatar-fallback.svelte @@ -1,5 +1,5 @@ diff --git a/src/uix/soma/components/avatar/components/avatar-image.svelte b/src/uix/soma/components/avatar/components/avatar-image.svelte index 8c4550cac..982f40361 100644 --- a/src/uix/soma/components/avatar/components/avatar-image.svelte +++ b/src/uix/soma/components/avatar/components/avatar-image.svelte @@ -1,5 +1,5 @@ diff --git a/src/uix/soma/components/avatar/components/avatar.svelte b/src/uix/soma/components/avatar/components/avatar.svelte index ebc0bd29a..df15f0a13 100644 --- a/src/uix/soma/components/avatar/components/avatar.svelte +++ b/src/uix/soma/components/avatar/components/avatar.svelte @@ -1,5 +1,5 @@