--- title: Component Implementation Guide type: guide audience: human + agent authority: canonical — the ordered build process (steps 1–41 + rules A1–A37) status: current source: migrated from src/uix/soma/COMPONENT_GUIDE.md (2026-07-02, docs-book F7.5) --- # Component Implementation Guide Step-by-step guide for building soma headless components. > **⚠️ Build contract — read before building.** The canon table of WHAT every > component must consume to avoid drift lives **below, in this guide** > ([§ Build contract](#build-contract-the-canon-table)). Historical origin: > the 2026-06-19 archetype-coherence audit (its §13 seeded this table; the > audit is history now, not the source — DOC-1, 2026-07-11). ## Build contract (the canon table) **This is what EVERY component consumes to stay faithful to the eidos design.** All axes are LIVE — the phased rollout the 2026-06-19 audit planned (A3–A5) landed during 2026-06/07; each axis names the guard that defends it today. | Axis | Canon — WHAT to consume | NOT this (drift) | Guard | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **Surface / elevation** | `data-depth='overlay'\|'modal'\|…` → the full bundle (surface·shadow·halo·border·blur·z) | hand-picked `surface-raised`/`-default`; own `--{c}-overlay-z`; arbitrary frost | `elevation-plane.test.ts` · THEME-SYS-1 | | **Radius** | `--radius-default` / global factor + `[data-shape-nest]` concentric | `calc(--radius-md − space)` by hand; fixed px | R-2.x + shape engine | | **State (hover/active)** | `--state-{hover,press,selected}` layer (neutral tier; per-variant accent stays in the recipe) | ad-hoc `color-mix`; per-component `--x-hover-bg` | R-4.3 | | **Focus** | `outline` + `--focus-ring-*` (§32 — ONE model, HCM-safe; the foundation fallback is `:where()`-wrapped so recipes win) | own focus tokens; box-shadow rings (die in HCM) | R-1.5 + forced-colors floor | | **Field label** | the canonical label role (size-relative, one step below the input; unified weight/color) | redefining `--{c}-label-*` | Field doctrine 2026-07-05 | | **Size (controls)** | the `--size-{k}-*` bundle (height·font·padding·gap·radius·icon) | re-deriving size→font; consuming none of the bundle | size-bundle test (recipe-css-contract) | | **Touch hit-area** | §37: `--touch-target` (44px) under `pointer: coarse` only — AREA ≠ VISUAL (`::before` slop for bare markers) | targets <44 on touch without slop; growing the visual | archetypes.css coarse rules | | **Portal typography** | anchor `font-family`+`line-height`+`color` on the portaled content root | inheriting (falls to serif in the portal) | rule (LIVE) | | **RTL** | **logical** properties (`inline/block`, `inset-inline`) for flow; physical `left/right` ONLY where the geometry itself is physical (compass handles, polar arcs, a JS-measured offset) or as a **placement grid that must NOT mirror** — and that is now a named choice, not an exception: `Position` (physical) vs `LogicalPosition` (`start`/`end`, mirrors), both consts in `eidos/lib/types.ts`, both in [`canon/vocabularies.md`](../canon/vocabularies.md) §Placement grids. **Ask: must it flip for a right-to-left reader?** A strip pinned to `bottom-end` belongs on the trailing edge in both directions; a panel that opens to the physical right because that is where the space is does not. Narrow with `Extract<>`, never re-declare a grid (the logical one was hand-written five times until 2026-08-15). This closes EID-3, which recorded the physical exception in July 2026 and left its doctrine pending; branch on direction with **`:dir(rtl)`** — and then the provider MUST stamp the raw `dir`, or `:dir()` only ever sees the inherited direction ([direction contract](../canon/direction-contract.md)) | physical `padding-left`/… in content flow; a logical anchor paired with a physical `translateX` — the anchor flips, the transform does not; `[dir='rtl'] …`, which misses the common no-attribute case and ignores any nearer re-declaration; accepting the prop, running the chain and never stamping — the maths moves, the paint stays behind; a `:dir()` rule that turns one logical face off and repaints the other — the property had ALREADY mirrored, so that cancels it | RTL-1 · RTL-2 · `npm run rtl:check` | | **RTL · SVG** | a graphic with a READING axis mirrors (invert the scale's pixel range); a RADIAL one does not. `text-anchor` is LOGICAL: leave it alone when the composition mirrors, force the physical one when it does not — see `eidos/components/chart/README.md` §Direction | mirroring _and_ flipping the anchor (they cancel); flipping the anchor on a gutter that never moves (the label walks across the graphic); mirroring y values | eye, in RTL — RTL-1 reads CSS text and cannot see SVG attrs or JS-written inline geometry | | **i18n** | `eidos.langs.ts('#?key\|fallback')` + key in the catalog | hardcoded strings / `aria-label`s | rule (LIVE) | | **Color (values)** | role tokens `--color-*` / recipe tokens | raw hex/rgb/hsl/oklch | R-2.1/2.6 · R-4.6 | | **Density / spacing** | `--space-*` · `--control-height-*` | fixed px (bypasses density/scaling) | R-2.x | | **Composition** | compose the existing `Button`/`Field`/`Icon`/`Select` | re-implementing primitives inline | §4 + review | **Update rule (so the guide can never reference a nonexistent token):** a new axis enters this table WITH its guard in the same pass — the table, the how-to-consume section and the lint advance coupled to the implementation, never ahead of it. ## Before You Start ### 1. Compare with reference libraries **This step is mandatory. Do not skip it.** Search ark-ui, bits-ui, and radix-ui for the same component. Create a feature table: | Feature | Radix | Ark | Bits | Soma | Decision | | ----------- | ----- | --- | ---- | ---- | ------------- | | (each prop) | ... | ... | ... | ✓/✗ | justification | Document what soma includes and what it skips (with reason). ### 2. Audit Morfo/Sema events **This step is mandatory for every component, including existing morfos.** Do not treat an empty `events` array as correct by default. Classify the component first: | Shape | Sema expectation | | ----------- | ---------------------------------------------------------------------------- | | Passive | `0 events` is valid when the component only projects external state. | | Interactive | User decisions usually need discrete events. | | Continuous | Do not emit every frame/pixel; model start/confirmed drag/commit boundaries. | | Mixed | Passive display may stay silent, but user actions still need events. | For every real user action decide: - `family` and `verb` from the canonical Sema vocabulary. - `sequence` (`pre`, `post`, `coincident`) based on whether the perceptual event must precede, follow, or accompany the state change. - `intent` only when the occurrence is evaluative. Neutral UI mechanics can be non-evaluative or default to `neutral`. - `target` part. Prefer the part the user perceives as acting; use provider only when the event is component-wide. - `prewrite` / `commit` only when the DOM must expose state before/after the semantic occurrence. The provider must route semantic actions through `runtime.trigger(...)`. Local callbacks such as `onValueChange`/`onValueCommit` are not a substitute for Sema when the action is perceptual. ### 3. Verify membership criteria The component must meet ALL of these: - **WAI-ARIA pattern or semantic role** — the component implements a pattern from the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/) (Dialog, Combobox, Treegrid, Feed, Tabs, Toolbar, …) OR a canonical ARIA role (`role="status"`, `role="meter"`, `role="progressbar"`, `role="searchbox"`, …). If the browser's native HTML gives you the right role + keyboard model with no extra behavior required (e.g. `` for Link, `
` for Separator, `` for Image), the primitive belongs in **Eidos**, not Soma. - **Composition of parts** — 2+ sub-components communicating via context (Provider + Trigger + Content, Provider + Row + Cell, etc.). A single-DOM wrapper is Eidos-level styling, not headless behavior. - **Complex behavior** — keyboard navigation, focus management, floating, ARIA relationships, state machines, drag, form integration, or live-region coordination. Adding `role="…"` + `aria-label` to a single element is not enough. If it fails any of these → it's Eidos-native, not Soma. Accepted exceptions (composition criterion waived when WAI-ARIA defines a tight contract): - `Announce` — a live-region primitive per WAI-ARIA 1.2 live regions; meets complex-behavior via dual-region A/B dispatch + auto-clear + priority routing, even though its surface is a single region per priority. - `Progress` / `Meter` — canonical single-element roles with computed ARIA values and CSS custom properties for the decorative indicator; shipped with an `Indicator` part so consumers have two slots (the role host and the fill), crossing the composition threshold. ### 4. Compose existing components; flag gaps **Dogfood the framework.** When a new component — or its demo, or any UI you build — needs a building block the framework already provides (`Button`, `Field`, `Popover`, `Dialog`, `Icon`, `Calendar`, `Select`, …), **compose the existing soma/eidos component**. Never re-implement a primitive inline or hand-roll a one-off. The picker family is the canonical example: pickers compose `Popover` + `Field` + `Calendar`/`Slider` with shared state instead of reinventing any of them (A27). If a needed building block **does not exist** as a framework component, do **not** silently inline a bespoke version. **Flag the gap** — report that component `X` is missing — so it can be built as a proper, reusable component (its own morfo + soma + eidos) and then composed. A missing component is a signal to create it (or record the need), never an excuse for an ad-hoc reinvention that drifts from the system. **Button-shaped parts in a bar: two classes, not one rule.** An eidos part that renders a `