# Authoring cycle, author a NEW primitive or MODIFY an existing one

The 5-step procedure run AFTER [primitive-audit.md](primitive-audit.md) clears (new primitive), or jumping straight to Step 2 when modifying an existing one.

---

## Step 1, Read the contract and the reference components

Before writing or editing, load these four files. They are the ground truth:

- `.claude/docs/specs/component-token-contract.md`, the authoritative invariants. Skim the whole thing if you haven't read it this session; focus on "Variants vs Modes" and "Sanctioned Mode Attributes" if you're adding a new layout-affecting attribute.
- `packages/web-components/core/element.js`, `UIElement` base class. The `static properties` schema, `connected()`/`disconnected()`/`render()` lifecycle, and attribute-mapping conventions are all defined here.
- `packages/web-components/core/form.js`, `UIFormElement`. Only needed if the new component participates in forms (inputs, selects, checkboxes, etc.).
- At least one good-citizen reference that matches the shape of what you're building. Pick from: `button-ui`, `card-ui`, `input-ui`, `textarea-ui`, `check-ui`. Read both the `.js` and the `.css`.

## Step 2, Classify the work before you write

Before typing, answer:

1. **Is this cosmetic or structural?** If the change affects `display`, `flex-direction`, `grid-template`, `padding`, layout geometry, it's a mode, not a variant. Modes require an entry in the Sanctioned Mode Attributes table (`.claude/docs/specs/component-token-contract.md` `Modes` section). Do not introduce undocumented layout-changing variants.

2. **Does the new prop fit the Boolean-default-false rule?** If the default behavior is "on," negate the prop name before writing (`closable` is wrong if closable is the default; `permanent` is right).

3. **Does the component hold state that CSS needs to read?** If yes, every state-bearing Boolean declares `{ type: Boolean, default: false, reflect: true }`.

4. **Does the component add listeners, timers, or observers?** If yes, plan the `disconnected()` method at the same time as `connected()`, do not defer. The symmetric pair is one unit of work, never two.

## Step 3, Apply the non-negotiable rules

These rules are the distilled lessons from a 5-iteration audit. Each one corresponds to a real bug that was fixed in the codebase. Full rationale and the bug histories are in [anti-patterns.md](anti-patterns.md).

### API / Attributes

1. **Boolean defaults are `false`.** If the expected default is "on," rename: `closable` → `permanent`, `animate` → `static`, `cursor` → `noCursor`, `average` → `noAverage`, `pause-on-hover` → `noPauseOnHover`. `no-*` is the canonical negation prefix (ADR-0063 decision 1), a new rename lands on `no-*` directly, never `hide-*`.

2. **No magic-value sentinels in numeric props.** Indeterminate = `null`, not `-1`. Consumers branch on `value == null`, which is explicit.

3. **Use `attribute:` not `attr:`.** `attr:` is a silent typo, the mapper ignores it and the kebab-case HTML attribute never wires. This cost a real bug in `rating-ui`.

4. **State-bearing Booleans reflect.** `{ type: Boolean, default: false, reflect: true }`. Without `reflect`, CSS can't match `:scope[disabled]`, hover/active/selected states break silently.

5. **Reserved-name anti-patterns.** Avoid: `title` (collides with HTML tooltip attribute), `active` on parent components (use `value` for a selection or `step` for an index, per-item `active` on children is fine), `error` in variant names (use `danger`; reserve `error` for validation state), `disabled` on non-form-participating components (use `readonly`), `multiple` with exclusion semantics (use a negated positive like `single`).

6. **Element tag ends in `-ui`; JS class is `UI<Component>`.** `<foo-ui>` ↔ `class UIFoo extends UIElement`. Three-way consistency: filename, class name, custom-element tag. The sanctioned `-n` carve-out is `cot-ui` (the chain-of-thought streaming component); `nav-ui` was deprecated in favor of the `-ui` replacements. New `-n` tags require a contract-doc update, see `.claude/docs/specs/component-token-contract.md`.

### CSS

1. **Two-block `@scope` structure** is mandatory:

   ```css
   @scope (component-ui) {
     :where(:scope) {
       /* ── Tokens ── */
       --component-bg: var(--a-bg);
       --component-fg: var(--a-fg);
       /* ...all component tokens here, zero-specificity */
     }

     :scope {
       /* ── Base styles, consume only component tokens ── */
       background: var(--component-bg);
       color: var(--component-fg);
     }

     /* ── Variants / states third, override TOKENS only ── */
     :scope[variant="outlined"] {
       --component-bg: transparent;
       --component-border: var(--a-border);
     }
   }
   ```

