morfo: add `archetype` field — cross-layer part categorization

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).
morfo-runtime
dev 6 months ago
parent e7e32a8355
commit bf394873e0

@ -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<typeof literal<string>>,
ReturnType<typeof literal<string>>,
...ReturnType<typeof literal<string>>[]
])
) as Schema<MorfoArchetype, MorfoArchetype>;
// ── 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()),

@ -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' // <img>-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;
/**

@ -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) ──────────────────────────────────────────────────────

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

Loading…
Cancel
Save

Powered by TurnKey Linux.