[
  {
    "type": "carousel",
    "description": "`carousel` — a `track` of `slide`s with optional `prev`/`next` buttons and `dot` pagination. `params.autoplay` (+ `params.interval`, default 5000ms) advances automatically, pausing on hover; suppressed entirely in preview and when the user prefers reduced motion (§7, §9.8)."
  },
  {
    "type": "disclosure",
    "description": "`disclosure` — trigger/panel pairs, correlated by document order (architecture §7: \"parts correlate to their root by structural nesting, never by id\"). With `params.single`, opening one pair closes every other pair under the same root (single-open accordion); otherwise each pair toggles independently."
  },
  {
    "type": "tabs",
    "description": "`tabs` — a `tab`/`panel` list, exclusive selection, with roving arrow-key navigation (Left/Right, Up/Down, Home/End) per the standard tabs pattern. Pairing is positional (Nth `tab` ↔ Nth `panel`), same nesting-scoped lookup as `disclosure`. If the macro also emitted a `track` (the tab list) plus `prev`/`next` controls, the strip announces its own overflow — a tab past the edge with nothing saying so is a tab that, to the person looking, does not exist. That is wired HERE rather than by nesting a `scroll-strip` root around the list, because part lookup stops at a nested behavior boundary: every `tab` would resolve to the inner root and this handler would find none."
  },
  {
    "type": "menu",
    "description": "`menu` — a triggered popup (dropdown/mega-menu/context-menu) of `item` parts: `params.trigger` (default `\"click\"`) can be `\"context\"` (right-click — ContextMenu; positions the panel at the pointer instead of the trigger rect). Escape and outside-click dismiss; Up/Down/Home/End rove focus across items while open. This is the vanilla counterpart to @wizeworks/silicaui-react's Base-UI-backed `DropdownMenu`/`ContextMenu` — same authored markers, same `.dropdown*` classes, no React required."
  },
  {
    "type": "marquee",
    "description": "`marquee` — an auto-scrolling ticker. The motion itself is a CSS animation on the component's `track` part (§7 prefers CSS-only where it suffices), and so is the pause: this handler only toggles `data-sui-paused` on the root and lets `marquee.js` decide what that means. That keeps `animation-play-state` with exactly ONE owner — the stylesheet — so the handler never has to know which descendant is actually animated, and an inline style can never end up fighting the `:hover` rule. What's left for JS is the part CSS genuinely can't express: the editor-canvas freeze (§9.8 autoplay suppression), and `params.pauseOnHover` for markup that carries the behavior marker without the `.marquee-pause-on-hover` class (hand-authored HTML; the component macro emits both together). `prefers-reduced-motion` is deliberately NOT handled here — marquee.js already stops the animation and hands the strip back as a plain scroller under that media query, which is the better outcome: content past the first viewport stays reachable instead of being stranded behind `overflow: hidden`. Freezing it from JS as well would only re-strand it."
  },
  {
    "type": "scrollspy",
    "description": "Shared implementation behind `scrollspy` and `toc`: `spy` parts are links (`href=\"#id\"`) tracked against the section they point to; the in-view one gets `aria-current=\"true\"` for a nav/TOC to style. `toc` differs only in its default intersection threshold (a smaller sliver of a heading counts as \"current\" for a table of contents than a full scrollspy section does)."
  },
  {
    "type": "counter",
    "description": "`counter` — animates the root's own text content from 0 to a target number once it scrolls into view. `params.target` overrides the parsed initial text (so authored content can show the final value statically); `duration` defaults to 1500ms. Suppressed (jumps straight to the final value) in preview, under reduced motion, or without `IntersectionObserver`."
  },
  {
    "type": "dismiss",
    "description": "`dismiss` — a self-dismissing widget (alert, toast): clicking its `trigger` hides or removes the root. `params.remove` (default `true`) removes the element outright; `false` just sets `hidden` (e.g. to keep layout stable)."
  },
  {
    "type": "toc",
    "description": "Shared implementation behind `scrollspy` and `toc`: `spy` parts are links (`href=\"#id\"`) tracked against the section they point to; the in-view one gets `aria-current=\"true\"` for a nav/TOC to style. `toc` differs only in its default intersection threshold (a smaller sliver of a heading counts as \"current\" for a table of contents than a full scrollspy section does)."
  },
  {
    "type": "form",
    "description": "`form` — makes an authored `<form>` genuinely functional on publish (architecture §8, the action primitive). On submit it: 1. VALIDATES natively (`checkValidity()` — required, type=email, pattern, min/max/step, maxlength come for free) and, if invalid, blocks the submit, marks each failing control `aria-invalid`, and surfaces the browser's own messages via `reportValidity()`. 2. If valid and a host `onAction` was provided, DISPATCHES a structured `{ kind: \"submit\", values }` payload (values gathered with native FormData semantics — checkboxes/radios/multi-selects handled) to the action `ref` read from the form's `data-sui-action`. The default is prevented; while the dispatch promise is pending the form is `aria-busy`, its submit control is disabled, and `data-sui-state` walks idle → submitting → success | error. 3. If valid with NO `onAction` (and not preview), it does nothing and lets the browser submit natively — progressive enhancement, so a plain server-posted form still works. In `preview` mode (the builder canvas) a valid submit is prevented and never dispatched, so editing a form never fires a real host action. Field PREFILL: at hydrate, any control carrying `data-sui-bind` is seeded from `opts.resolve(ref)` when a resolver is supplied."
  },
  {
    "type": "sidebar",
    "description": "`sidebar` — a persistent nav panel that collapses IN PLACE to an icon rail (unlike `disclosure`, the root is never hidden — only its `data-collapsed` state changes, and CSS alone handles the width/label transition). Any number of `trigger` parts nested inside the root toggle it; `params.defaultCollapsed` seeds the initial state."
  },
  {
    "type": "selection-list",
    "description": "`selection-list` — a listbox of `item` parts (single- or multi-select, via `params.multiple`). Click (or Enter/Space on the focused item) toggles selection; the vanilla counterpart to @wizeworks/silicaui-react's `SelectionList`. Roving tabindex + ↑/↓/Home/End mirror the React component's keyboard behavior."
  },
  {
    "type": "modal",
    "description": "`modal` — a portal-free dialog: `trigger`(s) open a `panel` behind an optional `backdrop`; `close` parts and Escape always close it; `params.dismissible` (default `true`) also lets a backdrop click close it (AlertDialog sets this `false` — its backdrop is inert, per the ARIA alert-dialog pattern, Escape still cancels). Covers Dialog/Drawer/AlertDialog directly; Lightbox and CommandPalette layer optional extra parts on the same root rather than getting their own type (see below)."
  },
  {
    "type": "popover",
    "description": "`popover` — one or more `trigger`/`panel` pairs (positional, like `disclosure`), anchored via runtime-computed position (same precedent as `carousel`'s `track.style.transform` — this is live-DOM positioning, not an authored inline style). `params.trigger`: `\"click\"` (default) | `\"hover\"` | `\"context\"` (right-click, positions at the pointer instead of the trigger rect). `params.single`: closes other pairs under the same root when one opens (Menubar/NavigationMenu's \"only one menu open\"). Covers Popover, Tooltip, PreviewCard, ContextMenu, Menubar, NavigationMenu — each is this one behavior with different params, not a broken reuse: the real differences (trigger event, anchor point, single-open grouping) are all genuine parameters, not papered-over mismatches."
  },
  {
    "type": "combobox",
    "description": "`combobox` — a text `input` + a `panel` of `item` options, filtered live. `params.mode`: `\"select\"` (default — commits an item's value into the input, like Combobox), `\"freetext\"` (Autocomplete — Enter with nothing highlighted commits the raw typed text), `\"multiple\"` (MultiSelect — commits remove the item from the remaining list; Backspace on an empty input pops the last commit). One handler, mode branches, mirroring how `selection-list` handles `params.multiple` in one file rather than three."
  },
  {
    "type": "date-segment",
    "description": "`date-segment` — `role=spinbutton` `segment` divs (non-form-associated — value lives only in `aria-valuenow`), each configured via `data-role` (`month`/`day`/`year`/`hour`/`minute`/`second`/`period`), `data-min`, `data-max`, `data-digits`, and an optional `data-cycle` JSON array (AM/PM). Ports `lib/date-time-segment.tsx`'s digit-buffer commit rule verbatim: a typed digit commits once the buffer hits `digits` length OR one more digit could no longer stay ≤ max. The day segment's max is recomputed live from sibling month/year segments (real calendar-aware clamping, not a static 31). Covers DateInput/DateTimeInput/TimeInput; DateRangeInput is two of these roots side by side (a shared `-` separator needs no behavior of its own)."
  },
  {
    "type": "pin-input",
    "description": "`pin-input` — real single-char `<input>` `cell`s (index-based, native value/maxlength do the work), unlike `date-segment`'s buffer-accumulate model on non-form-associated divs. Each keystroke is a whole commit with immediate auto-advance; Backspace-on-empty steps back; paste distributes characters starting at the focused cell."
  },
  {
    "type": "calendar",
    "description": "Parses a `YYYY-MM-DD` string as a LOCAL date, not `new Date(iso)`'s UTC-midnight parse — the latter shifts a day backward in any timezone behind UTC once `.getDate()` reads it back in local time (a real bug caught by the jsdom interaction test, not the structural one)."
  },
  {
    "type": "tree",
    "description": "`tree` — an ARIA tree (`role=treeitem` `node`s, nested `role=group` containers). No `ownParts`-style single-root scoping here: nodes at any depth belong to the SAME tree, unlike a carousel's slides, so this walks `querySelectorAll` directly. Roving tabindex is recomputed against the currently-VISIBLE set (collapsed branches are skipped) on every move, since children only exist in the accessibility tree while expanded."
  },
  {
    "type": "wizard",
    "description": "`wizard` — `step`/`panel` pairs like `tabs`, but with real state-machine rules `tabs` doesn't have: `params.linear` (default `true`) only allows clicking BACK to a completed step, never ahead; a `next`/`prev` footer pair drives forward/back, and `next` relabels to \"Finish\" on the last step. Host-driven validation gates progress via `aria-disabled` on the `next` part (the author/host toggles it — this behavior only reads it, it doesn't invent its own validation)."
  },
  {
    "type": "number-field",
    "description": "`number-field` — a native `<input type=\"number\">` plus `increment`/ `decrement` step buttons. The CSS here has no Base-UI-only selectors (just `:disabled`/`:hover`), so the input itself is the real source of truth; the buttons just call `stepUp()`/`stepDown()` and fire the events a plain keyboard-driven change would."
  },
  {
    "type": "toggle-group",
    "description": "`toggle-group` — a toolbar of toggle buttons (`item` parts), not a listbox (distinct ARIA pattern from `selection-list`: `aria-pressed`/`data-pressed` on real buttons, not `aria-selected` on listbox options). `params.multiple` allows several pressed at once (default: single, pressing one un-presses the rest); `params.orientation` (`\"horizontal\"` default | `\"vertical\"`) picks which arrow keys rove focus, matching `tabs`' convention. An OPTIONAL `close` part un-presses everything. That's what makes `Filter` (a single-select chip row with a reset) this behavior rather than a new one: the only delta was one extra control, which is the \"one type, optional parts\" pattern, not a fork. The reset hides itself while nothing is pressed, so a static page renders the same resting state React does."
  },
  {
    "type": "scroll-area",
    "description": "`scroll-area` — a real `overflow:auto` `track` (the CSS hides its native scrollbar) with a custom `thumb` whose size/position is computed from the viewport/content ratio on `scroll` + `ResizeObserver`. This can't be a pure-CSS `scrollbar-gutter` trick — the CSS expects an addressable thumb element to size/position, which only JS can drive."
  },
  {
    "type": "scroll-strip",
    "description": "`scroll-strip` — a real `overflow-x: auto` `track` whose `prev`/`next` controls appear only once the content stops fitting, then disable at each end. `params.step` is the fraction of the visible width moved per press (default 0.8). Not `carousel`, which translates a track of full-width slides one at a time and marks the off-screen ones `inert` — here every item is meant to be visible at once and the scroll position is continuous, so there is no slide index to speak of. Not `scroll-area` either: that one paints a decorative thumb for a scrollbar it hid, whereas this hides the scrollbar precisely because the buttons replace it. `Tabs` carries the same wiring itself (see `scroll-strip-core`) rather than nesting one of these inside its list. The controls ship in the static markup already `hidden`, so a no-JS render shows a plain scroller rather than two dead buttons."
  },
  {
    "type": "overflow-list",
    "description": "`overflow-list` — real `item`s reparent into a hidden `panel` (behind a `trigger` showing \"+N\") once they no longer fit their row, then reparent back once they do — the SAME elements move, so their event listeners and content survive (no clone-and-lose-interactivity). `params.maxVisible` forces a fixed count instead of measuring (used when real layout isn't available — e.g. under jsdom, or a host that wants deterministic wrapping)."
  },
  {
    "type": "dropzone",
    "description": "`dropzone` — a drag-counted drop target (dragleave fires on every child hover, so a depth counter is required, not optional) plus a hidden file `input`. `params.accept`/`params.maxSize` filter; matched files are announced via a `sui:file` CustomEvent per file. When a `list` part is present (FileUpload, not bare Dropzone), each accepted file also gets a managed row appended there (thumbnail for images, name, remove button) — this is the ONE place a behavior owns real create-your-own-markup state, same precedent as `carousel` building its own track/dots."
  },
  {
    "type": "slider",
    "description": "`slider` — one or two `thumb`s on a `track`. Base UI's `.slider-track`/ `.slider-indicator`/`.slider-thumb` are three addressable nodes whose position Base UI sets via inline styles from pointer math — a bare `<input type=range>` has no such nodes, so this rebuilds that geometry directly (pointer drag + full keyboard), same runtime-`.style` precedent as `carousel`. Two thumbs = a range slider (`--slider-range-start/-end` custom props for the fill bar CSS to read)."
  },
  {
    "type": "switch",
    "description": "`switch` — the root itself is a `role=switch` element (no parts). Silica's `.switch` CSS keys off `[data-checked]` (Base UI's synthetic attribute in React, ported verbatim here) rather than `:checked`, since the visual pill needs a real element to key selectors off, unlike `.toggle` which stays a plain native checkbox. A hidden `<input type=\"checkbox\">` inside carries the value for form submission, matching Base UI's own hidden-input pattern."
  },
  {
    "type": "rating",
    "description": "`rating` — a `role=radio` row of `item` stars. Not Base UI-backed in React either (plain JSX-set `data-filled`), so this ports near 1:1: click sets the value, hover previews it, and a hidden `<input>` (if present) carries the value for form submission."
  },
  {
    "type": "theme-toggle",
    "description": "`theme-toggle` — the root itself is the trigger (a button): each click cycles through `params.themes` (default `[\"light\", \"dark\"]`), wrapping around. Thin wiring over the existing `setTheme`/`getTheme` primitives — there was no new state-machine to build here, just a registration."
  },
  {
    "type": "phone-input",
    "description": "`phone-input` — a `country` `<select>` (options carry `data-dial`) joined with a digits `input`; a hidden input carries the combined `+{dial}{digits}` value for form submission. No React-only mechanism was involved in the original — the country list is plain static data, ported verbatim into the `PhoneInput` macro's expansion (see `component.ts`)."
  },
  {
    "type": "reveal",
    "description": "`reveal` — the ON SCROLL trigger for @wizeworks/silicaui's assignable entrance animations. Sets `data-sui-inview` on the root once it enters the viewport, which a `.sui-reveal-*` preset class (packages/silicaui/src/components/ animations.js) reads to transition from its hidden to visible state. `params.once` (default `true`) stops observing after the first reveal; `params.threshold` is passed straight to `IntersectionObserver`. Suppressed (reveals immediately, no observer) in preview, under reduced motion, or without `IntersectionObserver` — same guard shape as `counter`."
  },
  {
    "type": "countdown",
    "description": "`countdown` — a live days/hours/minutes/seconds display that ticks to a target time and stops at zero, announcing `sui:complete` once. This is NOT `counter`, which was checked first: `counter` tweens text from 0 to a target once when it scrolls into view. A one-shot animation and a recurring clock share a shape but not a behavior — different trigger, different cadence, different stopping condition, and time formatting rather than a single number. Reuse would have meant a `counter` that ignores most of its own parameters. `params.to` is an epoch-ms timestamp. Each `value` part carries `data-unit=\"days|hours|minutes|seconds\"` and only the units actually authored are written, so the markup decides which units show — the handler never invents or removes DOM. The authored markup already contains correct values for its render moment, so a page that never hydrates still shows a sensible (if frozen) countdown rather than empty boxes."
  },
  {
    "type": "tag-input",
    "description": "`tag-input` — a chip-based multi-value text field. Type and press Enter (or comma) to add; Backspace on an empty field removes the last chip; each chip's `close` part removes that one. Nothing existing modelled this. `selection-list` and `toggle-group` both choose among items that ALREADY EXIST in the markup; this one creates them from typed text, which is a different contract, not a parameter. New chips are cloned from a `template` part rather than built in JS. That keeps every class name in the authored markup, so the runtime stays correct under a `SilicaProvider` prefix — a handler that constructed `<span class=\"tag-input-chip\">` itself would silently emit unprefixed classes and render unstyled in exactly the apps that opted into a prefix. The value travels on a real `input[type=hidden]`, so the field submits with a normal form post and the `form` behavior needs no special case. Chips are comma-joined, matching what the React component posts."
  },
  {
    "type": "color-picker",
    "description": "`color-picker` — the OKLCH-native color editor, in vanilla. This is the real picker, not a degraded stand-in. The obvious shortcut was to lower to `<input type=\"color\">`: it works without JS and posts a value. It was rejected because it is a DIFFERENT CONTROL — a native sRGB swatch dialog, not an OKLCH L/C/H editor — and shipping it under the same component name would misrepresent what a consumer gets. Silica's whole token system is OKLCH; a picker that can't express chroma past sRGB isn't the same tool. Each `track` part carries `data-channel` (`l` | `c` | `h`) and behaves as a real `role=\"slider\"`: pointer drag with capture, plus arrows / PageUp+Down / Home / End, matching the React component's step sizes exactly. The track gradients and swatch fill are painted HERE rather than emitted as inline styles, because the static output must stay free of `style` attributes (enforced by verify-csp). So an unhydrated page renders the picker's structure unpainted — correct degradation for an editor that cannot function without JS — while the hidden input still carries the value for a form post."
  }
]