2. **Variants override tokens only.** A variant body may only contain `--component-*: var(...)` lines. Never `padding`, `display`, `position`, `width`, `height`, `margin`, `gap`, `flex`, `grid`, `overflow`, `border-radius`. Layout changes are modes, see rule 3 in Step 2.

3. **Zero raw colors in component CSS.** No `#hex`, `rgb()`, `rgba()`, `oklch()` outside `packages/web-components/styles/colors/semantics.css` and `packages/web-components/styles/tokens.css`. Every color goes through a token.

4. **Raw px ≥ 3 is forbidden.** Use `var(--a-space-*)`. Stroke/border widths (1–2px) and documented component-intrinsic constants are carve-outs, and each carve-out needs a one-line code comment explaining why the literal.

5. **Component tokens follow `--<tag-stem>-<prop>`.** `--button-bg`, not `--btn-bg`. Files hosting multiple `@scope` blocks (e.g. `layout.css` with `col-ui`, `row-ui`, `stack-ui`) use each scope's own stem (`--col-gap`, `--row-gap`, `--stack-gap`).

6. **Consume L3, not L2.** In a variant/state body, alias from the role×state matrix, not the family base. Right: `--button-fg-hover: var(--a-primary-fg-hover)`. Wrong: `--button-fg: var(--a-primary)`.

7. **No BEM, no `::part()`, no `::slotted()`.** AdiaUI is light-DOM; the shadow-DOM escape hatches don't apply. Slots are styled through slotted attribute selectors (`:scope > [slot="foo"]`), not `::slotted()`.

### JS Lifecycle

1. **Every `addEventListener` in `connected()` has a matching `removeEventListener` in `disconnected()`.** Handler must be a stable `#field` arrow (`#onClick = (e) => { ... }`), never an inline arrow passed to `addEventListener`. Inline arrows can't be removed, `removeEventListener` needs reference equality.

2. **`UIFormElement` subclasses call `super.connected()` and `super.disconnected()`.** `ElementInternals` registration depends on it. Omitting `super` strands the form-association.

3. **Timers and observers are torn down.** `clearInterval`, `clearTimeout`, `ResizeObserver.disconnect()`, `MutationObserver.disconnect()`, `IntersectionObserver.disconnect()` all in `disconnected()`.

4. **Null cached DOM refs in `disconnected()`.** `this.#fooEl = null` after removing its listeners. Prevents stale-tree GC pinning when the component is re-attached.

5. **Never declare `disconnected()` twice in one class.** The second silently overrides the first, this exact bug lost `ResizeObserver` cleanup in `chart.js` for several commits. If you find yourself needing a "second disconnected," merge it into the existing one.

6. **Popover/tooltip overlays created in `connected()` are removed in `disconnected()`.** Anything appended to `document.body` or `<body>` via the Popover API needs explicit cleanup; they don't GC with the host.

7. **Inline arrows on dynamically created ephemeral DOM are tolerated only when the container is guaranteed fully-detached before re-render.** If a row is built fresh per render and the parent's `innerHTML` replacement detaches the old subtree, GC collects the listeners with their nodes. If the container persists, use stable handlers or event delegation on the parent.

### Field composition

1. **Do not add a `label` attribute to a new form-associated control.** `<field-ui label="…">` is the canonical labeled-field wrapper. It owns the real `<label for="…">` and binds to the slotted control's id for proper click-to-focus, a pattern the embedded per-control `label` attribute can't provide (no `[for]`, just a shadow slot). Existing controls (input-ui, select-ui, textarea-ui, switch-ui, check-ui, radio-ui, slider-ui, calendar-picker-ui, upload-ui, range-ui) still accept the legacy `label` attr but log a one-shot console.warn; **no new control should declare one**. Wrap instead:

   ```html
   <!-- right -->
   <field-ui label="Email">
     <input-ui type="email" value="…"></input-ui>
   </field-ui>

   <!-- wrong (deprecated) -->
   <input-ui label="Email" type="email" value="…"></input-ui>
   ```

   `field-ui` also carries `[slot="trailing"]` and `[slot="action"]` composition slots + an `inline` mode attribute (stacked vs. single-row). See the Field component at `packages/web-components/components/field/`.

