From bf394873e045e781d41866effd50bec9dc2b83ac Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 26 Apr 2026 15:29:04 +0200 Subject: [PATCH] =?UTF-8?q?morfo:=20add=20`archetype`=20field=20=E2=80=94?= =?UTF-8?q?=20cross-layer=20part=20categorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step toward making morfo articulate the three layers (soma, sema, eidos) instead of just serving soma. Archetype is a small canonical vocabulary that lets a part declare its conceptual role beyond its component-specific kebab name. Articulation purpose: - Eidos can style transversally — `[data-archetype=trigger] { ... }` applies to every Trigger across all 60+ components without enumeration. - Sema can map archetypes to canonical action verbs (`trigger` may fire `activate`, `item` may fire `select`). - Docs can categorize parts cross-component for discovery. The "2-of-3 rule" justifies the extension: at least two layers consume it. Vocabulary 24 archetypes covering the common cross-component roles: provider, trigger, content, overlay, viewport, item, option, indicator, thumb, track, label, title, description, close, action, header, image, fallback, arrow, separator, group, input, segment, preview. Designed to stay small. New archetype only added when at least two existing components share the role. Genuinely-unique parts (Slider.Range, PinInput.Segment) omit the field. Implementation - `MorfoPart.archetype?: MorfoArchetype` — optional union type. - `ARCHETYPE_VOCABULARY` exported as `as const satisfies` array for enumeration tooling. - `partShallowSchema` (sium validator) gets `archetype: optional(union(...))`. - MorfoRuntime caches `partMeta` in PartRegistration so `partProps` can read `archetype` without re-walking the morfo tree per render. - `runtime.partProps(part)` emits `data-archetype="..."` when declared. Static identity (never mutates), so it ships through partProps not through `dom.apply` — Svelte renders it once on first paint. Tests 2 new tests: - partProps emits data-archetype when morfo declares one (inline test morfo to avoid coupling to catalog state). - partProps omits data-archetype when not declared (Toggle, pre-catalog). Verification - 22/22 runtime tests green. - svelte-check unchanged at 155. - No morfo currently declares archetype yet — catalog pass follows in the next commit. All existing morfos remain valid (field is optional). --- src/uix/morfo/schema.ts | 13 +++- src/uix/morfo/types.ts | 93 +++++++++++++++++++++++ src/uix/soma/morfo/runtime.svelte.test.ts | 46 +++++++++++ src/uix/soma/morfo/runtime.svelte.ts | 9 ++- 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 3b420c339..c82fb1218 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -44,8 +44,10 @@ import type { MorfoPrimitiveValueSource, MorfoValueSource, MorfoCondition, - MorfoElement + MorfoElement, + MorfoArchetype } from './types'; +import { ARCHETYPE_VOCABULARY } from './types'; // ── Leaf schemas ────────────────────────────────────────────────────────── @@ -101,6 +103,14 @@ const severitySchema = union( const partKindSchema = union(literal('public'), literal('virtual')); +const archetypeSchema = union( + ...(ARCHETYPE_VOCABULARY.map((archetype) => literal(archetype)) as [ + ReturnType>, + ReturnType>, + ...ReturnType>[] + ]) +) as Schema; + // ── MorfoValueSource (tagged union) ─────────────────────────────────────── const primitiveValueSourceSchema = discriminated('kind', [ @@ -261,6 +271,7 @@ const focusSchema = object({ const partShallowSchema = object({ name: string(), kebab: string(), + archetype: optional(archetypeSchema), kind: partKindSchema, defaultElement: elementSchema, role: optional(string()), diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 4a97cc81f..03da26e3d 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -334,6 +334,86 @@ export interface MorfoFocus { restore?: boolean; } +// ── Archetypes ──────────────────────────────────────────────────────────── + +/** + * Cross-component part archetypes — a small canonical vocabulary that lets + * a part declare its conceptual role beyond its component-specific kebab. + * + * Articulation purpose: + * - **Eidos** can style transversally: `[data-archetype=trigger] { ... }` + * applies to every Trigger across all components without enumeration. + * - **Sema** can map archetypes to canonical action verbs (a `trigger` may + * fire `activate`, an `item` may fire `select`). + * - **Docs** can categorize parts cross-component for discovery. + * + * Not all parts fit an archetype — when a part is genuinely unique to its + * component (e.g. `Slider.Range`, `PinInput.Segment`), omit the field. The + * runtime emits `data-archetype="..."` on the part's element only when set. + * + * The vocabulary is intentionally small and stable. Add a new archetype + * only when at least two existing components share the role. + */ +export type MorfoArchetype = + | 'provider' // root context provider + | 'trigger' // activates an action or opens an overlay + | 'content' // primary content panel of an overlay or section + | 'overlay' // modal/dim backdrop behind content + | 'viewport' // scrollable / focusable container + | 'item' // list / tree / menu item + | 'option' // selectable option in a select-like list + | 'indicator' // visual progress / decorative state + | 'thumb' // draggable handle (slider, scroll, switch) + | 'track' // background of slider / scroll / progress + | 'label' // text label associated with a control + | 'title' // overlay or section title + | 'description' // secondary descriptive text + | 'close' // dismiss / close button + | 'action' // call-to-action button + | 'header' // section header (often above content) + | 'image' // -based content + | 'fallback' // shown when primary content unavailable + | 'arrow' // pointer arrow (tooltip / popover) + | 'separator' // visual divider + | 'group' // grouping container for related items + | 'input' // form input element + | 'segment' // discrete sub-input (pin-input segment, OTP digit) + | 'preview'; // file / link / data preview + +/** + * Canonical archetype vocabulary as a runtime array. Used by the schema + * validator and by tooling that needs to enumerate archetypes. + * + * Keep in sync with the `MorfoArchetype` type above — order doesn't matter + * but every union member must appear. + */ +export const ARCHETYPE_VOCABULARY = [ + 'provider', + 'trigger', + 'content', + 'overlay', + 'viewport', + 'item', + 'option', + 'indicator', + 'thumb', + 'track', + 'label', + 'title', + 'description', + 'close', + 'action', + 'header', + 'image', + 'fallback', + 'arrow', + 'separator', + 'group', + 'input', + 'segment', + 'preview' +] as const satisfies readonly MorfoArchetype[]; + // ── Part ────────────────────────────────────────────────────────────────── /** @@ -351,6 +431,19 @@ export interface MorfoPart { * Unique across the whole morfo (no nested path namespacing). */ kebab: string; + /** + * Cross-component part archetype. Lets eidos style "all triggers" or + * "all overlays" with a transversal selector (`[data-archetype=trigger]`), + * lets sema associate canonical action verbs with a part role, and lets + * docs categorize parts across the catalog. + * + * Optional — omit when the part has no clean cross-component analogue + * (e.g. `Slider.Range` is unique to Slider). When present, the runtime + * emits `data-archetype="..."` on the part's DOM element via `partProps`. + * + * See `ARCHETYPE_VOCABULARY` for the canonical set. + */ + archetype?: MorfoArchetype; /** Public API part vs internal coordinator (see `MorfoPartKind`). */ kind: MorfoPartKind; /** diff --git a/src/uix/soma/morfo/runtime.svelte.test.ts b/src/uix/soma/morfo/runtime.svelte.test.ts index b0b598265..b22f67389 100644 --- a/src/uix/soma/morfo/runtime.svelte.test.ts +++ b/src/uix/soma/morfo/runtime.svelte.test.ts @@ -173,6 +173,52 @@ describe('createMorfoRuntime', () => { }) cleanup() }) + + it('emits data-archetype in partProps when the morfo part declares one', () => { + // Inline a tiny morfo with an archetype so the test doesn't depend on + // the catalog state of any real component. + const archetypedMorfo = { + name: 'Probe', + kebab: 'probe', + scope: ['soma'], + parts: [ + { + name: 'Provider', + kebab: 'provider', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + optional: false, + data: [], + aria: [] + } + ] + } as const + const { result, cleanup } = withEffectRoot(() => { + const r = createMorfoRuntime(archetypedMorfo, { dom }) + r.registerPart('provider', { id: state('p-1') }) + return r + }) + + const props = result.partProps('provider') + expect(props['data-archetype']).toBe('trigger') + expect(props.id).toBe('p-1') + expect(props['data-probe']).toBe('') + cleanup() + }) + + it('omits data-archetype when the part does not declare one (default)', () => { + // Toggle's morfo has no archetype declared yet (until the catalog pass). + const { result, cleanup } = withEffectRoot(() => { + const r = createMorfoRuntime(toggleMorfo, { dom }) + r.registerPart('provider', { id: state('t-1') }) + return r + }) + + const props = result.partProps('provider') + expect(props['data-archetype']).toBeUndefined() + cleanup() + }) }) // ── trigger(eventName) ────────────────────────────────────────────────────── diff --git a/src/uix/soma/morfo/runtime.svelte.ts b/src/uix/soma/morfo/runtime.svelte.ts index dcb96ce45..c45591f70 100644 --- a/src/uix/soma/morfo/runtime.svelte.ts +++ b/src/uix/soma/morfo/runtime.svelte.ts @@ -146,6 +146,8 @@ interface PartRegistration { attachment: RefAttachment | undefined; states: SourceMap | undefined; props: SourceMap | undefined; + /** Cached part metadata so `partProps` doesn't re-walk the morfo tree. */ + meta: MorfoPart; } function readBindings( @@ -196,7 +198,8 @@ export function createMorfoRuntime( ref: opts.ref, attachment: undefined, states: opts.states, - props: opts.props + props: opts.props, + meta: partMeta }; if (opts.ref) { @@ -229,6 +232,10 @@ export function createMorfoRuntime( id: reg.id.current }; if (marker) props[marker] = ''; + // `data-archetype` is part of static identity (cross-component + // classification, never mutates), so it ships through partProps — + // not through dom.apply. Eidos reads it via `[data-archetype=trigger]`. + if (reg.meta.archetype) props['data-archetype'] = reg.meta.archetype; if (reg.attachment) Object.assign(props, reg.attachment); return props;