You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/morfo/study.md

25 KiB

Morfo — study: iterative design with AI peer review

Record of the design process for morfo, iterated across three independent AI reviews (Gemini, Grok, ChatGPT). Each round shifted the shape in concrete ways; this document captures what each reviewer contributed, where they converged, where they disagreed, and the consolidated v5 shape.

Sibling documents: DESIGN.md for the standalone design doc shared with reviewers; this file is the meta-record of the review process and its outcomes.


0. Motivation for running peer review

The initial morfo design (shape v1) was written self-contained as DESIGN.md. Before committing to any implementation touching 66 components and three framework layers, the doc was shared with three independent AI reviewers chosen for methodological diversity: Google's Gemini, xAI's Grok, OpenAI's ChatGPT. Same prompt to each: standalone review of the doc, critical feedback, point out blind spots, suggest concrete alternatives.

The goal was not validation (seeking agreement) but adversarial review — intentionally using different models to surface distinct angles.

1. Baseline: Morfo v1 (pre-review shape)

Starting shape proposed to the reviewers:

interface Morfo {
  name: string;
  kebab: string;
  scope: Layer[];
  apg?: string;
  parts: MorfoPart[];
}

interface MorfoPart {
  name: string;
  kebab: string;
  element: string;               // "<button>" literal string
  optional: boolean;
  data: MorfoData[];
  aria: MorfoAria[];
  keyboard?: MorfoKeyboard[];
  parts?: MorfoPart[];
}

interface MorfoData { attr: string; values?: string[]; }
interface MorfoAria { attr: string; valueRef: string; condition?: string; }
interface MorfoKeyboard { key: string; action: string; }

Six open questions included (1) props in morfo, (2) composition constraints, (3) valueRef type shape, (4) missed precedents, (5) strict mode appropriateness, (6) unseen patologías.

2. Round 1 — Gemini's review