### Nested-control composition

1. **Composite hosts own the focus ring; nested form controls suppress theirs.** When a composite wraps a form control as an internal implementation detail (textarea-ui inside chat-input-ui is the canonical example), the composite IS the primary surface from a user's perspective. Focus should wrap the whole composite, not just the inner control. Pattern:

   ```css
   @scope (my-composite-ui) {
     /* 1. Composite paints the ring via :focus-within, wraps both the control and any siblings inside the shell. */
     :scope:focus-within {
       box-shadow: var(--my-composite-focus-ring);
     }
     :scope[aria-invalid="true"]:focus-within,
     :scope[error]:focus-within {
       box-shadow: var(--my-composite-focus-ring-invalid);
     }

     /* 2. Suppress the inner control's own focus affordance.
        The @scope block's containment IS the signal, no data
        attribute or explicit opt-in needed; selectors targeting
        inner elements only apply when they're inside this host. */
     textarea-ui [slot="text"]:focus {
       box-shadow: none;
     }
   }
   ```

   Tokens follow the L3 pattern from rule 12:

   ```css
   --my-composite-focus-ring:         var(--a-focus-ring);
   --my-composite-focus-ring-invalid: var(--a-focus-ring-invalid);
   ```

   **When to use this (not field-ui):**
   - **field-ui** is a _wrapper composite_, it adds chrome (label / hint / error / required) around a control, but the control is still the primary focus target. Control owns the ring.
   - **chat-input-ui** (and future equivalents) are _shell composites_: the composite IS the control; the inner textarea is an implementation detail. Host owns the ring.

   A user's mental model is the discriminator: do they think of the composite as a single control, or as a labeled/wrapped version of an inner control? The former is a shell; the latter is a wrapper.

## Step 4, Run the 30-second self-check

Before declaring the work done, run through this checklist. If anything fails, fix before committing.

### Attributes

- [ ] No Boolean prop has `default: true`.
- [ ] No numeric prop uses `-1` or other sentinels (indeterminate = `null`).
- [ ] Every `static properties` entry uses `attribute:` (not `attr:`).
- [ ] Every state-bearing Boolean has `reflect: true`.
- [ ] No reserved-name anti-patterns (`title`, parent-level `active`, `error` variant, `disabled` on non-form, exclusion-`multiple`).
- [ ] Element tag ends in `-ui`; class is `UI<Component>`.

### CSS

- [ ] File opens with `@scope (component-ui) {` and has both `:where(:scope)` (tokens) and `:scope` (base styles) blocks.
- [ ] Variants in the file contain ONLY `--component-*: var(...)` lines.
- [ ] Zero `#hex` / `rgb()` / `rgba()` / `oklch()` in the file.
- [ ] Zero raw `px` values ≥ 3 (or each has a one-line justification comment).
- [ ] Component tokens prefixed with the scope's tag-stem.
- [ ] Variant/state bodies alias from L3 (`--a-<family>-<role>-<state>`), not L2 (`--a-<family>`).

### Lifecycle

- [ ] Every `addEventListener` in `connected()` has a paired `removeEventListener` in `disconnected()`.
- [ ] All handlers passed to `addEventListener` are stable `#field` arrows (or bound method refs), not inline arrows.
- [ ] If the class extends `UIFormElement`, both `connected()` and `disconnected()` call `super`.
- [ ] Every timer/observer created in `connected()` is disposed in `disconnected()`.
- [ ] Cached DOM refs (`this.#fooEl`) are nulled in `disconnected()`.
- [ ] Class declares `disconnected()` exactly once.

## Step 4b, Author the yaml SoT (+ regenerate the sidecar)

A component isn't just its `.js`/`.css`: the `<name>.yaml` is the SOURCE OF
TRUTH the whole pipeline reads (docs site, A2UI registries, consumer
harnesses, `.d.ts` codegen). Every new primitive ships one; every prop/slot/
event change updates it. Field-by-field contract:
[yaml-contract.md](yaml-contract.md). Then regenerate, never hand-edit the
sidecar (`sidecar-prewrite-guard` blocks it anyway):

