# API Contract, Props, Attributes, Reflection

Deep dive on declaring component APIs in AdiaUI. Read this when adding a new prop whose shape doesn't match any obvious pattern in the good-citizen references.

## The `static properties` block

Every `UIElement` subclass declares its public API as a `static properties` object. Keys are camelCase JS names; values describe type, default, reflection, and attribute mapping.

```javascript
static properties = {
  disabled:  { type: Boolean, default: false,  reflect: true },
  placeholder: { type: String,  default: '',     reflect: true },
  value:     { type: String,  default: '' },
  maxLength: { type: Number,  default: null,   reflect: true, attribute: 'max-length' },
};
```

**Field rules:**

- `type`, one of `String`, `Number`, `Boolean`, `Object`, `Array`. The runtime uses this to coerce attribute strings into typed values.
- `default`: the value the prop takes when no attribute is present AND no JS value has been assigned. For `Boolean` props, default is `false` in the standard shape (see rule 1); `default: true` is a rare, ratified exception carried by connect-time attribute stamping (ADR-0075, gh#961), never the default shape to reach for. For numeric props, default is `0`, a real value, or `null` for indeterminate, NEVER a sentinel like `-1`.
- `reflect`, when `true`, JS property changes write back to the HTML attribute so CSS can match it. Required for every state-bearing Boolean. Usually safe to omit for large value props (long strings, big objects).
- `attribute`, explicit kebab-case mapping when the JS name doesn't auto-convert cleanly. `camelCase` → `camel-case` automatic; override via `attribute: 'max-length'` when you want non-default behavior.

[verified 2026-08-19] **Literal-string `"false"` parity (ADR-0075).** For a
`Boolean`-typed prop, `parseAttr` (`core/element.js:93-94`) special-cases the
literal attribute string `"false"` to parse as JS `false`, a deliberate
deviation from strict HTML boolean-attribute semantics (where any presence,
including `attr="false"`, means `true`). This matches the A2UI transpiler's
own prior `// (defensive)` special-case
(`compose/transpiler/transpiler-maps.js:206-207`), so identical markup now
parses the same in the live DOM and in transpiled/generated output. Quoting
the Decision: "for `t === Boolean`, the literal string `\"false\"` parses to
JS `false`; any other present value (including empty string) parses to
`true`; absence stays `false`." A component author or generation pipeline
that writes `interactive="false"` on a `default: true` Boolean prop gets the
intuitive result, do not assume strict HTML semantics here. (`default:
true` itself remains the rare, ratified exception this ADR's blast radius
runs against, ADR-0063's stamped-attribute mechanism, gh#961, never the
default shape "Boolean prop naming, the flip rule" above documents.) Named
blast radius already shipped and relying on this: `password-strength-ui
show-label="false"`, `nav-group-ui collapsible="false"`
(`.examples.html` demos for both).

## The `attr:` silent-typo trap

A real bug caught in iteration 4:

```javascript
// BROKEN, silent
allowHalf: { type: Boolean, default: false, reflect: true, attr: 'allow-half' },

// FIXED
allowHalf: { type: Boolean, default: false, reflect: true, attribute: 'allow-half' },
```

`attr:` is not a recognized key. The mapper ignores it. The default auto-conversion (`allowHalf` → `allow-half`) happens to produce the same result, so the bug hides, until someone changes the prop name and notices the attribute never wired.

Use `attribute:` verbatim. If you think you're writing `attr:`, stop and correct it before saving.

## Boolean prop naming, the flip rule

AdiaUI conventions require `default: false` on Boolean props in the standard shape, unless a ratified exception applies (ADR-0075's connect-time attribute stamping, gh#961, a rare exception, not a route open to new work). The naming follows:

| Intended default behavior | Wrong name (default:true) | Right name (default:false) |
| --- | --- | --- |
| Modal can be dismissed | `closable` | `permanent` |
| Skeleton animates | `animate` | `static` |
| Stream shows blinking cursor | `cursor` | `noCursor` |
| Chart shows average line | `average` | `noAverage` |
| Toggle group allows multi-select | `multiple` | `single` |
| Swiper pauses on hover | `pause-on-hover` | `noPauseOnHover` |

**Naming patterns:**

- `permanent` / `static` / `readonly`, describes the non-default state positively.
- `no*` / `hide*` / `disable*`, prefixes for "opt-out of a default."

**Don't write:**

- `enabled` (invert to `disabled`), `visible` (invert to `hidden`), these clash with standard HTML attribute vocabulary.
- Double-negatives like `unhide` or `dontSkip`.

## Enum attribute + container-query auto-snap default, no interpolation

[verified 2026-08-19] ADR-0074 establishes the cross-component pattern for a
value-enum attribute whose sensible default is "pick automatically from live
layout, but let a consumer pin it explicitly": `chart-ui` /
`chart-legend-ui` / `chart-in-card`'s `ratio` attribute, three allowed
values (`3:2`, `1:1`, `2:3`), unset by default. Quoting the Decision:
"Unset (the default, empty string) means auto-snap: a CSS container query on
the element's own box compares its live aspect ratio against two midpoint
boundaries … and renders the nearest named ratio's studied layout, never a
scaled/interpolated blend. Setting `ratio` explicitly pins that ratio's
rendering regardless of the container's actual aspect, overriding the
container query."

Shape to follow for a new attribute of this kind:

- A plain value-enum attribute, not a boolean, ADR-0063's `no-*`
  negation-prefix grammar does not apply.
- Unset/default means container-query-driven auto-snap against studied
  breakpoints, discrete snapping between named values, never continuous
  interpolation (an in-between state was never individually studied, so it
  can't carry a "deliberately designed" claim).
- An explicit attribute value always pins and overrides the auto-snap,
  never blends with it.
- The resolved value reflects back for CSS/consumer introspection, `data-ratio-resolved` on `chart-ui` names which of the enum's snapped or
  pinned values is currently in effect, distinct from the (possibly unset)
  `ratio` attribute itself.
- Don't fold the new axis into an existing enum attribute that covers a
  different concern (`size` stayed `sm|md|lg`-only; `ratio` didn't grow a
  compound value like `lg-2:3`), orthogonal axes get their own attribute.

`table-toolbar-ui[stage]` (ADR-0076) is the second precedent for this same
shape, see the ADR-0063 conventions list in
[token-contract.md](token-contract.md).

Source: [ADR-0074](../../../../../../docs/ops/adr/adr-0074-chart-ratio-attribute-grammar.md).

**[verified 2026-08-23] Failure mode, DOM-ancestry auto-detection is riskier
than container-query auto-detection.** ADR-0081 Decision 1 (`chart-ui[labels]`,
a 3-value enum: `""`/`chip`/`outside`) initially mirrored this same
auto/explicit-override shape, but auto-detected via DOM ancestry, resolving
unset `labels=""` to `chip` whenever the element sat inside a `section[bleed]`
or `card-ui[padding="none"]` ancestor, instead of a container query on the
element's own box. The 2026-08-21 amendment walked that back entirely after it
silently flipped 2 of 60 fixtures on the Charts visual-eval floor
(`comp-chart-in-card-n-*`, pre-existing full-bleed compositions), caught only
by the floor's pixel-diff gate, neither the unit-test suite nor code review
renders real CSS/layout. Amended decision: unset `labels=""` resolves to
`outside` unconditionally; `chip` mode activates only via the explicit
`labels="chip"` attribute, no ancestry detection at all. ADR-0081's Decision
2 (a `today` marker attribute) independently rejects clock-derived
auto-detection for the same reliability reason (cites IDR-0006). Lesson for a
new auto-snap attribute of this shape: auto-detection is safe when it reads
the element's *own* rendered geometry (a container query against its own box,
as `ratio` does); it is risky when it reads *ancestor* DOM state or wall-clock
time, because neither is guaranteed stable across every composition that
happens to nest the element, verify any such default against a real
pixel-diff/visual-eval gate, not unit tests or review alone. Source:
[ADR-0081](../../../../../../docs/ops/adr/adr-0081-chart-2-0-foundations-attribute-grammar.md)
Amendment (2026-08-21).

## Canonical breakpoint scale (adr-0089)

[verified 2026-08-25, gh#1984] `core/responsive.js`'s `BREAKPOINTS` export is
the single canonical reference for any device-class threshold, whether the
query mechanism is `@media` (the five sanctioned viewport primitives, `grid-ui`, `col-ui`, `row-ui`, `block-ui`, `text-ui`, plus top-layer/popover
positioning with no ancestor box to query) or `@container` (everything else,
per `adr-0088`, `spec-breakpoint-convention` REQ-004 forbids importing
`core/responsive.js` itself outside those cases). This table MUST match
`core/responsive.js`'s `BREAKPOINTS` export byte-for-byte, a gap here was
exactly the drift `spec-breakpoint-convention` REQ-007 named and closed:

| Name | Min-width |
| --- | --- |
| `xs` | `0` |
| `sm` | `480px` |
| `md` | `768px` |
| `lg` | `1024px` |
| `xl` | `1280px` |

A `@container`/`@media` rule can't read a CSS custom property at parse time
(forbidden-pattern #2 in [token-contract.md](token-contract.md)), so these
values are cited as raw literals in component CSS, never tokenized, but
every such literal MUST carry a preceding comment naming the rung it
implements (device-class threshold) or the ergonomic reason for the number
(a component-intrinsic threshold, exempt from this table entirely, `nav-ui`'s
96px icon-rail floor, `table-toolbar-ui`'s compaction stages, `chart-ui`'s
200px legend-hide are correctly exempt and are NOT migration candidates).
`packages/web-components/core/breakpoint-observer.js` is the shared
`ResizeObserver`-to-attribute helper for a component that needs
JS-observable state (relocating a node, switching a positioning strategy)
rather than a pure CSS layout switch, see its own module doc for the
boolean-vs-named-value reflection modes. Full requirements:
[spec-breakpoint-convention](../../../../../../docs/ops/spec/spec-breakpoint-convention.md).

## Numeric props, `null` over sentinels

Indeterminate, unknown, or "not yet set" numeric state uses `null`, not `-1` or `Infinity`:

```javascript
// BROKEN, `-1` is a magic sentinel
value: { type: Number, default: -1, reflect: true }
// Consumer: if (this.value !== -1) { ... }

// FIXED
value: { type: Number, default: null, reflect: true }
// Consumer: if (this.value != null) { ... }
```

The `progress-ui` component carried `-1` for "indeterminate" for a long time. Branching on a specific number is fragile, someone assigns `-1` meaningfully later and the indeterminate check breaks silently. `null` is unambiguous.

**Back-compat coercion is allowed:**

```javascript
set value(v) {
  if (v === -1) v = null; // legacy input coercion, remove in a future cycle
  this._value = v;
}
```

## Reserved-name anti-patterns

These prop names collide with HTML semantics, framework-wide conventions, or create ambiguity in audit. Pick alternatives:

### `title`

HTML's global `title` attribute is the browser tooltip. Using `title` on a custom element makes any custom-element heading a tooltip by default, which is almost never intended. Use `heading`.

### `active` on a parent component

Children can have per-item `active` (a Boolean describing that item's state). Parents use `value` (for a selection) or `step` (for an index into a series).

- `<timeline-item-ui active>`, OK, per-item Boolean state.
- `<timeline-ui step="3">`, correct: parent holds the index.
- `<timeline-ui active="3">`, wrong: `active` shouldn't carry a non-boolean payload.

### `error` as a variant

Reserve `error` for validation state (`[error]` on form inputs matches ARIA patterns). For visual emphasis meaning "destructive/negative," use `danger`, matching the semantic family (`--a-danger-*` tokens).

```html
<!-- wrong -->
<tag-ui variant="error">Failed</tag-ui>

<!-- right -->
<tag-ui variant="danger">Failed</tag-ui>

<!-- separate use -->
<input-ui error>Please enter a valid email</input-ui>
```

### `disabled` on a non-form component

`disabled` has form-participating semantics, it removes the element from the tab order, blocks submission, etc. On a non-form component (a diagram, a toolbar, a noodle editor), use `readonly`:

- `<input-ui disabled>`, correct: input is form-participating.
- `<noodles-ui readonly>`, correct: diagram is read-only, not form-disabled.

### `multiple` with exclusion semantics

`<select multiple>` in HTML means "allow multiple selections." A prop named `multiple` means "multi-select is the default." If you want single-select as the default behavior:

```html
<!-- wrong: implies multiple was default, which violates Boolean-false rule -->
<toggle-group-ui multiple>...</toggle-group-ui>

<!-- right: single-select is the opt-out, matches Boolean-false -->
<toggle-group-ui single>...</toggle-group-ui>
```

**[historical, 2026-08-31, ADR-0056 amendment]** `<toggle-group-ui>` /
`<toggle-option-ui>` were cut outright in `0.8.43` (gh#1617), before the
migration this ADR's Decision 2 anticipated ever ran, moot, not
falsified. `segmented-ui`/`segment-ui` absorbed the role
(`segmented.yaml`'s `multiple` prop description names the absorption
directly, gh#1369/#1363 C1). The example above stays as written because it
illustrates the Boolean-false naming rule against a real historical prop
name, not because `<toggle-group-ui>` still exists, `segmented-ui`
deliberately does NOT follow this same polarity (`[multiple]` is
positive-polarity by design, matching `select-ui[multiple]`; there is no
`segmented-ui[single]` opt-out), so it is not a drop-in replacement
example for this rule.

## Selection-item state, a declared, reflected `selected` prop, never a private `data-*` stamp

A **selection-item primitive** (one selectable option inside a selection-group parent, `segment-ui` in `segmented-ui`; historically also `toggle-option-ui` in `toggle-group-ui`, cut in `0.8.43`/gh#1617, see the dated note below) exposes its current state as a declared, reflected `selected: Boolean` prop, documented as parent-managed, and styled via `[selected]`:

```javascript
// child, the declared API surface
static properties = {
  selected: { type: Boolean, default: false, reflect: true },
};
```

```yaml
# child yaml, the SoT the catalog and A2UI grammar read
selected:
  description: >-
    Whether this option is currently selected. Managed by the parent
    <group> container, don't set directly; the group's `value` is the
    single source of truth.
  type: boolean
  default: false
  reflect: true
```

```css
/* child css: the state hook is the reflected attribute */
segment-ui[selected] { … }
```

Rules:

- **The parent's `value` is the single source of truth**; the parent writes `selected` on its children (`segmented.class.js:146,149` sets/removes the attribute; `toggle-group.class.js:104-111` assigns `opt.selected`). Authors and generated markup drive the group's `value`, never `[selected]` on a child (`toggle-group.yaml`'s rules block says so in so many words).
- **No private `data-*` stamp for API-conceptual state.** `data-selected` hides the state from the yaml SoT, the catalog, and generative authoring, a consumer or the A2UI grammar cannot express "this option is selected" against a stamp that no schema declares. `toggle-group-ui` used to stamp `data-selected` + style `:scope[data-selected]`; it converged onto `segment-ui`'s mechanism (`toggle-option.yaml:38-46`, `toggle-group.css:14`, `toggle-group.test.js:64` asserts the stamp is gone).
- **Parent-stamped ARIA stays as-is, ARIA is wiring, not API.** `segment-ui` derives `aria-checked` from `selected` (`segment.class.js:42`); `toggle-option-ui` derives `aria-pressed` (`toggle-group.class.js:65`). The reflected prop is the API; the ARIA attribute follows it.
- **Reference implementation:** `segment-ui` (`segment.yaml:32-36`, `segment.css:7`) is the current, live reference. **[historical, 2026-08-31, ADR-0056 amendment]** `toggle-option-ui`/`toggle-group-ui` were the converged second instance at ratification time, but both were cut outright in `0.8.43` (gh#1617), before any migration ran, `segmented-ui`/`segment-ui` absorbed the role. A new selection-item primitive copies `segment-ui`'s shape directly; there is no second live instance to extend by analogy anymore (the divergence gh#1303 describes was between `segment-ui` and the now-deleted `toggle-option-ui`).

Migration note: removing `data-selected` was a breaking change for external CSS that targeted it, so it shipped in two halves, the additive `selected` prop in `0.8.39`, the stamp removal on the `0.8.40` breaking wave with a migration-guide entry (`packages/web-components/CHANGELOG.md`; the ADR text names the wave `0.9.0`, it shipped as `0.8.40`).

Source: ADR-0056 (ratified 2026-08-15, gh#1303).

## Three-way name consistency

The component has three names that must agree:

- **File path:** `packages/web-components/components/foo/foo.js`
- **Class name:** `class UIFoo extends UIElement`
- **Custom element tag:** `customElements.define('foo-ui', UIFoo)`

A fourth consistency requirement: the CSS file at `packages/web-components/components/foo/foo.css` uses `@scope (foo-ui)`.

## Extending `UIFormElement`

Form-participating components extend `UIFormElement` (which extends `UIElement`) and get `ElementInternals` wiring, form-reset handling, and `.form` / `.labels` / `.validity` accessors for free.

```javascript
import { UIFormElement } from '../../core/form.js';

class UIInput extends UIFormElement {
  static properties = {
    ...UIFormElement.properties, // inherit name, value, disabled, required, etc.
    placeholder: { type: String, default: '', reflect: true },
  };

  connected() {
    super.connected(); // MUST call, registers ElementInternals
    // ...
  }

  disconnected() {
    super.disconnected(); // MUST call
    // ...
  }

  // Override `value` getter/setter if the form-submitted value differs
  // from the stored one
  get value() { return this._value ?? ''; }
  set value(v) { this._value = v; this.syncValue(String(v)); }
}
```

Key details:

- **Always `super.connected()` and `super.disconnected()`**, without them, form-association doesn't register.
- **`this.syncValue(str)`**, call this whenever the value changes to update the form-submitted string. Accepts a string.
- **Inheriting properties**, spread `UIFormElement.properties` into your own `static properties` so you don't re-declare `name`, `disabled`, `required`.

## Event conventions

- Bubble custom events: `new CustomEvent('foo', { bubbles: true, detail: {...} })`.
- Reuse standard events where possible: `input`, `change`, `submit`, `focus`.
- Custom event names are kebab-case: `cot-toggle`, `noodle-connected`.
- When dispatching state changes, fire `input` during interaction and `change` on commit. Matches native form semantics.

## When to add the `render()` method

`render()` runs when reflected attributes change. Use it to update internal DOM that depends on props:

```javascript
render() {
  if (this.#textareaEl) {
    this.#textareaEl.disabled = this.disabled;
    this.#textareaEl.placeholder = this.placeholder;
  }
}
```

Rule of thumb: if CSS can do the work via an attribute selector (`:scope[disabled] { ... }`), prefer CSS. Reserve `render()` for propagating state into child inputs, recalculating positions, or reflecting data changes that attribute selectors can't express.

## Popover `placement` defaults, picked by popover-to-trigger width ratio

Every popover-bearing primitive exposes a consumer-overridable `placement` attribute (declare it in the yaml SoT, with the default documented in the prop description). The DEFAULT is selected by the popover's natural width relative to its trigger, never by component identity:

| Case | Default |
|---|---|
| Popover ≈ trigger width (≤ ~1.5×; listboxes / action menus, anything that `matchWidth`s the trigger) | `bottom-start` |
| Popover materially wider than trigger (calendar grids, date/time/color pickers, filter panels) | `bottom` (centered) |
| Trigger sits at a container's right edge **by construction** (toolbar spillover, right-pinned "more") | `bottom-end` |
| Non-bottom-axis surfaces | component-specific: `tooltip-ui` → `top`, `nav-group-ui` collapsed flyout → `right` |

Rationale: `bottom-start` on a wide popover under a right-anchored trigger fires `anchor.js`'s right-edge overflow recovery, snapping the panel far past the trigger's left edge (the v0.6.35 date-range-picker incident, ~800px panel under a ~280px button). Centered `bottom` shifts at most half the overflow distance. Classification is done once at authoring time; borderline cases resolve by what the popover *wants*: if it `matchWidth`s the trigger it is trigger-width regardless of absolute size. "The consumer placed their button on the right of the layout" is a call-site `placement="bottom-end"` override, never a default. The overflow-recovery logic in `anchor.js` is the safety net and stays unchanged: the default's job is to make recovery rarely fire.

Source: ADR-0034.
