{
  "$schema_version": 1,
  "$canonical_source": "design-system/Swivel Component Library.html",
  "$notes": [
    "Machine-readable inventory of components in the Swivel component library.",
    "Each component lists the categories it belongs to, the anchor on the showcase page,",
    "and its documented variants / sizes / states. State and variant names match the",
    "CSS class hooks used in the showcase so a linter or codegen can map directly.",
    "SOURCE OF TRUTH ORDER: components.css defines what exists; a component's .d.ts defines its",
    "typed React API; this file indexes both. Variant, size and tone ids here are identical to the",
    "string-literal unions in the .d.ts, so no alias table is needed to go between them.",
    "Components with a 'react' block ship real code under components/<Name>/ (.jsx + .d.ts).",
    "Components without one are CSS class hooks only — style them from class_root.",
    "When you add a new component or variant, update this file in the same commit.",
    "CSS-only patterns are flagged with \"css_only\": true; entries with a \"react\" block ship a typed component in components/<Name>/.",
    "Every component carries a \"state_matrix\": state id → { trigger, selector, changes } so a linter or generator can resolve what each state actually changes. Shared rules live under \"state_conventions\".",
    "Usage rules (severity, detection, fix) live in design-system/adherence.rules.yaml.",
    "Start at selection_cheat_sheet if you know the intent but not the component. It covers the confusable pairs (Checkbox vs Switch, ButtonGroup vs SelectField, Badge vs Chip, Modal vs Drawer) and names what NOT to use in each case.",
    "The compiled bundle exposes only capitalised exports on window.<Namespace>. A lowercase export (a hook, a helper) is reachable through the ESM barrel index.js only — see Toast.react.window_access.",
    "Data-viz components implement design-system/charts.rules.yaml. Read that file before building any chart the components do not cover (heatmap, scatter, small multiples).",
    "Date and time components inherit Material Design 3 structure, interaction, and a11y wholesale and override colour only, through the --md-* bridge in tokens.css. design-system/Date and Time.html §01 is the policy.",
    "The button family — .btn, .btn-icon, .bg-btn, .bs-action — shares one colour ramp (--slate-100 / --slate-110 / --slate-120) and one label type contract (--font-body 500 at --fs-ui-caps). --slate-base is NOT part of it: it holds in light mode but shifts to #7B9BBF in dark, so any button using it diverges from its neighbours in dark mode only.",
    "Building a whole screen? Start from a template, not from components — design-system/templates.json start_here names the default (templates/app-shell) and the decision that picks another.",
    "Table columns have KINDS. components.json Table.cell_kinds is the registry: each kind names its header class, body class, content, and what to avoid. Read it before adding a column — it is why numeric headers take .cell-num while their values take .cell-metric.",
    "Before adding a class contract to components.css, grep for an existing one. A \"Beacon chat thread\" vocabulary was built twice — once as a showcase demo swept into the shared stylesheet, once as the real .as-* contract — and neither referenced the other."
  ],
  "components": [
    {
      "id": "button",
      "name": "Button",
      "category": "actions",
      "anchor": "Swivel Component Library.html#c-buttons",
      "class_root": ".btn",
      "react": {
        "module": "components/Button/Button.jsx",
        "types": "components/Button/Button.d.ts",
        "export": "Button",
        "variant_prop": "variant",
        "size_prop": "size",
        "default_variant": "primary",
        "default_size": "md"
      },
      "variants": [
        {
          "id": "primary",
          "class": "btn-primary",
          "purpose": "Primary action on a surface"
        },
        {
          "id": "secondary",
          "class": "btn-secondary",
          "purpose": "Secondary action paired with primary"
        },
        {
          "id": "tertiary",
          "class": "btn-tertiary",
          "purpose": "Low-emphasis text-only action"
        },
        {
          "id": "tonal",
          "class": "btn-tonal",
          "purpose": "Mid-emphasis filled action"
        },
        {
          "id": "accent",
          "class": "btn-accent",
          "purpose": "Switch-cyan call-to-action"
        },
        {
          "id": "danger",
          "class": "btn-danger",
          "purpose": "Destructive action"
        },
        {
          "id": "tonal-error",
          "class": "btn-tonal-error",
          "purpose": "Mid-emphasis filled destructive — distinct from danger (outlined); use for inline delete in a tonal context"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "class": "btn-sm",
          "height_var": "--control-h-sm",
          "height_px": 24
        },
        {
          "id": "md",
          "class": null,
          "height_var": "--control-h",
          "height_px": 34
        },
        {
          "id": "lg",
          "class": "btn-lg",
          "height_var": "--control-h-lg",
          "height_px": 40
        }
      ],
      "states": [
        "default",
        "hover",
        "pressed",
        "focused",
        "disabled"
      ],
      "supports_icon": true,
      "icon_positions": [
        "leading",
        "trailing"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".btn.btn-<variant>",
          "changes": {
            "height": "--control-h (34px)",
            "border-radius": "--radius-sm"
          }
        },
        "hover": {
          "trigger": ":hover",
          "selector": ".btn-primary:hover",
          "changes": {
            "background": "--slate-110 (primary)"
          },
          "siblings": {
            "btn-secondary/tertiary": "--graphite-10",
            "btn-tonal": "#CDDCEC",
            "btn-accent": "#C2FBFF",
            "btn-danger": "#B71C1C",
            "btn-tonal-error": "#FFD4D1"
          }
        },
        "pressed": {
          "trigger": ":active",
          "changes": {
            "transform": "scale(0.98)",
            "transition-duration": "80ms"
          }
        },
        "focused": {
          "trigger": ":focus-visible",
          "changes": {
            "box-shadow": "--focus-ring"
          }
        },
        "disabled": {
          "trigger": ":disabled | .is-disabled",
          "selector": ".btn:disabled, .btn.is-disabled",
          "changes": {
            "opacity": "0.4",
            "pointer-events": "none"
          }
        }
      }
    },
    {
      "id": "button_group",
      "name": "ButtonGroup",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-button-groups",
      "class_root": ".btn-group",
      "variants": [
        {
          "id": "text",
          "purpose": "Text labels only"
        },
        {
          "id": "icon-only",
          "class": "icon-only",
          "purpose": "Icon-only segmented control"
        },
        {
          "id": "icon-and-text",
          "purpose": "Icon + text per segment"
        }
      ],
      "selection_modes": [
        "single"
      ],
      "states": [
        "default",
        "hover",
        "selected",
        "selected-hover",
        "disabled"
      ],
      "css_only": false,
      "note": "Unselected segments sit in --slate-100 with a --surface-muted hover fill; the selected segment takes the --slate-bg fill and deepens fill + label together on hover. Segment glyphs may carry .is-success / .is-warning / .is-error / .is-neutral for status tones. Filtering and segmented single-selection only — never actions.",
      "react": {
        "module": "components/ButtonGroup/ButtonGroup.jsx",
        "types": "components/ButtonGroup/ButtonGroup.d.ts",
        "export": "ButtonGroup",
        "items_prop": "items",
        "value_prop": "value",
        "multi_prop": "multi"
      },
      "state_matrix": {
        "default": {
          "selector": ".bg-btn",
          "changes": {
            "background": "transparent",
            "color": "--slate-100",
            "border": "1px solid --graphite-20",
            "height": "34px"
          }
        },
        "hover": {
          "trigger": ":hover:not(.is-selected)",
          "selector": ".bg-btn:hover:not(.is-selected)",
          "changes": {
            "background": "--surface-muted",
            "color": "--slate-contrast"
          }
        },
        "selected": {
          "selector": ".bg-btn.is-selected",
          "changes": {
            "background": "--slate-bg",
            "color": "--slate-100",
            "border-color": "--slate-100",
            "z-index": "1"
          }
        },
        "selected-hover": {
          "trigger": ".is-selected:hover",
          "selector": ".bg-btn.is-selected:hover",
          "changes": {
            "background": "color-mix(in srgb, var(--slate-bg) 82%, var(--slate-100))",
            "color": "--slate-contrast"
          },
          "note": "Fill and label deepen together so the label keeps contrast in both themes — never change the fill alone."
        },
        "disabled": {
          "selector": ".btn-group.is-disabled",
          "changes": {
            "pointer-events": "none",
            "background": "--surface-muted",
            "color": "--graphite-40",
            "selected-background": "--graphite-20",
            "selected-color": "--graphite-50"
          }
        }
      }
    },
    {
      "id": "tooltip",
      "name": "Tooltip",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-tooltips",
      "class_root": ".tooltip",
      "note": "Fill contrasts page background (graphite/base). Typography: UI/Small (12px Avantt SemiBold). Two usage patterns: [data-tooltip] attribute for pure-CSS auto-tooltip; .tooltip class for JS-positioned overlays.",
      "variants": [
        {
          "id": "no-tail",
          "class": "tooltip",
          "purpose": "No directional context; centered above group or truncation label"
        },
        {
          "id": "top",
          "class": "tooltip tooltip-top",
          "purpose": "Tooltip above anchor, tail points down"
        },
        {
          "id": "bottom",
          "class": "tooltip tooltip-bottom",
          "purpose": "Tooltip below anchor, tail points up"
        },
        {
          "id": "right",
          "class": "tooltip tooltip-right",
          "purpose": "Tooltip right of anchor, tail points left"
        },
        {
          "id": "left",
          "class": "tooltip tooltip-left",
          "purpose": "Tooltip left of anchor, tail points right"
        }
      ],
      "auto_css": {
        "attribute": "data-tooltip",
        "placement_attribute": "data-tooltip-placement",
        "values": [
          "top",
          "bottom",
          "left",
          "right"
        ]
      },
      "copy_rule": "Verb phrase describing what clicking/hovering does. Never just the element name.",
      "accessibility": "Pair with aria-label or aria-describedby on the trigger so the tooltip copy is available to screen readers.",
      "states": [
        "hidden",
        "visible"
      ],
      "css_only": false,
      "react": {
        "module": "components/Tooltip/Tooltip.jsx",
        "types": "components/Tooltip/Tooltip.d.ts",
        "export": "Tooltip",
        "label_prop": "label"
      },
      "state_matrix": {
        "hidden": {
          "selector": "[data-tip]::after",
          "changes": {
            "opacity": "0",
            "pointer-events": "none"
          }
        },
        "visible": {
          "trigger": ":hover | :focus-visible",
          "changes": {
            "opacity": "1",
            "background": "--graphite-base",
            "color": "--surface-canvas",
            "font-size": "--fs-ui-small"
          }
        }
      }
    },
    {
      "id": "split_button",
      "name": "SplitButton",
      "category": "actions",
      "anchor": "Swivel Component Library.html#c-split-buttons",
      "class_root": ".btn-split",
      "react": {
        "module": "components/SplitButton/SplitButton.jsx",
        "types": "components/SplitButton/SplitButton.d.ts",
        "export": "SplitButton",
        "variant_prop": "variant",
        "default_variant": "primary"
      },
      "note": "Two-segment control: left fires the primary action, right opens a dropdown of related options. Use when 2-4 closely related actions share a surface and one is a clear default. Not for unrelated actions.",
      "variants": [
        {
          "id": "primary",
          "class": "btn-split-primary",
          "purpose": "Filled action segment — text only, or leading icon + text when the icon prop is set"
        },
        {
          "id": "compact",
          "class": "btn-split-primary btn-split-compact",
          "purpose": "Filled, icon-only action — space-premium contexts. Needs icon + label; label becomes the aria-label"
        },
        {
          "id": "ghost",
          "class": "btn-split-ghost",
          "purpose": "No fill or stroke; dropdown arrow hidden until hover"
        }
      ],
      "segments": [
        {
          "id": "action",
          "class": ".bs-action",
          "note": "Fires the primary action on click"
        },
        {
          "id": "trigger",
          "class": ".bs-trigger",
          "note": "Opens the dropdown menu. Must have data-tooltip describing the options available."
        }
      ],
      "tooltip": {
        "required_on": "bs-trigger",
        "required_on_compact_action": true,
        "copy_rule": "Describe the options available, e.g. 'More save options'"
      },
      "states": [
        "default",
        "hover",
        "open",
        "disabled"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".btn-split.btn-split-<variant>"
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "background": "--slate-110 (primary) | --graphite-10 (ghost)"
          }
        },
        "open": {
          "selector": ".btn-split.is-open",
          "changes": {
            "trigger-background": "--slate-120",
            "menu-display": "block",
            "chevron-opacity": "1"
          }
        },
        "disabled": {
          "trigger": ":disabled",
          "changes": {
            "opacity": "0.4",
            "pointer-events": "none"
          }
        }
      },
      "token_parity": {
        "rule": "A split button must be indistinguishable from the Button beside it. Fill, hover, and pressed all come from the same slate ramp as .btn-primary and the selected .bg-btn: --slate-100 / --slate-110 / --slate-120.",
        "not": "--slate-base. It resolves to the same hex as --slate-100 in light mode but shifts to #7B9BBF in dark, so a split button and a primary button diverged in dark mode only.",
        "type": "Label type is one contract across .btn, .bg-btn and .bs-action — --font-body 500 at --fs-ui-caps, 0.04em, uppercase. The ghost variant drops the caps treatment by design but still sizes from --fs-ui-caps, never a literal 13px.",
        "divider": "The primary variant's segment divider is color-mix(--white 25%, transparent), routed through the token rather than a raw rgba."
      }
    },
    {
      "id": "icon_button",
      "name": "IconButton",
      "react": {
        "module": "components/IconButton/IconButton.jsx",
        "types": "components/IconButton/IconButton.d.ts",
        "export": "IconButton",
        "variant_prop": "variant",
        "size_prop": "size",
        "default_variant": "secondary",
        "default_size": "md",
        "required_props": [
          "icon",
          "tooltip"
        ]
      },
      "category": "actions",
      "anchor": "Swivel Component Library.html#c-buttons",
      "class_root": ".btn-icon",
      "note": "Standalone square icon-only button. Shares variant/size tokens with Button — do not use ButtonGroup's .bg-btn.icon-only for standalone actions.",
      "variants": [
        {
          "id": "primary",
          "class": "btn-icon btn-primary",
          "purpose": "Primary action"
        },
        {
          "id": "secondary",
          "class": "btn-icon btn-secondary",
          "purpose": "Secondary action"
        },
        {
          "id": "tertiary",
          "class": "btn-icon btn-tertiary",
          "purpose": "Low-emphasis ghost"
        },
        {
          "id": "tonal",
          "class": "btn-icon btn-tonal",
          "purpose": "Mid-emphasis filled"
        },
        {
          "id": "danger",
          "class": "btn-icon btn-danger",
          "purpose": "Destructive"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "class": "btn-sm",
          "height_var": "--control-h-sm",
          "height_px": 24
        },
        {
          "id": "md",
          "class": null,
          "height_var": "--control-h",
          "height_px": 34
        },
        {
          "id": "lg",
          "class": "btn-lg",
          "height_var": "--control-h-lg",
          "height_px": 40
        }
      ],
      "states": [
        "default",
        "hover",
        "focused",
        "disabled"
      ],
      "tooltip": {
        "required": true,
        "attribute": "data-tooltip",
        "copy_rule": "Verb phrase describing what clicking does — e.g. 'Add rule', 'Delete campaign', 'Export CSV'. Never the icon name or a noun.",
        "note": "Every IconButton must carry data-tooltip. Omitting it is an accessibility failure — the tooltip is the only label for the action. A dashed red outline and console warning appear when the attribute is missing."
      },
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".btn-icon.btn-<variant>",
          "changes": {
            "aspect-ratio": "1 / 1",
            "icon-size": "18px"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "background": "--slate-110 (primary) | --graphite-10 (secondary, tertiary, tonal) | #B71C1C (danger)"
          }
        },
        "focused": {
          "trigger": ":focus-visible",
          "changes": {
            "box-shadow": "--focus-ring",
            "tooltip-opacity": "1"
          }
        },
        "disabled": {
          "trigger": ":disabled | .is-disabled",
          "changes": {
            "opacity": "0.4",
            "cursor": "not-allowed",
            "pointer-events": "none"
          }
        }
      },
      "token_parity": "Variant colours are the same contract as .btn — primary fills --slate-100, hovers --slate-110. Not --slate-base, which diverges in dark mode."
    },
    {
      "id": "text_field",
      "name": "TextField",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-text-fields",
      "class_root": ".field",
      "react": {
        "module": "components/TextField/TextField.jsx",
        "types": "components/TextField/TextField.d.ts",
        "export": "TextField",
        "variant_prop": "variant",
        "default_variant": "default",
        "controlled_props": {
          "value": "defaultValue"
        }
      },
      "note": "The icon adornment is not a variant — set the icon / iconRight props on either variant. Fields floor at min-width:240px. Wrap a field in .field-narrow to opt out of that floor (popover rows, packed toolbars); the modifier also clears the floor on .field-inset and .tag-input. An unlabelled field (a min/max pair under one group heading, a toolbar filter) takes ariaLabel — without a visible label or ariaLabel the input has no accessible name.",
      "variants": [
        {
          "id": "default",
          "class": null,
          "purpose": "Label stacked above the input"
        },
        {
          "id": "inset-label",
          "class": "field-inset",
          "purpose": "Compact field with the label notched into the top stroke"
        }
      ],
      "adornment_class": "field-input-icon",
      "states": [
        "default",
        "focused",
        "required",
        "error",
        "error-focused",
        "disabled"
      ],
      "adornments": [
        "leading-icon",
        "trailing-icon",
        "prefix",
        "suffix",
        {
          "id": "prefix",
          "class": "field-prefix",
          "prop": "prefix",
          "purpose": "Static text before the value — a currency symbol or unit. Shares the leading-icon slot and its 32px padding, so a field takes prefix or icon, not both"
        }
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".field-input | .field-inset",
          "changes": {
            "border": "1px solid --graphite-20",
            "background": "--surface-input",
            "height": "34px"
          }
        },
        "focused": {
          "trigger": ":focus | :focus-within",
          "selector": ".field-input:focus, .field-inset:focus-within",
          "changes": {
            "border-width": "2px",
            "border-color": "--switch-contrast",
            "box-shadow": "--focus-ring",
            "padding": "compensated 1px to hold layout"
          }
        },
        "required": {
          "selector": ".field-label .req",
          "changes": {
            "color": "--error",
            "content": "*"
          }
        },
        "error": {
          "selector": ".field.is-error, .field-inset.is-error",
          "changes": {
            "border-color": "--error",
            "label-color": "--error",
            "help-color": "--error"
          }
        },
        "error-focused": {
          "selector": ".field.is-error .field-input:focus",
          "changes": {
            "border-color": "--error",
            "box-shadow": "0 0 0 3px color-mix(in srgb, var(--error) 32%, transparent)"
          }
        },
        "disabled": {
          "selector": ".field-inset.is-disabled, .field-input:disabled",
          "changes": {
            "background": "--graphite-10",
            "border-color": "--graphite-20",
            "color": "--graphite-50"
          }
        }
      }
    },
    {
      "id": "textarea",
      "name": "Textarea",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-forms",
      "class_root": "textarea.field-input",
      "note": "Multi-line sibling of TextField — the same .field / .field-input / .field-help contract, so stroke, focus, and error treatment are shared. The one difference that matters: height is a row count, never --control-h. A textarea must not resolve to the 34px control height. Use it whenever the answer is prose (a note, a description, a pause reason) rather than a value.",
      "variants": [
        {
          "id": "default",
          "class": null,
          "purpose": "Label stacked above the field"
        },
        {
          "id": "inset-label",
          "class": "field-inset",
          "purpose": "Label notched into the top stroke, matching the inset TextField"
        }
      ],
      "slots": [
        {
          "id": "foot",
          "class": "field-foot",
          "purpose": "Row under the field holding help text and the counter — the counter is right-aligned"
        },
        {
          "id": "count",
          "class": "field-count",
          "purpose": "Mono, tabular-nums character counter; .is-over turns it --error"
        }
      ],
      "density": {
        "min_height": "76px (default) / 62px (inset)",
        "rows_default": 3,
        "resize": "vertical — removed when autosize is on, because JS owns the height"
      },
      "states": [
        "default",
        "focus",
        "error",
        "disabled",
        "readonly",
        "over-limit"
      ],
      "css_only": false,
      "react": {
        "module": "components/Textarea/Textarea.jsx",
        "types": "components/Textarea/Textarea.d.ts",
        "export": "Textarea",
        "controlled_props": [
          "value"
        ],
        "props": [
          "label",
          "required",
          "variant",
          "value",
          "defaultValue",
          "onChange",
          "placeholder",
          "rows",
          "autosize",
          "maxRows",
          "help",
          "error",
          "maxLength",
          "focused",
          "disabled",
          "readOnly",
          "id",
          "name",
          "textareaRef"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": "textarea.field-input",
          "changes": {
            "min-height": "76px",
            "height": "auto",
            "padding": "8px --space-lg",
            "line-height": "1.5",
            "resize": "vertical"
          },
          "note": "In the inset variant the wrapper drops its horizontal padding and the textarea carries it instead (11px 14px 7px) — the control fills the field box edge to edge while the text keeps the same 14px gutter. A large scrollable region inset by the wrapper reads as a box inside a box. The top padding also clears the notch label, which straddles the top border; a single-line inset field is centred in its row and needs neither."
        },
        "focus": {
          "trigger": ":focus",
          "changes": {
            "border-width": "2px",
            "border-color": "--switch-contrast",
            "box-shadow": "--focus-ring"
          },
          "note": "Inherited from .field-input — identical to TextField."
        },
        "error": {
          "trigger": ".field.is-error / error prop",
          "changes": {
            "border-color": "--error",
            "help-color": "--error"
          }
        },
        "disabled": {
          "trigger": ":disabled",
          "changes": {
            "color": "--graphite-50"
          }
        },
        "readonly": {
          "trigger": "[readonly]",
          "changes": {
            "note": "No visual change — the value stays selectable and copyable."
          }
        },
        "over-limit": {
          "trigger": ".field-count.is-over",
          "changes": {
            "color": "--error"
          },
          "note": "Only reachable when maxLength is absent from the DOM but enforced in state; maxLength normally blocks the overflow."
        }
      },
      "guidance": {
        "maxLength": "Only where the backend actually enforces a limit. A decorative counter invites the user to plan around a rule that does not exist.",
        "autosize": "For fields whose length is unpredictable and worth seeing at once (a change log, a note). Pair with maxRows so one long entry cannot push the save footer off screen."
      }
    },
    {
      "id": "date-field",
      "name": "DateField",
      "category": "inputs",
      "anchor": "Date and Time.html#date-input-field",
      "class_root": ".md-input",
      "note": "Date input with a calendar affordance, in both label treatments. A text input, not a masked control — the host owns parsing and validation. Pair with DatePicker on the trailing glyph (see docked_pairing).",
      "slots": [
        {
          "id": "label-stacked",
          "class": "field-label",
          "purpose": "Above the field in the default variant, identical to TextField"
        },
        {
          "id": "label-inset",
          "class": "label",
          "purpose": "On the top border in the inset variant — Avantt SemiBold at --fs-ui-small. This is .label, not .field-label, so the required marker carries its own rule (.md-input .label .req); the shared .field-label .req does not reach it."
        },
        {
          "id": "trailing",
          "class": "trailing icon-r",
          "purpose": "calendar_today glyph, 18px; a button with a focus ring only when onTrailingClick is passed"
        },
        {
          "id": "supporting",
          "class": "supporting / field-help",
          "purpose": "Line below the field — .supporting when inset, .field-help when stacked. --error when invalid"
        }
      ],
      "states": [
        "default",
        "hover",
        "focus",
        "error",
        "error-focus",
        "disabled"
      ],
      "css_only": false,
      "react": {
        "module": "components/DatePicker/DatePicker.jsx",
        "types": "components/DatePicker/DatePicker.d.ts",
        "export": "DateField",
        "controlled_props": [
          "value"
        ],
        "props": [
          "label",
          "variant",
          "required",
          "value",
          "onChange",
          "placeholder",
          "help",
          "error",
          "disabled",
          "focused",
          "onTrailingClick",
          "id",
          "name"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".md-input",
          "changes": {
            "height": "--control-h",
            "border": "1px solid --md-outline",
            "border-radius": "--radius-sm",
            "background": "--md-surface"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "border-color": "--md-on-surface"
          }
        },
        "focus": {
          "trigger": ":focus-within",
          "changes": {
            "border": "2px solid --switch-contrast",
            "padding": "0 13px",
            "box-shadow": "--focus-ring",
            "label-color": "--md-primary"
          },
          "note": "Padding drops 1px so the 2px stroke does not shift the text."
        },
        "error": {
          "trigger": ".is-error",
          "changes": {
            "border-color": "--error",
            "label-color": "--error",
            "supporting-color": "--error"
          }
        },
        "error-focus": {
          "trigger": ".is-error:focus-within",
          "changes": {
            "border": "2px solid --error",
            "box-shadow": "0 0 0 3px color-mix(--error 32%)"
          },
          "note": "Keeps the halo but in the error palette, so the field still reads invalid rather than merely selected."
        },
        "disabled": {
          "trigger": "[disabled]",
          "changes": {
            "note": "Inherits the input disabled colour."
          }
        }
      },
      "m3_policy": {
        "inherits_from": "Material Design 3",
        "inherited": [
          "component structure — modal and docked pickers, single and range, dial and numeric time",
          "interaction model — arrow-key navigation, type-to-jump, year/month dropdowns, drag range selection, dial drag",
          "accessibility — ARIA roles, focus rings, landmarks, RTL, locale-aware day-of-week order",
          "layout dimensions — header height, 40x40 cells single / 36x36 range, elevation, motion timings"
        ],
        "overridden": [
          "colour, through the --md-* bridge in tokens.css",
          "type — faces AND sizes both come from the Swivel scale; M3's Roboto recommendation is not used",
          "radius follows the Swivel scale — M3's 16px container becomes --radius-xl (12px)"
        ],
        "why": "Pickers are deceptively expensive to build well. M3 already solves keyboard nav, locale, range selection, year jumps, and a11y. Visually a picker reads as Swivel; structurally and behaviourally it reads as M3.",
        "canonical_source": "design-system/Date and Time.html"
      },
      "type_contract": {
        "faces": "Avantt (--font-display) for headlines, month labels, day-of-week, text buttons, and the time readout. Antarctica (--font-body) for day cells, the input value, eyebrows, and supporting lines. M3's Roboto recommendation does not apply.",
        "sizes": "Every size resolves from the Swivel type scale — no literal px. --fs-h3 headline, --fs-h4 range headline, --fs-ui-default month/dow-adjacent, --fs-ui-caps day cells and text buttons, --fs-ui-small eyebrows and supporting, --fs-ui-metric time readout, --fs-ui-small inset label.",
        "what_is_m3": "Only the layout dimensions are M3 — cell box 40x40 single / 36x36 range, header height, elevation, motion. Type and colour are both Swivel.",
        "note": "The time readout was 40px, which sits between --fs-h2 and --fs-ui-metric with no token behind it; it now uses --fs-ui-metric (32px), the same token as a KPI readout, which is what it is."
      },
      "docked_pairing": {
        "rule": "onTrailingClick opens a DatePicker docked under the field. Without it the calendar glyph is decorative and should not be shown as interactive.",
        "markup": "Wrap the field in a position: relative container and absolutely position the picker at top: calc(100% + 8px), z-index above the form.",
        "picker_props": "actions={false} — a docked picker commits on selection, so a Cancel/OK row would be a second, redundant commit. Pass defaultView so it opens on the current value's month.",
        "dismissal": "Outside click and Escape both close it. The field keeps ownership of the value."
      },
      "variants": [
        {
          "id": "default",
          "class": "field",
          "purpose": "Stacked label — the same .field contract as TextField, for a date that is one of an object's properties"
        },
        {
          "id": "inset-label",
          "class": "md-input",
          "purpose": "Label notched into the top stroke, at --control-h, for a control that changes what a table shows"
        }
      ],
      "variant_choice": {
        "question": "What does the date belong to?",
        "stacked": "A property of the object being edited — flight start, contract date, a field in a create or edit form. It sits among TextFields and SelectFields and must be indistinguishable from them.",
        "inset": "A control over what is displayed — a table toolbar filter, a date range above a list, anything in a dense row of controls at --control-h.",
        "why": "The two treatments are not interchangeable styling. A stacked label reads as a form field the user is filling in; an inset label reads as a control acting on the view."
      }
    },
    {
      "id": "date-picker",
      "name": "DatePicker",
      "category": "inputs",
      "anchor": "Date and Time.html#date-picker",
      "class_root": ".md-datepicker",
      "note": "Calendar for one date or a range. Single is 360px with 40x40 cells; range is 680px showing two months with a ribbon fill and 36x36 cells — an ad-ops flight is almost always read across a month boundary. Six week rows always render, so the grid height never jumps between months.",
      "variants": [
        {
          "id": "single",
          "class": "md-datepicker",
          "purpose": "One date. 360px, 40x40 cells"
        },
        {
          "id": "range",
          "class": "md-range",
          "purpose": "Two months side by side. 680px, 36x36 cells, ribbon fill between endpoints"
        },
        {
          "id": "range + presets",
          "class": "md-range has-presets",
          "purpose": "Range with a left rail of relative-range chips. The dialog becomes a two-column grid; the rail spans both month rows"
        }
      ],
      "slots": [
        {
          "id": "header",
          "class": "md-dp-header",
          "purpose": "--md-surface-container band holding the eyebrow and the headline date"
        },
        {
          "id": "eyebrow",
          "class": "md-dp-eyebrow",
          "purpose": "Caps line naming what is being picked"
        },
        {
          "id": "nav",
          "class": "md-dp-nav",
          "purpose": "Prev/next .md-icon-btn pair with the month label. In range mode the label spans both months (\"August – September 2026\") — never the first month alone, which the per-month .md-range-month-label already names."
        },
        {
          "id": "cell",
          "class": "md-dp-cell",
          "purpose": "Day button. .is-today outlines in --md-primary, .is-selected fills it, .is-muted dims adjacent months"
        },
        {
          "id": "ribbon",
          "class": "in-range",
          "purpose": "::before fill between endpoints; .range-start / .range-end round the ends. A 3-layer stack — ribbon, endpoint circle, then .md-day-num on top"
        },
        {
          "id": "actions",
          "class": "md-dp-actions",
          "purpose": ".md-text-btn pair; omit with actions={false} for a docked picker"
        },
        {
          "id": "inert-cell",
          "class": "is-inert",
          "purpose": "Range mode only — an adjacent-month cell. Disabled, aria-hidden, pointer-events none, and both pseudo-elements suppressed so no ribbon or endpoint paints. The same date is a real cell in the other grid."
        },
        {
          "id": "presets",
          "class": "md-range-presets",
          "purpose": "Optional left rail of relative ranges, shown only when `presets` is passed. Chips hug their text (align-items: flex-start) — a stretched chip reads as a list of buttons. Separated by a --md-outline border that reaches the full dialog height (align-self: stretch, since the grid is align-items: start for the content column). The rail takes grid-row: 1 / span 99 rather than 1 / -1 — the latter resolves against the explicit grid and collapses to row 1 — and every non-rail child is pinned to grid-column 2, or auto-placement drops the month nav and action row into the rail."
        },
        {
          "id": "preset",
          "class": "md-preset",
          "purpose": "One chip, hugging its label. --md-surface-container at rest; .is-active takes --switch-bg with a --md-primary border, the same tonal fill as the range ribbon, so the chip and the span it produced read as one thing rather than competing with the solid selected endpoints. .is-reset clears rather than selects, so it sits apart with a transparent fill and an outline."
        }
      ],
      "states": [
        "default",
        "hover",
        "today",
        "selected",
        "in-range",
        "outside-month",
        "keyboard",
        "preset-active"
      ],
      "css_only": false,
      "react": {
        "module": "components/DatePicker/DatePicker.jsx",
        "types": "components/DatePicker/DatePicker.d.ts",
        "export": "DatePicker",
        "controlled_props": [
          "value"
        ],
        "required_props": [],
        "props": [
          "mode",
          "value",
          "onChange",
          "onCancel",
          "onConfirm",
          "eyebrow",
          "defaultView",
          "actions",
          "confirmLabel",
          "cancelLabel",
          "presets",
          "today"
        ],
        "exports": [
          "DatePicker",
          "RANGE_PRESETS"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".md-dp-cell",
          "changes": {
            "width": "40px",
            "height": "40px",
            "border-radius": "50%",
            "font-weight": "500"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "background": "--md-state-hover"
          }
        },
        "today": {
          "trigger": ".is-today",
          "changes": {
            "border": "1px solid --md-primary",
            "color": "--md-primary"
          },
          "note": "Also carries aria-current=\"date\"."
        },
        "selected": {
          "trigger": ".is-selected",
          "changes": {
            "background": "--md-primary",
            "color": "--md-on-primary (= --graphite-contrast, near-black in both themes)"
          }
        },
        "in-range": {
          "trigger": ".in-range",
          "changes": {
            "::before": "--md-primary-container ribbon, inset 4px -4px"
          },
          "note": "Range cells isolate their own stacking context so the ribbon sits under the endpoint circle and both sit under the day number."
        },
        "outside-month": {
          "trigger": ".is-muted",
          "changes": {
            "color": "--md-on-surface-variant",
            "opacity": "0.4",
            "note": "In range mode these also take .is-inert: M3 does not paint range fill on adjacent-month cells, and a click there would set an endpoint from the wrong grid."
          }
        },
        "keyboard": {
          "trigger": "arrow keys",
          "changes": {
            "note": "Arrows move by day and week and pull the view to the new month; only the anchored cell is tabbable (roving tabindex)."
          }
        },
        "preset-active": {
          "trigger": ".md-preset.is-active",
          "selector": ".md-preset.is-active",
          "changes": {
            "background": "--switch-bg",
            "color": "--md-on-primary-container",
            "border-color": "--md-primary"
          },
          "note": "Tonal, matching the range ribbon rather than the solid selected endpoints. Derived by comparing the current selection to what each preset produces, not stored — so a preset stays lit after a reload or a programmatic set, and stops being lit the moment the user edits either end in the grid."
        }
      },
      "range_behaviour": "First click sets the anchor and fires { start, end: null }. The second closes the range and fires the ordered pair, flipping it if the user picked backwards.",
      "m3_policy": {
        "inherits_from": "Material Design 3",
        "inherited": [
          "component structure — modal and docked pickers, single and range, dial and numeric time",
          "interaction model — arrow-key navigation, type-to-jump, year/month dropdowns, drag range selection, dial drag",
          "accessibility — ARIA roles, focus rings, landmarks, RTL, locale-aware day-of-week order",
          "layout dimensions — header height, 40x40 cells single / 36x36 range, elevation, motion timings"
        ],
        "overridden": [
          "colour, through the --md-* bridge in tokens.css",
          "type — faces AND sizes both come from the Swivel scale; M3's Roboto recommendation is not used",
          "radius follows the Swivel scale — M3's 16px container becomes --radius-xl (12px)"
        ],
        "why": "Pickers are deceptively expensive to build well. M3 already solves keyboard nav, locale, range selection, year jumps, and a11y. Visually a picker reads as Swivel; structurally and behaviourally it reads as M3.",
        "canonical_source": "design-system/Date and Time.html"
      },
      "type_contract": {
        "faces": "Avantt (--font-display) for headlines, month labels, day-of-week, text buttons, and the time readout. Antarctica (--font-body) for day cells, the input value, eyebrows, and supporting lines. M3's Roboto recommendation does not apply.",
        "sizes": "Every size resolves from the Swivel type scale — no literal px. --fs-h3 headline, --fs-h4 range headline, --fs-ui-default month/dow-adjacent, --fs-ui-caps day cells and text buttons, --fs-ui-small eyebrows and supporting, --fs-ui-metric time readout, --fs-ui-small inset label.",
        "what_is_m3": "Only the layout dimensions are M3 — cell box 40x40 single / 36x36 range, header height, elevation, motion. Type and colour are both Swivel.",
        "note": "The time readout was 40px, which sits between --fs-h2 and --fs-ui-metric with no token behind it; it now uses --fs-ui-metric (32px), the same token as a KPI readout, which is what it is."
      },
      "contrast": {
        "rule": "--md-on-primary is --graphite-contrast, near-black in both themes.",
        "not": "--white. --md-primary maps to --switch-contrast, which is bright in BOTH themes (#0097A7 light, #00E5FF dark), so white ink puts a selected date at ~3.4:1 in light and ~1.4:1 in dark.",
        "measured": "13.7:1 light / 11.9:1 dark on a selected day cell; 6.0:1 / 11.9:1 on a selected dial mark.",
        "precedent": "The same fix .btn-accent uses — any control filled with switch cyan needs ink that does not invert."
      },
      "presets": {
        "default_set": [
          "Today",
          "Yesterday",
          "This Week",
          "Last Week",
          "Last 7 Days",
          "Last 14 Days",
          "Last 30 Days"
        ],
        "export": "RANGE_PRESETS — import to extend rather than replace: presets={[...RANGE_PRESETS, mine]}",
        "inclusive": "Day counts include today, so \"Last 7 Days\" spans 7 cells, not 8.",
        "today_prop": "presets compute from the `today` prop, defaulting to the real date — pass a fixed one for a deterministic specimen or test.",
        "when": "Turn presets on for any operational range picker. Leave them off only when the span is inherently absolute — a flight, a contract term.",
        "optional": "Off by default. The rail renders only when `presets` is passed — the plain range picker is unchanged."
      }
    },
    {
      "id": "time-picker",
      "name": "TimePicker",
      "category": "inputs",
      "anchor": "Date and Time.html#time-picker",
      "class_root": ".md-timepicker",
      "note": "M3 time picker in both spec'd modes. Dial is the default and is faster for round times (00, 15, 30, 45); input turns the hour and minute into text fields for sub-minute precision or a dense form. The M3 toggle sits on the action row and is preserved. 12-hour with an AM/PM stack; dial hours step 1, dial minutes step 5.",
      "slots": [
        {
          "id": "field",
          "class": "md-tp-num",
          "purpose": "40px display-font hour and minute buttons; .is-active tints with --md-primary-container"
        },
        {
          "id": "meridiem",
          "class": "md-tp-ampm",
          "purpose": "Stacked AM/PM pair, 56px wide"
        },
        {
          "id": "dial",
          "class": "md-tp-dial",
          "purpose": "256px dial with .md-tp-num-mark buttons and a --md-primary hand"
        },
        {
          "id": "mode-toggle",
          "class": "md-tp-mode",
          "purpose": "keyboard / schedule glyph on the action row, margin-right auto so it sits left of Cancel and OK — M3's placement"
        },
        {
          "id": "labels",
          "class": "md-tp-labels",
          "purpose": "\"Hour\" / \"Minute\" captions under the fields, input mode only"
        }
      ],
      "states": [
        "hour-active",
        "minute-active",
        "mark-selected",
        "mark-hover",
        "input-focus"
      ],
      "css_only": false,
      "react": {
        "module": "components/DatePicker/DatePicker.jsx",
        "types": "components/DatePicker/DatePicker.d.ts",
        "export": "TimePicker",
        "controlled_props": [
          "value",
          "mode"
        ],
        "props": [
          "value",
          "onChange",
          "onCancel",
          "onConfirm",
          "eyebrow",
          "mode",
          "defaultMode",
          "onModeChange",
          "actions"
        ]
      },
      "state_matrix": {
        "hour-active": {
          "trigger": ".md-tp-num.is-active",
          "changes": {
            "background": "--md-primary-container",
            "color": "--md-on-primary-container",
            "border-color": "--md-primary"
          }
        },
        "minute-active": {
          "trigger": ".md-tp-num.is-active",
          "changes": {
            "note": "Same treatment — only one field is active at a time."
          }
        },
        "mark-selected": {
          "trigger": ".md-tp-num-mark.is-selected",
          "changes": {
            "background": "--md-primary",
            "color": "--md-on-primary (= --graphite-contrast)"
          }
        },
        "mark-hover": {
          "trigger": "button.md-tp-num-mark:hover:not(.is-selected)",
          "changes": {
            "background": "--md-state-hover"
          }
        },
        "input-focus": {
          "trigger": ".md-tp-num.is-input:focus-within",
          "changes": {
            "border-color": "--md-primary",
            "box-shadow": "--focus-ring"
          }
        }
      },
      "m3_policy": {
        "inherits_from": "Material Design 3",
        "inherited": [
          "component structure — modal and docked pickers, single and range, dial and numeric time",
          "interaction model — arrow-key navigation, type-to-jump, year/month dropdowns, drag range selection, dial drag",
          "accessibility — ARIA roles, focus rings, landmarks, RTL, locale-aware day-of-week order",
          "layout dimensions — header height, 40x40 cells single / 36x36 range, elevation, motion timings"
        ],
        "overridden": [
          "colour, through the --md-* bridge in tokens.css",
          "type — faces AND sizes both come from the Swivel scale; M3's Roboto recommendation is not used",
          "radius follows the Swivel scale — M3's 16px container becomes --radius-xl (12px)"
        ],
        "why": "Pickers are deceptively expensive to build well. M3 already solves keyboard nav, locale, range selection, year jumps, and a11y. Visually a picker reads as Swivel; structurally and behaviourally it reads as M3.",
        "canonical_source": "design-system/Date and Time.html"
      },
      "type_contract": {
        "faces": "Avantt (--font-display) for headlines, month labels, day-of-week, text buttons, and the time readout. Antarctica (--font-body) for day cells, the input value, eyebrows, and supporting lines. M3's Roboto recommendation does not apply.",
        "sizes": "Every size resolves from the Swivel type scale — no literal px. --fs-h3 headline, --fs-h4 range headline, --fs-ui-default month/dow-adjacent, --fs-ui-caps day cells and text buttons, --fs-ui-small eyebrows and supporting, --fs-ui-metric time readout, --fs-ui-small inset label.",
        "what_is_m3": "Only the layout dimensions are M3 — cell box 40x40 single / 36x36 range, header height, elevation, motion. Type and colour are both Swivel.",
        "note": "The time readout was 40px, which sits between --fs-h2 and --fs-ui-metric with no token behind it; it now uses --fs-ui-metric (32px), the same token as a KPI readout, which is what it is."
      },
      "variants": [
        {
          "id": "dial",
          "class": null,
          "purpose": "Default — 256px dial. Tapping the hour or minute field chooses which value the dial drives"
        },
        {
          "id": "input",
          "class": "is-input",
          "purpose": "Typed entry. Same 72px field boxes so the toggle does not resize the card"
        }
      ],
      "typing_behaviour": "Keystrokes commit immediately when the partial value is already valid, but clamping happens on blur or Enter — so typing \"1\" on the way to \"11\" is never rewritten under the cursor. Non-digits are stripped and entry caps at two characters.",
      "contrast": {
        "rule": "--md-on-primary is --graphite-contrast, near-black in both themes.",
        "not": "--white. --md-primary maps to --switch-contrast, which is bright in BOTH themes (#0097A7 light, #00E5FF dark), so white ink puts a selected date at ~3.4:1 in light and ~1.4:1 in dark.",
        "measured": "13.7:1 light / 11.9:1 dark on a selected day cell; 6.0:1 / 11.9:1 on a selected dial mark.",
        "precedent": "The same fix .btn-accent uses — any control filled with switch cyan needs ink that does not invert."
      }
    },
    {
      "id": "select_field",
      "name": "SelectField",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-select-fields",
      "class_root": ".field",
      "react": {
        "module": "components/SelectField/SelectField.jsx",
        "types": "components/SelectField/SelectField.d.ts",
        "export": "SelectField",
        "variant_prop": "variant",
        "default_variant": "default",
        "controlled_props": {
          "value": "defaultValue",
          "open": "defaultOpen"
        },
        "composes": [
          "select_menu"
        ]
      },
      "note": "Field-shaped trigger that owns a SelectMenu popover. Closes on outside click, Escape, and on choice when not multi. With multi + chips it renders the .tag-input box, showing each selection as a dismissible chip rather than an 'N selected' summary — prefer that when the user needs to see and remove individual values. Multi-select shows dismissible chips (chips prop), not an \"N selected\" summary. Inside .field-inset the chips trigger draws no box of its own.",
      "variants": [
        {
          "id": "default",
          "class": null,
          "purpose": "Label stacked above the trigger"
        },
        {
          "id": "inset-label",
          "class": "field-inset",
          "purpose": "Label notched into the top stroke"
        }
      ],
      "states": [
        "default",
        "open",
        "selected",
        "error",
        "disabled"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".field-inset",
          "changes": {
            "trigger": "readonly input + arrow_drop_down glyph"
          }
        },
        "open": {
          "selector": ".field-inset.is-focused",
          "changes": {
            "border-width": "2px",
            "border-color": "--switch-contrast",
            "box-shadow": "--focus-ring",
            "menu-display": "block"
          }
        },
        "selected": {
          "changes": {
            "trigger": "single: option label · multi: dismissible chips (never an N-selected summary)"
          }
        },
        "error": {
          "selector": ".field-inset.is-error",
          "changes": {
            "border-color": "--error",
            "label-color": "--error"
          }
        },
        "disabled": {
          "selector": ".field-inset.is-disabled",
          "changes": {
            "background": "--graphite-10",
            "color": "--graphite-50"
          }
        }
      }
    },
    {
      "id": "select_menu",
      "name": "SelectMenu",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-select-menus",
      "class_root": ".select-menu",
      "react": {
        "module": "components/SelectMenu/SelectMenu.jsx",
        "types": "components/SelectMenu/SelectMenu.d.ts",
        "export": "SelectMenu",
        "controlled_props": {
          "value": "defaultValue"
        }
      },
      "note": "Popover dropdown — the surface SelectField opens, also usable on its own. Options are set by props, not variant classes: icons come from item.icon / item.iconUrl, sections from items with a header, search and multi from the search and multi props.",
      "variants": [
        {
          "id": "default",
          "purpose": "Standard option list"
        },
        {
          "id": "with-icons",
          "purpose": "Leading platform logo or glyph per option"
        },
        {
          "id": "with-search",
          "purpose": "Filterable list with inline search field"
        },
        {
          "id": "multi-select",
          "purpose": "Checkbox selection, multiple values"
        },
        {
          "id": "with-sections",
          "purpose": "Options grouped under section headers"
        }
      ],
      "states": [
        "closed",
        "open",
        "item-hover",
        "item-selected",
        "item-disabled",
        "loading",
        "empty"
      ],
      "css_only": false,
      "state_matrix": {
        "closed": {
          "changes": {
            "display": "none"
          }
        },
        "open": {
          "selector": ".select-menu",
          "changes": {
            "background": "--surface-base",
            "border": "1px solid --surface-stroke",
            "box-shadow": "--elev-2",
            "border-radius": "--radius-md"
          }
        },
        "item-hover": {
          "selector": ".sm-item:hover",
          "changes": {
            "background": "--graphite-10",
            "only-link-display": "inline-flex"
          }
        },
        "item-selected": {
          "selector": ".sm-item.is-selected",
          "changes": {
            "background": "--slate-bg",
            "check-border-color": "--slate-100"
          }
        },
        "item-disabled": {
          "selector": ".sm-item.is-disabled",
          "changes": {
            "color": "--graphite-40",
            "pointer-events": "none"
          }
        },
        "loading": {
          "changes": {
            "body": "replaced by a loading row"
          }
        },
        "empty": {
          "changes": {
            "body": "replaced by an empty row; offer onCreate when the query could become a value"
          }
        }
      },
      "slots": [
        {
          "id": "search",
          "class": "sm-search",
          "purpose": "Inline filter field pinned at the top of the list"
        },
        {
          "id": "section",
          "class": "sm-header",
          "purpose": "Group header above a run of options"
        },
        {
          "id": "check",
          "class": "sm-check",
          "purpose": "Multi-select box; .is-checked / .is-indeterminate. Same treatment as the standalone .checkbox"
        },
        {
          "id": "icon",
          "class": "sm-icon-l",
          "purpose": "Leading platform logo or Material glyph"
        },
        {
          "id": "only",
          "class": "sm-only",
          "purpose": "Hover-revealed pill on a multi-select row — replaces the selection with just that row"
        },
        {
          "id": "create",
          "class": "sm-create",
          "purpose": "\"+ value\" creator pinned at the bottom"
        }
      ],
      "only_affordance": {
        "class": ".sm-only",
        "use_in": "Value menus — a filter's Values list, a Format or Status picker, any list where one value is a legitimate end state.",
        "never_in": "The table toolbar's column picker.",
        "why": "\"Only\" replaces the whole selection with one row. For a filter value that is a normal end state. For columns it never is — a table showing one metric with no ID or name column is not a state anyone asked for — and the chip sits exactly where the pointer travels to reach the checkbox, making the most destructive action in the menu the easiest misclick.",
        "instead": "Show/Hide All for the bulk move, Reset to return to the default column set."
      }
    },
    {
      "id": "tag_input",
      "name": "TagInput",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-tag-input",
      "class_root": ".tag-input",
      "react": {
        "module": "components/TagInput/TagInput.jsx",
        "types": "components/TagInput/TagInput.d.ts",
        "export": "TagInput",
        "variant_prop": "variant",
        "default_variant": "default",
        "controlled_props": {
          "value": "defaultValue"
        }
      },
      "note": "Chips inside are the canonical .chip element. Enter and comma commit the draft; Backspace on an empty draft removes the last tag.",
      "variants": [
        {
          "id": "default",
          "class": "tag-input",
          "purpose": "Label stacked above the field"
        },
        {
          "id": "inset-label",
          "class": "field-inset tag-inset",
          "purpose": "Chips inside the notched-border field"
        }
      ],
      "states": [
        "default",
        "hover",
        "focused",
        "with-values",
        "error",
        "disabled"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".tag-input",
          "changes": {
            "border": "1px solid --graphite-20",
            "min-height": "34px"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "clear-icon-opacity": "1"
          }
        },
        "focused": {
          "trigger": ":focus-within",
          "changes": {
            "border-width": "2px",
            "border-color": "--switch-contrast",
            "box-shadow": "--focus-ring"
          }
        },
        "with-values": {
          "changes": {
            "chips": ".chip per value with .chip-dismiss",
            "inset-variant": ".field-inset .tag-input draws no box of its own"
          }
        },
        "error": {
          "selector": ".field.is-error .tag-input",
          "changes": {
            "border-color": "--error",
            "clear-icon-color": "--error"
          }
        },
        "disabled": {
          "selector": ".tag-input.is-disabled",
          "changes": {
            "background": "--graphite-10",
            "chip-background": "--graphite-20",
            "dismiss-display": "none"
          }
        }
      }
    },
    {
      "id": "checkbox",
      "name": "Checkbox",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-forms",
      "class_root": ".checkbox",
      "note": "A real <input type=\"checkbox\">, appearance:none, styled to match .sm-check so a menu row and a standalone box read identically. Table selection columns inherit the same rules via .table-card .cell-select input[type=\"checkbox\"] at 15px. For a setting that applies the moment it is flipped, use Switch. There is no Radio in this system — single-choice pickers are a ButtonGroup or a SelectField.",
      "variants": [
        {
          "id": "md",
          "class": null,
          "size_px": 18,
          "purpose": "Default — forms and standalone opt-ins"
        },
        {
          "id": "sm",
          "class": "checkbox-sm",
          "size_px": 15,
          "purpose": "Table selection columns and dense lists"
        }
      ],
      "slots": [
        {
          "id": "row",
          "class": "checkbox-row",
          "purpose": "Label wraps the input so the whole row is the hit target"
        },
        {
          "id": "help",
          "class": "cb-help",
          "purpose": "Secondary line under the label, inside .cb-text"
        }
      ],
      "states": [
        "default",
        "hover",
        "checked",
        "indeterminate",
        "focus",
        "disabled"
      ],
      "css_only": false,
      "react": {
        "module": "components/Checkbox/Checkbox.jsx",
        "types": "components/Checkbox/Checkbox.d.ts",
        "export": "Checkbox",
        "controlled_props": [
          "checked"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".checkbox",
          "changes": {
            "width": "18px",
            "height": "18px",
            "border": "2px solid --graphite-bg",
            "border-radius": "2px",
            "background": "transparent"
          }
        },
        "hover": {
          "trigger": ":hover:not(:disabled)",
          "changes": {
            "border-color": "--slate-100"
          }
        },
        "checked": {
          "trigger": ":checked",
          "changes": {
            "background-color": "--slate-100",
            "border-color": "--slate-100",
            "background-image": "inline SVG check, --white stroke"
          }
        },
        "indeterminate": {
          "trigger": ":indeterminate",
          "changes": {
            "background-color": "--slate-100",
            "border-color": "--slate-100",
            "background-image": "inline SVG dash, --white fill"
          },
          "note": "Set via the DOM property, not an attribute — the component syncs it on the ref."
        },
        "focus": {
          "trigger": ":focus-visible",
          "changes": {
            "outline": "none",
            "box-shadow": "--focus-ring"
          }
        },
        "disabled": {
          "trigger": ":disabled",
          "changes": {
            "opacity": "0.4",
            "cursor": "default",
            "row-color": "--graphite-30 (via .checkbox-row.is-disabled)"
          }
        }
      }
    },
    {
      "id": "chip",
      "name": "Chip",
      "category": "data",
      "anchor": "Swivel Component Library.html#c-chips",
      "class_root": ".chip",
      "react": {
        "module": "components/Chip/Chip.jsx",
        "types": "components/Chip/Chip.d.ts",
        "export": "Chip",
        "size_prop": "size",
        "default_size": "md"
      },
      "note": "Pill is the default shape; there is no round class. Style is set by boolean props, not a variant enum.",
      "variants": [
        {
          "id": "selected",
          "prop": "selected",
          "class": "is-selected",
          "purpose": "Filled selected state"
        },
        {
          "id": "outlined",
          "prop": "outlined",
          "class": "is-outlined",
          "purpose": "Outlined unselected style"
        },
        {
          "id": "accent",
          "prop": "accent",
          "class": "is-accent",
          "purpose": "Switch-accent fill"
        },
        {
          "id": "square",
          "prop": "square",
          "class": "chip-square",
          "purpose": "Square corners instead of pill"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "class": "chip-sm"
        },
        {
          "id": "md",
          "class": null
        },
        {
          "id": "lg",
          "class": "chip-lg"
        }
      ],
      "supports_icon": true,
      "supports_dismiss": true,
      "states": [
        "default",
        "selected",
        "dismiss-hover",
        "disabled"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".chip",
          "changes": {
            "background": "--surface-muted",
            "border-radius": "--radius-full"
          }
        },
        "selected": {
          "selector": ".chip.is-selected",
          "changes": {
            "background": "--graphite-90",
            "color": "--surface-base"
          }
        },
        "dismiss-hover": {
          "selector": ".chip .chip-dismiss:hover",
          "changes": {
            "color": "--graphite-90"
          }
        },
        "disabled": {
          "selector": ".chip.is-disabled",
          "changes": {
            "opacity": "0.4",
            "pointer-events": "none"
          }
        }
      }
    },
    {
      "id": "badge",
      "name": "Badge",
      "category": "data",
      "anchor": "Swivel Component Library.html#c-status-badges",
      "class_root": ".badge",
      "react": {
        "module": "components/Badge/Badge.jsx",
        "types": "components/Badge/Badge.d.ts",
        "export": "Badge",
        "tone_prop": "tone",
        "default_tone": "neutral"
      },
      "purpose": "A single prominent status callout — a header, a card, a summary line. NOT a repeated per-row status column: use the state cell kind (Table.cell_kinds) there instead.",
      "tones": [
        {
          "id": "success",
          "class": "badge-success",
          "token": "--success"
        },
        {
          "id": "warning",
          "class": "badge-warning",
          "token": "--warning"
        },
        {
          "id": "info",
          "class": "badge-info",
          "token": "--info"
        },
        {
          "id": "error",
          "class": "badge-error",
          "token": "--error"
        },
        {
          "id": "neutral",
          "class": "badge-neutral",
          "token": "--graphite-70"
        },
        {
          "id": "pending",
          "class": "badge-pending",
          "token": "--graphite-50"
        }
      ],
      "supports_dot": true,
      "supports_icon": true,
      "semantic_tokens": [
        "--success",
        "--warning",
        "--info",
        "--error"
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".badge.badge-<tone>",
          "changes": {
            "background": "--<tone>-bg",
            "color": "--<tone>-fg",
            "border-radius": "--radius-sm"
          }
        }
      },
      "states": [
        "default"
      ],
      "avoid": "One per table row. A column of Badges is a wall of pills that makes the rest of the row harder to read — see the status-column-is-an-icon rule and Table.cell_kinds.kind \"state\"."
    },
    {
      "id": "platform_icon",
      "name": "PlatformIcon",
      "category": "data",
      "anchor": "Swivel Component Library.html#c-platform-icons",
      "class_root": ".pf-tile",
      "note": "Third-party platform logos. Never recolor, restyle, or substitute. Always render on a neutral white plate. Assets in assets/partners/<platform_name>.",
      "sizes": [
        {
          "id": "symbol",
          "plate_px": 64,
          "img_max_px": 40
        },
        {
          "id": "lockup",
          "plate_px": 116,
          "img_max_px": 92
        }
      ],
      "platforms": [
        "GAM",
        "DCM",
        "innovid",
        "operative",
        "springserve",
        "magnite",
        "freewheel",
        "salesforce",
        "publica",
        "slack"
      ],
      "missing_assets": [],
      "css_only": false,
      "react": {
        "module": "components/PlatformIcon/PlatformIcon.jsx",
        "types": "components/PlatformIcon/PlatformIcon.d.ts",
        "export": "PlatformIcon",
        "size_prop": "size",
        "default_size": "symbol"
      },
      "state_matrix": {
        "default": {
          "selector": ".pf-plate",
          "changes": {
            "background": "white plate",
            "rule": "never recolor or restyle a partner mark"
          }
        }
      },
      "states": [
        "default"
      ]
    },
    {
      "id": "chat_sticker",
      "name": "ChatSticker",
      "category": "brand",
      "anchor": "Swivel Component Library.html#c-brand-stickers",
      "note": "Animated brand GIF for use as a standalone chat message. Never embed inside a text bubble. Transparent background — no plate or shadow. The class sits on the <img> itself, not a wrapper — target it as img.chat-sticker. basePath takes a prefix like \"../..\" and the component adds the separator. Its chat context is the .as-* agent workspace vocabulary — .as-working uses size=\"inline\" (32px) — a real ChatSticker size, not a caller-side override — sized to sit beside the working-state text without crowding it. A separate .beacon-* demo block used to exist in components.css and was removed: there is one chat contract, not two.",
      "sizes": [
        {
          "id": "reaction",
          "px": 48,
          "use": "Reaction-style use"
        },
        {
          "id": "thread",
          "px": 80,
          "use": "Standard thread message"
        },
        {
          "id": "emphasis",
          "px": 128,
          "use": "Emphasis moments only"
        }
      ],
      "assets": [
        "assets/brand/keep_swiveling.gif"
      ],
      "reduced_motion": "Substitute first frame as static <img> when prefers-reduced-motion: reduce",
      "css_only": false,
      "react": {
        "module": "components/ChatSticker/ChatSticker.jsx",
        "types": "components/ChatSticker/ChatSticker.d.ts",
        "export": "ChatSticker",
        "size_prop": "size",
        "default_size": "thread"
      },
      "class_root": ".chat-sticker",
      "variants": [
        {
          "id": "reaction",
          "class": "chat-sticker-reaction"
        },
        {
          "id": "thread",
          "class": "chat-sticker-thread"
        },
        {
          "id": "emphasis",
          "class": "chat-sticker-emphasis"
        },
        {
          "id": "inline",
          "class": "chat-sticker-inline",
          "purpose": "32px — beside a line of running text (a working-state label, a footer note) where reaction's 48px would crowd the text"
        }
      ],
      "state_matrix": {
        "default": {
          "selector": ".chat-sticker",
          "changes": {
            "background": "none",
            "box-shadow": "none"
          }
        },
        "reduced-motion": {
          "trigger": "@media (prefers-reduced-motion: reduce)",
          "changes": {
            "src": "pass staticSrc (first frame)"
          }
        }
      },
      "states": [
        "default",
        "reduced-motion"
      ]
    },
    {
      "id": "switch",
      "name": "Switch",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-controls",
      "class_root": ".switch",
      "note": "Binary on/off for a single setting (dark mode, auto-pause, notifications). Not for choosing between options — that is a ButtonGroup. Track is .switch-track; the label sits beside it inside .switch. Add role=\"switch\" and aria-checked when the element is interactive.",
      "variants": [
        {
          "id": "default",
          "class": null,
          "purpose": "Graphite fill when on"
        },
        {
          "id": "accent",
          "class": "accent",
          "purpose": "Switch-cyan fill when on — for the one setting that matters most on a surface"
        }
      ],
      "states": [
        "off",
        "on",
        "disabled"
      ],
      "state_class": "is-on",
      "css_only": false,
      "react": {
        "module": "components/Switch/Switch.jsx",
        "types": "components/Switch/Switch.d.ts",
        "export": "Switch",
        "value_prop": "checked"
      },
      "state_matrix": {
        "off": {
          "selector": ".switch",
          "changes": {
            "track-background": "--graphite-30",
            "knob-left": "2px"
          }
        },
        "on": {
          "selector": ".switch.is-on",
          "changes": {
            "track-background": "--graphite-90 (--switch-100 with .accent)",
            "knob-left": "16px"
          }
        },
        "disabled": {
          "trigger": ":disabled",
          "changes": {
            "opacity": "0.4",
            "pointer-events": "none"
          }
        }
      }
    },
    {
      "id": "theme_switch",
      "name": "ThemeSwitch",
      "category": "inputs",
      "anchor": "Swivel Component Library.html#c-controls",
      "class_root": ".theme-switch",
      "note": "THE canonical light/dark control. Sun icon, track, moon icon, no text label — the icon on the active side tints slate, the other stays muted. Drive it from aria-checked (true = dark), never from a button group. Track is .ts-track, icons are .ts-ico.ts-sun / .ts-ico.ts-moon. Add .is-fixed to pin it top-right on a specimen page. Persist the choice in localStorage under swivel-theme and apply it before first paint; first visit defaults to dark.",
      "variants": [
        {
          "id": "inline",
          "class": null,
          "purpose": "Sits in a header or sidebar alongside other controls"
        },
        {
          "id": "fixed",
          "class": "is-fixed",
          "purpose": "Pinned top-right on a showcase or specimen page"
        }
      ],
      "states": [
        "light",
        "dark",
        "focus-visible"
      ],
      "state_attr": "aria-checked",
      "css_only": false,
      "react": {
        "module": "components/ThemeSwitch/ThemeSwitch.jsx",
        "types": "components/ThemeSwitch/ThemeSwitch.d.ts",
        "export": "ThemeSwitch",
        "value_prop": "theme",
        "default_value": "dark"
      },
      "state_matrix": {
        "light": {
          "selector": ".theme-switch[aria-checked=\"false\"]",
          "changes": {
            "sun-color": "--slate-base",
            "moon-color": "--graphite-40",
            "knob-left": "-2px",
            "document-attr": "data-theme=\"light\""
          }
        },
        "dark": {
          "selector": ".theme-switch[aria-checked=\"true\"]",
          "changes": {
            "moon-color": "--slate-base",
            "sun-color": "--graphite-40",
            "knob-left": "18px",
            "document-attr": "data-theme=\"dark\" (or absent — dark is the default)"
          }
        },
        "focus-visible": {
          "trigger": ":focus-visible",
          "changes": {
            "track-box-shadow": "--focus-ring",
            "outline": "none"
          }
        }
      }
    },
    {
      "id": "activity-panel",
      "name": "ActivityPanel",
      "category": "feedback",
      "anchor": "components/ActivityPanel/activitypanel.html",
      "class_root": ".activity",
      "note": "A reverse-chronological stream of what users and agents did, each row expandable into the record it wrote. Use for any audit, history, or activity surface. Not a Table: a table cannot carry a nested before/after diff, and it would offer sorting and selection that an audit log does not have. Not a bare timeline either — the point is that each row opens.",
      "slots": [
        {
          "id": "day",
          "class": "act-day",
          "purpose": "Day divider as a chip, left-aligned — a full-width rule across the stream reads as a section break rather than a date change"
        },
        {
          "id": "event",
          "class": "act-event",
          "purpose": "One event card; .act-head is its 64px time / 36px avatar / body / toggle grid"
        },
        {
          "id": "time",
          "class": "act-time",
          "purpose": "Time of day only, tabular-nums, right-aligned. The day belongs to the group"
        },
        {
          "id": "avatar",
          "class": "act-avatar",
          "purpose": "Carries the ENTITY glyph in --slate-base on --slate-bg — account, creative, media buy, proposal, automation, script."
        },
        {
          "id": "badge",
          "class": "act-badge",
          "purpose": "Carries the ACTION, as a 17px badge on the avatar with an outset --surface-base ring. Tonal, using the same --bg / --fg pairing as Badge and Alert: .is-create success, .is-update warning, .is-sync --switch-bg with --switch-contrast ink (the accent already means \"the system did this\"; there is no --switch-fg), .is-delete error, .is-neutral --graphite-10 with --graphite-90. At 17px a saturated fill is a bright dot that pulls the eye off the sentence, and the tonal pair inverts correctly per theme on its own. Two facts in one mark, so the sentence need not label either."
        },
        {
          "id": "line",
          "class": "act-line",
          "purpose": "The sentence: actor, verb, entity type, ID chip, then the object name. .act-actor and .act-object take --graphite-90 600 because they are the two things scanned for; the verb and entity type stay --graphite-bg. The .id-chip sits BEFORE the object name so the ID reads as a prefix to it rather than trailing off the end of a long name."
        },
        {
          "id": "meta",
          "class": "act-meta",
          "purpose": "Context values joined by .am-sep middots — principal, account, environment, token. Prefixed with a smart_toy glyph when the event is agent-initiated"
        },
        {
          "id": "detail",
          "class": "act-detail",
          "purpose": "Field/Value or Field/Before/After table. tr.is-nested tints for depth; .ad-unset renders an absent value as italic \"Not Set\"; a row with no value at all is a group heading"
        },
        {
          "id": "rail",
          "class": "filter-rail",
          "purpose": "The stream's filters, stacked in a card beside it — .fr-head holds the .fr-title and a right-aligned clear-filters IconButton (it acts on the whole rail, so it belongs to the rail rather than the toolbar over the content), then .fr-group with .fr-legend and an .fr-row per option. Each row reveals an .fr-only chip on hover or chip focus that narrows the facet to that one value; in a list of seven, the alternative is unchecking six. The row is a <label>, so the chip must stop the click from reaching the checkbox. A date-range trigger takes an inset label via .range-field > .rt-inset and puts its date_range glyph in the trailing position, matching DateField's calendar affordance. Grouped checkbox lists, not a bar: an activity feed filters on several axes at once."
        }
      ],
      "states": [
        "collapsed",
        "expanded",
        "no-detail",
        "empty"
      ],
      "css_only": false,
      "react": {
        "module": "components/ActivityPanel/ActivityPanel.jsx",
        "types": "components/ActivityPanel/ActivityPanel.d.ts",
        "export": "ActivityPanel",
        "exports": [
          "ActivityPanel",
          "ActivityEvent"
        ],
        "required_props": [
          "groups"
        ],
        "controlled_props": [
          "expandedIds"
        ],
        "props": [
          "groups",
          "expandedIds",
          "onToggle",
          "emptyLabel"
        ]
      },
      "state_matrix": {
        "collapsed": {
          "selector": ".act-event",
          "changes": {
            "border": "1px solid --surface-stroke",
            "border-radius": "--radius-md",
            "background": "--surface-base"
          }
        },
        "expanded": {
          "trigger": ".act-toggle[aria-expanded=\"true\"]",
          "changes": {
            "chevron": "keyboard_arrow_up",
            "detail": ".act-detail appears with a --surface-stroke top border"
          }
        },
        "no-detail": {
          "trigger": "event.detail omitted",
          "changes": {
            "note": "The chevron is not rendered — an expander that opens nothing is a broken promise. A spacer keeps the grid aligned."
          }
        },
        "empty": {
          "trigger": "every group empty",
          "changes": {
            "note": "Renders an EmptyState (.state-panel) with a history glyph rather than an empty stream."
          }
        }
      },
      "detail_modes": {
        "value": "Field / Value — for a record that was created or read.",
        "diff": "Field / Before / After — for one that was changed. Use diff whenever a previous value exists: \"what it was\" is the question an operator opens an audit row to answer.",
        "unset": "null or empty string renders as an italic muted \"Not Set\" — absent data, not the literal text.",
        "nesting": "depth indents 16px per level and tints the row; a row with label only, no value, is a group heading for the rows beneath it."
      }
    },
    {
      "id": "alert",
      "name": "Alert",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-alerts",
      "class_root": ".alert",
      "react": {
        "module": "components/Alert/Alert.jsx",
        "types": "components/Alert/Alert.d.ts",
        "export": "Alert",
        "variant_prop": "variant",
        "tone_prop": "tone",
        "default_variant": "filled",
        "default_tone": "info"
      },
      "variants": [
        {
          "id": "filled",
          "class": "alert-filled",
          "purpose": "High-emphasis system message"
        },
        {
          "id": "outlined",
          "class": "alert-outlined",
          "purpose": "Inline reassurance"
        }
      ],
      "tones": [
        "info",
        "success",
        "warning",
        "error"
      ],
      "supports_action": true,
      "supports_dismiss": true,
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".alert.alert-<tone>",
          "changes": {
            "background": "--<tone>-bg",
            "color": "--<tone>-fg"
          }
        },
        "dismiss-hover": {
          "selector": ".alert-dismiss:hover",
          "changes": {
            "opacity": "1",
            "background": "rgba(0,0,0,0.06)"
          }
        }
      },
      "states": [
        "default",
        "dismiss-hover"
      ]
    },
    {
      "id": "toast",
      "name": "Toast",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-alerts",
      "class_root": ".toast-region",
      "note": "A transient receipt for an action the user already took — \"Plan saved\", \"3 rows paused\". Reuses the .alert body wholesale (.alert-icon, .alert-body, .alert-title, .alert-dismiss) and adds only the fixed stack, --elev-3, and enter/exit motion. Anything the user must READ, act on, or refer back to is an inline Alert; a destructive confirmation is a Modal.",
      "variants": [
        {
          "id": "info",
          "class": "alert-info",
          "purpose": "Neutral receipt"
        },
        {
          "id": "success",
          "class": "alert-success",
          "purpose": "The action completed"
        },
        {
          "id": "warning",
          "class": "alert-warning",
          "purpose": "It completed with caveats"
        },
        {
          "id": "error",
          "class": "alert-error",
          "purpose": "It failed — pin it with duration 0"
        }
      ],
      "slots": [
        {
          "id": "region",
          "class": "toast-region",
          "purpose": "Fixed stack; pos-bottom-right (default), pos-bottom-left, pos-top-right, pos-top-center. role=\"region\" aria-live=\"polite\""
        },
        {
          "id": "toast",
          "class": "toast",
          "purpose": "One .alert plus elevation and motion; role=\"status\""
        },
        {
          "id": "action",
          "class": "toast-action",
          "purpose": "A single follow-up, usually Undo — inside the body, never replacing dismiss"
        }
      ],
      "states": [
        "entering",
        "resting",
        "leaving",
        "pinned"
      ],
      "css_only": false,
      "react": {
        "module": "components/Toast/Toast.jsx",
        "types": "components/Toast/Toast.d.ts",
        "export": "Toast",
        "hook": "useToasts (ESM only) / Toasts.use (either)",
        "props": [
          "toasts",
          "onDismiss",
          "position"
        ],
        "exports": [
          "Toast",
          "ToastRegion",
          "Toasts",
          "useToasts"
        ],
        "note": "ToastRegion renders the stack from a useToasts queue. Toast is a single item, exported for a host that owns its own queue.",
        "window_access": "The compiler puts only capitalised exports on window.<Namespace>, so window.<NS>.useToasts is undefined. Reading the compiled bundle, take the hook off Toasts.use(). Importing the package, either name works."
      },
      "state_matrix": {
        "entering": {
          "trigger": "mount",
          "selector": ".toast",
          "changes": {
            "animation": "toast-in --motion-base cubic-bezier(0.2,0,0,1)",
            "from": "opacity 0, translateY(8px) scale(0.98)"
          }
        },
        "resting": {
          "selector": ".toast",
          "changes": {
            "box-shadow": "--elev-3",
            "min-width": "260px",
            "pointer-events": "auto"
          },
          "note": "The region itself is pointer-events: none so it never blocks the page beneath."
        },
        "leaving": {
          "trigger": ".is-leaving",
          "changes": {
            "animation": "toast-out --motion-fast ease-in"
          }
        },
        "pinned": {
          "trigger": "duration: 0",
          "changes": {
            "note": "No timer — the toast stays until dismissed. Use for errors the user needs to see."
          }
        },
        "reduced_motion": {
          "trigger": "@media (prefers-reduced-motion: reduce)",
          "changes": {
            "animation-duration": "1ms"
          }
        }
      },
      "guidance": {
        "duration": "4000ms default. 0 pins it — errors only.",
        "never": "Never put the only path to finishing a task in a toast; it disappears. Never stack more than three at once. Never use a toast for validation — that belongs beside the field.",
        "position": "One region per app, bottom-right by default. Move it to top-center only when the bottom-right corner is occupied by something persistent."
      }
    },
    {
      "id": "id-chip",
      "name": "IdChip",
      "category": "navigation",
      "anchor": "components/IdChip/idchip.html",
      "class_root": ".id-chip",
      "note": "Copyable object-ID pill, promoted out of Breadcrumb/Table so every consumer shares one contract: mono-ish body type, --graphite-10 fill, --graphite-90 text, pill radius, 22px, white-space: nowrap and flex: 0 0 auto (real IDs are hyphenated and the pill cannot grow). Used in Breadcrumb's object segment, the leading cell of any object table, ActivityPanel entries, and Beacon focus-object headers.",
      "states": ["default", "hover", "focus-visible", "copied", "manual"],
      "state_matrix": [
        { "state": "default", "resolution": "button.id-chip — --graphite-10 fill, --graphite-90 text." },
        { "state": "hover", "selector": "button.id-chip:hover", "resolution": "background: --graphite-20; border-color: --graphite-30." },
        { "state": "focus-visible", "selector": "button.id-chip:focus-visible", "resolution": "box-shadow: --focus-ring." },
        { "state": "copied", "trigger": ".is-copied (1.6s after click)", "resolution": "background: --success-bg; border-color: --success; color: --success-fg; data-tip reads \"Copied\"." },
        { "state": "manual", "trigger": ".is-manual — Clipboard API unavailable or refused", "resolution": "background: --warning-bg; border-color: --warning; color: --warning-fg; data-tip reads \"Press ⌘C\"/\"Press Ctrl+C\". The chip selects its own text so the keyboard copy works." }
      ],
      "a11y": "Real <button>, aria-label=\"Copy ID {id}\".",
      "code": { "jsx": "components/IdChip/IdChip.jsx", "types": "components/IdChip/IdChip.d.ts" }
    },
    {
      "id": "breadcrumb",
      "name": "Breadcrumb",
      "category": "navigation",
      "anchor": "Swivel Component Library.html#c-navigation",
      "class_root": ".breadcrumb",
      "note": "A small trail (--fs-ui-small, body font) sitting ABOVE the page's own <h1> — it never replaces the heading. Parents in --graphite-bg, the current segment in --graphite-90 at weight 600. On an object page the object's ID always appears as a copyable IdChip (its own component — see \"id-chip\" below). The separator is always \"/\" and the last item is never a link (aria-current=\"page\"). Required on every detail page: it is how an operator returns to the list they arrived from. Replaces the showcase-only .pp-crumb scaffolding, which rendered the trail at title scale — that treatment is retired.",
      "slots": [
        {
          "id": "link",
          "class": "bc-link",
          "purpose": "Ancestor segment; a bare <a> inside .breadcrumb gets the same treatment"
        },
        {
          "id": "current",
          "class": "bc-current",
          "purpose": "Last segment — never a link, carries aria-current=\"page\""
        },
        {
          "id": "separator",
          "class": "bc-sep",
          "purpose": "\"/\" in --graphite-20, aria-hidden"
        },
        {
          "id": "id-chip",
          "class": "id-chip",
          "purpose": "Renders the shared IdChip component — see the standalone \"id-chip\" entry for its states."
        },
        {
          "id": "segment",
          "class": "bc-seg",
          "purpose": "Wraps a chip plus its link when an ancestor object segment carries an ID, keeping the button outside the anchor"
        }
      ],
      "states": [
        "default",
        "link-hover",
        "link-focus",
        "current"
      ],
      "css_only": false,
      "react": {
        "module": "components/Breadcrumb/Breadcrumb.jsx",
        "types": "components/Breadcrumb/Breadcrumb.d.ts",
        "export": "Breadcrumb",
        "items_prop": "items",
        "required_props": [
          "items"
        ],
        "props": [
          "items",
          "className"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".breadcrumb",
          "changes": {
            "font-family": "--font-body",
            "font-size": "--fs-ui-small",
            "font-weight": "500",
            "line-height": "1.4",
            "gap": "6px"
          },
          "note": "Segments are inline-flex with a 6px gap so a leading glyph aligns to its own label; the container gap spaces segment to segment."
        },
        "link-hover": {
          "trigger": ":hover",
          "selector": ".breadcrumb a, .breadcrumb .bc-link",
          "changes": {
            "color": "--graphite-90",
            "text-decoration": "underline"
          }
        },
        "link-focus": {
          "trigger": ":focus-visible",
          "selector": ".breadcrumb a",
          "changes": {
            "outline": "none",
            "box-shadow": "--focus-ring"
          }
        },
        "current": {
          "selector": ".bc-current",
          "changes": {
            "color": "--graphite-90",
            "font-weight": "600"
          }
        }
      },
      "icon_use": {
        "glyph_set": "icons.json → Swivel objects. The same mark the Sidenav uses for that object type.",
        "put_it_on": "The segment naming a type — \"Agent Scripts\", \"Buyers\", \"Ad products\".",
        "not_on": "The object itself. Its identity is its name, not its type."
      },
      "object_id": {
        "rule": "On an object page the object ID always appears in the breadcrumb. It is what an operator quotes in a ticket, a query, or a support thread — it must be on screen and one click from the clipboard.",
        "renders": "The IdChip component — see its own entry for full states.",
        "placement": "On every segment that IS an object, ancestors included — \"Buyers / [BYR-4471] Omnicom Media Group / [PLN-92]\". Collection segments (\"Buyers\") have no ID and carry none. The last segment may be the chip alone, with the object name as the page <h1> beneath the trail.",
        "prop": "items[].id",
        "markup": "An ancestor segment with an ID wraps its chip and its link in a .bc-seg span — the chip is a <button> and must never nest inside the anchor.",
        "copy_behavior": {
          "primary": "navigator.clipboard.writeText → .is-copied for 1.6s, data-tip \"Copied\".",
          "fallback": "A rejected write falls through to document.execCommand(\"copy\") — not only an absent API. Sandboxed iframes and an unfocused document both reject, and swallowing that left the chip inert after a click.",
          "last_resort": "If execCommand also fails, the chip selects its own text, takes .is-manual (warning tint) for 4s, and its data-tip reads \"Press ⌘C\" / \"Press Ctrl+C\" by platform. The control never looks like nothing happened.",
          "permission": "The .is-copied path needs clipboard-write. An iframe without it always lands on the fallback — that is expected, not a defect."
        }
      }
    },
    {
      "id": "top_header",
      "name": "TopHeader",
      "category": "navigation",
      "anchor": "Swivel Component Library.html#c-top-header",
      "class_root": ".top-header",
      "height_px": 56,
      "react": {
        "module": "components/TopHeader/TopHeader.jsx",
        "types": "components/TopHeader/TopHeader.d.ts",
        "export": "TopHeader",
        "controlled_props": [
          "theme"
        ],
        "props": [
          "userEmail",
          "userMenu"
        ]
      },
      "note": "Fixed slot order, left to right: hamburger, brand lockup, account selector, spacer, theme switch, user indicator, logout. The bare .top-header tracks the page theme through tokens — use it in real product shells. .is-dark / .is-light are hardcoded specimen variants for showing both themes side by side on the showcase page; never ship them. Clicking the user indicator opens a grouped account popover (userMenu prop) when userMenu or onLogout is set — self-managed open state, closes on outside click, Escape, or picking an item.",
      "slots": [
        {
          "id": "menu",
          "class": "th-menu",
          "purpose": "Hamburger — toggles the sidenav collapsed rail"
        },
        {
          "id": "brand",
          "class": "th-brand",
          "purpose": "Swivel lockup. Ship both marks with literal src values — .th-mark-light (swivel-lockup-graphite-slate.svg) and .th-mark-dark (swivel-lockup-switch-white.svg) — and let the [data-theme=\"dark\"] rules in components.css pick one. Never interpolate the src."
        },
        {
          "id": "org",
          "class": "th-org",
          "purpose": "Account / org selector"
        },
        {
          "id": "spacer",
          "class": "th-spacer",
          "purpose": "Pushes the trailing group right"
        },
        {
          "id": "theme",
          "class": "theme-switch",
          "purpose": "Canonical light/dark control"
        },
        {
          "id": "user",
          "class": "th-user",
          "purpose": "User indicator — avatar + name"
        },
        {
          "id": "logout",
          "class": "th-logout",
          "purpose": "Log out icon action"
        },
        {
          "id": "user-menu",
          "class": "th-user-menu",
          "purpose": "Popover opened by clicking the user indicator: a profile block (avatar, name, email), labeled groups (.thm-group-label caps text), then a footer group set off by its own divider. Right-aligned to the header so it never overhangs the viewport."
        },
        {
          "id": "user-menu-item",
          "class": "thm-item",
          "purpose": "One row: optional icon, label. --graphite-10 hover, focus-visible ring inset so it does not clip against the popover edge."
        }
      ],
      "deprecated_slots": [
        {
          "id": "th-theme",
          "reason": "Second switch implementation. Use .theme-switch; .th-theme survives only for the locked specimen rows."
        }
      ],
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".top-header",
          "changes": {
            "height": "56px"
          }
        },
        "control-hover": {
          "selector": ".th-menu:hover, .th-org:hover, .th-logout:hover",
          "changes": {
            "background": "--graphite-10 (light) | rgba(255,255,255,0.06) (dark)"
          }
        },
        "user-menu-open": {
          "trigger": "click on .th-user when userMenu or onLogout is set",
          "selector": ".th-user-menu",
          "changes": {
            "display": "mounted, --elev-3, border --surface-stroke"
          },
          "note": "Closes on outside pointerdown, Escape, or selecting any item — never left open behind a stale click."
        }
      },
      "states": [
        "default",
        "control-hover"
      ]
    },
    {
      "id": "sidenav",
      "name": "Sidenav",
      "category": "navigation",
      "anchor": "Swivel Component Library.html#c-navigation",
      "class_root": ".sidenav",
      "react": {
        "module": "components/Sidenav/Sidenav.jsx",
        "types": "components/Sidenav/Sidenav.d.ts",
        "export": "Sidenav",
        "controlled_props": [
          "active",
          "collapsed"
        ]
      },
      "note": "Production root is .sidenav; .sidenav-demo is the showcase alias with identical rules. Rows are .sn-item, active row takes .is-selected, groups sit under .sn-section headers.",
      "variants": [
        {
          "id": "expanded",
          "class": null,
          "width_px": 232,
          "purpose": "Icon + label rows"
        },
        {
          "id": "collapsed",
          "class": "is-collapsed",
          "width_px": 64,
          "purpose": "Icon-only rail, toggled by the header hamburger"
        }
      ],
      "state_class": "is-selected",
      "slots": [
        {
          "id": "scroll",
          "class": "sn-scroll",
          "purpose": "Scrolling route list"
        },
        {
          "id": "footer",
          "class": "sn-footer",
          "purpose": "Rows pinned above a divider at the bottom — typically Settings"
        }
      ],
      "sticky": true,
      "css_only": false,
      "state_matrix": {
        "default": {
          "selector": ".sidenav .sn-item",
          "changes": {
            "color": "--graphite-bg"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "background": "--graphite-10",
            "color": "--graphite-90"
          }
        },
        "selected": {
          "selector": ".sn-item.is-selected",
          "changes": {
            "background": "--slate-bg",
            "color": "--slate-110",
            "font-weight": "600"
          }
        },
        "collapsed": {
          "changes": {
            "width": "icon rail; labels hidden",
            "driver": "TopHeader hamburger → Sidenav `collapsed`"
          }
        }
      },
      "states": [
        "default",
        "hover",
        "selected",
        "collapsed"
      ]
    },
    {
      "id": "tabs",
      "name": "Tabs",
      "category": "navigation",
      "anchor": "Swivel Component Library.html#c-navigation",
      "variants": [
        "underline",
        "segmented"
      ],
      "states": [
        "default",
        "hover",
        "active",
        "disabled"
      ],
      "css_only": false,
      "react": {
        "module": "components/Tabs/Tabs.jsx",
        "types": "components/Tabs/Tabs.d.ts",
        "export": "Tabs",
        "items_prop": "items",
        "value_prop": "value"
      },
      "state_matrix": {
        "default": {
          "selector": ".tab",
          "changes": {
            "color": "--slate-100",
            "height": "56px",
            "border-bottom": "2px solid transparent"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "color": "--slate-110"
          }
        },
        "active": {
          "selector": ".tab.is-selected",
          "changes": {
            "color": "--slate-100",
            "border-bottom-color": "--slate-100",
            "font-weight": "600"
          }
        },
        "disabled": {
          "trigger": ":disabled",
          "changes": {
            "opacity": "0.4",
            "pointer-events": "none"
          }
        }
      },
      "slots": [
        {
          "id": "label",
          "class": "tab-label",
          "purpose": "The tab text"
        },
        {
          "id": "count",
          "class": "tab-count",
          "purpose": "Subset size — mono, tabular-nums, --graphite-bg, its own element. Pass items[].count; never concatenate it into the label"
        }
      ],
      "note": "A tab never wraps: .tab is white-space: nowrap and flex: 0 0 auto, because a shrinkable tab breaks a multi-word label and leaves the underline indicator under a two-line box. Counts are items[].count, rendered as .tab-count — concatenating \"(12)\" into the label makes the preceding space a break opportunity and orphans the count on its own line."
    },
    {
      "id": "card",
      "name": "Card",
      "category": "containers",
      "anchor": "Swivel Component Library.html#c-cards",
      "background_token": "--surface-base",
      "border_token": "--surface-stroke",
      "radius_range_px": [
        4,
        8
      ],
      "default_padding_token": "--space-3xl",
      "css_only": false,
      "react": {
        "module": "components/Card/Card.jsx",
        "types": "components/Card/Card.d.ts",
        "export": "Card",
        "variant_prop": "variant",
        "default_variant": "outline",
        "size_prop": "padding",
        "default_size": "md"
      },
      "class_root": ".card",
      "variants": [
        {
          "id": "outline",
          "class": "card-outline",
          "purpose": "Default hairline surface"
        },
        {
          "id": "elevation",
          "class": "card-elevation",
          "purpose": "Floating panel / popover"
        },
        {
          "id": "flat",
          "class": "card-flat",
          "purpose": "Nested inside an already-bordered region"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "class": "card-sm"
        },
        {
          "id": "md"
        },
        {
          "id": "lg",
          "class": "card-lg"
        }
      ],
      "slots": [
        "title",
        "subtitle",
        "actions",
        "body",
        "footer"
      ],
      "state_matrix": {
        "default": {
          "selector": ".card.card-outline",
          "changes": {
            "background": "--surface-base",
            "border": "1px solid --surface-stroke"
          }
        },
        "elevation": {
          "selector": ".card.card-elevation",
          "changes": {
            "box-shadow": "--elev-2",
            "border-color": "--graphite-10"
          }
        },
        "flat": {
          "selector": ".card.card-flat",
          "changes": {
            "border": "0",
            "box-shadow": "none"
          }
        }
      },
      "states": [
        "default",
        "elevation",
        "flat"
      ]
    },
    {
      "id": "drawer",
      "name": "Drawer",
      "category": "containers",
      "anchor": "Swivel Component Library.html#c-drawer",
      "class_root": ".drawer-overlay",
      "note": "Slide-in panel anchored to viewport edge. Use for settings, detail views, filter panels. Open by adding .is-open to .drawer-overlay.",
      "variants": [
        {
          "id": "right",
          "class": "drawer-overlay",
          "purpose": "Default — slides in from right"
        },
        {
          "id": "left",
          "class": "drawer-overlay drawer-left",
          "purpose": "Secondary panel, slides in from left"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "class": "drawer-sm",
          "width_px": 360,
          "use": "Filter panels"
        },
        {
          "id": "default",
          "class": null,
          "width_px": 480,
          "use": "Forms, settings"
        },
        {
          "id": "lg",
          "class": "drawer-lg",
          "width_px": 640,
          "use": "Rich detail views"
        }
      ],
      "slots": [
        "drawer-header (title + close)",
        "drawer-body (scrollable)",
        "drawer-footer (actions)"
      ],
      "states": [
        "closed",
        "open"
      ],
      "accessibility": "role=dialog, aria-modal=true, focus trap when open, Escape key closes, backdrop click closes",
      "css_only": false,
      "react": {
        "module": "components/Drawer/Drawer.jsx",
        "types": "components/Drawer/Drawer.d.ts",
        "export": "Drawer",
        "open_prop": "open",
        "size_prop": "size",
        "default_size": "md"
      },
      "state_matrix": {
        "closed": {
          "selector": ".drawer-overlay",
          "changes": {
            "opacity": "0",
            "pointer-events": "none",
            "panel-transform": "translateX(100%)"
          }
        },
        "open": {
          "selector": ".drawer-overlay.is-open",
          "changes": {
            "opacity": "1",
            "pointer-events": "all",
            "panel-transform": "translateX(0)",
            "transition": "260ms cubic-bezier(0.4,0,0.2,1)"
          }
        }
      }
    },
    {
      "id": "modal",
      "name": "Modal",
      "category": "containers",
      "anchor": "Swivel Component Library.html#c-modal",
      "elevation_token": "--elev-modal",
      "backdrop_blur_px": 12,
      "states": [
        "closed",
        "open"
      ],
      "css_only": false,
      "react": {
        "module": "components/Modal/Modal.jsx",
        "types": "components/Modal/Modal.d.ts",
        "export": "Modal",
        "open_prop": "open"
      },
      "state_matrix": {
        "closed": {
          "changes": {
            "render": "nothing (component returns null)"
          }
        },
        "open": {
          "selector": ".modal-shell > .modal",
          "changes": {
            "scrim-background": "rgba(19,21,20,0.6)",
            "panel-background": "--surface-base",
            "box-shadow": "0 20px 50px rgba(0,0,0,0.3)",
            "dismiss": "scrim click + Escape"
          }
        }
      }
    },
    {
      "id": "editable-title",
      "name": "EditableTitle",
      "category": "layout",
      "anchor": "components/EditableTitle/editabletitle.html",
      "class_root": ".editable-title",
      "note": "Click-to-rename h1/h2 for object detail pages. Static state shows a pencil glyph on hover/focus; click (or Enter/Space) swaps to a text input; Enter/blur commits, Escape reverts. Sits below the Breadcrumb, replacing the plain heading — never used on a list/collection heading, which has no single name to rename.",
      "variants": [
        { "id": "h1", "class": "is-h1", "purpose": "Page-level object title — --fs-h3, matches the templates' existing detail heading size." },
        { "id": "h2", "class": "is-h2", "purpose": "Nested/section-level rename target — --fs-h4." }
      ],
      "states": ["static", "hover", "focus-visible", "editing"],
      "state_matrix": [
        { "state": "static", "resolution": "Plain text + hidden pencil (opacity 0)." },
        { "state": "hover", "resolution": "background: var(--graphite-10); pencil opacity 1." },
        { "state": "focus-visible", "resolution": "Same background as hover — keyboard parity, no separate ring since the element isn't yet an input." },
        { "state": "editing", "resolution": "Swaps to <input>: border var(--btn-primary-bg), box-shadow var(--focus-ring)." }
      ],
      "a11y": "role=button + tabIndex=0 on the static heading (Enter/Space opens edit); input carries aria-label. Escape reverts without committing.",
      "code": { "jsx": "components/EditableTitle/EditableTitle.jsx", "types": "components/EditableTitle/EditableTitle.d.ts" }
    },
    {
      "id": "chart",
      "name": "Chart",
      "category": "data-viz",
      "anchor": "components/Chart/chart.html",
      "class_root": ".chart",
      "note": "Line, area, and bar charts with the Swivel axis, tooltip, and palette contract built in. design-system/charts.rules.yaml is the full rule registry and Charts.html the rendered showcase — this component implements the universal rules so a consumer cannot half-apply them. The plot is SVG; the .chart-* classes own the chrome (head, axis type, tooltip, legend). Bars always compare from zero; the axis is never truncated.",
      "variants": [
        {
          "id": "line",
          "purpose": "Continuous metric over time — fill rate, CPM, ROI"
        },
        {
          "id": "area",
          "purpose": "Magnitude over time, when the total matters more than the rate of change. Single-series fill 12%"
        },
        {
          "id": "bar",
          "purpose": "Value over time when the gap between points matters; stacked for part-of-whole"
        },
        {
          "id": "bar-horizontal",
          "purpose": "Ranked categories — the default for \"top N\""
        }
      ],
      "slots": [
        {
          "id": "head",
          "class": "chart-head",
          "purpose": "Title and subtitle row above the plot"
        },
        {
          "id": "axis",
          "class": "chart-axis-label",
          "purpose": "Tick type, 11px, tabular-nums"
        },
        {
          "id": "grid",
          "class": "chart-grid-line",
          "purpose": "--surface-stroke hairlines; the zero line is .chart-baseline"
        },
        {
          "id": "target",
          "class": "chart-target-line",
          "purpose": "Dashed reference line — a target is never a solid series. targetLabel sits at the right edge just above the line, on a --surface-base backing rect: a target line is normally drawn where the series crosses it, so bare text there collides with the plot."
        },
        {
          "id": "tooltip",
          "class": "chart-tooltip",
          "purpose": "--surface-base on a --surface-stroke hairline in both themes; .is-active promotes one row, rows never reorder"
        },
        {
          "id": "legend",
          "class": "chart-legend",
          "purpose": "Swatch matches the shape it describes — a 16x3 rule for lines, a block for areas and bars"
        }
      ],
      "states": [
        "default",
        "hover",
        "missing-data",
        "empty"
      ],
      "css_only": false,
      "react": {
        "module": "components/Chart/Chart.jsx",
        "types": "components/Chart/Chart.d.ts",
        "export": "Chart",
        "exports": [
          "Chart",
          "Sparkline",
          "Donut",
          "Bullet",
          "ChartFormat"
        ],
        "required_props": [
          "series"
        ],
        "props": [
          "type",
          "series",
          "categories",
          "height",
          "format",
          "currency",
          "unitLabel",
          "title",
          "subtitle",
          "legend",
          "target",
          "targetLabel",
          "stacked",
          "yTicks"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".chart-series-line",
          "changes": {
            "fill": "none",
            "stroke-width": "2",
            "stroke-linecap": "round"
          }
        },
        "hover": {
          "trigger": "pointer over the plot",
          "changes": {
            "crosshair": ".chart-crosshair — dashed --graphite-30",
            "tooltip": ".chart-tooltip follows the nearest category",
            "active_row": ".chart-tooltip-row.is-active promotes to --graphite-90 — the series nearest the cursor vertically, measured against the pointer y that onMove stores on the hover state."
          },
          "note": "Only one row promotes at a time and rows never reorder — visual stability beats novelty."
        },
        "missing-data": {
          "trigger": "null in series.data",
          "selector": ".chart-series-line.is-gap",
          "changes": {
            "stroke-dasharray": "3 3"
          },
          "note": "A null bridges with a dashed segment. Never drop the line to zero."
        },
        "empty": {
          "trigger": "series: []",
          "changes": {
            "note": "Renders the frame with no series. Pair with EmptyState above the chart when there is genuinely no data."
          }
        }
      },
      "formatting": {
        "api": "ChartFormat.axis / .tick / .exact / .palette",
        "rules_implemented": [
          "currency symbol on every tick",
          "K / MM / B abbreviation by magnitude",
          "one suffix per axis — the scale snaps to the largest tick",
          "zero never carries a suffix ($0, not $0K) — bars and areas baseline at zero on nearly every chart",
          "precision raised until every tick round-trips, so a 2.5MM step reads $2.5MM and never $3MM",
          "minus sign outside the currency symbol (−$2.4K)",
          "tooltips show full precision, axes abbreviate"
        ],
        "exported_why": "A custom plot that cannot use Chart still needs the axis rules. ChartFormat is capitalised so it reaches window.<Namespace>."
      },
      "palette": {
        "order": [
          "--viz-slate",
          "--viz-switch",
          "--viz-deep",
          "--viz-steel",
          "--viz-mist",
          "--viz-graphite"
        ],
        "rule": "Order series by importance, not alphabetically. Never a semantic colour — those mean good/bad, not category."
      },
      "not_implemented": {
        "types": [
          "heatmap",
          "scatter",
          "small-multiples",
          "divergent-bars",
          "waterfall"
        ],
        "where": "Documented in Charts.html and charts.rules.yaml but not yet components. Build them from the same .chart-* chrome and ChartFormat."
      },
      "geometry": {
        "band_axis": "Bands run along the category axis — down the plot height when type=\"bar-horizontal\", across the width otherwise. barW derives from that same band.",
        "why": "Deriving both from plot width put five horizontal bars 278px below the frame, and .chart-plot has overflow: visible so they painted over the next section instead of clipping."
      }
    },
    {
      "id": "sparkline",
      "name": "Sparkline",
      "category": "data-viz",
      "anchor": "components/Chart/chart.html",
      "class_root": ".chart-sparkline",
      "note": "Inline trend for one metric — no axes, no tooltip. Context beside a number, not analysis. Also the trend inside a KpiCard.",
      "states": [
        "default"
      ],
      "css_only": false,
      "react": {
        "module": "components/Chart/Chart.jsx",
        "types": "components/Chart/Chart.d.ts",
        "export": "Sparkline",
        "required_props": [
          "data"
        ],
        "props": [
          "data",
          "width",
          "height",
          "color",
          "area"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".chart-sparkline path",
          "changes": {
            "stroke-width": "1.5",
            "fill": "none"
          },
          "note": "area fills at 12% under the line."
        }
      }
    },
    {
      "id": "donut",
      "name": "Donut",
      "category": "data-viz",
      "anchor": "components/Chart/chart.html",
      "class_root": ".chart-donut-wrap",
      "note": "One anchor number with up to four supporting slices. The centre is what justifies the format — never a pie. Past four slices, or when values sit within 5% of each other, or when there is no anchor number, switch to Chart type=\"bar-horizontal\": humans rank lengths, not arcs.",
      "slots": [
        {
          "id": "anchor",
          "class": "chart-donut-anchor",
          "purpose": "Centred value plus optional label — the dominant fact"
        }
      ],
      "states": [
        "default"
      ],
      "css_only": false,
      "react": {
        "module": "components/Chart/Chart.jsx",
        "types": "components/Chart/Chart.d.ts",
        "export": "Donut",
        "required_props": [
          "slices",
          "value"
        ],
        "props": [
          "slices",
          "size",
          "thickness",
          "value",
          "label"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".chart-donut-value",
          "changes": {
            "font-family": "--font-display",
            "font-size": "--fs-h3",
            "font-variant-numeric": "tabular-nums"
          }
        }
      },
      "swap_triggers": [
        {
          "condition": "5+ slices",
          "use": "bar-horizontal"
        },
        {
          "condition": "values within 5%",
          "use": "bar-horizontal"
        },
        {
          "condition": "no anchor number",
          "use": "bar-horizontal"
        },
        {
          "condition": "trend rather than snapshot",
          "use": "line"
        }
      ]
    },
    {
      "id": "bullet",
      "name": "Bullet",
      "category": "data-viz",
      "anchor": "components/Chart/chart.html",
      "class_root": ".chart-bullet-measure",
      "note": "Actual versus target with qualitative bands. Replaces gauges — a gauge spends a lot of space on one number and reads its angle, not its length.",
      "slots": [
        {
          "id": "band",
          "class": "chart-bullet-band",
          "purpose": "Qualitative range behind the measure; .is-mid is the second step"
        },
        {
          "id": "measure",
          "class": "chart-bullet-measure",
          "purpose": "The actual value, --viz-slate"
        },
        {
          "id": "target",
          "class": "chart-bullet-target",
          "purpose": "Vertical rule across the measure at the target"
        }
      ],
      "states": [
        "default"
      ],
      "css_only": false,
      "react": {
        "module": "components/Chart/Chart.jsx",
        "types": "components/Chart/Chart.d.ts",
        "export": "Bullet",
        "required_props": [
          "value"
        ],
        "props": [
          "value",
          "target",
          "max",
          "bands",
          "height"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".chart-bullet-measure",
          "changes": {
            "fill": "--viz-slate"
          }
        }
      }
    },
    {
      "id": "progress-bar",
      "name": "ProgressBar",
      "category": "data-viz",
      "anchor": "components/ProgressBar/progressbar.html",
      "class_root": ".progress",
      "note": "Completion of a known whole — a delivery share, an upload, an export. Distinct from two neighbours: Bullet is actual against a target with qualitative bands, and Skeleton stands in for content that has not arrived. A progress bar says how far along something is; a skeleton says nothing is here yet.",
      "variants": [
        {
          "id": "determinate",
          "class": null,
          "purpose": "Default — the fill is value/max and carries aria-valuenow"
        },
        {
          "id": "indeterminate",
          "class": "is-indeterminate",
          "purpose": "Activity of unknown duration. A 40% sliver sweeps the track; no value is shown and no aria-valuenow is set, because the width means nothing"
        }
      ],
      "sizes": [
        {
          "id": "sm",
          "track_h": "4px",
          "purpose": "A table cell — pair with ProgressCell"
        },
        {
          "id": "md",
          "track_h": "8px",
          "purpose": "Default"
        },
        {
          "id": "lg",
          "track_h": "12px",
          "purpose": "Where progress is the page's subject"
        }
      ],
      "slots": [
        {
          "id": "head",
          "class": "progress-head",
          "purpose": "Label row; the figure right-aligns via .pr-value in --font-mono with tabular-nums"
        },
        {
          "id": "track",
          "class": "progress-track",
          "purpose": "--graphite-10 rail at --radius-full; carries role=\"progressbar\". overflow: hidden is scoped to .is-indeterminate — the sweep needs clipping, a determinate fill rounds its own end, and clipping the track would flatten .progress-target into a notch inside the fill."
        },
        {
          "id": "fill",
          "class": "progress-fill",
          "purpose": "--slate-100 by default; .is-success / .is-warning / .is-error for a delivery state"
        },
        {
          "id": "target",
          "class": "progress-target",
          "purpose": "A single 2px reference line — where pacing should be today. Extends 2px past the rail top and bottom so it reads as a mark against the bar rather than a gap in it; this only works because the track does not clip. More than one reference is a Bullet."
        },
        {
          "id": "cell",
          "class": "progress-cell",
          "purpose": "ProgressCell — a 72px sm bar beside its figure, for a table column. Carries the full reading on [data-tip]: \"4.2MM of 6MM impressions (70%)\"."
        }
      ],
      "states": [
        "default",
        "success",
        "warning",
        "error",
        "indeterminate",
        "reduced-motion",
        "cell-hover"
      ],
      "css_only": false,
      "react": {
        "module": "components/ProgressBar/ProgressBar.jsx",
        "types": "components/ProgressBar/ProgressBar.d.ts",
        "export": "ProgressBar",
        "exports": [
          "ProgressBar",
          "ProgressCell"
        ],
        "props": [
          "value",
          "max",
          "indeterminate",
          "tone",
          "size",
          "label",
          "showValue",
          "valueText",
          "target",
          "ariaLabel"
        ],
        "cell_props": [
          "value",
          "max",
          "tone",
          "metric",
          "unit",
          "valueText",
          "ariaLabel"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".progress-fill",
          "changes": {
            "background": "--slate-100",
            "border-radius": "--radius-full",
            "transition": "width --motion-base cubic-bezier(0.2,0,0,1)"
          }
        },
        "success": {
          "trigger": "tone=\"success\"",
          "changes": {
            "background": "--success"
          },
          "note": "Complete, or ahead of pace."
        },
        "warning": {
          "trigger": "tone=\"warning\"",
          "changes": {
            "background": "--warning"
          },
          "note": "Behind pace."
        },
        "error": {
          "trigger": "tone=\"error\"",
          "changes": {
            "background": "--error"
          },
          "note": "The run failed at this point — not simply low."
        },
        "indeterminate": {
          "trigger": ".progress-track.is-indeterminate",
          "changes": {
            "width": "40%",
            "animation": "progress-sweep 1.4s infinite",
            "transition": "none"
          }
        },
        "reduced-motion": {
          "trigger": "@media (prefers-reduced-motion: reduce)",
          "changes": {
            "animation": "none",
            "width": "100%",
            "opacity": "0.4"
          },
          "note": "The sweep would be a physical symptom for a user who set the preference, so it resolves to a static dimmed rail."
        },
        "cell-hover": {
          "trigger": ".progress-cell:hover / :focus-visible",
          "changes": {
            "::after": "[data-tip] plate with the full reading, white-space nowrap"
          },
          "note": "Nowrap so the tip does not widen the column."
        }
      },
      "tone_rule": "Tone marks a delivery state, never decoration — the same reservation as Badge and the status colours. A bar at 20% is not error unless something failed.",
      "accessibility": "role=\"progressbar\" with aria-valuenow / min / max / valuetext when determinate; all four omitted when indeterminate, which is what tells a screen reader the duration is unknown. ariaLabel is required when there is no visible label.",
      "cell_tooltip": {
        "rule": "A progress bar in a table cell always carries the full reading on hover and keyboard focus. The column has room only for the percentage; the absolute figures are what an operator acts on.",
        "format": "\"<value> of <max> <metric> (<n>%)\" — each figure abbreviated at its OWN magnitude via ChartFormat.tick: \"336K of 1.2MM impressions (28%)\". unit=\"currency\" puts the symbol on both.",
        "mechanism": "The system's [data-tip] on .progress-cell, with tabIndex={0} so focus-visible shows it too. The same string is the accessible name and the bar's aria-valuetext.",
        "why": "A bare percentage answers \"how far\" but not \"how much left\", which is the number that drives the decision.",
        "not": "ChartFormat.axis. That snaps a whole scale to one suffix and raises precision until every value round-trips exactly — correct for nice axis ticks, wrong for two measured numbers, where it turned 336,000 into \"0.336MM\". One suffix per axis exists so tick labels line up in length; two figures in a sentence have no such constraint."
      }
    },
    {
      "id": "flight-progress",
      "name": "FlightProgress",
      "category": "data-viz",
      "anchor": "components/ProgressBar/progressbar.html",
      "class_root": ".progress-flight",
      "note": "Elapsed time across a flight window, with dark days blocked out. This measures TIME, not delivery — impressions against a goal is a ProgressBar, pacing against a target with qualitative bands is a Bullet. Two encodings in deliberately different colour families: --slate-100 for time spent, --graphite-80 for time the flight was not delivering.",
      "slots": [
        {
          "id": "fill",
          "class": "progress-fill",
          "purpose": "Elapsed portion in --slate-100"
        },
        {
          "id": "dark",
          "class": "flight-dark",
          "purpose": "A dark stretch in --graphite-80 with an inset 1px --surface-base hairline, absolutely positioned over the fill at z-index 2. Real element, not a pseudo, so each stretch is its own hover and focus target with a [data-tip]. Takes .is-at-start / .is-at-end when it touches the window edge, which drives its pill cap."
        },
        {
          "id": "today",
          "class": "progress-target",
          "purpose": "Today marker at z-index 3, above both, with a 1px --surface-base ring so it stays visible when today lands on a dark day. Hidden automatically when today falls outside the window."
        },
        {
          "id": "bounds",
          "class": "flight-bounds",
          "purpose": "Start and end dates under the bar, with the dark-day count centred between them"
        },
        {
          "id": "legend",
          "class": "flight-legend",
          "purpose": "Names the two encodings. On by default whenever dark days are present"
        }
      ],
      "states": [
        "default",
        "dark-hover",
        "not-started",
        "complete"
      ],
      "css_only": false,
      "react": {
        "module": "components/ProgressBar/ProgressBar.jsx",
        "types": "components/ProgressBar/ProgressBar.d.ts",
        "export": "FlightProgress",
        "required_props": [
          "start",
          "end"
        ],
        "props": [
          "start",
          "end",
          "asOf",
          "darkDays",
          "label",
          "showBounds",
          "legend",
          "showToday",
          "ariaLabel"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".progress-flight .progress-track",
          "changes": {
            "height": "10px",
            "note": "Taller than the 8px default — the bar carries segments, not just a fill. Both the dark stretches and the today marker are separated from their ground by a --surface-base hairline rather than by hue."
          },
          "note": "Taller than the 8px default — the bar carries segments, not just a fill."
        },
        "dark-hover": {
          "trigger": ".flight-dark:hover / :focus-visible",
          "changes": {
            "background": "--graphite-90",
            "::after": "[data-tip] with the date, or the range and day count"
          }
        },
        "not-started": {
          "trigger": "asOf before start",
          "changes": {
            "note": "Fill width 0 and no today marker — an empty slate track reads as scheduled, not stalled."
          }
        },
        "complete": {
          "trigger": "asOf after end",
          "changes": {
            "note": "Fill at 100% and the today marker hidden, since it would sit on the end cap."
          }
        }
      },
      "dark_days": {
        "input": "A bare date is one day; { start, end } is an inclusive range. Both are clamped to the flight window.",
        "merging": "Adjacent and overlapping entries merge into one stretch, so an unbroken outage is a single hover target rather than several abutting ones that each show a different date.",
        "tooltip": "\"Dark · Jul 14\" for one day, \"Dark · Jul 28 – Aug 2 (6 days)\" for a range. On [data-tip], with tabIndex 0 so keyboard focus reveals it.",
        "min_width": "3px floor on .flight-dark — a single day in a long flight is under a pixel wide and would be unhittable.",
        "colour_rule": "Dark days must not share a hue family with the elapsed fill — a dark day inside the elapsed portion paints over it, so slate-on-slate would be invisible. --graphite-80 rather than --graphite-bg: bg is a mid grey in both themes and read as a soft smudge on the fill.",
        "caps": "Rounding is keyed to .is-at-start / .is-at-end, computed from the geometry — not :first-of-type / :last-of-type. Those encode position in the list, which capped a mid-flight stretch that happened to be first, and never matched the last one at all because the today marker is also a span.",
        "separation": "Hue alone is not enough. --graphite-80 against the slate fill measures 2.9:1 in dark mode, under the 3:1 WCAG 1.4.11 non-text threshold, and the fill is the primary ground since dark days inside elapsed time overlay it. So .flight-dark carries a 1px --surface-base ring and the boundary is carried by SHAPE: 4.25:1 light / 3.82:1 dark against the fill, 13.4:1 / 11.1:1 against the dark day itself. The ring is OUTSET, matching the today marker — an inset hairline is a fixed 1px on all four sides, so it ate 48% of a single-day stretch and 73% at the 3px clamp, and the top and bottom edges of a dark day are the track edges anyway, separating it from nothing.",
        "marker_collision": "The today marker is --graphite-90, one step from the dark days, and today falling inside a dark stretch is a normal state (paused today) — at 1.3:1 the marker would vanish inside the rail. It therefore carries a 1px --surface-base ring and reads on any ground rather than relying on the ramp. Stepping dark days to --graphite-90 instead would collapse this case entirely.",
        "dark_mode_note": "In dark mode the graphite ramp inverts, so --graphite-80 resolves near-white and \"not delivering\" is the brightest element on the bar. That is the token model working as designed — graphite encodes distance from the surface, not literal darkness — and the legend names the encoding either way."
      },
      "accessibility": "The track's aria-valuetext is a full sentence — \"Q3 Upfront flight: Jul 1 to Sep 30, 51% elapsed, 9 dark of 92 days\" — because a bare percentage does not convey the dark days at all. Each dark stretch carries its own aria-label."
    },
    {
      "id": "kpi-card",
      "name": "KpiCard",
      "category": "data-viz",
      "anchor": "components/KpiCard/kpicard.html",
      "class_root": ".kpi-card",
      "note": "One number, one label, one delta. If a second dimension is needed the answer is a chart, not a bigger card. The label states the window the delta covers (\"vs. last 7d\") — never the card chrome.",
      "slots": [
        {
          "id": "label",
          "class": "kpi-label",
          "purpose": "What the number is"
        },
        {
          "id": "value",
          "class": "kpi-value",
          "purpose": "The UI/Metric token — --font-body (Antarctica) 700 at --fs-ui-metric 32px, line-height 0.95, in --switch-contrast. Never the display face: Avantt is for headings and titles, not metric readouts."
        },
        {
          "id": "delta",
          "class": "kpi-delta",
          "purpose": "Arrow plus value beside the metric, --font-mono 11px 500. .is-up --success, .is-down --error, otherwise --graphite-bg."
        },
        {
          "id": "window",
          "class": "kpi-window",
          "purpose": "The comparison window"
        }
      ],
      "states": [
        "up",
        "down",
        "neutral"
      ],
      "css_only": false,
      "react": {
        "module": "components/KpiCard/KpiCard.jsx",
        "types": "components/KpiCard/KpiCard.d.ts",
        "export": "KpiCard",
        "required_props": [
          "label",
          "value"
        ],
        "props": [
          "label",
          "value",
          "delta",
          "direction",
          "window",
          "sparkline"
        ]
      },
      "state_matrix": {
        "up": {
          "trigger": "direction=\"up\"",
          "selector": ".kpi-delta.is-up",
          "changes": {
            "color": "--success",
            "icon": "arrow_upward"
          }
        },
        "down": {
          "trigger": "direction=\"down\"",
          "selector": ".kpi-delta.is-down",
          "changes": {
            "color": "--error",
            "icon": "arrow_downward"
          }
        },
        "neutral": {
          "trigger": "direction=\"neutral\"",
          "selector": ".kpi-delta",
          "changes": {
            "color": "--graphite-bg",
            "icon": "remove"
          },
          "note": "For a metric where direction is not inherently good or bad."
        }
      },
      "type_contract": {
        "value": "UI/Metric — .t-ui-metric in tokens.css: --font-body 700 / --fs-ui-metric / 0.95 / --switch-contrast.",
        "delta": "--font-mono 11px 500, letter-spacing 0.02em.",
        "canonical_source": "design-system/Charts.html §KPI card, per charts.rules.yaml canonical_source."
      }
    },
    {
      "id": "table",
      "name": "Table",
      "category": "data",
      "anchor": "Swivel Component Library.html#c-tables",
      "class_root": ".table-card",
      "note": "A table is always .table-card + a variant class wrapping .table-toolbar, the table itself, and .table-foot. The List page pattern requires the toolbar — never ship a bare <table>. Cell classes carry the type: .cell-select, .cell-id (with .id-chip), .cell-metric (right-aligned numerals), .cell-date, .cell-state. Sortable headers use a .th-sort button. Column visibility: add .col-hidden to a th/td pair to hide that column. Status cells are icon-only: .cell-state plus a tone class (.is-success, .is-warning, .is-error, .is-neutral) and .is-outline for an unfilled glyph; give the glyph data-tip so hover reveals the status text. Row hover uses --slate-bg so it reads as selection-adjacent and never matches the --graphite-10 header fill. The .id-chip in the ID column is the same contract the Breadcrumb uses; as a <button> it becomes copyable.",
      "variants": [
        {
          "id": "elevated",
          "class": "table-card-elevation",
          "purpose": "Default — sits on the page canvas"
        },
        {
          "id": "outlined",
          "class": "table-card-outline",
          "purpose": "Inside an already-elevated container"
        }
      ],
      "slots": [
        {
          "id": "toolbar",
          "class": "table-toolbar",
          "purpose": "Icon actions above the table: Sort, Columns, Filters (with .tt-badge count), Density, Reset. Buttons are .tt-btn carrying data-tip for the tooltip."
        },
        {
          "id": "scroll",
          "class": "table-scroll",
          "purpose": "Scroll container — cap its height and the header pins"
        },
        {
          "id": "foot",
          "class": "table-foot",
          "purpose": "Result count and pagination below the table"
        },
        {
          "id": "actions-cell",
          "class": "cell-actions",
          "purpose": "Per-row action at the end of the row. Hugs its content (width 1%, nowrap, right-aligned); the IconButton is opacity 0 until the row is hovered or the button takes keyboard focus, so a column of glyphs does not compete with the data"
        },
        {
          "id": "lifecycle-cell",
          "class": "cell-lifecycle / .lifecycle / .lc-bar",
          "purpose": "A fixed set of named stages as equal segments, filled up to the one reached; .is-done fills --slate-100 against a --graphite-10 rest. Hugs its content and shrinks under .is-compact. Use instead of ProgressBar when the stages are discrete and countable — a continuous rail invites reading a percentage that does not exist. The stage name goes in the tooltip so the marks need no legend"
        },
        {
          "id": "subtable",
          "class": "tr.subtable-row > td[colspan] > .subtable > .table",
          "purpose": "The records behind a count, opened in place beneath the row that owns them. Triggered only by a .count-btn in that row (see expandable_rows). One spanning cell, inset past the ID column, holding a header-only .table: ID chip, linked name, then right-aligned counts. No toolbar, no pagination; capped at 10 rows with a .subtable-foot link to the full list."
        }
      ],
      "density": {
        "class": "is-compact",
        "purpose": "Tighter rows, toggled from the toolbar's density control"
      },
      "states": [
        "default",
        "row-hover",
        "row-selected",
        "header-focus",
        "toolbar-hover",
        "loading",
        "empty",
        "error"
      ],
      "css_only": true,
      "toolbar_menus": {
        "note": "Toolbar buttons open popovers anchored to the button (or, for Filters, to .table-toolbar). Each popover is a role=\"dialog\" with an aria-label the outside-click handler matches on.",
        "column_picker": {
          "aria_label": "Show columns",
          "contents": "search input, one checkbox per column, Show/Hide All with indeterminate state, Reset (disabled until a column is hidden)",
          "no_only_chip": "Never render .sm-only here — see SelectMenu.only_affordance. Show/Hide All and Reset cover the bulk moves.",
          "reset_vs_clear": "Reset returns to the default column set and is disabled until dirty. That is not the same control as a value menu's \"Clear all\", which empties the selection — a column picker has no empty end state."
        },
        "sort_rules": {
          "aria_label": "Sort rules",
          "row_class": ".sort-rule",
          "contents": "column SelectField, direction ButtonGroup (icon-only arrow_upward/arrow_downward), drag handle to reorder, remove x revealed on row hover"
        },
        "filter_rules": {
          "aria_label": "Filter rules",
          "row_class": ".sort-rule",
          "contents": "If All/Any match-mode select, then Property / Operator / Values per row; Values is a chips multi-select for enums and a TagInput for free text"
        },
        "rows_per_page": {
          "aria_label": "Rows per page",
          "role": "listbox",
          "contents": "one role=\"option\" per page size; the current size carries .is-selected"
        }
      },
      "state_matrix": {
        "default": {
          "selector": ".table-card .table tbody tr",
          "changes": {
            "row-border-bottom": "1px solid --graphite-10"
          }
        },
        "row-hover": {
          "trigger": ":hover",
          "selector": ".table-card .table tbody tr:hover",
          "changes": {
            "background": "--graphite-10"
          },
          "note": "Matches the secondary button hover fill, so a hovered row and a hovered secondary button read as the same interaction state."
        },
        "row-selected": {
          "changes": {
            "selection-bar": "appears above the card with bulk actions"
          }
        },
        "header-focus": {
          "selector": ".th-sort:focus-visible",
          "changes": {
            "box-shadow": "--focus-ring"
          }
        },
        "toolbar-hover": {
          "selector": ".tt-btn:hover",
          "changes": {
            "background": "--slate-bg"
          }
        },
        "loading": {
          "changes": {
            "body": "Skeleton rows in the shape of the real rows"
          }
        },
        "empty": {
          "changes": {
            "body": "EmptyState (.state-panel) or .table-card-empty inside the card"
          }
        },
        "error": {
          "changes": {
            "body": "ErrorState (.state-panel.is-error) with a retry"
          }
        },
        "count-expanded": {
          "trigger": "click on .count-btn",
          "selector": ".table-card .count-btn[aria-expanded=\"true\"]",
          "changes": {
            "background": "--surface-canvas",
            "border": "1px --slate-base + 1px ring",
            "color": "--graphite-90"
          },
          "siblings": {
            "parent-row": "tr.is-expanded: --graphite-10 fill, no bottom rule",
            "subtable": "tr.subtable-row inserted directly beneath"
          }
        }
      },
      "cell_kinds": {
        "$note": "The canonical cell registry. A column has a KIND, and the kind decides the header class, the body class, and what goes inside — so a table never invents alignment or type treatment per page. Both templates that render tables build a th/td class map from exactly this table (TH_CLS / TD_CLS); copy it rather than re-deriving it.",
        "$header_body_split": "Several kinds use a DIFFERENT class on the header than the body, and this is deliberate: a header label is text, so it takes .cell-num (mono, right-aligned) to sit over its column; the values beneath take the treatment their type calls for (.cell-metric for numbers, .cell-date for dates). Putting the body class on the header makes a numeric column's label read as data.",
        "kinds": [
          {
            "kind": "select",
            "th": "cell-select",
            "td": "cell-select",
            "content": "<input type=\"checkbox\">",
            "width": "44px fixed",
            "use": "Row selection. Always the first column.",
            "avoid": "Anywhere but first — a checkbox mid-row is unfindable."
          },
          {
            "kind": "id",
            "th": "cell-id",
            "td": "cell-id",
            "content": "<span class=\"id-chip\">",
            "type": "--font-mono 11px --slate-100",
            "width": "hugs (width 1%, nowrap)",
            "use": "The record's identifier. Always a chip, and always hugging — an ID column that stretches wastes the row's most valuable horizontal space.",
            "avoid": "Plain text. The chip is what makes the ID copyable and scannable."
          },
          {
            "kind": "icon",
            "th": "cell-icon",
            "td": "cell-icon",
            "content": "Either a bare Material Symbol (20px, --graphite-bg) or a .row-avatar plate around one, with a data-tip naming the value",
            "width": "fixed narrow",
            "use": "A type or kind column where the glyph IS the value — automation type, channel, platform.",
            "avoid": "Decorating a name. A glyph beside a label belongs inside the name cell (.name-cell), not in its own column.",
            "note": "Used for Visibility in the data-table template — open (language) / private (lock). A two-state type dimension is exactly this kind: the glyph is the value, the word lives in the tooltip.",
            "plate": "Use the bare glyph when the column states a PROPERTY (visibility, type) — a plate reads as a badge, which implies a state. Reserve .row-avatar for an identity or entity column, where the plate stands in for an object."
          },
          {
            "kind": "link",
            "th": null,
            "td": "cell-name-slate",
            "content": "The object name in an <a>, coloured var(--link) by .cell-name-slate",
            "use": "The row's primary name, linking to the record. One per row.",
            "avoid": "Two link columns — the reader cannot tell which one is the row.",
            "token": "--link (--slate-120 light, --slate-contrast dark) — NOT --slate-100, which is theme-fixed. Measured 6.32 card / 5.09 row-hover light, 9.13 / 7.52 dark. Hover takes --link-hover.",
            "note": "When the row's object is inactive, the name takes .is-inactive-name — italic, --graphite-bg — instead of the normal --link colour, so an inactive record reads differently without a badge or extra column."
          },
          {
            "kind": "text",
            "th": null,
            "td": null,
            "content": "Plain value, inherits --graphite-90",
            "use": "A categorical or descriptive value: owner, billing, format, rate card.",
            "avoid": "Numbers. They need right alignment to compare down the column."
          },
          {
            "kind": "count",
            "th": "cell-num",
            "td": "cell-metric",
            "content": "An integer",
            "align": "right",
            "use": "A countable quantity — child records, packages, advertisers.",
            "avoid": "Left alignment. Digits only line up when the column is right-aligned."
          },
          {
            "kind": "money",
            "th": "cell-num",
            "td": "cell-metric",
            "content": "A pre-formatted currency figure",
            "align": "right",
            "use": "Budget, spend, CPM.",
            "avoid": "Abbreviating in the cell when the exact figure fits — abbreviate on axes, not in tables."
          },
          {
            "kind": "flight",
            "use": "Never a single column. Flight is always two date columns — 'Flight start' then 'Flight end' — each kind 'date', so each sorts and filters on its own. The same applies to detail panels and activity rows: two rows, not one range."
          },
          {
            "kind": "range-compare",
            "modifier_of": ["count", "money"],
            "td": "has-delta (added to the metric's cell-metric td)",
            "content": "The metric value, then <span class=\"delta is-good|is-bad\"> after it on the same line, in a fixed-width slot, holding an arrow_drop_up / arrow_drop_down glyph and the percent change (12.4%). --fs-ui-small. Zero renders 0.0% alone, no arrow, --graphite-70.",
            "use": "Range compare: percent change vs the compare range, shown after the metric in its own cell. The column sorts by the metric.",
            "mode": "Always percent, one decimal. No absolute option.",
            "header": "Left-aligned, unlike other numeric headers — the cell holds two values, so the label sits at the start of the column rather than over either one.",
            "no_prior": "A row with no prior value shows no change — the slot is empty, not a dash, not 0%, not 'New'. A prior of 0 is treated the same, since it has no meaningful percentage. The empty slot still renders so values stay aligned.",
            "colour": "Colour follows good/bad, the arrow follows direction. Up is good by default; set compare.invert for lower-is-better metrics. --success-text / --error-text, never --success / --error, which fail 4.5:1 on the row-hover tint."
          },
          {
            "kind": "date",
            "th": "cell-num",
            "td": "cell-date",
            "content": "A formatted date",
            "type": "--graphite-bg 13px",
            "use": "A date or timestamp. Recedes deliberately: it is context, not the answer.",
            "avoid": "--graphite-90 at body size, which makes every row's timestamp compete with its name.",
            "token": "--graphite-70, which inverts. NOT --graphite-bg: 4.00:1 on the row-hover ground in light, under AA at 13px. Measured 8.71 card / 7.01 hover light, 8.32 / 6.85 dark.",
            "gotcha": "The rule must be `.table-card .table tbody td.cell-date` — at two classes it loses to `.table-card .table tbody td`, which sets the row colour, and the treatment silently never applies."
          },
          {
            "kind": "state",
            "th": "cell-state",
            "td": "cell-state + a tone class",
            "content": "A filled 18px Material Symbol with a data-tip naming the state",
            "tones": [
              "is-success",
              "is-warning",
              "is-error",
              "is-neutral",
              "is-outline (unfilled, for a state that is running rather than settled)"
            ],
            "use": "Lifecycle or status. One icon, one hover label.",
            "avoid": "A text badge per row — a column of badges is a wall of pills. Reserve Badge for a single prominent state, not a column.",
            "position": "Status goes last, after the descriptive and numeric columns. It is the column a reader checks after identifying the row, not while scanning for it.",
            "vocabulary": "Define one STATE map — icon, tone class, and human label per value — and derive the cells, the filter segments, and the criteria-builder options from it. Restating the list at each surface is how a status ends up filterable under a name the cell does not use."
          },
          {
            "kind": "children",
            "th": "cell-children",
            "td": "cell-children-slate",
            "content": "<span class=\"children-stamp\">",
            "align": "center",
            "use": "A count of nested records, stamped rather than plain, because it is a link into a sub-list.",
            "avoid": "A bare number, which reads as a metric rather than a way in."
          },
          {
            "kind": "count-button",
            "th": "cell-count-btn",
            "td": "cell-count-btn",
            "content": "<button class=\"count-btn\">",
            "align": "center",
            "width": "hugs",
            "use": "A count the user can act on — packages, child records, members. Same geometry as .children-stamp but interactive, coloured --link.",
            "avoid": "A stamp when the count opens something, or a button when it does not: a count the user can act on has to look pressable, and one they cannot must not. Zero takes .is-zero — no link colour, no hover, default cursor, because there is nothing behind it.",
            "opens": "Either navigation to the child list, or, the Swivel standard for child objects, an in-place sub-table (see Table.expandable_rows).",
            "expanded": "aria-expanded=\"true\" while its sub-table is open: inset --surface-canvas fill, 2px --slate-base ring, --graphite-90 label."
          },
          {
            "kind": "chips",
            "th": null,
            "td": null,
            "content": "<span class=\"label-stack\"> of .chip.chip-sm, then a .chip-more count",
            "use": "A SET of values per row — creative formats, labels, markets. Show the first three and collapse the rest into \"+n\" with the full list in the tooltip.",
            "avoid": "A fourth chip, which pushes the row wider than the column can hold; and a chip for the overflow count, which would read as one more value of the same kind rather than a count of the hidden ones.",
            "filtering": "A set-valued column matches if ANY of the row's values is selected. Derive the filter options from the data ([...new Set(DATA.flatMap(r => r.formats))].sort()) and share that one list with every filter surface on the page — a hardcoded option list silently omits values the rows already display, so the user sees a chip they cannot filter on.",
            "sorting": "A set sorts by its string coercion, which orders by first value then subsequent. Sort on length or [0] if a different order is wanted."
          },
          {
            "kind": "mark",
            "th": null,
            "td": "inside .name-cell",
            "content": "<span class=\"verified-mark\"> before the name",
            "use": "A boolean fact about the object that qualifies its name — guaranteed delivery, verified, locked. Icon-only, with the meaning in the tooltip.",
            "avoid": "A whole column, and a word: both would repeat down every row. Render it absent when it does not apply rather than greyed.",
            "token": "--slate-base. Not --success: the mark states a property of the object, not a healthy state, and semantic green would compete with the status column where good and bad actually live. Not --slate-100 either: the ramp steps are theme-fixed and the mark sits visibly dim in dark."
          },
          {
            "kind": "progress",
            "th": null,
            "td": null,
            "content": "ProgressCell or FlightProgress",
            "use": "Delivery, pacing, or a flight. Always carries the numbers in a tooltip.",
            "avoid": "A bar with no figures — see the progress-in-a-cell-needs-the-numbers rule."
          },
          {
            "kind": "lifecycle",
            "th": "cell-lifecycle",
            "td": "cell-lifecycle",
            "content": "<span class=\"lifecycle\"> with one .lc-bar per stage",
            "width": "hugs",
            "use": "Position in a fixed sequence of named stages.",
            "avoid": "A ProgressBar — the stages are discrete, so a continuous rail invites reading a percentage that does not exist."
          },
          {
            "kind": "actions",
            "th": "cell-actions",
            "td": "cell-actions",
            "content": "An IconButton, revealed on row hover or keyboard focus",
            "width": "hugs right",
            "use": "One per-row action. Always the last column.",
            "avoid": "More than one or two. A third action belongs in an overflow menu, and always-visible glyphs compete with the data."
          }
        ],
        "$grounds": "A cell has TWO grounds, not one: --surface-base and --slate-bg, the row-hover tint. The hover ground is the harder of the two and the one a row is read on most of the time, since pointing anywhere in the row lights it. Measure text against both. --slate-100 fails both (4.25 / 3.28 light); --graphite-bg fails the hover ground (4.00 light)."
      },
      "expandable_rows": {
        "$note": "The standard Swivel way to show what a count is made of: click the square count in a row, and the records behind it open as a sub-table directly beneath that row. Use it whenever a row's column counts child objects the user wants to scan without leaving the list: demand tags or creatives on a campaign, packages on a deal, line items on an order.",
        "trigger": "A .count-btn in a cell-count-btn column. Nothing else opens a sub-table: no chevron column, no row click, no separate expand icon. The count is both the number and the way in.",
        "markup": [
          "<tr class=\"is-expanded\">  …  <td class=\"cell-count-btn\"><button type=\"button\" class=\"count-btn\" aria-expanded=\"true\" aria-controls=\"sub-1284511-tags\" aria-label=\"6 demand tags, hide\">6</button></td> … </tr>",
          "<tr class=\"subtable-row\" id=\"sub-1284511-tags\"><td colspan=\"<all columns>\"><div class=\"subtable\"><table class=\"table\"> <thead>…</thead> <tbody>…</tbody> </table><div class=\"subtable-foot\">…optional…</div></div></td></tr>"
        ],
        "behaviour": [
          "Clicking a closed count opens its sub-table directly beneath the row and sets aria-expanded=\"true\" on the button and .is-expanded on the row.",
          "Clicking the open count closes it. Escape while focus is inside the sub-table closes it and returns focus to the count.",
          "One sub-table per row. Clicking a different count in the same row swaps the sub-table in place (Demand tags → Creatives) rather than stacking a second one.",
          "Rows are independent: opening a sub-table on one row leaves other rows' sub-tables as they are.",
          "Sorting, filtering, paging, or changing density on the parent table closes every open sub-table.",
          "A zero count is .is-zero and opens nothing; it carries no aria-expanded.",
          "Fetch on first open. While loading, the sub-table shows Skeleton rows in its own shape; on failure, a compact ErrorState (.state-panel.state-panel-compact.is-error) with a retry, inside the sub-table frame."
        ],
        "subtable_columns": "First the child's ID (.cell-id with an .id-chip), then its name as a link (.cell-name-slate), then at most three right-aligned counts or metrics. Child names truncate with an ellipsis on one line.",
        "limits": [
          "One level only. Counts inside a sub-table are plain .cell-metric numbers, never another .count-btn.",
          "At most 10 rows. Past that, show the first 10 and a .subtable-foot with \"Showing 10 of 48\" on the left and a \"View all 48 demand tags →\" link to the child list filtered to this parent."
        ],
        "accessibility": "The count is a real <button> with aria-expanded and aria-controls naming the sub-table row's id. Its aria-label states the count and the noun, plus the action (\"6 demand tags, show\" / \"…, hide\"); the visible label stays the bare number.",
        "motion": "The sub-table enters with a 4px downward reveal over --motion-slow on --ease-enter; no animation under prefers-reduced-motion. It leaves without animation."
      }
    },
    {
      "id": "object-card",
      "name": "ObjectCard",
      "category": "data",
      "anchor": "components/ObjectCard/objectcard.html",
      "class_root": ".object-card",
      "note": "One object, scanned as a card rather than a table row. The whole card opens the object's detail page (href/onClick) — there is no separate \"clickable\" variant, every ObjectCard behaves this way, so a kebab (onMore) is how a per-row action coexists with a click-through card. The badge identifies the object, not its status: square icon-over-ID by default (a type or lifecycle icon needs room to read as a symbol), or the short horizontal chip via isPlatform when the icon is a platform mark and the ID belongs to that third-party system. A real differentiator (a lifecycle state, a delivery figure) belongs in stats, where its colour means what it says.",
      "variants": [
        {
          "id": "default",
          "class": "oc-badge",
          "purpose": "Icon over ID, slate (--slate-bg / --slate-100) by default — the badge identifies the object, and slate is this system's identifying tint. lifecycleTone overrides to success/warning/error/neutral when the badge IS the object's lifecycle stage."
        },
        {
          "id": "platform",
          "class": "oc-badge is-platform",
          "purpose": "Short horizontal chip (26px tall). Use ONLY when the leading mark is a platform/third-party logo and the ID is that system's own identifier (e.g. a SpringServe ID). Renders the real partner mark via `platform` (assets/partners/<platform>.png, same source as PlatformIcon) — not a generic Material glyph. badgeIcon is a fallback only. Stays graphite regardless of lifecycleTone — a partner mark is neutral by definition."
        }
      ],
      "slots": [
        {
          "id": "badge",
          "class": "oc-badge",
          "purpose": "Leading platform/source icon + monospace ID, in a short graphite chip. Stops its own click, since the card itself is the target. Sits inside .oc-lead together with the optional rate chip."
        },
        {
          "id": "rate-chip",
          "class": "oc-rate-chip",
          "purpose": "Optional rate or platform detail beside the ID chip (\"$22.88 CPM\") — its own chip, not a second line stacked in the badge"
        },
        {
          "id": "name",
          "class": "oc-name",
          "purpose": "Reads in --link / --link-hover on card hover, since the whole card is the target rather than the name being its own link"
        },
        {
          "id": "meta",
          "class": "oc-meta",
          "purpose": "Provenance line under the name, joined by middots — platform, owner, \"Updated 2h ago\""
        },
        {
          "id": "stats",
          "class": "oc-stats",
          "purpose": "Labelled readouts in body type. This is where a real differentiator belongs — a status, a delivery figure — with its own colour, not doubled onto the badge"
        },
        {
          "id": "actions",
          "class": "oc-actions",
          "purpose": "A custom control (usually a Switch) plus an optional kebab for per-row actions. The kebab is .oc-kebab — no fill at rest, round on hover/focus, icon in --graphite-bg — not a filled button, since it is a secondary affordance next to the primary Switch. Both stop their click before it reaches the card."
        },
        {
          "id": "lead",
          "class": "oc-lead",
          "purpose": "Groups the badge and optional rate chip into ONE grid cell — grid auto-placement gives every direct child of .object-card its own track, so an ungrouped rate chip stole the body's flexible column. .is-stacked (badge over rate) applies only when isPlatform + rate: the short chip is already the width the platform mark needs, so the rate stacks under it rather than beside it."
        }
      ],
      "states": [
        "default",
        "hover",
        "focus"
      ],
      "css_only": false,
      "react": {
        "module": "components/ObjectCard/ObjectCard.jsx",
        "types": "components/ObjectCard/ObjectCard.d.ts",
        "export": "ObjectCard",
        "exports": [
          "ObjectCard",
          "ObjectStack"
        ],
        "required_props": [
          "badgeId",
          "name"
        ],
        "props": [
          "badgeId",
          "badgeIcon",
          "badgeTip",
          "isPlatform",
          "lifecycleTone",
          "platform",
          "basePath",
          "rate",
          "name",
          "href",
          "onClick",
          "meta",
          "stats",
          "actions",
          "onMore",
          "moreLabel"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".object-card",
          "changes": {
            "border": "1px solid --surface-stroke",
            "border-radius": "--radius-md",
            "background": "--surface-base",
            "cursor": "pointer"
          }
        },
        "hover": {
          "trigger": ":hover",
          "changes": {
            "border-color": "--graphite-20",
            "background": "--graphite-10",
            "name-color": "--link-hover"
          }
        },
        "focus": {
          "trigger": ":focus-visible",
          "changes": {
            "box-shadow": "--focus-ring"
          },
          "note": "The card is keyboard-activatable (Enter/Space) since it carries the click, not the name."
        }
      }
    },
    {
      "id": "pagination",
      "name": "Pagination",
      "category": "data",
      "anchor": "Swivel Component Library.html#c-tables",
      "class_root": ".table-pagination",
      "note": "Sits directly below the .table inside its .table-card — never floated on the page canvas. Every list table carries one, even an unpaged list (use countOnly for a bare count).",
      "variants": [
        {
          "id": "paged",
          "class": null,
          "purpose": "Default — rows-per-page menu, range label, prev/next nav"
        },
        {
          "id": "count-only",
          "class": null,
          "purpose": "countOnly: \"Showing N\" with no menu or nav, for lists that are not paged"
        }
      ],
      "slots": [
        {
          "id": "rows-per-page",
          "class": "tp-rpp",
          "purpose": "Label plus .tp-rpp-value button opening a listbox of page sizes"
        },
        {
          "id": "range",
          "class": "tp-range",
          "purpose": "Range label — \"1–25 of 311 buyers\""
        },
        {
          "id": "nav",
          "class": "tp-nav",
          "purpose": "Prev/next icon buttons, 28px, disabled at the ends"
        }
      ],
      "states": [
        "default",
        "rpp-hover",
        "rpp-open",
        "option-selected",
        "nav-hover",
        "nav-disabled"
      ],
      "css_only": false,
      "react": {
        "module": "components/Pagination/Pagination.jsx",
        "types": "components/Pagination/Pagination.d.ts",
        "export": "Pagination",
        "controlled_props": [
          "page",
          "pageSize"
        ],
        "required_props": [
          "total"
        ]
      },
      "state_matrix": {
        "default": {
          "selector": ".table-pagination",
          "changes": {
            "font-family": "--font-display",
            "font-size": "--fs-ui-small",
            "font-weight": "600",
            "color": "--graphite-90"
          }
        },
        "rpp-hover": {
          "trigger": ":hover",
          "selector": ".tp-rpp-value",
          "changes": {
            "background": "--slate-bg"
          }
        },
        "rpp-open": {
          "trigger": "aria-expanded=\"true\"",
          "selector": ".tp-rpp-value",
          "changes": {
            "note": "Listbox opens upward (bottom: calc(100% + 6px)) so it clears the page edge below the card."
          }
        },
        "option-selected": {
          "selector": "[role=\"listbox\"] [role=\"option\"].is-selected",
          "changes": {
            "background": "--slate-bg",
            "color": "--slate-contrast"
          }
        },
        "nav-hover": {
          "trigger": ":hover",
          "selector": ".tp-nav button",
          "changes": {
            "background": "--slate-bg"
          }
        },
        "nav-disabled": {
          "trigger": ":disabled",
          "selector": ".tp-nav button",
          "changes": {
            "color": "--graphite-30",
            "cursor": "default"
          }
        }
      }
    },
    {
      "id": "section",
      "name": "Section",
      "category": "containers",
      "anchor": "components/Section/section.html",
      "class_root": ".section",
      "note": "A titled, optionally collapsible block within a stack — reconciles the two ad hoc implementations that existed before it (beacon-workspace's icon-swap card-header, Data Viz Dashboard's rotating chevron). One chevron, rotating -90deg when collapsed; the body collapses via max-height + opacity rather than a hard cut, so multiple stacked sections animate consistently.",
      "variants": [
        {
          "id": "elevated",
          "class": null,
          "purpose": "Default — --surface-base with --elev-1, no border. For a section standing on its own."
        },
        {
          "id": "outline",
          "class": "is-outline",
          "purpose": "Border, no elevation — for a stack of sections inside a page that already has its own elevation, so they don't double up."
        }
      ],
      "slots": [
        {
          "id": "header",
          "class": "section-header",
          "purpose": "Title, optional meta line, and the chevron, with a --surface-stroke hairline beneath separating it from the body. Clickable and keyboard-operable (Enter/Space, aria-expanded, aria-controls) when collapsible. The hairline drops when collapsed — nothing left to divide from."
        },
        {
          "id": "title",
          "class": "section-title",
          "purpose": "--font-display 600 at --fs-ui-title."
        },
        {
          "id": "meta",
          "class": "section-meta",
          "purpose": "Secondary line under the title — a timestamp, range, or count."
        },
        {
          "id": "chevron",
          "class": "section-chev",
          "purpose": "keyboard_arrow_down, pointing down when open. Rotates 180deg to point up when collapsed, rather than swapping to a different glyph, so the motion reads as the same element turning."
        },
        {
          "id": "body",
          "class": "section-body",
          "purpose": "Collapses via max-height + opacity, not display:none — display:none has no transition, so a stack of sections collapsing together reads as a jump cut instead of a fold."
        }
      ],
      "states": [
        "open",
        "collapsed",
        "not-collapsible"
      ],
      "css_only": false,
      "react": {
        "module": "components/Section/Section.jsx",
        "types": "components/Section/Section.d.ts",
        "export": "Section",
        "controlled_props": [
          "open"
        ],
        "required_props": [
          "title"
        ],
        "props": [
          "title",
          "meta",
          "children",
          "collapsible",
          "defaultOpen",
          "open",
          "onToggle",
          "outline"
        ]
      },
      "state_matrix": {
        "open": {
          "selector": ".section:not(.is-collapsed) .section-body",
          "changes": {
            "max-height": "2000px",
            "opacity": "1"
          }
        },
        "collapsed": {
          "trigger": ".is-collapsed",
          "changes": {
            "section-body max-height": "0",
            "section-body opacity": "0",
            "chevron transform": "rotate(180deg)"
          }
        },
        "not-collapsible": {
          "trigger": "collapsible={false}",
          "changes": {
            "note": "No chevron, header is not a button — a plain titled block."
          }
        }
      },
      "replaces": "The hand-rolled card-header + keyboard_arrow_up/down swap in templates/beacon-workspace and the .chart-card head/chev pattern in Data Viz Dashboard Demo.html. Both predate this component and should migrate to it rather than keep diverging."
    },
    {
      "id": "skeleton",
      "name": "Skeleton",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-skeleton",
      "purpose": "Loading placeholder shape that matches the eventual content",
      "css_only": false,
      "react": {
        "module": "components/Skeleton/Skeleton.jsx",
        "types": "components/Skeleton/Skeleton.d.ts",
        "export": "Skeleton",
        "variant_prop": "shape",
        "default_variant": "text"
      },
      "class_root": ".skeleton",
      "variants": [
        {
          "id": "text",
          "class": "skeleton-text"
        },
        {
          "id": "title",
          "class": "skeleton-title"
        },
        {
          "id": "block",
          "class": "skeleton-block"
        },
        {
          "id": "circle",
          "class": "skeleton-circle"
        }
      ],
      "note": "Shape must match the content it stands in for. Sheen animation is suppressed under prefers-reduced-motion.",
      "state_matrix": {
        "loading": {
          "selector": ".skeleton",
          "changes": {
            "background": "linear-gradient(--surface-muted → --graphite-10 → --surface-muted)",
            "animation": "skeleton-sheen 1.4s infinite"
          }
        },
        "reduced-motion": {
          "trigger": "@media (prefers-reduced-motion: reduce)",
          "changes": {
            "animation": "none",
            "background": "--surface-muted"
          }
        }
      },
      "states": [
        "loading",
        "reduced-motion"
      ]
    },
    {
      "id": "empty_state",
      "name": "EmptyState",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-empty-state",
      "slots": [
        "icon-or-illustration",
        "headline",
        "body",
        "primary-action",
        "secondary-action"
      ],
      "css_only": false,
      "react": {
        "module": "components/EmptyState/EmptyState.jsx",
        "types": "components/EmptyState/EmptyState.d.ts",
        "export": "EmptyState"
      },
      "class_root": ".state-panel",
      "note": "Icon + headline + one line of guidance + an action. Add .state-panel-compact (compact prop) inside a card; use .table-card-empty for the in-table variant.",
      "state_matrix": {
        "default": {
          "selector": ".state-panel",
          "changes": {
            "icon-color": "--slate-100",
            "icon-size": "32px",
            "padding": "64px --space-xxl"
          }
        },
        "compact": {
          "selector": ".state-panel-compact",
          "changes": {
            "padding": "--space-xxl --space-lg"
          }
        }
      },
      "states": [
        "default",
        "compact"
      ]
    },
    {
      "id": "error_state",
      "name": "ErrorState",
      "category": "feedback",
      "anchor": "Swivel Component Library.html#c-error-state",
      "slots": [
        "icon",
        "headline",
        "body",
        "retry-action"
      ],
      "css_only": false,
      "react": {
        "module": "components/ErrorState/ErrorState.jsx",
        "types": "components/ErrorState/ErrorState.d.ts",
        "export": "ErrorState"
      },
      "class_root": ".state-panel.is-error",
      "note": "Same anatomy as EmptyState with .is-error tinting the icon and role=\"alert\" on the panel. Always offer a retry or a way forward.",
      "state_matrix": {
        "default": {
          "selector": ".state-panel.is-error",
          "changes": {
            "icon-color": "--error",
            "role": "alert"
          }
        },
        "compact": {
          "selector": ".state-panel.is-error.state-panel-compact",
          "changes": {
            "padding": "--space-xxl --space-lg"
          }
        }
      },
      "states": [
        "default",
        "compact"
      ]
    }
  ],
  "form_guidance": [
    {
      "id": "label_placement",
      "anchor": "Swivel Component Library.html#c-fields-usage",
      "topic": "When to use stacked labels vs. inset labels vs. floating labels",
      "rule": "Above (stacked) when the field defines an object property; inset when it affects page display; no label + named placeholder inside a labeled toolbar row. EXCEPTION: inside a Modal, always use inset labels, even for a field that defines an object property — a modal is already a compact bounded surface, and inset keeps its form dense. Reserve the stacked/above pattern for full page forms. Modal fields additionally set floating (TextField/TagInput's `floating` prop): the label stays hidden and the input shows a placeholder until the field has a value or focus, at which point the label floats to the notch — denser still than a permanently-visible inset label, and it doubles as an empty-state hint."
    },
    {
      "id": "required_and_optional",
      "topic": "Required field markers (red asterisk) and optional-field hinting"
    },
    {
      "id": "help_text_and_errors",
      "topic": "Single-line help under the field; errors replace help text and add an icon"
    },
    {
      "id": "sizing_and_density",
      "topic": "Match field height to surrounding control height (sm / md / lg)"
    },
    {
      "id": "grouping_and_layout",
      "topic": "Two-column form layout, field-group spacing, related-field grouping"
    }
  ],
  "control_heights": {
    "sm": {
      "token": "--control-h-sm",
      "px": 24
    },
    "md": {
      "token": "--control-h",
      "px": 34
    },
    "lg": {
      "token": "--control-h-lg",
      "px": 40
    }
  },
  "state_conventions": {
    "$note": "Every state below is expressed as { trigger, selector, changes }. `changes` maps a CSS property to the token or literal it resolves to. Selectors are the real hooks in design-system/components.css — a linter can assert against them directly.",
    "focus_ring": {
      "token": "--focus-ring",
      "value": "0 0 0 3px rgba(168,248,255,0.4)",
      "rule": "Never remove a focus ring. Interactive elements use :focus-visible."
    },
    "disabled": {
      "rule": "Disabled controls drop to opacity 0.4 (buttons) or a --graphite-10 fill with --graphite-50 text (fields), and always set pointer-events: none.",
      "selectors": [
        ":disabled",
        ".is-disabled"
      ]
    },
    "error": {
      "rule": "Error borders use --error; the focus ring becomes a 3px --error mix. Help text turns --error.",
      "selectors": [
        ".is-error"
      ]
    },
    "press": {
      "rule": "Press is scale(0.98) over 80ms with no color change.",
      "token": "--motion-fast"
    }
  },
  "selection_cheat_sheet": {
    "$note": "Intent → component, for the pairs that are genuinely confusable. Read this before components[] — the per-component \"note\" fields explain a choice you have already made, this table makes the choice. Where two entries look alike, the \"not\" column is the point.",
    "binary_input": [
      {
        "need": "A value submitted with a form or list — multi-select, table row selection, an opt-in",
        "use": "Checkbox",
        "not": "Switch — a checkbox is pending until the form is saved"
      },
      {
        "need": "A setting that takes effect the moment it is flipped",
        "use": "Switch",
        "not": "Checkbox — a switch has no save step"
      },
      {
        "need": "Light / dark mode",
        "use": "ThemeSwitch",
        "not": "Switch or a ButtonGroup — this control is canonical and must actually work"
      }
    ],
    "single_choice": [
      {
        "need": "Two to five short options, all visible, used to filter or segment a view",
        "use": "ButtonGroup",
        "not": "A SelectField — hiding a short filter set behind a menu costs a click for nothing"
      },
      {
        "need": "Six or more options, or long labels",
        "use": "SelectField",
        "not": "A ButtonGroup — it will wrap and lose its segmented read"
      },
      {
        "need": "Anything that saves, runs, deletes, or navigates",
        "use": "Button / IconButton / SplitButton",
        "not": "A ButtonGroup — it is selection only, never an action"
      },
      {
        "need": "A single choice from a fixed small set in a form",
        "use": "ButtonGroup or SelectField",
        "not": "Radio buttons — this system has none"
      }
    ],
    "multi_value_input": [
      {
        "need": "Pick several from a known list",
        "use": "SelectField multi (chips)",
        "not": "A count label like \"3 selected\" — the values must stay visible"
      },
      {
        "need": "Type arbitrary values — IDs, keywords, emails",
        "use": "TagInput",
        "not": "A SelectField — there is no list to pick from"
      },
      {
        "need": "A bare option list inside a popover you own",
        "use": "SelectMenu",
        "not": "SelectField — that is the field plus this menu"
      },
      {
        "need": "A prose answer — a note, a description, a reason",
        "use": "Textarea",
        "not": "TextField — a single-line input hides everything past the first line"
      }
    ],
    "navigation": [
      {
        "need": "Get back up the hierarchy from a detail page",
        "use": "Breadcrumb, above the page h1",
        "not": "A back link — a trail shows where you are, not just where you came from. And never a heading-scale trail: the breadcrumb never replaces the <h1>."
      },
      {
        "need": "Switch between views of the same object",
        "use": "Tabs",
        "not": "A ButtonGroup — tabs swap a panel, a group filters a list"
      },
      {
        "need": "Move between top-level areas of the product",
        "use": "Sidenav",
        "not": "Tabs"
      }
    ],
    "status_and_feedback": [
      {
        "need": "A persistent state word on a row or object",
        "use": "Badge",
        "not": "Chip — a chip is a value the user put there, a badge is a state the system reports"
      },
      {
        "need": "A value the user selected or entered, removable",
        "use": "Chip",
        "not": "Badge"
      },
      {
        "need": "A receipt for something the user already did — saved, paused, archived",
        "use": "Toast (useToasts + ToastRegion)",
        "not": "An inline Alert — it would persist on a page the user is done with"
      },
      {
        "need": "Anything the user must read, act on, or refer back to",
        "use": "Alert, inline where it applies",
        "not": "A Toast — it disappears, so the task becomes unfinishable"
      },
      {
        "need": "A message about the page or form, staying put",
        "use": "Alert",
        "not": "A Modal — do not block the page for information"
      },
      {
        "need": "Extra detail on hover for an icon-only control",
        "use": "Tooltip / data-tip",
        "not": "Help text — and never a tooltip for information the user needs to complete a field"
      }
    ],
    "containers": [
      {
        "need": "Group related content on a page",
        "use": "Card",
        "not": "A bare div — surface, stroke, and radius are contracted"
      },
      {
        "need": "A decision that must be answered before continuing",
        "use": "Modal",
        "not": "Drawer"
      },
      {
        "need": "Secondary or ephemeral UI beside the page — filters, detail, settings",
        "use": "Drawer",
        "not": "Modal — a drawer does not block, and never holds primary navigation"
      },
      {
        "need": "A titled block the user can collapse",
        "use": "Section",
        "not": "A hand-rolled card-header with a chevron icon you swap on click"
      }
    ],
    "async_regions": [
      {
        "need": "Data is loading",
        "use": "Skeleton, shaped like the content that will land",
        "not": "A spinner or a blank card"
      },
      {
        "need": "The query returned nothing",
        "use": "EmptyState",
        "not": "A blank card"
      },
      {
        "need": "The request failed",
        "use": "ErrorState with a retry",
        "not": "EmptyState — the user needs to know it can be retried"
      }
    ],
    "tables": [
      {
        "need": "Any list of records",
        "use": "Table (.table-card + .table-toolbar + .table + Pagination)",
        "not": "A bare <table>"
      },
      {
        "need": "Row count and page navigation",
        "use": "Pagination",
        "not": "Hand-rolled markup — it exists as a component"
      },
      {
        "need": "A count on a list that is not paged",
        "use": "Pagination countOnly",
        "not": "A loose <span>"
      },
      {
        "need": "See the child records behind a count in a row (demand tags, creatives, packages) without leaving the list",
        "use": "Count expander: .count-btn opening a sub-table beneath the row (Table.expandable_rows)",
        "not": "A chevron column, a click-anywhere row, a Drawer, or a Modal. The count is the way in, and the children stay in the context of their parent"
      }
    ],
    "data_viz": [
      {
        "need": "A continuous metric over time",
        "use": "Chart type=\"line\"",
        "not": "Bars — they compare categories, not a trajectory"
      },
      {
        "need": "Magnitude over time where the total matters more than the rate of change",
        "use": "Chart type=\"area\"",
        "not": "line — the fill is the point"
      },
      {
        "need": "Ranked categories, \"top N\"",
        "use": "Chart type=\"bar-horizontal\", sorted by value",
        "not": "A vertical bar chart — long labels do not fit under vertical bars"
      },
      {
        "need": "Part of a whole where the total is also interesting",
        "use": "Chart type=\"bar\" stacked, six slices maximum",
        "not": "A pie chart — this system has none"
      },
      {
        "need": "One dominant percentage or count with a few supporting slices",
        "use": "Donut with a centre anchor, four slices maximum",
        "not": "A pie — the anchor is what justifies the format"
      },
      {
        "need": "Actual versus target",
        "use": "Bullet",
        "not": "A gauge — it reads angle instead of length"
      },
      {
        "need": "How far along something is against a known whole — delivery, an upload, an export",
        "use": "ProgressBar",
        "not": "Bullet — that is actual against a target with qualitative bands"
      },
      {
        "need": "Actual against a target where the bands matter (behind / on / ahead)",
        "use": "Bullet",
        "not": "ProgressBar — it carries one reference line, not a qualitative range"
      },
      {
        "need": "Where a flight is in its window, and which days it went dark",
        "use": "FlightProgress",
        "not": "ProgressBar — it measures one quantity and cannot show gaps"
      },
      {
        "need": "How much of a goal has been delivered",
        "use": "ProgressBar",
        "not": "FlightProgress — that measures elapsed time, which is not the same as delivery"
      },
      {
        "need": "Pacing in a table column",
        "use": "ProgressCell",
        "not": "A bare percentage — the bar makes the row scannable at a glance"
      },
      {
        "need": "One number on a dashboard",
        "use": "KpiCard",
        "not": "A chart with one point"
      },
      {
        "need": "Trend beside a number, in a table cell or a card",
        "use": "Sparkline",
        "not": "A full Chart — no axes fit at that size"
      },
      {
        "need": "More than three series",
        "use": "Small multiples",
        "not": "Stacking them on one plot"
      },
      {
        "need": "Axis labels on a plot you built yourself",
        "use": "ChartFormat.axis",
        "not": "Hand-formatted strings — the currency, suffix, and precision rules are easy to half-apply"
      }
    ],
    "dates": [
      {
        "need": "A date that is a property of the object being edited",
        "use": "DateField (default, stacked label)",
        "not": "variant=\"inset-label\" — a stacked label reads as a form field being filled in"
      },
      {
        "need": "A relative range — last 7 days, this week",
        "use": "DatePicker mode=\"range\" presets",
        "not": "Making the user count backwards from today in the grid"
      },
      {
        "need": "A date that changes what a table or list shows",
        "use": "DateField variant=\"inset-label\"",
        "not": "The stacked default — an inset label reads as a control acting on the view, and fits a --control-h toolbar row"
      },
      {
        "need": "A single date the user types more often than picks",
        "use": "DateField, with onTrailingClick opening a DatePicker",
        "not": "A bare TextField — the calendar affordance is what says this is a date"
      },
      {
        "need": "A flight, campaign window, or reporting period",
        "use": "DatePicker mode=\"range\" — two months, ribbon fill",
        "not": "Two separate single pickers — the user cannot see the span"
      },
      {
        "need": "A time of day, usually a round one",
        "use": "TimePicker (dial, the default)",
        "not": "A TextField — the dial is the M3 spec and handles the meridiem"
      },
      {
        "need": "A time needing exact minutes, or a time field in a dense form",
        "use": "TimePicker defaultMode=\"input\"",
        "not": "A bare TextField — the toggle lets the user go back to the dial"
      },
      {
        "need": "Anything about picker behaviour the components do not cover",
        "use": "The Material Design 3 spec, then recolour through the --md-* tokens",
        "not": "Inventing a Swivel-specific interaction — the policy is M3 structure, Swivel colour only"
      }
    ],
    "activity": [
      {
        "need": "What a user or agent did, with the ability to see what changed",
        "use": "ActivityPanel",
        "not": "A Table — it cannot carry a nested before/after diff, and it offers sorting and selection an audit log does not have"
      },
      {
        "need": "A record that was created",
        "use": "detail mode 'value'",
        "not": "mode 'diff' with empty befores — there was no previous value"
      },
      {
        "need": "A record that was changed",
        "use": "detail mode 'diff'",
        "not": "mode 'value' showing only the new state — the previous value is why the row is opened"
      },
      {
        "need": "Filters for an activity stream",
        "use": ".filter-rail beside the stream — date range, user select, grouped checkbox facets",
        "not": "A filter bar above it: four axes either wrap or push the first event below the fold"
      },
      {
        "need": "An event with nothing to expand",
        "use": "Omit detail; the chevron disappears",
        "not": "An empty detail table"
      }
    ],
    "table_cells": [
      {
        "need": "A record identifier",
        "use": "kind id — .cell-id with an .id-chip, hugging",
        "not": "Plain text in a stretched column"
      },
      {
        "need": "A number to compare down the column",
        "use": "kind count or money — .cell-num header, .cell-metric body, right-aligned",
        "not": "Left-aligned text; digits only line up when right-aligned"
      },
      {
        "need": "A status or lifecycle state",
        "use": "kind state — one filled 18px icon with a hover label",
        "not": "A Badge per row; a column of pills is a wall"
      },
      {
        "need": "A date or timestamp",
        "use": "kind date — .cell-date, --graphite-bg at 13px",
        "not": "Body size at full strength, which competes with the row name"
      },
      {
        "need": "Position in a fixed set of stages",
        "use": "kind lifecycle — .lifecycle segments",
        "not": "ProgressBar; the stages are countable"
      },
      {
        "need": "Delivery or pacing",
        "use": "kind progress — ProgressCell with the figures in a tooltip",
        "not": "A bare bar"
      },
      {
        "need": "A per-row action",
        "use": "kind actions — last column, .cell-actions, revealed on hover",
        "not": "A column of always-visible glyphs, or three actions inline"
      },
      {
        "need": "A count that opens a sub-list",
        "use": "kind children — .children-stamp, centred",
        "not": "A bare number, which reads as a metric"
      },
      {
        "need": "A count the user can click into",
        "use": "kind count-button — .count-btn, --link coloured, .is-zero when empty",
        "not": "A .children-stamp, which does not look pressable"
      },
      {
        "need": "Several values in one cell",
        "use": "kind chips — first three plus a \"+n\" carrying the full list",
        "not": "All of them, which widens the row unpredictably"
      },
      {
        "need": "A boolean qualifying the object name",
        "use": "kind mark — an icon before the name inside .name-cell",
        "not": "Its own column, or a word repeated down every row"
      }
    ],
    "lists": [
      {
        "need": "Homogeneous records to sort, select, and act on in bulk",
        "use": "The data-table page",
        "not": "Cards, which cannot align values for comparison"
      },
      {
        "need": "Records whose state is the first thing scanned for, with a few attributes read together",
        "use": "ObjectCard in an ObjectStack — every card opens its detail page; add onMore for a per-row action",
        "not": "A table, which flattens them into columns that only make sense read together"
      },
      {
        "need": "Two or more related record types on one page",
        "use": "The tabbed list page",
        "not": "Separate pages"
      },
      {
        "need": "What happened and what changed",
        "use": "ActivityPanel",
        "not": "Either of the above"
      }
    ]
  },
  "$categories": {
    "$note": "The complete, closed list. Every component carries exactly one. Adding a component means placing it here, not inventing a category — the previous set had fields vs forms and data vs data-display splitting the same idea, a filters category with one member, and Switch filed under actions.",
    "actions": "Controls that DO something — save, run, delete, open. Never a ButtonGroup.",
    "inputs": "Controls that collect or select a value, including the binary and segmented ones. A ButtonGroup is selection, so it lives here rather than with actions.",
    "navigation": "Controls that move the user between places, or show where they are.",
    "data": "Presenting records and the values inside them — the table composition, its pagination, and the marks that appear in cells.",
    "data-viz": "Encoding quantity as position, length, angle, or colour. Governed additionally by design-system/charts.rules.yaml.",
    "feedback": "Telling the user what happened, what is happening, or what is missing — including the absent and failed states of a region.",
    "containers": "Surfaces that hold other content, whether inline or overlaid.",
    "brand": "Expressive, Swivel-specific elements with no generic equivalent."
  }
}
