# nodes

id: component:pattern:ren-menubar
type: component
name: ren-menubar
path: components/patterns/ren-menubar
body:
# ren-menubar Pattern Contract

Application menubar pattern for top-level menu navigation.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-menubar` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-menubar` pattern.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Menubar pattern behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this pattern.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The UI is a desktop-style application menubar with persistent top-level menus (File / Edit / View / Help)."
    - "You need WAI-ARIA Menubar semantics: role=\"menubar\", role=\"menu\", role=\"menuitem|menuitemcheckbox|menuitemradio\"."
    - "You need keyboard navigation (Arrow keys, Enter, Space, Escape, Home, End) with roving focus and \"menubar glide\" between triggers."
    - "You need typeahead character matching for fast item selection."
    - "You need checkbox / radio menu items (.ren-menubar-checkbox, .ren-menubar-radio) with aria-checked state."
    - "You need nested submenus with chevron and lateral keyboard navigation."
  avoidWhen:
    - "The nav is a horizontal site bar (Home / About / Pricing) — use ren-nav."
    - "It is a single dropdown / context menu — use ren-menu or ren-popover."
    - "It is a side rail with sections — use ren-sidebar."
    - "It is a Ctrl+K searchable launcher — use ren-command."

canonicalImports:
  css:
    - "rends/components/patterns/ren-menubar/ren-menubar.css"
  js:
    - "rends/components/patterns/ren-menubar/ren-menubar.js"
  notes:
    - "JS owns the keyboard model, roving focus, submenu opening, and ren-menubar-select event. CSS-only fallback only renders static chrome."
    - "Custom element registers as <ren-menubar>; menus are toggled via the [hidden] attribute on .ren-menubar-menu."

requiredMarkup:
  - "Root is <ren-menubar> wrapping <div class=\"ren-menubar\" role=\"menubar\">."
  - "Each top-level menu uses <button class=\"ren-menubar-trigger\" aria-haspopup=\"menu\" aria-expanded=\"false\"> + a sibling <div class=\"ren-menubar-menu\" role=\"menu\" hidden>."
  - "Each item is a <button class=\"ren-menubar-item\" role=\"menuitem\"> (or role=\"menuitemcheckbox|menuitemradio\" for stateful variants)."
  - "Checkbox / radio items carry aria-checked=\"true|false\"; the ::before pseudo renders the check / dot — do not hand-draw it."
  - "Separators are <div class=\"ren-menubar-separator\" role=\"separator\">; submenu chevrons are added automatically by .ren-menubar-submenu::after."
  - "Keyboard shortcuts shown via <span class=\"ren-menubar-shortcut\">⌘N</span>; mirror with a real document-level keydown handler."

forbiddenPatterns:
  - "Building from <a> tags — items must be <button> so Space activates them."
  - "Hiding menus with display: none from external code — toggle the [hidden] attribute; the component owns animation states."
  - "Setting aria-expanded manually from consumer code — the menubar JS manages it on the trigger."
  - "Wrapping items in <li> without role=\"none\" parent — keep the menu role tree flat or wrap with role=\"none\"."
  - "Reimplementing keyboard navigation; the component already handles arrows, typeahead, Home/End, and Escape."

tokenPolicy:
  allowed:
    - "Semantic surface / text tokens: --color-surface, --color-surface-raised, --color-border, --color-separator, --color-text, --color-text-muted, --color-fill-hover, --color-fill-active, --color-accent, --color-on-accent."
    - "Spacing / radius / shadow tokens: --space-1, --space-2, --space-3, --radius-sm, --radius-md, --shadow-lg."
    - "Type tokens: --body-size, --label-size, --label-weight, --caption-size."
    - "Motion tokens: --duration-tactile, --duration-exit, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / named colors for hover, active, or accent."
    - "Replacing the focus outline color with anything other than --color-accent."

accessibility:
  required:
    - "Container has role=\"menubar\" and each <button> is a real menuitem with role=\"menuitem|menuitemcheckbox|menuitemradio\"."
    - "Triggers expose aria-haspopup=\"menu\" and aria-expanded reflects the open/closed state of the associated menu."
    - "Roving tabindex: only one focusable item at a time; arrow keys move focus. The component manages this — do not set tabindex manually on every item."
    - "Checkbox / radio items expose aria-checked; the visual check (::before) must not be the only state cue."
    - "Disabled items use [data-disabled] AND pointer-events: none (CSS handles the latter)."
    - "Focus-visible outline uses --color-accent — never strip without restoring a visible alternative."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/patterns/ren-menubar/ren-menubar.css">
<script type="module" src="rends/components/patterns/ren-menubar/ren-menubar.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-menubar><div class="ren-menubar" role="menubar"><button class="ren-menubar-trigger" type="button" aria-haspopup="menu" aria-expanded="false">File</button><div class="ren-menubar-menu" role="menu" hidden><button class="ren-menubar-item" type="button" role="menuitem">New</button></div></div></ren-menubar>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-menubar`
- `.ren-menubar-checkbox`
- `.ren-menubar-item`
- `.ren-menubar-label`
- `.ren-menubar-menu`
- `.ren-menubar-radio`
- `.ren-menubar-separator`
- `.ren-menubar-shortcut`
- `.ren-menubar-submenu`
- `.ren-menubar-trigger`

## States And Attributes

- `[aria-checked]`
- `[aria-expanded]`
- `[data-disabled]`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

If no `--ren-*` token is detected here, theme through semantic tokens from `tokens/tokens.md` and avoid selector overrides.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/patterns/ren-menubar/ren-menubar.css`
- `components/patterns/ren-menubar/ren-menubar.js`
- `docs/components/ren-menubar.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/**
 * RenDS Menubar
 * Horizontal menu bar component (File, Edit, View, etc.)
 *
 * Implements WAI-ARIA Menubar pattern with keyboard navigation,
 * roving focus, submenu support, and checkbox/radio items.
 *
 * Classes:
 * - .ren-menubar: Root horizontal menu bar
 * - .ren-menubar-trigger: Top-level menu button
 * - .ren-menubar-menu: Dropdown menu panel
 * - .ren-menubar-item: Individual menu item
 * - .ren-menubar-separator: Divider between item groups
 * - .ren-menubar-shortcut: Keyboard shortcut indicator
 * - .ren-menubar-label: Non-interactive group label
 * - .ren-menubar-submenu: Submenu with chevron
 * - .ren-menubar-checkbox: Checkbox menu item
 * - .ren-menubar-radio: Radio menu item
 */

/* =================================================================
   ROOT MENUBAR
   ================================================================= */

.ren-menubar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  position: relative;
}

/* =================================================================
   MENUBAR TRIGGER (Top-level Menu Button)
   ================================================================= */

.ren-menubar-trigger {
  position: relative;
  padding: var(--space-1) var(--space-2);
  background-color: transparent;
  color: var(--color-text);
  border: none;
  border-radius: var(--radius-sm);
  font-size: var(--body-size);
  font-weight: var(--label-weight);
  cursor: pointer;
  transition: background-color var(--duration-tactile) var(--ease-enter);
  white-space: nowrap;
}

.ren-menubar-trigger:hover:not([aria-expanded="true"]) {
  background-color: var(--color-fill-hover);
}

.ren-menubar-trigger[aria-expanded="true"] {
  background-color: var(--color-fill-active);
  color: var(--color-text);
}

.ren-menubar-trigger:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

.ren-menubar-trigger:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* =================================================================
   MENUBAR MENU (Dropdown Panel)
   ================================================================= */

.ren-menubar-menu {
  position: absolute;
  top: 100%;
  inset-inline-start: 0;
  margin-top: var(--space-1);
  min-width: 200px;
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  z-index: 1000;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* Visible state for menu open */
.ren-menubar-menu:not([hidden]) {
  display: flex;
  animation: menubar-menu-open var(--duration-exit) var(--ease-enter);
}

/* Hidden state for menu closed */
.ren-menubar-menu[hidden] {
  display: none;
  animation: menubar-menu-close var(--duration-exit) var(--ease-enter);
}

@starting-style {
  .ren-menubar-menu:not([hidden]) {
    opacity: 0;
    transform: translateY(-4px);
  }
}

@keyframes menubar-menu-open {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes menubar-menu-close {
  from {
    opacity: 1;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    transform: translateY(-4px);
  }
}

/* =================================================================
   MENUBAR ITEM (Menu Item)
   ================================================================= */

.ren-menubar-item {
  display: flex;
  align-items: center;
  width: 100%;
  padding: var(--space-2) var(--space-3);
  background-color: transparent;
  color: var(--color-text);
  border: none;
  font-size: var(--body-size);
  font-weight: normal;
  text-align: start;
  cursor: pointer;
  transition: background-color var(--duration-tactile) var(--ease-enter);
  position: relative;
  gap: var(--space-2);
}

.ren-menubar-item:hover:not([data-disabled]) {
  background-color: var(--color-fill-hover);
}

.ren-menubar-item:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: -2px;
}

.ren-menubar-item[data-disabled] {
  color: var(--color-text-muted);
  cursor: not-allowed;
}

.ren-menubar-item[data-disabled]:hover {
  background-color: transparent;
}

/* =================================================================
   MENUBAR SHORTCUT (Keyboard Shortcut Indicator)
   ================================================================= */

.ren-menubar-shortcut {
  display: inline-block;
  margin-inline-start: auto;
  padding-inline-start: var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--caption-size);
  font-family: monospace;
  flex-shrink: 0;
}

/* =================================================================
   MENUBAR SEPARATOR (Divider)
   ================================================================= */

.ren-menubar-separator {
  height: 1px;
  background-color: var(--color-separator);
  margin: var(--space-1) 0;
}

/* =================================================================
   MENUBAR LABEL (Group Label)
   ================================================================= */

.ren-menubar-label {
  padding: var(--space-2) var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  user-select: none;
  pointer-events: none;
}

/* =================================================================
   MENUBAR SUBMENU (With Chevron)
   ================================================================= */

.ren-menubar-submenu {
  position: relative;
}

/* Chevron indicator via ::after */
.ren-menubar-submenu::after {
  content: '›';
  display: inline-block;
  margin-inline-start: auto;
  padding-inline-start: var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--body-size);
  flex-shrink: 0;
}

/* Submenu panel positioning (higher z-index for layering) */
.ren-menubar-submenu > .ren-menubar-menu {
  top: 0;
  inset-inline-start: calc(100% + var(--space-1));
  margin-top: 0;
  z-index: 1100;
}

/* =================================================================
   MENUBAR CHECKBOX (Checkbox Menu Item)
   ================================================================= */

.ren-menubar-checkbox {
  position: relative;
}

/* Checkmark via ::before */
.ren-menubar-checkbox::before {
  content: '';
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1em;
  height: 1em;
  margin-inline-end: var(--space-1);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background-color: transparent;
  flex-shrink: 0;
}

.ren-menubar-checkbox[aria-checked="true"]::before {
  content: '✓';
  background-color: var(--color-accent);
  border-color: var(--color-accent);
  color: var(--color-on-accent);
  font-size: 0.75em;
  font-weight: bold;
}

/* =================================================================
   MENUBAR RADIO (Radio Menu Item)
   ================================================================= */

.ren-menubar-radio {
  position: relative;
}

/* Radio indicator via ::before */
.ren-menubar-radio::before {
  content: '';
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1em;
  height: 1em;
  margin-inline-end: var(--space-1);
  border: 1px solid var(--color-border);
  border-radius: 50%;
  background-color: transparent;
  flex-shrink: 0;
}

.ren-menubar-radio[aria-checked="true"]::before {
  content: '';
  background-color: var(--color-accent);
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 2px var(--color-surface-raised);
}

/* =================================================================
   ACCESSIBILITY & INTERACTION STATES
   ================================================================= */

/* Hover fill for interactive items */
.ren-menubar-item:not([data-disabled]):not(.ren-menubar-label) {
  user-select: none;
}

/* Focus management for submenus */
.ren-menubar-menu [role="menuitem"]:focus-visible,
.ren-menubar-menu [role="menuitemcheckbox"]:focus-visible,
.ren-menubar-menu [role="menuitemradio"]:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: -2px;
}

/* Ensure disabled items don't respond to pointer events */
.ren-menubar-item[data-disabled] {
  pointer-events: none;
}

/* Active/selected state */
.ren-menubar-item.is-focused {
  background-color: var(--color-fill-hover);
}

/* =================================================================
   MEDIA QUERIES & RESPONSIVE
   ================================================================= */

@media (prefers-reduced-motion: reduce) {
  .ren-menubar-trigger,
  .ren-menubar-item,
  .ren-menubar-menu {
    transition: none;
    animation: none;
  }
}


/**
 * RenDS Menubar Component
 * Horizontal menu bar with WAI-ARIA Menubar pattern support
 *
 * Features:
 * - Keyboard navigation (Arrow keys, Enter, Space, Escape, Home, End)
 * - Roving focus with "menubar glide" (hover opens adjacent menus when one is open)
 * - Typeahead character matching for quick item selection
 * - Checkbox and radio menu items with state management
 * - Submenu support with nested navigation
 * - Full accessibility compliance with ARIA roles and attributes
 *
 * Usage:
 * <ren-menubar>
 *   <div class="ren-menubar" role="menubar">
 *     <button class="ren-menubar-trigger">File</button>
 *     <div class="ren-menubar-menu" role="menu">
 *       <button class="ren-menubar-item" role="menuitem">New</button>
 *     </div>
 *   </div>
 * </ren-menubar>
 *
 * Events:
 * - ren-menubar-select: Fired when a menu item is activated
 *   detail: { item: Element, value: string, checked?: boolean }
 *
 * Public Methods:
 * - closeAll(): Close all open menus
 * - openMenu(triggerIndex): Open menu at specified trigger index
 */

export class RenMenubar extends HTMLElement {
  constructor() {
    super();
    this.triggers = [];
    this.menus = [];
    this.activeMenuIndex = -1;
    this.currentMenu = null;
    this.focusedItem = null;
    this.typeaheadBuffer = '';
    this.typeaheadTimeout = null;
  }

  /* ===================================================================
     LIFECYCLE HOOKS
     =================================================================== */

  connectedCallback() {
    this.initialize();
  }

  /* ===================================================================
     INITIALIZATION
     =================================================================== */

  /**
   * Initialize the menubar:
   * - Cache triggers and menus
   * - Set up ARIA attributes
   * - Attach event listeners
   */
  initialize() {
    const menubarEl = this.querySelector('[role="menubar"]');
    if (!menubarEl) return;

    // Find all triggers and their associated menus
    this.triggers = Array.from(menubarEl.querySelectorAll('.ren-menubar-trigger'));
    this.menus = this.triggers.map((trigger) => {
      // Menu is the next sibling after the trigger
      const menu = trigger.nextElementSibling;
      if (menu && menu.classList.contains('ren-menubar-menu')) {
        return menu;
      }
      return null;
    }).filter(Boolean);

    // Ensure triggers and menus have proper ARIA attributes
    this.triggers.forEach((trigger, index) => {
      trigger.setAttribute('aria-haspopup', 'true');
      trigger.setAttribute('aria-expanded', 'false');
      trigger.setAttribute('role', 'button');
      trigger.addEventListener('click', () => this.toggleMenu(index));
      trigger.addEventListener('keydown', (e) => this.handleTriggerKeydown(e, index));
      trigger.addEventListener('mouseenter', () => this.handleTriggerMouseenter(index));
    });

    // Set up menu items
    this.menus.forEach((menu, menuIndex) => {
      if (menu) {
        const items = this.getMenuItems(menu);
        items.forEach((item, itemIndex) => {
          item.addEventListener('click', (e) => this.handleItemClick(e, menuIndex, itemIndex));
          item.addEventListener('keydown', (e) => this.handleItemKeydown(e, menuIndex, itemIndex));
          item.addEventListener('mouseenter', () => this.handleItemMouseenter(menuIndex, itemIndex));
        });
      }
    });

    // Close menu when clicking outside
    document.addEventListener('click', (e) => {
      if (!this.contains(e.target)) {
        this.closeAll();
      }
    });
  }

  /* ===================================================================
     MENU MANAGEMENT
     =================================================================== */

  /**
   * Toggle menu visibility for trigger at index
   */
  toggleMenu(index) {
    if (this.activeMenuIndex === index) {
      this.closeAll();
    } else {
      this.openMenu(index);
    }
  }

  /**
   * Open menu at specified trigger index and focus first item
   */
  openMenu(index) {
    if (index < 0 || index >= this.triggers.length) return;

    // Close any currently open menu
    if (this.activeMenuIndex !== -1) {
      this.closeMenu(this.activeMenuIndex);
    }

    this.activeMenuIndex = index;
    const trigger = this.triggers[index];
    const menu = this.menus[index];

    if (menu) {
      menu.removeAttribute('hidden');
      trigger.setAttribute('aria-expanded', 'true');
      this.currentMenu = menu;

      // Focus first focusable item in menu
      const items = this.getMenuItems(menu);
      if (items.length > 0) {
        this.focusItem(items[0]);
      }
    }
  }

  /**
   * Close menu at specified index
   */
  closeMenu(index) {
    if (index < 0 || index >= this.triggers.length) return;

    const trigger = this.triggers[index];
    const menu = this.menus[index];

    if (menu) {
      menu.setAttribute('hidden', '');
      trigger.setAttribute('aria-expanded', 'false');
    }

    if (this.activeMenuIndex === index) {
      this.activeMenuIndex = -1;
      this.currentMenu = null;
      this.focusedItem = null;
      trigger.focus();
    }
  }

  /**
   * Close all open menus and return focus to the last active trigger
   */
  closeAll() {
    if (this.activeMenuIndex !== -1) {
      this.closeMenu(this.activeMenuIndex);
    }
  }

  /* ===================================================================
     ITEM NAVIGATION & FOCUS
     =================================================================== */

  /**
   * Get focusable menu items (excluding separators and labels)
   */
  getMenuItems(menu) {
    const items = Array.from(menu.querySelectorAll(
      '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
    ));
    return items.filter(item => !item.hasAttribute('data-disabled'));
  }

  /**
   * Focus a specific menu item and update aria-selected state
   */
  focusItem(item) {
    if (!item) return;

    // Remove focus class from previously focused item
    if (this.focusedItem && this.focusedItem !== item) {
      this.focusedItem.classList.remove('is-focused');
    }

    this.focusedItem = item;
    item.classList.add('is-focused');
    item.focus();
  }

  /**
   * Get the next focusable item in menu (wraps around)
   */
  getNextItem(menu, currentItem, direction = 1) {
    const items = this.getMenuItems(menu);
    if (items.length === 0) return null;

    const currentIndex = items.indexOf(currentItem);
    const nextIndex = (currentIndex + direction + items.length) % items.length;
    return items[nextIndex];
  }

  /* ===================================================================
     EVENT HANDLERS: TRIGGERS
     =================================================================== */

  /**
   * Handle keyboard events on trigger buttons
   */
  handleTriggerKeydown(event, triggerIndex) {
    const { key } = event;

    // ArrowRight: move to next trigger
    if (key === 'ArrowRight') {
      event.preventDefault();
      const nextIndex = (triggerIndex + 1) % this.triggers.length;
      this.openMenu(nextIndex);
    }

    // ArrowLeft: move to previous trigger
    if (key === 'ArrowLeft') {
      event.preventDefault();
      const prevIndex = (triggerIndex - 1 + this.triggers.length) % this.triggers.length;
      this.openMenu(prevIndex);
    }

    // ArrowDown: open menu
    if (key === 'ArrowDown' || key === ' ' || key === 'Enter') {
      event.preventDefault();
      this.openMenu(triggerIndex);
    }

    // Home: first trigger
    if (key === 'Home') {
      event.preventDefault();
      this.openMenu(0);
    }

    // End: last trigger
    if (key === 'End') {
      event.preventDefault();
      this.openMenu(this.triggers.length - 1);
    }
  }

  /**
   * Handle mouse enter on trigger (roving focus "glide" behavior)
   * If a menu is already open, open the new menu instead
   */
  handleTriggerMouseenter(triggerIndex) {
    if (this.activeMenuIndex !== -1 && this.activeMenuIndex !== triggerIndex) {
      this.openMenu(triggerIndex);
    }
  }

  /* ===================================================================
     EVENT HANDLERS: MENU ITEMS
     =================================================================== */

  /**
   * Handle click on menu item
   */
  handleItemClick(event, menuIndex, itemIndex) {
    event.preventDefault();
    const menu = this.menus[menuIndex];
    const items = this.getMenuItems(menu);
    const item = items[itemIndex];

    if (!item || item.hasAttribute('data-disabled')) return;

    this.activateItem(item);
  }

  /**
   * Handle keyboard events within menu
   */
  handleItemKeydown(event, menuIndex, itemIndex) {
    const { key } = event;
    const menu = this.menus[menuIndex];
    const items = this.getMenuItems(menu);
    const item = items[itemIndex];

    if (!item) return;

    // ArrowDown: next item
    if (key === 'ArrowDown') {
      event.preventDefault();
      const nextItem = this.getNextItem(menu, item, 1);
      if (nextItem) this.focusItem(nextItem);
    }

    // ArrowUp: previous item
    if (key === 'ArrowUp') {
      event.preventDefault();
      const prevItem = this.getNextItem(menu, item, -1);
      if (prevItem) this.focusItem(prevItem);
    }

    // ArrowRight: open submenu or move to next trigger
    if (key === 'ArrowRight') {
      event.preventDefault();
      const submenu = item.querySelector('.ren-menubar-menu');
      if (submenu && item.classList.contains('ren-menubar-submenu')) {
        submenu.removeAttribute('hidden');
        const subItems = this.getMenuItems(submenu);
        if (subItems.length > 0) this.focusItem(subItems[0]);
      } else {
        // Move to next trigger
        const nextIndex = (menuIndex + 1) % this.triggers.length;
        this.openMenu(nextIndex);
      }
    }

    // ArrowLeft: close submenu or go back to previous menu
    if (key === 'ArrowLeft') {
      event.preventDefault();
      const parentMenu = item.closest('.ren-menubar-menu');
      const parentTrigger = parentMenu?.previousElementSibling;
      if (parentTrigger && parentTrigger.classList.contains('ren-menubar-submenu')) {
        const submenu = parentTrigger.querySelector('.ren-menubar-menu');
        if (submenu) submenu.setAttribute('hidden', '');
        this.focusItem(parentTrigger);
      } else {
        const prevIndex = (menuIndex - 1 + this.triggers.length) % this.triggers.length;
        this.openMenu(prevIndex);
      }
    }

    // Enter or Space: activate item
    if (key === 'Enter' || key === ' ') {
      event.preventDefault();
      this.activateItem(item);
    }

    // Escape: close menu
    if (key === 'Escape') {
      event.preventDefault();
      this.closeMenu(menuIndex);
    }

    // Home: first item
    if (key === 'Home') {
      event.preventDefault();
      if (items.length > 0) this.focusItem(items[0]);
    }

    // End: last item
    if (key === 'End') {
      event.preventDefault();
      if (items.length > 0) this.focusItem(items[items.length - 1]);
    }

    // Typeahead: match item by first letter
    if (key.length === 1 && /^[a-z0-9]$/i.test(key)) {
      event.preventDefault();
      this.handleTypeahead(key, menu);
    }
  }

  /**
   * Handle mouse enter on item (focus and close typeahead)
   */
  handleItemMouseenter(menuIndex, itemIndex) {
    const menu = this.menus[menuIndex];
    const items = this.getMenuItems(menu);
    const item = items[itemIndex];

    if (item) {
      this.focusItem(item);
    }

    // Clear typeahead buffer on mouse movement
    this.typeaheadBuffer = '';
    if (this.typeaheadTimeout) {
      clearTimeout(this.typeaheadTimeout);
    }
  }

  /* ===================================================================
     ITEM ACTIVATION & STATE MANAGEMENT
     =================================================================== */

  /**
   * Activate a menu item (handle checkbox/radio states and emit event)
   */
  activateItem(item) {
    if (item.hasAttribute('data-disabled')) return;

    const role = item.getAttribute('role');

    // Handle checkbox items
    if (role === 'menuitemcheckbox') {
      const isChecked = item.getAttribute('aria-checked') === 'true';
      item.setAttribute('aria-checked', !isChecked ? 'true' : 'false');
      this.emitSelectEvent(item, !isChecked);
    }
    // Handle radio items
    else if (role === 'menuitemradio') {
      const name = item.getAttribute('name');
      const menu = item.closest('[role="menu"]');
      const radioGroup = menu.querySelectorAll(`[role="menuitemradio"][name="${name}"]`);
      radioGroup.forEach(radio => radio.setAttribute('aria-checked', 'false'));
      item.setAttribute('aria-checked', 'true');
      this.emitSelectEvent(item, true);
    }
    // Handle standard menu items
    else {
      this.emitSelectEvent(item);
    }

    // Close menu after activation (unless it's a checkbox/radio)
    if (role !== 'menuitemcheckbox' && role !== 'menuitemradio') {
      this.closeAll();
    }
  }

  /**
   * Emit ren-menubar-select event with item details
   */
  emitSelectEvent(item, checked = undefined) {
    const detail = {
      item,
      value: item.textContent?.trim() || item.getAttribute('value') || '',
    };

    if (checked !== undefined) {
      detail.checked = checked;
    }

    this.dispatchEvent(new CustomEvent('ren-menubar-select', {
      detail,
      bubbles: true,
      composed: true,
    }));
  }

  /* ===================================================================
     TYPEAHEAD SUPPORT
     =================================================================== */

  /**
   * Handle typeahead character matching
   * Type characters to jump to matching items
   */
  handleTypeahead(char, menu) {
    // Clear buffer after 500ms of inactivity
    if (this.typeaheadTimeout) {
      clearTimeout(this.typeaheadTimeout);
    }

    this.typeaheadBuffer += char.toLowerCase();

    // Find item matching buffer
    const items = this.getMenuItems(menu);
    const currentIndex = items.indexOf(this.focusedItem);
    const startIndex = (currentIndex + 1) % items.length;

    let matchedItem = null;
    for (let i = 0; i < items.length; i++) {
      const index = (startIndex + i) % items.length;
      const item = items[index];
      const text = item.textContent?.trim().toLowerCase() || '';
      if (text.startsWith(this.typeaheadBuffer)) {
        matchedItem = item;
        break;
      }
    }

    if (matchedItem) {
      this.focusItem(matchedItem);
    }

    // Clear buffer after 500ms
    this.typeaheadTimeout = setTimeout(() => {
      this.typeaheadBuffer = '';
    }, 500);
  }

  /* ===================================================================
     PUBLIC API
     =================================================================== */

  /**
   * Close all open menus
   * @public
   */
  closeAll() {
    if (this.activeMenuIndex !== -1) {
      this.closeMenu(this.activeMenuIndex);
    }
  }

  /**
   * Open menu at specified trigger index
   * @param {number} triggerIndex - Index of the trigger to open
   * @public
   */
  openMenu(triggerIndex) {
    if (triggerIndex < 0 || triggerIndex >= this.triggers.length) {
      throw new RangeError(`Invalid trigger index: ${triggerIndex}`);
    }
    this.openMenu(triggerIndex);
  }
}

// Register custom element
if (!customElements.get('ren-menubar')) {
  customElements.define('ren-menubar', RenMenubar);
}

data:
{"kind":"pattern","selectors":[".ren-menubar",".ren-menubar-checkbox",".ren-menubar-item",".ren-menubar-label",".ren-menubar-menu",".ren-menubar-radio",".ren-menubar-separator",".ren-menubar-shortcut",".ren-menubar-submenu",".ren-menubar-trigger"],"tokens":["--body-size","--caption-size","--color-accent","--color-border","--color-fill-active","--color-fill-hover","--color-on-accent","--color-separator","--color-surface","--color-surface-raised","--color-text","--color-text-muted","--duration-exit","--duration-tactile","--ease-enter","--label-size","--label-weight","--radius-md","--radius-sm","--shadow-lg","--space-1","--space-2","--space-3"],"hasScript":true,"hasDocsPage":true}


id: component:pattern:ren-nav
type: component
name: ren-nav
path: components/patterns/ren-nav
body:
# ren-nav Pattern Contract

Navigation pattern for responsive site/app nav and active states.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-nav` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-nav` pattern.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Nav pattern behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this pattern.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The product needs a horizontal site / app top navigation bar (brand + links + actions)."
    - "The nav must collapse to a hamburger menu below 48rem (768px) with an animated <span>-stripe toggle."
    - "Active route should be reflected via aria-current=\"page\" (or the .active class) on a .ren-nav-link."
    - "You need optional sticky-on-scroll behavior with a translucent backdrop blur (.ren-nav-sticky)."
    - "You need a transparent variant for hero sections (.ren-nav-transparent)."
    - "You need top-level dropdown menus inside the nav using the native [popover] API (.ren-nav-dropdown)."
  avoidWhen:
    - "The nav is a persistent left/right rail — use ren-sidebar."
    - "The UI is a desktop-style application menubar (File / Edit / View) — use ren-menubar."
    - "The disclosure is a one-off dropdown unrelated to site nav — use ren-menu or ren-popover."
    - "The nav lives inside a card or pattern as secondary tabs — use ren-tabs."

canonicalImports:
  css:
    - "rends/components/patterns/ren-nav/ren-nav.css"
  js:
    - "rends/components/patterns/ren-nav/ren-nav.js"
  notes:
    - "JS registers <ren-nav> and owns the mobile toggle, Escape-to-close, resize handler, and popover-based dropdowns."
    - "CSS-only fallback renders the desktop layout; mobile menu requires JS to toggle [aria-expanded] on .ren-nav-toggle."

requiredMarkup:
  - "Wrap the bar in <ren-nav> containing a <nav class=\"ren-nav\" aria-label=\"…\">."
  - "Brand block uses <a class=\"ren-nav-brand\" href=\"/\"> with a logo + product name; it sits at the inline-start."
  - "Links live in <ul class=\"ren-nav-links\"> of <li><a class=\"ren-nav-link\" href=\"…\"></a></li>; active item adds aria-current=\"page\"."
  - "Mobile hamburger is <button class=\"ren-nav-toggle\" aria-expanded=\"false\" aria-controls=\"…\" aria-label=\"Toggle menu\"> with three <span> stripes inside."
  - "Action area (CTA buttons, theme toggle, sign-in) goes in <div class=\"ren-nav-actions\"> which floats to the inline-end via margin-inline-start: auto."
  - "Dropdowns use .ren-nav-dropdown wrapping a trigger <a class=\"ren-nav-link\" aria-expanded=\"false\" popovertarget=\"…\"> and a [popover] panel."

forbiddenPatterns:
  - "Using <div> stacks instead of <nav> + <ul>/<li> — landmarks and list semantics matter."
  - "Manually toggling display: none on .ren-nav-links for mobile — set [data-open] on .ren-nav or aria-expanded on .ren-nav-toggle."
  - "Styling links with ren-btn — sidebar / nav items own their own chrome via .ren-nav-link."
  - "Replacing the hamburger with a custom toggle that lacks aria-expanded / aria-controls."
  - "Hardcoding the sticky backdrop color — the .ren-nav-sticky variant already uses color-mix on --color-surface."

tokenPolicy:
  allowed:
    - "Public component tokens listed in Public Token API: --ren-nav-bg, --ren-nav-border, --ren-nav-gap, --ren-nav-height, --ren-nav-link-active, --ren-nav-link-color, --ren-nav-padding-x."
    - "Semantic tokens consumed by selectors: --color-surface, --color-border, --color-text, --color-text-muted, --color-accent, --color-accent-subtle, --color-fill (for sticky color-mix)."
    - "Spacing / radius tokens: --ren-space-1, --ren-space-2, --ren-space-3, --ren-space-4, --ren-title-sm-size, --ren-label-size."
    - "Motion tokens: --duration-overlay, --duration-state, --duration-route, --duration-enter, --ease-state-change, --ease-enter."
    - "Z-index token: --ren-z-sticky."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / named colors for bg, border, active, or hover."
    - "Custom z-index values that bypass --ren-z-sticky for sticky mode."

accessibility:
  required:
    - "Root is a real <nav> with aria-label (e.g. \"Primary\") so screen readers can list landmarks."
    - "Active route uses aria-current=\"page\" (the .active class is a styling alias, not the source of truth)."
    - ".ren-nav-toggle has aria-expanded reflecting menu state, aria-controls pointing to the links list, and an aria-label (icon-only is not a name)."
    - "Mobile menu closes on Escape and on link activation (the component owns both behaviors)."
    - "Dropdown triggers expose aria-expanded; popover panels are real [popover] elements so the platform manages focus return."
    - "Focus-visible outlines use --color-accent at 2px offset — do not remove without a visible alternative."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/patterns/ren-nav/ren-nav.css">
<script type="module" src="rends/components/patterns/ren-nav/ren-nav.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-nav><nav class="ren-nav" aria-label="Primary"><a class="ren-nav-brand" href="/">RenDS</a><ul id="primary-links" class="ren-nav-links"><li><a class="ren-nav-link" href="/docs" aria-current="page">Docs</a></li></ul><button class="ren-nav-toggle" type="button" aria-expanded="false" aria-controls="primary-links" aria-label="Toggle menu"><span></span><span></span><span></span></button></nav></ren-nav>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-nav`
- `.ren-nav-actions`
- `.ren-nav-brand`
- `.ren-nav-dropdown`
- `.ren-nav-link`
- `.ren-nav-links`
- `.ren-nav-sticky`
- `.ren-nav-toggle`
- `.ren-nav-transparent`

## States And Attributes

- `[aria-current]`
- `[aria-expanded]`
- `[data-dropdown]`
- `[data-open]`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-nav-bg`
- `--ren-nav-border`
- `--ren-nav-gap`
- `--ren-nav-height`
- `--ren-nav-link-active`
- `--ren-nav-link-color`
- `--ren-nav-padding-x`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/patterns/ren-nav/ren-nav.css`
- `components/patterns/ren-nav/ren-nav.js`
- `docs/components/ren-nav.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


.ren-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--ren-space-2, 0.5rem) var(--ren-space-4, 1rem);
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  min-height: 3.5rem;
  transition: backdrop-filter var(--duration-overlay) var(--ease-state-change), background-color var(--duration-overlay) var(--ease-state-change);

  &[data-open] .ren-nav-links,
  &:has(.ren-nav-toggle[aria-expanded="true"]) .ren-nav-links {
    display: flex;
  }

  &.ren-nav-sticky {
    position: sticky;
    top: 0;
    z-index: var(--ren-z-sticky, 100);
    backdrop-filter: blur(8px);
    background-color: color-mix(in srgb, var(--color-surface) 95%, transparent);
  }

  &.ren-nav-transparent {
    background-color: transparent;
    border-bottom-color: transparent;
  }
}

.ren-nav-brand {
  display: flex;
  align-items: center;
  gap: var(--ren-space-2, 0.5rem);
  font-weight: 600;
  font-size: var(--ren-title-sm-size, 1.125rem);
  color: var(--color-text);
  text-decoration: none;
  flex-shrink: 0;

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
    border-radius: 0.375rem;
  }
}

.ren-nav-links {
  display: flex;
  align-items: center;
  gap: var(--ren-space-1, 0.25rem);
  list-style: none;
  margin: 0;
  padding: 0;

  @media (max-width: 48rem) {
    display: none;
    position: absolute;
    top: 3.5rem;
    inset-inline: 0;
    flex-direction: column;
    gap: 0;
    background-color: var(--color-surface);
    border-bottom: 1px solid var(--color-border);
    padding: var(--ren-space-2, 0.5rem) 0;
    animation: ren-nav-slide-down var(--duration-enter) var(--ease-enter);
    z-index: 1000;

    @media (prefers-reduced-motion: reduce) {
      animation: none;
    }
  }
}

@keyframes ren-nav-slide-down {
  from {
    opacity: 0;
    transform: translateY(-0.5rem);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.ren-nav-link {
  padding: var(--ren-space-2, 0.5rem) var(--ren-space-3, 0.75rem);
  color: var(--color-text-muted);
  font-size: var(--ren-label-size, 0.875rem);
  border-radius: 0.375rem;
  transition: background-color var(--duration-state) var(--ease-state-change), color var(--duration-state) var(--ease-state-change);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;

  &:hover {
    background-color: var(--color-fill);
    color: var(--color-text);
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
  }

  &[aria-current="page"],
  &.active {
    color: var(--color-accent);
    font-weight: 500;
    background-color: var(--color-accent-subtle);
  }

  @media (max-width: 48rem) {
    padding: var(--ren-space-2, 0.5rem) var(--ren-space-4, 1rem);
    border-radius: 0;

    &:hover {
      background-color: var(--color-fill);
    }
  }
}

.ren-nav-actions {
  display: flex;
  align-items: center;
  gap: var(--ren-space-2, 0.5rem);
  margin-inline-start: auto;
}

.ren-nav-toggle {
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.375rem;
  background: none;
  border: none;
  padding: var(--ren-space-2, 0.5rem);
  cursor: pointer;
  color: var(--color-text);
  min-width: 44px;
  min-height: 44px;
  border-radius: 0.375rem;
  transition: background-color var(--duration-state) var(--ease-state-change);

  &:hover {
    background-color: var(--color-fill);
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  & span {
    width: 1.5rem;
    height: 2px;
    background-color: currentColor;
    border-radius: 1px;
    transition: transform var(--duration-route) var(--ease-state-change), opacity var(--duration-route) var(--ease-state-change);
    transform-origin: center;
  }

  &[aria-expanded="true"] {
    & span:nth-child(1) {
      transform: translateY(0.5rem) rotate(45deg);
    }

    & span:nth-child(2) {
      opacity: 0;
      transform: scaleX(0);
    }

    & span:nth-child(3) {
      transform: translateY(-0.5rem) rotate(-45deg);
    }
  }

  @media (max-width: 48rem) {
    display: flex;
  }
}

.ren-nav-dropdown {
  position: relative;

  & [popover] {
    padding: var(--ren-space-1, 0.25rem);
    background-color: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: 0.5rem;
    box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
    margin-top: var(--ren-space-1, 0.25rem);
    list-style: none;
    padding-inline-start: 0;

    &[popover] {
      inset: auto auto auto auto;
    }
  }

  & .ren-nav-link[aria-expanded="true"] {
    background-color: var(--color-fill);
    color: var(--color-accent);
  }
}
/* Appearance API bridge */
.ren-nav { min-block-size: var(--ren-nav-height, 3.5rem); padding-inline: var(--ren-nav-padding-x, var(--space-4)); background: var(--ren-nav-bg, var(--color-surface)); border-color: var(--ren-nav-border, var(--color-border)); gap: var(--ren-nav-gap, var(--space-6)); }
.ren-nav a { color: var(--ren-nav-link-color, var(--color-text-muted)); }
.ren-nav a[aria-current="page"], .ren-nav a[data-active] { color: var(--ren-nav-link-active, var(--color-text)); }


/**
 * RenNav Component - Responsive Navigation Bar
 * Modern web component with keyboard navigation and mobile support
 */

const MOBILE_BREAKPOINT = 768;
const KEYBOARD_CODES = { Escape: 'Escape' };

class RenNav extends HTMLElement {
  constructor() {
    super();
    this._toggleBtn = null;
    this._linksContainer = null;
    this._links = [];
    this._isOpen = false;
    this._dropdowns = new Map();
    this._listenerController = null;
  }

  connectedCallback() {
    this._initElements();
    this._attachEventListeners();
    this._initDropdowns();
    this._updateMobileState();
  }

  disconnectedCallback() {
    this._removeEventListeners();
  }

  _initElements() {
    this._toggleBtn = this.querySelector('.ren-nav-toggle');
    this._linksContainer = this.querySelector('.ren-nav-links');
    this._links = Array.from(this.querySelectorAll('.ren-nav-link'));
  }

  _attachEventListeners() {
    this._listenerController?.abort();
    this._listenerController = new AbortController();
    const { signal } = this._listenerController;

    // Toggle button click
    if (this._toggleBtn) {
      this._toggleBtn.addEventListener('click', () => this._toggleMenu(), { signal });
    }

    // Close menu on link click
    this._links.forEach(link => {
      link.addEventListener('click', () => {
        if (this._isOpen) {
          this._closeMenu();
        }
      }, { signal });
    });

    // Close on Escape
    document.addEventListener('keydown', (e) => {
      if (e.key === KEYBOARD_CODES.Escape && this._isOpen) {
        this._closeMenu();
      }
    }, { signal });

    // Handle window resize
    window.addEventListener('resize', () => {
      this._updateMobileState();
    }, { signal });

    // Close menu on outside click
    document.addEventListener('click', (e) => {
      if (this._isOpen && !this.contains(e.target)) {
        this._closeMenu();
      }
    }, { signal });
  }

  _initDropdowns() {
    const signal = this._listenerController?.signal;
    const dropdownToggles = this.querySelectorAll('[data-dropdown]');
    dropdownToggles.forEach(toggle => {
      const popoverId = toggle.getAttribute('aria-controls');
      if (popoverId) {
        const popover = this.querySelector(`#${popoverId}`);
        if (popover) {
          this._dropdowns.set(toggle, popover);
          toggle.addEventListener('click', (e) => {
            e.stopPropagation();
            this._toggleDropdown(toggle, popover);
          }, { signal });

          // Close dropdown on item click
          popover.querySelectorAll('a, button').forEach(item => {
            item.addEventListener('click', () => {
              this._closeDropdown(toggle, popover);
            }, { signal });
          });
        }
      }
    });
  }

  _toggleDropdown(toggle, popover) {
    const isOpen = toggle.getAttribute('aria-expanded') === 'true';
    if (isOpen) {
      this._closeDropdown(toggle, popover);
    } else {
      // Close other dropdowns
      this._dropdowns.forEach((pop, tog) => {
        if (tog !== toggle) {
          this._closeDropdown(tog, pop);
        }
      });

      toggle.setAttribute('aria-expanded', 'true');
      popover.showPopover?.();
    }
  }

  _closeDropdown(toggle, popover) {
    toggle.setAttribute('aria-expanded', 'false');
    popover.hidePopover?.();
  }

  _toggleMenu() {
    if (this._isOpen) {
      this._closeMenu();
    } else {
      this._openMenu();
    }
  }

  _openMenu() {
    this._isOpen = true;
    this.setAttribute('data-open', '');
    if (this._toggleBtn) {
      this._toggleBtn.setAttribute('aria-expanded', 'true');
    }
  }

  _closeMenu() {
    this._isOpen = false;
    this.removeAttribute('data-open');
    if (this._toggleBtn) {
      this._toggleBtn.setAttribute('aria-expanded', 'false');
    }
  }

  _updateMobileState() {
    const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
    if (!isMobile && this._isOpen) {
      this._closeMenu();
    }
  }

  _removeEventListeners() {
    this._listenerController?.abort();
    this._listenerController = null;
  }

  // Public API
  setActiveLink(href) {
    this._links.forEach(link => {
      if (link.getAttribute('href') === href) {
        link.setAttribute('aria-current', 'page');
        link.classList.add('active');
      } else {
        link.removeAttribute('aria-current');
        link.classList.remove('active');
      }
    });
  }

  get isOpen() {
    return this._isOpen;
  }
}

customElements.define('ren-nav', RenNav);

data:
{"kind":"pattern","selectors":[".ren-nav",".ren-nav-actions",".ren-nav-brand",".ren-nav-dropdown",".ren-nav-link",".ren-nav-links",".ren-nav-sticky",".ren-nav-toggle",".ren-nav-transparent"],"tokens":["--color-accent","--color-accent-subtle","--color-border","--color-fill","--color-surface","--color-text","--color-text-muted","--duration-enter","--duration-overlay","--duration-route","--duration-state","--ease-enter","--ease-state-change","--ren-label-size","--ren-nav-bg","--ren-nav-border","--ren-nav-gap","--ren-nav-height","--ren-nav-link-active","--ren-nav-link-color","--ren-nav-padding-x","--ren-space-1","--ren-space-2","--ren-space-3","--ren-space-4","--ren-title-sm-size","--ren-z-sticky","--space-4","--space-6"],"hasScript":true,"hasDocsPage":true}


id: component:pattern:ren-sidebar
type: component
name: ren-sidebar
path: components/patterns/ren-sidebar
body:
# ren-sidebar

Application sidebar pattern: persistent left/right navigation with desktop
collapse, mobile overlay, active-item state, and keyboard accessibility.
Pairs with a main content slot to form an app shell layout.

Load this file after `ren-design.md` and before generating, editing, or
reviewing any `<ren-sidebar>` UI.

## Purpose

The dashboard / app-shell sidebar pattern in RenDS. Owns:
- Persistent layout with `.ren-sidebar-layout` (sidebar + main content).
- Desktop collapsed / expanded states (`[data-collapsed]`).
- Mobile overlay state (`[data-open]`) with backdrop and Escape / outside
  click dismissal.
- Active item state via `aria-current="page"` plus `.active`.
- Persistence of collapse state in `localStorage`.
- Toggle button + keyboard support.

Sidebar items are real `<a>` (for routes) or `<button>` (for actions); they
are *not* `ren-link` / `ren-btn` — sidebar styling owns their chrome.

## Use When

- The product has persistent left/right navigation across multiple pages.
- The page is an app shell with sidebar + main layout (`.ren-sidebar-layout`).
- The sidebar must collapse on desktop and overlay on mobile.
- Persistence of collapse state across sessions matters.

## Do Not Use When

- The navigation is a top horizontal bar — use `ren-nav`.
- The navigation is a one-off menu inside another component — use
  `ren-menu` / `ren-menubar`.
- The sidebar is a transient panel — use `ren-sheet`.
- The sidebar is a single floating button toolbar — use `ren-toolbar`.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The product needs persistent navigation across many pages."
    - "Desktop should support a collapsed (icon-only) and expanded state."
    - "Mobile should overlay with backdrop dismissal."
    - "Active route should be reflected via aria-current=\"page\"."
    - "Collapse state should persist in localStorage."
  avoidWhen:
    - "The navigation is a horizontal top bar — use ren-nav."
    - "The disclosure is transient or context-specific — use ren-sheet / ren-menu."
    - "The page does not have a multi-section nav."

canonicalImports:
  css:
    - "rends/components/patterns/ren-sidebar/ren-sidebar.css"
  js:
    - "rends/components/patterns/ren-sidebar/ren-sidebar.js"
  notes:
    - "JS handles toggle, persistence, and overlay dismissal. CSS-only fallback only renders the expanded desktop state."

requiredMarkup:
  - "Wrap the app shell in <div class=\"ren-sidebar-layout\">."
  - "<ren-sidebar> is the sidebar root; main content goes in a sibling <main class=\"ren-sidebar-main\">."
  - "Wrap items in <nav class=\"ren-sidebar-nav\"> with <a class=\"ren-sidebar-item\"> children."
  - "Use aria-current=\"page\" on the active item; the .active class is for styling parity."
  - "Provide an aria-label on <ren-sidebar> when multiple navigation landmarks exist on the page."
  - "Toggle button uses .ren-sidebar-toggle and aria-label (the icon alone is not a name)."

forbiddenPatterns:
  - "Wrapping non-nav content in <nav class=\"ren-sidebar-nav\">."
  - "Using ren-btn / ren-link as sidebar items — use .ren-sidebar-item directly."
  - "Hardcoded sidebar widths in inline styles; use --ren-sidebar-width."
  - "Manually toggling display: none for the mobile overlay; use the [data-open] state."

tokenPolicy:
  allowed:
    - "Component tokens: every --ren-sidebar-* listed in Public Token API."
    - "Semantic tokens for content (--color-text, --color-text-muted, etc.)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, etc.) in consumer code."
    - "Hardcoded shadow / overlay colors that bypass tokens."

accessibility:
  required:
    - "Wrap items in a real <nav> landmark."
    - "Active item uses aria-current=\"page\"."
    - "Toggle button has aria-label and toggles aria-expanded if it controls disclosure."
    - "Collapsed icon-only items must keep accessible names from their link text (do not hide it from AT)."
    - "Mobile overlay closes on Escape and outside click."
```

## Required CSS / JS Imports

```html
<link rel="stylesheet" href="rends/components/patterns/ren-sidebar/ren-sidebar.css">
<script type="module" src="rends/components/patterns/ren-sidebar/ren-sidebar.js"></script>
```

## Canonical Markup

App shell with sidebar + main:

```html
<div class="ren-sidebar-layout">
  <ren-sidebar class="ren-sidebar" aria-label="Primary">
    <div class="ren-sidebar-header">
      <a class="ren-sidebar-item" href="/" aria-label="Home">
        <span class="ren-sidebar-item-icon" aria-hidden="true">⌂</span>
        <span class="ren-sidebar-item-text">Acme</span>
      </a>
    </div>

    <nav class="ren-sidebar-nav" aria-label="Main">
      <a class="ren-sidebar-item active" href="/dashboard" aria-current="page">
        <span class="ren-sidebar-item-icon" aria-hidden="true">📊</span>
        <span class="ren-sidebar-item-text">Dashboard</span>
      </a>
      <a class="ren-sidebar-item" href="/projects">
        <span class="ren-sidebar-item-icon" aria-hidden="true">📁</span>
        <span class="ren-sidebar-item-text">Projects</span>
      </a>
    </nav>

    <div class="ren-sidebar-section">
      <div class="ren-sidebar-section-label">Account</div>
      <a class="ren-sidebar-item" href="/settings">
        <span class="ren-sidebar-item-icon" aria-hidden="true">⚙</span>
        <span class="ren-sidebar-item-text">Settings</span>
      </a>
    </div>

    <button class="ren-sidebar-toggle" type="button" aria-label="Toggle sidebar"></button>
  </ren-sidebar>

  <main class="ren-sidebar-main">
    <!-- page content -->
  </main>
</div>
```

Programmatic API:

```js
const sidebar = document.querySelector('ren-sidebar');
sidebar.toggleMenu();
sidebar.setActiveItem('/projects');
sidebar.addEventListener('ren-sidebar-toggle', (e) => console.log(e.detail.collapsed));
```

## Attributes, Events, and API

- Host states:
  - `[data-collapsed]` — desktop collapsed (icon-only).
  - `[data-open]` — mobile overlay open.
- Active item: `.active` class plus `aria-current="page"` on the
  current `.ren-sidebar-item`.
- Methods: `toggleMenu()`, `openMobileMenu()`, `closeMobileMenu()`,
  `setActiveItem(href)`.
- Getters: `isCollapsed`, `isMobileOpen`.
- Event: `ren-sidebar-toggle` (`detail.collapsed`).
- Persistence: collapse state stored in `localStorage` under
  `ren-sidebar-collapsed`.

## Variants and Public Selectors

| Class                            | Role                                |
|----------------------------------|-------------------------------------|
| `.ren-sidebar`                   | Sidebar root chrome.                |
| `.ren-sidebar-layout`            | Layout wrapper (sidebar + main).    |
| `.ren-sidebar-main`              | Main-content slot.                  |
| `.ren-sidebar-header`            | Header slot.                        |
| `.ren-sidebar-content`           | Scrollable middle slot.             |
| `.ren-sidebar-footer`            | Footer slot.                        |
| `.ren-sidebar-nav`               | `<nav>` wrapper.                    |
| `.ren-sidebar-section`           | Grouped section.                    |
| `.ren-sidebar-section-label`     | Section heading text.               |
| `.ren-sidebar-item`              | Single nav item.                    |
| `.ren-sidebar-item-icon`         | Icon slot inside an item.           |
| `.ren-sidebar-item-text`         | Text slot inside an item.           |
| `.ren-sidebar-divider`           | Visual divider between sections.    |
| `.ren-sidebar-toggle`            | Collapse button.                    |
| `.ren-sidebar-overlay`           | Mobile backdrop.                    |

## States

| Selector / attr        | Meaning                                |
|------------------------|----------------------------------------|
| `[aria-current="page"]`| Active route on the current item.      |
| `[aria-disabled]`      | Disabled item (still focusable).       |
| `[data-collapsed]`     | Desktop collapsed (icon-only) state.   |
| `[data-open]`          | Mobile overlay open.                   |
| `:focus-visible`       | Keyboard focus on an item or toggle.   |

## Public Token API

- `--ren-sidebar-bg`
- `--ren-sidebar-border`
- `--ren-sidebar-width`
- `--ren-sidebar-collapsed-width`
- `--ren-sidebar-item-height`
- `--ren-sidebar-item-padding`
- `--ren-sidebar-item-radius`
- `--ren-sidebar-active-bg`
- `--ren-sidebar-active-color`
- `--ren-sidebar-duration`
- `--ren-sidebar-easing`

## Accessibility Contract

- Wrap items in a real `<nav>` landmark and label the sidebar with
  `aria-label` when multiple nav landmarks exist on the page.
- The current route uses `aria-current="page"`.
- Collapsed icon-only items must keep accessible names — do not hide the
  text label from screen readers.
- The toggle button has an `aria-label` (`"Toggle sidebar"`).
- Mobile overlay closes on Escape and outside click.
- Persistence respects user choice; do not auto-collapse on every visit.

## Anti-Patterns

- ❌ Wrapping a `<nav>` of non-RenDS items inside `.ren-sidebar-nav`.
- ❌ Replacing items with `ren-btn` / `ren-link` and re-styling.
- ❌ `<ren-sidebar style="width:240px">` — set `--ren-sidebar-width` on a
  parent scope.
- ❌ Using `display: none` to hide the sidebar on mobile — toggle the
  `[data-open]` state instead.
- ❌ Decorative-icon-only items without text labels.

## Related Files

- `components/patterns/ren-sidebar/ren-sidebar.css`
- `components/patterns/ren-sidebar/ren-sidebar.js`
- `docs/components/ren-sidebar.html`
- `ren-design.md`
- `tokens/tokens.md`

## Test Expectations

- Run component / docs a11y coverage on collapse / mobile overlay flows.
- Run `npm run lint` after token / selector changes.
- Manually verify keyboard navigation: Tab through items, Escape closes
  the mobile overlay.


.ren-sidebar-layout {
  display: flex;
  min-height: 100dvh;
  background-color: var(--color-surface);
}

.ren-sidebar {
  --sidebar-width: var(--ren-sidebar-width, 16rem);
  --sidebar-collapsed-width: var(--ren-sidebar-collapsed-width, 4rem);

  width: var(--sidebar-width);
  flex-shrink: 0;
  background-color: var(--color-surface-sunken);
  border-inline-end: 1px solid var(--color-border);
  display: flex;
  flex-direction: column;
  transition: width var(--duration-enter) var(--ease-state-change), transform var(--duration-enter) var(--ease-state-change);
  overflow: hidden;
  z-index: 50;

  &[data-collapsed] {
    width: var(--sidebar-collapsed-width);

    & .ren-sidebar-item-text {
      opacity: 0;
      width: 0;
      overflow: hidden;
      transition: opacity var(--duration-enter) var(--ease-state-change), width var(--duration-enter) var(--ease-state-change);
    }

    & .ren-sidebar-section-label {
      opacity: 0;
      width: 0;
      overflow: hidden;
      transition: opacity var(--duration-enter) var(--ease-state-change), width var(--duration-enter) var(--ease-state-change);
    }

    & .ren-sidebar-item {
      justify-content: center;
      position: relative;

      &:hover::after {
        content: attr(data-tooltip);
        position: absolute;
        inset-inline-start: 100%;
        top: 50%;
        transform: translateY(-50%);
        margin-inline-start: var(--ren-space-2, 0.5rem);
        padding: var(--ren-space-2, 0.5rem) var(--ren-space-3, 0.75rem);
        background-color: var(--color-text);
        color: var(--color-surface);
        border-radius: 0.375rem;
        font-size: var(--ren-caption-size, 0.75rem);
        white-space: nowrap;
        z-index: 1000;
        pointer-events: none;
      }
    }
  }

  @media (max-width: 48rem) {
    position: fixed;
    top: 0;
    inset-inline-start: 0;
    height: 100dvh;
    width: var(--sidebar-width);
    transform: translateX(-100%);
    transition: transform var(--duration-enter) var(--ease-state-change);

    &[data-open] {
      transform: translateX(0);
      box-shadow: 4px 0 12px rgb(0 0 0 / 0.15);
    }

    &[data-collapsed] {
      transform: translateX(-100%);
      width: var(--sidebar-width);
    }
  }
}

.ren-sidebar-header {
  padding: var(--ren-space-3, 0.75rem) var(--ren-space-4, 1rem);
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid var(--color-border);
  flex-shrink: 0;

  @media (max-width: 48rem) {
    padding: var(--ren-space-3, 0.75rem) var(--ren-space-4, 1rem);
  }
}

.ren-sidebar-content {
  flex: 1;
  overflow-y: auto;
  padding: var(--ren-space-2, 0.5rem);
  scrollbar-width: thin;
  scrollbar-color: var(--color-border) transparent;

  &::-webkit-scrollbar {
    width: 6px;
  }

  &::-webkit-scrollbar-track {
    background: transparent;
  }

  &::-webkit-scrollbar-thumb {
    background-color: var(--color-border);
    border-radius: 3px;

    &:hover {
      background-color: var(--color-text-muted);
    }
  }
}

.ren-sidebar-footer {
  padding: var(--ren-space-3, 0.75rem) var(--ren-space-4, 1rem);
  border-top: 1px solid var(--color-border);
  flex-shrink: 0;

  @media (max-width: 48rem) {
    padding: var(--ren-space-3, 0.75rem) var(--ren-space-4, 1rem);
  }
}

.ren-sidebar-nav {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--ren-space-1, 0.25rem);
}

.ren-sidebar-section {
  margin-top: var(--ren-space-4, 1rem);

  &:first-child {
    margin-top: 0;
  }
}

.ren-sidebar-section-label {
  padding: var(--ren-space-1, 0.25rem) var(--ren-space-3, 0.75rem);
  font-size: var(--ren-caption-size, 0.75rem);
  font-weight: 600;
  color: var(--color-text-faint);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  transition: opacity var(--duration-enter) var(--ease-state-change);
  white-space: nowrap;
  overflow: hidden;
}

.ren-sidebar-item {
  display: flex;
  align-items: center;
  gap: var(--ren-space-3, 0.75rem);
  padding: var(--ren-space-2, 0.5rem) var(--ren-space-3, 0.75rem);
  border-radius: 0.375rem;
  color: var(--color-text-muted);
  font-size: var(--ren-label-size, 0.875rem);
  cursor: pointer;
  transition: background-color var(--duration-state) var(--ease-state-change), color var(--duration-state) var(--ease-state-change);
  text-decoration: none;
  white-space: nowrap;
  user-select: none;

  &:hover {
    background-color: var(--color-fill);
    color: var(--color-text);
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
  }

  &.active,
  &[aria-current="page"] {
    background-color: var(--color-accent-subtle);
    color: var(--color-accent);
    font-weight: 500;

    & .ren-sidebar-item-icon {
      color: var(--color-accent);
    }
  }

  &[aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
    pointer-events: none;
  }
}

.ren-sidebar-item-icon {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  color: var(--color-text-muted);
  transition: color var(--duration-state) var(--ease-state-change);
  display: flex;
  align-items: center;
  justify-content: center;
}

.ren-sidebar-item-text {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: opacity var(--duration-enter) var(--ease-state-change), width var(--duration-enter) var(--ease-state-change);
}

.ren-sidebar-divider {
  height: 1px;
  background-color: var(--color-separator);
  margin: var(--ren-space-2, 0.5rem) 0;
}

.ren-sidebar-toggle {
  background: none;
  border: none;
  padding: var(--ren-space-2, 0.5rem);
  cursor: pointer;
  color: var(--color-text-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  min-height: 32px;
  border-radius: 0.375rem;
  transition: background-color var(--duration-state) var(--ease-state-change), color var(--duration-state) var(--ease-state-change);

  &:hover {
    background-color: var(--color-fill);
    color: var(--color-text);
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  @media (max-width: 48rem) {
    display: none;
  }
}

.ren-sidebar-main {
  flex: 1;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  scrollbar-width: thin;
  scrollbar-color: var(--color-border) transparent;

  &::-webkit-scrollbar {
    width: 6px;
  }

  &::-webkit-scrollbar-track {
    background: transparent;
  }

  &::-webkit-scrollbar-thumb {
    background-color: var(--color-border);
    border-radius: 3px;

    &:hover {
      background-color: var(--color-text-muted);
    }
  }

  @media (max-width: 48rem) {
    margin-inline-start: 0;
  }
}

.ren-sidebar-overlay {
  position: fixed;
  inset: 0;
  background-color: rgb(0 0 0 / 0.5);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-enter) var(--ease-state-change);
  z-index: 40;

  @media (max-width: 48rem) {
    .ren-sidebar[data-open] ~ & {
      opacity: 1;
      pointer-events: auto;
    }
  }
}

/* ═══ REDUCED MOTION ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-sidebar,
  .ren-sidebar * {
    animation-duration: 0ms !important;
    transition-duration: 0ms !important;
  }
}
/* Appearance API bridge */
.ren-sidebar { inline-size: var(--ren-sidebar-width, 260px); background: var(--ren-sidebar-bg, var(--color-surface-sunken)); border-color: var(--ren-sidebar-border, var(--color-border)); transition-duration: var(--ren-sidebar-duration, var(--duration-normal)); transition-timing-function: var(--ren-sidebar-easing, var(--ease-out)); }
.ren-sidebar.is-collapsed, .ren-sidebar[data-collapsed="true"] { inline-size: var(--ren-sidebar-collapsed-width, 60px); }
.ren-sidebar a, .ren-sidebar button { min-block-size: var(--ren-sidebar-item-height, var(--size-lg)); padding: var(--ren-sidebar-item-padding, var(--space-2) var(--space-3)); border-radius: var(--ren-sidebar-item-radius, var(--radius-md)); }
.ren-sidebar a[aria-current="page"], .ren-sidebar a[data-active] { background: var(--ren-sidebar-active-bg, var(--color-accent-subtle)); color: var(--ren-sidebar-active-color, var(--color-accent)); }

@media (any-pointer: coarse), (pointer: coarse) {
  .ren-sidebar-toggle {
    min-inline-size: var(--touch-min);
    min-block-size: var(--touch-min);
  }
}


/**
 * RenSidebar Component - Collapsible sidebar for app layouts
 * Supports desktop collapse and mobile overlay modes
 */

const MOBILE_BREAKPOINT = 768;
const STORAGE_KEY = 'ren-sidebar-collapsed';
const KEYBOARD_CODES = { Escape: 'Escape' };

class RenSidebar extends HTMLElement {
  constructor() {
    super();
    this._toggleBtn = null;
    this._isCollapsed = false;
    this._isOpen = false;
    this._isMobile = false;
    this._navItems = [];
    this._listenerController = null;
    this._storageKey = STORAGE_KEY;
  }

  connectedCallback() {
    this._initElements();
    this._restoreState();
    this._attachEventListeners();
    this._updateMobileState();
  }

  disconnectedCallback() {
    this._removeEventListeners();
  }

  _initElements() {
    this._toggleBtn = this.querySelector('.ren-sidebar-toggle');
    this._navItems = Array.from(this.querySelectorAll('.ren-sidebar-item'));
    this._storageKey = this.getAttribute('storage-key') || STORAGE_KEY;
  }

  _attachEventListeners() {
    this._listenerController?.abort();
    this._listenerController = new AbortController();
    const { signal } = this._listenerController;

    // Toggle collapse button
    if (this._toggleBtn) {
      this._toggleBtn.addEventListener('click', () => this._toggleCollapse(), { signal });
    }

    // Nav item clicks
    this._navItems.forEach(item => {
      item.addEventListener('click', (e) => {
        if (e.currentTarget.hasAttribute('href')) {
          this._setActiveItem(e.currentTarget);
        }
        if (this._isMobile && this._isOpen) {
          this._closeMenu();
        }
      }, { signal });
    });

    // Close on Escape (mobile)
    document.addEventListener('keydown', (e) => {
      if (e.key === KEYBOARD_CODES.Escape && this._isMobile && this._isOpen) {
        this._closeMenu();
      }
    }, { signal });

    // Handle window resize
    window.addEventListener('resize', () => {
      this._updateMobileState();
    }, { signal });

    // Close menu on outside click (mobile)
    document.addEventListener('click', (e) => {
      if (this._isMobile && this._isOpen && !this.contains(e.target)) {
        const overlay = this.nextElementSibling;
        if (overlay && overlay.classList.contains('ren-sidebar-overlay')) {
          if (overlay.contains(e.target)) {
            this._closeMenu();
          }
        }
      }
    }, { signal });

    // Expose toggle menu for mobile
    this._setupMobileToggle();
  }

  _setupMobileToggle() {
    // Allow external control via method
    this.toggleMenu = () => {
      if (this._isOpen) {
        this._closeMenu();
      } else {
        this._openMenu();
      }
    };
  }

  _removeEventListeners() {
    this._listenerController?.abort();
    this._listenerController = null;
  }

  _toggleCollapse() {
    if (this._isCollapsed) {
      this._expand();
    } else {
      this._collapse();
    }
  }

  _collapse() {
    this._isCollapsed = true;
    this.setAttribute('data-collapsed', '');
    this._writeCollapsedState(true);
    this._dispatchToggleEvent();
  }

  _expand() {
    this._isCollapsed = false;
    this.removeAttribute('data-collapsed');
    this._writeCollapsedState(false);
    this._dispatchToggleEvent();
  }

  _toggleMenu() {
    if (this._isOpen) {
      this._closeMenu();
    } else {
      this._openMenu();
    }
  }

  _openMenu() {
    if (!this._isMobile) return;
    this._isOpen = true;
    this.setAttribute('data-open', '');
  }

  _closeMenu() {
    if (!this._isMobile) return;
    this._isOpen = false;
    this.removeAttribute('data-open');
  }

  _updateMobileState() {
    const wasMobile = this._isMobile;
    this._isMobile = window.innerWidth < MOBILE_BREAKPOINT;

    // Close menu when transitioning to desktop
    if (wasMobile && !this._isMobile && this._isOpen) {
      this._closeMenu();
    }

    // Reset collapse on mobile
    if (this._isMobile && this._isCollapsed) {
      this._expand();
    }
  }

  _restoreState() {
    const isCollapsed = this._readCollapsedState();
    if (isCollapsed && !this._isMobile) {
      this._collapse();
    }
  }

  _readCollapsedState() {
    try {
      return localStorage.getItem(this._storageKey) === 'true';
    } catch {
      return false;
    }
  }

  _writeCollapsedState(isCollapsed) {
    try {
      localStorage.setItem(this._storageKey, isCollapsed ? 'true' : 'false');
    } catch {
      // Storage can be blocked in private contexts; visual state still updates.
    }
  }

  _setActiveItem(item) {
    this._navItems.forEach(navItem => {
      navItem.classList.remove('active');
      navItem.removeAttribute('aria-current');
    });

    item.classList.add('active');
    item.setAttribute('aria-current', 'page');
  }

  _dispatchToggleEvent() {
    const event = new CustomEvent('ren-sidebar-toggle', {
      detail: { collapsed: this._isCollapsed },
      bubbles: true,
      composed: true
    });
    this.dispatchEvent(event);
  }

  // Public API
  get isCollapsed() {
    return this._isCollapsed;
  }

  get isMobileOpen() {
    return this._isOpen;
  }

  setActiveItem(href) {
    const item = this._navItems.find((navItem) => navItem.getAttribute('href') === href);
    if (item && item.classList.contains('ren-sidebar-item')) {
      this._setActiveItem(item);
    }
  }

  openMobileMenu() {
    if (this._isMobile) {
      this._openMenu();
    }
  }

  closeMobileMenu() {
    if (this._isMobile) {
      this._closeMenu();
    }
  }
}

customElements.define('ren-sidebar', RenSidebar);

data:
{"kind":"pattern","selectors":[".ren-sidebar",".ren-sidebar-content",".ren-sidebar-divider",".ren-sidebar-footer",".ren-sidebar-header",".ren-sidebar-item",".ren-sidebar-item-icon",".ren-sidebar-item-text",".ren-sidebar-layout",".ren-sidebar-main",".ren-sidebar-nav",".ren-sidebar-overlay",".ren-sidebar-section",".ren-sidebar-section-label",".ren-sidebar-toggle"],"tokens":["--color-accent","--color-accent-subtle","--color-border","--color-fill","--color-separator","--color-surface","--color-surface-sunken","--color-text","--color-text-faint","--color-text-muted","--duration-enter","--duration-normal","--duration-state","--ease-out","--ease-state-change","--radius-md","--ren-caption-size","--ren-label-size","--ren-sidebar-active-bg","--ren-sidebar-active-color","--ren-sidebar-bg","--ren-sidebar-border","--ren-sidebar-collapsed-width","--ren-sidebar-duration","--ren-sidebar-easing","--ren-sidebar-item-height","--ren-sidebar-item-padding","--ren-sidebar-item-radius","--ren-sidebar-width","--ren-space-1","--ren-space-2","--ren-space-3","--ren-space-4","--sidebar-collapsed-width","--sidebar-width","--size-lg","--space-2","--space-3","--touch-min"],"hasScript":true,"hasDocsPage":true}


id: component:pattern:ren-table
type: component
name: ren-table
path: components/patterns/ren-table
body:
# ren-table Pattern Contract

Data table pattern with sorting, row state, selection, and dense scanning.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-table` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-table` pattern.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Table pattern behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this pattern.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The UI is a tabular dataset with rows + columns that needs sortable headers (.ren-th-sortable + data-sort)."
    - "You need row selection (multi-select with shift-click range) using a leading <input type=\"checkbox\"> in .ren-table-select cells."
    - "You need client-side pagination (.ren-table-pagination + data-page-prev/data-page-next/data-page-size-select buttons)."
    - "You need search / filter via a [data-table-search] input inside .ren-table-toolbar."
    - "You need density variants (.ren-table-compact, .ren-table-comfortable) or sticky / pinned headers and columns."
    - "You need integrated empty state (.ren-table-empty) and loading shimmer ([data-loading] + .ren-table-loading-overlay)."
  avoidWhen:
    - "The data is a list of cards / tiles — use ren-card grid, not a table."
    - "There are only 2-3 key/value pairs per item — use ren-description-list."
    - "It is a tree / hierarchical view — use ren-tree."
    - "It is a single read-only stat — use ren-stat / ren-metric."
    - "The dataset is bigger than what fits in memory — pair with server-side pagination, not the built-in client pager."

canonicalImports:
  css:
    - "rends/components/patterns/ren-table/ren-table.css"
  js:
    - "rends/components/patterns/ren-table/ren-table.js"
  notes:
    - "JS registers <ren-table> and owns sorting, selection, pagination, keyboard nav, column resize, and search filter."
    - "Use real <table>, <thead>, <tbody>, <tr>, <th>, <td> — the JS hooks the standard elements; do not replace with divs."

requiredMarkup:
  - "Root is <ren-table data-page-size=\"…\"> wrapping a <div class=\"ren-table-wrapper\"> > <table class=\"ren-table\">."
  - "Header row uses <thead class=\"ren-table-header\"><tr><th class=\"ren-th\">…</th></tr></thead>; sortable headers add .ren-th-sortable + data-column=\"<key>\"."
  - "Body uses <tbody class=\"ren-table-body\">; its rows are <tr class=\"ren-tr\" data-row-id=\"…\"> with <td class=\"ren-td\"> cells. Selection uses class .ren-table-select on both th/td and a real <input type=\"checkbox\" aria-label=\"Select row\">."
  - "Pagination chrome goes in <div class=\"ren-table-pagination\"> with .ren-table-pagination-info + .ren-table-pagination-controls containing buttons carrying [data-page-prev] / [data-page-next] / [data-page-size-select]."
  - "Toolbar lives in <div class=\"ren-table-toolbar\"> and the search box must include the [data-table-search] attribute so the JS wires filtering."
  - "Empty state is <div class=\"ren-table-empty\"> with .ren-table-empty-icon / .ren-table-empty-title / .ren-table-empty-description (or reuse the ren-empty-state pattern)."

forbiddenPatterns:
  - "Replacing <table> with <div role=\"table\"> — kills native keyboard / screen-reader support."
  - "Implementing sort by clicking a non-th element — sort affordance must live on <th class=\"ren-th-sortable\">."
  - "Setting [data-sort] manually from consumer code — the component owns asc/desc cycling."
  - "Custom checkbox UI inside .ren-table-select — keep the native <input type=\"checkbox\"> (accent-color is themed via --color-accent)."
  - "Toggling rows with display: none for filtering — let the JS manage row visibility through its filter pipeline."
  - "Hardcoding pinned column offsets — use .ren-th-pinned / .ren-td-pinned which already set sticky positioning."

tokenPolicy:
  allowed:
    - "Public component tokens listed in Public Token API: --ren-table-border-color, --ren-table-cell-padding, --ren-table-header-bg, --ren-table-header-color, --ren-table-header-size, --ren-table-header-weight, --ren-table-hover-bg, --ren-table-row-height, --ren-table-selected-bg, --ren-table-stripe-bg."
    - "Semantic tokens consumed by selectors: --color-surface, --color-surface-raised, --color-border, --color-text, --color-text-muted, --color-fill, --color-fill-hover, --color-accent, --color-on-accent, --color-accent-subtle."
    - "Spacing / radius / type tokens: --space-2, --space-3, --space-4, --space-10, --radius-sm, --radius-md, --stroke-1, --body-size, --label-size, --label-weight, --caption-size, --touch-min."
    - "Motion tokens: --duration-tactile, --duration-state, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) for stripes, borders, or selection."
    - "Hardcoded hex / named colors for hover, selected, or accent."
    - "Inline transition values — use --duration-* / --ease-* tokens."

accessibility:
  required:
    - "Use a real <table> with <thead> / <tbody>; sortable <th> elements set aria-sort=\"ascending|descending|none\" mirroring [data-sort]."
    - "Selection checkboxes are real <input type=\"checkbox\"> with aria-label (\"Select all\" / \"Select row N\"); rows reflect state via aria-selected=\"true\" on <tr>."
    - "Pagination buttons are real <button> with min-width/height of 44px (--touch-min); disabled state uses the disabled attribute, not opacity alone."
    - "Loading state sets [data-loading] on <ren-table> and exposes pointer-events: none on rows; pair with an aria-live announcement so SR users hear \"Loading…\" / \"Loaded N rows\"."
    - "Empty state must include a visible .ren-table-empty-title text; do not rely on icon alone."
    - "Focus rings on <th>, <td>, .ren-tr (:focus-within), and pagination buttons all use --color-accent — never strip without restoring a visible alternative."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/patterns/ren-table/ren-table.css">
<script type="module" src="rends/components/patterns/ren-table/ren-table.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-table data-page-size="10"><div class="ren-table-wrapper"><table class="ren-table"><thead class="ren-table-header"><tr><th class="ren-th ren-th-sortable" data-column="name">Name</th></tr></thead><tbody class="ren-table-body"><tr class="ren-tr" data-row-id="1"><td class="ren-td">Jane Doe</td></tr></tbody></table></div></ren-table>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-table`
- `.ren-table-body`
- `.ren-table-comfortable`
- `.ren-table-compact`
- `.ren-table-empty`
- `.ren-table-empty-description`
- `.ren-table-empty-icon`
- `.ren-table-empty-title`
- `.ren-table-header`
- `.ren-table-loading-overlay`
- `.ren-table-pagination`
- `.ren-table-pagination-button`
- `.ren-table-pagination-controls`
- `.ren-table-pagination-info`
- `.ren-table-select`
- `.ren-table-toolbar`
- `.ren-table-toolbar-search`
- `.ren-table-wrapper`
- `.ren-td`
- `.ren-td-pinned`
- `.ren-th`
- `.ren-th-pinned`
- `.ren-th-resize`
- `.ren-th-sortable`
- `...and 1 more in the source files.`

## States And Attributes

- `[aria-selected]`
- `[data-loading]`
- `[data-page-next]`
- `[data-page-prev]`
- `[data-page-size-select]`
- `[data-sort]`
- `[data-table-search]`
- `:active`
- `:checked`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-table-border-color`
- `--ren-table-cell-padding`
- `--ren-table-header-bg`
- `--ren-table-header-color`
- `--ren-table-header-size`
- `--ren-table-header-weight`
- `--ren-table-hover-bg`
- `--ren-table-row-height`
- `--ren-table-selected-bg`
- `--ren-table-stripe-bg`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Events

- `ren-sort` — `{ column, direction }` after the sort direction changes.
- `ren-select` — `{ selected }` after row selection changes or is cleared.
- `ren-filter` — `{ value }` after the toolbar search filter changes.

All three events bubble and are composed; they are not cancelable.

## Related Files

- `components/patterns/ren-table/ren-table.css`
- `components/patterns/ren-table/ren-table.js`
- `docs/components/ren-table.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/**
 * RenDS — Data Table Organism
 * ============================
 *
 * A comprehensive, accessible data table component supporting:
 * - Sortable columns with directional indicators
 * - Row selection with multi-select and shift+click range selection
 * - Client-side pagination with configurable page size
 * - Keyboard navigation (arrow keys, Enter, Space)
 * - Column resizing via drag handle
 * - Pinned/sticky columns and headers
 * - Multiple density variants (compact, comfortable)
 * - Loading states with skeleton shimmer
 * - Empty state messaging
 * - Responsive horizontal scrolling
 * - Search/filter toolbar integration
 * - Full ARIA support for accessibility
 *
 * @example
 * <ren-table data-page-size="10">
 *   <div class="ren-table-toolbar">
 *     <input class="ren-input ren-input-sm" placeholder="Search..." data-table-search>
 *   </div>
 *   <div class="ren-table-wrapper">
 *     <table class="ren-table">
 *       <thead class="ren-table-header">
 *         <tr>
 *           <th class="ren-th ren-table-select"><input type="checkbox" aria-label="Select all"></th>
 *           <th class="ren-th ren-th-sortable" data-column="name">Name</th>
 *         </tr>
 *       </thead>
 *       <tbody class="ren-table-body">
 *         <tr class="ren-tr" data-row-id="1">
 *           <td class="ren-td ren-table-select"><input type="checkbox" aria-label="Select row"></td>
 *           <td class="ren-td">John Doe</td>
 *         </tr>
 *       </tbody>
 *     </table>
 *   </div>
 *   <div class="ren-table-pagination"></div>
 * </ren-table>
 */

/* ===== Base Table Structure ===== */

ren-table {
  container-type: inline-size;
  container-name: ren-table;
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
}

.ren-table-wrapper {
  flex: 1;
  overflow-x: auto;
  overflow-y: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  scroll-behavior: smooth;
  scrollbar-width: thin;
  scrollbar-color: var(--color-border) transparent;

  &::-webkit-scrollbar {
    height: 8px;
    width: 8px;
  }

  &::-webkit-scrollbar-track {
    background: transparent;
  }

  &::-webkit-scrollbar-thumb {
    background-color: var(--color-border);
    border-radius: var(--radius-sm);
    transition: background-color var(--duration-state) var(--ease-enter);

    &:hover {
      background-color: var(--color-text-muted);
    }
  }
}

.ren-table {
  width: 100%;
  border-collapse: collapse;
  background-color: var(--color-surface);
  font-size: var(--body-size);
  color: var(--color-text);
}

/* ===== Header Styles ===== */

.ren-table-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background-color: var(--color-surface-raised);
}

.ren-table-header tr {
  border-bottom: 1px solid var(--color-border);
}

.ren-th {
  padding: var(--space-3) var(--space-4);
  text-align: start;
  font-weight: var(--label-weight);
  font-size: var(--label-size);
  color: var(--color-text);
  user-select: none;
  white-space: nowrap;
  background-color: var(--color-surface-raised);
  border-inline-end: 1px solid var(--color-border);
  height: 44px;
  display: table-cell;
  vertical-align: middle;

  &:last-child {
    border-inline-end: none;
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
  }
}

.ren-th-sortable {
  cursor: pointer;
  position: relative;
  transition: background-color var(--duration-tactile) var(--ease-enter);

  &:hover {
    background-color: var(--color-fill);
  }

  &::after {
    content: '';
    display: inline-block;
    width: 12px;
    height: 12px;
    margin-inline-start: var(--space-2);
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none"><path d="M3 8L6 5l3 3M3 4L6 7l3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>');
    background-size: contain;
    background-repeat: no-repeat;
    opacity: 0.4;
    transition: opacity var(--duration-tactile) var(--ease-enter), transform var(--duration-tactile) var(--ease-enter);
    vertical-align: middle;
    color: var(--color-text-muted);
  }

  &[data-sort="asc"]::after {
    opacity: 1;
    transform: scaleY(1);
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none"><path d="M6 2v6M3 5l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>');
  }

  &[data-sort="desc"]::after {
    opacity: 1;
    transform: scaleY(-1);
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none"><path d="M6 2v6M3 5l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>');
  }
}

.ren-th-resize {
  position: absolute;
  inset-inline-end: 0;
  top: 0;
  height: 100%;
  width: 4px;
  cursor: col-resize;
  user-select: none;
  background-color: transparent;
  padding: 0;
  margin: 0;
  transition: background-color var(--duration-tactile) var(--ease-enter);

  &:hover,
  &:active {
    background-color: var(--color-accent);
  }
}

/* Pinned columns */
.ren-th-pinned {
  position: sticky;
  z-index: 11;
}

/* ===== Body & Row Styles ===== */

.ren-table-body {
  background-color: var(--color-surface);
}

.ren-tr {
  border-bottom: var(--stroke-1) solid var(--color-border);
  transition: background-color var(--duration-tactile) var(--ease-enter);
  height: 44px;

  &:nth-child(even) {
    background-color: var(--color-fill);
  }

  &:hover {
    background-color: var(--color-fill-hover);
  }

  &[aria-selected="true"] {
    background-color: var(--color-accent-subtle);

    .ren-td {
      background-color: var(--color-accent-subtle);
    }
  }

  &:focus-within {
    outline: 2px solid var(--color-accent);
    outline-offset: -1px;
  }
}

.ren-td {
  padding: var(--space-3) var(--space-4);
  border-inline-end: 1px solid var(--color-border);
  color: var(--color-text);
  vertical-align: middle;
  height: 44px;

  &:last-child {
    border-inline-end: none;
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
  }
}

/* Pinned column cells */
.ren-td-pinned {
  position: sticky;
  z-index: 9;
  background-color: inherit;
}

/* ===== Selection Styles ===== */

.ren-table-select {
  width: var(--space-10);
  padding: var(--space-3);
  text-align: center;

  input[type="checkbox"] {
    width: 18px;
    height: 18px;
    cursor: pointer;
    accent-color: var(--color-accent);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    transition: background-color var(--duration-tactile) var(--ease-enter),
                border-color var(--duration-tactile) var(--ease-enter);

    &:hover:not(:disabled) {
      border-color: var(--color-accent);
    }

    &:checked {
      background-color: var(--color-accent);
      border-color: var(--color-accent);
      color: var(--color-on-accent);
    }

    &:focus-visible {
      outline: 2px solid var(--color-accent);
      outline-offset: 2px;
    }

    &:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
  }
}

/* ===== Pagination ===== */

.ren-table-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-border);
  background-color: var(--color-surface);
  font-size: var(--caption-size);
  color: var(--color-text-muted);
  gap: var(--space-4);

  &:empty {
    display: none;
  }
}

.ren-table-pagination-info {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--body-size);
  color: var(--color-text);
}

.ren-table-pagination-controls {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.ren-table-pagination-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-min);
  height: var(--touch-min);
  padding: var(--space-2);
  background-color: var(--color-fill);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  color: var(--color-text);
  font-size: var(--body-size);
  cursor: pointer;
  transition: background-color var(--duration-tactile) var(--ease-enter),
              border-color var(--duration-tactile) var(--ease-enter);
  user-select: none;
  min-width: 44px;
  min-height: 44px;

  &:hover:not(:disabled) {
    background-color: var(--color-fill-hover);
    border-color: var(--color-accent);
  }

  &:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    background-color: var(--color-fill);
  }
}

/* ===== Toolbar Styles ===== */

.ren-table-toolbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  flex-wrap: wrap;

  &:empty {
    display: none;
  }
}

.ren-table-toolbar-search {
  flex: 1;
  min-width: 200px;
  max-width: 300px;
}

/* ===== Empty State ===== */

.ren-table-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: var(--space-10);
  gap: var(--space-4);
  text-align: center;
  color: var(--color-text-muted);
  background-color: var(--color-surface);
  min-height: 200px;

  .ren-table-empty-icon {
    font-size: 40px;
    opacity: 0.4;
  }

  .ren-table-empty-title {
    font-size: var(--label-size);
    font-weight: var(--label-weight);
    color: var(--color-text);
  }

  .ren-table-empty-description {
    font-size: var(--caption-size);
    color: var(--color-text-muted);
    max-width: 300px;
  }
}

/* ===== Loading State ===== */

ren-table[data-loading] {
  .ren-table-body {
    position: relative;
  }

  .ren-tr {
    opacity: 0.5;
    pointer-events: none;
  }
}

.ren-table-loading-overlay {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    rgb(255, 255, 255, 0.4),
    transparent
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  z-index: 20;
}

@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }

  100% {
    background-position: 200% 0;
  }
}

/* ===== Density Variants ===== */

.ren-table-compact {
  .ren-th,
  .ren-td {
    padding: var(--space-2) var(--space-3);
    height: 32px;
  }

  .ren-tr {
    height: 32px;
  }
}

.ren-table-comfortable {
  .ren-th,
  .ren-td {
    padding: var(--space-4) var(--space-5);
    height: 56px;
  }

  .ren-tr {
    height: 56px;
  }
}

/* ===== Responsive Design (Container Query) ===== */

@container ren-table (max-width: 600px) {
  .ren-table {
    min-width: 600px;
  }

  .ren-table-pagination {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-3);
  }

  .ren-table-pagination-info {
    width: 100%;
  }

  .ren-table-pagination-controls {
    width: 100%;
    justify-content: space-between;
  }

  .ren-table-toolbar {
    flex-direction: column;
  }

  .ren-table-toolbar-search {
    min-width: 100%;
    max-width: 100%;
  }
}

/* ===== Accessibility ===== */

/* High contrast mode support */
@media (prefers-contrast: more) {
  .ren-th {
    border: 1px solid var(--color-text);
  }

  .ren-td {
    border: var(--stroke-1) solid var(--color-text);
  }

  .ren-th-sortable:hover {
    background-color: var(--color-text);
    color: var(--color-surface);
  }
}

/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
  .ren-tr,
  .ren-th-sortable,
  .ren-table-pagination-button,
  .ren-table-select input[type="checkbox"] {
    transition: none;
  }

  .ren-table-loading-overlay {
    animation: none;
    background: rgb(255, 255, 255, 0.2);
  }
}

/* Dark mode support (if light/dark theming is used) */
@media (prefers-color-scheme: dark) {
  .ren-table {
    /* Uses CSS custom properties from design tokens */
    /* No additional overrides needed if tokens are properly defined */
  }
}
/* Appearance API bridge */
.ren-table { font-size: var(--ren-table-header-size, var(--body-size)); }
.ren-table thead { background: var(--ren-table-header-bg, var(--color-surface-sunken)); color: var(--ren-table-header-color, var(--color-text-muted)); font-weight: var(--ren-table-header-weight, var(--weight-semibold)); }
.ren-table tr { min-block-size: var(--ren-table-row-height, var(--size-lg)); block-size: var(--ren-table-row-height, var(--size-lg)); }
.ren-table th, .ren-table td { padding: var(--ren-table-cell-padding, var(--space-3)); }
.ren-table { border-color: var(--ren-table-border-color, var(--color-border-muted)); }
.ren-table tbody tr:nth-child(even) { background: var(--ren-table-stripe-bg, var(--color-surface-sunken)); }
.ren-table tbody tr:hover { background: var(--ren-table-hover-bg, var(--color-fill)); }
.ren-table tbody tr[aria-selected="true"], .ren-table tbody tr[data-selected] { background: var(--ren-table-selected-bg, var(--color-accent-subtle)); }


/**
 * RenDS — Data Table Organism
 * ============================
 *
 * A comprehensive, accessible data table web component that extends native
 * HTML tables with powerful features while maintaining semantic structure.
 *
 * Features:
 * - Click-to-sort columns with asc/desc/none cycling
 * - Multi-select with select-all checkbox and shift+click range selection
 * - Client-side pagination with configurable page size
 * - Keyboard navigation (arrow keys, Enter to sort, Space to select)
 * - Client-side filtering/search
 * - Column resizing via drag handle
 * - Pinned/sticky column support
 * - Multiple density variants (compact, comfortable, default)
 * - Loading state with shimmer effect
 * - Empty state messaging
 * - Full ARIA support for accessibility
 * - Light DOM (no Shadow DOM) for easy styling and integration
 *
 * @example
 * <ren-table data-page-size="10">
 *   <div class="ren-table-wrapper">
 *     <table class="ren-table">
 *       <thead class="ren-table-header">
 *         <tr>
 *           <th class="ren-th ren-table-select"><input type="checkbox"></th>
 *           <th class="ren-th ren-th-sortable" data-column="name">Name</th>
 *         </tr>
 *       </thead>
 *       <tbody class="ren-table-body">
 *         <tr class="ren-tr" data-row-id="1">
 *           <td class="ren-td ren-table-select"><input type="checkbox"></td>
 *           <td class="ren-td">John Doe</td>
 *         </tr>
 *       </tbody>
 *     </table>
 *   </div>
 *   <div class="ren-table-pagination"></div>
 * </ren-table>
 *
 * @fires ren-sort - Dispatched when column sort changes: { column, direction }
 * @fires ren-select - Dispatched when row selection changes: { selected }
 * @fires ren-page - Dispatched when page changes: { page, pageSize }
 * @fires ren-filter - Dispatched when filter changes: { columnIndex, value }
 */

export class RenTable extends HTMLElement {
  #table = null;
  #thead = null;
  #tbody = null;
  #headerCheckbox = null;
  #rowCheckboxes = [];
  #headerCells = [];
  #rows = [];
  #visibleRows = [];
  #sortColumn = null;
  #sortDirection = null;
  #selectedRows = new Set();
  #currentPage = 1;
  #pageSize = 10;
  #filterValues = new Map();
  #columnWidths = new Map();
  #resizingColumn = null;
  #resizeStartX = 0;
  #resizeStartWidth = 0;
  #lastSelectedRowIndex = -1;
  #listenerController = null;
  #rowCheckboxController = null;

  constructor() {
    super();
  }

  connectedCallback() {
    this._initializeComponent();
    this._attachEventListeners();
    this._setupPagination();
  }

  disconnectedCallback() {
    this._removeEventListeners();
  }

  /**
   * Initialize table references and attributes
   * @private
   */
  _initializeComponent() {
    this.#table = this.querySelector('.ren-table');
    this.#thead = this.querySelector('.ren-table-header');
    this.#tbody = this.querySelector('.ren-table-body');

    if (!this.#table || !this.#thead || !this.#tbody) {
      console.warn('RenTable: Missing required table structure');
      return;
    }

    // Get page size from attribute
    const pageSize = this.getAttribute('data-page-size');
    if (pageSize) {
      this.#pageSize = parseInt(pageSize, 10);
    }

    // Initialize header cells
    this.#headerCells = Array.from(this.#thead.querySelectorAll('.ren-th'));
    this.#headerCheckbox = this.#thead.querySelector('input[type="checkbox"]');

    // Initialize rows
    this._refreshRows();

    // Add ARIA roles
    this.#table.setAttribute('role', 'table');
    if (!this.#thead.hasAttribute('role')) {
      this.#thead.setAttribute('role', 'rowgroup');
    }
    if (!this.#tbody.hasAttribute('role')) {
      this.#tbody.setAttribute('role', 'rowgroup');
    }
  }

  /**
   * Attach all event listeners
   * @private
   */
  _attachEventListeners() {
    this.#listenerController?.abort();
    this.#listenerController = new AbortController();
    const { signal } = this.#listenerController;

    // Sorting
    this.#headerCells.forEach((cell, index) => {
      if (cell.classList.contains('ren-th-sortable')) {
        cell.addEventListener('click', () => this._handleSort(cell, index), { signal });
        cell.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            this._handleSort(cell, index);
          }
        }, { signal });
        cell.setAttribute('tabindex', '0');
        cell.setAttribute('role', 'columnheader');
        cell.setAttribute('aria-sort', 'none');
      }
    });

    // Selection - header checkbox
    if (this.#headerCheckbox) {
      this.#headerCheckbox.addEventListener('change', () => this._handleSelectAll(), { signal });
    }

    // Selection - row checkboxes
    this._attachRowCheckboxListeners();

    // Keyboard navigation
    this.#table.addEventListener('keydown', (e) => this._handleKeyboard(e), { signal });

    // Column resizing
    this._setupColumnResize(signal);

    // Search input
    const searchInput = this.querySelector('[data-table-search]');
    if (searchInput) {
      searchInput.addEventListener('input', (e) => {
        this._filterRows(e.target.value);
      }, { signal });
    }

    // Pagination button clicks
    const paginationCtrls = this.querySelector('.ren-table-pagination-controls');
    if (paginationCtrls) {
      const prevBtn = paginationCtrls.querySelector('[data-page-prev]');
      const nextBtn = paginationCtrls.querySelector('[data-page-next]');

      if (prevBtn) prevBtn.addEventListener('click', () => this._previousPage(), { signal });
      if (nextBtn) nextBtn.addEventListener('click', () => this._nextPage(), { signal });
    }

    // Pagination page size selector
    const pageSizeSelect = this.querySelector('[data-page-size-select]');
    if (pageSizeSelect) {
      pageSizeSelect.addEventListener('change', (e) => {
        this.#pageSize = parseInt(e.target.value, 10);
        this.#currentPage = 1;
        this._renderTable();
      }, { signal });
    }
  }

  /**
   * Remove event listeners
   * @private
   */
  _removeEventListeners() {
    this.#listenerController?.abort();
    this.#listenerController = null;
    this.#rowCheckboxController?.abort();
    this.#rowCheckboxController = null;
    this.#resizingColumn = null;
  }

  /**
   * Refresh the list of all rows
   * @private
   */
  _refreshRows() {
    this.#rows = Array.from(this.#tbody.querySelectorAll('.ren-tr'));
    this.#visibleRows = [...this.#rows];
    this._attachRowCheckboxListeners();
  }

  /**
   * Attach listeners to row checkboxes
   * @private
   */
  _attachRowCheckboxListeners() {
    this.#rowCheckboxController?.abort();
    this.#rowCheckboxController = new AbortController();
    const { signal } = this.#rowCheckboxController;

    const checkboxes = Array.from(this.#tbody.querySelectorAll('.ren-table-select input[type="checkbox"]'));
    checkboxes.forEach((checkbox, index) => {
      checkbox.addEventListener('change', (e) => {
        this._handleRowSelect(e, index);
      }, { signal });
    });
    this.#rowCheckboxes = checkboxes;
  }

  /**
   * Handle column header click for sorting
   * @private
   */
  _handleSort(cell, index) {
    const columnName = cell.getAttribute('data-column');
    if (!columnName) return;

    // Cycle through sort states: none -> asc -> desc -> none
    let newDirection = null;
    if (this.#sortColumn !== columnName) {
      newDirection = 'asc';
    } else if (this.#sortDirection === 'asc') {
      newDirection = 'desc';
    } else if (this.#sortDirection === 'desc') {
      newDirection = null;
    } else {
      newDirection = 'asc';
    }

    this.#sortColumn = newDirection ? columnName : null;
    this.#sortDirection = newDirection;

    // Update visual indicators
    this.#headerCells.forEach((hc) => {
      if (hc.classList.contains('ren-th-sortable')) {
        hc.removeAttribute('data-sort');
        hc.setAttribute('aria-sort', 'none');
      }
    });

    if (newDirection) {
      cell.setAttribute('data-sort', newDirection);
      cell.setAttribute('aria-sort', newDirection === 'asc' ? 'ascending' : 'descending');
    }

    // Dispatch event
    this._dispatchEvent('ren-sort', {
      column: columnName,
      direction: newDirection,
    });

    // Re-render
    this.#currentPage = 1;
    this._renderTable();
  }

  /**
   * Handle select all checkbox
   * @private
   */
  _handleSelectAll() {
    const isChecked = this.#headerCheckbox.checked;

    this.#visibleRows.forEach((row) => {
      const rowId = row.getAttribute('data-row-id');
      const checkbox = row.querySelector('input[type="checkbox"]');

      if (isChecked) {
        this.#selectedRows.add(rowId);
        row.setAttribute('aria-selected', 'true');
        if (checkbox) checkbox.checked = true;
      } else {
        this.#selectedRows.delete(rowId);
        row.removeAttribute('aria-selected');
        if (checkbox) checkbox.checked = false;
      }
    });

    this._dispatchEvent('ren-select', {
      selected: Array.from(this.#selectedRows),
    });
  }

  /**
   * Handle individual row selection
   * @private
   */
  _handleRowSelect(event, rowIndex) {
    const row = this.#visibleRows[rowIndex];
    if (!row) return;

    const rowId = row.getAttribute('data-row-id');
    const isChecked = event.target.checked;
    const isShiftClick = event.shiftKey;

    if (isShiftClick && this.#lastSelectedRowIndex !== -1) {
      // Range selection
      const start = Math.min(this.#lastSelectedRowIndex, rowIndex);
      const end = Math.max(this.#lastSelectedRowIndex, rowIndex);

      for (let i = start; i <= end; i++) {
        const rangeRow = this.#visibleRows[i];
        const rangeRowId = rangeRow.getAttribute('data-row-id');
        const rangeCheckbox = rangeRow.querySelector('input[type="checkbox"]');

        if (isChecked) {
          this.#selectedRows.add(rangeRowId);
          rangeRow.setAttribute('aria-selected', 'true');
          if (rangeCheckbox) rangeCheckbox.checked = true;
        } else {
          this.#selectedRows.delete(rangeRowId);
          rangeRow.removeAttribute('aria-selected');
          if (rangeCheckbox) rangeCheckbox.checked = false;
        }
      }
    } else {
      // Single selection
      if (isChecked) {
        this.#selectedRows.add(rowId);
        row.setAttribute('aria-selected', 'true');
      } else {
        this.#selectedRows.delete(rowId);
        row.removeAttribute('aria-selected');
      }
    }

    this.#lastSelectedRowIndex = rowIndex;

    // Update select-all checkbox state
    this._updateSelectAllCheckbox();

    // Dispatch event
    this._dispatchEvent('ren-select', {
      selected: Array.from(this.#selectedRows),
    });
  }

  /**
   * Update select-all checkbox based on row selections
   * @private
   */
  _updateSelectAllCheckbox() {
    if (!this.#headerCheckbox) return;

    const visibleRowIds = this.#visibleRows
      .map((row) => row.getAttribute('data-row-id'))
      .filter(Boolean);
    const allVisibleSelected = visibleRowIds.every((id) =>
      this.#selectedRows.has(id)
    );
    const someSelected = visibleRowIds.some((id) =>
      this.#selectedRows.has(id)
    );

    this.#headerCheckbox.checked = allVisibleSelected;
    this.#headerCheckbox.indeterminate = someSelected && !allVisibleSelected;
  }

  /**
   * Handle keyboard navigation
   * @private
   */
  _handleKeyboard(event) {
    const { key, target } = event;

    // Space to toggle row selection
    if (key === ' ' && target.classList.contains('ren-tr')) {
      event.preventDefault();
      const checkbox = target.querySelector('input[type="checkbox"]');
      if (checkbox) {
        checkbox.checked = !checkbox.checked;
        checkbox.dispatchEvent(new Event('change', { bubbles: true }));
      }
    }

    // Arrow keys for navigation
    if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
      event.preventDefault();
      this._navigateCells(target, key);
    }

    // Enter to sort (on header)
    if (key === 'Enter' && target.classList.contains('ren-th-sortable')) {
      event.preventDefault();
      target.click();
    }
  }

  /**
   * Navigate between cells with arrow keys
   * @private
   */
  _navigateCells(currentCell, direction) {
    let nextCell = currentCell;

    if (direction === 'ArrowUp') {
      const row = currentCell.closest('tr');
      const prevRow = row?.previousElementSibling;
      if (prevRow) {
        const cellIndex = Array.from(row.cells).indexOf(currentCell);
        nextCell = prevRow.cells[cellIndex];
      }
    } else if (direction === 'ArrowDown') {
      const row = currentCell.closest('tr');
      const nextRow = row?.nextElementSibling;
      if (nextRow) {
        const cellIndex = Array.from(row.cells).indexOf(currentCell);
        nextCell = nextRow.cells[cellIndex];
      }
    } else if (direction === 'ArrowLeft') {
      nextCell = currentCell.previousElementSibling || currentCell;
    } else if (direction === 'ArrowRight') {
      nextCell = currentCell.nextElementSibling || currentCell;
    }

    if (nextCell && nextCell !== currentCell) {
      nextCell.focus();
    }
  }

  /**
   * Setup column resizing
   * @private
   */
  _setupColumnResize(signal) {
    this.#headerCells.forEach((cell) => {
      let resizeHandle = cell.querySelector('.ren-th-resize');

      if (!resizeHandle) {
        resizeHandle = document.createElement('div');
        resizeHandle.className = 'ren-th-resize';
        cell.appendChild(resizeHandle);
      }

      resizeHandle.addEventListener('mousedown', (e) => {
        this._startResize(e, cell);
      }, { signal });
    });

    document.addEventListener('mousemove', (e) => this._handleResize(e), { signal });
    document.addEventListener('mouseup', () => this._endResize(), { signal });
  }

  /**
   * Start column resize
   * @private
   */
  _startResize(event, cell) {
    this.#resizingColumn = cell;
    this.#resizeStartX = event.clientX;
    this.#resizeStartWidth = cell.offsetWidth;
    cell.style.cursor = 'col-resize';
  }

  /**
   * Handle column resize drag
   * @private
   */
  _handleResize(event) {
    if (!this.#resizingColumn) return;

    const delta = event.clientX - this.#resizeStartX;
    const newWidth = Math.max(80, this.#resizeStartWidth + delta);
    this.#resizingColumn.style.width = `${newWidth}px`;
  }

  /**
   * End column resize
   * @private
   */
  _endResize() {
    if (this.#resizingColumn) {
      this.#resizingColumn.style.cursor = '';
      this.#resizingColumn = null;
    }
  }

  /**
   * Filter rows by search value
   * @private
   */
  _filterRows(searchValue) {
    const lowerSearch = searchValue.toLowerCase();

    this.#visibleRows = this.#rows.filter((row) => {
      const cells = Array.from(row.querySelectorAll('.ren-td'));
      return cells.some((cell) =>
        cell.textContent.toLowerCase().includes(lowerSearch)
      );
    });

    this.#currentPage = 1;
    this._renderTable();

    this._dispatchEvent('ren-filter', {
      value: searchValue,
    });
  }

  /**
   * Sort visible rows
   * @private
   */
  _sortVisibleRows() {
    if (!this.#sortColumn || !this.#sortDirection) return;

    const columnIndex = this.#headerCells.findIndex(
      (cell) => cell.getAttribute('data-column') === this.#sortColumn
    );

    if (columnIndex === -1) return;

    this.#visibleRows.sort((a, b) => {
      const aValue = a.cells[columnIndex]?.textContent || '';
      const bValue = b.cells[columnIndex]?.textContent || '';

      // Try numeric comparison first
      const aNum = parseFloat(aValue);
      const bNum = parseFloat(bValue);

      let comparison = 0;
      if (!isNaN(aNum) && !isNaN(bNum)) {
        comparison = aNum - bNum;
      } else {
        comparison = aValue.localeCompare(bValue);
      }

      return this.#sortDirection === 'asc' ? comparison : -comparison;
    });
  }

  /**
   * Setup pagination controls
   * @private
   */
  _setupPagination() {
    const paginationDiv = this.querySelector('.ren-table-pagination');
    if (!paginationDiv) return;

    // Only render pagination if there are rows
    if (this.#visibleRows.length === 0) {
      paginationDiv.innerHTML = '';
      return;
    }

    const totalPages = Math.ceil(this.#visibleRows.length / this.#pageSize);
    if (totalPages <= 1) {
      paginationDiv.innerHTML = '';
      return;
    }

    const startItem = (this.#currentPage - 1) * this.#pageSize + 1;
    const endItem = Math.min(this.#currentPage * this.#pageSize, this.#visibleRows.length);

    paginationDiv.innerHTML = `
      <div class="ren-table-pagination-info">
        <span>Showing <strong>${startItem}</strong> to <strong>${endItem}</strong> of <strong>${this.#visibleRows.length}</strong></span>
      </div>
      <div class="ren-table-pagination-controls">
        <button class="ren-table-pagination-button" data-page-prev aria-label="Previous page" ${this.#currentPage === 1 ? 'disabled' : ''}>
          ← Prev
        </button>
        <span style="padding: 0 var(--space-2); font-size: var(--caption-size); color: var(--color-text-muted);">
          Page ${this.#currentPage} of ${totalPages}
        </span>
        <button class="ren-table-pagination-button" data-page-next aria-label="Next page" ${this.#currentPage === totalPages ? 'disabled' : ''}>
          Next →
        </button>
      </div>
    `;

    // Reattach listeners
    const prevBtn = paginationDiv.querySelector('[data-page-prev]');
    const nextBtn = paginationDiv.querySelector('[data-page-next]');
    const listenerOptions = this.#listenerController
      ? { signal: this.#listenerController.signal }
      : undefined;

    if (prevBtn) prevBtn.addEventListener('click', () => this._previousPage(), listenerOptions);
    if (nextBtn) nextBtn.addEventListener('click', () => this._nextPage(), listenerOptions);
  }

  /**
   * Go to previous page
   * @private
   */
  _previousPage() {
    if (this.#currentPage > 1) {
      this.#currentPage--;
      this._renderTable();
    }
  }

  /**
   * Go to next page
   * @private
   */
  _nextPage() {
    const totalPages = Math.ceil(this.#visibleRows.length / this.#pageSize);
    if (this.#currentPage < totalPages) {
      this.#currentPage++;
      this._renderTable();
    }
  }

  /**
   * Render the table with current page, sort, and filter
   * @private
   */
  _renderTable() {
    // Apply sorting
    this._sortVisibleRows();

    // Show/hide rows based on current page
    const startIndex = (this.#currentPage - 1) * this.#pageSize;
    const endIndex = startIndex + this.#pageSize;

    const visibleIndexByRow = new Map(
      this.#visibleRows.map((row, visibleIndex) => [row, visibleIndex])
    );

    this.#rows.forEach((row) => {
      const visibleIndex = visibleIndexByRow.get(row);
      const isVisible = visibleIndex !== undefined &&
        visibleIndex >= startIndex &&
        visibleIndex < endIndex;

      if (isVisible) {
        row.style.display = '';
        row.setAttribute('role', 'row');
      } else {
        row.style.display = 'none';
      }
    });

    // Update pagination
    this._setupPagination();

    // Show/hide empty state
    this._updateEmptyState();
  }

  /**
   * Update empty state display
   * @private
   */
  _updateEmptyState() {
    let emptyState = this.querySelector('.ren-table-empty');

    if (this.#visibleRows.length === 0) {
      if (!emptyState) {
        emptyState = document.createElement('div');
        emptyState.className = 'ren-table-empty';
        emptyState.innerHTML = `
          <div class="ren-table-empty-icon">📭</div>
          <div class="ren-table-empty-title">No results</div>
          <div class="ren-table-empty-description">No data matches your search or filters</div>
        `;
        const wrapper = this.querySelector('.ren-table-wrapper');
        if (wrapper) {
          wrapper.parentNode.insertBefore(emptyState, wrapper);
        }
      }
      if (this.#tbody) {
        this.#tbody.style.display = 'none';
      }
    } else {
      if (emptyState) {
        emptyState.remove();
      }
      if (this.#tbody) {
        this.#tbody.style.display = '';
      }
    }
  }

  /**
   * Dispatch custom event
   * @private
   */
  _dispatchEvent(eventName, detail) {
    this.dispatchEvent(
      new CustomEvent(eventName, {
        detail,
        bubbles: true,
        composed: true,
      })
    );
  }

  /**
   * Public API: Filter rows by column
   * @param {number} columnIndex - Index of column to filter
   * @param {string} value - Filter value
   */
  filter(columnIndex, value) {
    this.#filterValues.set(columnIndex, value);

    this.#visibleRows = this.#rows.filter((row) => {
      for (const [colIdx, filterVal] of this.#filterValues) {
        const cell = row.cells[colIdx];
        if (!cell || !cell.textContent.toLowerCase().includes(filterVal.toLowerCase())) {
          return false;
        }
      }
      return true;
    });

    this.#currentPage = 1;
    this._renderTable();
  }

  /**
   * Public API: Get selected row IDs
   * @returns {string[]} Array of selected row IDs
   */
  getSelectedRows() {
    return Array.from(this.#selectedRows);
  }

  /**
   * Public API: Set loading state
   * @param {boolean} isLoading - Whether table is loading
   */
  setLoading(isLoading) {
    if (isLoading) {
      this.setAttribute('data-loading', '');
      const wrapper = this.querySelector('.ren-table-wrapper');
      if (wrapper && !wrapper.querySelector('.ren-table-loading-overlay')) {
        const overlay = document.createElement('div');
        overlay.className = 'ren-table-loading-overlay';
        wrapper.appendChild(overlay);
      }
    } else {
      this.removeAttribute('data-loading');
      const overlay = this.querySelector('.ren-table-loading-overlay');
      if (overlay) overlay.remove();
    }
  }

  /**
   * Public API: Set density variant
   * @param {'default' | 'compact' | 'comfortable'} density - Density variant
   */
  setDensity(density) {
    this.#table.classList.remove('ren-table-compact', 'ren-table-comfortable');
    if (density === 'compact' || density === 'comfortable') {
      this.#table.classList.add(`ren-table-${density}`);
    }
  }

  /**
   * Public API: Clear all selections
   */
  clearSelection() {
    this.#selectedRows.clear();
    this.#rowCheckboxes.forEach((checkbox) => {
      checkbox.checked = false;
    });
    if (this.#headerCheckbox) {
      this.#headerCheckbox.checked = false;
      this.#headerCheckbox.indeterminate = false;
    }
    this.#rows.forEach((row) => {
      row.removeAttribute('aria-selected');
    });
    this._dispatchEvent('ren-select', { selected: [] });
  }

  /**
   * Public API: Refresh table data
   */
  refresh() {
    this._refreshRows();
    this.#currentPage = 1;
    this._renderTable();
  }
}

customElements.define('ren-table', RenTable);

data:
{"kind":"pattern","selectors":[".ren-table",".ren-table-body",".ren-table-comfortable",".ren-table-compact",".ren-table-empty",".ren-table-empty-description",".ren-table-empty-icon",".ren-table-empty-title",".ren-table-header",".ren-table-loading-overlay",".ren-table-pagination",".ren-table-pagination-button",".ren-table-pagination-controls",".ren-table-pagination-info",".ren-table-select",".ren-table-toolbar",".ren-table-toolbar-search",".ren-table-wrapper",".ren-td",".ren-td-pinned",".ren-th",".ren-th-pinned",".ren-th-resize",".ren-th-sortable",".ren-tr"],"tokens":["--body-size","--caption-size","--color-accent","--color-accent-subtle","--color-border","--color-border-muted","--color-fill","--color-fill-hover","--color-on-accent","--color-surface","--color-surface-raised","--color-surface-sunken","--color-text","--color-text-muted","--duration-state","--duration-tactile","--ease-enter","--label-size","--label-weight","--radius-md","--radius-sm","--ren-table-border-color","--ren-table-cell-padding","--ren-table-header-bg","--ren-table-header-color","--ren-table-header-size","--ren-table-header-weight","--ren-table-hover-bg","--ren-table-row-height","--ren-table-selected-bg","--ren-table-stripe-bg","--size-lg","--space-10","--space-2","--space-3","--space-4","--space-5","--stroke-1","--touch-min","--weight-semibold"],"hasScript":true,"hasDocsPage":true}


id: component:primitive:ren-avatar
type: component
name: ren-avatar
path: components/primitives/ren-avatar
body:
# ren-avatar Component Contract

User identity primitive for images, initials, and presence indicators.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-avatar` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-avatar` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Avatar primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need to represent a user or entity identity via image, initials, or fallback glyph."
    - "You need a presence / status dot attached to the identity (online, away, busy, offline)."
    - "You need a stack of overlapping identities (collaborators, attendees) via .ren-avatar-group."
    - "You need consistent circular (or .ren-avatar-square) framing across sizes xs / sm / md / lg / xl / 2xl."
    - "The image must crop to a fixed square with object-fit: cover regardless of source aspect ratio."
  avoidWhen:
    - "You only need a status label or count without identity — use ren-badge / ren-badge-dot."
    - "You need a clickable identity menu / profile dropdown — wrap the avatar in ren-menu / ren-popover."
    - "You need a decorative logo or product icon — use a plain <img> or ren-icon."

canonicalImports:
  css:
    - "rends/components/primitives/ren-avatar/ren-avatar.css"
  notes:
    - "CSS-only primitive — no colocated JS exists. Do not import a ren-avatar.js."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Root is <div class=\"ren-avatar\"> containing either an <img> or text initials (max 2 chars); never both as siblings."
  - "<img> children must have alt=\"\" when the avatar is decorative, or alt=\"Name\" when it conveys identity."
  - "Status dot must be wrapped together with the avatar in <div class=\"ren-avatar-wrapper\"> with .ren-avatar-status as a sibling, not a child of .ren-avatar."
  - "Avatar groups use <div class=\"ren-avatar-group\"> with .ren-avatar children only (overlap is owned by the group, not by margins on items)."
  - "Size modifiers (.ren-avatar-xs / -sm / -lg / -xl / -2xl) replace the default md size; do not stack two size classes."

forbiddenPatterns:
  - "Background <img> via CSS background-image — always use a real <img> child for alt-text and lazy-loading."
  - "Hardcoded width / height in inline styles — use a size modifier or override --ren-avatar-size."
  - "Communicating presence with .ren-avatar-status alone without a visually-hidden text label (color is not a name)."
  - "Nesting .ren-avatar inside .ren-avatar (e.g., avatar-on-avatar) — group overlap is the documented pattern."
  - "Custom border-radius via inline style — use .ren-avatar-square or override --ren-avatar-radius."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-avatar-bg, --ren-avatar-border, --ren-avatar-color, --ren-avatar-font-size, --ren-avatar-radius, --ren-avatar-size."
    - "Semantic tokens: --color-fill, --color-text-secondary, --color-surface, --color-success, --color-warning, --color-danger."
    - "Size aliases: --avatar-xs / -sm / -md / -lg / -xl / -2xl and --radius-full / --radius-md."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors for background, border, or status dot."
    - "Bypassing --color-surface for the status ring; the white/dark ring must adapt to theme."

accessibility:
  required:
    - "Provide a meaningful alt attribute on the <img> when the avatar identifies a specific person; use alt=\"\" only when the name is rendered elsewhere."
    - "Initials avatars must have an accessible name via aria-label on the .ren-avatar or visually-hidden text matching the user's full name."
    - "Status indicator must be accompanied by a textual label for screen readers (e.g., visually-hidden \"Online\") — color alone is not sufficient."
    - "Avatar groups should expose a roll-up count or list label (e.g., aria-label=\"3 collaborators\") rather than relying on visual stacking."
    - "Do not attach click handlers to a non-interactive .ren-avatar; wrap in a real <button> or <a> if the identity is actionable."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-avatar/ren-avatar.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<div class="ren-avatar"><img src="/avatars/jane.jpg" alt="Jane Doe"></div>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-avatar`
- `.ren-avatar-2xl`
- `.ren-avatar-group`
- `.ren-avatar-lg`
- `.ren-avatar-sm`
- `.ren-avatar-square`
- `.ren-avatar-status`
- `.ren-avatar-status-away`
- `.ren-avatar-status-busy`
- `.ren-avatar-status-offline`
- `.ren-avatar-wrapper`
- `.ren-avatar-xl`
- `.ren-avatar-xs`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-avatar-bg`
- `--ren-avatar-border`
- `--ren-avatar-color`
- `--ren-avatar-font-size`
- `--ren-avatar-radius`
- `--ren-avatar-size`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-avatar/ren-avatar.css`
- `docs/components/ren-avatar.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Avatar
   ============================================
   Circular (or square) user image with fallback
   text, status indicator and group (overlap).

   Usage:
     <div class="ren-avatar"><img src="…" alt=""></div>
     <div class="ren-avatar ren-avatar-lg">JD</div>

     <div class="ren-avatar-wrapper">
       <div class="ren-avatar"><img …></div>
       <span class="ren-avatar-status"></span>
     </div>

     <div class="ren-avatar-group">
       <div class="ren-avatar">…</div>
       <div class="ren-avatar">…</div>
     </div>
   ============================================ */

.ren-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--ren-avatar-size);     /* 40px default */
  height: var(--ren-avatar-size);
  border-radius: var(--ren-avatar-radius);
  overflow: hidden;
  background-color: var(--ren-avatar-bg);
  color: var(--ren-avatar-color);
  font-weight: var(--weight-semibold);
  font-size: var(--ren-avatar-font-size);
  flex-shrink: 0;
  user-select: none;
}

.ren-avatar > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ─── Sizes ─── */
.ren-avatar-xs  { width: var(--avatar-xs);  height: var(--avatar-xs);  font-size: var(--text-xs); }
.ren-avatar-sm  { width: var(--avatar-sm);  height: var(--avatar-sm);  font-size: var(--text-xs); }
.ren-avatar-lg  { width: var(--avatar-lg);  height: var(--avatar-lg);  font-size: var(--text-base); }
.ren-avatar-xl  { width: var(--avatar-xl);  height: var(--avatar-xl);  font-size: var(--text-lg); }
.ren-avatar-2xl { width: var(--avatar-2xl); height: var(--avatar-2xl); font-size: var(--text-xl); }

/* ─── Square variant ─── */
.ren-avatar-square {
  border-radius: var(--radius-md);
}

/* ─── Status indicator ─── */
.ren-avatar-wrapper {
  position: relative;
  display: inline-block;
}

.ren-avatar-status {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 0.75rem;
  height: 0.75rem;
  border-radius: var(--radius-full);
  border: 2px solid var(--color-surface);
  background-color: var(--color-success);
}

.ren-avatar-status-offline { background-color: var(--gray-400); }
.ren-avatar-status-busy    { background-color: var(--color-danger); }
.ren-avatar-status-away    { background-color: var(--color-warning); }

/* ─── Group (overlapping) ─── */
.ren-avatar-group {
  display: flex;
  flex-direction: row-reverse;
}

.ren-avatar-group > .ren-avatar {
  border: var(--ren-avatar-border);
  margin-inline-start: -0.5rem;
}

.ren-avatar-group > .ren-avatar:last-child {
  margin-inline-start: 0;
}

data:
{"kind":"primitive","selectors":[".ren-avatar",".ren-avatar-2xl",".ren-avatar-group",".ren-avatar-lg",".ren-avatar-sm",".ren-avatar-square",".ren-avatar-status",".ren-avatar-status-away",".ren-avatar-status-busy",".ren-avatar-status-offline",".ren-avatar-wrapper",".ren-avatar-xl",".ren-avatar-xs"],"tokens":["--avatar-2xl","--avatar-lg","--avatar-sm","--avatar-xl","--avatar-xs","--color-danger","--color-success","--color-surface","--color-warning","--gray-400","--radius-full","--radius-md","--ren-avatar-bg","--ren-avatar-border","--ren-avatar-color","--ren-avatar-font-size","--ren-avatar-radius","--ren-avatar-size","--text-base","--text-lg","--text-xl","--text-xs","--weight-semibold"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-badge
type: component
name: ren-badge
path: components/primitives/ren-badge
body:
# ren-badge Component Contract

Small status, category, count, or state label.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-badge` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-badge` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Badge primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a small inline label for status (success / warning / danger / info), category, count, or tag."
    - "The label is decorative or status-only; it does not navigate or trigger a side effect."
    - "You need a presence dot without text (.ren-badge-dot) — color-coded indicator."
    - "You need a pill-shaped (radius-full) label sized in em so it scales with surrounding text."
    - "The label must sit inline inside a heading, list item, button, or table cell."
  avoidWhen:
    - "The label is a removable chip with an X button — use ren-tag."
    - "The label is a persistent inline message with title/icon/dismiss — use ren-banner."
    - "The label triggers a click action — use ren-btn (or .ren-btn-sm) instead."
    - "You need an avatar / identity surface — use ren-avatar."

canonicalImports:
  css:
    - "rends/components/primitives/ren-badge/ren-badge.css"
  notes:
    - "CSS-only primitive — no colocated JS exists. Do not import a ren-badge.js."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Use <span class=\"ren-badge\"> (inline) — not <div> — so badges flow with surrounding text."
  - "Combine .ren-badge with a single variant modifier (.ren-badge-primary | -secondary | -success | -warning | -danger | -info | -outline); do not stack two variants."
  - "Status badges that convey meaning must include text (or visually-hidden text) — color alone is not sufficient."
  - "Dot indicators use <span class=\"ren-badge-dot ren-badge-dot-success\"> (empty content) and require a sibling text label."
  - "Numeric count badges should set aria-label (e.g., aria-label=\"3 unread\") when the number is ambiguous without context."

forbiddenPatterns:
  - "<button class=\"ren-badge\"> — badges are not interactive; use ren-btn for actions."
  - "Hardcoded background / color overrides via inline style — override --ren-badge-bg / --ren-badge-color instead."
  - "Using .ren-badge as a removable chip with a close button — that is ren-tag's contract."
  - "Color-only status (.ren-badge-danger with no text and no aria-label)."
  - "Setting font-size in px on .ren-badge — the em-based padding will desynchronize from the text scale."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-badge-bg, --ren-badge-color, --ren-badge-font-size, --ren-badge-font-weight, --ren-badge-height, --ren-badge-padding-x, --ren-badge-radius."
    - "Semantic tokens: --color-fill, --color-fill-hover, --color-accent, --color-on-accent, --color-success-subtle / -strong, --color-warning-subtle / -strong, --color-danger-subtle / -strong, --color-info-subtle / -strong, --color-border."
    - "Layout tokens: --space-1, --radius-full, --stroke-1, --text-xs, --weight-semibold."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors for badge background, text, or dot color."
    - "Reaching past the semantic -subtle / -strong pair into raw success / warning / danger primitives."

accessibility:
  required:
    - "Badges that communicate status must not rely on color alone — include text or visually-hidden labels."
    - "Numeric badges need aria-label when the meaning is ambiguous (e.g., \"3 unread messages\")."
    - "Decorative dot badges (.ren-badge-dot) should be paired with adjacent text content — not used alone."
    - "Do not place a badge inside a focusable element without ensuring the badge itself is not announced twice; rely on the parent's accessible name."
    - "Maintain WCAG AA contrast: variants pair -subtle background with -strong text by design; do not invert."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-badge/ren-badge.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<span class="ren-badge ren-badge-success">Published</span>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-badge`
- `.ren-badge-danger`
- `.ren-badge-dot`
- `.ren-badge-dot-danger`
- `.ren-badge-dot-success`
- `.ren-badge-dot-warning`
- `.ren-badge-info`
- `.ren-badge-outline`
- `.ren-badge-primary`
- `.ren-badge-secondary`
- `.ren-badge-success`
- `.ren-badge-warning`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-badge-bg`
- `--ren-badge-color`
- `--ren-badge-font-size`
- `--ren-badge-font-weight`
- `--ren-badge-height`
- `--ren-badge-padding-x`
- `--ren-badge-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-badge/ren-badge.css`
- `docs/components/ren-badge.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Badge
   ============================================
   Small inline label for status, counts, or tags.
   CSS-only, no JS needed.

   Sibling primitives that used to live here are
   now in their own folders:
     - ren-separator
     - ren-avatar
     - ren-spinner
     - ren-skeleton
     - ren-kbd

   Usage:
     <span class="ren-badge">3</span>
     <span class="ren-badge ren-badge-success">Active</span>
     <span class="ren-badge-dot ren-badge-dot-danger"></span>
   ============================================ */

.ren-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--ren-badge-height);
  padding: 0.15em var(--ren-badge-padding-x);
  font-size: var(--ren-badge-font-size);
  font-weight: var(--ren-badge-font-weight);
  line-height: 1.5;
  white-space: nowrap;
  border-radius: var(--ren-badge-radius);
  /* Default: neutral */
  background-color: var(--ren-badge-bg);
  color: var(--ren-badge-color);
}

/* ─── Variants ─── */
.ren-badge-primary {
  background-color: var(--color-accent);
  color: var(--color-on-accent);
}

.ren-badge-secondary {
  background-color: var(--color-fill-hover);
  color: var(--color-text);
}

.ren-badge-success {
  background-color: var(--color-success-subtle);
  color: var(--color-success-strong);
}

.ren-badge-warning {
  background-color: var(--color-warning-subtle);
  color: var(--color-warning-strong);
}

.ren-badge-danger {
  background-color: var(--color-danger-subtle);
  color: var(--color-danger-strong);
}

.ren-badge-info {
  background-color: var(--color-info-subtle);
  color: var(--color-info-strong);
}

/* Outline variant */
.ren-badge-outline {
  background-color: transparent;
  border: var(--stroke-1) solid var(--color-border);
  color: var(--color-text-secondary);
}

/* ─── Dot indicator (no text) ─── */
.ren-badge-dot {
  width: 0.5rem;
  height: 0.5rem;
  padding: 0;
  border-radius: var(--radius-full);
  background-color: var(--color-accent);
}

.ren-badge-dot-success { background-color: var(--color-success); }
.ren-badge-dot-warning { background-color: var(--color-warning); }
.ren-badge-dot-danger  { background-color: var(--color-danger); }

data:
{"kind":"primitive","selectors":[".ren-badge",".ren-badge-danger",".ren-badge-dot",".ren-badge-dot-danger",".ren-badge-dot-success",".ren-badge-dot-warning",".ren-badge-info",".ren-badge-outline",".ren-badge-primary",".ren-badge-secondary",".ren-badge-success",".ren-badge-warning"],"tokens":["--color-accent","--color-border","--color-danger","--color-danger-strong","--color-danger-subtle","--color-fill-hover","--color-info-strong","--color-info-subtle","--color-on-accent","--color-success","--color-success-strong","--color-success-subtle","--color-text","--color-text-secondary","--color-warning","--color-warning-strong","--color-warning-subtle","--radius-full","--ren-badge-bg","--ren-badge-color","--ren-badge-font-size","--ren-badge-font-weight","--ren-badge-height","--ren-badge-padding-x","--ren-badge-radius","--space-1","--stroke-1"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-banner
type: component
name: ren-banner
path: components/primitives/ren-banner
body:
# ren-banner Component Contract

Persistent inline message for status, warning, or alert content.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-banner` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-banner` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Banner primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a persistent inline message that sits in page flow (success / warning / danger / info / neutral)."
    - "The message has structured content: icon + title + message + optional actions or dismiss button."
    - "You need a page-top full-bleed announcement via .ren-banner-full (no border-radius, no side borders)."
    - "You need a compact single-line variant for dense surfaces (.ren-banner-compact)."
    - "The banner should remain on screen until user dismisses it or the underlying state changes."
  avoidWhen:
    - "The message is transient and overlay-style — use ren-toast."
    - "The message blocks the entire flow and requires confirmation — use ren-dialog (alert)."
    - "You only need a small inline status label — use ren-badge."
    - "The message is form validation tied to a single field — use ren-field's error slot."

canonicalImports:
  css:
    - "rends/components/primitives/ren-banner/ren-banner.css"
  notes:
    - "CSS-only primitive — no colocated JS exists. Dismiss button is plain HTML; if you wire dismissal, set data-dismissing to trigger the documented animation."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Root must carry role=\"status\" (info / success / neutral) or role=\"alert\" (danger / urgent warning) so AT announces correctly."
  - "Use the documented children: <span class=\"ren-banner-icon\">, <div class=\"ren-banner-content\"> with <strong class=\"ren-banner-title\"> + <p class=\"ren-banner-message\">."
  - "Dismiss button is <button class=\"ren-banner-dismiss\" aria-label=\"Dismiss\"> — never a <div> with click handler."
  - "Wrap any banner action buttons in <div class=\"ren-banner-actions\"> placed inside .ren-banner-content."
  - "To animate dismissal, set [data-dismissing] on the root; do not animate via inline styles."

forbiddenPatterns:
  - "Using a banner as a transient toast that auto-dismisses without user action — wrong semantics."
  - "Hardcoded border / background colors per severity — use the .ren-banner-{success | warning | danger | neutral} variant."
  - "Stacking two severity variants on the same banner (e.g., .ren-banner-success.ren-banner-danger)."
  - "Dismiss button without aria-label or visible text — the ✕ glyph alone is not an accessible name."
  - "Communicating severity with icon color only; the role attribute and surrounding text must convey severity to AT."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-banner-bg, --ren-banner-border-color, --ren-banner-border-width, --ren-banner-color, --ren-banner-gap, --ren-banner-icon-size, --ren-banner-padding, --ren-banner-radius."
    - "Semantic tokens: --color-info-subtle, --color-success-subtle, --color-warning-subtle, --color-danger-subtle, --color-surface-sunken, --color-border, --color-text, --color-text-secondary, --color-text-muted, --color-focus-ring."
    - "Motion tokens: --duration-tactile, --duration-state, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors or hardcoded rgba border tints — use color-mix on semantic tokens as the CSS already does."
    - "Inline transition / animation values; use --duration-* / --ease-* tokens."

accessibility:
  required:
    - "Set role=\"alert\" for danger / urgent warning banners and role=\"status\" for non-urgent info / success / neutral."
    - "Dismiss button must have aria-label (or visible text) and a visible :focus-visible ring driven by --color-focus-ring."
    - ".ren-banner-title renders as a real heading-like element (<strong>); when stacked among <h*> content, follow document outline."
    - "Respect prefers-reduced-motion: the [data-dismissing] animation collapses to display:none per the CSS — do not re-introduce motion."
    - "Banner content must not rely on color alone; the icon + title + message convey severity together."
    - "Action buttons inside .ren-banner-actions must be real <button> elements with their own keyboard focus order."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-banner/ren-banner.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<div class="ren-banner ren-banner-info" role="status">
  <span class="ren-banner-icon" aria-hidden="true">i</span>
  <div class="ren-banner-content"><strong class="ren-banner-title">Maintenance</strong><p class="ren-banner-message">Service resumes at 11 PM.</p></div>
  <button class="ren-banner-dismiss" type="button" aria-label="Dismiss">×</button>
</div>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-banner`
- `.ren-banner-actions`
- `.ren-banner-compact`
- `.ren-banner-content`
- `.ren-banner-danger`
- `.ren-banner-dismiss`
- `.ren-banner-full`
- `.ren-banner-icon`
- `.ren-banner-message`
- `.ren-banner-neutral`
- `.ren-banner-success`
- `.ren-banner-title`
- `.ren-banner-warning`

## States And Attributes

- `[data-dismissing]`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-banner-bg`
- `--ren-banner-border-color`
- `--ren-banner-border-width`
- `--ren-banner-color`
- `--ren-banner-gap`
- `--ren-banner-icon-size`
- `--ren-banner-padding`
- `--ren-banner-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-banner/ren-banner.css`
- `docs/components/ren-banner.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Banner / Alert Component
   ============================================
   Inline messages for success, warning, error, info.
   CSS-only. No JS needed (dismiss is optional JS).

   Different from Toast: Banner is inline, persistent,
   part of the page flow. Toast is ephemeral overlay.

   Usage:
     <div class="ren-banner" role="status">
       <span class="ren-banner-icon">ℹ</span>
       <div class="ren-banner-content">
         <strong class="ren-banner-title">Info</strong>
         <p class="ren-banner-message">Your changes have been saved.</p>
       </div>
     </div>

     <div class="ren-banner ren-banner-danger" role="alert">
       <span class="ren-banner-icon">!</span>
       <div class="ren-banner-content">
         <strong class="ren-banner-title">Error</strong>
         <p class="ren-banner-message">Could not save changes.</p>
       </div>
       <button class="ren-banner-dismiss" aria-label="Dismiss">&times;</button>
     </div>

   Simple (no title):
     <div class="ren-banner ren-banner-success" role="status">
       <span class="ren-banner-icon">✓</span>
       <p class="ren-banner-message">File uploaded successfully.</p>
     </div>
   ============================================ */

/* ─── Base Banner ─── */
.ren-banner {
  display: flex;
  align-items: flex-start;
  gap: var(--ren-banner-gap);
  padding: var(--ren-banner-padding);
  border-radius: var(--ren-banner-radius);
  font-size: var(--body-size);
  line-height: var(--leading-normal);
  /* Default: info */
  background-color: var(--ren-banner-bg);
  border: var(--ren-banner-border-width) solid var(--ren-banner-border-color);
  color: var(--ren-banner-color);
}

/* ─── Icon ─── */
.ren-banner-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--ren-banner-icon-size);
  height: var(--ren-banner-icon-size);
  font-size: var(--text-base);
  margin-top: 1px;
}

/* ─── Content ─── */
.ren-banner-content {
  flex: 1;
  min-width: 0;
}

.ren-banner-title {
  display: block;
  font-weight: var(--weight-semibold);
  font-size: var(--label-size, var(--text-sm));
  margin: 0 0 var(--space-1);
}

.ren-banner-message {
  font-size: var(--caption-size, var(--text-sm));
  color: var(--color-text-secondary);
  margin: 0;
}

/* Title-only (no message) */
.ren-banner-title:last-child {
  margin-bottom: 0;
}

/* ─── Actions (buttons inside banner) ─── */
.ren-banner-actions {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-2);
}

/* ─── Dismiss button ─── */
.ren-banner-dismiss {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--color-text-muted);
  cursor: pointer;
  border-radius: var(--radius-sm);
  font-size: var(--text-lg);
  line-height: 1;
  transition: color var(--duration-tactile) var(--ease-enter);
}

.ren-banner-dismiss:hover {
  color: var(--color-text);
}

.ren-banner-dismiss:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: 2px;
}

/* ─── Variants ─── */

.ren-banner-success {
  background-color: var(--color-success-subtle);
  border-color: color-mix(in oklch, var(--color-success), transparent 70%);
}

.ren-banner-warning {
  background-color: var(--color-warning-subtle);
  border-color: color-mix(in oklch, var(--color-warning), transparent 70%);
}

.ren-banner-danger {
  background-color: var(--color-danger-subtle);
  border-color: color-mix(in oklch, var(--color-danger), transparent 70%);
}

.ren-banner-neutral {
  background-color: var(--color-surface-sunken);
  border-color: var(--color-border);
}

/* ─── Compact variant (less padding, single line) ─── */
.ren-banner-compact {
  align-items: center;
  padding: var(--space-2) var(--space-3);
  gap: var(--space-2);
}

.ren-banner-compact .ren-banner-icon {
  width: 1.25rem;
  height: 1.25rem;
  font-size: var(--text-sm);
}

/* ─── Full-width variant (no border-radius, for page top) ─── */
.ren-banner-full {
  border-radius: 0;
  border-inline-start: none;
  border-inline-end: none;
}

/* ─── Dismiss animation ─── */
.ren-banner[data-dismissing] {
  animation: ren-banner-dismiss var(--duration-state) var(--ease-enter) forwards;
}

@keyframes ren-banner-dismiss {
  to {
    opacity: 0;
    height: 0;
    padding-block: 0;
    margin-block: 0;
    overflow: hidden;
  }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  .ren-banner[data-dismissing] {
    animation: none;
    display: none;
  }
}

data:
{"kind":"primitive","selectors":[".ren-banner",".ren-banner-actions",".ren-banner-compact",".ren-banner-content",".ren-banner-danger",".ren-banner-dismiss",".ren-banner-full",".ren-banner-icon",".ren-banner-message",".ren-banner-neutral",".ren-banner-success",".ren-banner-title",".ren-banner-warning"],"tokens":["--body-size","--caption-size","--color-border","--color-danger","--color-danger-subtle","--color-focus-ring","--color-success","--color-success-subtle","--color-surface-sunken","--color-text","--color-text-muted","--color-text-secondary","--color-warning","--color-warning-subtle","--duration-state","--duration-tactile","--ease-enter","--label-size","--leading-normal","--radius-sm","--ren-banner-bg","--ren-banner-border-color","--ren-banner-border-width","--ren-banner-color","--ren-banner-gap","--ren-banner-icon-size","--ren-banner-padding","--ren-banner-radius","--ring-width","--space-1","--space-2","--space-3","--text-base","--text-lg","--text-sm","--weight-semibold"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-breadcrumb
type: component
name: ren-breadcrumb
path: components/primitives/ren-breadcrumb
body:
# ren-breadcrumb Component Contract

Hierarchical navigation trail built on native navigation/list semantics.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-breadcrumb` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-breadcrumb` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Breadcrumb primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a hierarchical trail that shows the user's location within a site / app tree."
    - "The trail has 2+ ancestor levels and the final crumb represents the current page."
    - "You want native <nav> + <ol> semantics with the \"Breadcrumb\" landmark announcement."
    - "You need a customizable separator (default ›) via the --breadcrumb-separator custom property."
    - "Deep paths must collapse middle entries to an ellipsis via .ren-breadcrumb-truncated."
  avoidWhen:
    - "The navigation is the primary site nav — use ren-nav."
    - "The navigation is a tabbed page section — use ren-tabs."
    - "The navigation is a multi-step wizard — use ren-steps / ren-progress."
    - "You only need a single \"Back\" link — use ren-link with an arrow icon."

canonicalImports:
  css:
    - "rends/components/primitives/ren-breadcrumb/ren-breadcrumb.css"
  notes:
    - "CSS-only primitive — no colocated JS exists. Do not import a ren-breadcrumb.js."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Root must be <nav aria-label=\"Breadcrumb\" class=\"ren-breadcrumb\"> — the aria-label is what AT announces."
  - "Items are wrapped in an ordered list: <ol> with <li> children; do not use <ul> or plain <div>s."
  - "Each crumb is an <a href=\"...\"> except the current page, which uses aria-current=\"page\" and may stay as <a> (pointer-events disabled by CSS) or render as plain text."
  - "Separators are CSS-generated via ::before — never insert literal \"/\" or \"›\" characters as text nodes."
  - "Override the separator per-instance with inline style=\"--breadcrumb-separator: '/'\" — do not edit the CSS."

forbiddenPatterns:
  - "Using <div class=\"ren-breadcrumb\"> without <nav> — strips the landmark."
  - "Marking multiple crumbs with aria-current=\"page\" — only the final crumb is current."
  - "Inserting separator glyphs as text content inside <li> — the ::before pseudo handles it."
  - "Hardcoded font-size or color overrides via inline style — use the --ren-breadcrumb-* tokens."
  - "Wrapping crumb links in ren-link / ren-btn — breadcrumb owns its own link chrome."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-breadcrumb-active-color, --ren-breadcrumb-color, --ren-breadcrumb-font-size, --ren-breadcrumb-gap, --ren-breadcrumb-separator."
    - "Semantic tokens: --color-text, --color-text-muted, --color-text-faint, --color-focus-ring."
    - "Layout / motion tokens: --space-1, --radius-sm, --duration-tactile, --ease-enter, --ring-width, --caption-size, --text-sm."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors for link / current / separator styling."
    - "Overriding text-decoration with !important — let :hover and aria-current rules drive it."

accessibility:
  required:
    - "Root <nav> must carry aria-label=\"Breadcrumb\" (translated per locale) so AT announces the landmark."
    - "Current page crumb uses aria-current=\"page\"; CSS disables pointer-events but the semantic state is what matters for AT."
    - "Crumb links must have visible :focus-visible outline driven by --color-focus-ring and --ring-width."
    - "Separators are decorative (CSS ::before) so they are not announced — never put separator text in the DOM."
    - "When using .ren-breadcrumb-truncated, the hidden middle crumbs are display:none — ensure the ellipsis is purely decorative and provide a way to expand if the hidden context matters."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-breadcrumb/ren-breadcrumb.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<nav class="ren-breadcrumb" aria-label="Breadcrumb"><ol><li><a href="/">Home</a></li><li aria-current="page">Settings</li></ol></nav>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-breadcrumb`
- `.ren-breadcrumb-truncated`

## States And Attributes

- `[aria-current]`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-breadcrumb-active-color`
- `--ren-breadcrumb-color`
- `--ren-breadcrumb-font-size`
- `--ren-breadcrumb-gap`
- `--ren-breadcrumb-separator`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-breadcrumb/ren-breadcrumb.css`
- `docs/components/ren-breadcrumb.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Breadcrumb Component
   ============================================
   Hierarchical navigation trail.
   CSS-only. No JS needed.

   Uses semantic <nav> + <ol> for accessibility.
   Screen readers announce "Breadcrumb" navigation.

   Usage:
     <nav aria-label="Breadcrumb" class="ren-breadcrumb">
       <ol>
         <li><a href="/">Home</a></li>
         <li><a href="/products">Products</a></li>
         <li><a href="/products/shoes" aria-current="page">Shoes</a></li>
       </ol>
     </nav>

   With custom separator:
     <nav aria-label="Breadcrumb" class="ren-breadcrumb"
          style="--breadcrumb-separator: '/'">
       ...
     </nav>
   ============================================ */

.ren-breadcrumb > ol {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ren-breadcrumb-gap);
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: var(--ren-breadcrumb-font-size);
}

.ren-breadcrumb > ol > li {
  display: inline-flex;
  align-items: center;
  gap: var(--ren-breadcrumb-gap);
  color: var(--ren-breadcrumb-color);
}

/* Separator */
.ren-breadcrumb > ol > li + li::before {
  content: var(--ren-breadcrumb-separator);
  color: var(--color-text-faint);
  font-size: 1.1em;
  margin-inline-end: var(--space-1);
}

/* Links */
.ren-breadcrumb > ol a {
  color: var(--ren-breadcrumb-color);
  text-decoration: none;
  padding: var(--space-1) 0;
  border-radius: var(--radius-sm);
  transition: color var(--duration-tactile) var(--ease-enter);
}

.ren-breadcrumb > ol a:hover {
  color: var(--color-text);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

.ren-breadcrumb > ol a:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: 2px;
}

/* Current page (last item) */
.ren-breadcrumb [aria-current="page"] {
  color: var(--ren-breadcrumb-active-color);
  font-weight: var(--weight-medium);
  text-decoration: none;
  pointer-events: none;
}

/* ─── Truncated (for deep paths) ─── */
.ren-breadcrumb-truncated > ol > li:not(:first-child):not(:last-child):not(:nth-last-child(2)) {
  display: none;
}

/* Show ellipsis in place of hidden items */
.ren-breadcrumb-truncated > ol > li:nth-child(2)::after {
  content: '…';
  color: var(--color-text-faint);
  padding: 0 var(--space-1);
}

data:
{"kind":"primitive","selectors":[".ren-breadcrumb",".ren-breadcrumb-truncated"],"tokens":["--color-focus-ring","--color-text","--color-text-faint","--duration-tactile","--ease-enter","--radius-sm","--ren-breadcrumb-active-color","--ren-breadcrumb-color","--ren-breadcrumb-font-size","--ren-breadcrumb-gap","--ren-breadcrumb-separator","--ring-width","--space-1","--weight-medium"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-button
type: component
name: ren-button
path: components/primitives/ren-button
body:
# ren-button

Action primitive for commands, form submissions, links styled as controls,
icon-only triggers, and loading states.

Load this file after `ren-design.md` and before generating, editing, or
reviewing any `ren-btn` / `<ren-button>` UI.

## Purpose

The single touch-target primitive in RenDS. Owns visible action affordances:
solid CTAs, secondary commands, ghosted toolbar actions, link-styled inline
actions, and icon-only buttons. Does not own form inputs, navigation, or
dialog launchers — those compose `ren-button` inside their own contracts.

## Use When

- The element is a clickable command: submit, save, delete, open modal,
  toggle, fire any imperative action.
- An anchor needs to *look* like a button (use `.ren-btn` on `<a>` only when
  the link semantically replaces a button — otherwise use `ren-link`).
- A trigger is part of a composite (`ren-dialog` opener, `ren-menu` trigger,
  `ren-popover` anchor); the inner element should be `<button class="ren-btn">`.
- A loading / busy state must be reflected in markup (`data-loading`).

## Do Not Use When

- The control is a form input (use `ren-field` + native input).
- The control is plain navigation (use `ren-link` or a real `<a>`).
- The control is a checkbox / radio / toggle (use `ren-checkbox`,
  `ren-radio`, `ren-toggle-group`).
- The element is a styled label or status (use `ren-badge` or `ren-tag`).

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "User action triggers an imperative side effect (submit, save, delete, open dialog)."
    - "Need primary / secondary / destructive emphasis tied to the same touch-target shape."
    - "Need a 44px-minimum touch target with a visible focus ring."
    - "Need a loading / busy affordance via data-loading + aria-busy."
    - "Need an icon-only control with an accessible name (.ren-btn-icon + aria-label)."
  avoidWhen:
    - "The element is purely navigational — use ren-link."
    - "The element is a form input — use ren-field with a native input."
    - "The element is a stateful selector (checkbox, radio, switch, toggle)."
    - "You only need text emphasis or a status pill — use ren-badge / ren-tag."

canonicalImports:
  css:
    - "rends/components/primitives/ren-button/ren-button.css"
  js:
    - "rends/components/primitives/ren-button/ren-button.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "JS is only required when using <ren-button> attributes (loading, variant, size, icon, full); plain <button class=\"ren-btn\"> works without JS."

requiredMarkup:
  - "Always render an actual <button> (or <ren-button>) element. Never style a <div> as a button."
  - "Icon-only buttons must include an accessible name via aria-label or visually-hidden text."
  - "Loading buttons must set data-loading and aria-busy=\"true\" on the same node."
  - "Use type=\"button\" for non-submit buttons inside <form> to avoid accidental submission."

forbiddenPatterns:
  - "div / span with a click handler styled as a button."
  - "<button style=\"background: #...\"> — use --ren-btn-bg."
  - "Removing the focus ring (outline: none) without restoring a visible :focus-visible style."
  - "Custom danger styling that overrides --color-danger with a primitive --red-* token."
  - "Disabling via aria-disabled alone without preventing the click handler."

tokenPolicy:
  allowed:
    - "Semantic tokens: --color-accent, --color-on-accent, --color-danger, --color-text-link, --color-focus-ring."
    - "Component tokens: every --ren-btn-* listed in Public Token API."
    - "Layout / size tokens: --space-*, --size-*, --radius-*."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, etc.) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Raw transition values; use --ren-btn-duration / --ren-btn-easing."

accessibility:
  required:
    - "Real <button> semantics; rely on native click / Enter / Space."
    - "Visible :focus-visible ring driven by --ren-btn-ring-* tokens."
    - "Touch target ≥ 44px (default size). Smaller (.ren-btn-sm = 32px) only for non-touch contexts."
    - "Icon-only buttons have aria-label or visually-hidden label text."
    - "Loading buttons expose aria-busy=\"true\" and ignore activation while busy."
    - "Disabled buttons set both `disabled` (real) or `aria-disabled=\"true\"` AND prevent activation in the handler."
```

## Required CSS / JS Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-button/ren-button.css">
<script type="module" src="rends/components/primitives/ren-button/ren-button.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the
CSS again. JS is only required when consuming `<ren-button>` attributes.

## Canonical Markup

Primary action button:

```html
<button class="ren-btn">Save changes</button>
```

Secondary + small:

```html
<button class="ren-btn ren-btn-secondary ren-btn-sm" type="button">Cancel</button>
```

Destructive with loading state:

```html
<button class="ren-btn ren-btn-danger" data-loading aria-busy="true" disabled>
  Deleting...
</button>
```

Icon-only with accessible name:

```html
<button class="ren-btn ren-btn-icon" aria-label="Close dialog">
  <svg aria-hidden="true" focusable="false" width="16" height="16">...</svg>
</button>
```

Web component form (attribute-driven state):

```html
<ren-button variant="secondary" size="sm">Cancel</ren-button>
<ren-button variant="primary" loading>Submitting</ren-button>
```

## Variants

| Class                  | Role                                |
|------------------------|-------------------------------------|
| `.ren-btn`             | Default primary CTA.                |
| `.ren-btn-primary`     | Same as default; explicit.          |
| `.ren-btn-secondary`   | Tonal / fill-style secondary.       |
| `.ren-btn-outline`     | Border-only, transparent fill.      |
| `.ren-btn-ghost`       | No fill, no border; text + hover.   |
| `.ren-btn-link`        | Inline text-style action.           |
| `.ren-btn-danger`      | Destructive emphasis.               |
| `.ren-btn-accent`      | Custom accent via `--btn-accent`.   |
| `.ren-btn-icon`        | Square icon-only target.            |
| `.ren-btn-full`        | Stretches to container width.       |
| `.ren-btn-group`       | Joined sibling buttons.             |
| `.ren-btn-sm` / `-lg`  | Size override (32px / 52px).        |

## States

| Selector / attr        | Meaning                                                |
|------------------------|--------------------------------------------------------|
| `:hover`               | Pointer is over an interactive button.                 |
| `:active`              | Pressed state.                                         |
| `:focus-visible`       | Keyboard focus; renders the ring.                      |
| `:disabled`            | Native disabled; non-interactive, in tab order off.    |
| `[aria-disabled="true"]` | ARIA-disabled; visually disabled but stays focusable.|
| `[data-loading]`       | Spinner replaces label; pair with `aria-busy="true"`.  |

## Public Token API

Override these on `:root`, `[data-theme]`, or a scope wrapping the button.
Do **not** override the internal `.ren-btn` class.

- `--ren-btn-bg`
- `--ren-btn-bg-hover`
- `--ren-btn-bg-active`
- `--ren-btn-color`
- `--ren-btn-border-color`
- `--ren-btn-border-width`
- `--ren-btn-radius`
- `--ren-btn-height`
- `--ren-btn-padding-x`
- `--ren-btn-padding-y`
- `--ren-btn-gap`
- `--ren-btn-font-size`
- `--ren-btn-font-weight`
- `--ren-btn-ring-width`
- `--ren-btn-ring-offset`
- `--ren-btn-ring-color`
- `--ren-btn-duration`
- `--ren-btn-easing`

## Accessibility Contract

- Use a real `<button>` (or `<ren-button>` which renders a button internally).
- Keep a visible focus ring driven by `--ren-btn-ring-*`.
- Maintain a 44px minimum touch target (default size). `ren-btn-sm` (32px)
  is reserved for non-touch contexts (toolbars, dense desktop UI).
- Icon-only buttons must have an accessible name via `aria-label` or visually
  hidden text inside the button.
- Loading buttons expose `aria-busy="true"`. The component sets it
  automatically when `loading` is the chosen attribute on `<ren-button>`.
- Do not communicate state through color alone — `ren-btn-danger` carries the
  variant in the class, not just the hue.

## Anti-Patterns

- ❌ `<div class="ren-btn" onclick="...">` — must be a real `<button>`.
- ❌ Inline hex colors: `<button style="background:#0066ff;">`.
- ❌ Overriding `.ren-btn { background: ... !important }` — use
  `--ren-btn-bg` on a parent scope.
- ❌ Mixing `disabled` and a click handler that still fires (the handler
  must early-return or be detached).
- ❌ Using `ren-btn-link` when the destination is a navigation URL — use
  `ren-link` instead.

## Related Files

- `components/primitives/ren-button/ren-button.css`
- `components/primitives/ren-button/ren-button.js`
- `docs/components/ren-button.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, ARIA, or states change.
- Run `npm run lint` after token / selector changes.
- Manually verify light/dark themes when surface, border, or shadow values
  change — focus rings must remain visible in both modes.


/* ============================================
   RenDS — Button Component
   ============================================
   Accessible, multi-variant button.
   Works as pure CSS classes OR as <ren-button>.

   Variants: primary, secondary, ghost, danger, outline
   Sizes: sm, md (default), lg
   States: hover, active, disabled, loading

   Touch target: 44px minimum (Apple HIG)
   ============================================ */

/* ─── Base Button ─── */
.ren-btn {
  /* Layout */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ren-btn-gap);

  /* Sizing — default md */
  min-height: var(--ren-btn-height);        /* 44px touch target */
  padding: var(--ren-btn-padding-y) var(--ren-btn-padding-x);
  font-size: var(--ren-btn-font-size);
  font-weight: var(--ren-btn-font-weight);
  line-height: 1;

  /* Appearance */
  border: var(--ren-btn-border-width) solid var(--ren-btn-border-color);
  border-radius: var(--ren-btn-radius);
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
  text-decoration: none;

  /* Transition — intent: tactile feedback on hover/press/focus */
  transition:
    background-color var(--ren-btn-duration) var(--ren-btn-easing),
    border-color var(--ren-btn-duration) var(--ren-btn-easing),
    color var(--ren-btn-duration) var(--ren-btn-easing),
    box-shadow var(--ren-btn-duration) var(--ren-btn-easing),
    transform var(--duration-tactile) var(--ease-enter),
    opacity var(--ren-btn-duration) var(--ren-btn-easing);
}

/* Active press effect */
.ren-btn:active:not(:disabled):not([aria-disabled="true"]) {
  transform: scale(0.97);
}

/* ─── Variants ─── */

/* Primary (default) */
.ren-btn:not(.ren-btn-secondary):not(.ren-btn-ghost):not(.ren-btn-outline):not(.ren-btn-danger):not(.ren-btn-link):not(.ren-btn-accent),
.ren-btn-primary {
  background-color: var(--ren-btn-bg);
  color: var(--ren-btn-color);
  border-color: var(--ren-btn-border-color);
}

.ren-btn:not(.ren-btn-secondary):not(.ren-btn-ghost):not(.ren-btn-outline):not(.ren-btn-danger):not(.ren-btn-link):not(.ren-btn-accent):hover:not(:disabled):not([aria-disabled="true"]),
.ren-btn-primary:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--ren-btn-bg-hover);
  border-color: var(--ren-btn-bg-hover);
}

.ren-btn:not(.ren-btn-secondary):not(.ren-btn-ghost):not(.ren-btn-outline):not(.ren-btn-danger):not(.ren-btn-link):not(.ren-btn-accent):active:not(:disabled):not([aria-disabled="true"]),
.ren-btn-primary:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--ren-btn-bg-active);
  border-color: var(--ren-btn-bg-active);
}

/* Secondary */
.ren-btn-secondary {
  background-color: var(--color-fill);
  color: var(--color-text);
  border-color: var(--color-border);
}

.ren-btn-secondary:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-fill-hover);
}

.ren-btn-secondary:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-fill-active);
}

/* Ghost (no background) */
.ren-btn-ghost {
  background-color: transparent;
  color: var(--color-text);
  border-color: transparent;
}

.ren-btn-ghost:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-fill);
}

.ren-btn-ghost:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-fill-hover);
}

/* Outline */
.ren-btn-outline {
  background-color: transparent;
  color: var(--color-accent);
  border-color: var(--color-accent);
}

.ren-btn-outline:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-accent-subtle);
}

.ren-btn-outline:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--color-accent);
  color: var(--color-on-accent);
}

/* Danger */
.ren-btn-danger {
  background-color: var(--color-danger);
  color: var(--color-on-danger);
  border-color: var(--color-danger);
}

.ren-btn-danger:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--red-500);
  border-color: var(--red-500);
}

.ren-btn-danger:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--red-600);
  border-color: var(--red-600);
}

/* Link style */
.ren-btn-link {
  background-color: transparent;
  color: var(--color-text-link);
  border-color: transparent;
  padding-inline: var(--space-1);
  min-height: auto;
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

.ren-btn-link:hover:not(:disabled):not([aria-disabled="true"]) {
  color: var(--color-text-link-hover);
  text-decoration-thickness: 2px;
}

/* ─── Sizes ─── */

.ren-btn-sm {
  min-height: var(--size-sm);       /* 32px */
  padding: var(--space-1) var(--space-3);
  font-size: var(--label-size);
  border-radius: var(--radius-sm);
  gap: var(--space-1);
}

.ren-btn-lg {
  min-height: var(--size-xl);       /* 52px */
  padding: var(--space-3) var(--space-6);
  font-size: var(--body-size);
  border-radius: var(--radius-lg);
}

/* ─── Icon Button (square) ─── */
.ren-btn-icon {
  min-width: var(--size-lg);        /* 44px */
  padding: 0;
  aspect-ratio: 1;
}

.ren-btn-icon.ren-btn-sm {
  min-width: var(--size-sm);
  min-height: var(--size-sm);
}

.ren-btn-icon.ren-btn-lg {
  min-width: var(--size-xl);
  min-height: var(--size-xl);
}

/* ─── Full Width ─── */
.ren-btn-full {
  width: 100%;
}

/* ─── Button Group ─── */
.ren-btn-group {
  display: inline-flex;
  isolation: isolate;  /* contain z-index stacking */
}

.ren-btn-group > .ren-btn {
  border-radius: 0;
  position: relative;
}

.ren-btn-group > .ren-btn:first-child {
  border-start-start-radius: var(--radius-md);
  border-end-start-radius: var(--radius-md);
}

.ren-btn-group > .ren-btn:last-child {
  border-start-end-radius: var(--radius-md);
  border-end-end-radius: var(--radius-md);
}

.ren-btn-group > .ren-btn + .ren-btn {
  margin-inline-start: calc(var(--stroke-1) * -1);
}

/* Hovered/focused button rises above siblings so border isn't hidden */
.ren-btn-group > .ren-btn:hover,
.ren-btn-group > .ren-btn:focus-visible {
  z-index: 1;
}

/* ─── States ─── */

/* Disabled */
.ren-btn:disabled,
.ren-btn[aria-disabled="true"] {
  background-color: var(--color-disabled-bg);
  color: var(--color-disabled-text);
  border-color: var(--color-disabled-bg);
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

/* Loading */
.ren-btn[data-loading] {
  position: relative;
  color: transparent !important;
  pointer-events: none;
}

.ren-btn[data-loading]::after {
  content: '';
  position: absolute;
  width: 1.2em;
  height: 1.2em;
  border: 2px solid currentColor;
  border-inline-end-color: transparent;
  border-radius: var(--radius-full);
  animation: ren-btn-spin 600ms linear infinite;
  /* Restore color since parent is transparent */
  color: var(--color-on-accent);
}

.ren-btn-secondary[data-loading]::after,
.ren-btn-ghost[data-loading]::after,
.ren-btn-outline[data-loading]::after {
  color: var(--color-text);
}

@keyframes ren-btn-spin {
  to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .ren-btn[data-loading]::after {
    animation-duration: 1.5s;
  }
  .ren-btn:active:not(:disabled):not([aria-disabled="true"]) {
    transform: none;
  }
}

/* ─── Custom Accent (color-mix pattern) ───
   Use a single --btn-accent color and derive hover/active automatically.
   Example: <button class="ren-btn ren-btn-accent" style="--btn-accent: #8B5CF6">Purple</button>
*/
.ren-btn-accent {
  --_hover: color-mix(in oklch, var(--btn-accent, var(--color-accent)), black 12%);
  --_active: color-mix(in oklch, var(--btn-accent, var(--color-accent)), black 22%);
  --_subtle: color-mix(in oklch, var(--btn-accent, var(--color-accent)), white 90%);

  background-color: var(--btn-accent, var(--color-accent));
  color: var(--color-on-accent);
  border-color: var(--btn-accent, var(--color-accent));
}

.ren-btn-accent:hover:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--_hover);
  border-color: var(--_hover);
}

.ren-btn-accent:active:not(:disabled):not([aria-disabled="true"]) {
  background-color: var(--_active);
  border-color: var(--_active);
}

/* ─── Focus ─── */
.ren-btn:focus-visible {
  outline: var(--ren-btn-ring-width) solid var(--ren-btn-ring-color);
  outline-offset: var(--ren-btn-ring-offset);
}


/**
 * RenDS — <ren-button> Web Component
 * ====================================
 * Enhanced button with loading state, variants, and
 * proper accessibility out of the box.
 *
 * Uses Light DOM — no Shadow DOM needed for a button.
 * The CSS classes work standalone too.
 *
 * Attributes:
 *   variant:  primary | secondary | ghost | outline | danger | link
 *   size:     sm | md | lg
 *   loading:  boolean — shows spinner, disables interaction
 *   disabled: boolean — standard disabled state
 *   icon:     boolean — square icon button
 *   full:     boolean — full width
 *
 * Usage:
 *   <ren-button>Click me</ren-button>
 *   <ren-button variant="secondary" size="sm">Cancel</ren-button>
 *   <ren-button variant="danger" loading>Deleting...</ren-button>
 *   <ren-button icon aria-label="Close">✕</ren-button>
 */

const VARIANT_MAP = {
  primary: 'ren-btn',
  secondary: 'ren-btn ren-btn-secondary',
  ghost: 'ren-btn ren-btn-ghost',
  outline: 'ren-btn ren-btn-outline',
  danger: 'ren-btn ren-btn-danger',
  link: 'ren-btn ren-btn-link',
};

const SIZE_MAP = {
  sm: 'ren-btn-sm',
  md: '',
  lg: 'ren-btn-lg',
};

export class RenButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'size', 'loading', 'disabled', 'icon', 'full'];
  }

  constructor() {
    super();
    this._button = null;
  }

  connectedCallback() {
    // If this element wraps a <button>, enhance it
    // If not, create one
    this._button = this.querySelector('button, a, [role="button"]');

    if (!this._button) {
      // Wrap content in a button
      const button = document.createElement('button');
      button.setAttribute('type', 'button');

      // Move children into the button
      while (this.firstChild) {
        button.appendChild(this.firstChild);
      }
      this.appendChild(button);
      this._button = button;
    }

    this._updateClasses();
    this._updateState();
  }

  attributeChangedCallback() {
    if (this._button) {
      this._updateClasses();
      this._updateState();
    }
  }

  _updateClasses() {
    if (!this._button) return;

    const variant = this.getAttribute('variant') || 'primary';
    const size = this.getAttribute('size') || 'md';

    const classes = [
      VARIANT_MAP[variant] || VARIANT_MAP.primary,
      SIZE_MAP[size] || '',
      this.hasAttribute('icon') ? 'ren-btn-icon' : '',
      this.hasAttribute('full') ? 'ren-btn-full' : '',
    ]
      .filter(Boolean)
      .join(' ');

    this._button.className = classes;
  }

  _updateState() {
    if (!this._button) return;

    // Loading
    if (this.hasAttribute('loading')) {
      this._button.setAttribute('data-loading', '');
      this._button.setAttribute('aria-busy', 'true');
    } else {
      this._button.removeAttribute('data-loading');
      this._button.removeAttribute('aria-busy');
    }

    // Disabled
    if (this.hasAttribute('disabled')) {
      this._button.setAttribute('disabled', '');
      this._button.setAttribute('aria-disabled', 'true');
    } else {
      this._button.removeAttribute('disabled');
      this._button.removeAttribute('aria-disabled');
    }
  }

  // ─── Programmatic API ───

  get loading() {
    return this.hasAttribute('loading');
  }

  set loading(val) {
    if (val) {
      this.setAttribute('loading', '');
    } else {
      this.removeAttribute('loading');
    }
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  get variant() {
    return this.getAttribute('variant') || 'primary';
  }

  set variant(val) {
    this.setAttribute('variant', val);
  }
}

// Register
if (!customElements.get('ren-button')) {
  customElements.define('ren-button', RenButton);
}

data:
{"kind":"primitive","selectors":[".ren-btn",".ren-btn-accent",".ren-btn-danger",".ren-btn-full",".ren-btn-ghost",".ren-btn-group",".ren-btn-icon",".ren-btn-lg",".ren-btn-link",".ren-btn-outline",".ren-btn-primary",".ren-btn-secondary",".ren-btn-sm"],"tokens":["--_active","--_hover","--body-size","--btn-accent","--color-accent","--color-accent-subtle","--color-border","--color-danger","--color-disabled-bg","--color-disabled-text","--color-fill","--color-fill-active","--color-fill-hover","--color-on-accent","--color-on-danger","--color-text","--color-text-link","--color-text-link-hover","--duration-tactile","--ease-enter","--label-size","--radius-full","--radius-lg","--radius-md","--radius-sm","--red-500","--red-600","--ren-btn-bg","--ren-btn-bg-active","--ren-btn-bg-hover","--ren-btn-border-color","--ren-btn-border-width","--ren-btn-color","--ren-btn-duration","--ren-btn-easing","--ren-btn-font-size","--ren-btn-font-weight","--ren-btn-gap","--ren-btn-height","--ren-btn-padding-x","--ren-btn-padding-y","--ren-btn-radius","--ren-btn-ring-color","--ren-btn-ring-offset","--ren-btn-ring-width","--size-lg","--size-sm","--size-xl","--space-1","--space-3","--space-6","--stroke-1"],"hasScript":true,"hasDocsPage":true}


id: component:primitive:ren-card
type: component
name: ren-card
path: components/primitives/ren-card
body:
# ren-card

Container primitive for grouped content with header, body, footer, status,
and interactive (clickable / selectable) variants.

Load this file after `ren-design.md` and before generating, editing, or
reviewing any `.ren-card` UI.

## Purpose

Owns the shape, padding, surface, and border of grouped content. Provides
slots (`-header`, `-body`, `-footer`) and a small set of variants
(`-elevated`, `-outline`, `-sunken`, `-ghost`, `-simple`,
`-interactive`, `-selectable`) plus a status hook (`[data-status]`).
Card never owns inputs, dialogs, or navigation behavior — it is a passive
container.

## Use When

- A group of related content needs its own surface separated from the page.
- A list of repeating items would benefit from a consistent card grid
  (`ren-grid` + `ren-card`).
- A tile must be clickable or selectable as a whole (use
  `.ren-card-interactive` or `.ren-card-selectable`).
- A surface needs a header / body / footer layout already proven by RenDS.

## Do Not Use When

- The container is just a layout primitive — use `ren-stack`, `ren-cluster`,
  `ren-grid`, etc.
- The container is a modal — use `ren-dialog`.
- The container is a popover / tooltip / menu — use those composites.
- You need an alert / banner / call-out — use `ren-banner`.
- You need a form section — use `ren-form` + `ren-field` directly.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You want a visually grouped surface (border / shadow / radius) with predictable spacing."
    - "You need a card grid (stat tiles, product cards, list items)."
    - "You need a whole-card click target (.ren-card-interactive) or selection (.ren-card-selectable)."
    - "You need a status accent driven by [data-status]."
  avoidWhen:
    - "You only need vertical spacing — use ren-stack."
    - "You need overlay behavior — use ren-dialog / ren-popover / ren-sheet."
    - "You need a banner / alert message — use ren-banner."
    - "You need a navigation list with hover / active — use ren-sidebar items."

canonicalImports:
  css:
    - "rends/components/primitives/ren-card/ren-card.css"
  notes:
    - "Card is CSS-only. There is no <ren-card> custom element."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "A single .ren-card root wrapping any number of .ren-card-header, .ren-card-body, .ren-card-footer."
  - ".ren-card-title is the heading; use a real <h*> element inside it for outline / a11y."
  - "Selectable cards must wire aria-selected (or [data-selected]) AND a keyboard handler — the class only styles."
  - "Interactive cards (whole-card click) should wrap content in a real <a> or <button>, not a div with onclick."

forbiddenPatterns:
  - "Nesting another .ren-card inside .ren-card-body — flatten the structure."
  - "Replacing internal .ren-card-* selectors with custom classes for theming — use --ren-card-* tokens."
  - "Hardcoded shadow / radius values overriding card defaults; theme through tokens."

tokenPolicy:
  allowed:
    - "Component tokens: every --ren-card-* listed in Public Token API."
    - "Semantic tokens for content inside the card (--color-text, --color-text-muted, etc.)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, etc.) in consumer code."
    - "Hardcoded hex / rgb colors in consumer overrides."

accessibility:
  required:
    - "Card titles use real heading elements (<h2>, <h3>) — never style a <div> as a heading."
    - "Whole-card click targets must use <a> or <button> wrappers and respect tab order / focus rings."
    - "Selectable cards announce state via aria-selected + data-selected; pair with keyboard activation."
    - "[data-status] is decorative; pair it with meaningful text or icons, not color alone."
```

## Required CSS / JS Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-card/ren-card.css">
<!-- No JS — card is CSS-only. -->
```

## Canonical Markup

Static card with header + body:

```html
<article class="ren-card">
  <div class="ren-card-header">
    <h3 class="ren-card-title">Project status</h3>
    <p class="ren-card-description">Updated 2 minutes ago.</p>
  </div>
  <div class="ren-card-body">
    <p>All checks passing.</p>
  </div>
</article>
```

Interactive (whole-card click) with link wrapper:

```html
<a class="ren-card ren-card-interactive" href="/projects/atlas">
  <div class="ren-card-header">
    <h3 class="ren-card-title">Atlas</h3>
  </div>
  <div class="ren-card-body">42 open issues</div>
</a>
```

Selectable in a grid:

```html
<div class="ren-grid-3">
  <button class="ren-card ren-card-selectable" type="button" aria-pressed="false">
    <span class="ren-card-title">Hobby</span>
    <span class="ren-card-description">$0/mo</span>
  </button>
</div>
```

## Variants

| Class                   | Role                                      |
|-------------------------|-------------------------------------------|
| `.ren-card`             | Default surface.                          |
| `.ren-card-elevated`    | Adds shadow.                              |
| `.ren-card-outline`     | Border-only, transparent fill.            |
| `.ren-card-sunken`      | Inset surface (sunken background).        |
| `.ren-card-ghost`       | No surface; spacing only.                 |
| `.ren-card-simple`      | Reduced padding for dense lists.          |
| `.ren-card-interactive` | Hover / focus styles for clickable cards. |
| `.ren-card-selectable`  | Toggleable card; pair with `aria-selected`. |
| `.ren-card-cover`       | Edge-to-edge media slot at the top.       |
| `.ren-card-group`       | Joined sibling cards with shared borders. |
| `.ren-card-footer-border` | Footer with top divider.                |

## States

| Selector / attr        | Meaning                                     |
|------------------------|---------------------------------------------|
| `:hover` / `:active`   | Pointer interaction (interactive cards).    |
| `:focus-visible`       | Keyboard focus ring (interactive cards).    |
| `[aria-selected="true"]` / `[data-selected]` | Selection state.       |
| `[data-status="success" / "warning" / "danger" / "info"]` | Status accent. |

## Public Token API

- `--ren-card-bg`
- `--ren-card-border-color`
- `--ren-card-border-width`
- `--ren-card-radius`
- `--ren-card-padding`
- `--ren-card-gap`
- `--ren-card-header-gap`
- `--ren-card-shadow`

## Accessibility Contract

- Card titles are real `<h*>` elements; never style a `<div>` as a heading.
- Whole-card clickability wraps the entire card in a real `<a>` or `<button>`.
- Selectable cards must expose `aria-selected` (or `aria-pressed`) plus a
  visible focus ring via `:focus-visible`.
- `[data-status]` is decorative; status meaning must be carried by text or
  icons too.

## Anti-Patterns

- ❌ `<div class="ren-card" onclick="...">` — wrap with a real link / button.
- ❌ Nesting `.ren-card` inside `.ren-card-body`.
- ❌ Inline hex colors on `.ren-card { background: #FFFFFF; }` — override
  `--ren-card-bg` on the parent scope.
- ❌ Replacing built-in selectors (`.ren-card-title`) with custom classes
  for theming — theme through component tokens.

## Related Files

- `components/primitives/ren-card/ren-card.css`
- `docs/components/ren-card.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, ARIA, or states change.
- Run `npm run lint` after token / selector changes.
- Manually verify light/dark themes when surface, border, or shadow values
  change.


/* ============================================
   RenDS — Card Component
   ============================================
   Versatile container with header, body, footer.
   CSS-only. No JS needed.

   Constraint-driven: limited variants on purpose
   to ensure consistent UI across projects.

   Usage:
     <div class="ren-card">
       <div class="ren-card-header">
         <h3 class="ren-card-title">Title</h3>
         <p class="ren-card-description">Description here</p>
       </div>
       <div class="ren-card-body">
         Content
       </div>
       <div class="ren-card-footer">
         <button class="ren-btn">Action</button>
       </div>
     </div>

   Minimal:
     <div class="ren-card">
       <p>Just content, no sections needed.</p>
     </div>
   ============================================ */

/* ─── Base Card ─── */
.ren-card {
  background-color: var(--ren-card-bg);
  border: var(--ren-card-border-width) solid var(--ren-card-border-color);
  border-radius: var(--ren-card-radius);
  box-shadow: var(--ren-card-shadow);
  overflow: hidden;
}

/* ─── Sections ─── */

.ren-card-header {
  display: flex;
  flex-direction: column;
  gap: var(--ren-card-header-gap);
  padding: var(--ren-card-padding) var(--ren-card-padding) 0;
}

/* Title and description are typically applied to <h2>/<h3>/<h4> and <p>
   respectively, which carry browser-default margins that fight the header's
   `gap`. Reset them here so .ren-card-header's flex gap is the actual visual
   gap, regardless of the underlying tag. */
.ren-card-title {
  font-size: var(--title-sm-size, var(--text-lg));
  font-weight: var(--weight-semibold);
  color: var(--color-text);
  line-height: var(--leading-snug);
  margin: 0;
}

.ren-card-description {
  font-size: var(--caption-size, var(--text-sm));
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
  margin: 0;
}

.ren-card-body {
  padding: var(--ren-card-padding);
}

/* Remove top padding when body follows header */
.ren-card-header + .ren-card-body {
  padding-top: var(--space-3);
}

/* Optional spacing between documented card sections without forcing a new
   flex/grid layout on the card root. */
.ren-card > :is(.ren-card-header, .ren-card-body, .ren-card-footer, .ren-card-footer-border)
  + :is(.ren-card-header, .ren-card-body, .ren-card-footer, .ren-card-footer-border) {
  margin-block-start: var(--ren-card-gap);
}

.ren-card-footer {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--ren-card-padding) var(--ren-card-padding);
}

/* Footer at bottom with border (for action-heavy cards) */
.ren-card-footer-border {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--ren-card-padding);
  border-top: var(--stroke-1) solid var(--color-border-muted);
}

/* When the bordered footer follows the header directly (no body in between),
   tighten the top padding so the divider line sits visually close to the
   description, not floating ~17px below it (which is what you get from the
   default --space-3 padding plus line-height extra of the description text). */
.ren-card-header + .ren-card-footer-border {
  padding-top: var(--space-2);
}

/* When card has no sections, just padding */
.ren-card > :first-child:not(.ren-card-header):not(.ren-card-body):not(.ren-card-footer) {
  padding-top: var(--ren-card-padding);
}

.ren-card > :last-child:not(.ren-card-header):not(.ren-card-body):not(.ren-card-footer) {
  padding-bottom: var(--ren-card-padding);
}

/* Simple card (no sections) */
.ren-card-simple {
  padding: var(--ren-card-padding);
}

/* ─── Variants ─── */

/* Elevated — more shadow, no border */
.ren-card-elevated {
  border-color: transparent;
  box-shadow: var(--shadow-md);
}

/* Outline — emphasis border, no background difference */
.ren-card-outline {
  background-color: transparent;
}

/* Ghost — no border, no bg (content grouping) */
.ren-card-ghost {
  background-color: transparent;
  border: none;
}

/* Sunken — recessed appearance */
.ren-card-sunken {
  background-color: var(--color-surface-sunken);
  border-color: transparent;
}

/* ─── Interactive Card ─── */

.ren-card-interactive {
  cursor: pointer;
  transition:
    box-shadow var(--duration-state) var(--ease-enter),
    border-color var(--duration-state) var(--ease-enter),
    transform var(--duration-tactile) var(--ease-enter);
}

.ren-card-interactive:hover {
  border-color: var(--color-border-strong);
  box-shadow: var(--shadow-md);
}

.ren-card-interactive:active {
  transform: scale(0.99);
}

.ren-card-interactive:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* ─── Selectable Card ─── */

.ren-card-selectable {
  cursor: pointer;
  transition:
    border-color var(--duration-state) var(--ease-enter),
    box-shadow var(--duration-state) var(--ease-enter);
}

.ren-card-selectable:hover {
  border-color: var(--color-accent);
}

.ren-card-selectable[aria-selected="true"],
.ren-card-selectable[data-selected] {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 1px var(--color-accent);
}

/* ─── Status accent (top border) ─── */

.ren-card[data-status="success"] { border-top: 3px solid var(--color-success); }
.ren-card[data-status="warning"] { border-top: 3px solid var(--color-warning); }
.ren-card[data-status="danger"]  { border-top: 3px solid var(--color-danger); }
.ren-card[data-status="info"]    { border-top: 3px solid var(--color-info); }

/* ─── Card with cover image ─── */

.ren-card-cover {
  width: 100%;
  height: auto;
  object-fit: cover;
  display: block;
}

/* ─── Card Group (grid of cards) ─── */

.ren-card-group {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
  gap: var(--space-4);
}

data:
{"kind":"primitive","selectors":[".ren-card",".ren-card-body",".ren-card-cover",".ren-card-description",".ren-card-elevated",".ren-card-footer",".ren-card-footer-border",".ren-card-ghost",".ren-card-group",".ren-card-header",".ren-card-interactive",".ren-card-outline",".ren-card-selectable",".ren-card-simple",".ren-card-sunken",".ren-card-title"],"tokens":["--caption-size","--color-accent","--color-border-muted","--color-border-strong","--color-danger","--color-focus-ring","--color-info","--color-success","--color-surface-sunken","--color-text","--color-text-muted","--color-warning","--duration-state","--duration-tactile","--ease-enter","--leading-normal","--leading-snug","--ren-card-bg","--ren-card-border-color","--ren-card-border-width","--ren-card-gap","--ren-card-header-gap","--ren-card-padding","--ren-card-radius","--ren-card-shadow","--ring-offset-width","--ring-width","--shadow-md","--space-2","--space-3","--space-4","--stroke-1","--text-lg","--text-sm","--title-sm-size","--weight-semibold"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-checkbox
type: component
name: ren-checkbox
path: components/primitives/ren-checkbox
body:
# ren-checkbox Component Contract

Native checkbox styling with checked, indeterminate, disabled, and focus states.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-checkbox` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-checkbox` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Checkbox primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The control toggles a boolean value (checked / unchecked) for a single option."
    - "The control belongs in a list where each option is independently selectable (multi-select)."
    - "You need an indeterminate / mixed state (e.g., parent of a tree, \"select all\" with partial children)."
    - "You need native form-submission semantics (name / value / required) tied to a real <input type=\"checkbox\">."
    - "The label sits next to a custom-styled box that participates in :checked / :hover / :focus-visible / :indeterminate / :disabled states."
  avoidWhen:
    - "The control is an on/off toggle with two visible states — use ren-switch."
    - "The control is one of several mutually-exclusive options — use ren-radio."
    - "You need a yes/no choice inside a tabular row without form submission — consider ren-switch."
    - "You need a tri-state with named states beyond on/off/mixed — design a custom segmented control."

canonicalImports:
  css:
    - "rends/components/primitives/ren-checkbox/ren-checkbox.css"
  notes:
    - "CSS-only primitive — no colocated JS exists. Indeterminate must be set in JS via input.indeterminate = true; the CSS reacts to :indeterminate."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Wrap each control in <label class=\"ren-checkbox\"> containing exactly one <input type=\"checkbox\"> and one <span class=\"ren-checkbox-control\"> sibling, in that order."
  - "Label text goes in a trailing <span> (or text node) after .ren-checkbox-control so :checked + .ren-checkbox-control adjacency works."
  - "Group related checkboxes inside a <fieldset> with a <legend> to provide a group accessible name."
  - "For indeterminate state, set input.indeterminate = true via JS; you cannot express it via an HTML attribute alone."
  - "Disabled inputs must use the real disabled attribute — the .ren-checkbox:has(input:disabled) selector relies on it."

forbiddenPatterns:
  - "Replacing <input type=\"checkbox\"> with a <div role=\"checkbox\"> — breaks form submission and native keyboard."
  - "Hiding the input with display:none — destroys keyboard focus; use the documented visually-hidden technique already in the CSS."
  - "Custom checkmark glyphs injected via JS or inline SVG — the ::after pseudo owns the checkmark."
  - "Mixing two label nodes per input (e.g., wrapping <label> plus a separate <label for>) — choose one."
  - "Communicating error / validity via color alone; pair with text or an inline error message."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-checkbox-border, --ren-checkbox-checked-bg, --ren-checkbox-checked-color, --ren-checkbox-duration, --ren-checkbox-easing, --ren-checkbox-gap, --ren-checkbox-radius, --ren-checkbox-size."
    - "Semantic tokens: --color-accent, --color-accent-hover, --color-on-accent, --color-border-strong, --color-text, --color-focus-ring."
    - "Layout / motion tokens: --space-2, --radius-sm, --touch-min, --ring-width, --ring-offset-width, --duration-state, --duration-tactile, --ease-enter, --ease-playful."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors for the checked fill, border, or checkmark."
    - "Raw transition values; use --ren-checkbox-duration / --ren-checkbox-easing or the --duration-* / --ease-* tokens."

accessibility:
  required:
    - "Always use a real <input type=\"checkbox\"> so Space toggles, Tab focuses, and form submission carries the value."
    - "Touch target stays ≥ var(--touch-min) via min-height on .ren-checkbox; do not shrink below that for touch contexts."
    - "Focus is visible on the .ren-checkbox-control via :focus-visible outline driven by --color-focus-ring and --ring-width."
    - "Each checkbox has an accessible name via its wrapping <label> text; never rely on placeholder or title alone."
    - "Group multiple related checkboxes inside <fieldset><legend> for a programmatic group name."
    - "Disabled state must use the native disabled attribute (not just aria-disabled) so the input is excluded from form submission and keyboard navigation."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-checkbox/ren-checkbox.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<label class="ren-checkbox">
  <input type="checkbox" name="updates">
  <span class="ren-checkbox-control"></span>
  <span>Email updates</span>
</label>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-checkbox`
- `.ren-checkbox-control`

For toggle switches with on/off semantics, see the sibling primitive `ren-switch`.

## States And Attributes

- `:active`
- `:checked`
- `:disabled`
- `:focus-visible`
- `:hover`
- `:indeterminate`

## Public Token API

- `--ren-checkbox-border`
- `--ren-checkbox-checked-bg`
- `--ren-checkbox-checked-color`
- `--ren-checkbox-duration`
- `--ren-checkbox-easing`
- `--ren-checkbox-gap`
- `--ren-checkbox-radius`
- `--ren-checkbox-size`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-checkbox/ren-checkbox.css`
- `docs/components/ren-checkbox.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Checkbox Component
   ============================================
   Custom-styled checkbox built on native
   <input type="checkbox">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <label class="ren-checkbox">
       <input type="checkbox">
       <span class="ren-checkbox-control"></span>
       <span>Accept terms</span>
     </label>

   For toggle switches, see ren-switch.
   ============================================ */

.ren-checkbox {
  display: inline-flex;
  align-items: center;
  gap: var(--ren-checkbox-gap);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  /* Ensure touch target */
  min-height: var(--touch-min);
}

/* Hide native checkbox visually but keep accessible */
.ren-checkbox > input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Custom checkbox visual */
.ren-checkbox-control {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--ren-checkbox-size);   /* 20px */
  height: var(--ren-checkbox-size);
  flex-shrink: 0;
  border: 2px solid var(--ren-checkbox-border);
  border-radius: var(--ren-checkbox-radius);
  background-color: transparent;
  transition:
    background-color var(--ren-checkbox-duration) var(--ease-enter),
    border-color var(--ren-checkbox-duration) var(--ease-enter),
    transform var(--duration-tactile) var(--ren-checkbox-easing);
}

/* Checkmark (hidden by default) */
.ren-checkbox-control::after {
  content: '';
  display: block;
  width: 0.45rem;
  height: 0.7rem;
  border: solid var(--ren-checkbox-checked-color);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform var(--ren-checkbox-duration) var(--ren-checkbox-easing);
  margin-top: -2px;
}

/* ─── States ─── */

/* Checked */
.ren-checkbox > input:checked + .ren-checkbox-control {
  background-color: var(--ren-checkbox-checked-bg);
  border-color: var(--ren-checkbox-checked-bg);
}

.ren-checkbox > input:checked + .ren-checkbox-control::after {
  transform: rotate(45deg) scale(1);
}

/* Hover */
.ren-checkbox:hover > .ren-checkbox-control {
  border-color: var(--color-accent);
}

.ren-checkbox:hover > input:checked + .ren-checkbox-control {
  background-color: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

/* Focus */
.ren-checkbox > input:focus-visible + .ren-checkbox-control {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active / Press */
.ren-checkbox:active > .ren-checkbox-control {
  transform: scale(0.9);
}

/* Disabled */
.ren-checkbox:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Indeterminate */
.ren-checkbox > input:indeterminate + .ren-checkbox-control {
  background-color: var(--ren-checkbox-checked-bg);
  border-color: var(--ren-checkbox-checked-bg);
}

.ren-checkbox > input:indeterminate + .ren-checkbox-control::after {
  border: none;
  width: 0.6rem;
  height: 2px;
  background-color: var(--ren-checkbox-checked-color);
  transform: none;
  margin: 0;
  border-radius: 1px;
}

data:
{"kind":"primitive","selectors":[".ren-checkbox",".ren-checkbox-control"],"tokens":["--body-size","--color-accent","--color-accent-hover","--color-focus-ring","--color-text","--duration-tactile","--ease-enter","--ren-checkbox-border","--ren-checkbox-checked-bg","--ren-checkbox-checked-color","--ren-checkbox-duration","--ren-checkbox-easing","--ren-checkbox-gap","--ren-checkbox-radius","--ren-checkbox-size","--ring-offset-width","--ring-width","--touch-min","--weight-regular"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-field
type: component
name: ren-field
path: components/primitives/ren-field
body:
# ren-field

Form-field wrapper that connects labels, descriptions, errors, and the
underlying control through ARIA. Carries the field's visual frame
(input chrome, error / valid borders, sizing).

Load this file after `ren-design.md` and before generating, editing, or
reviewing any `<ren-field>` UI.

## Purpose

Owns the wiring between a `<label>`, helper text (`[data-description]`),
error text (`[data-error]`), and the control inside it. The custom element
auto-generates IDs, links them via `for` / `aria-labelledby` /
`aria-describedby` / `aria-errormessage`, and toggles `aria-invalid` and
`data-invalid` from the host's validation state. Visual styling for the
control itself comes from `.ren-input` / `.ren-input-wrapper`.

## Use When

- The UI is a labeled form control: text, email, password, number, search,
  url, date, textarea, select.
- The control needs helper text and / or error text wired through ARIA.
- You want consistent field heights, border / focus ring treatment, and
  validation states across the app.
- The field belongs to a `ren-form` pattern that drives validation.

## Do Not Use When

- The control is a single button — use `ren-button`.
- The control is a checkbox / radio / switch — use `ren-checkbox` /
  `ren-radio` / `ren-toggle-group`.
- The control is a search/combobox with autocomplete — use `ren-combobox`.
- You only need a static label-value pair for display — use `<dl>` /
  `ren-card` content.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The control is a labeled form input that benefits from auto-wired ARIA."
    - "You need helper text + error text + invalid styling tied to the same control."
    - "You want a consistent input frame (height, padding, focus ring, error border)."
    - "You are inside a <ren-form> that drives validation."
  avoidWhen:
    - "The control is a checkbox / radio / switch — use those primitives directly."
    - "The control is a combobox / select with custom listbox — use ren-combobox / ren-select."
    - "You need a fully custom number stepper — use ren-number-field."
    - "You need OTP segmented inputs — use ren-otp."

canonicalImports:
  css:
    - "rends/components/primitives/ren-field/ren-field.css"
  js:
    - "rends/components/primitives/ren-field/ren-field.js"
  notes:
    - "JS is required for ARIA wiring and validation state syncing — do not skip."

requiredMarkup:
  - "<ren-field> wraps exactly one <label>, exactly one control, plus optional [data-description] and [data-error]."
  - "Error and description nodes do not need their own IDs — the component generates them."
  - "Native control attributes drive validity (required, type=email, pattern, min, max). Do not duplicate validation in JS for the same rules."
  - "Use .ren-input on inputs / textareas / selects; use .ren-input-wrapper for icon adornments."

forbiddenPatterns:
  - "Manual aria-describedby / aria-errormessage on the input — the component sets them."
  - "Reaching into the slotted control to override .ren-input internals; theme via --ren-field-* tokens."
  - "Showing error text without setting [data-invalid] / aria-invalid on the control."
  - "Hiding required-state info in placeholder text."

tokenPolicy:
  allowed:
    - "Component tokens: every --ren-field-* listed in Public Token API."
    - "Semantic tokens for any custom decoration (--color-text-muted for the description, etc.)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, etc.) in consumer code."
    - "Hardcoded hex / rgb colors in consumer overrides."

accessibility:
  required:
    - "Always provide a visible <label>; do not rely on placeholder text."
    - "Required inputs must mark required (the attribute) — visual indicator alone is not enough."
    - "Error messages must be set in [data-error] AND aria-invalid must be true on the control."
    - "Keep visible focus rings; do not remove the input outline without a replacement."
    - "Disabled fields use the disabled attribute on the control, not aria-disabled alone."
```

## Required CSS / JS Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-field/ren-field.css">
<script type="module" src="rends/components/primitives/ren-field/ren-field.js"></script>
```

## Canonical Markup

Required email field with helper + error:

```html
<ren-field>
  <label>Email</label>
  <input class="ren-input" type="email" name="email" autocomplete="email" required>
  <span data-description>We will never share your email.</span>
  <span data-error>Enter a valid email address.</span>
</ren-field>
```

Password with visible label and helper:

```html
<ren-field>
  <label>Password</label>
  <input class="ren-input" type="password" name="password" autocomplete="new-password" minlength="8" required>
  <span data-description>At least 8 characters.</span>
  <span data-error></span>
</ren-field>
```

Textarea:

```html
<ren-field>
  <label>Notes</label>
  <textarea class="ren-input" name="notes" rows="4"></textarea>
</ren-field>
```

Input with leading icon:

```html
<ren-field>
  <label>Search</label>
  <span class="ren-input-wrapper">
    <span class="ren-input-icon" aria-hidden="true">🔍</span>
    <input class="ren-input" type="search" name="q">
  </span>
</ren-field>
```

## Variants

| Class                       | Role                                |
|-----------------------------|-------------------------------------|
| `.ren-input`                | Base input chrome.                  |
| `.ren-input-sm` / `-lg`     | Size override.                      |
| `.ren-input-error`          | Manual error styling on the input.  |
| `.ren-input-success`        | Manual success styling.             |
| `.ren-input-wrapper`        | Container for adornments.           |
| `.ren-input-icon`           | Leading icon slot.                  |
| `.ren-input-icon-end`       | Trailing icon slot.                 |
| `.ren-field-label`          | Label slot styling.                 |
| `.ren-field-description`    | Helper text slot styling.           |
| `.ren-field-error`          | Error slot styling.                 |

## States

| Selector / attr        | Meaning                                              |
|------------------------|------------------------------------------------------|
| `:hover` / `:active`   | Pointer interaction.                                 |
| `:focus-visible`       | Keyboard focus on the control.                       |
| `:disabled`            | Native disabled state.                               |
| `:user-invalid`        | Native validity after interaction (CSS Lv4).         |
| `:user-valid`          | Native validity after interaction (CSS Lv4).         |
| `[aria-invalid="true"]` / `[data-invalid]` | Component-driven invalid state.  |
| `[data-valid]`         | Component-driven valid state.                        |
| `[data-required]`      | Component marks required visually.                   |
| `[data-description]`   | Slot for helper text.                                |
| `[data-error]`         | Slot for error text.                                 |

## Public Token API

- `--ren-field-bg`
- `--ren-field-border-color`
- `--ren-field-border-width`
- `--ren-field-radius`
- `--ren-field-height`
- `--ren-field-padding-x`
- `--ren-field-padding-y`
- `--ren-field-font-size`
- `--ren-field-color`
- `--ren-field-placeholder`
- `--ren-field-focus-color`
- `--ren-field-error-color`
- `--ren-field-success-color`

## Accessibility Contract

- Always provide a visible `<label>`; placeholder is not a label.
- Required inputs use the native `required` attribute; mark it visually too.
- Error text lives in `[data-error]`. The component sets `aria-invalid` on
  the control automatically when the host marks the field invalid.
- Focus rings must remain visible — do not strip outline without restoring.
- Disabled fields use `disabled` on the control. `aria-disabled` is only
  acceptable when the disabled state must remain focusable.

## Anti-Patterns

- ❌ Putting label text in `placeholder` instead of `<label>`.
- ❌ Manual `aria-describedby` / `aria-errormessage` on the input — the
  component owns these.
- ❌ Showing error text without setting `aria-invalid` on the control.
- ❌ `.ren-input { border: 2px solid #ccc; }` — theme through
  `--ren-field-border-*` tokens.

## Related Files

- `components/primitives/ren-field/ren-field.css`
- `components/primitives/ren-field/ren-field.js`
- `components/patterns/ren-form/pattern.md` (validation pattern)
- `docs/components/ren-field.html`
- `ren-design.md`
- `tokens/tokens.md`

## Test Expectations

- Run component / docs a11y coverage when markup, ARIA, or validation
  behavior changes.
- Run `npm run lint` after token / selector changes.
- Manually verify light/dark themes for focus ring and error glow.


/* ============================================
   RenDS — Field Component (Input + Label + Error)
   ============================================
   Wraps any form control with label, description,
   and error message — all properly wired with ARIA.

   Works as pure CSS classes OR as <ren-field>.

   Structure:
   .ren-field
     .ren-field-label       (label element)
     .ren-field-control     (input/select/textarea wrapper)
     .ren-field-description (helper text)
     .ren-field-error       (error message)
   ============================================ */

/* ─── Field Container ─── */
.ren-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* ─── Label ─── */
.ren-field-label {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  color: var(--color-text);
}

/* Required indicator */
.ren-field-label[data-required]::after {
  content: '*';
  color: var(--color-danger);
  font-weight: var(--weight-bold);
}

/* ─── Input / Control ─── */
.ren-input {
  display: block;
  width: 100%;
  min-height: var(--ren-field-height);
  padding: var(--ren-field-padding-y) var(--ren-field-padding-x);
  font-size: var(--ren-field-font-size);
  font-family: inherit;
  color: var(--ren-field-color);
  background-color: var(--ren-field-bg);
  border: var(--ren-field-border-width) solid var(--ren-field-border-color);
  border-radius: var(--ren-field-radius);
  transition:
    border-color var(--duration-state) var(--ease-enter),
    background-color var(--duration-state) var(--ease-enter),
    box-shadow var(--duration-state) var(--ease-enter);
}

.ren-input:hover:not(:disabled):not(:focus) {
  background-color: var(--color-input-bg-hover);
}

.ren-input:focus {
  outline: none;
  border-color: var(--ren-field-focus-color);
  box-shadow: 0 0 0 3px var(--color-input-focus-ring);
  background-color: var(--color-input-bg-hover);
}

.ren-input:disabled {
  background-color: var(--color-input-disabled-bg);
  color: var(--color-input-disabled-text);
  cursor: not-allowed;
  opacity: 0.7;
}

.ren-input::placeholder {
  color: var(--ren-field-placeholder);
  opacity: 1;
}

/* ─── Textarea ─── */
textarea.ren-input {
  field-sizing: content;  /* Auto-grow with content — no JS needed */
  min-height: calc(var(--touch-min) * 2);
  max-height: 20rem;      /* Prevent runaway growth */
  resize: vertical;
}

/* ─── Select ─── */
select.ren-input {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238E8E93' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-3) center;
  padding-inline-end: var(--space-10);
}

@supports (appearance: base-select) {
  /* ── Opt-in ── */
  select.ren-input,
  select.ren-input::picker(select) {
    appearance: base-select;
  }

  /* ── Trigger ── */
  select.ren-input {
    display: flex;
    align-items: center;
    background-image: none;
    padding-inline-end: var(--space-3);
  }

  select.ren-input::picker-icon {
    color: var(--color-text-muted);
    opacity: 0.6;
    margin-inline-start: auto;
    padding-inline-start: var(--space-3);
    transition: rotate var(--duration-tactile) var(--ease-enter);
  }

  select.ren-input:open::picker-icon {
    rotate: 180deg;
  }

  select.ren-input option::checkmark {
    display: none;
  }

  /* ── Dropdown ── */
  select.ren-input::picker(select) {
    background: var(--color-surface-overlay);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    box-shadow: 0 4px 20px rgb(0, 0, 0, 0.08),
                0 1px 3px rgb(0, 0, 0, 0.06);
    padding: var(--space-1);
    margin-block-start: var(--space-1);
    overflow: clip;
  }

  /* ── Options ── */
  select.ren-input option {
    display: flex;
    align-items: center;
    padding: var(--space-2) var(--space-3);
    border-radius: 4px;
    color: var(--color-text);
    background: transparent;
    cursor: pointer;
  }

  select.ren-input option + option {
    margin-block-start: 1px;
  }

  select.ren-input option:hover {
    background: light-dark(var(--gray-50), var(--gray-800));
  }

  select.ren-input option:focus-visible,
  select.ren-input option:active {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
    background: light-dark(var(--gray-50), var(--gray-800));
  }

  select.ren-input option:checked:not(:disabled) {
    background: light-dark(var(--blue-50), var(--blue-900));
    color: light-dark(var(--blue-700), var(--blue-200));
    font-weight: var(--weight-medium);
  }

  select.ren-input option:checked:not(:disabled)::after {
    content: "✓";
    margin-inline-start: auto;
    color: light-dark(var(--blue-600), var(--blue-300));
    font-weight: var(--weight-bold);
  }

  select.ren-input option:checked:hover {
    background: light-dark(var(--blue-100), var(--blue-800));
  }

  select.ren-input option:disabled {
    color: light-dark(var(--gray-500), var(--gray-400));
    cursor: not-allowed;
  }

  select.ren-input option[hidden] {
    display: none;
  }

  /* ── Optgroups ── */
  select.ren-input optgroup {
    padding: var(--space-1) 0;
    padding-inline-start: 0;
  }

  select.ren-input optgroup:not(:first-of-type) {
    border-block-start: 1px solid var(--color-border);
    margin-block-start: var(--space-2);
    padding-block-start: var(--space-2);
  }

  select.ren-input optgroup > legend,
  select.ren-input optgroup > label {
    padding: var(--space-1) var(--space-3);
    margin-inline-start: var(--space-3);
  }

  /* ── Open state ── */
  select.ren-input:open {
    border-color: var(--color-input-border-focus);
    box-shadow: 0 0 0 3px var(--color-input-focus-ring);
  }
}

/* ─── Input Sizes ─── */
.ren-input-sm {
  min-height: var(--size-sm);
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-sm);
  border-radius: var(--radius-sm);
}

.ren-input-lg {
  min-height: var(--size-xl);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-lg);
}

/* ─── Input with icon ─── */
.ren-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}

.ren-input-icon {
  position: absolute;
  left: var(--space-3);
  color: var(--color-text-muted);
  pointer-events: none;
  display: flex;
}

.ren-input-icon + .ren-input,
.ren-input-wrapper > .ren-input:has(+ .ren-input-icon) {
  padding-inline-start: calc(var(--space-3) + var(--icon-lg) + var(--space-2));
}

.ren-input-icon-end {
  position: absolute;
  right: var(--space-3);
  color: var(--color-text-muted);
  pointer-events: none;
  display: flex;
}

/* ─── Description ─── */
.ren-field-description {
  font-size: var(--caption-size);
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
  margin: 0;
}

/* ─── Error Message ─── */
.ren-field-error {
  font-size: var(--caption-size);
  color: var(--ren-field-error-color);
  line-height: var(--leading-normal);
  margin: 0;
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

/* Error state on input */
.ren-field[data-invalid] .ren-input,
.ren-input[aria-invalid="true"],
.ren-input-error {
  border-color: var(--ren-field-error-color);
}

.ren-field[data-invalid] .ren-input:focus,
.ren-input[aria-invalid="true"]:focus,
.ren-input-error:focus {
  box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
  border-color: var(--ren-field-error-color);
}

/* Error state on label */
.ren-field[data-invalid] .ren-field-label {
  color: var(--ren-field-error-color);
}

/* ─── Success state ─── */
.ren-field[data-valid] .ren-input,
.ren-input-success {
  border-color: var(--ren-field-success-color);
}

.ren-field[data-valid] .ren-input:focus,
.ren-input-success:focus {
  box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
}

/* ─── Modern Validation (no JS needed) ─── */
/* Only show validation state AFTER user interaction */
.ren-input:user-invalid {
  border-color: var(--ren-field-error-color);
}

.ren-input:user-invalid:focus {
  box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
  border-color: var(--ren-field-error-color);
}

.ren-input:user-valid {
  border-color: var(--ren-field-success-color);
}

.ren-input:user-valid:focus {
  box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
}

/* Show/hide error and success messages based on validation */
.ren-field:has(.ren-input:user-invalid) .ren-field-label {
  color: var(--ren-field-error-color);
}

.ren-field:has(.ren-input:user-invalid) .ren-field-error {
  display: flex;
}

.ren-field:has(.ren-input:user-valid) .ren-field-error {
  display: none;
}


/**
 * RenDS — <ren-field> Web Component
 * ===================================
 * Auto-wires ARIA relationships between label, input,
 * description, and error message.
 *
 * Light DOM — no Shadow DOM. Just enhances existing HTML.
 *
 * Usage:
 *   <ren-field>
 *     <label>Email</label>
 *     <input type="email" placeholder="name@example.com">
 *     <span data-description>We'll never share your email.</span>
 *     <span data-error>Please enter a valid email.</span>
 *   </ren-field>
 *
 * The component automatically:
 * - Generates IDs and wires aria-labelledby
 * - Wires aria-describedby to description
 * - Wires aria-errormessage to error
 * - Sets aria-invalid when error is visible
 * - Adds data-required to label when input is required
 * - Adds .ren-field, .ren-field-label, .ren-input, etc. classes
 */

import { autoId } from '../../../utils/id-generator.js';

export class RenField extends HTMLElement {
  connectedCallback() {
    this._listenerController?.abort();
    this._listenerController = new AbortController();
    const { signal } = this._listenerController;
    // Add base class
    this.classList.add('ren-field');

    // Find child elements
    const label = this.querySelector('label');
    const input = this.querySelector('input, select, textarea');
    const description = this.querySelector('[data-description]');
    const error = this.querySelector('[data-error]');

    if (!input) return;

    // ─── Add classes ───
    label?.classList.add('ren-field-label');
    input.classList.add('ren-input');
    description?.classList.add('ren-field-description');
    error?.classList.add('ren-field-error');

    // ─── Generate IDs ───
    const inputId = autoId(input, 'input');

    // Wire label → input (using for attribute)
    if (label && !label.getAttribute('for')) {
      label.setAttribute('for', inputId);
    }

    // Wire description → input (aria-describedby)
    if (description) {
      const descId = autoId(description, 'desc');
      const existing = input.getAttribute('aria-describedby');
      if (!existing?.includes(descId)) {
        input.setAttribute(
          'aria-describedby',
          existing ? `${existing} ${descId}` : descId
        );
      }
    }

    // Wire error → input (aria-errormessage)
    if (error) {
      const errorId = autoId(error, 'error');
      input.setAttribute('aria-errormessage', errorId);
      error.setAttribute('role', 'alert');
      error.setAttribute('aria-live', 'polite');

      // Initially hide error if not invalid
      if (!this.hasAttribute('data-invalid')) {
        error.hidden = true;
      }
    }

    // ─── Required indicator ───
    if (input.hasAttribute('required') && label) {
      label.setAttribute('data-required', '');
    }

    // ─── Observe validity changes ───
    this._input = input;
    this._error = error;

    // Listen for custom validation
    input.addEventListener('invalid', () => this._setInvalid(true), { signal });
    input.addEventListener('input', () => {
      if (this.hasAttribute('data-invalid') && input.validity.valid) {
        this._setInvalid(false);
      }
    }, { signal });
  }

  disconnectedCallback() {
    this._listenerController?.abort();
    this._listenerController = null;
  }

  _setInvalid(invalid) {
    if (invalid) {
      this.setAttribute('data-invalid', '');
      this._input?.setAttribute('aria-invalid', 'true');
      if (this._error) this._error.hidden = false;
    } else {
      this.removeAttribute('data-invalid');
      this._input?.removeAttribute('aria-invalid');
      if (this._error) this._error.hidden = true;
    }
  }

  // ─── Public API ───

  /** Set field to invalid state with a message */
  setError(message) {
    if (this._error && message) {
      this._error.textContent = message;
    }
    this._setInvalid(true);
  }

  /** Clear error state */
  clearError() {
    this._setInvalid(false);
  }

  /** Check if field is currently invalid */
  get invalid() {
    return this.hasAttribute('data-invalid');
  }

  set invalid(val) {
    this._setInvalid(val);
  }
}

if (!customElements.get('ren-field')) {
  customElements.define('ren-field', RenField);
}

data:
{"kind":"primitive","selectors":[".ren-field",".ren-field-control",".ren-field-description",".ren-field-error",".ren-field-label",".ren-input",".ren-input-error",".ren-input-icon",".ren-input-icon-end",".ren-input-lg",".ren-input-sm",".ren-input-success",".ren-input-wrapper"],"tokens":["--blue-100","--blue-200","--blue-300","--blue-50","--blue-600","--blue-700","--blue-800","--blue-900","--caption-size","--color-accent","--color-border","--color-danger","--color-input-bg-hover","--color-input-border-focus","--color-input-disabled-bg","--color-input-disabled-text","--color-input-focus-ring","--color-surface-overlay","--color-text","--color-text-muted","--duration-state","--duration-tactile","--ease-enter","--gray-400","--gray-50","--gray-500","--gray-800","--icon-lg","--label-size","--label-weight","--leading-normal","--radius-lg","--radius-sm","--ren-field-bg","--ren-field-border-color","--ren-field-border-width","--ren-field-color","--ren-field-error-color","--ren-field-focus-color","--ren-field-font-size","--ren-field-height","--ren-field-padding-x","--ren-field-padding-y","--ren-field-placeholder","--ren-field-radius","--ren-field-success-color","--size-sm","--size-xl","--space-1","--space-10","--space-2","--space-3","--space-4","--text-lg","--text-sm","--touch-min","--weight-bold","--weight-medium"],"hasScript":true,"hasDocsPage":true}


id: component:primitive:ren-icon
type: component
name: ren-icon
path: components/primitives/ren-icon
body:
# ren-icon Component Contract

Icon sizing, color, and motion utility for SVG icons.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-icon` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-icon` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Icon primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a consistent sizing wrapper (xs/sm/md/lg/xl/2xl) around an inline SVG."
    - "You need an icon that inherits the parent text color via currentColor."
    - "You need a semantic color tint (primary, success, warning, danger, muted) on a glyph."
    - "You need a spinning loading glyph that respects prefers-reduced-motion."
    - "You need an icon slot inside ren-button, ren-link, ren-badge, or other primitives."
  avoidWhen:
    - "The graphic is decorative and inline — a raw <svg aria-hidden=\"true\"> is enough."
    - "The element is a clickable control — wrap the icon inside ren-button (ren-btn-icon) instead."
    - "You need a multi-color illustration or logo — use a plain <svg> with its own viewport."

canonicalImports:
  css:
    - "rends/components/primitives/ren-icon/ren-icon.css"
  notes:
    - "CSS-only primitive; no JavaScript file exists for ren-icon."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Always wrap a real <svg> (or icon-font glyph) inside <span class=\"ren-icon\"> — never style the SVG directly with .ren-icon."
  - "Decorative icons inside text or buttons must set aria-hidden=\"true\" on the SVG (the wrapper has no role)."
  - "Standalone meaningful icons must have an accessible name on the parent control (aria-label) or visually-hidden text — the .ren-icon wrapper itself does not carry a name."
  - "Pick exactly one size variant (.ren-icon-xs through .ren-icon-2xl); the base .ren-icon defaults to md (20px)."
  - ".ren-icon-spin must be paired with role=\"status\" or aria-busy on a parent for screen reader users."

forbiddenPatterns:
  - "Setting width/height inline on <svg> instead of using the .ren-icon-* size variants."
  - "Hardcoding fill=\"#...\" on the inner SVG — drive color via currentColor and the .ren-icon-* color variants."
  - "Using .ren-icon-danger to convey state without an accompanying text label (state by color alone)."
  - "Animating the spin with a custom CSS animation instead of .ren-icon-spin (loses reduced-motion guard)."
  - "Using .ren-icon as a clickable target — wrap with a real <button> or use ren-button instead."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-icon-color, --ren-icon-fill, --ren-icon-size."
    - "Semantic tokens consumed internally: --color-accent, --color-success, --color-warning, --color-danger, --color-text-secondary."
    - "Motion tokens: --duration-loop, --ease-loop (for .ren-icon-spin)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex or named colors on the SVG or wrapper."
    - "Raw animation durations; rely on --duration-loop / --ease-loop via .ren-icon-spin."

accessibility:
  required:
    - "Decorative icons set aria-hidden=\"true\" on the SVG element."
    - "Meaningful icons get their accessible name from the surrounding control (button aria-label, link text, etc.) — never from the .ren-icon wrapper alone."
    - "Color variants (.ren-icon-success, .ren-icon-danger) must be paired with text or aria-label so meaning is not color-only."
    - ".ren-icon-spin animation honors prefers-reduced-motion: reduce automatically."
    - "Do not turn .ren-icon into an interactive target; it has no focus styles or hit-area guarantees."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-icon/ren-icon.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<span class="ren-icon" aria-hidden="true">
  <svg viewBox="0 0 24 24" focusable="false"><path d="M5 12h14"></path></svg>
</span>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-icon`
- `.ren-icon-2xl`
- `.ren-icon-danger`
- `.ren-icon-lg`
- `.ren-icon-md`
- `.ren-icon-muted`
- `.ren-icon-primary`
- `.ren-icon-sm`
- `.ren-icon-spin`
- `.ren-icon-success`
- `.ren-icon-warning`
- `.ren-icon-xl`
- `.ren-icon-xs`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-icon-color`
- `--ren-icon-fill`
- `--ren-icon-size`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-icon/ren-icon.css`
- `docs/components/ren-icon.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Icon Component
   ============================================
   Icon sizing, coloring, and animation utilities.
   Works with any SVG or icon font.

   The base class provides inline layout,
   color inheritance, and shrink-to-fit.

   Usage:
     <span class="ren-icon ren-icon-md">
       <svg>...</svg>
     </span>

     <button>
       <span class="ren-icon ren-icon-lg ren-icon-success">
         <svg>...</svg>
       </span>
       Export
     </button>

     <span class="ren-icon ren-icon-spin ren-icon-xl">
       <svg>...</svg>
     </span>
   ============================================ */

/* ═══════════════════════════════
   ICON BASE
   ═══════════════════════════════ */

.ren-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--ren-icon-size);   /* 20px — default md */
  height: var(--ren-icon-size);
  color: var(--ren-icon-color);
  fill: var(--ren-icon-fill);
}

.ren-icon > svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* ─── Sizes ─── */

.ren-icon-xs {
  width: 0.75rem;   /* 12px */
  height: 0.75rem;
}

.ren-icon-sm {
  width: 1rem;      /* 16px */
  height: 1rem;
}

.ren-icon-md {
  width: 1.25rem;   /* 20px */
  height: 1.25rem;
}

.ren-icon-lg {
  width: 1.5rem;    /* 24px */
  height: 1.5rem;
}

.ren-icon-xl {
  width: 2rem;      /* 32px */
  height: 2rem;
}

.ren-icon-2xl {
  width: 3rem;      /* 48px */
  height: 3rem;
}

/* ─── Color Variants ─── */

.ren-icon-primary {
  color: var(--color-accent);
}

.ren-icon-success {
  color: var(--color-success);
}

.ren-icon-warning {
  color: var(--color-warning);
}

.ren-icon-danger {
  color: var(--color-danger);
}

.ren-icon-muted {
  color: var(--color-text-secondary);
}

/* ─── Animation ─── */

.ren-icon-spin {
  animation: ren-icon-spin var(--duration-loop) var(--ease-loop) infinite;
}

@keyframes ren-icon-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .ren-icon-spin {
    animation: none;
  }
}

data:
{"kind":"primitive","selectors":[".ren-icon",".ren-icon-2xl",".ren-icon-danger",".ren-icon-lg",".ren-icon-md",".ren-icon-muted",".ren-icon-primary",".ren-icon-sm",".ren-icon-spin",".ren-icon-success",".ren-icon-warning",".ren-icon-xl",".ren-icon-xs"],"tokens":["--color-accent","--color-danger","--color-success","--color-text-secondary","--color-warning","--duration-loop","--ease-loop","--ren-icon-color","--ren-icon-fill","--ren-icon-size"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-kbd
type: component
name: ren-kbd
path: components/primitives/ren-kbd
body:
# ren-kbd Component Contract

Keyboard key / shortcut primitive for documentation and command hints.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-kbd` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-kbd` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Kbd primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need to display a single keyboard key (⌘, K, Esc, Enter) in documentation or command hints."
    - "You need to render a keyboard combo (⌘ + K) where each key is a chip with native <kbd> semantics."
    - "You want monospaced, chip-styled tokens with a sunken background and bottom-shadow border."
    - "You need an inline label inside menu items, tooltips, or docs showing the shortcut for an action."
  avoidWhen:
    - "The element is a status pill or category label — use ren-badge or ren-tag."
    - "The element is a clickable shortcut trigger — use ren-button and place a <kbd> inside its label."
    - "You need block-level code samples — use <pre><code> or a code-block component, not <kbd>."

canonicalImports:
  css:
    - "rends/components/primitives/ren-kbd/ren-kbd.css"
  notes:
    - "CSS-only primitive; no JavaScript file exists for ren-kbd."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Always use a real <kbd> element with class=\"ren-kbd\"; never style a <span> or <code> as a kbd chip."
  - "Each key in a combo is its own <kbd class=\"ren-kbd\">; the joiner (+) lives between them as plain text."
  - "Inside running prose, place the <kbd> inline (it is display: inline-flex with no forced line break)."
  - "Use white-space: nowrap behavior built in — do not wrap the key glyph across lines via extra markup."

forbiddenPatterns:
  - "<span class=\"ren-kbd\"> or <code class=\"ren-kbd\"> — must be a real <kbd> for assistive tech."
  - "Hardcoded background or border colors that bypass --ren-kbd-bg / --ren-kbd-border."
  - "Adding click handlers on .ren-kbd to fire a shortcut — wrap the kbd inside a ren-button instead."
  - "Using <kbd class=\"ren-kbd\"> for a multi-character command name (e.g., \"git status\") — use <code> for that."
  - "Stacking multiple keys inside a single <kbd> (e.g., <kbd>⌘K</kbd>) — split them into one <kbd> per key."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-kbd-bg, --ren-kbd-border, --ren-kbd-color, --ren-kbd-font-family, --ren-kbd-font-size, --ren-kbd-padding, --ren-kbd-radius."
    - "Semantic tokens consumed internally: --color-text, --color-surface-sunken, --color-border-strong."
    - "Type tokens: --font-mono, --caption-size, --weight-medium."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Custom font-family overrides that bypass --ren-kbd-font-family / --font-mono."

accessibility:
  required:
    - "Use the native <kbd> element so screen readers announce \"keyboard input\" semantics."
    - "Keep contrast on the chip background ≥ WCAG AA against surrounding text (driven by --color-surface-sunken / --color-text)."
    - "Do not rely on the chip styling alone to convey shortcut meaning; pair with descriptive text (e.g., \"Press <kbd>⌘</kbd> + <kbd>K</kbd> to open search\")."
    - "Do not make .ren-kbd interactive — it has no focus ring or hit target."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-kbd/ren-kbd.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<kbd class="ren-kbd">⌘</kbd>+<kbd class="ren-kbd">K</kbd>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-kbd`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-kbd-bg`
- `--ren-kbd-border`
- `--ren-kbd-color`
- `--ren-kbd-font-family`
- `--ren-kbd-font-size`
- `--ren-kbd-padding`
- `--ren-kbd-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-kbd/ren-kbd.css`
- `docs/components/ren-kbd.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Kbd (keyboard shortcut)
   ============================================
   Inline chip showing a keyboard key or shortcut
   combo. Uses native <kbd> semantics.

   Usage:
     <kbd class="ren-kbd">⌘</kbd>
     <kbd class="ren-kbd">K</kbd>

   Combo:
     <kbd class="ren-kbd">⌘</kbd> + <kbd class="ren-kbd">K</kbd>
   ============================================ */

.ren-kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.5em;
  padding: var(--ren-kbd-padding);
  font-family: var(--ren-kbd-font-family);
  font-size: var(--ren-kbd-font-size);
  font-weight: var(--weight-medium);
  line-height: 1.4;
  color: var(--ren-kbd-color);
  background-color: var(--ren-kbd-bg);
  border: var(--stroke-1) solid var(--ren-kbd-border);
  border-bottom-width: 2px;
  border-radius: var(--ren-kbd-radius);
  white-space: nowrap;
}

data:
{"kind":"primitive","selectors":[".ren-kbd"],"tokens":["--ren-kbd-bg","--ren-kbd-border","--ren-kbd-color","--ren-kbd-font-family","--ren-kbd-font-size","--ren-kbd-padding","--ren-kbd-radius","--stroke-1","--weight-medium"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-link
type: component
name: ren-link
path: components/primitives/ren-link
body:
# ren-link Component Contract

Accessible link styling for inline, muted, nav, external, and skip links.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-link` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-link` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Link primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The element navigates to a URL, anchor, or routed page (i.e., it has a real href)."
    - "You need inline text links inside paragraphs styled with underline + hover."
    - "You need a navigation link with a 44px touch target (.ren-link-nav) plus active-route styling via aria-current=\"page\" / [data-active]."
    - "You need an external-link affordance with a trailing ↗ marker (.ren-link-external)."
    - "You need a skip-to-content link that becomes visible only on focus (.ren-link-skip)."
    - "You need a muted or unstyled (inherits color) anchor (.ren-link-muted, .ren-link-plain)."
  avoidWhen:
    - "The element triggers an imperative action (submit, open dialog, delete) — use ren-button."
    - "The element is a tab, menuitem, or pagination control — use the corresponding component."
    - "The element is a stateful toggle — use a switch, checkbox, or button with aria-pressed."

canonicalImports:
  css:
    - "rends/components/primitives/ren-link/ren-link.css"
  notes:
    - "CSS-only primitive; no JavaScript file exists for ren-link."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Always render an actual <a href=\"...\"> element. Never style a <button> or <span> as a link."
  - "External links should set rel=\"noopener noreferrer\" and target=\"_blank\" alongside .ren-link-external when opening in a new tab."
  - "Nav links representing the current page set aria-current=\"page\" (or data-active for non-routing states) on the <a>."
  - "Skip links (.ren-link-skip) must be the first focusable element in <body> and reference a real target id (e.g., href=\"#main\")."
  - "The trailing ↗ on .ren-link-external is decorative content — never use it as the only signal that a link opens externally; provide aria-label or visually-hidden text for screen readers if needed."

forbiddenPatterns:
  - "<button class=\"ren-link\"> — use a real <a> with an href."
  - "<a class=\"ren-link\" onclick=\"...\"> without an href — non-focusable, breaks keyboard activation."
  - "Removing the focus ring (outline: none) without restoring a visible :focus-visible style."
  - "Hardcoded color overrides like style=\"color:#0066cc\" — use --ren-link-color / --color-text-link."
  - "Using .ren-link-nav for inline prose links; it expects flex layout and a 44px hit area."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-link-color, --ren-link-font-weight, --ren-link-hover-color, --ren-link-underline."
    - "Semantic tokens: --color-text-link, --color-text-link-hover, --color-text, --color-text-muted, --color-accent, --color-accent-subtle, --color-on-accent, --color-focus-ring, --color-fill."
    - "Layout / motion tokens: --space-*, --radius-sm, --radius-md, --touch-min, --ring-width, --duration-tactile, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Custom underline colors via text-decoration shorthand that bypass --ren-link-underline."

accessibility:
  required:
    - "Real <a href> semantics; never simulate a link with a div + onclick."
    - "Visible :focus-visible ring driven by --color-focus-ring and --ring-width."
    - ".ren-link-nav guarantees a 44px touch target via min-height: var(--touch-min)."
    - "The current navigation item must set aria-current=\"page\" so screen readers announce route position."
    - "Skip links must be reachable as the first Tab stop and become visually focused via .ren-link-skip:focus."
    - "External-link ↗ glyph is a visual hint only; do not rely on it for assistive-tech announcement."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-link/ren-link.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<a class="ren-link" href="/docs">Read the documentation</a>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-link`
- `.ren-link-external`
- `.ren-link-muted`
- `.ren-link-nav`
- `.ren-link-plain`
- `.ren-link-skip`

## States And Attributes

- `[aria-current]`
- `[data-active]`
- `:active`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-link-color`
- `--ren-link-font-weight`
- `--ren-link-hover-color`
- `--ren-link-underline`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-link/ren-link.css`
- `docs/components/ren-link.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Link Component
   ============================================
   Styled anchor with variants.
   CSS-only. No JS needed.

   44px touch target on mobile via min-height.
   Accessible focus ring on keyboard navigation.

   Usage:
     <a href="/page" class="ren-link">Standard link</a>
     <a href="/page" class="ren-link ren-link-muted">Subtle link</a>
     <a href="/page" class="ren-link ren-link-external">External ↗</a>
   ============================================ */

/* ─── Base Link ─── */
.ren-link {
  display: inline;
  color: var(--ren-link-color);
  font-weight: var(--ren-link-font-weight, inherit);
  background: none;
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--color-text-link), transparent 60%);
  text-underline-offset: 0.15em;
  text-decoration-thickness: var(--ren-link-underline);
  cursor: pointer;
  transition:
    color var(--duration-tactile) var(--ease-enter),
    text-decoration-color var(--duration-tactile) var(--ease-enter);
  /* Inline elements: ensure touch target via padding */
  -webkit-tap-highlight-color: transparent;
}

.ren-link:hover {
  color: var(--ren-link-hover-color);
  background: none;
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--color-text-link-hover), transparent 30%);
  text-decoration-thickness: calc(var(--ren-link-underline) * 2);
}

.ren-link:active {
  opacity: 0.8;
}

.ren-link:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ─── Muted / Subtle ─── */
.ren-link-muted {
  color: var(--color-text-muted);
  text-decoration: none;
}

.ren-link-muted:hover {
  color: var(--color-text);
  text-decoration: underline;
}

/* ─── Unstyled (inherits parent color) ─── */
.ren-link-plain {
  color: inherit;
  text-decoration: none;
}

.ren-link-plain:hover {
  text-decoration: underline;
}

/* ─── External link indicator ─── */
.ren-link-external::after {
  content: ' ↗';
  font-size: 0.85em;
  text-decoration: none;
  display: inline;
}

/* ─── Nav link (block-level, with padding for touch target) ─── */
.ren-link-nav {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--touch-min);
  padding: var(--space-1) var(--space-2);
  color: var(--color-text);
  text-decoration: none;
  border-radius: var(--radius-md);
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    color var(--duration-tactile) var(--ease-enter);
}

.ren-link-nav:hover {
  background-color: var(--color-fill);
  color: var(--color-text);
  text-decoration: none;
}

.ren-link-nav[aria-current="page"],
.ren-link-nav[data-active] {
  background-color: var(--color-accent-subtle);
  color: var(--color-accent);
  font-weight: var(--weight-medium);
}

.ren-link-nav:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: -2px;
}

/* ─── Skip link (a11y) ─── */
.ren-link-skip {
  position: absolute;
  top: -100%;
  left: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background-color: var(--color-accent);
  color: var(--color-on-accent);
  border-radius: var(--radius-md);
  font-weight: var(--weight-semibold);
  text-decoration: none;
  z-index: 9999;
  transition: top var(--duration-tactile) var(--ease-enter);
}

.ren-link-skip:focus {
  top: var(--space-3);
}

data:
{"kind":"primitive","selectors":[".ren-link",".ren-link-external",".ren-link-muted",".ren-link-nav",".ren-link-plain",".ren-link-skip"],"tokens":["--color-accent","--color-accent-subtle","--color-fill","--color-focus-ring","--color-on-accent","--color-text","--color-text-link","--color-text-link-hover","--color-text-muted","--duration-tactile","--ease-enter","--radius-md","--radius-sm","--ren-link-color","--ren-link-font-weight","--ren-link-hover-color","--ren-link-underline","--ring-width","--space-1","--space-2","--space-3","--space-4","--touch-min","--weight-medium","--weight-semibold"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-pagination
type: component
name: ren-pagination
path: components/primitives/ren-pagination
body:
# ren-pagination Component Contract

Pagination navigation for page ranges and previous/next movement.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-pagination` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-pagination` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Pagination primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "A list, table, or grid is broken into discrete numbered pages with a known total count."
    - "You need prev / next + numbered items with aria-current=\"page\" on the active page."
    - "You need a minimal prev / info / next layout (.ren-pagination-simple) when total pages is unknown."
    - "You need a compact variant for dense tables (.ren-pagination-compact)."
    - "You need a centered standalone block under content (.ren-pagination-centered)."
    - "You need an ellipsis spacer between page ranges (.ren-pagination-ellipsis)."
  avoidWhen:
    - "The feed is infinite-scroll or load-more — use a button + scroll observer, not pagination."
    - "The data is paged by cursor with no concept of page numbers — show only prev/next."
    - "You need step navigation inside a wizard — use a stepper component."
    - "The navigation is between sibling sections of a page — use tabs or anchor links."

canonicalImports:
  css:
    - "rends/components/primitives/ren-pagination/ren-pagination.css"
  notes:
    - "CSS-only primitive; no JavaScript file exists for ren-pagination."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Wrap the control in a real <nav aria-label=\"Pagination\"> with class=\"ren-pagination\"."
  - "Each page link is an <a href=\"...\"> with class=\"ren-pagination-item\"; mark the active page with aria-current=\"page\" (it auto-disables pointer-events)."
  - "Prev / next are <a class=\"ren-pagination-prev\"> / <a class=\"ren-pagination-next\"> with aria-label describing direction when the content is a glyph (e.g., aria-label=\"Previous page\")."
  - "Use <span class=\"ren-pagination-ellipsis\" aria-hidden=\"true\">…</span> for visual gaps — it must not be focusable."
  - "Disabled prev/next use aria-disabled=\"true\" (preferred for <a>) or disabled (for <button>)."

forbiddenPatterns:
  - "<div role=\"navigation\"> or <ul> instead of <nav> for the wrapper."
  - "Plain <span> or <button> styled as a pagination item — use <a class=\"ren-pagination-item\">."
  - "Marking the active page with only a CSS class — must include aria-current=\"page\" for screen readers."
  - "Hiding disabled prev/next via display: none — keep them in DOM with aria-disabled so layout stays stable."
  - "Hardcoded color overrides on the active item that bypass --color-accent / --color-on-accent."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-pagination-active-bg, --ren-pagination-active-color, --ren-pagination-bg, --ren-pagination-font-size, --ren-pagination-gap, --ren-pagination-hover-bg, --ren-pagination-radius, --ren-pagination-size."
    - "Semantic tokens consumed internally: --color-text, --color-text-muted, --color-accent, --color-on-accent, --color-fill, --color-fill-hover, --color-disabled-text, --color-focus-ring."
    - "Layout tokens: --touch-min, --size-sm, --space-1, --space-2, --radius-md, --stroke-1, --ring-width, --ring-offset-width, --duration-tactile, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Inline width/height styling that breaks min 44px touch target on default variant."

accessibility:
  required:
    - "Wrapper is a real <nav> landmark with aria-label=\"Pagination\" (or equivalent)."
    - "Active page uses aria-current=\"page\"; do not duplicate it on a separate <span>."
    - "Default and simple variants guarantee min 44px touch targets (--touch-min); compact variant is for non-touch contexts only."
    - "Visible :focus-visible ring driven by --color-focus-ring + --ring-width / --ring-offset-width."
    - "Glyph-only prev/next provide an aria-label so the direction is announced."
    - "Disabled prev/next set aria-disabled=\"true\" AND pointer-events: none (handled by CSS); do not rely on visual dimming alone."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-pagination/ren-pagination.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<nav class="ren-pagination" aria-label="Pagination">
  <a class="ren-pagination-prev" href="?page=1" aria-label="Previous page">‹</a>
  <a class="ren-pagination-item" href="?page=1">1</a>
  <a class="ren-pagination-item" href="?page=2" aria-current="page">2</a>
  <a class="ren-pagination-next" href="?page=3" aria-label="Next page">›</a>
</nav>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-pagination`
- `.ren-pagination-centered`
- `.ren-pagination-compact`
- `.ren-pagination-ellipsis`
- `.ren-pagination-info`
- `.ren-pagination-item`
- `.ren-pagination-next`
- `.ren-pagination-prev`
- `.ren-pagination-simple`

## States And Attributes

- `[aria-current]`
- `[aria-disabled]`
- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-pagination-active-bg`
- `--ren-pagination-active-color`
- `--ren-pagination-bg`
- `--ren-pagination-font-size`
- `--ren-pagination-gap`
- `--ren-pagination-hover-bg`
- `--ren-pagination-radius`
- `--ren-pagination-size`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-pagination/ren-pagination.css`
- `docs/components/ren-pagination.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Pagination Component
   ============================================
   Page navigation for lists/tables.
   CSS-only. No JS needed for styling.

   Semantic <nav> + links for accessibility.
   44px touch targets on all interactive elements.

   Usage (simple):
     <nav aria-label="Pagination" class="ren-pagination">
       <a href="?p=1" class="ren-pagination-prev" aria-label="Previous page">&lsaquo;</a>
       <a href="?p=1" class="ren-pagination-item">1</a>
       <a href="?p=2" class="ren-pagination-item" aria-current="page">2</a>
       <a href="?p=3" class="ren-pagination-item">3</a>
       <a href="?p=3" class="ren-pagination-next" aria-label="Next page">&rsaquo;</a>
     </nav>

   Usage (minimal — just prev/next):
     <nav aria-label="Pagination" class="ren-pagination ren-pagination-simple">
       <a href="?p=1" class="ren-pagination-prev">Previous</a>
       <span class="ren-pagination-info">Page 2 of 10</span>
       <a href="?p=3" class="ren-pagination-next">Next</a>
     </nav>
   ============================================ */

.ren-pagination {
  display: flex;
  align-items: center;
  gap: var(--ren-pagination-gap);
}

/* ─── Page items ─── */
.ren-pagination-item,
.ren-pagination-prev,
.ren-pagination-next {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--ren-pagination-size);
  min-height: var(--ren-pagination-size);
  padding: var(--space-1) var(--space-2);
  font-size: var(--ren-pagination-font-size);
  font-weight: var(--weight-medium);
  color: var(--color-text);
  text-decoration: none;
  border-radius: var(--ren-pagination-radius);
  border: var(--stroke-1) solid transparent;
  background-color: var(--ren-pagination-bg);
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    border-color var(--duration-tactile) var(--ease-enter);
  user-select: none;
}

.ren-pagination-item:hover,
.ren-pagination-prev:hover,
.ren-pagination-next:hover {
  background-color: var(--ren-pagination-hover-bg);
}

.ren-pagination-item:active,
.ren-pagination-prev:active,
.ren-pagination-next:active {
  background-color: var(--color-fill-hover);
}

.ren-pagination-item:focus-visible,
.ren-pagination-prev:focus-visible,
.ren-pagination-next:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Current page */
.ren-pagination-item[aria-current="page"] {
  background-color: var(--ren-pagination-active-bg);
  color: var(--ren-pagination-active-color);
  pointer-events: none;
}

/* Disabled prev/next */
.ren-pagination-prev[aria-disabled="true"],
.ren-pagination-next[aria-disabled="true"],
.ren-pagination-prev:disabled,
.ren-pagination-next:disabled {
  color: var(--color-disabled-text);
  pointer-events: none;
  opacity: 0.5;
}

/* Ellipsis */
.ren-pagination-ellipsis {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  user-select: none;
}

/* ─── Simple variant (prev + info + next) ─── */
.ren-pagination-simple {
  gap: var(--space-2);
}

.ren-pagination-info {
  font-size: var(--caption-size, var(--text-sm));
  color: var(--color-text-muted);
  white-space: nowrap;
  padding: 0 var(--space-2);
}

/* ─── Compact variant (smaller touch targets) ─── */
.ren-pagination-compact .ren-pagination-item,
.ren-pagination-compact .ren-pagination-prev,
.ren-pagination-compact .ren-pagination-next {
  min-width: var(--size-sm);
  min-height: var(--size-sm);
  padding: var(--space-1);
  font-size: var(--caption-size, var(--text-sm));
}

/* ─── Centered (for standalone pagination) ─── */
.ren-pagination-centered {
  justify-content: center;
}

data:
{"kind":"primitive","selectors":[".ren-pagination",".ren-pagination-centered",".ren-pagination-compact",".ren-pagination-ellipsis",".ren-pagination-info",".ren-pagination-item",".ren-pagination-next",".ren-pagination-prev",".ren-pagination-simple"],"tokens":["--caption-size","--color-disabled-text","--color-fill-hover","--color-focus-ring","--color-text","--color-text-muted","--duration-tactile","--ease-enter","--ren-pagination-active-bg","--ren-pagination-active-color","--ren-pagination-bg","--ren-pagination-font-size","--ren-pagination-gap","--ren-pagination-hover-bg","--ren-pagination-radius","--ren-pagination-size","--ring-offset-width","--ring-width","--size-sm","--space-1","--space-2","--stroke-1","--text-sm","--touch-min","--weight-medium"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-progress
type: component
name: ren-progress
path: components/primitives/ren-progress
body:
# ren-progress Component Contract

Progress and meter styling for determinate and indeterminate completion states.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-progress` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-progress` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Progress primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a determinate horizontal bar showing percent completion of a known task (.ren-progress + .ren-progress-bar with inline width)."
    - "You need an indeterminate loading bar with sliding animation (.ren-progress-indeterminate)."
    - "You need a semantic-colored progress state (success / warning / danger / info)."
    - "You need a meter (current value within a min/max range, not progress) — use <meter class=\"ren-meter\">."
    - "You need optional caption rows above (.ren-progress-label) and below (.ren-progress-value) the bar."
    - "You need multiple bar heights: sm (4px), md (8px default), lg (12px), xl (16px)."
  avoidWhen:
    - "You need a circular spinner — use .ren-icon-spin on a loading glyph."
    - "You need a busy state on a button — use ren-button with data-loading / aria-busy."
    - "You need a step indicator with discrete milestones — use a stepper component."
    - "You need a skeleton placeholder while content loads — use ren-skeleton."

canonicalImports:
  css:
    - "rends/components/primitives/ren-progress/ren-progress.css"
  notes:
    - "CSS-only primitive; no JavaScript file exists for ren-progress."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Determinate progress: <div class=\"ren-progress\" role=\"progressbar\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"<pct>\"><div class=\"ren-progress-bar\" style=\"width: <pct>%\"></div></div>."
  - "Indeterminate progress: add class .ren-progress-indeterminate to the outer and omit aria-valuenow (set aria-valuetext or aria-busy=\"true\" instead)."
  - "Native semantics alternative: <progress class=\"ren-progress\" max=\"100\" value=\"<pct>\"></progress> when no custom inner bar is needed."
  - "Meter: <meter class=\"ren-meter\" min=\"0\" max=\"10\" value=\"<n>\"> — for a current value in a range (battery, score), not for progress over time."
  - "Provide an accessible label via aria-label, aria-labelledby, or a sibling .ren-progress-label."

forbiddenPatterns:
  - "<div class=\"ren-progress\"> without role=\"progressbar\" + aria-value* (when not using native <progress>)."
  - "Animating bar width with a custom transition that bypasses --duration-state / --ease-enter."
  - "Using .ren-progress-danger to convey state by color alone — pair with a text label."
  - "Hardcoded background colors on .ren-progress-bar that bypass --color-accent / semantic variants."
  - "Using <meter> for a progress-over-time scenario or <progress> for a current-value-in-range scenario."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-progress-bg, --ren-progress-duration, --ren-progress-easing, --ren-progress-fill, --ren-progress-height, --ren-progress-radius."
    - "Semantic tokens consumed internally: --color-fill, --color-accent, --color-success, --color-warning, --color-danger, --color-info, --color-text, --color-text-secondary."
    - "Layout / type tokens: --radius-full, --label-size, --weight-medium, --weight-regular, --space-1, --duration-state, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Raw animation durations; rely on --ren-progress-duration / --duration-state."

accessibility:
  required:
    - "Determinate bars expose role=\"progressbar\" + aria-valuemin / aria-valuemax / aria-valuenow (or use native <progress>)."
    - "Indeterminate bars omit aria-valuenow and set aria-busy=\"true\" or aria-valuetext=\"Loading\" so AT can announce status."
    - "Every progress bar has an accessible name (aria-label, aria-labelledby, or a .ren-progress-label paired by id)."
    - "Color-coded variants (success / warning / danger / info) must be paired with a text label or .ren-progress-value — never color-only."
    - "Indeterminate animation auto-honors prefers-reduced-motion: reduce (slide is suppressed, bar holds at 50%)."
    - "Width transition is suppressed under reduced motion so value updates do not animate."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-progress/ren-progress.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<div class="ren-progress" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="65">
  <div class="ren-progress-bar" style="width: 65%"></div>
</div>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-meter`
- `.ren-meter-lg`
- `.ren-meter-sm`
- `.ren-meter-xl`
- `.ren-progress`
- `.ren-progress-bar`
- `.ren-progress-danger`
- `.ren-progress-indeterminate`
- `.ren-progress-info`
- `.ren-progress-label`
- `.ren-progress-lg`
- `.ren-progress-sm`
- `.ren-progress-success`
- `.ren-progress-value`
- `.ren-progress-warning`
- `.ren-progress-xl`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-progress-bg`
- `--ren-progress-duration`
- `--ren-progress-easing`
- `--ren-progress-fill`
- `--ren-progress-height`
- `--ren-progress-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-progress/ren-progress.css`
- `docs/components/ren-progress.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Progress Bar & Meter Components
   ============================================
   Progress indicators and meter displays.
   CSS-only, works with <progress> and <meter>.

   Supports multiple sizes, colors, and states.
   Includes indeterminate animations.

   Usage:
     <div class="ren-progress">
       <div class="ren-progress-bar" style="width: 65%"></div>
     </div>

     <div class="ren-progress ren-progress-lg ren-progress-success">
       <div class="ren-progress-bar" style="width: 85%"></div>
     </div>

     <meter class="ren-meter" value="6" min="0" max="10"></meter>
   ============================================ */

/* ═══════════════════════════════
   PROGRESS BAR
   ═══════════════════════════════ */

.ren-progress {
  display: flex;
  width: 100%;
  height: var(--ren-progress-height);   /* default / md */
  background-color: var(--ren-progress-bg);
  border-radius: var(--ren-progress-radius);
  overflow: hidden;
}

.ren-progress-bar {
  height: 100%;
  width: 0%;
  background-color: var(--ren-progress-fill);
  border-radius: var(--ren-progress-radius);
  transition: width var(--ren-progress-duration) var(--ren-progress-easing);
}

/* ─── Sizes ─── */

.ren-progress-sm {
  height: 0.25rem;
}

.ren-progress-lg {
  height: 0.75rem;
}

.ren-progress-xl {
  height: 1rem;
}

/* ─── Color Variants ─── */

.ren-progress-success .ren-progress-bar {
  background-color: var(--color-success);
}

.ren-progress-warning .ren-progress-bar {
  background-color: var(--color-warning);
}

.ren-progress-danger .ren-progress-bar {
  background-color: var(--color-danger);
}

.ren-progress-info .ren-progress-bar {
  background-color: var(--color-info);
}

/* ─── Indeterminate Animation ─── */

.ren-progress-indeterminate .ren-progress-bar {
  width: 30% !important;
  animation: ren-progress-slide 1.5s var(--ease-enter) infinite;
}

@keyframes ren-progress-slide {
  0% {
    transform: translateX(-100%);
  }
  50% {
    transform: translateX(500%);
  }
  100% {
    transform: translateX(500%);
  }
}

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .ren-progress-indeterminate .ren-progress-bar {
    animation: none;
    width: 50% !important;
  }

  .ren-progress-bar {
    transition: none;
  }
}

/* ═══════════════════════════════
   PROGRESS LABELS
   ═══════════════════════════════ */

.ren-progress-label {
  display: block;
  font-size: var(--label-size);
  font-weight: var(--weight-medium);
  color: var(--color-text);
  margin: 0 0 var(--space-1);
}

.ren-progress-value {
  display: block;
  font-size: var(--label-size);
  font-weight: var(--weight-regular);
  color: var(--color-text-secondary);
  margin: var(--space-1) 0 0;
  text-align: end;
}

/* ═══════════════════════════════
   METER ELEMENT
   ═══════════════════════════════ */

.ren-meter {
  display: block;
  width: 100%;
  height: 0.5rem;
  border: none;
  border-radius: var(--radius-full);
  overflow: hidden;
  background-color: var(--color-fill);
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
}

/* Webkit browsers (Chrome, Safari) */
.ren-meter::-webkit-meter-inner-element {
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-bar {
  background-color: var(--color-fill);
  border-radius: var(--radius-full);
  overflow: hidden;
  border: none;
}

.ren-meter::-webkit-meter-optimum-value {
  background-color: var(--color-success);
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-suboptimum-value {
  background-color: var(--color-warning);
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-even-less-good-value {
  background-color: var(--color-danger);
  border-radius: var(--radius-full);
}

/* Firefox */
.ren-meter::-moz-meter-bar {
  background: var(--color-success);
  border-radius: var(--radius-full);
  border: none;
}

/* ─── Meter Sizes ─── */

.ren-meter-sm {
  height: 0.25rem;
}

.ren-meter-lg {
  height: 0.75rem;
}

.ren-meter-xl {
  height: 1rem;
}

data:
{"kind":"primitive","selectors":[".ren-meter",".ren-meter-lg",".ren-meter-sm",".ren-meter-xl",".ren-progress",".ren-progress-bar",".ren-progress-danger",".ren-progress-indeterminate",".ren-progress-info",".ren-progress-label",".ren-progress-lg",".ren-progress-sm",".ren-progress-success",".ren-progress-value",".ren-progress-warning",".ren-progress-xl"],"tokens":["--color-danger","--color-fill","--color-info","--color-success","--color-text","--color-text-secondary","--color-warning","--ease-enter","--label-size","--radius-full","--ren-progress-bg","--ren-progress-duration","--ren-progress-easing","--ren-progress-fill","--ren-progress-height","--ren-progress-radius","--space-1","--weight-medium","--weight-regular"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-radio
type: component
name: ren-radio
path: components/primitives/ren-radio
body:
# ren-radio Component Contract

Native radio group styling with optional custom element keyboard enhancement.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-radio` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-radio` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Radio primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The user must pick exactly one option from 2-7 mutually-exclusive choices."
    - "All choices should be visible at once (no overflow / collapse)."
    - "You need a custom-styled radio dot built on native <input type=\"radio\"> with preserved semantics."
    - "You need keyboard arrow navigation + roving tabindex via <ren-radio-group>."
    - "You need a vertical (.ren-radio-group) or horizontal (.ren-radio-group-horizontal) layout."
  avoidWhen:
    - "There are more than ~7 options or the list overflows — use a ren-select / combobox."
    - "Multiple selections are allowed — use ren-checkbox."
    - "The choice is binary on/off — use ren-switch or ren-checkbox."
    - "The selection drives navigation or filters with immediate side effects — consider ren-segmented or tabs."

canonicalImports:
  css:
    - "rends/components/primitives/ren-radio/ren-radio.css"
  js:
    - "rends/components/primitives/ren-radio/ren-radio.js"
  notes:
    - "JS is only required when using <ren-radio-group> for arrow-key roving tabindex; a plain <fieldset role=\"radiogroup\"> with .ren-radio labels works CSS-only with native browser radio behavior."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Each option is a <label class=\"ren-radio\"> containing a real <input type=\"radio\" name=\"<group>\">, a <span class=\"ren-radio-control\"></span>, and the visible label text — in that DOM order so the :checked + .ren-radio-control adjacent selector works."
  - "All radios in one group share the same name attribute so the browser enforces single-selection."
  - "Wrap the group in <ren-radio-group> (auto-sets role=\"radiogroup\") or in a <fieldset> with <legend> + role=\"radiogroup\" when no JS is desired."
  - "Use <ren-radio-group orientation=\"horizontal\"> to switch to row layout; the JS swaps the class to .ren-radio-group-horizontal."
  - "Never set tabindex manually on inputs inside <ren-radio-group> — the roving-tabindex utility manages it."

forbiddenPatterns:
  - "Replacing <input type=\"radio\"> with a styled <div role=\"radio\"> — loses form submission + native a11y."
  - "Hiding the input with display: none (breaks focus) — use the built-in visually-hidden clip-path pattern."
  - "Two radios with different name attributes in the same group (allows multi-selection)."
  - "Using .ren-radio for an icon-only toggle without a visible text label or aria-label."
  - "Animating the dot via custom keyframes that bypass --duration-state / --ease-playful."

tokenPolicy:
  allowed:
    - "Semantic tokens consumed internally: --color-text, --color-border-strong, --color-accent, --color-accent-hover, --color-on-accent, --color-focus-ring."
    - "Layout / motion tokens: --space-2, --space-3, --space-4, --radius-full, --body-size, --weight-regular, --touch-min, --ring-width, --ring-offset-width, --duration-state, --duration-tactile, --ease-enter, --ease-playful."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Custom radio sizes that fall below the 20px control + 44px label touch target."

accessibility:
  required:
    - "Real <input type=\"radio\"> elements so the browser handles single-selection, form submission, and assistive-tech announcement."
    - ".ren-radio label has min-height: var(--touch-min) so the whole row is a 44px touch target."
    - "Visible :focus-visible ring on .ren-radio-control driven by --color-focus-ring (the native input is visually hidden but focus is delegated to its sibling)."
    - "<ren-radio-group> sets role=\"radiogroup\" and supports arrow-key navigation (Up/Down vertical, Left/Right horizontal) with loop; selection follows focus."
    - "Disabled options use <input disabled> (handled via :has(input:disabled)) — do not rely on opacity alone."
    - "Provide a group label via <legend> inside <fieldset> or aria-label / aria-labelledby on <ren-radio-group>."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-radio/ren-radio.css">
<script type="module" src="rends/components/primitives/ren-radio/ren-radio.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-radio-group>
  <label class="ren-radio"><input type="radio" name="plan" value="basic"><span class="ren-radio-control"></span><span>Basic</span></label>
  <label class="ren-radio"><input type="radio" name="plan" value="pro"><span class="ren-radio-control"></span><span>Pro</span></label>
</ren-radio-group>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-radio`
- `.ren-radio-control`
- `.ren-radio-group`
- `.ren-radio-group-horizontal`

## States And Attributes

- `:active`
- `:checked`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

If no `--ren-*` token is detected here, theme through semantic tokens from `tokens/tokens.md` and avoid selector overrides.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/primitives/ren-radio/ren-radio.css`
- `components/primitives/ren-radio/ren-radio.js`
- `docs/components/ren-radio.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Radio Button & Radio Group Components
   ============================================
   Custom-styled radio buttons and radio groups
   built on native <input type="radio">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <div class="ren-radio-group" role="radiogroup">
       <label class="ren-radio">
         <input type="radio" name="option" value="a">
         <span class="ren-radio-control"></span>
         <span>Option A</span>
       </label>
       <label class="ren-radio">
         <input type="radio" name="option" value="b">
         <span class="ren-radio-control"></span>
         <span>Option B</span>
       </label>
     </div>
   ============================================ */

/* ═══════════════════════════════
   RADIO BUTTON
   ═══════════════════════════════ */

.ren-radio {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  /* Ensure touch target */
  min-height: var(--touch-min);
}

/* Hide native radio visually but keep accessible */
.ren-radio > input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Custom radio visual */
.ren-radio-control {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;   /* 20px */
  height: 1.25rem;
  flex-shrink: 0;
  border: 2px solid var(--color-border-interactive);
  border-radius: var(--radius-full);
  background-color: transparent;
  transition:
    background-color var(--duration-state) var(--ease-enter),
    border-color var(--duration-state) var(--ease-enter),
    transform var(--duration-tactile) var(--ease-playful);
}

/* Radio dot (hidden by default) */
.ren-radio-control::after {
  content: '';
  display: block;
  width: 0.5rem;
  height: 0.5rem;
  background-color: var(--color-on-accent);
  border-radius: var(--radius-full);
  transform: scale(0);
  transition: transform var(--duration-state) var(--ease-playful);
}

/* ─── States ─── */

/* Checked */
.ren-radio > input:checked + .ren-radio-control {
  background-color: var(--color-accent);
  border-color: var(--color-accent);
}

.ren-radio > input:checked + .ren-radio-control::after {
  transform: scale(1);
}

/* Hover */
.ren-radio:hover > .ren-radio-control {
  border-color: var(--color-accent);
}

.ren-radio:hover > input:checked + .ren-radio-control {
  background-color: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

/* Focus */
.ren-radio > input:focus-visible + .ren-radio-control {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active / Press */
.ren-radio:active > .ren-radio-control {
  transform: scale(0.9);
}

/* Disabled */
.ren-radio:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* ═══════════════════════════════
   RADIO GROUP
   ═══════════════════════════════ */

.ren-radio-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.ren-radio-group-horizontal {
  display: flex;
  flex-direction: row;
  gap: var(--space-4);
}


/**
 * RenDS — <ren-radio-group> Web Component
 * ========================================
 * Accessible radio group with keyboard navigation.
 * Implements arrow key navigation and roving tabindex.
 *
 * Uses Light DOM — no Shadow DOM.
 * Works with native <input type="radio"> elements.
 *
 * Attributes:
 *   orientation: 'vertical' (default) | 'horizontal'
 *   role:        'radiogroup' (auto-set)
 *
 * Usage:
 *   <ren-radio-group>
 *     <label class="ren-radio">
 *       <input type="radio" name="option" value="a">
 *       <span class="ren-radio-control"></span>
 *       <span>Option A</span>
 *     </label>
 *     <label class="ren-radio">
 *       <input type="radio" name="option" value="b">
 *       <span class="ren-radio-control"></span>
 *       <span>Option B</span>
 *     </label>
 *   </ren-radio-group>
 *
 * Arrow keys navigate between radios.
 * Selecting a radio via arrow keys also checks it.
 */

import { createKeyboardNav } from '../../../utils/keyboard-nav.js';

export class RenRadioGroup extends HTMLElement {
  static get observedAttributes() {
    return ['orientation'];
  }

  constructor() {
    super();
    this._nav = null;
  }

  connectedCallback() {
    // Set ARIA role
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'radiogroup');
    }

    // Find all radio inputs
    const radios = this.querySelectorAll('input[type="radio"]');
    if (radios.length === 0) return;

    // Set up keyboard navigation using roving tabindex
    const orientation = this.getAttribute('orientation') || 'vertical';

    this._nav = createKeyboardNav(this, {
      selector: 'input[type="radio"]',
      orientation,
      loop: true,
      typeahead: false,
      focusOnHover: false,
      onActivate: (radio, index) => {
        // When arrow key navigation moves to a radio, check it
        radio.checked = true;
        // Dispatch change event to match native behavior
        radio.dispatchEvent(new Event('change', { bubbles: true }));
      },
      onSelect: null,
    });

    this._nav.attach();

    // Update class based on orientation
    this._updateOrientationClass();
  }

  disconnectedCallback() {
    if (this._nav) {
      this._nav.detach();
      this._nav = null;
    }
  }

  attributeChangedCallback(name) {
    if (name === 'orientation') {
      this._updateOrientationClass();
      // Detach and reattach nav with new orientation
      if (this._nav) {
        this._nav.detach();
        const orientation = this.getAttribute('orientation') || 'vertical';
        this._nav = createKeyboardNav(this, {
          selector: 'input[type="radio"]',
          orientation,
          loop: true,
          typeahead: false,
          focusOnHover: false,
          onActivate: (radio, index) => {
            radio.checked = true;
            radio.dispatchEvent(new Event('change', { bubbles: true }));
          },
          onSelect: null,
        });
        this._nav.attach();
      }
    }
  }

  _updateOrientationClass() {
    const orientation = this.getAttribute('orientation') || 'vertical';

    // Remove existing orientation classes
    this.classList.remove('ren-radio-group', 'ren-radio-group-horizontal');

    // Add appropriate class
    if (orientation === 'horizontal') {
      this.classList.add('ren-radio-group-horizontal');
    } else {
      this.classList.add('ren-radio-group');
    }
  }

  // ─── Programmatic API ───

  /**
   * Get the currently checked radio value
   */
  get value() {
    const checked = this.querySelector('input[type="radio"]:checked');
    return checked ? checked.value : null;
  }

  /**
   * Set which radio is checked by value
   */
  set value(val) {
    const radio = this.querySelector(`input[type="radio"][value="${val}"]`);
    if (radio) {
      radio.checked = true;
      radio.dispatchEvent(new Event('change', { bubbles: true }));
    }
  }

  /**
   * Get all radio inputs
   */
  get radios() {
    return Array.from(this.querySelectorAll('input[type="radio"]'));
  }
}

// Register
if (!customElements.get('ren-radio-group')) {
  customElements.define('ren-radio-group', RenRadioGroup);
}

data:
{"kind":"primitive","selectors":[".ren-radio",".ren-radio-control",".ren-radio-group",".ren-radio-group-horizontal"],"tokens":["--body-size","--color-accent","--color-accent-hover","--color-border-interactive","--color-focus-ring","--color-on-accent","--color-text","--duration-state","--duration-tactile","--ease-enter","--ease-playful","--radius-full","--ring-offset-width","--ring-width","--space-2","--space-3","--space-4","--touch-min","--weight-regular"],"hasScript":true,"hasDocsPage":true}


id: component:primitive:ren-separator
type: component
name: ren-separator
path: components/primitives/ren-separator
body:
# ren-separator Component Contract

Visual or semantic divider between content groups.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-separator` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-separator` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Separator primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a horizontal divider between content groups inside a flow (use <hr class=\"ren-separator\">)."
    - "You need a vertical divider inside a flex/inline row (use .ren-separator-vertical)."
    - "You need a labeled divider (e.g. \"or\" between auth options) using .ren-separator-label."
    - "The divider is purely visual and does not need any interactivity or focus."
    - "You want spacing tokens (--space-3 / --space-4) baked into the divider's margin instead of ad-hoc CSS."
  avoidWhen:
    - "You need a sectioning landmark — use semantic <section> / <aside> with headings instead."
    - "The divider should also collapse the sidebar / scroll region — use ren-sidebar / a layout primitive."
    - "You want a decorative border around a card — use the card's --color-border, not a separator."

canonicalImports:
  css:
    - "rends/components/primitives/ren-separator/ren-separator.css"
  notes:
    - "CSS-only primitive. There is no colocated JS — do not import one."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Horizontal separators must be a real <hr class=\"ren-separator\"> element so AT exposes the implicit separator role."
  - "Vertical separators use a non-<hr> element (<div class=\"ren-separator-vertical\">) inside a flex row so align-self: stretch can take effect."
  - "Labeled separators use a flex container: <div class=\"ren-separator-label\">or</div>; the ::before / ::after pseudo-elements draw the lines automatically — do not add extra <span> rules."
  - "Do not nest interactive content inside a separator; it is decorative chrome only."

forbiddenPatterns:
  - "<div class=\"ren-separator\"> for horizontal dividers — use the native <hr> so screen readers announce the separator."
  - "border-top: 1px solid #... or border-bottom: 1px solid #... in consumer code; use .ren-separator and let --color-separator drive the color."
  - "Inline margin overrides (style=\"margin: 0\") to tighten spacing — set --space-* tokens at a parent scope instead."
  - "Using .ren-separator-vertical outside a flex / grid container that gives it a measurable cross-axis height."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-separator-color, --ren-separator-margin, --ren-separator-width."
    - "Semantic tokens consumed internally: --color-separator, --color-text-muted, --space-3, --space-4, --caption-size."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named colors for the separator line."
    - "Raw pixel widths in inline styles; use --ren-separator-width."

accessibility:
  required:
    - "Horizontal separators are <hr> so the implicit role=\"separator\" is exposed to assistive technology."
    - "Purely decorative vertical separators may use aria-hidden=\"true\" if they add no information beyond a sibling label."
    - "Labels on .ren-separator-label must remain real text (not background-image) so they are announced and translatable."
    - "Color contrast is not required for decorative separators, but the line must remain visible in both light and dark themes via --color-separator."
    - "Do not add focusable behavior; separators are not interactive."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-separator/ren-separator.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<hr class="ren-separator">

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-separator`
- `.ren-separator-horizontal`
- `.ren-separator-label`
- `.ren-separator-vertical`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-separator-color`
- `--ren-separator-margin`
- `--ren-separator-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-separator/ren-separator.css`
- `docs/components/ren-separator.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Separator
   ============================================
   Horizontal or vertical divider with optional
   label. CSS-only, no JS needed.

   Usage:
     <hr class="ren-separator">
     <div class="ren-separator-vertical"></div>
     <div class="ren-separator-label">or</div>
   ============================================ */

.ren-separator {
  border: none;
  background-color: var(--ren-separator-color);
}

/* Horizontal (default) */
.ren-separator,
.ren-separator-horizontal {
  width: 100%;
  height: var(--ren-separator-width);
  margin-block: var(--ren-separator-margin);
}

/* Vertical */
.ren-separator-vertical {
  width: var(--ren-separator-width);
  height: auto;
  align-self: stretch;
  margin-inline: var(--space-3);
}

/* With label (e.g. "or" between auth options) */
.ren-separator-label {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--caption-size);
  margin-block: var(--ren-separator-margin);
}

.ren-separator-label::before,
.ren-separator-label::after {
  content: '';
  flex: 1;
  height: var(--ren-separator-width);
  background-color: var(--ren-separator-color);
}

data:
{"kind":"primitive","selectors":[".ren-separator",".ren-separator-horizontal",".ren-separator-label",".ren-separator-vertical"],"tokens":["--caption-size","--color-text-muted","--ren-separator-color","--ren-separator-margin","--ren-separator-width","--space-3"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-skeleton
type: component
name: ren-skeleton
path: components/primitives/ren-skeleton
body:
# ren-skeleton Component Contract

Loading placeholder primitive that respects reduced motion.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-skeleton` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-skeleton` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Skeleton primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "Content is asynchronously loading and you need a placeholder that preserves layout dimensions."
    - "You want a shimmer that automatically degrades to a static fill under prefers-reduced-motion."
    - "You need shape-specific placeholders: text line (.ren-skeleton-text), heading (.ren-skeleton-heading), avatar (.ren-skeleton-circle), or block (.ren-skeleton-rect)."
    - "The placeholder is short-lived — it will be replaced by real content within a few seconds."
  avoidWhen:
    - "The wait is indeterminate or very long — use ren-spinner with descriptive copy."
    - "Only a single small inline indicator is needed — use ren-spinner-xs or ren-spinner-sm."
    - "You want a progress bar with a known percentage — use ren-progress instead."

canonicalImports:
  css:
    - "rends/components/primitives/ren-skeleton/ren-skeleton.css"
  notes:
    - "CSS-only primitive. There is no colocated JS — do not import one."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Always combine the base .ren-skeleton with one shape modifier (.ren-skeleton-text, .ren-skeleton-heading, .ren-skeleton-circle, .ren-skeleton-rect) so the size token resolves."
  - "Wrap a group of skeletons in a container that sets aria-busy=\"true\" and aria-live=\"polite\" so assistive tech is told to wait."
  - "Replace skeleton nodes with real content as soon as the data resolves — do not leave them mounted indefinitely."
  - "Match the skeleton's dimensions to the final content (avatar size, line count) to avoid layout shift on swap."

forbiddenPatterns:
  - "Animating with background-image: linear-gradient(...) hex colors directly; use the .ren-skeleton class and let --color-fill / --color-fill-hover drive the shimmer."
  - "Hardcoded width: 60% or height: 1.5em overrides for headings; use .ren-skeleton-heading and override --avatar-md / --radius-* via tokens if needed."
  - "Leaving the skeleton in the DOM after content loads — toggle visibility / remove, never repurpose as decoration."
  - "Using a skeleton for a determinate progress state (e.g. uploading X of Y) — that is a job for ren-progress."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-skeleton-bg, --ren-skeleton-radius, --ren-skeleton-shine, --ren-skeleton-speed."
    - "Semantic tokens consumed internally: --color-fill, --color-fill-hover, --radius-sm, --radius-md, --radius-full, --avatar-md, --duration-loop-slow, --ease-loop-pulse."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgba shimmer colors."
    - "Custom animation durations in seconds; theme via --ren-skeleton-speed or --duration-loop-slow."

accessibility:
  required:
    - "The container holding skeleton placeholders must set aria-busy=\"true\" so AT announces the loading state."
    - "Pair aria-busy with aria-live=\"polite\" when the content swap should be announced once data resolves."
    - "Skeleton nodes are presentational; do not put role=\"img\" or descriptive alt-text on them."
    - "The shimmer animation must stop under prefers-reduced-motion (the CSS already handles this — do not override animation in consumer code)."
    - "Skeleton color must remain perceivable in both light and dark themes via --color-fill (do not pin to a specific gray)."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-skeleton/ren-skeleton.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<div aria-busy="true" aria-live="polite"><div class="ren-skeleton ren-skeleton-heading"></div><div class="ren-skeleton ren-skeleton-text"></div></div>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-skeleton`
- `.ren-skeleton-circle`
- `.ren-skeleton-heading`
- `.ren-skeleton-rect`
- `.ren-skeleton-text`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-skeleton-bg`
- `--ren-skeleton-radius`
- `--ren-skeleton-shine`
- `--ren-skeleton-speed`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-skeleton/ren-skeleton.css`
- `docs/components/ren-skeleton.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Skeleton
   ============================================
   Loading placeholder with shimmer animation.
   Respects prefers-reduced-motion (static fill).

   Usage:
     <div class="ren-skeleton ren-skeleton-heading"></div>
     <div class="ren-skeleton ren-skeleton-text"></div>
     <div class="ren-skeleton ren-skeleton-circle"></div>
     <div class="ren-skeleton ren-skeleton-rect"></div>

   Accessibility:
     - Wrap multiple skeletons in a container with
       aria-busy="true" aria-live="polite"
     - Replace with real content when loaded
   ============================================ */

.ren-skeleton {
  display: block;
  background: linear-gradient(
    90deg,
    var(--ren-skeleton-bg) 25%,
    var(--ren-skeleton-shine) 50%,
    var(--ren-skeleton-bg) 75%
  );
  background-size: 200% 100%;
  animation: ren-skeleton-pulse var(--ren-skeleton-speed) var(--ease-loop-pulse) infinite;
  border-radius: var(--ren-skeleton-radius);
  min-height: 1rem;
}

/* ─── Variants ─── */
.ren-skeleton-text {
  height: 1em;
  width: 100%;
  border-radius: var(--radius-sm);
}

.ren-skeleton-heading {
  height: 1.5em;
  width: 60%;
  border-radius: var(--radius-sm);
}

.ren-skeleton-circle {
  width: var(--avatar-md);
  height: var(--avatar-md);
  border-radius: var(--radius-full);
}

.ren-skeleton-rect {
  width: 100%;
  height: 8rem;
  border-radius: var(--radius-md);
}

@keyframes ren-skeleton-pulse {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .ren-skeleton {
    animation: none;
    background: var(--ren-skeleton-bg);
  }
}

data:
{"kind":"primitive","selectors":[".ren-skeleton",".ren-skeleton-circle",".ren-skeleton-heading",".ren-skeleton-rect",".ren-skeleton-text"],"tokens":["--avatar-md","--ease-loop-pulse","--radius-full","--radius-md","--radius-sm","--ren-skeleton-bg","--ren-skeleton-radius","--ren-skeleton-shine","--ren-skeleton-speed"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-spinner
type: component
name: ren-spinner
path: components/primitives/ren-spinner
body:
# ren-spinner Component Contract

Small loading indicator for pending or busy states.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-spinner` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-spinner` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Spinner primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a small inline busy / pending indicator with no known duration."
    - "You need to indicate loading inside a button or near a single control (xs / sm sizes)."
    - "The spinner sits on a dark / colored surface — use .ren-spinner-light."
    - "The wait is short and content layout is not yet known (otherwise prefer ren-skeleton for layout preservation)."
    - "You want CSS-only animation that automatically degrades to an opacity pulse under prefers-reduced-motion."
  avoidWhen:
    - "Content placeholders that preserve layout — use ren-skeleton for cards, lists, avatars."
    - "Determinate progress (you know the percentage) — use ren-progress."
    - "Long-running background operations where the user can navigate away — use ren-toast / ren-banner status messaging instead of a persistent spinner."

canonicalImports:
  css:
    - "rends/components/primitives/ren-spinner/ren-spinner.css"
  notes:
    - "CSS-only primitive. There is no colocated JS — do not import one."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Render a <span class=\"ren-spinner\"> (or another inline-block element) with role=\"status\" and an aria-label like \"Loading\"."
  - "Add a size modifier when the default 1.5rem is wrong: .ren-spinner-xs (0.875rem), .ren-spinner-sm (1rem), .ren-spinner-lg (2rem), .ren-spinner-xl (3rem)."
  - "Use .ren-spinner-light only on dark / colored surfaces where the default --color-accent ring would lack contrast."
  - "When a spinner replaces a button's label, also set aria-busy=\"true\" on the button so AT announces the busy state."

forbiddenPatterns:
  - "<div class=\"ren-spinner\" aria-hidden=\"true\"> with no accessible label when the spinner is the only signal of busy state."
  - "Hardcoded border-color: #... overrides; theme via --ren-spinner-color or use .ren-spinner-light for inverted surfaces."
  - "Custom animation-duration values in inline styles; use --ren-spinner-speed (or --duration-loop)."
  - "Combining .ren-spinner-light with --color-accent overrides — pick one approach; light is for inverted surfaces only."
  - "Spinner left mounted after the operation finishes — remove it or replace with success / error state."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-spinner-color, --ren-spinner-size, --ren-spinner-speed, --ren-spinner-width."
    - "Semantic tokens consumed internally: --color-fill-active, --color-accent, --white, --radius-full, --duration-loop, --ease-loop-smooth, --ease-loop-pulse."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgba border colors."
    - "Raw animation: spin 1s linear infinite; use the .ren-spinner class and --ren-spinner-speed."

accessibility:
  required:
    - "The spinner must have role=\"status\" and an aria-label (e.g. \"Loading\") when it conveys busy state."
    - "When the spinner replaces a button label, set aria-busy=\"true\" on the button so the busy state is exposed once."
    - "Do not stack multiple labeled spinners in the same live region — they will be announced repeatedly."
    - "Reduced-motion users see a gentle opacity pulse (built-in); do not override animation in consumer code."
    - "Color is not the only signal: the spinner is also a moving shape. Ensure the rotation / pulse is visible against the background."
    - "Provide a textual fallback (\"Saving…\") near the spinner whenever the wait can exceed a couple of seconds."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-spinner/ren-spinner.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<span class="ren-spinner" role="status" aria-label="Loading"></span>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-spinner`
- `.ren-spinner-lg`
- `.ren-spinner-light`
- `.ren-spinner-sm`
- `.ren-spinner-xl`
- `.ren-spinner-xs`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-spinner-color`
- `--ren-spinner-size`
- `--ren-spinner-speed`
- `--ren-spinner-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-spinner/ren-spinner.css`
- `docs/components/ren-spinner.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Spinner
   ============================================
   Circular loading indicator. CSS-only animation
   that respects prefers-reduced-motion (falls
   back to a gentle opacity pulse instead of
   rotating).

   Usage:
     <span class="ren-spinner" role="status"
           aria-label="Loading"></span>
     <span class="ren-spinner ren-spinner-lg"></span>

   Accessibility:
     - Provide role="status" and aria-label for SR
     - On dark surfaces use .ren-spinner-light
   ============================================ */

.ren-spinner {
  display: inline-block;
  width: var(--ren-spinner-size);
  height: var(--ren-spinner-size);
  border: var(--ren-spinner-width) solid var(--color-fill-active);
  border-top-color: var(--ren-spinner-color);
  border-radius: var(--radius-full);
  animation: ren-spin var(--ren-spinner-speed) var(--ease-loop-smooth) infinite;
}

/* ─── Sizes ─── */
.ren-spinner-xs { width: 0.875rem; height: 0.875rem; border-width: 2px; }
.ren-spinner-sm { width: 1rem;     height: 1rem;     border-width: 2px; }
.ren-spinner-lg { width: 2rem;     height: 2rem;     border-width: 3px; }
.ren-spinner-xl { width: 3rem;     height: 3rem;     border-width: 3px; }

/* ─── Color variants ─── */
.ren-spinner-light {
  border-color: rgb(255, 255, 255, 0.3);
  border-top-color: var(--white);
}

@keyframes ren-spin {
  to { transform: rotate(360deg); }
}

@keyframes ren-spin-gentle {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}

/* Reduced motion: pulse opacity instead of rotating */
@media (prefers-reduced-motion: reduce) {
  .ren-spinner {
    animation: ren-spin-gentle var(--duration-loop-gentle) var(--ease-loop-pulse) infinite;
  }
}

data:
{"kind":"primitive","selectors":[".ren-spinner",".ren-spinner-lg",".ren-spinner-light",".ren-spinner-sm",".ren-spinner-xl",".ren-spinner-xs"],"tokens":["--color-fill-active","--duration-loop-gentle","--ease-loop-pulse","--ease-loop-smooth","--radius-full","--ren-spinner-color","--ren-spinner-size","--ren-spinner-speed","--ren-spinner-width","--white"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-switch
type: component
name: ren-switch
path: components/primitives/ren-switch
body:
# ren-switch Component Contract

Toggle switch primitive built on native `<input type="checkbox" role="switch">` with checked, hover, focus, active, and disabled states.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-switch` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-switch` primitive.
- Keep generated markup aligned with the colocated CSS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- The UI requires an instant on/off state that takes effect immediately (e.g. enable/disable a setting, toggle dark mode).
- The control sits in a settings panel or in-line preference area, not in a form that must be submitted before changes apply.
- Native checkbox semantics with `role="switch"` are appropriate.

## Do Not Use When

- The choice should only take effect on form submit — use `ren-checkbox` instead.
- More than two states are possible — use `ren-radio` or `ren-toggle-group`.
- The control must look like a checkbox (square box with checkmark) — use `ren-checkbox`.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The control flips an immediate on/off setting (dark mode, notifications, feature flags)."
    - "The change should take effect the moment the user toggles it, with no submit step."
    - "Native checkbox semantics extended with role=\"switch\" are the right a11y model."
    - "You want the iOS-style 51×31 track plus full label wrapper for a 44px touch target."
    - "You need the checked color to come from --color-success (positive enablement), not the brand accent."
  avoidWhen:
    - "The change must wait for a Save / Submit step — use ren-checkbox so users can review before commit."
    - "There are 3+ choices or the option is mutually exclusive within a set — use ren-radio or ren-toggle-group."
    - "The control needs a checkmark glyph to communicate selection — use ren-checkbox."
    - "The control is a tab / segmented selector across views — use ren-tabs or ren-toggle-group."

canonicalImports:
  css:
    - "rends/components/primitives/ren-switch/ren-switch.css"
  notes:
    - "CSS-only primitive. There is no colocated JS — do not import one."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Wrap the control in a <label class=\"ren-switch\"> so the entire label area is the click / touch target."
  - "Use a real <input type=\"checkbox\" role=\"switch\"> as the first child; the CSS hides it visually but keeps it focusable and announced."
  - "Follow the input with <span class=\"ren-switch-track\"></span>; the thumb is the track's ::after pseudo-element — do not add a separate thumb element."
  - "Include a visible <span>Label</span> sibling for sighted users; if the switch is icon-only, set aria-label on the input."
  - "Persist on/off changes immediately via the input's change event — never defer to a separate Save action."

forbiddenPatterns:
  - "<div role=\"switch\" tabindex=\"0\"> custom toggles; always use a real <input type=\"checkbox\" role=\"switch\">."
  - "Hiding the input with display: none — it removes it from the accessibility tree. Keep the existing clip-path / visually-hidden pattern."
  - "Hardcoded track / thumb colors via background-color in inline styles; theme via --ren-switch-bg, --ren-switch-checked-bg, --ren-switch-thumb-color."
  - "Putting the switch inside a <form> where it only applies after submit — that is checkbox territory."
  - "Removing the focus outline on .ren-switch-track without restoring an equivalent :focus-visible ring."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-switch-bg, --ren-switch-checked-bg, --ren-switch-duration, --ren-switch-easing, --ren-switch-height, --ren-switch-thumb-color, --ren-switch-thumb-size, --ren-switch-width."
    - "Semantic tokens consumed internally: --color-fill-active, --color-fill-hover, --color-success, --color-success-strong, --color-text, --color-focus-ring, --white, --radius-full, --ring-width, --ring-offset-width, --touch-min, --shadow-sm."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors for the checked / unchecked track."
    - "Custom transition values; use --ren-switch-duration and --ren-switch-easing."

accessibility:
  required:
    - "The native <input type=\"checkbox\" role=\"switch\"> must remain in the DOM and focusable — assistive tech announces it as a switch with on/off states."
    - "The whole <label class=\"ren-switch\"> wrapper provides a touch target of at least --touch-min (44px)."
    - "Focus must show the outline ring on .ren-switch-track via :focus-visible (the CSS already wires this up; do not remove it)."
    - "Do not signal state by color alone — the thumb position (left vs right) is the primary visual cue."
    - "When the switch is disabled, set disabled on the <input> (the parent .ren-switch:has(input:disabled) handles styling) instead of a custom data attribute."
    - "If the switch's label is not adjacent text, set aria-label or aria-labelledby on the <input>."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-switch/ren-switch.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<label class="ren-switch">
  <input type="checkbox" role="switch">
  <span class="ren-switch-track"></span>
  <span>Dark mode</span>
</label>
```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-switch`
- `.ren-switch-track`

## States And Attributes

- `:active`
- `:checked`
- `:disabled`
- `:focus-visible`
- `:hover`
- `role="switch"` on the native input (announces toggle semantics to assistive technology)

## Public Token API

- `--ren-switch-bg`
- `--ren-switch-checked-bg`
- `--ren-switch-duration`
- `--ren-switch-easing`
- `--ren-switch-height`
- `--ren-switch-thumb-color`
- `--ren-switch-thumb-size`
- `--ren-switch-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; the `role="switch"` on the input announces the on/off toggle pattern.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum (the track is 51×31 but the label wrapper provides the full touch target).
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone — the thumb position is the primary visual signal.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-switch/ren-switch.css`
- `docs/components/ren-switch.html`
- `components/primitives/ren-checkbox/component.md` (sibling primitive — submit-time vs immediate semantics)
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Switch / Toggle Component
   ============================================
   Custom-styled toggle switch built on native
   <input type="checkbox" role="switch">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <label class="ren-switch">
       <input type="checkbox" role="switch">
       <span class="ren-switch-track"></span>
       <span>Dark mode</span>
     </label>
   ============================================ */

.ren-switch {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  min-height: var(--touch-min);
}

/* Hide native input */
.ren-switch > input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Track */
.ren-switch-track {
  position: relative;
  width: var(--ren-switch-width);    /* 51px — Apple HIG */
  height: var(--ren-switch-height);   /* 31px */
  flex-shrink: 0;
  background-color: var(--ren-switch-bg);
  border-radius: var(--radius-full);
  transition:
    background-color var(--ren-switch-duration) var(--ren-switch-easing);
}

/* Thumb */
.ren-switch-track::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: var(--ren-switch-thumb-size);   /* 27px */
  height: var(--ren-switch-thumb-size);
  background-color: var(--ren-switch-thumb-color);
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-sm);
  transition:
    transform var(--ren-switch-duration) var(--ren-switch-easing),
    box-shadow var(--ren-switch-duration) var(--ren-switch-easing);
}

/* ─── States ─── */

/* Checked (on) */
.ren-switch > input:checked + .ren-switch-track {
  background-color: var(--ren-switch-checked-bg);
}

.ren-switch > input:checked + .ren-switch-track::after {
  transform: translateX(calc(var(--ren-switch-width) - var(--ren-switch-height)));
}

/* Hover */
.ren-switch:hover > .ren-switch-track {
  background-color: var(--color-fill-hover);
}

.ren-switch:hover > input:checked + .ren-switch-track {
  background-color: var(--color-success-strong);
}

/* Focus */
.ren-switch > input:focus-visible + .ren-switch-track {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active */
.ren-switch:active > .ren-switch-track::after {
  width: calc(var(--ren-switch-thumb-size) + 6px);
}

.ren-switch:active > input:checked + .ren-switch-track::after {
  transform: translateX(calc(var(--ren-switch-width) - var(--ren-switch-height) - 6px));
}

/* Disabled */
.ren-switch:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

data:
{"kind":"primitive","selectors":[".ren-switch",".ren-switch-track"],"tokens":["--body-size","--color-fill-hover","--color-focus-ring","--color-success-strong","--color-text","--radius-full","--ren-switch-bg","--ren-switch-checked-bg","--ren-switch-duration","--ren-switch-easing","--ren-switch-height","--ren-switch-thumb-color","--ren-switch-thumb-size","--ren-switch-width","--ring-offset-width","--ring-width","--shadow-sm","--space-2","--touch-min","--weight-regular"],"hasScript":false,"hasDocsPage":true}


id: component:primitive:ren-tag
type: component
name: ren-tag
path: components/primitives/ren-tag
body:
# ren-tag Component Contract

Compact keyword, filter, or removable token primitive.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-tag` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-tag` primitive.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Tag primitive behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this primitive.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The user picks, adds, or removes a value (filter chip, selected keyword, attached label)."
    - "You need a removable token with an embedded dismiss button (.ren-tag-dismiss)."
    - "You need a toggleable filter pill driven by aria-pressed or data-selected (.ren-tag-clickable)."
    - "You need a status-tinted token (.ren-tag-primary / .ren-tag-success / .ren-tag-warning / .ren-tag-danger) tied to a semantic color."
    - "Multiple tokens flow together in a wrap — use .ren-tag-group as the parent."
  avoidWhen:
    - "The element is a static, non-interactive status pill — use ren-badge."
    - "The element triggers an imperative action (submit, open dialog) — use ren-button."
    - "The element is a hyperlink to another page — use ren-link."
    - "The element is a form-style multi-select dropdown option — use ren-select / ren-combobox option markup."

canonicalImports:
  css:
    - "rends/components/primitives/ren-tag/ren-tag.css"
  notes:
    - "CSS-only primitive. There is no colocated JS — wire up dismiss/click handlers in your own script."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Render the tag as an inline element: <span class=\"ren-tag\"> for static, <span class=\"ren-tag ren-tag-clickable\" tabindex=\"0\" role=\"button\"> for toggleable filters."
  - "Removable tags must include <button class=\"ren-tag-dismiss\" type=\"button\" aria-label=\"Remove <name>\">; never use a non-button element for dismissal."
  - "Toggle / filter tags must mirror selection state via aria-pressed=\"true|false\" (preferred) or data-selected; do not rely on color alone."
  - "Group multiple tags inside a parent with class=\"ren-tag-group\" so the gap and wrap behavior come from the design system."
  - "Combine at most one color variant (.ren-tag-primary | .ren-tag-success | .ren-tag-warning | .ren-tag-danger) with at most one size (.ren-tag-sm | .ren-tag-lg)."

forbiddenPatterns:
  - "<div class=\"ren-tag\" onclick=...> for clickable tags — use a real <button> or .ren-tag-clickable with role=\"button\" and tabindex=\"0\"."
  - "Custom dismiss icons drawn with background-image; place a text \"×\" or an <svg aria-hidden=\"true\"> inside .ren-tag-dismiss instead."
  - "Hardcoded background-color: #... overrides; theme via --ren-tag-bg / --ren-tag-border-color / --ren-tag-color."
  - "Using ren-tag-danger purely for emphasis on a non-danger value — semantic color variants must match meaning (danger = destructive / error)."
  - "Removing the :focus-visible outline on .ren-tag-clickable or .ren-tag-dismiss without restoring a visible ring."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-tag-bg, --ren-tag-border-color, --ren-tag-color, --ren-tag-font-size, --ren-tag-font-weight, --ren-tag-gap, --ren-tag-height, --ren-tag-padding-x, --ren-tag-radius."
    - "Semantic tokens consumed internally: --color-text, --color-text-muted, --color-fill, --color-fill-hover, --color-fill-active, --color-border, --color-border-strong, --color-accent, --color-accent-subtle, --color-on-accent, --color-success / --color-success-subtle / --color-success-strong, --color-warning / --color-warning-subtle / --color-warning-strong, --color-danger / --color-danger-subtle / --color-danger-strong, --color-focus-ring, --stroke-1, --radius-full, --ring-width, --ring-offset-width."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgba background and border colors."
    - "Inline padding / font-size overrides; pick the .ren-tag-sm / .ren-tag-lg variant or override --ren-tag-padding-x / --ren-tag-font-size."

accessibility:
  required:
    - "Clickable tags must be focusable: either a real <button>, or use .ren-tag-clickable with tabindex=\"0\" and role=\"button\" plus Space / Enter handling."
    - "Toggle state must be exposed via aria-pressed; data-selected is for styling parity only, not a substitute for ARIA."
    - "Dismiss buttons need an aria-label that names the value being removed (e.g. \"Remove React\"), not just \"Remove\"."
    - "Color variants must not be the sole signal of meaning — pair .ren-tag-danger with text or an icon that conveys the same state."
    - ":focus-visible outline must remain visible on both .ren-tag-clickable and .ren-tag-dismiss; the CSS wires this to --color-focus-ring."
    - "Long tag text truncates with ellipsis (.ren-tag > span overflow rule); when this happens, surface the full value via title or an off-screen label."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/primitives/ren-tag/ren-tag.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<span class="ren-tag ren-tag-removable">Accessibility<button class="ren-tag-dismiss" type="button" aria-label="Remove Accessibility">×</button></span>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-tag`
- `.ren-tag-clickable`
- `.ren-tag-danger`
- `.ren-tag-dismiss`
- `.ren-tag-group`
- `.ren-tag-lg`
- `.ren-tag-primary`
- `.ren-tag-sm`
- `.ren-tag-success`
- `.ren-tag-warning`

## States And Attributes

- `[aria-pressed]`
- `[data-selected]`
- `:active`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-tag-bg`
- `--ren-tag-border-color`
- `--ren-tag-color`
- `--ren-tag-font-size`
- `--ren-tag-font-weight`
- `--ren-tag-gap`
- `--ren-tag-height`
- `--ren-tag-padding-x`
- `--ren-tag-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/primitives/ren-tag/ren-tag.css`
- `docs/components/ren-tag.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


/* ============================================
   RenDS — Tag / Chip Component
   ============================================
   Interactive badge with optional dismiss button.
   CSS-only (dismiss needs minimal JS).

   Different from Badge: Tag is interactive,
   dismissable, and represents user-selected items.

   Usage:
     <span class="ren-tag">
       HTML
     </span>

     <span class="ren-tag ren-tag-removable">
       React
       <button class="ren-tag-dismiss" aria-label="Remove React">&times;</button>
     </span>

     <span class="ren-tag ren-tag-clickable" tabindex="0" role="button">
       Filter: Active
     </span>
   ============================================ */

.ren-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--ren-tag-gap);
  min-height: var(--ren-tag-height);
  padding: 0.25em var(--ren-tag-padding-x);
  font-size: var(--ren-tag-font-size);
  font-weight: var(--ren-tag-font-weight);
  line-height: 1.5;
  color: var(--ren-tag-color);
  background-color: var(--ren-tag-bg);
  border: var(--stroke-1) solid var(--ren-tag-border-color);
  border-radius: var(--ren-tag-radius);
  white-space: nowrap;
  user-select: none;
  max-width: 100%;
}

/* Tag text truncation */
.ren-tag > span {
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ─── Dismiss button ─── */
.ren-tag-dismiss {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.1em;
  height: 1.1em;
  padding: 0;
  border: none;
  background: none;
  color: var(--color-text-muted);
  cursor: pointer;
  border-radius: var(--radius-full);
  font-size: 1.1em;
  line-height: 1;
  flex-shrink: 0;
  transition:
    color var(--duration-tactile) var(--ease-enter),
    background-color var(--duration-tactile) var(--ease-enter);
}

.ren-tag-dismiss:hover {
  color: var(--color-text);
  background-color: var(--color-fill-hover);
}

.ren-tag-dismiss:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: 1px;
}

/* ─── Clickable / Selectable ─── */
.ren-tag-clickable {
  cursor: pointer;
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    border-color var(--duration-tactile) var(--ease-enter);
}

.ren-tag-clickable:hover {
  background-color: var(--color-fill-hover);
  border-color: var(--color-border-strong);
}

.ren-tag-clickable:active {
  background-color: var(--color-fill-active);
}

.ren-tag-clickable:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Selected state */
.ren-tag-clickable[aria-pressed="true"],
.ren-tag-clickable[data-selected] {
  background-color: var(--color-accent);
  color: var(--color-on-accent);
  border-color: var(--color-accent);
}

/* ─── Color variants ─── */
.ren-tag-primary {
  background-color: var(--color-accent-subtle);
  border-color: color-mix(in oklch, var(--color-accent), transparent 70%);
  color: var(--color-accent);
}

.ren-tag-success {
  background-color: var(--color-success-subtle);
  border-color: color-mix(in oklch, var(--color-success), transparent 70%);
  color: var(--color-success-strong);
}

.ren-tag-warning {
  background-color: var(--color-warning-subtle);
  border-color: color-mix(in oklch, var(--color-warning), transparent 70%);
  color: var(--color-warning-strong);
}

.ren-tag-danger {
  background-color: var(--color-danger-subtle);
  border-color: color-mix(in oklch, var(--color-danger), transparent 70%);
  color: var(--color-danger-strong);
}

/* ─── Size variants ─── */
.ren-tag-sm {
  padding: 0.1em 0.45em;
  font-size: var(--text-xs);
  gap: 0.15em;
}

.ren-tag-lg {
  padding: 0.35em 0.8em;
  font-size: var(--body-size);
}

/* ─── Tag group ─── */
.ren-tag-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  align-items: center;
}

data:
{"kind":"primitive","selectors":[".ren-tag",".ren-tag-clickable",".ren-tag-danger",".ren-tag-dismiss",".ren-tag-group",".ren-tag-lg",".ren-tag-primary",".ren-tag-sm",".ren-tag-success",".ren-tag-warning"],"tokens":["--body-size","--color-accent","--color-accent-subtle","--color-border-strong","--color-danger","--color-danger-strong","--color-danger-subtle","--color-fill-active","--color-fill-hover","--color-focus-ring","--color-on-accent","--color-success","--color-success-strong","--color-success-subtle","--color-text","--color-text-muted","--color-warning","--color-warning-strong","--color-warning-subtle","--duration-tactile","--ease-enter","--radius-full","--ren-tag-bg","--ren-tag-border-color","--ren-tag-color","--ren-tag-font-size","--ren-tag-font-weight","--ren-tag-gap","--ren-tag-height","--ren-tag-padding-x","--ren-tag-radius","--ring-offset-width","--ring-width","--space-2","--stroke-1","--text-xs"],"hasScript":false,"hasDocsPage":true}


id: contract:composite:ren-accordion
type: contract
name: ren-accordion component.md
path: components/composites/ren-accordion/component.md
data: {"kind":"composite"}
body:
# ren-accordion Component Contract

Disclosure group for vertically stacked expandable sections.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-accordion` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-accordion` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Accordion composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "You need a vertically stacked group of disclosure sections sharing chrome."
    - "Exclusive (single open) behavior is required, optionally with collapsible=all-closed."
    - "Multiple sections may be open simultaneously (type=\"multiple\")."
    - "You want native <details>/<summary> semantics with smooth height animation."
    - "Initial open items must be declarable via default-value=\"0,2\"."
  avoidWhen:
    - "Only one disclosure is needed — use ren-collapsible instead."
    - "Sections should not share chrome / divider lines — use isolated ren-collapsible items."
    - "The disclosure is a navigation menu — use ren-menu or ren-sidebar."
    - "Content swaps in place without expanding — use ren-tabs."

canonicalImports:
  css:
    - "rends/components/composites/ren-accordion/ren-accordion.css"
  js:
    - "rends/components/composites/ren-accordion/ren-accordion.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "JS adds exclusive mode, default-value parsing, and the ren-accordion-change event; native <details> handles open/close without JS."

requiredMarkup:
  - "<ren-accordion> wraps real <details> elements; each <details> holds a <summary class=\"ren-accordion-trigger\"> and a <div class=\"ren-accordion-content\">."
  - "Do not replace <details>/<summary> with <button>+<div> — exclusive mode relies on native [name] grouping."
  - "Use type=\"single\" (default) or type=\"multiple\"; add collapsible only when single mode must allow all closed."
  - "Set default-value=\"0,1\" (comma-separated indices) to declare initial open items."
  - "Use .ren-accordion-bordered or .ren-accordion-flush on the host for variants; do not invent new variant classes."

forbiddenPatterns:
  - "<div role=\"button\"> styled as a summary instead of a real <summary> / <button>."
  - "Manual height animation with hardcoded max-height: 500px in inline styles — rely on the component's ::details-content transition."
  - "Hardcoded chevron icons inside the summary; the ::after pseudo-element already renders one."
  - "Toggling open state via display: none on .ren-accordion-content instead of the <details>[open] attribute."
  - "Overriding focus ring with outline: none on .ren-accordion-trigger without restoring :focus-visible."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-collapse-bg, --ren-collapse-border, --ren-collapse-duration, --ren-collapse-easing, --ren-collapse-padding, --ren-collapse-radius, --ren-collapse-trigger-font, --ren-collapse-trigger-weight."
    - "Semantic tokens: --color-border, --color-text, --color-text-muted, --color-fill, --color-fill-active, --color-accent."
    - "Layout / motion tokens: --space-*, --size-*, --radius-*, --duration-enter, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Raw transition durations; use --duration-enter / --ease-enter or --ren-collapse-duration / --ren-collapse-easing."

accessibility:
  required:
    - "Use real <details>/<summary> so the browser exposes the native disclosure pattern to AT."
    - "Keep the chevron decorative (::after pseudo); never put text inside it that an AT must announce."
    - "Touch target on .ren-accordion-trigger is min-height: var(--size-lg); do not shrink below 44px on touch surfaces."
    - "Visible :focus-visible outline (2px solid --color-accent) must be preserved on the summary."
    - "Disabled triggers must set aria-disabled=\"true\" and not toggle on activation."
    - "Animations respect prefers-reduced-motion (transitions are removed under reduce)."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-accordion/ren-accordion.css">
<script type="module" src="rends/components/composites/ren-accordion/ren-accordion.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-accordion type="single"><details><summary class="ren-accordion-trigger">Billing</summary><div class="ren-accordion-content">Billing details</div></details></ren-accordion>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-accordion`
- `.ren-accordion-bordered`
- `.ren-accordion-content`
- `.ren-accordion-flush`
- `.ren-accordion-item`
- `.ren-accordion-trigger`

## States And Attributes

- `[aria-disabled]`
- `[aria-expanded]`
- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-collapse-bg`
- `--ren-collapse-border`
- `--ren-collapse-duration`
- `--ren-collapse-easing`
- `--ren-collapse-padding`
- `--ren-collapse-radius`
- `--ren-collapse-trigger-font`
- `--ren-collapse-trigger-weight`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/composites/ren-accordion/ren-accordion.css`
- `components/composites/ren-accordion/ren-accordion.js`
- `docs/components/ren-accordion.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-alert-dialog
type: contract
name: ren-alert-dialog component.md
path: components/composites/ren-alert-dialog/component.md
data: {"kind":"composite"}
body:
# ren-alert-dialog Component Contract

Non-dismissible confirmation dialog for destructive or critical decisions.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-alert-dialog` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-alert-dialog` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Alert Dialog composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "A destructive or irreversible action (delete, sign-out, discard) requires explicit confirmation."
    - "Accidental dismissal (Escape, backdrop click) must NOT close the dialog."
    - "The decision is binary and centered around two actions (cancel + confirm)."
    - "Content is short: an icon, a title, a one-paragraph description, and two buttons."
    - "You want the native <dialog> showModal() flow with returnValue for the outcome."
  avoidWhen:
    - "The dialog hosts a form, multi-step flow, or scrollable content — use ren-dialog."
    - "The disclosure should dismiss on Escape / backdrop — use ren-dialog (no alert)."
    - "The notification does not block the page — use ren-toast or ren-banner."

canonicalImports:
  css:
    - "rends/components/composites/ren-alert-dialog/ren-alert-dialog.css"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "No colocated JS — call dialog.showModal() / dialog.close(returnValue) directly on the native <dialog>."
    - "The component uses the same --ren-dialog-* token namespace as ren-dialog, so token overrides cascade."

requiredMarkup:
  - "The root element is a real <dialog class=\"ren-alert-dialog\">; never substitute a <div role=\"alertdialog\">."
  - "Provide a .ren-alert-dialog-title (heading) and a .ren-alert-dialog-description so AT can announce the decision."
  - "Actions live inside .ren-alert-dialog-actions and use real <button> elements (.ren-btn-secondary + .ren-btn-danger or similar)."
  - "Each button calls dialog.close('cancel' | 'confirm' | …) so the consumer can read returnValue on the close event."
  - "Use .ren-alert-dialog-icon-danger or .ren-alert-dialog-icon-warning on the icon slot for severity — do not invent new severity classes."

forbiddenPatterns:
  - "<div class=\"ren-alert-dialog\"> rendered without the native <dialog> element."
  - "Calling dialog.show() instead of dialog.showModal() — alert behavior requires modality + ::backdrop."
  - "Adding Escape-to-close handlers; the alert variant intentionally blocks accidental dismissal."
  - "Hardcoded rgba backdrops (background: rgba(0,0,0,.5)) — the ::backdrop already reads --color-overlay."
  - "Replacing .ren-alert-dialog-actions with arbitrary flex containers that bypass the @container narrow-stack layout."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-dialog-backdrop, --ren-dialog-bg, --ren-dialog-border-color, --ren-dialog-duration, --ren-dialog-easing, --ren-dialog-gap, --ren-dialog-padding, --ren-dialog-radius, --ren-dialog-shadow, --ren-dialog-width."
    - "Semantic tokens: --color-surface, --color-overlay, --color-text, --color-text-muted, --color-danger, --color-danger-subtle, --color-warning, --color-warning-subtle, --color-fill."
    - "Layout / motion tokens: --space-*, --radius-*, --shadow-xl, --icon-xl, --duration-enter, --ease-enter, --transition-overlay."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Custom backdrop opacity values that bypass --color-overlay or --transition-overlay."

accessibility:
  required:
    - "Use a real <dialog> so the browser exposes role=\"dialog\" + aria-modal automatically; add aria-labelledby pointing to .ren-alert-dialog-title."
    - "Open with showModal(); the backdrop becomes inert so background content is unreachable to keyboard / AT."
    - "The destructive button must be visually distinct (.ren-btn-danger) AND announced by its text — never rely on color alone."
    - "Focus must move into the dialog on open and return to the trigger on close (native <dialog> behavior)."
    - "Touch targets on action buttons stay ≥ 44px (default .ren-btn size)."
    - "Reduced motion: transitions are disabled under prefers-reduced-motion (already handled by the component)."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-alert-dialog/ren-alert-dialog.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<dialog class="ren-alert-dialog" aria-labelledby="delete-title" aria-describedby="delete-description" closedby="none">
  <h2 id="delete-title" class="ren-alert-dialog-title">Delete project?</h2>
  <p id="delete-description" class="ren-alert-dialog-description">This cannot be undone.</p>
  <form method="dialog" class="ren-alert-dialog-actions">
    <button value="cancel" class="ren-btn ren-btn-secondary">Cancel</button>
    <button value="delete" class="ren-btn ren-btn-danger">Delete</button>
  </form>
</dialog>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-alert-dialog`
- `.ren-alert-dialog-actions`
- `.ren-alert-dialog-description`
- `.ren-alert-dialog-icon`
- `.ren-alert-dialog-icon-danger`
- `.ren-alert-dialog-icon-warning`
- `.ren-alert-dialog-title`

## States And Attributes

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

## Public Token API

- `--ren-dialog-backdrop`
- `--ren-dialog-bg`
- `--ren-dialog-border-color`
- `--ren-dialog-duration`
- `--ren-dialog-easing`
- `--ren-dialog-gap`
- `--ren-dialog-padding`
- `--ren-dialog-radius`
- `--ren-dialog-shadow`
- `--ren-dialog-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/composites/ren-alert-dialog/ren-alert-dialog.css`
- `docs/components/ren-alert-dialog.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-calendar
type: contract
name: ren-calendar component.md
path: components/composites/ren-calendar/component.md
data: {"kind":"composite"}
body:
# ren-calendar Component Contract

Calendar grid and date selection composite.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-calendar` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-calendar` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Calendar composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The UI needs a full month grid for date browsing or selection."
    - "You need single, range, or multiple selection modes (mode=\"single|range|multiple\")."
    - "Locale-aware month names and first-day-of-week must be supported (locale, first-day attrs)."
    - "min / max bounds must constrain selectable dates."
    - "You need ARIA grid + roving tabindex keyboard navigation across days."
  avoidWhen:
    - "You only need a date text input — use ren-date-picker (composite that may embed ren-calendar)."
    - "You need a year/decade picker without a month grid — use a different control."
    - "The UI is a schedule / agenda view rather than date selection — use a calendar-view pattern."
    - "Only a static date display is needed — use ren-text with a formatted Date."

canonicalImports:
  css:
    - "rends/components/composites/ren-calendar/ren-calendar.css"
  js:
    - "rends/components/composites/ren-calendar/ren-calendar.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "JS is required: <ren-calendar> renders the grid imperatively; there is no static markup fallback."
    - "Configure via attributes (value, mode, locale, first-day, min, max); avoid manual DOM mutations on .ren-calendar-grid."

requiredMarkup:
  - "Render <ren-calendar> as the host; the component populates its own .ren-calendar-header, .ren-calendar-weekdays, and .ren-calendar-grid."
  - "Each day cell is a real <button class=\"ren-calendar-day\"> with aria-selected and (when applicable) data-today / data-outside / [disabled]."
  - "Navigation arrows use .ren-calendar-prev / .ren-calendar-next inside .ren-calendar-nav and rely on the component's chevron pseudo-elements."
  - "Use .ren-calendar-sm or .ren-calendar-lg on the host for size variants; do not invent custom size classes."
  - "For ranges, the component sets .ren-calendar-day-range-start, .ren-calendar-day-in-range, .ren-calendar-day-range-end — do not author these by hand."

forbiddenPatterns:
  - "Replacing day <button>s with <div role=\"button\"> — breaks keyboard + screen reader expectations."
  - "Hardcoded today highlight (border: 2px solid #...) — rely on [data-today] and --color-accent."
  - "Toggling a day's selected state by adding a custom class instead of aria-selected=\"true\"."
  - "Mounting the calendar inside a non-modal popover without focus management — wrap in ren-popover or ren-dialog if floating."
  - "Hardcoded weekday labels in the markup; the component renders them from locale."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-calendar-bg, --ren-calendar-border, --ren-calendar-day-size, --ren-calendar-radius, --ren-calendar-range-bg, --ren-calendar-selected-bg, --ren-calendar-selected-color, --ren-calendar-today-bg, --ren-calendar-width."
    - "Semantic tokens: --color-surface-raised, --color-border, --color-text, --color-text-muted, --color-text-faint, --color-fill, --color-fill-active, --color-accent, --color-accent-subtle, --color-on-accent."
    - "Layout / motion tokens: --space-*, --radius-*, --shadow-md, --touch-min, --duration-enter, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Hardcoded day-cell sizes; tune --ren-calendar-day-size or pick the .ren-calendar-sm / .ren-calendar-lg variant."

accessibility:
  required:
    - "Day cells implement the ARIA grid pattern with a single roving tabindex; never expose more than one tab stop in the grid."
    - "Keyboard: Arrow keys move focus between days, PageUp/PageDown move months, Home/End jump within a week — preserve these in any customization."
    - "Selected days set aria-selected=\"true\"; do not communicate selection through color alone."
    - "Today indicator uses [data-today] AND an inset ring (box-shadow); the ring is required so color-blind users perceive today."
    - "Out-of-month days set [data-outside] and pointer-events: none; do not let them receive focus."
    - "Min/max bounds disable cells via the native [disabled] attribute so the browser blocks activation."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-calendar/ren-calendar.css">
<script type="module" src="rends/components/composites/ren-calendar/ren-calendar.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-calendar value="2026-07-10" aria-label="Choose a date"></ren-calendar>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-calendar`
- `.ren-calendar-day`
- `.ren-calendar-day-in-range`
- `.ren-calendar-day-range-end`
- `.ren-calendar-day-range-start`
- `.ren-calendar-grid`
- `.ren-calendar-header`
- `.ren-calendar-lg`
- `.ren-calendar-nav`
- `.ren-calendar-next`
- `.ren-calendar-prev`
- `.ren-calendar-sm`
- `.ren-calendar-title`
- `.ren-calendar-weekday`
- `.ren-calendar-weekdays`

## States And Attributes

- `[aria-selected]`
- `[data-outside]`
- `[data-today]`
- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-calendar-bg`
- `--ren-calendar-border`
- `--ren-calendar-day-size`
- `--ren-calendar-radius`
- `--ren-calendar-range-bg`
- `--ren-calendar-selected-bg`
- `--ren-calendar-selected-color`
- `--ren-calendar-today-bg`
- `--ren-calendar-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/composites/ren-calendar/ren-calendar.css`
- `components/composites/ren-calendar/ren-calendar.js`
- `docs/components/ren-calendar.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-carousel
type: contract
name: ren-carousel component.md
path: components/composites/ren-carousel/component.md
data: {"kind":"composite"}
body:
# ren-carousel Component Contract

Slide navigation composite for grouped media/content.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-carousel` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-carousel` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Carousel composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "Multiple peer items (images, cards, testimonials) need horizontal cycling with snap behavior."
    - "Different slides-per-view densities are needed (.ren-carousel-2 / -3 / -4 / -full)."
    - "Optional autoplay with visible progress indicator is desired (data-autoplay + data-autoplay-duration)."
    - "Touch swipe + keyboard arrow navigation are required."
    - "You want a fade transition variant via .ren-carousel-fade in addition to scroll-snap."
  avoidWhen:
    - "Content is editorial reading order — use a vertical stack instead (carousels hide content)."
    - "The interaction is a tab switch with persistent panels — use ren-tabs."
    - "Items are step-by-step in a process — use ren-stepper / ren-wizard."
    - "There is only one or two items — render them inline."

canonicalImports:
  css:
    - "rends/components/composites/ren-carousel/ren-carousel.css"
  js:
    - "rends/components/composites/ren-carousel/ren-carousel.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "JS handles arrow generation, dot pagination, autoplay timer, and aria-current bookkeeping; CSS-only fallback offers scroll-snap + the native ::scroll-button() pseudos when supported."
    - "Add data-autoplay to enable autoplay; pair with data-autoplay-duration=\"5000\" (ms) for the progress bar."

requiredMarkup:
  - "<ren-carousel> wraps a single <div class=\"ren-carousel-viewport\"> which contains <div class=\"ren-carousel-slide\"> children."
  - "Use .ren-carousel-2 / -3 / -4 / -full on the host to declare slides-per-view; do not set flex-basis inline."
  - "Arrow buttons use .ren-carousel-prev / .ren-carousel-next; the JS injects them when needed, do not author chevron glyphs by hand."
  - "Dot list is <ul class=\"ren-carousel-dots\"> with <li><button class=\"ren-carousel-dot\" aria-current=\"true|false\"></button></li> items."
  - "Each slide carries aria-current=\"true\" only when active; the component manages this attribute."

forbiddenPatterns:
  - "Replacing slides with arbitrary <div role=\"group\"> without scroll-snap-align: start — breaks snap behavior."
  - "Hardcoded translateX(-N%) transforms to advance slides; use scroll-snap or the fade variant."
  - "Adding an autoplay timer in consumer code instead of using data-autoplay + data-autoplay-duration."
  - "Hiding arrows by setting display: none on .ren-carousel-prev/.ren-carousel-next — use the .ren-carousel-no-arrows variant."
  - "Inline width: 50% on a slide to override slides-per-view — switch to the correct .ren-carousel-N class."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-carousel-arrow-size, --ren-carousel-dot-active, --ren-carousel-dot-color, --ren-carousel-dot-size, --ren-carousel-duration, --ren-carousel-easing, --ren-carousel-gap, --ren-carousel-radius."
    - "Semantic tokens: --color-accent, --color-border, --color-text, --color-text-muted."
    - "Layout / motion tokens: --space-*, --radius-full, --touch-min, --duration-enter, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides (the default rgba arrow scrim lives inside the component; do not duplicate it)."
    - "Hardcoded dot sizes / colors; tune --ren-carousel-dot-size / --ren-carousel-dot-active."

accessibility:
  required:
    - "Wrap the carousel with role=\"region\" (or role=\"group\") and an accessible name so screen readers can label the collection."
    - "Active slide / active dot use aria-current=\"true\"; never communicate the active state through color alone."
    - "Keyboard: Left/Right arrows traverse slides; arrow buttons and dots are real <button> elements with :focus-visible rings."
    - "Touch targets on .ren-carousel-prev/.ren-carousel-next stay ≥ 44px (min-width / min-height enforced in CSS)."
    - "Autoplay must pause on hover / focus / data-autoplay removal; aria-busy=\"true\" disables interaction during transitions."
    - "Respect prefers-reduced-motion: scroll-behavior becomes auto and the autoplay progress bar shows the final frame instead of animating."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-carousel/ren-carousel.css">
<script type="module" src="rends/components/composites/ren-carousel/ren-carousel.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-carousel aria-label="Featured articles"><div class="ren-carousel-viewport"><div class="ren-carousel-slide">First article</div><div class="ren-carousel-slide">Second article</div></div></ren-carousel>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-carousel`
- `.ren-carousel-2`
- `.ren-carousel-3`
- `.ren-carousel-4`
- `.ren-carousel-counter`
- `.ren-carousel-counter-current`
- `.ren-carousel-dot`
- `.ren-carousel-dots`
- `.ren-carousel-fade`
- `.ren-carousel-full`
- `.ren-carousel-next`
- `.ren-carousel-no-arrows`
- `.ren-carousel-no-counter`
- `.ren-carousel-no-dots`
- `.ren-carousel-prev`
- `.ren-carousel-slide`
- `.ren-carousel-viewport`

## States And Attributes

- `[aria-busy]`
- `[aria-current]`
- `[data-at-end]`
- `[data-at-start]`
- `[data-autoplay-duration]`
- `[data-autoplay]`
- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-carousel-arrow-size`
- `--ren-carousel-dot-active`
- `--ren-carousel-dot-color`
- `--ren-carousel-dot-size`
- `--ren-carousel-duration`
- `--ren-carousel-easing`
- `--ren-carousel-gap`
- `--ren-carousel-radius`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/composites/ren-carousel/ren-carousel.css`
- `components/composites/ren-carousel/ren-carousel.js`
- `docs/components/ren-carousel.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-collapsible
type: contract
name: ren-collapsible component.md
path: components/composites/ren-collapsible/component.md
data: {"kind":"composite"}
body:
# ren-collapsible Component Contract

Expandable/collapsible region with explicit state.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-collapsible` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-collapsible` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Collapsible composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "A single, standalone expandable section is needed (no sibling exclusivity)."
    - "You want a zero-JS disclosure based on native <details>/<summary>."
    - "Initial state must be declarable in HTML via the [open] attribute."
    - "A subtle ghost variant (no border, flush) is desired."
    - "The content area animates height via CSS interpolate-size (no JS measurement)."
  avoidWhen:
    - "Multiple grouped disclosures share chrome — use ren-accordion (handles exclusive mode)."
    - "The disclosure floats above content as a popover — use ren-popover."
    - "The trigger must control a remote panel — use a button + aria-controls on a custom panel."
    - "The disclosure is a navigation reveal — use ren-sidebar / ren-menu."

canonicalImports:
  css:
    - "rends/components/composites/ren-collapsible/ren-collapsible.css"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "No colocated JS — open / close uses the native <details> toggle event; consumers can listen for 'toggle' if needed."
    - "Add interpolate-size support polyfill only if you require height animation on legacy browsers."

requiredMarkup:
  - "Root element is <details class=\"ren-collapsible\">; do not wrap a <div> with a custom click handler."
  - "Trigger is the first child <summary>; content lives in a sibling <div class=\"ren-collapsible-content\">."
  - "Use the [open] attribute to render expanded on first paint (do not set hidden / display via style)."
  - "Add .ren-collapsible-ghost to the host for the borderless variant; do not invent additional variant classes."
  - "Keep the chevron decorative — it is rendered via the summary::after pseudo-element."

forbiddenPatterns:
  - "<div class=\"ren-collapsible\"><div role=\"button\">…</div></div> — must be a real <details>/<summary>."
  - "Custom triangle / chevron inside the summary text content; rely on summary::after rotation."
  - "Animating with JavaScript-driven max-height; CSS interpolate-size handles auto-to-zero transitions."
  - "Removing the focus ring (outline: none) without restoring :focus-visible on summary."
  - "Calling .open = true on a detached <details> outside the DOM — bind it inside .ren-collapsible first."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-collapse-bg, --ren-collapse-border, --ren-collapse-duration, --ren-collapse-easing, --ren-collapse-padding, --ren-collapse-radius, --ren-collapse-trigger-font, --ren-collapse-trigger-weight."
    - "Semantic tokens: --color-border, --color-text, --color-text-muted, --color-text-secondary, --color-fill, --color-focus-ring."
    - "Layout / motion tokens: --space-*, --radius-md, --stroke-1, --touch-min, --ring-width, --duration-enter, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Raw transition values; use --duration-enter / --ease-enter or --ren-collapse-duration / --ren-collapse-easing."

accessibility:
  required:
    - "Use the real <details>/<summary> pair so the browser exposes the native disclosure pattern and supports Enter / Space toggle."
    - "Touch target on the summary stays ≥ var(--touch-min) (44px) — do not shrink below this on touch surfaces."
    - "Visible :focus-visible ring on summary uses --color-focus-ring and --ring-width; do not remove it."
    - "Chevron is decorative (::after pseudo) — never expose its rotation to AT via aria-label."
    - "Communicate open state through the [open] attribute (native semantics) — never rely on color alone."
    - "Animations respect prefers-reduced-motion (chevron rotation transition is disabled)."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-collapsible/ren-collapsible.css">
<!-- No colocated JavaScript file detected. -->
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<details class="ren-collapsible"><summary>Advanced settings</summary><div class="ren-collapsible-content">Additional controls</div></details>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-collapsible`
- `.ren-collapsible-content`
- `.ren-collapsible-ghost`

## States And Attributes

- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-collapse-bg`
- `--ren-collapse-border`
- `--ren-collapse-duration`
- `--ren-collapse-easing`
- `--ren-collapse-padding`
- `--ren-collapse-radius`
- `--ren-collapse-trigger-font`
- `--ren-collapse-trigger-weight`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/composites/ren-collapsible/ren-collapsible.css`
- `docs/components/ren-collapsible.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-color-picker
type: contract
name: ren-color-picker component.md
path: components/composites/ren-color-picker/component.md
data: {"kind":"composite"}
body:
# ren-color-picker Component Contract

Color input composite for selecting and previewing colors.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-color-picker` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-color-picker` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Color Picker composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "Users must pick an arbitrary color via canvas (S/B) + hue + optional alpha."
    - "Multiple output formats are needed (HEX / RGB / HSL) with a toggle."
    - "Manual entry through hex + channel inputs and preset swatches is required."
    - "Eyedropper integration (EyeDropper API) is desired when supported."
    - "The control opens in a popover anchored to a trigger button showing the current color, with placement=\"top|right|bottom|left\" mirrored to data-side."
  avoidWhen:
    - "Users only need to pick from a small fixed palette — render a list of ren-swatch / radio buttons."
    - "A native <input type=\"color\"> is sufficient (no alpha, no format toggle, no presets)."
    - "The UI is a brand-theme picker driven by tokens — use a token-aware variant."
    - "The control must live inline without a dropdown — wrap the dropdown content directly without the trigger."

canonicalImports:
  css:
    - "rends/components/composites/ren-color-picker/ren-color-picker.css"
  js:
    - "rends/components/composites/ren-color-picker/ren-color-picker.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "JS is required: HSV ↔ HEX / RGB / HSL conversion, canvas painting, drag handles, and the popover open/close are all implemented in JS."
    - "The dropdown uses the native Popover API plus CSS anchor positioning via position-area; older browsers fall back to local absolute positioning."

requiredMarkup:
  - "Root <ren-color-picker> is the consumer-authored host; its JS generates the trigger button and .ren-color-picker-dropdown popover."
  - "Trigger is a real <button class=\"ren-color-picker-trigger\"> with aria-expanded reflecting popover state."
  - "Saturation/brightness area renders a <canvas class=\"ren-color-picker-saturation-canvas\"> inside .ren-color-picker-saturation; do not replace with a gradient div."
  - "Hue handle and alpha handle are decorative thumbs (no tab stops) — keyboard input lives on the hex / channel inputs."
  - "Swatch grid items are real <button class=\"ren-color-picker-swatch\"> with aria-selected on the active swatch."
  - "Use placement=\"bottom\" by default; the host and .ren-color-picker-dropdown mirror the preferred side to data-side."

forbiddenPatterns:
  - "Replacing the canvas with a CSS gradient div — drag math relies on canvas pixel sampling."
  - "Hardcoded hue rainbow gradients in consumer overrides; the rainbow's six hex stops are intentional and locked to HSL pure hues."
  - "Setting box-shadow / outline overrides that hide the focus ring on .ren-color-picker-trigger or .ren-color-picker-swatch."
  - "Using window.alert / window.prompt for color input; the component already exposes hex + channel inputs."
  - "Calling EyeDropper directly from consumer code; the .ren-color-picker-eyedropper button wraps the API with feature detection."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-cp-bg, --ren-cp-handle-size, --ren-cp-radius, --ren-cp-shadow, --ren-cp-swatch-size, --ren-cp-track-height, --ren-cp-width."
    - "Semantic tokens: --color-surface, --color-surface-raised, --color-border, --color-text, --color-text-muted, --color-fill, --color-fill-hover, --color-accent, --color-input-bg, --color-input-border, --color-input-border-focus, --color-input-focus-ring."
    - "Layout / motion tokens: --space-*, --radius-*, --shadow-lg, --touch-min, --font-mono, --duration-enter, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides (the hue rainbow stops inside the component are the only sanctioned exception)."
    - "Custom drop shadows that bypass --ren-cp-shadow / --shadow-lg."

accessibility:
  required:
    - "Trigger button has aria-haspopup=\"dialog\" (or appropriate) and aria-expanded reflects the dropdown state."
    - "All inputs (.ren-color-picker-hex-input, .ren-color-picker-channel-input) have associated <label> via .ren-color-picker-channel-label or aria-label."
    - "The saturation area is operable via keyboard (Arrow keys move the handle in 1% steps; Shift+Arrow in larger steps)."
    - "Focus rings use --color-input-focus-ring; never set outline: none without restoring box-shadow ring."
    - "Color is never communicated by hue alone — value text appears alongside the swatch (preview label + value)."
    - "Animations honor prefers-reduced-motion (open animation and transitions are clamped to 0.01ms)."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-color-picker/ren-color-picker.css">
<script type="module" src="rends/components/composites/ren-color-picker/ren-color-picker.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-color-picker value="#007aff" placement="bottom"></ren-color-picker>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-color-picker`
- `.ren-color-picker-alpha`
- `.ren-color-picker-alpha-gradient`
- `.ren-color-picker-alpha-handle`
- `.ren-color-picker-channel`
- `.ren-color-picker-channel-input`
- `.ren-color-picker-channel-label`
- `.ren-color-picker-channels`
- `.ren-color-picker-controls`
- `.ren-color-picker-controls-left`
- `.ren-color-picker-controls-right`
- `.ren-color-picker-dropdown`
- `.ren-color-picker-eyedropper`
- `.ren-color-picker-eyedropper-icon`
- `.ren-color-picker-format-toggle`
- `.ren-color-picker-hex-input`
- `.ren-color-picker-hue`
- `.ren-color-picker-hue-handle`
- `.ren-color-picker-inputs`
- `.ren-color-picker-preview`
- `.ren-color-picker-preview-info`
- `.ren-color-picker-preview-label`
- `.ren-color-picker-preview-swatch`
- `.ren-color-picker-preview-value`
- `...and 9 more in the source files.`

## States And Attributes

- `[aria-expanded]`
- `[aria-selected]`
- `[data-side]`
- `placement`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-cp-bg`
- `--ren-cp-handle-size`
- `--ren-cp-radius`
- `--ren-cp-shadow`
- `--ren-cp-swatch-size`
- `--ren-cp-track-height`
- `--ren-cp-width`

Theme through these public custom properties before reaching for selectors.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/composites/ren-color-picker/ren-color-picker.css`
- `components/composites/ren-color-picker/ren-color-picker.js`
- `docs/components/ren-color-picker.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


id: contract:composite:ren-combobox
type: contract
name: ren-combobox component.md
path: components/composites/ren-combobox/component.md
data: {"kind":"composite"}
body:
# ren-combobox Component Contract

Autocomplete/select composite with input, listbox, and keyboard behavior.

Load this file after `ren-design.md` and before generating, editing, or reviewing `ren-combobox` UI.

## Purpose

- Provide the agent-facing public contract for the `ren-combobox` composite.
- Keep generated markup aligned with the colocated CSS/JS source.
- Route theming through semantic tokens and the component token API instead of ad hoc styles.

## Use When

- You need the Combobox composite behavior or visual role.
- The UI should stay inside RenDS' vanilla HTML/CSS/JS conventions.
- The implementation can use the public selectors, states, and imports listed here.

## Do Not Use When

- Native HTML plus `base/primitive-zero.md` is enough.
- A simpler primitive can express the UI without this composite.
- You would need to invent undocumented selectors, states, or JavaScript APIs.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "Users must filter / autocomplete from a large list using free-text input."
    - "You need a typeahead with keyboard navigation (Arrow Up/Down, Enter, Escape)."
    - "Grouped options or items with secondary descriptions are needed."
    - "Async loading states (.ren-combobox-loading) or empty states (.ren-combobox-empty) are required."
    - "Single-select with a text-input affordance is the right control (input visible at rest)."
    - "The inline list needs a preferred top/bottom opening side via placement without becoming a Popover API overlay."
  avoidWhen:
    - "Users pick from a short fixed list without filtering — use ren-select."
    - "Multi-select with tag chips is required — use a dedicated multi-select / ren-multiselect."
    - "The input is a free-form search that submits a query — use ren-input + ren-search."
    - "The list is a navigation menu — use ren-menu / ren-command."

canonicalImports:
  css:
    - "rends/components/composites/ren-combobox/ren-combobox.css"
  js:
    - "rends/components/composites/ren-combobox/ren-combobox.js"
  notes:
    - "If the page already imports rends/components/index.css, do not import the CSS again."
    - "CSS works standalone on the .ren-combobox structure; <ren-combobox> JS adds ARIA wiring, filtering, highlight bookkeeping, and open/close."
    - "The component does not define --ren-combobox-* tokens; theme through semantic tokens or scope semantic token overrides."

requiredMarkup:
  - "Root <ren-combobox> wraps consumer-authored .ren-combobox-item options; its JS generates the input, listbox, hidden input, and live region."
  - "Each option is <div class=\"ren-combobox-item\" role=\"option\"> with aria-selected toggled by the component and data-highlighted reflecting keyboard focus."
  - "Use .ren-combobox-item-label and .ren-combobox-item-description inside items for two-line content; do not nest extra layout divs."
  - "Empty state lives in <div class=\"ren-combobox-empty\" hidden> and the loading state in <div class=\"ren-combobox-loading\" hidden>."
  - "Group items inside <div class=\"ren-combobox-group\"> with a leading <div class=\"ren-combobox-group-label\">."
  - "Use placement=\"bottom\" by default; the host and .ren-combobox-list mirror placement to data-side. Top changes the absolute list fallback to open above the input."

forbiddenPatterns:
  - "Replacing <input> with a contentEditable div — breaks IME, autofill, and form submission."
  - "Using <ul>/<li> for the list when the component's CSS targets .ren-combobox-list / .ren-combobox-item with role=\"listbox\" / role=\"option\"."
  - "Using :hover styling alone to indicate keyboard highlight; rely on [data-highlighted] and aria-selected=\"true\"."
  - "Toggling visibility with display: none on the list; use the [hidden] attribute (CSS handles the close transition)."
  - "Hardcoded dropdown widths; the list uses inset-inline: 0 to match the input width — override --space-* / --radius-* tokens instead."

tokenPolicy:
  allowed:
    - "Semantic tokens: --color-surface, --color-border, --color-text, --color-text-muted, --color-fill, --color-fill-hover, --color-fill-active, --color-accent, --color-accent-subtle."
    - "Typography / layout: --text-xs, --text-sm, --leading-5, --space-*, --radius-sm, --radius-md, --radius-lg, --radius-full, --shadow-lg."
    - "Motion tokens: --duration-micro, --duration-enter, --duration-slow, --ease-enter, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / named colors in consumer overrides."
    - "Defining new --ren-combobox-* component tokens — the component intentionally inherits semantic tokens."

accessibility:
  required:
    - "Input has role=\"combobox\", aria-expanded reflecting list visibility, aria-controls pointing to the .ren-combobox-list id, and aria-activedescendant pointing to the highlighted option."
    - "List has role=\"listbox\" and each item has role=\"option\"; do not nest interactive controls inside an option."
    - "Keyboard contract: Arrow Down opens / moves down, Arrow Up moves up, Enter selects, Escape closes; preserve all four."
    - "Selected option sets aria-selected=\"true\"; selection is announced — never communicate via color alone."
    - "Disabled options set aria-disabled=\"true\" AND ignore activation in JS (pointer-events styling is not enough)."
    - "Loading state uses an aria-live region (or aria-busy on the input) so AT users know results are pending."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/composites/ren-combobox/ren-combobox.css">
<script type="module" src="rends/components/composites/ren-combobox/ren-combobox.js"></script>
```

If the page already imports `rends/components/index.css`, do not import the CSS twice.

## Canonical Markup

```html
<ren-combobox name="country" placeholder="Search a country">
  <div class="ren-combobox-item" data-value="ar">Argentina</div>
  <div class="ren-combobox-item" data-value="uy">Uruguay</div>
</ren-combobox>

```

Use the docs page and source files listed below for full examples before adding production markup.

## Variants And Public Selectors

- `.ren-combobox`

## States And Attributes

- `[aria-disabled]`
- `[aria-selected]`
- `[data-side]`
- `[data-highlighted]`
- `placement`
- `:disabled`
- `:hover`

## Public Token API

- `None detected in the colocated CSS/JS. Check related files before inventing one.`

If no `--ren-*` token is detected here, theme through semantic tokens from `tokens/tokens.md` and avoid selector overrides.

## Accessibility Contract

- Preserve native semantics first; add ARIA only when semantic HTML is insufficient.
- Keep visible keyboard focus via RenDS focus tokens and `:focus-visible`.
- Keep interactive hit areas at 44px minimum unless this file's source clearly defines a smaller non-touch target.
- Respect reduced motion by using RenDS duration/easing tokens only.
- Do not communicate state through color alone.
- Keep JS behavior progressive: the visual structure should remain understandable before enhancement.

## Related Files

- `components/composites/ren-combobox/ren-combobox.css`
- `components/composites/ren-combobox/ren-combobox.js`
- `docs/components/ren-combobox.html`
- `ren-design.md`
- `tokens/tokens.md`
- `base/layouts.md`

## Test Expectations

- Run component or docs a11y coverage when markup, states, or ARIA change.
- Run CSS lint when selectors, tokens, or visual states change.
- Manually verify light/dark themes when color, surface, border, or shadow behavior changes.


