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
defaultElementcon emitir ARIA distinta según<button>o<a>?"
My reply to Gemini
- Accepted tagged union + enriched it with a sixth-variant analysis — accepted.
- Accepted CEM as precedent, but flagged it for inclusion as precedent, not as internal format.
- Accepted
defaultElementrename. - Accepted strict mode scope restriction.
- Answered polymorphism question by splitting
defaultElement(advisory) fromrole(always-emitted). Keyboard handlers are element-agnostic by convention (already soma practice).
Resulting shape v2
Same as v1 with:
MorfoAriaValuetagged 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
- 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. - Accepted CI smoke extension — added as item 40 to COMPONENT_GUIDE plan.
- Accepted
morfoVersion: number+ codemods. - Accepted
defaultElementliteral union. - Clarified parts visual-only: morfo = minimum shared, each layer extends.
- Accepted
condition?onMorfoKeyboard. - Rejected Zod — the project has its own schema layer
sium(Schema In Use Module) atsrc/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:
-
Recursion/lazy schemas — morfo parts are recursive (
MorfoPart.parts?). Sium needs alazy()equivalent (like Zod'sz.lazy()). Current sium status unclear on this. Three fallbacks if absent: addlazyto sium, max-depth flatten, or validate parts separately outside the recursive call. -
Cross-field refine. Invariants like "
aria.value.kind === 'id-ref'→ target must match a part kebab in the same morfo" needrefinethat sees the whole value. If sium'srefinereceives only the local value, custom validation is needed. -
Pre-existing sium issues.
RefineFailureundefined,defaultedO vs I semantics, field-level vs schema-level validation — blockers if morfo runs into them.
Resulting shape v3/v4
- Tagged union for
valueRefretained from v2. defaultElementas literal union.morfoVersion: numberadded.condition?onMorfoKeyboard.- 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.TriggervsAccordion.Header.Trigger. Flat kebab namespace suffices only if all kebabs are globally unique. Otherwise needs path IDs (item.trigger). elementtoo 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)
- sium schema (build/dev): tagged unions, enums, and cross-field invariants (every
partRef.targetexists as a kebab in the same morfo, everystateRef.stateexists in the containing part'sstates[], everytranslationRef.keyexists in the component'slangs.ts). - Strict mode in
assertProps(dev runtime): the provider's emitted data-attrs and ARIA are exactly what morfo declares. Missingrequired= error, missingrecommended= warning, undeclared attr = error (unlessdata-_*private). - 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
conditionvalues.
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:
-
Any component with enter/exit transitions declares
data-starting-styleanddata-ending-styleasMorfoDataentries on the transitioning part, with appropriatecondition. -
Any component with multiple semantically distinct exit paths declares
data-last-actionwith enumerablevalues. Canonical example: Dialog close viasaved | cancelled | dismissed | failed. -
Cross-component value consistency is enforced via CI diff. If Accordion uses
data-state: ['open', 'closed'], Dialog and Drawer use the same tokens, notvisible|hidden. The diff tool walks all morfos, groups components by semantic (disclosure → same states), and fails when a new component introduces a divergent vocabulary. -
data-last-actionis updated beforedata-statechanges. This is a provider-side protocol; morfo's strict mode validates ordering by watching DOM mutation timing in smoke permutations. -
No semantic props in Soma (
<Button intent="threat">,variant="danger"). Sema applies intents via.csemfiles 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:
src/uix/morfo/types.ts— v5 types.src/uix/morfo/schema.ts— sium validator (contingent on siumlazyavailability).src/uix/morfo/components/dialog.ts— first canonical morfo, validates shape on a rich component.- Refactor
createAttrs(morfo)/registerContract(morfo)— providers read parts/data from morfo rather than duplicating. - Extend
scripts/smoke-check.mjsto validate emitted DOM vs morfo per-component. - Port remaining 65 components by category (Overlay, Forms, Dates, …).
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.