```bash
npm run build:components            # yaml → <name>.a2ui.json + catalog + .d.ts
node scripts/build/components.mjs --verify   # "clean, N files up-to-date"
```

A primitive also needs BOTH barrel registrations, the CSS `@import` in
`styles/components.css` AND the JS `export` in `components/index.js`
(`scripts/audit/check-components-js-barrel.mjs` gates the second). Either
registration drifts the built `dist/` bundles, regenerate them in the same
change (`npm run build:bundle-css && npm run build:bundle-js`) and commit the
`dist/` updates, or CI's `check:{css,js}-bundles-fresh` gates fail on the PR
(gh#390's PR shipped without this and failed exactly there). Both commands,
not just one, `theme-provider.min.js` (a JS bundle) inlines the co-emitted
`dist/web-components.sheet.js` twin, so a CSS-only edit still drifts a JS
bundle if only `build:bundle-css` runs (bit the v0.8.13 theme-panel work
twice).

**Rebuild JS bundles from an `npm ci` scratch checkout, never a pnpm-bootstrapped worktree.**
`node scripts/dev/bootstrap-worktree.mjs`'s pnpm install produces `dist/*.min.js` output
~30% larger than CI's npm-ci install (a lockfile/hoisting difference, not a code difference), running `build:bundle-js` in such a worktree commits drift instead of fixing it, and a
local `check:js-bundles-fresh` run there will falsely pass against its own inflated bundle.
When a PR built in a pnpm worktree needs a JS-bundle rebuild: commit the CSS-side changes
first if any (`build:bundle-css` has no install-shape risk, LightningCSS's bundling is
deterministic from source, so the branch's committed `dist/web-components.sheet.js` is
trustworthy going into the next step, satisfying the CSS-before-JS ordering above), then
`git worktree add <scratch-path> <branch>`, `npm ci` there (real npm-shaped install), run
`build:bundle-js`, copy only the changed `dist/*.min.js` file(s) back into the working
worktree, commit there with an explicit pathspec, then remove the scratch worktree. This
is the correction to gh#1825's own PR body, which deferred the rebuild to "CI's
derived-artifact pipeline", wrong per ADR-0069 (`dist/` is Class C, committed and
PR-blocking, never derive-resync'd); the scratch-checkout procedure above is what actually
landed the fix, 2026-08-21.

**Never `@import` a remote URL from any file `build:bundle-css` bundles.**
LightningCSS's `bundleAsync` (the engine behind it) resolves every `@import`
as a local filesystem path, remote `http(s)` URLs included, a CSS-level
`@import url('https://fonts.googleapis.com/...')` breaks the build with
`ResolverError: No such file or directory`, not a lint warning; there's no
`bundleAsync` option to skip or externalize one `@import`. Link web fonts
(or anything else external) as a real `<link>` in the consumer's `<head>`
instead, see `styles/theme-fonts-url.txt` for the pattern this repo's 12
named themes use. A generated CSS file's own fix doesn't survive its next
regen unless the generator is fixed too, not just the committed output: the standing "generated artifacts are never hand-edited" invariant
(AGENTS.md) cuts both ways.

## Step 5, Run the project's verification gates

Before committing, run the project's verify scripts. These catch the drift-shaped bugs the checklist can miss:

```bash
npm run verify:components   # component schema integrity (catalog)
npm run verify:palette      # CVD thresholds across theme × scheme
node -c path/to/new/file.js # JS syntax check
```

The full release-side gate roster lives in the sibling **package-release** skill; run its pre-flight sweep after any structural change.

If a gate fails, fix before declaring done.

## Cross-references

- [primitive-audit.md](primitive-audit.md), the §0 gate (run BEFORE this)
- [api-contract.md](api-contract.md), deep dive on prop naming, type choices, reflection policy
- [css-patterns.md](css-patterns.md), exhaustive CSS architecture (@scope, variants, modes)
- [lifecycle-patterns.md](lifecycle-patterns.md), timers, observers, popovers, listener patterns
- [anti-patterns.md](anti-patterns.md), full failure-mode catalogue, file:line refs
- [worked-example.md](worked-example.md), badge-ui + counter-ui walkthroughs
- [yaml-contract.md](yaml-contract.md), the `<name>.yaml` SoT schema (Step 4b)
- [token-contract.md](token-contract.md), token audit (post-implementation check)