Agreements

  • Props out of morfo (match with inclination).
  • Composition constraints in provider, not morfo (would create a poorly-typed DSL).
  • Strict mode + data-_* escape hatch is correct.
  • Option A for location (Conway's Law: physical placement dictates semantics).

Key contributions

(a) Tagged union for valueRef. The flat valueRef: string is too permissive — no validation of id-refs, state names, or translation keys. Proposed tagged union:

type MorfoAriaValue =
  | { kind: 'literal'; value: string }
  | { kind: 'state'; state: string }
  | { kind: 'id-ref'; target: string }
  | { kind: 'translated'; key: string }
  | { kind: 'prop'; prop: string }
  | { kind: 'computed'; semantic: string };

(b) Custom Elements Manifest (CEM). Precedent missed in v1. custom-elements.json is the Web Components community standard — documents attributes, properties, events, slots, cssParts. Closest prior art after Zag.

(c) defaultElement not element. Polymorphism (consumer rendering via child snippet as <a> or <div>) means the element is not fixed. Rename acknowledges consumer override.

(d) Strict mode scope. Validation should only apply to provider-emitted attrs, not consumer restProps (data-testid, custom aria-controls to consumer's own id, etc.). Matters for correctness.

Final question

"¿Cómo reconcilias defaultElement con emitir ARIA distinta según <button> o <a>?"

My reply to Gemini

  1. Accepted tagged union + enriched it with a sixth-variant analysis — accepted.
  2. Accepted CEM as precedent, but flagged it for inclusion as precedent, not as internal format.
  3. Accepted defaultElement rename.
  4. Accepted strict mode scope restriction.
  5. Answered polymorphism question by splitting defaultElement (advisory) from role (always-emitted). Keyboard handlers are element-agnostic by convention (already soma practice).

Resulting shape v2

Same as v1 with:

  • MorfoAriaValue tagged union (6 variants).
  • element → defaultElement.
  • New role? field (always-emitted).
  • Strict mode scoped to provider-emitted props only.

3. Round 2 — Grok's review

Agreements

  • Props out — reinforces inclination with ts-morph / TypeDoc as existing tooling.
  • Constraints in provider, not morfo.
  • Strict mode + data-_* escape is exactly right.
  • Option A for location — physical honesty.

Key contributions

(a) Disagreement with Gemini on valueRef shape. Grok proposed flat string-literal union instead of tagged union:

type SemanticValue =
  | 'open' | 'closed' | 'expanded' | 'collapsed'
  | 'selected' | 'deselected'
  | 'true' | 'false' | 'undefined'
  | 'dialog' | 'menu' | 'listbox'
  | string;  // escape

Simpler, better autocompletion for common cases, less ceremony. Trade-off vs Gemini: loses cross-validation (id-ref → part kebab, state → declared states).

(b) Silent drift in CI. assertProps fires only in dev. If CI runs npm run build -- --skip-validation or if component tests don't mount the real provider, morfo can diverge from the emitted DOM without being caught. Mitigation: extend the existing Playwright smoke (items 37–39 of COMPONENT_GUIDE) to validate emitted DOM vs morfo per-component.

(c) Evolution plan. Adding a new field (e.g. role?) to MorfoPart breaks all 66 morfos at compile time. Needs morfoVersion: number + codemods from day one, or at minimum a plan for schema evolution.

(d) defaultElement as literal union. Strengthens Gemini's rename:

defaultElement: 'button' | 'div' | 'span' | 'a' | 'input' | 'ul' | 'li' | 'none' | ...;

Catches typos, improves autocomplete. Free win.

(e) Parts visual-only in eidos. Can the eidos implementation add parts not in morfo (e.g. DecorativeBackdrop)? Clarification needed: morfo = minimum shared form; each layer can extend with layer-specific parts.

(f) Keyboard state-dependence. Home/End in Tabs only work with focus in tablist. Single key + action is too flat. Added condition to MorfoKeyboard to match how MorfoAria has a condition.

(g) Zod for runtime validation of morfos. Suggested Zod as schema validator for the morfo files themselves — build-time validation of invariants TS can't express (e.g. "every id-ref.target must exist as a part kebab").

My reply to Grok

  1. Resolved the valueRef disagreement in favour of Gemini's tagged union — cross-validation is the main reason morfo exists. Introduced helper builders (v.state(), v.idRef()) to compensate for the verbosity.
  2. Accepted CI smoke extension — added as item 40 to COMPONENT_GUIDE plan.
  3. Accepted morfoVersion: number + codemods.
  4. Accepted defaultElement literal union.
  5. Clarified parts visual-only: morfo = minimum shared, each layer extends.
  6. Accepted condition? on MorfoKeyboard.
  7. Rejected Zod — the project has its own schema layer sium (Schema In Use Module) at src/lib/sium/. Required re-researching sium's API to propose a sium-based validator.

sium substitution

After reading src/lib/sium/README.md and core/index.ts:

  • sium exports string, number, boolean, literal, enumOf, optional, nullable, defaulted, object, array, union, discriminated, pipe, refine, transform, codec, meta, plus refinements (min, max, length, regex, email, url, integer).
  • Implements Standard Schema v1 via '~standard' field — interop with TanStack Form, tRPC, Hono.
  • discriminated('kind', [...]) covers the tagged union case natively.

Three concerns raised for sium + morfo:

  1. Recursion/lazy schemas — morfo parts are recursive (MorfoPart.parts?). Sium needs a lazy() equivalent (like Zod's z.lazy()). Current sium status unclear on this. Three fallbacks if absent: add lazy to sium, max-depth flatten, or validate parts separately outside the recursive call.

  2. Cross-field refine. Invariants like "aria.value.kind === 'id-ref' → target must match a part kebab in the same morfo" need refine that sees the whole value. If sium's refine receives only the local value, custom validation is needed.

  3. Pre-existing sium issues. RefineFailure undefined, defaulted O vs I semantics, field-level vs schema-level validation — blockers if morfo runs into them.

Resulting shape v3/v4

  • Tagged union for valueRef retained from v2.
  • defaultElement as literal union.
  • morfoVersion: number added.
  • condition? on MorfoKeyboard.
  • Sium schema for runtime validation (contingent on lazy support).

4. Round 3 — ChatGPT's review

Agreements

  • Props out — but flagged future exception for props that change the accessible contract.
  • Option A for location.
  • Morfo is the right abstraction; validated with concrete precedents.

Key contributions (net-new)

(a) W3C UI Specification Schema Community Group — precedent missed by both prior reviewers. Formed August 13, 2025, explicitly aims at a meta-model machine-readable for behavior, constraints, and accessibility requirements. Strongest validation that morfo's problem space is legitimate and actively under standardization discussion at W3C.

(b) Public vs virtual parts. Root/Provider parts with element: 'none' reveals a mixing of natures: public (Trigger, Content, Title) vs virtual/internal (context-only Provider, future FocusGuards, Portal roots, SafePolygon trackers, eidos visual wrappers). Virtual parts should not render public data-attrs, docs should hide them, validator should ignore them. Proposed kind: 'public' | 'virtual' on MorfoPart or split collections (parts vs internals).

(c) Focus policy as a dedicated section. MorfoKeyboard alone covers half the semantic of overlay components. APG Dialog makes initial focus, Tab-trap, Escape, and focus return central to the pattern; React Aria bundles ARIA/keyboard/focus as inseparable. Without focus, morfo gives a false sense of completeness.

interface MorfoFocus {
  initial?: 'first-focusable' | 'trigger' | { partRef: string };
  trap?: boolean;
  return?: 'trigger' | 'previous' | { partRef: string };
  restore?: boolean;
}

(d) Severity. ARIA attrs aren't binary required/omit. APG Dialog explicitly advises omitting aria-describedby when content is rich (lists, tables, multiple paragraphs) because announcing it as a single string worsens screen reader UX. aria-controls on trigger is recommended, not required.

Without severity, strict mode treats everything as required → false positives. With severity:

type MorfoSeverity = 'required' | 'recommended' | 'optional';

required missing = error; recommended missing = warning; optional missing = silent.

(e) Scope derivation from workspace, not manual. scope: Layer[] as hand-declared field drifts — dev forgets to update it when adding a layer implementation. Better: derive from filesystem presence (if src/uix/eidos/components/dialog/ exists, 'eidos' is in scope).

(f) Version via snapshot diff, not manual number. morfoVersion: number (from Grok) drifts for the same reason. CI should snapshot the morfo, diff against previous release, classify change type (breaking / minor / patch) automatically by rules (rename part → breaking, add optional part → minor, widen enum → minor, narrow enum → breaking). Same outcome, zero manual bookkeeping.

(g) Dropped translated, needs reintroduction. My v4 had dropped translated from MorfoAriaValue arguing it was provider implementation detail. ChatGPT's implicit push: docs and codegen consumers do need to distinguish literal('Close') (static English — bug in i18n product) from translationRef('common.buttons.close') (localized). Different runtime semantics; should be different kinds.

(h) aria-describedby is NOT obligatory. Concrete catch in the Dialog example. APG recommends against it when content is rich.

(i) aria-modal="true" caveat. Only correct when fond is really inert and focus is confined. Marking modal without modal behavior is an AT disaster. Argues for richer semantics or at least severity.

(j) Title role/level is editorial. Forcing role="heading" + aria-level="2" as required overreaches. A dialog title can be h1, h2, div, delegated to consumer. Morfo should distinguish "semantics emitted by provider" from "semantics contributed by composition/host element".

Patologías surfaced

  • Name collisions in nested parts — Accordion.Item.Trigger vs Accordion.Header.Trigger. Flat kebab namespace suffices only if all kebabs are globally unique. Otherwise needs path IDs (item.trigger).
  • element too concrete — same semantic over <button> / <a> / <div role=button> / <dialog>. Adding to Gemini's polymorphism point.
  • Scope + version manual = drift candidates. Addressed above.
  • TS object as sole format not portable. JSON Schema export needed for low-code / AI consumers external to Vicen.

Disagreements I held

Layer namespaces for experimental attrs (data-soma-*). ChatGPT suggested opening strict mode with layer namespaces. This violates project rule A2 — data-attrs are always data-{component}[-{part}], never data-soma-*. The escape for experimental stays severity: 'experimental' on the morfo entry (informational) and data-_* for private. No layer prefix.

Constraints composition vocabulary. ChatGPT suggested a small vocabulary (requiresOneOf, mutuallyExclusive, idRefTargets, labeling). Legitimate but speculative — no evidence yet of 5+ components wanting the same constraint. YAGNI. Let patterns emerge before codifying.

5. Convergence / divergence matrix

Topic Gemini Grok ChatGPT v5 outcome
Props in morfo ❌ out ❌ out ❌ out ❌ out
Composition constraints in morfo ❌ out ❌ out ⚠️ small vocab future ❌ out (YAGNI)
Strict mode validation scope ⚠️ provider only ✅ default strict ⚠️ open with namespace ✅ provider only + severity
valueRef shape ✅ tagged union ⚠️ flat literal union ✅ tagged union ✅ tagged union
translationRef variant — — ✅ included ✅ reintroduced
CEM as internal format ⚠️ precedent — ❌ never ❌ use converter if needed
defaultElement rename ✅ — — ✅
defaultElement literal union — ✅ — ✅
role always-emitted ✅ — — ✅
MorfoSeverity — — ✅ ✅
MorfoFocus — — ✅ ✅
public vs virtual parts — — ✅ ✅
morfoVersion field — ✅ number ❌ snapshot diff ❌ snapshot diff
Sium validator — (Zod, rejected) — ✅ sium (with caveats)
CI smoke extension — ✅ — ✅
Nested part collisions — — ✅ raised ⚠️ flat unique kebabs with CI check
JSON Schema export — — ✅ ⚠️ later if needed
Location: src/uix/morfo/ ✅ A ✅ A ✅ A ✅ A

High convergence on structural decisions. Single meaningful divergence (valueRef shape) resolved through explicit trade-off analysis.

6. Precedent map after 3 rounds

Source Contribution Added in round
Zag.js @zag-js/anatomy Closest existing — parts + selectors + data-attrs v1
Design Tokens / Style Dictionary Philosophical parallel (SoT → multi-output) v1
React Spectrum / React Aria ARIA+keyboard+focus as inseparable contract v1, reinforced r3
OpenUI (W3C) Design system documentation as JSON schema v1, r3
Custom Elements Manifest (CEM) WC-community standard for component metadata r1 (Gemini)
W3C UI Specification Schema Community Group Aug 13, 2025 — W3C formal effort at same problem space r3 (ChatGPT)
Radix / Base UI / Ariakit / shadcn All lack unified SoT for component metadata v1
JSON Schema / OpenAPI Same pattern, different domain v1

Conclusion: nobody in the JS ecosystem ships morfo's full scope (anatomy + DOM contract + ARIA + keyboard + focus) as a single machine-readable artifact. Zag comes closest (anatomy + parts). W3C is currently standardizing this exact problem space, which both validates the direction and raises the bar on rigor.

7. Final shape v5

// src/uix/morfo/types.ts

export type Layer = 'soma' | 'sema' | 'eidos';

export type MorfoElement =
  | 'button' | 'div' | 'span' | 'a' | 'input'
  | 'ul' | 'ol' | 'li' | 'tr' | 'td' | 'th'
  | 'header' | 'nav' | 'section' | 'article' | 'main' | 'aside' | 'footer'
  | 'none';

export type MorfoAriaValue =
  | { kind: 'literal'; value: string }
  | { kind: 'stateRef'; state: string }
  | { kind: 'partRef'; target: string }
  | { kind: 'propRef'; prop: string }
  | { kind: 'translationRef'; key: string };

export type MorfoCondition =
  | 'always'
  | { when: 'part-present'; part: string }
  | { when: 'state-equals'; state: string; value: string }
  | { when: 'prop-truthy'; prop: string }
  | { when: 'prop-falsy'; prop: string };

export type MorfoSeverity = 'required' | 'recommended' | 'optional';

export interface MorfoAriaEntry {
  attr: string;
  value: MorfoAriaValue;
  condition?: MorfoCondition;
  severity?: MorfoSeverity;
}

export interface MorfoData {
  attr: string;
  values?: string[];
  condition?: MorfoCondition;
  severity?: MorfoSeverity;
}

export interface MorfoKeyboard {
  key: string;
  action: string;
  condition?: MorfoCondition;
}

export interface MorfoFocus {
  initial?: 'first-focusable' | 'trigger' | { partRef: string };
  trap?: boolean;
  return?: 'trigger' | 'previous' | { partRef: string };
  restore?: boolean;
}

export type MorfoPartKind = 'public' | 'virtual';

export interface MorfoPart {
  name: string;
  kebab: string;
  kind: MorfoPartKind;
  defaultElement: MorfoElement;
  role?: string;
  optional: boolean;
  supportsNesting?: boolean;
  states?: string[];
  data: MorfoData[];
  aria: MorfoAriaEntry[];
  keyboard?: MorfoKeyboard[];
  parts?: MorfoPart[];
}

export interface Morfo {
  name: string;
  kebab: string;
  scope: Layer[];
  apg?: string;
  focus?: MorfoFocus;
  parts: MorfoPart[];
}

8. Validation strategy (three layers)

  1. sium schema (build/dev): tagged unions, enums, and cross-field invariants (every partRef.target exists as a kebab in the same morfo, every stateRef.state exists in the containing part's states[], every translationRef.key exists in the component's langs.ts).
  2. Strict mode in assertProps (dev runtime): the provider's emitted data-attrs and ARIA are exactly what morfo declares. Missing required = error, missing recommended = warning, undeclared attr = error (unless data-_* private).
  3. Playwright smoke permutations (CI): mount each component with matrix of prop/slot combinations. Verify DOM attrs per permutation match morfo's predicted output for that permutation's condition values.

Each layer catches a distinct class of bug. All three together = tight contract.

9. Learnings about peer-reviewing design with AIs

  • Methodological diversity matters. Gemini surfaced the tagged union and CEM. Grok surfaced the CI drift and schema evolution. ChatGPT surfaced W3C, focus policy, severity, and public/virtual separation. None of the three would have caught all the blind spots alone.
  • Disagreement is the signal. The only real divergence (valueRef shape) forced articulating the trade-off (validation power vs ergonomics) and committing to a principle (validation is why morfo exists → accept verbosity).
  • The final question each AI asked was valuable. Each ended with a specific pointed question that hadn't been answered in the doc. Gemini: polymorphism. ChatGPT: ID linking for optional parts. These questions forced concretization of design areas that were vague.
  • Cost of three rounds was low relative to the cost of starting to refactor 66 components on a v1 shape that would need rework.

10. Alignment with Sema (post-review context)

After the three AI review rounds, reading src/uix/sema/sema_pre.md revealed that Sema (the planned semantic/perceptual layer) consumes exactly the DOM surface that morfo already models. The match is tight, but surfaces authoring guidelines that constrain how morfos are written without changing the shape.

What Sema reads from the DOM

Sema's only inputs are (1) DOM attribute mutations (data-state, data-last-action, data-starting-style, data-ending-style) and (2) standard DOM events (click, pointerdown, focus-visible, change). No special hooks. No custom events with Sema-specific names.

Tight match with morfo

Sema requirement How morfo expresses it
Stable data-{component}[-{part}] attrs MorfoPart.kebab + createAttrs(morfo)
Enumerable data-state values MorfoData.values: string[]
Transition phase markers (data-starting-style / data-ending-style) MorfoData entries with presence flags + condition
Causal exit state (data-last-action) MorfoData with enumerable values
Cross-component value consistency (`open closednotvisible
Micro (control) vs macro (region) plane separation Part tree + ARIA role already express this
Asymmetric enter/exit semantics Combine data-state + data-last-action + transition markers
OpenUI terminology alignment Provider, Trigger, Content, Item, Header already convention

Authoring guidelines derived from Sema alignment

These do not change morfo's shape but constrain its contents. Codified as rules for writing morfos:

  1. Any component with enter/exit transitions declares data-starting-style and data-ending-style as MorfoData entries on the transitioning part, with appropriate condition.

  2. Any component with multiple semantically distinct exit paths declares data-last-action with enumerable values. Canonical example: Dialog close via saved | cancelled | dismissed | failed.

  3. Cross-component value consistency is enforced via CI diff. If Accordion uses data-state: ['open', 'closed'], Dialog and Drawer use the same tokens, not visible|hidden. The diff tool walks all morfos, groups components by semantic (disclosure → same states), and fails when a new component introduces a divergent vocabulary.

  4. data-last-action is updated before data-state changes. This is a provider-side protocol; morfo's strict mode validates ordering by watching DOM mutation timing in smoke permutations.

  5. No semantic props in Soma (<Button intent="threat">, variant="danger"). Sema applies intents via .csem files post-hoc. Morfo refuses props that represent perceptual/affective dimensions.

Why this matters

Morfo was designed to be cross-layer (soma + eidos). Sema is the third consumer being designed in parallel. The fact that morfo's shape already supports everything Sema needs — without adding a single field for "semantics" — validates the abstraction: morfo is the DOM-surface contract, and every consumer layer (eidos for styling, sema for perceptual channels, future layers) reads the same contract.

The three AI reviews pushed toward a minimal contract (reject computed, reject composition DSL, reject embedded prose). That minimal contract turns out to be exactly what Sema's "observable from outside the component" principle requires. The alignment is not coincidental — both abstractions converge on "the DOM is the API between layers".

Implication for Dialog morfo

The initial dialogMorfo example omitted data-last-action. With Sema context, Dialog's Content part must include it:

{
  name: 'Content',
  kebab: 'content',
  kind: 'public',
  defaultElement: 'div',
  role: 'dialog',
  optional: false,
  states: ['open', 'closed'],
  data: [
    { attr: 'data-state', values: ['open', 'closed'] },
    {
      attr: 'data-last-action',
      values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'],
      severity: 'optional'
    },
    {
      attr: 'data-starting-style',
      condition: { when: 'state-equals', state: 'open', value: 'starting' }
    },
    {
      attr: 'data-ending-style',
      condition: { when: 'state-equals', state: 'closed', value: 'ending' }
    },
    { attr: 'data-nested' }
  ],
  // aria, keyboard, focus, etc.
}

Same pattern applies to Drawer, Toast, Dropdown, Popover, AlertDialog, and any component with asymmetric exits.

11. Final decision

Commit to v5. Implementation order:

  1. src/uix/morfo/types.ts — v5 types.
  2. src/uix/morfo/schema.ts — sium validator (contingent on sium lazy availability).
  3. src/uix/morfo/components/dialog.ts — first canonical morfo, validates shape on a rich component.
  4. Refactor createAttrs(morfo) / registerContract(morfo) — providers read parts/data from morfo rather than duplicating.
  5. Extend scripts/smoke-check.mjs to validate emitted DOM vs morfo per-component.
  6. Port remaining 65 components by category (Overlay, Forms, Dates, …).
  7. scripts/morfo-diff.mjs — CI snapshot diff for breaking-change detection.

After Dialog is working end-to-end (morfo → provider → runtime assertion → smoke validation), proceed to batch porting.


Process time cost: ~4 hours of conversation across reviews. Design maturity gained: more than any single-authored architectural spec in the project to date. Worth the investment.

Powered by TurnKey Linux.