{
  "$comment": "The Since Labs component system, generated from components/registry.mjs. Copy components/ui/ wholesale; taking half of it is how a codebase ends up with two button styles. Read usage.terms in brand.json before reproducing any mark.",
  "name": "Since Labs component system",
  "version": "2.3.0",
  "updated": "2026-08-28",
  "canonical": "https://brand.sincelabs.com/components/",
  "source": "https://github.com/sincelabs/brand/tree/main/components/ui",
  "brand": "https://brand.sincelabs.com/brand.json",
  "install": {
    "requires": [
      "The token stylesheets: tokens/since-brand.css and tokens/since-motion.css",
      "Tailwind v4 users: tokens/tailwind-theme.css, imported after the other two",
      "The three faces resolving to --font-lora, --font-dm-sans and --font-geist-mono",
      "THEME_SCRIPT in <head>, before first paint, or the page flashes the wrong theme"
    ],
    "import": "import { Button, Panel, StatusChip } from \"@/components/ui\";",
    "procedure": "https://brand.sincelabs.com/docs/adoption.md"
  },
  "conventions": {
    "colour": "#C4602A is a fill (4.16:1 on white, below AA for text). #A34A1F is what you read. This single substitution is the most common defect in Since Labs code.",
    "themes": "Every component works in both themes. The brand inverts rather than switching palettes, and a light-only component is half-built.",
    "motion": "One easing curve, nothing over 320ms, six sanctioned keyframes. Use .sl-transition, never transition-all.",
    "client": "Components marked client: true carry \"use client\". The rest are server-safe."
  },
  "categories": [
    {
      "id": "layout",
      "title": "Layout",
      "blurb": "The frame a page sits in."
    },
    {
      "id": "actions",
      "title": "Actions",
      "blurb": "Things a person presses."
    },
    {
      "id": "forms",
      "title": "Forms",
      "blurb": "Things a person fills in."
    },
    {
      "id": "navigation",
      "title": "Navigation",
      "blurb": "Moving between views and pages."
    },
    {
      "id": "data",
      "title": "Data",
      "blurb": "Records, figures and tables."
    },
    {
      "id": "feedback",
      "title": "Status and feedback",
      "blurb": "What is happening, and what happened."
    },
    {
      "id": "overlays",
      "title": "Overlays",
      "blurb": "Surfaces that sit above the page."
    },
    {
      "id": "identity",
      "title": "Identity",
      "blurb": "Marks, icons and the theme."
    }
  ],
  "components": [
    {
      "id": "app-shell",
      "name": "AppShell",
      "category": "layout",
      "client": true,
      "summary": "The product frame: a recessed nav rail, a content column, and the same rail behind a drawer on a phone.",
      "props": [
        {
          "name": "brand",
          "type": "ReactNode",
          "required": true,
          "description": "The lockup at the top of the rail."
        },
        {
          "name": "sections",
          "type": "ShellNavSection[]",
          "required": true,
          "description": "Nav rows, optionally grouped under titles."
        },
        {
          "name": "currentPath",
          "type": "string",
          "required": true,
          "description": "Matched against each href to mark the current row."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "description": "Top-right of the content column: account menu, search."
        },
        {
          "name": "renderLink",
          "type": "(props) => ReactNode",
          "description": "Supply your router's Link. Defaults to a plain anchor."
        }
      ],
      "rules": [
        "Use it rather than rebuilding the layout — it carries the skip link, the main landmark and the rail recession.",
        "The rail is --sl-paper-base and the content is --sl-paper. That difference is the recession; do not add a shadow."
      ],
      "accessibility": [
        "Renders the skip link and <main id=\"content\"> it targets.",
        "Marks the active row with aria-current=\"page\", not colour alone."
      ],
      "example": "https://brand.sincelabs.com/components/#app-shell"
    },
    {
      "id": "page-header",
      "name": "PageHeader",
      "category": "layout",
      "client": false,
      "summary": "Breadcrumb, eyebrow, h1, description and actions. Every page opens with exactly this.",
      "props": [
        {
          "name": "title",
          "type": "ReactNode",
          "required": true,
          "description": "Rendered as the page's only h1."
        },
        {
          "name": "breadcrumbs",
          "type": "Crumb[]",
          "description": "Ancestors. The last item is the current page and is never a link."
        },
        {
          "name": "eyebrow",
          "type": "ReactNode",
          "description": "Tracked-out uppercase label above the title."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "description": "Page-level buttons, right-aligned."
        }
      ],
      "rules": [
        "Never hand-roll a heading beside it — that is how two pages end up with two title sizes.",
        "One per page. A second PageHeader means a second h1."
      ],
      "accessibility": [
        "Supplies the page's h1. The app audit found eleven pages with no h1 at all; this closes that."
      ],
      "example": "https://brand.sincelabs.com/components/#page-header"
    },
    {
      "id": "card",
      "name": "Card / Panel",
      "category": "layout",
      "client": false,
      "summary": "The default container. Panel adds a titled header rail, and is the one you usually want.",
      "props": [
        {
          "name": "title",
          "type": "ReactNode",
          "description": "Panel only. Renders in the header rail."
        },
        {
          "name": "headingLevel",
          "type": "\"h2\" | \"h3\" | \"h4\"",
          "default": "\"h2\"",
          "description": "Caller-set so the document outline stays correct."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "description": "Controls in the header rail, right-aligned."
        },
        {
          "name": "bodyClassName",
          "type": "string",
          "default": "\"p-5\"",
          "description": "Set to \"\" for a flush table or list."
        }
      ],
      "rules": [
        "White on paper, one hairline border, the barely-there raised shadow. Not two of the three.",
        "Content that needs a name gets a Panel. A bare Card is for content that is already labelled."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#card"
    },
    {
      "id": "separator",
      "name": "Separator",
      "category": "layout",
      "client": false,
      "summary": "A hairline rule, decorative by default, optionally labelled.",
      "props": [
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "default": "\"horizontal\"",
          "description": ""
        },
        {
          "name": "semantic",
          "type": "boolean",
          "default": "false",
          "description": "Announce it. Only where the rule divides two genuinely different groups."
        },
        {
          "name": "label",
          "type": "ReactNode",
          "description": "Renders as a labelled divider, e.g. \"or\". Implies semantic."
        }
      ],
      "rules": [
        "Decorative by default: a rule between two cards is not information."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#separator"
    },
    {
      "id": "eyebrow",
      "name": "Eyebrow",
      "category": "layout",
      "client": false,
      "summary": "The tracked-out uppercase section label. The one place the brand uses capitals.",
      "props": [
        {
          "name": "slash",
          "type": "boolean",
          "default": "false",
          "description": "Prefix with the // mark. One per surface."
        }
      ],
      "rules": [
        "Always above a heading, never alone, never a button."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#eyebrow"
    },
    {
      "id": "button",
      "name": "Button",
      "category": "actions",
      "client": false,
      "summary": "Four variants, three sizes. Primary is the one action the page exists for.",
      "props": [
        {
          "name": "variant",
          "type": "\"primary\" | \"secondary\" | \"ghost\" | \"destructive\"",
          "default": "\"primary\"",
          "description": ""
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "default": "\"lg\"",
          "description": "lg 48px on forms; md 38px inline; sm 32px in table rows only."
        },
        {
          "name": "type",
          "type": "\"button\" | \"submit\"",
          "default": "\"button\"",
          "description": "Defaults to button, NOT the HTML default of submit."
        }
      ],
      "rules": [
        "One primary per surface. Two primaries is no primary.",
        "The primary fill is --sl-accent-ink (#A34A1F), not --sl-accent (#C4602A): white on #C4602A is 4.16:1 and fails AA.",
        "destructive only where something is actually destroyed.",
        "Inside a <form action>, pass type=\"submit\" explicitly — the default here is \"button\" and it fails silently."
      ],
      "accessibility": [
        "Focus ring is instant and never transitioned.",
        "Disabled buttons keep 4.5:1 label contrast at 50% opacity on paper."
      ],
      "example": "https://brand.sincelabs.com/components/#button"
    },
    {
      "id": "dropdown-menu",
      "name": "DropdownMenu",
      "category": "actions",
      "client": true,
      "summary": "A menu of actions hanging off a trigger, with the full ARIA keyboard contract.",
      "props": [
        {
          "name": "trigger",
          "type": "ReactNode",
          "required": true,
          "description": "The button's contents; the button itself is rendered for you."
        },
        {
          "name": "items",
          "type": "MenuItem[]",
          "required": true,
          "description": "Actions and separators. Each item has an onSelect."
        },
        {
          "name": "align",
          "type": "\"start\" | \"end\"",
          "default": "\"start\"",
          "description": "Which edge the panel hangs from."
        }
      ],
      "rules": [
        "Every item does something. Items that set a value are a Select; items that navigate are links in a list.",
        "Closes on outside pointerdown, not click — bound on click, the press that opens it also closes it."
      ],
      "accessibility": [
        "Arrow keys move, Home/End jump, Escape closes and returns focus to the trigger.",
        "Tab is deliberately not trapped: a menu is not a modal."
      ],
      "example": "https://brand.sincelabs.com/components/#dropdown-menu"
    },
    {
      "id": "copy-button",
      "name": "CopyButton / CopyField",
      "category": "actions",
      "client": true,
      "summary": "Copy a value and say so. CopyField pairs it with a read-only input.",
      "props": [
        {
          "name": "value",
          "type": "string",
          "required": true,
          "description": "What lands on the clipboard."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Copy\"",
          "description": ""
        }
      ],
      "rules": [
        "Clipboard access fails on an insecure origin; the failure surfaces as \"Press ⌘C\" rather than a false success."
      ],
      "accessibility": [
        "The confirmation is announced in a polite live region, not only drawn."
      ],
      "example": "https://brand.sincelabs.com/components/#copy-button"
    },
    {
      "id": "text-link",
      "name": "TextLink",
      "category": "actions",
      "client": false,
      "summary": "A link in running text, in the one terracotta that passes AA.",
      "props": [
        {
          "name": "external",
          "type": "boolean",
          "description": "Inferred from the href. Adds rel=noopener noreferrer and says so."
        },
        {
          "name": "underline",
          "type": "boolean",
          "default": "true",
          "description": "Drop it only in chrome where position marks the link."
        }
      ],
      "rules": [
        "Links are --sl-accent-ink (#A34A1F). --sl-accent (#C4602A) is 4.16:1 and fails AA — the app audit counted 62 of these.",
        "Underlined in prose: colour alone is not a distinguishing feature (WCAG 1.4.1)."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#text-link"
    },
    {
      "id": "field",
      "name": "Field",
      "category": "forms",
      "client": false,
      "summary": "The label / hint / error scaffold every control shares. Use it to build a control this set lacks.",
      "props": [
        {
          "name": "ids",
          "type": "FieldIds",
          "required": true,
          "description": "From useFieldIds({ id, hint, error })."
        },
        {
          "name": "required",
          "type": "boolean",
          "description": "Renders the asterisk and the screen-reader-only \"(required)\"."
        }
      ],
      "rules": [
        "An error replaces the hint rather than stacking under it."
      ],
      "accessibility": [
        "useFieldIds wires hint and error into aria-describedby, so they are read as part of the field."
      ],
      "example": "https://brand.sincelabs.com/components/#field"
    },
    {
      "id": "input",
      "name": "Input",
      "category": "forms",
      "client": false,
      "summary": "A labelled single-line text field.",
      "props": [
        {
          "name": "label",
          "type": "ReactNode",
          "description": "Always rendered. A placeholder is not a label."
        },
        {
          "name": "hint",
          "type": "ReactNode",
          "description": "Guidance before typing."
        },
        {
          "name": "error",
          "type": "string",
          "description": "Sets aria-invalid and replaces the hint."
        }
      ],
      "rules": [
        "Never use a placeholder as the label: it disappears exactly when it is needed."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#input"
    },
    {
      "id": "textarea",
      "name": "Textarea",
      "category": "forms",
      "client": false,
      "summary": "Multi-line text. Four rows by default, vertical resize only.",
      "props": [
        {
          "name": "rows",
          "type": "number",
          "default": "4",
          "description": "The browser's 2 invites a two-line answer."
        }
      ],
      "rules": [
        "Horizontal resize is disabled — dragging it wide breaks the page column."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#textarea"
    },
    {
      "id": "select",
      "name": "Select",
      "category": "forms",
      "client": false,
      "summary": "A native select, deliberately. The OS picker beats a custom listbox on a phone.",
      "props": [
        {
          "name": "options",
          "type": "{ value, label, disabled? }[]",
          "description": "Convenience alternative to children."
        }
      ],
      "rules": [
        "Reach for a custom control only when the options need search, multi-select or rich rows — and then it is a DropdownMenu."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#select"
    },
    {
      "id": "checkbox",
      "name": "Checkbox / CheckboxGroup",
      "category": "forms",
      "client": false,
      "summary": "A real input under a styled box, and the fieldset that makes a set of them one question.",
      "props": [],
      "rules": [
        "A checkbox states an intention a Save button will commit. If it takes effect immediately, it is a Switch.",
        "Two or more related boxes go inside a CheckboxGroup, which renders the fieldset and legend."
      ],
      "accessibility": [
        "Native input, so it survives Windows High Contrast Mode, where a styled div disappears."
      ],
      "example": "https://brand.sincelabs.com/components/#checkbox"
    },
    {
      "id": "radio",
      "name": "Radio / RadioGroup",
      "category": "forms",
      "client": false,
      "summary": "One of several mutually exclusive options.",
      "props": [],
      "rules": [
        "Never ship a lone radio: unlike a checkbox it cannot be unset by the person who set it.",
        "Every radio in a group shares one name — that is what makes the arrow keys move between them."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#radio"
    },
    {
      "id": "switch",
      "name": "Switch",
      "category": "forms",
      "client": true,
      "summary": "An on/off control that takes effect immediately.",
      "props": [
        {
          "name": "checked",
          "type": "boolean",
          "required": true,
          "description": "Controlled."
        },
        {
          "name": "onCheckedChange",
          "type": "(next: boolean) => void",
          "required": true,
          "description": ""
        },
        {
          "name": "label",
          "type": "ReactNode",
          "required": true,
          "description": "Required. A switch with no label is a mystery toggle."
        }
      ],
      "rules": [
        "If the page has a Save button, the control is a Checkbox. A switch beside Save asks the person to guess."
      ],
      "accessibility": [
        "role=\"switch\", so it is announced as on/off rather than checked."
      ],
      "example": "https://brand.sincelabs.com/components/#switch"
    },
    {
      "id": "tabs",
      "name": "Tabs",
      "category": "navigation",
      "client": true,
      "summary": "Tabbed sections with roving tabindex and arrow-key movement.",
      "props": [
        {
          "name": "items",
          "type": "TabItem[]",
          "required": true,
          "description": "id, label, optional badge."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "description": "Controlled selection."
        }
      ],
      "rules": [
        "Only the selected panel renders. If a hidden panel holds a form, render all of them and toggle hidden instead.",
        "Tabs switch views of one thing. Different things are different pages."
      ],
      "accessibility": [
        "Roving tabindex: only the selected tab is in the tab order. Without it, eight tabs cost eight Tab presses.",
        "Left/Right move, Home/End jump, and selection follows focus."
      ],
      "example": "https://brand.sincelabs.com/components/#tabs"
    },
    {
      "id": "segmented-control",
      "name": "SegmentedControl",
      "category": "navigation",
      "client": true,
      "summary": "Two to four short, mutually exclusive options that change a view.",
      "props": [],
      "rules": [
        "Five or more options, or anything longer than two words, wants a Select.",
        "It sets a value; it does not control a labelled panel. That is Tabs."
      ],
      "accessibility": [
        "A radiogroup, so state is announced as \"selected, 2 of 3\"."
      ],
      "example": "https://brand.sincelabs.com/components/#segmented-control"
    },
    {
      "id": "pagination",
      "name": "Pagination",
      "category": "navigation",
      "client": false,
      "summary": "Page controls that state the range in words: 1–25 of 340.",
      "props": [
        {
          "name": "hrefFor",
          "type": "(page: number) => string",
          "description": "Renders links instead of buttons, keeping back, middle-click and sharing."
        }
      ],
      "rules": [
        "\"Page 2 of 14\" does not tell anyone whether the row they want is ahead or behind. State the range."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#pagination"
    },
    {
      "id": "disclosure",
      "name": "Disclosure",
      "category": "navigation",
      "client": false,
      "summary": "A section that opens and closes, built on details/summary.",
      "props": [],
      "rules": [
        "Not an accordion: opening one does not close its siblings, so two sections can be compared.",
        "details/summary means find-in-page opens a closed section containing the match. A div-and-state version hides it from Ctrl+F."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#disclosure"
    },
    {
      "id": "data-table",
      "name": "DataTable",
      "category": "data",
      "client": false,
      "summary": "The table shell: a scroll box, a required caption, and the header and cell styles.",
      "props": [
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "description": "Visually hidden. Says what the table is before anyone arrows into it."
        },
        {
          "name": "numeric",
          "type": "boolean",
          "description": "On Th/Td: right-aligns and applies tabular-nums."
        }
      ],
      "rules": [
        "The overflow-x-auto wrapper is not optional: without it a wide table pushes the whole page sideways on a phone.",
        "Figures are tabular-nums so a column of them lines up."
      ],
      "accessibility": [
        "Caption is required by the API. The app audit found 16 tables and 0 captions."
      ],
      "example": "https://brand.sincelabs.com/components/#data-table"
    },
    {
      "id": "description-list",
      "name": "DescriptionList",
      "category": "data",
      "client": false,
      "summary": "Label-and-value pairs for a detail panel or a record summary.",
      "props": [],
      "rules": [
        "A real dl, so \"Plan: Team\" is read as one thing rather than two unrelated cells."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#description-list"
    },
    {
      "id": "stat-tile",
      "name": "StatTile / StatGrid",
      "category": "data",
      "client": false,
      "summary": "A headline figure with its label, and the grid they sit in.",
      "props": [
        {
          "name": "accent",
          "type": "boolean",
          "default": "false",
          "description": "The terracotta wash. One per grid, on the figure that matters most."
        }
      ],
      "rules": [
        "Rendered as a dt/dd pair, so the label and its value are read together."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#stat-tile"
    },
    {
      "id": "empty-state",
      "name": "EmptyState",
      "category": "data",
      "client": false,
      "summary": "What a surface says when it has nothing to show.",
      "props": [],
      "rules": [
        "Three parts: what is not here, why or what to do, and the control that does it.",
        "The title is never a joke. It states what is missing."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#empty-state"
    },
    {
      "id": "alert",
      "name": "Alert",
      "category": "feedback",
      "client": false,
      "summary": "An inline message about the surface it sits on. Six tones.",
      "props": [
        {
          "name": "tone",
          "type": "StatusTone",
          "default": "\"info\"",
          "description": ""
        }
      ],
      "rules": [
        "It stays on the page. A message that needs a decision is never a Toast.",
        "error and warning announce themselves; the quieter tones do not, so a page of notes does not shout on load."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#alert"
    },
    {
      "id": "status-chip",
      "name": "StatusChip",
      "category": "feedback",
      "client": false,
      "summary": "State as a chip, with a dot that carries the meaning without colour.",
      "props": [
        {
          "name": "status",
          "type": "string",
          "description": "Mapped to a tone by toneForStatus, so one value cannot render two ways."
        },
        {
          "name": "tone",
          "type": "StatusTone",
          "description": "Override the mapping."
        }
      ],
      "rules": [
        "The dot is not decoration: it is the non-colour channel. Never ship a status pill that is a bare colour swatch."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#status-chip"
    },
    {
      "id": "badge",
      "name": "Badge",
      "category": "feedback",
      "client": false,
      "summary": "A short static label: a count, a plan name, a version.",
      "props": [
        {
          "name": "mono",
          "type": "boolean",
          "default": "false",
          "description": "For identifiers, versions and SHAs."
        }
      ],
      "rules": [
        "Not a StatusChip. If the thing can be \"active\" or \"failed\", it is a chip.",
        "Never interactive. A clickable badge is a button that looks like a label, and nobody clicks it."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#badge"
    },
    {
      "id": "progress",
      "name": "Progress",
      "category": "feedback",
      "client": false,
      "summary": "How far along something is. Determinate whenever you can say.",
      "props": [
        {
          "name": "value",
          "type": "number | null",
          "required": true,
          "description": "null when the total is genuinely unknown."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Required: a bar with no label describes nothing."
        }
      ],
      "rules": [
        "The fill animates with scaleX, never width — animating width re-lays out on every frame."
      ],
      "accessibility": [
        "Indeterminate is a real state: progressbar with no aria-valuenow, not a styling variant."
      ],
      "example": "https://brand.sincelabs.com/components/#progress"
    },
    {
      "id": "spinner",
      "name": "Spinner",
      "category": "feedback",
      "client": false,
      "summary": "The busy indicator. One second, linear, forever.",
      "props": [],
      "rules": [
        "Prefer a Skeleton for content that has a shape, and prefer nothing at all under ~300ms.",
        "Under prefers-reduced-motion it stops dead at 65% opacity — a 0.01ms infinite spin strobes."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#spinner"
    },
    {
      "id": "skeleton",
      "name": "Skeleton",
      "category": "feedback",
      "client": false,
      "summary": "A placeholder in the shape of what is coming, so nothing jumps when it lands.",
      "props": [],
      "rules": [
        "Shape it honestly. A 200px block where three lines will appear is worse than a spinner."
      ],
      "accessibility": [
        "aria-hidden. The loading state belongs to the live region around the fetch, not to a description of grey boxes."
      ],
      "example": "https://brand.sincelabs.com/components/#skeleton"
    },
    {
      "id": "toast",
      "name": "ToastProvider / useToast",
      "category": "feedback",
      "client": true,
      "summary": "Confirmation that the thing you just did worked.",
      "props": [
        {
          "name": "duration",
          "type": "number",
          "default": "5000",
          "description": "Errors ignore it and stay until dismissed."
        }
      ],
      "rules": [
        "Reports the result of an action the person just took. Not a notification channel.",
        "Never the only record of anything important — it disappears fastest for the people reading slowest.",
        "Something that needs a decision is an Alert, on the surface, where it stays."
      ],
      "accessibility": [
        "The region is polite; only the error tone uses role=\"alert\" and interrupts."
      ],
      "example": "https://brand.sincelabs.com/components/#toast"
    },
    {
      "id": "modal",
      "name": "Modal / ConfirmDialog",
      "category": "overlays",
      "client": true,
      "summary": "A native dialog in the browser's top layer, plus the destructive-action confirmation built on it.",
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "required": true,
          "description": "Controlled. The element is opened by method, not by attribute."
        },
        {
          "name": "confirmLabel",
          "type": "string",
          "description": "ConfirmDialog. Names the action — \"Delete organization\", never \"OK\"."
        }
      ],
      "rules": [
        "The top layer is why this is a <dialog>: no ancestor's overflow can clip it. A popover inside an overflow-hidden Card is cut off at the card edge.",
        "It appears instantly. A modal is a mode change; a fade makes the whole application feel slow."
      ],
      "accessibility": [
        "The element supplies the focus trap, Escape, aria-modal and inert-ing of the page behind it."
      ],
      "example": "https://brand.sincelabs.com/components/#modal"
    },
    {
      "id": "drawer",
      "name": "Drawer",
      "category": "overlays",
      "client": true,
      "summary": "A panel that slides in from an edge. 200ms, with the backdrop fading over the same duration.",
      "props": [
        {
          "name": "side",
          "type": "\"left\" | \"right\"",
          "default": "\"left\"",
          "description": "Sets --sl-slide-from; one keyframe serves both."
        }
      ],
      "rules": [
        "The only full-distance translate in the system, and the only sanctioned use of translateX."
      ],
      "accessibility": [
        "Focus moves to the panel on open, Tab cycles inside it, and focus returns to the trigger on close."
      ],
      "example": "https://brand.sincelabs.com/components/#drawer"
    },
    {
      "id": "tooltip",
      "name": "Tooltip / InfoTip",
      "category": "overlays",
      "client": true,
      "summary": "A short hint attached to a control, on hover and on focus.",
      "props": [],
      "rules": [
        "Never the only place the information exists: a tooltip is unreachable on touch and invisible in print.",
        "It describes, it does not label — the trigger keeps its own accessible name."
      ],
      "accessibility": [
        "Opens on focus as well as hover, and Escape closes it while the trigger keeps focus."
      ],
      "example": "https://brand.sincelabs.com/components/#tooltip"
    },
    {
      "id": "avatar",
      "name": "Avatar",
      "category": "identity",
      "client": false,
      "summary": "Initials on terracotta, from a name, falling back to an email.",
      "props": [],
      "rules": [
        "aria-hidden: the person's name is already beside it, and \"AL\" read aloud is noise."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#avatar"
    },
    {
      "id": "icon",
      "name": "Icon",
      "category": "identity",
      "client": false,
      "summary": "The icon set. One 24×24 grid, 2px stroke, round caps, no fills.",
      "props": [
        {
          "name": "name",
          "type": "IconName",
          "required": true,
          "description": "See ICON_NAMES for the full set."
        },
        {
          "name": "title",
          "type": "string",
          "description": "Only for a standalone glyph with no control around it."
        }
      ],
      "rules": [
        "Decoration by default. When an icon is a control's only content, the name goes on the control.",
        "Never scale a 16px icon up — the stroke thickens with it. Never mix in a filled or two-tone glyph."
      ],
      "accessibility": [],
      "example": "https://brand.sincelabs.com/components/#icon"
    },
    {
      "id": "theme-toggle",
      "name": "ThemeToggle",
      "category": "identity",
      "client": true,
      "summary": "Light / system / dark. Three states, because \"system\" is a real choice and the default.",
      "props": [],
      "rules": [
        "Requires THEME_SCRIPT in <head> — see lib/theme.ts. Without it the page paints in the wrong theme first.",
        "\"system\" removes data-theme rather than writing a resolved value, so the page tracks the OS live."
      ],
      "accessibility": [
        "A radiogroup: \"System, selected, 2 of 3\", not three unrelated toggles."
      ],
      "example": "https://brand.sincelabs.com/components/#theme-toggle"
    }
  ]
}
