# nodes

id: component:composite:ren-number-field
type: component
name: ren-number-field
path: components/composites/ren-number-field
body:
# ren-number-field Component Contract

Numeric input composite with increment/decrement affordances.

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

## Purpose

- Provide the agent-facing public contract for the `ren-number-field` 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 Number Field 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:
    - "User needs a discrete numeric value with visible − / + stepper buttons (quantities, counts, ratings out of N)."
    - "Need long-press auto-repeat with acceleration on the stepper buttons (mouse and touch)."
    - "Need keyboard contract: ArrowUp/Down for ±step, Home/End for min/max, clamped to min/max/step bounds."
    - "Need data-invalid / data-valid states wired to the wrapper (single source of truth for the focus ring)."
    - "Need :has(:focus) to color the whole group, not just the inner input."
  avoidWhen:
    - "Free-form numeric typing without bounds — use a plain <input type=\"number\"> styled via ren-input."
    - "Continuous value selection with visible track — use ren-slider."
    - "Numeric value paired with a unit selector (10 px / 1 rem) — compose ren-input with a ren-select."
    - "Currency / locale-formatted amount with thousands separators — that needs a masked input, not a stepper."

canonicalImports:
  css:
    - "rends/components/composites/ren-number-field/ren-number-field.css"
  js:
    - "rends/components/composites/ren-number-field/ren-number-field.js"
  notes:
    - "Public Token API has no --ren-number-field-* tokens; theme through semantic input/fill tokens listed below."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Use <ren-number-field min=\"…\" max=\"…\" step=\"…\" value=\"…\"> as the host so attributes wire min/max/step on the inner input."
  - "Inner input is <input type=\"number\" class=\"ren-number-field-input\"> — keep type=\"number\" so mobile keyboards switch to digits."
  - "Decrement / increment must be real type=\"button\" steppers with class=\"ren-number-field-decrement\" / class=\"ren-number-field-increment\" and aria-label=\"Decrease\" / \"Increase\" (the component fills these if missing)."
  - "Validation state goes on the wrapper as [data-invalid] or [data-valid]; the inner focus ring color reads from --color-danger / --color-success."
  - "Size variants are .ren-number-field-sm and .ren-number-field-lg on the wrapper; do not size buttons or input independently."

forbiddenPatterns:
  - "Wrapping a <span role=\"button\"> instead of a real <button> for the steppers — long-press auto-repeat hooks depend on native button events."
  - "Showing the native spinner UI — the CSS already hides ::-webkit-inner-spin-button / ::-webkit-outer-spin-button and uses -moz-appearance: textfield."
  - "Bypassing the host's clamp() by writing this.value directly — call setValue(), increment(), or decrement() so min/max/step clamping runs and ren-change dispatches."
  - "Removing aria-label from the stepper buttons — the visible ± glyph alone has no accessible name."
  - "Custom outline on the inner input — focus styling is owned by the wrapper via :has(.ren-number-field-input:focus)."

tokenPolicy:
  allowed:
    - "Semantic input tokens: --color-input-bg, --color-input-bg-hover, --color-input-border, --color-input-border-focus, --color-input-focus-ring, --color-input-placeholder, --color-disabled-bg, --color-disabled-text."
    - "Semantic neutral / state tokens: --color-text, --color-border, --color-fill-hover, --color-fill-active, --color-danger, --color-success."
    - "Layout / type / motion tokens: --space-1, --space-2, --space-3, --space-4, --stroke-1, --radius-sm, --radius-md, --radius-lg, --touch-min, --size-sm, --size-lg, --body-size, --text-sm, --text-lg, --font-mono, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / rgb() values; the current CSS still has two rgb() calls for focus-ring tints — do not propagate that pattern, theme via tokens."
    - "Inventing --ren-number-field-* custom properties not present in the source until they ship in the Public Token API."

accessibility:
  required:
    - "Stepper buttons keep min-width / height = var(--touch-min) (44px) — do not shrink below this except inside .ren-number-field-sm for non-touch contexts."
    - "Each stepper has an aria-label (\"Decrease\" / \"Increase\"); the icon is decorative content."
    - "ArrowUp / ArrowDown on the input dispatch increment()/decrement(); Home / End jump to min / max; values are always clamped before dispatching ren-change."
    - "Disabled state sets disabled on the inner input AND both buttons so they are removed from the tab order — do not rely on opacity alone."
    - "data-invalid / data-valid changes border AND focus ring color, but always pair with text feedback near the field — color is not the only signal."
    - "Long-press auto-repeat must stop on pointerup/touchend AND mouseleave so dragging off the button cancels acceleration."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-number-field min="0" max="10" step="1" value="2"><button class="ren-number-field-decrement" type="button" aria-label="Decrease">−</button><input class="ren-number-field-input" type="number" aria-label="Quantity"><button class="ren-number-field-increment" type="button" aria-label="Increase">+</button></ren-number-field>

```

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

## Variants And Public Selectors

- `.ren-number-field`
- `.ren-number-field-decrement`
- `.ren-number-field-increment`
- `.ren-number-field-input`
- `.ren-number-field-lg`
- `.ren-number-field-sm`

## States And Attributes

- `[data-invalid]`
- `[data-valid]`
- `:active`
- `: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-number-field/ren-number-field.css`
- `components/composites/ren-number-field/ren-number-field.js`
- `docs/components/ren-number-field.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 NUMBER FIELD (STEPPER) COMPONENT
   ═══════════════════════════════════════════════════════════════
   A controlled number input with increment/decrement buttons.
   Supports keyboard navigation, long-press acceleration, and
   min/max bounds. Follows RenDS design token system.

   Usage:
   <ren-number-field min="0" max="100" step="1" value="5">
     <button class="ren-number-field-decrement" aria-label="Decrease">−</button>
     <input class="ren-number-field-input" type="number" value="5">
     <button class="ren-number-field-increment" aria-label="Increase">+</button>
   </ren-number-field>
   ═══════════════════════════════════════════════════════════════ */

/* ═══ BASE WRAPPER ═══ */
.ren-number-field {
  display: inline-flex;
  align-items: center;
  gap: 0;
  border: var(--stroke-1) solid var(--color-input-border);
  border-radius: var(--radius-md);
  background-color: var(--color-input-bg);
  transition: var(--transition-tactile);

  /* ═══ FOCUS STATE ON GROUP ═══ */
  &:has(.ren-number-field-input:focus) {
    border-color: var(--color-input-border-focus);
    box-shadow: 0 0 0 3px var(--color-input-focus-ring);
    background-color: var(--color-input-bg-hover);
  }

  /* ═══ HOVER STATE ═══ */
  &:hover:not(:has(:disabled)) {
    background-color: var(--color-input-bg-hover);
  }

  /* ═══ DISABLED STATE ═══ */
  &:has(:disabled) {
    background-color: var(--color-disabled-bg);
    border-color: var(--color-border);
    cursor: not-allowed;
    opacity: 0.7;
  }
}

/* ═══ STEPPER BUTTONS ═══ */
.ren-number-field-decrement,
.ren-number-field-increment {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: var(--touch-min);
  height: var(--touch-min);
  padding: 0 var(--space-2);
  background: transparent;
  border: none;
  color: var(--color-text);
  cursor: pointer;
  font-size: var(--text-lg);
  font-weight: 600;
  font-family: var(--font-mono);
  transition: var(--transition-tactile);

  /* ═══ REMOVE FOCUS OUTLINE - GROUP HANDLES IT ═══ */
  &:focus {
    outline: none;
  }

  /* ═══ HOVER STATE ═══ */
  &:hover:not(:disabled) {
    background-color: var(--color-fill-hover);
  }

  /* ═══ ACTIVE STATE ═══ */
  &:active:not(:disabled) {
    background-color: var(--color-fill-active);
  }

  /* ═══ DISABLED STATE ═══ */
  &:disabled {
    color: var(--color-disabled-text);
    cursor: not-allowed;
    opacity: 0.5;
  }
}

/* ═══ DECREMENT BUTTON (LEFT) ═══ */
.ren-number-field-decrement {
  border-inline-end: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-md) 0 0 var(--radius-md);

  /* ═══ MINUS ICON ═══ */
  &::before {
    content: '−';
  }
}

/* ═══ INCREMENT BUTTON (RIGHT) ═══ */
.ren-number-field-increment {
  border-inline-start: var(--stroke-1) solid var(--color-border);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;

  /* ═══ PLUS ICON ═══ */
  &::before {
    content: '+';
  }
}

/* ═══ INPUT FIELD (CENTER) ═══ */
.ren-number-field-input {
  flex: 1;
  min-width: 3rem;
  padding: var(--space-2) var(--space-3);
  font-size: var(--body-size);
  font-family: var(--font-mono);
  font-weight: 500;
  color: var(--color-text);
  text-align: center;
  background: transparent;
  border: none;
  outline: none;
  cursor: text;

  /* ═══ HIDE NATIVE NUMBER SPINNER ═══ */
  &::-webkit-outer-spin-button,
  &::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
  }

  &[type='number'] {
    -moz-appearance: textfield;
  }

  /* ═══ PLACEHOLDER ═══ */
  &::placeholder {
    color: var(--color-input-placeholder);
  }

  /* ═══ DISABLED ═══ */
  &:disabled {
    cursor: not-allowed;
    color: var(--color-disabled-text);
  }
}

/* ═══ VARIANT: SMALL ═══ */
.ren-number-field-sm {
  height: var(--size-sm);
  border-radius: var(--radius-sm);

  & .ren-number-field-decrement,
  & .ren-number-field-increment {
    min-width: var(--size-sm);
    height: var(--size-sm);
    padding: 0 var(--space-1);
    font-size: var(--text-sm);
  }

  & .ren-number-field-decrement {
    border-radius: var(--radius-sm) 0 0 var(--radius-sm);
  }

  & .ren-number-field-increment {
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  }

  & .ren-number-field-input {
    padding: var(--space-1) var(--space-2);
    font-size: var(--text-sm);
  }
}

/* ═══ VARIANT: LARGE ═══ */
.ren-number-field-lg {
  height: var(--size-lg);
  border-radius: var(--radius-lg);

  & .ren-number-field-decrement,
  & .ren-number-field-increment {
    min-width: var(--size-lg);
    height: var(--size-lg);
    padding: 0 var(--space-3);
    font-size: var(--text-lg);
  }

  & .ren-number-field-decrement {
    border-radius: var(--radius-lg) 0 0 var(--radius-lg);
  }

  & .ren-number-field-increment {
    border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
  }

  & .ren-number-field-input {
    padding: var(--space-3) var(--space-4);
    font-size: var(--text-lg);
  }
}

/* ═══ ERROR STATE ═══ */
.ren-number-field[data-invalid] {
  border-color: var(--color-danger);

  &:focus-within {
    box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
  }
}

/* ═══ SUCCESS STATE ═══ */
.ren-number-field[data-valid] {
  border-color: var(--color-success);

  &:focus-within {
    box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
  }
}

/* ═══ REDUCED MOTION ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-number-field,
  .ren-number-field-decrement,
  .ren-number-field-increment {
    transition: none;
  }
}


/* ═══════════════════════════════════════════════════════════════
   REN NUMBER FIELD (STEPPER) WEB COMPONENT
   ═══════════════════════════════════════════════════════════════
   A light-DOM web component that enhances a number input with
   increment/decrement buttons. Handles keyboard navigation,
   long-press auto-repeat with acceleration, and min/max bounds.

   Attributes:
   - min: Minimum value (default: 0)
   - max: Maximum value (default: 100)
   - step: Step size (default: 1)
   - value: Current value
   - disabled: Disable the field

   Events:
   - ren-change: Dispatched on value change { value, oldValue }

   Methods:
   - increment(): Increase value by step
   - decrement(): Decrease value by step
   - getValue(): Return current numeric value
   - setValue(val): Set value and clamp to bounds
   ═══════════════════════════════════════════════════════════════ */

export class RenNumberField extends HTMLElement {
  constructor() {
    super();

    /* ═══ STATE ═══ */
    this.min = 0;
    this.max = 100;
    this.step = 1;
    this.value = 0;
    this.pressTimeout = null;
    this.pressInterval = null;
    this.pressRepeatDelay = 500;
    this.pressRepeatInterval = 100;
    this.pressRepeatAcceleration = 0.95;

    /* ═══ BIND METHODS ═══ */
    this.handleDecrementPress = this.handleDecrementPress.bind(this);
    this.handleIncrementPress = this.handleIncrementPress.bind(this);
    this.handleDecrementRelease = this.handleDecrementRelease.bind(this);
    this.handleIncrementRelease = this.handleIncrementRelease.bind(this);
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleInputKeydown = this.handleInputKeydown.bind(this);
  }

  connectedCallback() {
    /* ═══ READ ATTRIBUTES ═══ */
    const minAttr = this.getAttribute('min');
    const maxAttr = this.getAttribute('max');
    const stepAttr = this.getAttribute('step');
    const valueAttr = this.getAttribute('value');
    const disabledAttr = this.hasAttribute('disabled');

    if (minAttr !== null) this.min = parseFloat(minAttr);
    if (maxAttr !== null) this.max = parseFloat(maxAttr);
    if (stepAttr !== null) this.step = parseFloat(stepAttr);
    if (valueAttr !== null) this.value = parseFloat(valueAttr);

    /* ═══ GET OR CREATE ELEMENTS ═══ */
    this.input = this.querySelector('.ren-number-field-input');
    this.decrementBtn = this.querySelector('.ren-number-field-decrement');
    this.incrementBtn = this.querySelector('.ren-number-field-increment');

    /* ═══ CREATE IF MISSING ═══ */
    if (!this.input) {
      this.input = document.createElement('input');
      this.input.type = 'number';
      this.input.className = 'ren-number-field-input';
      this.appendChild(this.input);
    }

    if (!this.decrementBtn) {
      this.decrementBtn = document.createElement('button');
      this.decrementBtn.className = 'ren-number-field-decrement';
      this.decrementBtn.setAttribute('aria-label', 'Decrease');
      this.insertBefore(this.decrementBtn, this.input);
    }

    if (!this.incrementBtn) {
      this.incrementBtn = document.createElement('button');
      this.incrementBtn.className = 'ren-number-field-increment';
      this.incrementBtn.setAttribute('aria-label', 'Increase');
      this.appendChild(this.incrementBtn);
    }

    // Steppers are controls for the number input, never form submitters.
    this.decrementBtn.type = 'button';
    this.incrementBtn.type = 'button';

    /* ═══ SET INITIAL VALUES ═══ */
    this.input.value = this.value.toString();
    this.input.min = this.min.toString();
    this.input.max = this.max.toString();
    this.input.step = this.step.toString();

    if (disabledAttr) {
      this.input.disabled = true;
      this.decrementBtn.disabled = true;
      this.incrementBtn.disabled = true;
    }

    /* ═══ ATTACH EVENT LISTENERS ═══ */
    this.input.addEventListener('change', this.handleInputChange);
    this.input.addEventListener('keydown', this.handleInputKeydown);

    /* ═══ BUTTON PRESS & HOLD ═══ */
    this.decrementBtn.addEventListener('mousedown', this.handleDecrementPress);
    this.decrementBtn.addEventListener('mouseup', this.handleDecrementRelease);
    this.decrementBtn.addEventListener('mouseleave', this.handleDecrementRelease);
    this.decrementBtn.addEventListener('touchstart', this.handleDecrementPress);
    this.decrementBtn.addEventListener('touchend', this.handleDecrementRelease);

    this.incrementBtn.addEventListener('mousedown', this.handleIncrementPress);
    this.incrementBtn.addEventListener('mouseup', this.handleIncrementRelease);
    this.incrementBtn.addEventListener('mouseleave', this.handleIncrementRelease);
    this.incrementBtn.addEventListener('touchstart', this.handleIncrementPress);
    this.incrementBtn.addEventListener('touchend', this.handleIncrementRelease);
  }

  disconnectedCallback() {
    /* ═══ CLEAN UP EVENT LISTENERS ═══ */
    if (this.input) {
      this.input.removeEventListener('change', this.handleInputChange);
      this.input.removeEventListener('keydown', this.handleInputKeydown);
    }

    if (this.decrementBtn) {
      this.decrementBtn.removeEventListener('mousedown', this.handleDecrementPress);
      this.decrementBtn.removeEventListener('mouseup', this.handleDecrementRelease);
      this.decrementBtn.removeEventListener('mouseleave', this.handleDecrementRelease);
      this.decrementBtn.removeEventListener('touchstart', this.handleDecrementPress);
      this.decrementBtn.removeEventListener('touchend', this.handleDecrementRelease);
    }

    if (this.incrementBtn) {
      this.incrementBtn.removeEventListener('mousedown', this.handleIncrementPress);
      this.incrementBtn.removeEventListener('mouseup', this.handleIncrementRelease);
      this.incrementBtn.removeEventListener('mouseleave', this.handleIncrementRelease);
      this.incrementBtn.removeEventListener('touchstart', this.handleIncrementPress);
      this.incrementBtn.removeEventListener('touchend', this.handleIncrementRelease);
    }

    /* ═══ CLEAR TIMERS ═══ */
    this.clearPressTimers();
  }

  /* ═══════════════════════════════════════════════════════════════
     DECREMENT BUTTON HANDLERS
     ═══════════════════════════════════════════════════════════════ */
  handleDecrementPress = (e) => {
    if (this.input.disabled || this.decrementBtn.disabled) return;
    e.preventDefault();

    this.decrement();

    /* ═══ START LONG PRESS REPEAT ═══ */
    this.pressTimeout = setTimeout(() => {
      let repeatInterval = this.pressRepeatInterval;

      this.pressInterval = setInterval(() => {
        this.decrement();
        /* ═══ ACCELERATE ═══ */
        repeatInterval *= this.pressRepeatAcceleration;
        clearInterval(this.pressInterval);

        this.pressInterval = setInterval(
          () => this.decrement(),
          Math.max(50, repeatInterval)
        );
      }, this.pressRepeatInterval);
    }, this.pressRepeatDelay);
  };

  handleDecrementRelease = () => {
    this.clearPressTimers();
  };

  /* ═══════════════════════════════════════════════════════════════
     INCREMENT BUTTON HANDLERS
     ═══════════════════════════════════════════════════════════════ */
  handleIncrementPress = (e) => {
    if (this.input.disabled || this.incrementBtn.disabled) return;
    e.preventDefault();

    this.increment();

    /* ═══ START LONG PRESS REPEAT ═══ */
    this.pressTimeout = setTimeout(() => {
      let repeatInterval = this.pressRepeatInterval;

      this.pressInterval = setInterval(() => {
        this.increment();
        /* ═══ ACCELERATE ═══ */
        repeatInterval *= this.pressRepeatAcceleration;
        clearInterval(this.pressInterval);

        this.pressInterval = setInterval(
          () => this.increment(),
          Math.max(50, repeatInterval)
        );
      }, this.pressRepeatInterval);
    }, this.pressRepeatDelay);
  };

  handleIncrementRelease = () => {
    this.clearPressTimers();
  };

  /* ═══════════════════════════════════════════════════════════════
     INPUT HANDLERS
     ═══════════════════════════════════════════════════════════════ */
  handleInputChange = (e) => {
    const newValue = parseFloat(e.target.value);

    if (!isNaN(newValue)) {
      const oldValue = this.value;
      this.value = this.clamp(newValue);
      this.input.value = this.value.toString();

      this.dispatchChangeEvent(oldValue);
    }
  };

  handleInputKeydown = (e) => {
    if (e.key === 'ArrowUp') {
      e.preventDefault();
      this.increment();
    } else if (e.key === 'ArrowDown') {
      e.preventDefault();
      this.decrement();
    } else if (e.key === 'Home') {
      e.preventDefault();
      const oldValue = this.value;
      this.value = this.min;
      this.input.value = this.value.toString();
      this.dispatchChangeEvent(oldValue);
    } else if (e.key === 'End') {
      e.preventDefault();
      const oldValue = this.value;
      this.value = this.max;
      this.input.value = this.value.toString();
      this.dispatchChangeEvent(oldValue);
    }
  };

  /* ═══════════════════════════════════════════════════════════════
     PUBLIC METHODS
     ═══════════════════════════════════════════════════════════════ */
  increment() {
    const oldValue = this.value;
    this.value = this.clamp(this.value + this.step);
    this.input.value = this.value.toString();
    this.dispatchChangeEvent(oldValue);
  }

  decrement() {
    const oldValue = this.value;
    this.value = this.clamp(this.value - this.step);
    this.input.value = this.value.toString();
    this.dispatchChangeEvent(oldValue);
  }

  getValue() {
    return this.value;
  }

  setValue(val) {
    const oldValue = this.value;
    this.value = this.clamp(parseFloat(val) || 0);
    this.input.value = this.value.toString();
    this.dispatchChangeEvent(oldValue);
  }

  /* ═══════════════════════════════════════════════════════════════
     UTILITY METHODS
     ═══════════════════════════════════════════════════════════════ */
  clamp(val) {
    return Math.max(this.min, Math.min(this.max, val));
  }

  dispatchChangeEvent(oldValue) {
    if (this.value !== oldValue) {
      this.dispatchEvent(
        new CustomEvent('ren-change', {
          bubbles: true,
          composed: true,
          detail: {
            value: this.value,
            oldValue: oldValue,
          },
        })
      );
    }
  }

  clearPressTimers() {
    if (this.pressTimeout) {
      clearTimeout(this.pressTimeout);
      this.pressTimeout = null;
    }

    if (this.pressInterval) {
      clearInterval(this.pressInterval);
      this.pressInterval = null;
    }
  }

  /* ═══════════════════════════════════════════════════════════════
     PROPERTIES
     ═══════════════════════════════════════════════════════════════ */
  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
      if (this.input) this.input.disabled = true;
      if (this.decrementBtn) this.decrementBtn.disabled = true;
      if (this.incrementBtn) this.incrementBtn.disabled = true;
    } else {
      this.removeAttribute('disabled');
      if (this.input) this.input.disabled = false;
      if (this.decrementBtn) this.decrementBtn.disabled = false;
      if (this.incrementBtn) this.incrementBtn.disabled = false;
    }
  }
}

/* ═══ REGISTER COMPONENT ═══ */
if (!customElements.get('ren-number-field')) {
  customElements.define('ren-number-field', RenNumberField);
}

data:
{"kind":"composite","selectors":[".ren-number-field",".ren-number-field-decrement",".ren-number-field-increment",".ren-number-field-input",".ren-number-field-lg",".ren-number-field-sm"],"tokens":["--body-size","--color-border","--color-danger","--color-disabled-bg","--color-disabled-text","--color-fill-active","--color-fill-hover","--color-input-bg","--color-input-bg-hover","--color-input-border","--color-input-border-focus","--color-input-focus-ring","--color-input-placeholder","--color-success","--color-text","--font-mono","--radius-lg","--radius-md","--radius-sm","--size-lg","--size-sm","--space-1","--space-2","--space-3","--space-4","--stroke-1","--text-lg","--text-sm","--touch-min","--transition-tactile"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-otp
type: component
name: ren-otp
path: components/composites/ren-otp
body:
# ren-otp Component Contract

One-time-passcode segmented input composite.

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

## Purpose

- Provide the agent-facing public contract for the `ren-otp` 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 Otp 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:
    - "User must enter a short fixed-length code split across N single-character slots (SMS OTP, 2FA, email PIN)."
    - "Need auto-advance to the next slot, Backspace to previous slot, Arrow keys to navigate, Home/End to jump to first/last."
    - "Need clipboard paste to distribute the pasted string across consecutive slots and focus the next empty one."
    - "Need numeric vs alphanumeric filtering (inputmode=\"numeric\" or \"text\") and a per-slot data-filled state."
    - "Need explicit data-valid / data-invalid states on the wrapper plus a ren-complete event when all slots fill."
  avoidWhen:
    - "User pastes a long password / token in one go — use a single <input type=\"password\"> with autocomplete=\"one-time-code\" if it is a single field."
    - "Free-form numeric value with unit / decimals — use ren-number-field."
    - "Code length is variable or unknown — use a single text input."
    - "Code is alphanumeric AND case-sensitive in a way the slot UI cannot make obvious — fall back to a single masked input."

canonicalImports:
  css:
    - "rends/components/composites/ren-otp/ren-otp.css"
  js:
    - "rends/components/composites/ren-otp/ren-otp.js"
  notes:
    - "Public Token API has no --ren-otp-* tokens; theme through the semantic input tokens and size scale listed below."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Use <ren-otp length=\"6\" type=\"numeric\"> as the host element — slots are generated by JS, do not author them by hand."
  - "If you must author slots, each must be a real <input class=\"ren-otp-slot\" type=\"text\" maxlength=\"1\" inputmode=\"numeric|text\" aria-label=\"Code digit N of M\" data-index=\"N\">."
  - "Optional separators are <div class=\"ren-otp-separator\"> placed between slot groups; they are pointer-events: none and decorative."
  - "Size variants .ren-otp-sm and .ren-otp-lg go on the wrapper; do not size individual .ren-otp-slot."
  - "Validation state goes on the wrapper as [data-valid] or [data-invalid]; the per-slot [data-filled] is set automatically by the component."

forbiddenPatterns:
  - "Using a single <input maxlength=\"6\"> styled to look segmented — keyboard caret control and paste-distribute won't work."
  - "Removing maxlength=\"1\" / inputmode — without them mobile keyboards switch back to letters and the auto-advance breaks."
  - "Manually wiring focus on slot blur / change — let handleSlotInput / handleSlotKeydown manage focus transitions to keep Backspace and paste flows correct."
  - "Hardcoding the slot size — use --touch-min for the default, --size-sm via .ren-otp-sm, --size-lg via .ren-otp-lg."
  - "Mixing data-valid and data-invalid on the wrapper simultaneously — setValid() removes the opposite attribute by design."

tokenPolicy:
  allowed:
    - "Semantic input tokens: --color-input-bg, --color-input-bg-hover, --color-input-border, --color-input-border-focus, --color-input-focus-ring, --color-input-placeholder, --color-disabled-bg, --color-disabled-text."
    - "Semantic state / text tokens: --color-text, --color-text-muted, --color-border, --color-fill, --color-accent, --color-danger, --color-success."
    - "Layout / type / motion tokens: --space-0-5, --space-1, --space-2, --space-3, --stroke-1, --radius-sm, --radius-md, --radius-lg, --touch-min, --size-sm, --size-lg, --text-sm, --text-lg, --body-size, --font-mono, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / rgb() colors for focus rings or borders; the current CSS still uses rgb() for the danger / success ring tints — do not replicate, prefer tokens."
    - "Inventing --ren-otp-* custom properties not present in the source until they ship in the Public Token API."

accessibility:
  required:
    - "Slot dimensions default to var(--touch-min) (44px) for thumb accuracy; --size-sm only for non-touch surfaces."
    - "Each slot exposes aria-label=\"Code digit N of M\" so screen readers announce position; do not strip these labels."
    - "Backspace, ArrowLeft/Right, Home, End move focus across slots — keep the keydown handler intact when extending the component."
    - "Paste handler must run on every slot so users can paste from any slot, not only the first."
    - "data-valid and data-invalid are paired with text feedback elsewhere on the form (do not communicate validation through color alone)."
    - ":focus-visible draws --color-accent at 2px outline; do not remove it without restoring an equivalent ring."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-otp length="6" type="numeric" aria-label="Verification code"></ren-otp>

```

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

## Variants And Public Selectors

- `.ren-otp`
- `.ren-otp-lg`
- `.ren-otp-separator`
- `.ren-otp-slot`
- `.ren-otp-sm`

## States And Attributes

- `[data-filled]`
- `[data-invalid]`
- `[data-valid]`
- `: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/composites/ren-otp/ren-otp.css`
- `components/composites/ren-otp/ren-otp.js`
- `docs/components/ren-otp.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 OTP (ONE-TIME PASSWORD) INPUT COMPONENT
   ═══════════════════════════════════════════════════════════════
   A flexible OTP input with auto-advancing slots, paste support,
   and validation states. Handles numeric and alphanumeric codes.
   Follows RenDS design token system.

   Usage:
   <ren-otp length="6" type="numeric">
     \3c !-- Slots auto-generated by JS -->
   </ren-otp>

   Attributes:
   - length: Number of OTP digits (default: 6)
   - type: "numeric" or "alphanumeric" (default: "numeric")
   - disabled: Disable all slots

   Data Attributes:
   - data-valid: Success state
   - data-invalid: Error state
   - data-filled: On individual slots when filled
   ═══════════════════════════════════════════════════════════════ */

/* ═══ BASE WRAPPER ═══ */
.ren-otp {
  container-type: inline-size;
  container-name: ren-otp;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* ═══ INDIVIDUAL SLOT ═══ */
.ren-otp-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-min);
  height: var(--touch-min);
  padding: 0;
  font-size: var(--text-lg);
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--color-text);
  background-color: var(--color-input-bg);
  border: var(--stroke-1) solid var(--color-input-border);
  border-radius: var(--radius-md);
  outline: none;
  cursor: text;
  caret-color: var(--color-accent);
  text-align: center;
  transition: var(--transition-tactile);

  /* ═══ PLACEHOLDER STYLING ═══ */
  &::placeholder {
    color: var(--color-input-placeholder);
  }

  /* ═══ HOVER STATE ═══ */
  &:hover:not(:disabled):not(:focus) {
    background-color: var(--color-input-bg-hover);
  }

  /* ═══ FOCUS STATE ═══ */
  &:focus {
    border-color: var(--color-input-border-focus);
    box-shadow: 0 0 0 3px var(--color-input-focus-ring);
    background-color: var(--color-input-bg-hover);
  }

  /* ═══ FILLED STATE ═══ */
  &[data-filled] {
    background-color: var(--color-fill);
    border-color: var(--color-input-border);
  }

  /* ═══ DISABLED STATE ═══ */
  &:disabled {
    background-color: var(--color-disabled-bg);
    color: var(--color-disabled-text);
    cursor: not-allowed;
    opacity: 0.7;
    border-color: var(--color-border);
  }
}

/* ═══ SEPARATOR ═══ */
.ren-otp-separator {
  display: flex;
  align-items: center;
  justify-content: center;
  width: auto;
  height: var(--touch-min);
  padding: 0 var(--space-1);
  color: var(--color-text-muted);
  font-size: var(--text-lg);
  font-weight: 300;
  user-select: none;
  pointer-events: none;
}

/* ═══════════════════════════════════════════════════════════════
   VALIDATION STATES
   ═══════════════════════════════════════════════════════════════ */

/* ═══ ERROR STATE ═══ */
.ren-otp[data-invalid] .ren-otp-slot {
  border-color: var(--color-danger);

  &:focus {
    box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
    border-color: var(--color-danger);
  }
}

/* ═══ SUCCESS STATE ═══ */
.ren-otp[data-valid] .ren-otp-slot {
  border-color: var(--color-success);

  &:focus {
    box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
    border-color: var(--color-success);
  }
}

/* ═══════════════════════════════════════════════════════════════
   SIZE VARIANTS
   ═══════════════════════════════════════════════════════════════ */

/* ═══ SMALL VARIANT ═══ */
.ren-otp-sm {
  gap: var(--space-1);

  & .ren-otp-slot {
    width: var(--size-sm);
    height: var(--size-sm);
    font-size: var(--text-sm);
    border-radius: var(--radius-sm);
  }

  & .ren-otp-separator {
    height: var(--size-sm);
    font-size: var(--text-sm);
  }
}

/* ═══ LARGE VARIANT ═══ */
.ren-otp-lg {
  gap: var(--space-3);

  & .ren-otp-slot {
    width: var(--size-lg);
    height: var(--size-lg);
    font-size: var(--body-size);
    border-radius: var(--radius-lg);
  }

  & .ren-otp-separator {
    height: var(--size-lg);
    font-size: var(--body-size);
  }
}

/* ═══════════════════════════════════════════════════════════════
   ACCESSIBILITY
   ═══════════════════════════════════════════════════════════════ */

/* ═══ FOCUS VISIBLE (KEYBOARD NAVIGATION) ═══ */
.ren-otp-slot:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* ═══ DISABLED WRAPPER ═══ */
.ren-otp[disabled] .ren-otp-slot {
  cursor: not-allowed;
  opacity: 0.6;
}

/* ═══════════════════════════════════════════════════════════════
   REDUCED MOTION
   ═══════════════════════════════════════════════════════════════ */

@media (prefers-reduced-motion: reduce) {
  .ren-otp-slot {
    transition: none;
  }
}

/* ═══════════════════════════════════════════════════════════════
   MOBILE RESPONSIVENESS
   ═══════════════════════════════════════════════════════════════ */

@container ren-otp (max-width: 360px) {
  .ren-otp {
    gap: var(--space-1);

    & .ren-otp-slot {
      width: var(--size-sm);
      height: var(--size-sm);
      font-size: var(--text-sm);
    }

    & .ren-otp-separator {
      height: var(--size-sm);
      font-size: var(--text-sm);
      padding: 0 var(--space-0-5);
    }
  }
}


/* ═══════════════════════════════════════════════════════════════
   REN OTP (ONE-TIME PASSWORD) INPUT WEB COMPONENT
   ═══════════════════════════════════════════════════════════════
   A light-DOM web component for collecting OTP/PIN codes.
   Auto-creates input slots, manages focus transitions,
   handles paste events, and validates completion.

   Attributes:
   - length: Number of code slots (default: 6)
   - type: "numeric" or "alphanumeric" (default: "numeric")
   - disabled: Disable all slots

   Data Attributes:
   - data-valid: Set to mark as success
   - data-invalid: Set to mark as error
   - data-filled: Set on individual slots when they have content

   Events:
   - ren-change: Dispatched on any slot change { value: partial }
   - ren-complete: Dispatched when all slots filled { value: complete }

   Methods:
   - getValue(): Return current combined code
   - reset(): Clear all slots
   - focus(): Focus first slot
   ═══════════════════════════════════════════════════════════════ */

export class RenOtp extends HTMLElement {
  constructor() {
    super();

    /* ═══ STATE ═══ */
    this.length = 6;
    this.type = 'numeric';
    this.slots = [];

    /* ═══ BIND METHODS ═══ */
    this.handleSlotInput = this.handleSlotInput.bind(this);
    this.handleSlotKeydown = this.handleSlotKeydown.bind(this);
    this.handleSlotPaste = this.handleSlotPaste.bind(this);
    this.handleSlotFocus = this.handleSlotFocus.bind(this);
  }

  connectedCallback() {
    /* ═══ READ ATTRIBUTES ═══ */
    const lengthAttr = this.getAttribute('length');
    const typeAttr = this.getAttribute('type');
    const disabledAttr = this.hasAttribute('disabled');

    if (lengthAttr !== null) this.length = parseInt(lengthAttr, 10);
    if (typeAttr !== null) this.type = typeAttr;

    /* ═══ CREATE SLOTS ═══ */
    this.createSlots(disabledAttr);
  }

  /* ═══════════════════════════════════════════════════════════════
     SLOT CREATION & SETUP
     ═══════════════════════════════════════════════════════════════ */
  createSlots(disabled = false) {
    /* ═══ CLEAR EXISTING SLOTS ═══ */
    this.slots = [];
    const existingSlots = this.querySelectorAll('.ren-otp-slot');
    existingSlots.forEach((slot) => slot.remove());

    /* ═══ CREATE NEW SLOTS ═══ */
    for (let i = 0; i < this.length; i++) {
      const slot = document.createElement('input');
      slot.type = 'text';
      slot.className = 'ren-otp-slot';
      slot.setAttribute('inputmode', this.type === 'numeric' ? 'numeric' : 'text');
      slot.setAttribute('maxlength', '1');
      slot.setAttribute('placeholder', '•');
      slot.setAttribute('autocomplete', 'off');
      slot.setAttribute('data-index', i.toString());
      slot.setAttribute('aria-label', `Code digit ${i + 1} of ${this.length}`);

      if (disabled) {
        slot.disabled = true;
      }

      /* ═══ ATTACH EVENT LISTENERS ═══ */
      slot.addEventListener('input', this.handleSlotInput);
      slot.addEventListener('keydown', this.handleSlotKeydown);
      slot.addEventListener('paste', this.handleSlotPaste);
      slot.addEventListener('focus', this.handleSlotFocus);

      this.appendChild(slot);
      this.slots.push(slot);
    }
  }

  /* ═══════════════════════════════════════════════════════════════
     EVENT HANDLERS
     ═══════════════════════════════════════════════════════════════ */

  handleSlotInput = (e) => {
    const slot = e.target;
    const index = parseInt(slot.getAttribute('data-index'), 10);
    let value = slot.value;

    /* ═══ VALIDATE INPUT ═══ */
    if (this.type === 'numeric') {
      value = value.replace(/[^0-9]/g, '');
    } else if (this.type === 'alphanumeric') {
      value = value.replace(/[^a-zA-Z0-9]/g, '');
    }

    /* ═══ ENFORCE MAX LENGTH ═══ */
    if (value.length > 1) {
      value = value.slice(0, 1);
    }

    slot.value = value;

    /* ═══ UPDATE FILLED STATE ═══ */
    if (value) {
      slot.setAttribute('data-filled', '');
    } else {
      slot.removeAttribute('data-filled');
    }

    /* ═══ AUTO-ADVANCE TO NEXT SLOT ═══ */
    if (value && index < this.slots.length - 1) {
      this.slots[index + 1].focus();
    }

    /* ═══ DISPATCH CHANGE EVENT ═══ */
    this.dispatchChangeEvent();

    /* ═══ CHECK COMPLETION ═══ */
    this.checkCompletion();
  };

  handleSlotKeydown = (e) => {
    const slot = e.target;
    const index = parseInt(slot.getAttribute('data-index'), 10);

    /* ═══ BACKSPACE: MOVE TO PREVIOUS SLOT ═══ */
    if (e.key === 'Backspace') {
      if (slot.value) {
        slot.value = '';
        slot.removeAttribute('data-filled');
        this.dispatchChangeEvent();
      } else if (index > 0) {
        e.preventDefault();
        const prevSlot = this.slots[index - 1];
        prevSlot.value = '';
        prevSlot.removeAttribute('data-filled');
        prevSlot.focus();
        this.dispatchChangeEvent();
      }
    }

    /* ═══ ARROW KEYS: NAVIGATE SLOTS ═══ */
    if (e.key === 'ArrowLeft' && index > 0) {
      e.preventDefault();
      this.slots[index - 1].focus();
    } else if (e.key === 'ArrowRight' && index < this.slots.length - 1) {
      e.preventDefault();
      this.slots[index + 1].focus();
    }

    /* ═══ HOME/END: FIRST/LAST SLOT ═══ */
    if (e.key === 'Home') {
      e.preventDefault();
      this.slots[0].focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      this.slots[this.slots.length - 1].focus();
    }
  };

  handleSlotPaste = (e) => {
    e.preventDefault();
    const pastedData = e.clipboardData?.getData('text') || '';

    if (!pastedData) return;

    /* ═══ VALIDATE PASTED DATA ═══ */
    let sanitized = pastedData;
    if (this.type === 'numeric') {
      sanitized = pastedData.replace(/[^0-9]/g, '');
    } else if (this.type === 'alphanumeric') {
      sanitized = pastedData.replace(/[^a-zA-Z0-9]/g, '');
    }

    /* ═══ DISTRIBUTE ACROSS SLOTS ═══ */
    const index = parseInt(e.target.getAttribute('data-index'), 10);
    for (let i = 0; i < sanitized.length && index + i < this.slots.length; i++) {
      const slot = this.slots[index + i];
      slot.value = sanitized[i];
      slot.setAttribute('data-filled', '');
    }

    /* ═══ FOCUS NEXT EMPTY OR LAST SLOT ═══ */
    const nextEmptyIndex = this.slots.findIndex(
      (s, i) => i >= index && s.value === ''
    );
    if (nextEmptyIndex !== -1) {
      this.slots[nextEmptyIndex].focus();
    } else {
      this.slots[this.slots.length - 1].focus();
    }

    /* ═══ DISPATCH CHANGE & CHECK COMPLETION ═══ */
    this.dispatchChangeEvent();
    this.checkCompletion();
  };

  handleSlotFocus = (e) => {
    /* ═══ SELECT ALL TEXT WHEN FOCUSED ═══ */
    e.target.select();
  };

  /* ═══════════════════════════════════════════════════════════════
     PUBLIC METHODS
     ═══════════════════════════════════════════════════════════════ */

  getValue() {
    return this.slots.map((slot) => slot.value).join('');
  }

  reset() {
    this.slots.forEach((slot) => {
      slot.value = '';
      slot.removeAttribute('data-filled');
    });

    this.removeAttribute('data-valid');
    this.removeAttribute('data-invalid');
    this.dispatchChangeEvent();

    if (this.slots.length > 0) {
      this.slots[0].focus();
    }
  }

  setValid(isValid) {
    if (isValid) {
      this.setAttribute('data-valid', '');
      this.removeAttribute('data-invalid');
    } else {
      this.setAttribute('data-invalid', '');
      this.removeAttribute('data-valid');
    }
  }

  clearValidation() {
    this.removeAttribute('data-valid');
    this.removeAttribute('data-invalid');
  }

  /* ═══════════════════════════════════════════════════════════════
     UTILITY METHODS
     ═══════════════════════════════════════════════════════════════ */

  checkCompletion() {
    const value = this.getValue();
    const isComplete = value.length === this.length;

    if (isComplete) {
      this.dispatchCompleteEvent(value);
    }
  }

  dispatchChangeEvent() {
    const value = this.getValue();

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

  dispatchCompleteEvent(value) {
    this.dispatchEvent(
      new CustomEvent('ren-complete', {
        bubbles: true,
        composed: true,
        detail: {
          value: value,
        },
      })
    );
  }

  /* ═══════════════════════════════════════════════════════════════
     PROPERTIES
     ═══════════════════════════════════════════════════════════════ */

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

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
      this.slots.forEach((slot) => {
        slot.disabled = true;
      });
    } else {
      this.removeAttribute('disabled');
      this.slots.forEach((slot) => {
        slot.disabled = false;
      });
    }
  }

  /* ═══ OVERRIDE FOCUS METHOD ═══ */
  focus() {
    if (this.slots.length > 0) {
      this.slots[0].focus();
    }
  }
}

/* ═══ REGISTER COMPONENT ═══ */
if (!customElements.get('ren-otp')) {
  customElements.define('ren-otp', RenOtp);
}

data:
{"kind":"composite","selectors":[".ren-otp",".ren-otp-lg",".ren-otp-separator",".ren-otp-slot",".ren-otp-sm"],"tokens":["--body-size","--color-accent","--color-border","--color-danger","--color-disabled-bg","--color-disabled-text","--color-fill","--color-input-bg","--color-input-bg-hover","--color-input-border","--color-input-border-focus","--color-input-focus-ring","--color-input-placeholder","--color-success","--color-text","--color-text-muted","--font-mono","--radius-lg","--radius-md","--radius-sm","--size-lg","--size-sm","--space-0-5","--space-1","--space-2","--space-3","--stroke-1","--text-lg","--text-sm","--touch-min","--transition-tactile"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-popover
type: component
name: ren-popover
path: components/composites/ren-popover
body:
# ren-popover Component Contract

Non-modal floating surface for contextual controls or content.

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

## Purpose

- Provide the agent-facing public contract for the `ren-popover` 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 Popover 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:
    - "Click on a trigger should reveal a small contextual surface (filters, mini-form, info card) anchored to that trigger."
    - "You need anchor-positioned placement (top/right/bottom/left) via position-area with automatic viewport flip via position-try-fallbacks."
    - "Need click-outside + Escape dismissal and a visible arrow caret that re-rotates per [data-side]."
    - "Need progressive enhancement: anchor positioning in modern browsers, JS computePosition() fallback elsewhere."
    - "Need header / body / footer slots inside the popover surface, with native role=\"dialog\" + aria-modal=\"false\"."
  avoidWhen:
    - "The disclosure blocks the page and demands the user's attention — use ren-dialog (modal)."
    - "Disclosure is reveal-on-hover-intent for non-essential preview content — use ren-hover-card."
    - "Disclosure is a short text annotation only — use ren-tooltip."
    - "Disclosure is a list of commands (menu) — use ren-menu / ren-context-menu."
    - "Disclosure is a side sheet — use ren-sheet."

canonicalImports:
  css:
    - "rends/components/composites/ren-popover/ren-popover.css"
  js:
    - "rends/components/composites/ren-popover/ren-popover.js"
  notes:
    - "JS attaches click + Escape + outside-click handlers and computes JS positioning only when CSS anchor positioning is unsupported."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Use <ren-popover placement=\"bottom\" offset=\"8\"> as the host so it adds .ren-popover, role=\"dialog\", and popover=\"manual\" automatically."
  - "The trigger is referenced via trigger-id=\"<id>\", or by a child / previous-sibling with [data-popover-trigger]; the component picks them in that order."
  - "Inside the popover, group content into .ren-popover-header / .ren-popover-body / .ren-popover-footer; a .ren-popover-arrow div is injected if missing."
  - "Triggers should be real interactive elements (<button> or <a>) so click + keyboard activation work; the component does not click-style arbitrary nodes."
  - "When using multiple popovers in the same view, set unique trigger-id values so the click handlers do not cross-wire."

forbiddenPatterns:
  - "Manually wiring trigger.addEventListener('click', popover.show) — the host already attaches its click + dismissable listeners."
  - "Calling showPopover() directly while bypassing open() — aria-modal, data-state, and ren-open event won't fire."
  - "Hardcoding inline left/top values — let CSS position-anchor + position-area + position-try-fallbacks handle placement and flips (or the JS computePosition fallback)."
  - "Removing the .ren-popover-arrow element — the [data-side] rules rotate it per side; without it the visual anchor cue disappears."
  - "Using ren-popover purely as a click-bound tooltip — accessibility expectations differ; use ren-tooltip for short, non-interactive text."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-popover-bg, --ren-popover-border-color, --ren-popover-duration, --ren-popover-easing, --ren-popover-padding, --ren-popover-radius, --ren-popover-shadow, --ren-popover-width."
    - "Semantic tokens used inside content: --color-surface, --color-border, --color-text-primary, --color-text-secondary, --shadow-lg, --radius-lg."
    - "Layout / type / motion tokens: --space-2, --space-3, --space-4, --size-body-sm, --z-popover, --duration-enter, --ease-enter."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded backdrop / surface colors via inline styles or rgba()."
    - "Custom animation timings; reuse --duration-enter / --ease-enter (or --ren-popover-duration / --ren-popover-easing) so reduced-motion overrides apply."

accessibility:
  required:
    - "<ren-popover> sets role=\"dialog\" with aria-modal=\"false\" (non-modal by design). Do not set aria-modal=\"true\" or apply focus trapping."
    - "Trigger must be a focusable, real interactive element; click + Enter/Space activation come from native button semantics."
    - "Escape closes the popover via the document keydown handler; outside-click closes via the document click handler — do not stopPropagation on container click outside these targets."
    - "When CSS anchor positioning is unsupported the host applies left/top to the popover element; do not overlay your own positioning style or the flip logic breaks."
    - "Reduced motion strips the transform animation (opacity-only); keep the @media (prefers-reduced-motion: reduce) block intact."
    - "Avoid putting form submit buttons that affect the page outside the popover surface — keep the interaction self-contained within the dialog role."
```

## Required Imports

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

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

## Canonical Markup

```html
<button id="account-trigger" type="button" data-popover-trigger>Account</button>
<ren-popover trigger-id="account-trigger" placement="bottom">
  <div class="ren-popover-header"><strong>Account</strong></div>
  <div class="ren-popover-body">Signed in as Jane</div>
</ren-popover>

```

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

## Variants And Public Selectors

- `.ren-open`
- `.ren-popover`
- `.ren-popover-arrow`
- `.ren-popover-body`
- `.ren-popover-footer`
- `.ren-popover-header`
- `.ren-popover-trigger`

## States And Attributes

- `[data-popover-trigger]`
- `[data-side]`

## Public Token API

- `--ren-popover-bg`
- `--ren-popover-border-color`
- `--ren-popover-duration`
- `--ren-popover-easing`
- `--ren-popover-padding`
- `--ren-popover-radius`
- `--ren-popover-shadow`
- `--ren-popover-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-popover/ren-popover.css`
- `components/composites/ren-popover/ren-popover.js`
- `docs/components/ren-popover.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.


/* ═══ ANCHOR POSITIONING & BASE STYLES ═══ */
.ren-popover {
  position: absolute;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: var(--space-4);
  max-width: 20rem;
  z-index: var(--z-popover, 1000);
  pointer-events: auto;
  margin: 0;
  inset: auto;
  border-top: none;

  /* Anchor positioning (modern browsers) */
  position-anchor: --popover-anchor;

  /* Default: below the trigger with automatic fallback */
  position-area: bottom span-all;

  /* Automatic flip when near viewport edges */
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;

  /* Enter/exit transitions driven by semantic motion tokens. */
  opacity: 0;
  transform: translateY(-4px) scale(0.95);
  transition:
    opacity   var(--duration-enter) var(--ease-enter),
    transform var(--duration-enter) var(--ease-enter),
    overlay   var(--duration-enter) var(--ease-enter) allow-discrete,
    display   var(--duration-enter) var(--ease-enter) allow-discrete;
}

/* ═══ POPOVER OPEN STATE ═══ */
.ren-popover:popover-open,
.ren-popover.ren-open {
  opacity: 1;
  transform: translateY(0) scale(1);
}

/* ═══ POPOVER OPEN WITH @starting-style ═══ */
@starting-style {
  .ren-popover:popover-open {
    opacity: 0;
    transform: translateY(-4px) scale(0.95);
  }
}

/* ═══ POPOVER ARROW/CARET ═══ */
.ren-popover-arrow {
  position: absolute;
  width: 8px;
  height: 8px;
  background: inherit;
  border: inherit;
  border-top: none;
  border-inline-start: none;
  transform: rotate(45deg);
  z-index: -1;
}

/* ═══ PLACEMENT VARIANTS ═══ */
/* Bottom (default) */
.ren-popover[data-side="bottom"],
.ren-popover:not([data-side]) {
  position-area: bottom span-all;
  margin-block-start: var(--space-2);
  margin-block-end: 0;
  margin-inline: 0;

  & .ren-popover-arrow {
    top: -4px;
    left: 50%;
    transform: translateX(-50%) rotate(225deg);
  }
}

/* Top */
.ren-popover[data-side="top"] {
  position-area: top span-all;
  margin-block-start: 0;
  margin-block-end: var(--space-2);
  margin-inline: 0;

  & .ren-popover-arrow {
    bottom: -4px;
    left: 50%;
    transform: translateX(-50%) rotate(45deg);
  }
}

/* Right */
.ren-popover[data-side="right"] {
  position-area: right span-all;
  margin-block: 0;
  margin-inline-start: var(--space-2);
  margin-inline-end: 0;

  & .ren-popover-arrow {
    left: -4px;
    top: 50%;
    transform: translateY(-50%) rotate(135deg);
  }
}

/* Left */
.ren-popover[data-side="left"] {
  position-area: left span-all;
  margin-block: 0;
  margin-inline-start: 0;
  margin-inline-end: var(--space-2);

  & .ren-popover-arrow {
    right: -4px;
    top: 50%;
    transform: translateY(-50%) rotate(315deg);
  }
}

/* ═══ POPOVER SECTIONS ═══ */
.ren-popover-header {
  margin-bottom: var(--space-2);
  font-weight: 600;
  color: var(--color-text-primary);
}

.ren-popover-body {
  color: var(--color-text-secondary);
  font-size: var(--size-body-sm);
  line-height: 1.5;
}

.ren-popover-footer {
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border);
  display: flex;
  gap: var(--space-2);
}

/* ═══ RESPECTS MOTION PREFERENCES ═══
   Semantic --duration-enter already collapses to 0ms under
   reduced-motion. We keep this block to drop the transform
   (scale/translate) in the starting-style, so the popover
   fades in place instead of moving. */
@media (prefers-reduced-motion: reduce) {
  .ren-popover {
    transition:
      opacity var(--duration-enter),
      overlay var(--duration-enter) allow-discrete,
      display var(--duration-enter) allow-discrete;
    transform: none;
  }

  @starting-style {
    .ren-popover:popover-open {
      opacity: 0;
      transform: none;
    }
  }
}

/* ═══ FALLBACK FOR BROWSERS WITHOUT COMPLETE ANCHOR POSITIONING ═══ */
@supports not ((anchor-name: --ren-anchor) and
  (position-anchor: --ren-anchor) and
  (position-area: bottom span-all)) {
  .ren-popover {
    position: absolute;
    inset: auto auto auto auto;
  }

  [data-popover-trigger],
  .ren-popover-trigger {
    position: relative;
  }

  /* Fallback animations */
  .ren-popover {
    animation: ren-popover-fallback-open var(--duration-enter) var(--ease-enter);
  }

  .ren-popover:popover-open,
  .ren-popover.ren-open {
    animation: ren-popover-fallback-open var(--duration-enter) var(--ease-enter);
  }

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

@keyframes ren-popover-fallback-open {
  from {
    opacity: 0;
    transform: translateY(-4px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* Appearance API bridge */
.ren-popover {
  width: var(--ren-popover-width, 280px);
  max-width: var(--ren-popover-width, 280px);
  padding: var(--ren-popover-padding, var(--space-4));
  border-radius: var(--ren-popover-radius, var(--radius-lg));
  background: var(--ren-popover-bg, var(--color-surface-overlay));
  border-color: var(--ren-popover-border-color, var(--color-border));
  box-shadow: var(--ren-popover-shadow, var(--shadow-lg));
  transition-duration: var(--ren-popover-duration, var(--duration-normal));
  transition-timing-function: var(--ren-popover-easing, var(--ease-out));
}


let nextPopoverId = 0;
const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');
const PLACEMENTS = new Set(['top', 'right', 'bottom', 'left']);

function supportsAnchorPositioning() {
  return (
    typeof CSS !== 'undefined' &&
    typeof CSS.supports === 'function' &&
    CSS.supports('anchor-name', '--ren-anchor') &&
    CSS.supports('position-anchor', '--ren-anchor') &&
    CSS.supports('position-area', 'bottom span-all')
  );
}

function normalizePlacement(value, fallback = 'bottom') {
  return PLACEMENTS.has(value) ? value : fallback;
}

/**
 * Fallback position computation for browsers without CSS anchor positioning.
 * Used only when full CSS anchor positioning support is unavailable.
 *
 * @param {HTMLElement} trigger - The trigger element
 * @param {HTMLElement} popover - The popover element
 * @param {string} placement - Placement: 'top', 'right', 'bottom', 'left'
 * @param {number} offset - Offset in pixels between trigger and popover
 * @returns {Object} Position object with x, y, and finalPlacement properties
 */
function computePosition(trigger, popover, placement = 'bottom', offset = 8) {
  const triggerRect = trigger.getBoundingClientRect();
  const popoverRect = popover.getBoundingClientRect();
  const viewport = { width: window.innerWidth, height: window.innerHeight };

  let x = 0;
  let y = 0;
  let finalPlacement = placement;

  const placements = {
    top: () => {
      x = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
      y = triggerRect.top - popoverRect.height - offset;
      if (y < 0) {
        finalPlacement = 'bottom';
        return placements.bottom();
      }
      return { x, y };
    },
    bottom: () => {
      x = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
      y = triggerRect.bottom + offset;
      if (y + popoverRect.height > viewport.height) {
        finalPlacement = 'top';
        return placements.top();
      }
      return { x, y };
    },
    left: () => {
      x = triggerRect.left - popoverRect.width - offset;
      y = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
      if (x < 0) {
        finalPlacement = 'right';
        return placements.right();
      }
      return { x, y };
    },
    right: () => {
      x = triggerRect.right + offset;
      y = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
      if (x + popoverRect.width > viewport.width) {
        finalPlacement = 'left';
        return placements.left();
      }
      return { x, y };
    },
  };

  const result = placements[placement]?.() || placements.bottom();

  // Clamp X position within viewport
  if (result.x < 0) result.x = 8;
  else if (result.x + popoverRect.width > viewport.width)
    result.x = viewport.width - popoverRect.width - 8;

  return { ...result, finalPlacement };
}

/**
 * Popover component with CSS Anchor Positioning and native Popover API
 *
 * Displays content relative to a trigger element with automatic viewport collision
 * handling via CSS anchor positioning. Falls back to JS positioning in unsupported browsers.
 *
 * @example
 * <button data-popover-trigger>Open Popover</button>
 * <ren-popover placement="bottom" offset="8">
 *   <div class="ren-popover-header">Title</div>
 *   <div class="ren-popover-body">Content</div>
 * </ren-popover>
 *
 * @fires ren-open - Fired when popover opens
 * @fires ren-close - Fired when popover closes
 */
export class RenPopover extends HTMLElement {
  static observedAttributes = ['placement'];
  static supportsAnchor = supportsAnchorPositioning();

  #trigger = null;
  #triggerController = null;
  #dismissController = null;

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'placement' && oldValue !== newValue) {
      this.#syncPlacement();
    }
  }

  connectedCallback() {
    this.setupPopover();
    this.findTrigger();
    this.attachTriggerListener();
  }

  disconnectedCallback() {
    this.cleanup();
  }

  /**
   * Setup popover element with required attributes
   * @private
   */
  setupPopover() {
    this.classList.add('ren-popover');

    if (!this.id) {
      this.id = `ren-popover-${++nextPopoverId}`;
    }

    // Add arrow if not present
    if (!this.querySelector('.ren-popover-arrow')) {
      const arrow = document.createElement('div');
      arrow.className = 'ren-popover-arrow';
      this.appendChild(arrow);
    }

    // Setup native Popover API
    if ('popover' in HTMLElement.prototype) {
      this.setAttribute('popover', 'manual');
    }

    // Set accessibility attributes
    this.setAttribute('role', 'dialog');
    this.setAttribute('aria-modal', 'false');
    this.#syncPlacement();
  }

  /**
   * Find the trigger element
   * @private
   */
  findTrigger() {
    // Explicit trigger ID
    const triggerId = this.getAttribute('trigger-id');
    if (triggerId) {
      this.#trigger = document.getElementById(triggerId);
    }

    // Query selector for data-popover-trigger
    if (!this.#trigger) {
      this.#trigger = this.querySelector('[data-popover-trigger]');
    }

    // Fall back to previous sibling
    if (!this.#trigger) {
      this.#trigger = this.previousElementSibling;
    }
  }

  /**
   * Attach trigger listener and setup anchor relationship
   * @private
   */
  attachTriggerListener() {
    if (!this.#trigger) return;

    this.#triggerController?.abort();
    this.#triggerController = new AbortController();

    // Set up anchor relationship if CSS anchors are supported
    if (RenPopover.supportsAnchor) {
      this.#trigger.style.anchorName = '--popover-anchor';
    } else {
      // Fallback: ensure trigger can be positioned relative to
      if (getComputedStyle(this.#trigger.parentElement).position === 'static') {
        this.#trigger.parentElement.style.position = 'relative';
      }
    }

    // Wire up popovertarget if not already set
    if (!this.#trigger.hasAttribute('popovertarget')) {
      this.#trigger.setAttribute('popovertarget', this.id);
    }
    this.#trigger.setAttribute('aria-haspopup', 'dialog');
    this.#trigger.setAttribute('aria-controls', this.id);
    this.#trigger.setAttribute('aria-expanded', this.isOpen() ? 'true' : 'false');

    // Click handler
    this.#trigger.addEventListener(
      'click',
      (e) => {
        e.preventDefault();
        e.stopPropagation();
        this.toggle();
      },
      { signal: this.#triggerController.signal }
    );

    // Setup dismiss behavior (click outside, Escape key)
    this.#dismissController?.abort();
    this.#dismissController = new AbortController();

    document.addEventListener(
      'click',
      (e) => {
        if (
          this.isOpen() &&
          e.target !== this &&
          !this.contains(e.target) &&
          e.target !== this.#trigger &&
          !this.#trigger?.contains(e.target)
        ) {
          this.close();
        }
      },
      { signal: this.#dismissController.signal }
    );

    document.addEventListener(
      'keydown',
      (e) => {
        if (e.key === 'Escape' && this.isOpen()) {
          this.close();
        }
      },
      { signal: this.#dismissController.signal }
    );
  }

  /**
   * Position popover relative to trigger (fallback for non-anchor browsers)
   * @private
   */
  positionPopover() {
    if (!this.#trigger || RenPopover.supportsAnchor) return;

    const placement = normalizePlacement(this.getAttribute('placement'), 'bottom');
    const offset = parseInt(this.getAttribute('offset')) || 8;

    const { x, y, finalPlacement } = computePosition(
      this.#trigger,
      this,
      placement,
      offset
    );

    this.style.left = `${x}px`;
    this.style.top = `${y}px`;
    this.setAttribute('data-side', finalPlacement);
  }

  #syncPlacement() {
    const placement = normalizePlacement(this.getAttribute('placement'), 'bottom');
    this.setAttribute('data-side', placement);
  }

  /**
   * Open the popover
   */
  open() {
    if (this.isOpen()) return;

    this.positionPopover();
    this.setAttribute('data-state', 'open');

    if ('popover' in HTMLElement.prototype) {
      try {
        this.showPopover();
      } catch {
        // Already open or other error
      }
    } else {
      this.classList.add('ren-open');
    }

    // Popover is explicitly non-modal (see component.md).
    // No aria-modal or focus trap — those are for ren-dialog / ren-sheet.
    this.setAttribute('aria-modal', 'false');
    this.#trigger?.setAttribute('aria-expanded', 'true');
    // Note: no focus trap for non-modal popover.
    requestAnimationFrame(() => this.#focusInitialElement());
    this.dispatchEvent(new CustomEvent('ren-open', { bubbles: true }));
  }

  /**
   * Close the popover
   */
  close() {
    if (!this.isOpen()) return;

    const activeElement = document.activeElement;
    const shouldRestoreFocus =
      this.#trigger &&
      activeElement instanceof HTMLElement &&
      this.contains(activeElement);

    this.setAttribute('data-state', 'closed');

    if ('popover' in HTMLElement.prototype) {
      try {
        this.hidePopover();
      } catch {
        // Already closed or other error
      }
    } else {
      this.classList.remove('ren-open');
    }

    this.setAttribute('aria-modal', 'false');
    this.#trigger?.setAttribute('aria-expanded', 'false');
    if (shouldRestoreFocus) {
      this.#trigger.focus({ preventScroll: true });
    }
    this.dispatchEvent(new CustomEvent('ren-close', { bubbles: true }));
  }

  /**
   * Toggle popover open/closed state
   */
  toggle() {
    if (this.isOpen()) {
      this.close();
    } else {
      this.open();
    }
  }

  /**
   * Check if popover is currently open
   * @returns {boolean}
   */
  isOpen() {
    if ('popover' in HTMLElement.prototype) {
      return this.matches(':popover-open');
    }
    return this.classList.contains('ren-open');
  }

  /**
   * Cleanup event listeners
   * @private
   */
  cleanup() {
    this.#triggerController?.abort();
    this.#triggerController = null;
    this.#dismissController?.abort();
    this.#dismissController = null;
  }

  #focusInitialElement() {
    const target = this.querySelector(FOCUSABLE_SELECTOR) || this;
    if (target === this && !this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '-1');
    }
    target.focus({ preventScroll: true });
  }

  /**
   * Get the trigger element
   * @returns {HTMLElement|null}
   */
  getTrigger() {
    return this.#trigger;
  }

  /**
   * Set the trigger element
   * @param {HTMLElement} trigger
   */
  setTrigger(trigger) {
    this.#trigger = trigger;
    this.attachTriggerListener();
  }
}

if (!customElements.get('ren-popover')) {
  customElements.define('ren-popover', RenPopover);
}

data:
{"kind":"composite","selectors":[".ren-popover",".ren-popover-arrow",".ren-popover-body",".ren-popover-footer",".ren-popover-header",".ren-popover-trigger"],"tokens":["--color-border","--color-surface","--color-surface-overlay","--color-text-primary","--color-text-secondary","--duration-enter","--duration-normal","--ease-enter","--ease-out","--radius-lg","--ren-popover-bg","--ren-popover-border-color","--ren-popover-duration","--ren-popover-easing","--ren-popover-padding","--ren-popover-radius","--ren-popover-shadow","--ren-popover-width","--shadow-lg","--size-body-sm","--space-2","--space-3","--space-4","--z-popover"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-scroll-area
type: component
name: ren-scroll-area
path: components/composites/ren-scroll-area
body:
# ren-scroll-area Component Contract

Scrollable region styling that preserves native scrolling.

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

## Purpose

- Provide the agent-facing public contract for the `ren-scroll-area` 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 Scroll Area 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 bounded region needs themed native scrollbars (Firefox scrollbar-color + WebKit ::-webkit-scrollbar)."
    - "You want consistent scrollbar styling across light/dark themes without injecting JS or shadow DOM."
    - "Need preset max-height utilities (.ren-scroll-area-sm/-md/-lg/-xl/-full) and direction variants (.-x, .-y)."
    - "Need fade-edge masks (.-fade, .-fade-x) to hint at overflow without resorting to JS observers."
    - "Need an \"auto\" variant where the scrollbar only appears on hover / focus-within."
  avoidWhen:
    - "You need virtualization, restore-scroll-position, or scroll-into-view APIs — those need bespoke JS, not this CSS contract."
    - "You need a custom-drawn track / thumb (e.g. mini-map, position indicator) — native scrollbar styling cannot do this."
    - "The container should never scroll (clip overflow) — use overflow: clip / hidden directly."
    - "The content scrolls horizontally only and needs snap behavior — use a dedicated carousel/scroll-snap pattern."

canonicalImports:
  css:
    - "rends/components/composites/ren-scroll-area/ren-scroll-area.css"
  notes:
    - "CSS-only component: no JavaScript file is colocated. Do not introduce JS unless the source component grows that responsibility."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Apply class=\"ren-scroll-area\" to a real block element that owns overflow (commonly <div>, <section>, <main>)."
  - "Constrain height/width explicitly — either via .ren-scroll-area-sm/-md/-lg/-xl/-full, an inline --scroll-max custom property, or a parent flex/grid track."
  - "Pair direction with intent: .-x for horizontal-only, .-y for vertical-only; omit for auto in both axes."
  - "When using .-fade or .-fade-x the masking ::before/::after read from --color-surface; ensure the scroll area sits on a surface that matches that token."
  - "Children that should be focusable for keyboard scrolling need tabindex=\"0\" on the .ren-scroll-area itself when it has no focusable descendants."

forbiddenPatterns:
  - "Overriding overflow with overflow: hidden — that defeats the entire component (no scrollbar, no fade mask alignment)."
  - "Wrapping a <ren-scroll-area> custom element instead of the .ren-scroll-area class — no custom element exists for this composite."
  - "Stacking position: absolute children that escape the scroll context — the fade overlays (::before/::after at z-index: 10) assume in-flow content underneath."
  - "Hardcoding scrollbar-color or ::-webkit-scrollbar-thumb hex values — theme via --color-fill-active / --color-fill-hover so dark mode adapts."
  - "Setting --scroll-max on the parent expecting cascade — the rule is .ren-scroll-area[--scroll-max] { max-height: var(--scroll-max) } so the variable must be authored on the same element."

tokenPolicy:
  allowed:
    - "Semantic tokens used by the scrollbar and fade masks: --color-fill-active, --color-fill-hover, --color-surface."
    - "Layout / shape / motion tokens: --radius-full, --duration-enter, --ease-enter, --transition-tactile."
    - "Authoring custom max-height via the --scroll-max custom property on the .ren-scroll-area element."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / rgb() values for scrollbar thumb or fade-mask gradients."
    - "Inventing --ren-scroll-area-* tokens — none are part of the Public Token API today."

accessibility:
  required:
    - "If the scrollable region contains no focusable child, set tabindex=\"0\" on the .ren-scroll-area so keyboard users can scroll it with Arrow / PageUp / PageDown."
    - "Provide an accessible name (aria-label / aria-labelledby) when the region is a discrete content landmark; e.g. <section class=\"ren-scroll-area\" aria-label=\"Logs\">."
    - "Honor prefers-reduced-motion: the component disables scroll-behavior: smooth via the existing @media rule — do not re-enable smooth scrolling under reduced-motion."
    - "Do not rely on the fade-edge gradients alone to signal more content; pair with a visible affordance (chevron, scroll-hint) when content overflow is decision-critical."
    - "Scrollbar contrast: --color-fill-active and --color-fill-hover must remain distinguishable from --color-surface in both themes; do not theme so the thumb disappears."
```

## Required Imports

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

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

## Canonical Markup

```html
<section class="ren-scroll-area ren-scroll-area-md" tabindex="0" aria-label="Activity log"><p>Activity entry</p></section>

```

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

## Variants And Public Selectors

- `.ren-scroll-area`
- `.ren-scroll-area-full`
- `.ren-scroll-area-lg`
- `.ren-scroll-area-md`
- `.ren-scroll-area-sm`
- `.ren-scroll-area-xl`

## States And Attributes

- `: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.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/composites/ren-scroll-area/ren-scroll-area.css`
- `docs/components/ren-scroll-area.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-scroll-area {
  position: relative;
  overflow: auto;

  scrollbar-width: thin;
  scrollbar-color: var(--color-fill-active) transparent;

  transition: scrollbar-color var(--duration-enter) var(--ease-enter);

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

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

  &::-webkit-scrollbar-thumb {
    background: var(--color-fill-active);
    border-radius: var(--radius-full);
    border: 2px solid transparent;
    background-clip: content-box;

    transition: var(--transition-tactile);
  }

  &::-webkit-scrollbar-thumb:hover {
    background-color: var(--color-fill-hover);
    background-clip: content-box;
  }

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

  /* Hover state for standard scrollbar */
  &:hover {
    scrollbar-color: var(--color-fill-hover) transparent;
  }

  /* Variants */
  &.-auto {
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }

    /* Show on scroll */
    &:is(:hover, :focus-within) {
      scrollbar-width: thin;
      scrollbar-color: var(--color-fill-active) transparent;

      &::-webkit-scrollbar {
        display: block;
      }
    }
  }

  &.-always {
    scrollbar-width: thin;
    scrollbar-color: var(--color-fill-active) transparent;

    &::-webkit-scrollbar {
      display: block;
    }
  }

  &.-hidden {
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }
  }

  /* Direction variants */
  &.-x {
    overflow-x: auto;
    overflow-y: hidden;
  }

  &.-y {
    overflow-x: hidden;
    overflow-y: auto;
  }

  /* Fade edges for smooth content transitions */
  &.-fade {
    position: relative;

    &::before {
      content: '';
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      height: 20px;

      pointer-events: none;
      background: linear-gradient(
        to bottom,
        var(--color-surface) 0%,
        transparent 100%
      );

      z-index: 10;
    }

    &::after {
      content: '';
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 20px;

      pointer-events: none;
      background: linear-gradient(
        to top,
        var(--color-surface) 0%,
        transparent 100%
      );

      z-index: 10;
    }
  }

  /* Horizontal fade */
  &.-fade-x {
    position: relative;

    &::before {
      content: '';
      position: absolute;
      top: 0;
      left: 0;
      bottom: 0;
      width: 20px;

      pointer-events: none;
      background: linear-gradient(
        to right,
        var(--color-surface) 0%,
        transparent 100%
      );

      z-index: 10;
    }

    &::after {
      content: '';
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      width: 20px;

      pointer-events: none;
      background: linear-gradient(
        to left,
        var(--color-surface) 0%,
        transparent 100%
      );

      z-index: 10;
    }
  }

  /* Max-height with custom property */
  &[--scroll-max] {
    max-height: var(--scroll-max, 20rem);
  }

  /* Smooth scrolling behavior */
  scroll-behavior: smooth;

  @media (prefers-reduced-motion: reduce) {
    scroll-behavior: auto;
  }
}

/* Utility class for common heights */
.ren-scroll-area-sm {
  max-height: 12rem;
}

.ren-scroll-area-md {
  max-height: 20rem;
}

.ren-scroll-area-lg {
  max-height: 30rem;
}

.ren-scroll-area-xl {
  max-height: 40rem;
}

.ren-scroll-area-full {
  height: 100%;
  max-height: none;
}

data:
{"kind":"composite","selectors":[".ren-scroll-area",".ren-scroll-area-full",".ren-scroll-area-lg",".ren-scroll-area-md",".ren-scroll-area-sm",".ren-scroll-area-xl"],"tokens":["--color-fill-active","--color-fill-hover","--color-surface","--duration-enter","--ease-enter","--radius-full","--scroll-max","--transition-tactile"],"hasScript":false,"hasDocsPage":true}


id: component:composite:ren-select
type: component
name: ren-select
path: components/composites/ren-select
body:
# ren-select Component Contract

Custom select/listbox composite with trigger, options, and keyboard navigation.

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

## Purpose

- Provide the agent-facing public contract for the `ren-select` 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 Select 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 styled single- or multi-select with a button trigger and a listbox popup."
    - "You need full keyboard navigation (Arrow keys, Home/End, typeahead, Enter/Space, Escape)."
    - "You need ARIA combobox/listbox semantics with aria-expanded, aria-selected, and data-highlighted."
    - "You need optional grouping (.ren-select-group + .ren-select-label) or separators between options."
    - "You need multi-select chips (.ren-select-chips, .ren-select-chip) with removal affordances."
    - "You need hidden form-input integration for submission alongside custom dropdown UI."
    - "The dropdown needs a preferred placement via placement=\"top|right|bottom|left\" while preserving viewport flip fallback."
  avoidWhen:
    - "A bare <select> styled via base/primitive-zero would meet the requirements."
    - "The control is binary state — use ren-checkbox, ren-switch, or ren-toggle."
    - "The disclosure is a free-form menu of commands, not value selection — use ren-menu / ren-menubar."
    - "The disclosure is a navigation list — use ren-nav or ren-sidebar."

canonicalImports:
  css:
    - "rends/components/composites/ren-select/ren-select.css"
  js:
    - "rends/components/composites/ren-select/ren-select.js"
  notes:
    - "JS is required: it owns keyboard nav, dismissable popup, popover positioning, and the hidden form input."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "<ren-select> wraps a real <button data-select-trigger> and a [data-select-content] listbox container."
  - "Each option is a <div data-select-item data-value=\"...\"> inside .ren-select-content; rely on data-select-item, not arbitrary children."
  - "The trigger displays .ren-select-value when something is chosen and .ren-select-placeholder when empty — do not collapse them into one node."
  - "Set name on <ren-select> when the value must submit with a form; the component injects the hidden input automatically."
  - "With multiple, value is an ordered array and the component injects one same-name hidden input per selected value so FormData preserves repeated entries. Removable chips render as a sibling of the button trigger so interactive controls are never nested."
  - "Use .ren-select-group + .ren-select-label for grouped options and .ren-select-separator between groups; do not invent dividers."
  - "Use placement=\"bottom\" by default; the host and .ren-select-content mirror the resolved side to data-side and data-align."

forbiddenPatterns:
  - "Substituting a <div role=\"button\"> for the [data-select-trigger] <button> — the trigger must be a real button."
  - "Animating the dropdown manually with display: none / display: block — use the .ren-open class and the popover open state."
  - "Removing the focus ring on .ren-select-trigger:focus-visible without restoring an equivalent visible ring."
  - "Hardcoding option backgrounds via inline style — use --color-accent-subtle / --color-fill via the documented selectors."
  - "Putting interactive children (links, buttons) inside .ren-select-item; items must remain single-action options."

tokenPolicy:
  allowed:
    - "Semantic input tokens: --color-input-bg, --color-input-bg-hover, --color-input-border, --color-input-border-focus, --color-input-focus-ring, --color-input-placeholder, --color-input-disabled-bg, --color-input-disabled-text."
    - "Surface and content tokens: --color-surface, --color-fill, --color-accent, --color-accent-subtle, --color-text, --color-text-muted, --color-border."
    - "Shape / motion tokens: --radius-sm, --radius-md, --radius-lg, --space-*, --shadow-lg, --duration-enter, --duration-exit, --duration-micro, --ease-enter, --ease-exit, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides."
    - "Hardcoded transition durations; use --duration-enter / --duration-exit / --duration-micro with their paired easings."

accessibility:
  required:
    - "Trigger exposes aria-expanded on open/close and aria-disabled when inert; native <button> Enter/Space activation must be preserved."
    - "Listbox container carries role=\"listbox\"; each option carries role=\"option\" and aria-selected reflects the selected value."
    - "Roving highlight on options uses [data-highlighted]; only one option may be highlighted at a time."
    - "Trigger meets the 44px touch target via min-height: var(--touch-min); .ren-select-sm is only acceptable in non-touch contexts."
    - "Escape closes the listbox and returns focus to the trigger; outside click dismisses without stealing focus elsewhere."
    - "Disabled state combines :disabled (or aria-disabled=\"true\") with pointer-events: none on items so click and keypress both no-op."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-select name="country"><button type="button" data-select-trigger><span class="ren-select-value"></span><span class="ren-select-placeholder">Choose a country</span></button><div class="ren-select-content" data-select-content role="listbox"><div data-select-item data-value="ar">Argentina</div><div data-select-item data-value="uy">Uruguay</div></div></ren-select>

```

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

## Variants And Public Selectors

- `.ren-open`
- `.ren-select`
- `.ren-select-chip`
- `.ren-select-chip-remove`
- `.ren-select-chips`
- `.ren-select-content`
- `.ren-select-empty`
- `.ren-select-group`
- `.ren-select-icon`
- `.ren-select-item`
- `.ren-select-label`
- `.ren-select-lg`
- `.ren-select-placeholder`
- `.ren-select-separator`
- `.ren-select-sm`
- `.ren-select-trigger`
- `.ren-select-value`

## States And Attributes

- `[aria-disabled]`
- `[aria-expanded]`
- `[aria-selected]`
- `[data-highlighted]`
- `[data-align]`
- `[data-flipped]`
- `[data-select-content]`
- `[data-select-item]`
- `[data-select-trigger]`
- `[data-side]`
- `placement`
- `:disabled`
- `:focus-visible`
- `:hover`

## JavaScript API

- `value` / `setValue(value)` use a string (or `null`) in single-select mode.
- `value` / `setValue(values)` use an ordered array in `multiple` mode.
- Multiple selection accumulates and removes values without closing the
  listbox, renders `.ren-select-chip` entries, and submits repeated same-name
  hidden inputs.
- `aria-disabled="false"` remains operable; only native `disabled` or
  `aria-disabled="true"` disables an option.

## 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-select/ren-select.css`
- `components/composites/ren-select/ren-select.js`
- `docs/components/ren-select.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 — Select Component
   ═══════════════════════════════════════════════════════════════════
   Custom, accessible select/dropdown component with full keyboard nav,
   ARIA support, and multi-select capability.

   Structure:
   .ren-select (container)
     .ren-select-trigger (button showing selected value)
       .ren-select-value (the displayed text)
       .ren-select-placeholder (when nothing selected)
       .ren-select-icon (chevron)
     .ren-select-content (dropdown listbox)
       .ren-select-item (option)
       .ren-select-group (optional grouping)
       .ren-select-label (group header)
       .ren-select-separator (divider between groups)
   ═══════════════════════════════════════════════════════════════════ */

/* ═══ SELECT CONTAINER ═══ */
.ren-select {
  position: relative;
  display: inline-flex;
  flex-direction: column;
  width: 100%;
  font-family: inherit;
}

/* ═══ SELECT TRIGGER (Button) ═══ */
.ren-select-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);

  /* Input styling */
  width: 100%;
  min-height: var(--touch-min);
  padding: var(--space-2) var(--space-3);
  font-size: var(--body-size);
  font-family: inherit;
  color: var(--color-text);
  background-color: var(--color-input-bg);
  border: var(--stroke-1) solid var(--color-input-border);
  border-radius: var(--radius-md);
  cursor: pointer;
  text-align: start;
  white-space: nowrap;

  /* Semantic preset keeps trigger hover/focus in sync with other
     tactile inputs (button, checkbox, text field). */
  transition: var(--transition-tactile);
}

.ren-select-trigger:hover:not(:disabled):not([aria-expanded="true"]) {
  background-color: var(--color-input-bg-hover);
}

.ren-select-trigger:focus-visible {
  outline: none;
  border-color: var(--color-input-border-focus);
  box-shadow: 0 0 0 3px var(--color-input-focus-ring);
  background-color: var(--color-input-bg-hover);
}

.ren-select-trigger[aria-expanded="true"] {
  border-color: var(--color-input-border-focus);
  box-shadow: 0 0 0 3px var(--color-input-focus-ring);
  background-color: var(--color-input-bg-hover);
}

.ren-select-trigger:disabled {
  background-color: var(--color-input-disabled-bg);
  color: var(--color-input-disabled-text);
  cursor: not-allowed;
  opacity: 0.7;
}

/* ═══ SELECT VALUE / PLACEHOLDER ═══ */
.ren-select-value {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--color-text);
}

.ren-select-placeholder {
  color: var(--color-input-placeholder);
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ═══ SELECT ICON (Chevron) ═══ */
.ren-select-icon {
  flex-shrink: 0;
  width: 1.25rem;
  height: 1.25rem;
  color: var(--color-text-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform var(--duration-micro) var(--ease-enter);
  pointer-events: none;
}

.ren-select-trigger[aria-expanded="true"] .ren-select-icon {
  transform: rotate(180deg);
}

/* SVG chevron icon styling */
.ren-select-icon svg {
  width: 100%;
  height: 100%;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ═══ SELECT CONTENT (Dropdown Listbox) ═══ */
.ren-select-content {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: var(--space-1);
  background: var(--color-surface);
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding-block: var(--space-2);
  max-height: 15rem;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  z-index: var(--z-dropdown, 1000);

  visibility: hidden;
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  animation: ren-select-close var(--duration-exit) var(--ease-exit) forwards;
}

/* ═══ SELECT CONTENT OPEN STATE ═══ */
.ren-select-content[popover]:popover-open,
.ren-select-content.ren-open {
  visibility: visible;
  opacity: 1;
  animation: ren-select-open var(--duration-enter) var(--ease-enter);
}

@keyframes ren-select-open {
  from {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes ren-select-close {
  from {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
  to {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
}

/* ═══ SELECT ITEM (Option) ═══ */
.ren-select-item {
  padding: var(--space-2) var(--space-3);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  color: var(--color-text);
  border-radius: var(--radius-sm);
  /* Micro-duration keeps option hover snappy; tokens/semantic/motion
     collapses this to 0ms under reduced-motion. */
  transition:
    background-color var(--duration-micro) var(--ease-enter),
    color var(--duration-micro) var(--ease-enter);
  white-space: nowrap;
  user-select: none;
  min-height: 2rem;
}

.ren-select-item:hover:not([aria-disabled="true"]),
.ren-select-item[data-highlighted] {
  background-color: var(--color-fill);
  color: var(--color-text);
}

.ren-select-item[aria-selected="true"] {
  background-color: var(--color-accent-subtle);
  color: var(--color-accent);
  font-weight: 500;
}

.ren-select-item[aria-selected="true"]::before {
  content: '';
  flex-shrink: 0;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-accent);
  border-radius: var(--radius-sm);
  position: relative;
}

.ren-select-item[aria-selected="true"]::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 70%;
}

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

/* ═══ SELECT GROUP ═══ */
.ren-select-group {
  display: block;
  padding-block: var(--space-1);
}

/* ═══ SELECT GROUP LABEL ═══ */
.ren-select-label {
  padding: var(--space-1) var(--space-3);
  font-size: var(--caption-size);
  font-weight: 600;
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  white-space: nowrap;
  user-select: none;
}

/* ═══ SELECT SEPARATOR ═══ */
.ren-select-separator {
  height: var(--stroke-1);
  background: var(--color-border);
  margin-block: var(--space-1);
}

/* ═══ SELECT EMPTY STATE ═══ */
.ren-select-empty {
  padding: var(--space-3) var(--space-4);
  text-align: center;
  color: var(--color-text-muted);
  font-size: var(--body-size);
  white-space: nowrap;
}

/* ═══ SIZE VARIANTS ═══ */
.ren-select-sm .ren-select-trigger {
  min-height: var(--size-sm);
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-sm);
  border-radius: var(--radius-sm);
}

.ren-select-lg .ren-select-trigger {
  min-height: var(--size-xl);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-lg);
}

/* ═══ MULTI-SELECT CHIPS/TAGS ═══ */
.ren-select-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
  width: 100%;
}

.ren-select-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  background: var(--color-fill);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  color: var(--color-text);
  white-space: nowrap;
}

.ren-select-chip-remove {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  cursor: pointer;
  color: var(--color-text-muted);
  transition: var(--transition-tactile);
}

.ren-select-chip-remove:hover {
  color: var(--color-text);
}

/* ═══ REDUCED MOTION ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-select-trigger,
  .ren-select-icon,
  .ren-select-item {
    transition: none;
  }

  .ren-select-content {
    animation: none;
    opacity: 0;
  }

  .ren-select-content.ren-open {
    opacity: 1;
  }
}

/* ═══ SCROLLBAR STYLING ═══ */
.ren-select-content::-webkit-scrollbar {
  width: 6px;
}

.ren-select-content::-webkit-scrollbar-track {
  background: transparent;
}

.ren-select-content::-webkit-scrollbar-thumb {
  background: var(--color-border);
  border-radius: 3px;
}

.ren-select-content::-webkit-scrollbar-thumb:hover {
  background: var(--color-text-muted);
}

/* ═══ NATIVE POPOVER API SUPPORT ═══ */
@supports (selector(:popover-open)) {
  .ren-select-content[popover] {
    visibility: visible;
    opacity: 0;
    transition: opacity var(--duration-enter) var(--ease-enter),
                overlay var(--duration-enter) var(--ease-enter) allow-discrete,
                display var(--duration-enter) var(--ease-enter) allow-discrete;
    animation: none;
  }

  @starting-style {
    .ren-select-content[popover]:popover-open {
      opacity: 0;
    }
  }

  .ren-select-content[popover]:popover-open {
    opacity: 1;
  }
}


/**
 * RenDS — Select Component
 * ========================
 *
 * A custom, fully accessible select/dropdown component that extends native
 * form capabilities with keyboard navigation, ARIA support, grouping,
 * and multi-select capability.
 *
 * Features:
 * - Keyboard navigation (arrow keys, Enter, Space, Escape, Typeahead)
 * - Full ARIA support (combobox, listbox, role="option")
 * - Click-outside dismissal with roving tabindex
 * - Optional multi-select mode
 * - Option groups and separators
 * - Form submission integration
 * - Preferred placement with viewport-aware positioning
 * - Fallback styling for native select elements
 * - Respects prefers-reduced-motion
 *
 * @example
 * <ren-select placeholder="Choose an option" name="country">
 *   <button data-select-trigger>Select a country</button>
 *   <div data-select-content>
 *     <div data-select-item data-value="us">United States</div>
 *     <div data-select-item data-value="ca">Canada</div>
 *   </div>
 * </ren-select>
 *
 * @fires ren-select-change - Dispatched when selection changes
 * @fires change - Standard change event for form submission
 */

import { createKeyboardNav } from '../../../utils/keyboard-nav.js';
import { createDismissable } from '../../../utils/dismissable.js';
import { autoId } from '../../../utils/id-generator.js';

const SELECT_SIDES = new Set(['top', 'right', 'bottom', 'left']);
const SELECT_ALIGNS = new Set(['start', 'end']);

function normalizeSelectPlacement(value) {
  const [sideValue, alignValue] = String(value || 'bottom')
    .toLowerCase()
    .split('-');
  const side = SELECT_SIDES.has(sideValue) ? sideValue : 'bottom';
  const align = SELECT_ALIGNS.has(alignValue) ? alignValue : 'start';

  return { side, align };
}

/**
 * Compute dropdown position relative to trigger element.
 * Falls back above if not enough space below.
 *
 * @private
 * @param {HTMLElement} trigger - Trigger button element
 * @param {HTMLElement} content - Dropdown content element
 * @param {Object} placement - Normalized placement object
 * @returns {Object} Position with top, left, flipped, side, and align properties
 */
function computePosition(trigger, content, placement = normalizeSelectPlacement()) {
  const triggerRect = trigger.getBoundingClientRect();
  const contentRect = content.getBoundingClientRect();
  const viewport = {
    width: window.innerWidth,
    height: window.innerHeight,
  };
  const gap = 8;
  const margin = 16;

  let finalSide = placement.side;
  let flipped = false;
  let top = triggerRect.bottom + gap;
  let left = triggerRect.left;

  if (placement.align === 'end') {
    left = triggerRect.right - contentRect.width;
  }

  if (placement.side === 'top') {
    top = triggerRect.top - contentRect.height - gap;
  } else if (placement.side === 'right') {
    top = placement.align === 'end'
      ? triggerRect.bottom - contentRect.height
      : triggerRect.top;
    left = triggerRect.right + gap;
  } else if (placement.side === 'left') {
    top = placement.align === 'end'
      ? triggerRect.bottom - contentRect.height
      : triggerRect.top;
    left = triggerRect.left - contentRect.width - gap;
  }

  if (
    placement.side === 'bottom' &&
    top + contentRect.height > viewport.height - margin &&
    triggerRect.top - contentRect.height - gap >= margin
  ) {
    top = triggerRect.top - contentRect.height - gap;
    finalSide = 'top';
    flipped = true;
  } else if (
    placement.side === 'top' &&
    top < margin &&
    triggerRect.bottom + contentRect.height + gap <= viewport.height - margin
  ) {
    top = triggerRect.bottom + gap;
    finalSide = 'bottom';
    flipped = true;
  } else if (
    placement.side === 'right' &&
    left + contentRect.width > viewport.width - margin &&
    triggerRect.left - contentRect.width - gap >= margin
  ) {
    left = triggerRect.left - contentRect.width - gap;
    finalSide = 'left';
    flipped = true;
  } else if (
    placement.side === 'left' &&
    left < margin &&
    triggerRect.right + contentRect.width + gap <= viewport.width - margin
  ) {
    left = triggerRect.right + gap;
    finalSide = 'right';
    flipped = true;
  }

  if (left + contentRect.width > viewport.width - margin) {
    left = viewport.width - contentRect.width - margin;
  }
  if (left < margin) {
    left = margin;
  }

  if (top + contentRect.height > viewport.height - margin) {
    top = viewport.height - contentRect.height - margin;
  }
  if (top < margin) {
    top = margin;
  }

  return { top, left, flipped, side: finalSide, align: placement.align };
}

/**
 * Select Component
 *
 * Custom, accessible select/dropdown with keyboard nav and ARIA support.
 *
 * @class RenSelect
 * @extends HTMLElement
 */
export class RenSelect extends HTMLElement {
  static observedAttributes = ['placement'];

  #trigger = null;
  #content = null;
  #items = [];
  #isOpen = false;
  #selectedValue = null;
  #selectedItem = null;
  #keyboardNav = null;
  #dismissable = null;
  #hiddenInputs = [];
  #animationFrame = null;
  #listenerController = null;
  #scrollController = null;

  constructor() {
    super();
    this.positionContent = this.positionContent.bind(this);
  }

  /* ─────────────────────────────────────────────────────────────
     LIFECYCLE
     ───────────────────────────────────────────────────────────── */

  connectedCallback() {
    if (this.hasAttribute('multiple') && !Array.isArray(this.#selectedValue)) {
      this.#selectedValue = [];
    }
    this.loadStyles();
    this.setupComponent();
    this.bindElements();
    this.setupARIA();
    this.setupHiddenInput();
    this.attachListeners();
  }

  disconnectedCallback() {
    this.cleanup();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'placement' && oldValue !== newValue) {
      this.syncPlacement();
      if (this.#isOpen) {
        this.positionContent();
      }
    }
  }

  /* ─────────────────────────────────────────────────────────────
     SETUP & INITIALIZATION
     ───────────────────────────────────────────────────────────── */

  /**
   * Load component CSS styles into document
   * @private
   */
  loadStyles() {
    if (!document.getElementById('ren-select-styles')) {
      const style = document.createElement('style');
      style.id = 'ren-select-styles';
      style.textContent = this.constructor.styles || '';
      document.head.appendChild(style);
    }
  }

  /**
   * Setup container and trigger elements
   * @private
   */
  setupComponent() {
    this.classList.add('ren-select');
    this.syncPlacement();

    // Check for size variant
    if (this.hasAttribute('size')) {
      this.classList.add(`ren-select-${this.getAttribute('size')}`);
    }
  }

  /**
   * Find and cache trigger and content elements
   * @private
   */
  bindElements() {
    // Try to find explicitly marked elements
    this.#trigger = this.querySelector('[data-select-trigger], .ren-select-trigger');
    this.#content = this.querySelector('[data-select-content], .ren-select-content');

    // If not found, look for first button and first div (fallback)
    if (!this.#trigger) {
      this.#trigger = this.querySelector('button');
    }

    if (!this.#content) {
      this.#content = this.querySelector('[role="listbox"]');
    }

    // If still not found, create default structure
    if (!this.#trigger || !this.#content) {
      this.createDefaultStructure();
    }

    // Cache items
    this.updateItems();
    this.syncPlacement();
  }

  /**
   * Create default trigger and content if not provided
   * @private
   */
  createDefaultStructure() {
    // Create trigger button
    if (!this.#trigger) {
      this.#trigger = document.createElement('button');
      this.#trigger.className = 'ren-select-trigger';
      this.#trigger.type = 'button';
      this.appendChild(this.#trigger);
    }

    // Create content container
    if (!this.#content) {
      this.#content = document.createElement('div');
      this.#content.className = 'ren-select-content';
      this.#content.setAttribute('role', 'listbox');
      this.appendChild(this.#content);
    }
  }

  /**
   * Setup ARIA attributes on trigger and content
   * @private
   */
  setupARIA() {
    autoId(this.#trigger, 'select-trigger');
    const contentId = autoId(this.#content, 'select-content');

    // Trigger setup
    this.#trigger.setAttribute('role', 'combobox');
    this.#trigger.setAttribute('aria-expanded', 'false');
    this.#trigger.setAttribute('aria-haspopup', 'listbox');
    this.#trigger.setAttribute('aria-controls', contentId);
    this.#trigger.type = 'button';

    // Content setup
    this.#content.setAttribute('role', 'listbox');
    this.#content.id = contentId;
    if (this.hasAttribute('multiple')) {
      this.#content.setAttribute('aria-multiselectable', 'true');
    }

    // Set aria-label on content based on trigger text or placeholder
    const placeholder = this.getAttribute('placeholder') || 'Select an option';
    if (!this.#content.getAttribute('aria-label')) {
      this.#content.setAttribute('aria-label', placeholder);
    }

    // Wire up items
    this.updateItemsARIA();
  }

  /**
   * Update items ARIA attributes
   * @private
   */
  updateItemsARIA() {
    this.#items.forEach((item, index) => {
      if (item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true') {
        item.setAttribute('aria-disabled', 'true');
      }

      if (!item.getAttribute('role')) {
        item.setAttribute('role', 'option');
      }

      // Ensure item has tabindex
      if (!item.hasAttribute('tabindex')) {
        item.setAttribute('tabindex', '-1');
      }

      // Set aria-selected based on current selection
      const value = item.getAttribute('data-value');
      const isSelected = this.hasAttribute('multiple')
        ? this.#selectedValue.includes(value)
        : value === this.#selectedValue;
      item.setAttribute('aria-selected', isSelected ? 'true' : 'false');
    });
  }

  /**
   * Setup hidden input for form submission
   * @private
   */
  setupHiddenInput() {
    const name = this.getAttribute('name');
    if (!name) return;

    this.syncHiddenInputs();
  }

  /**
   * Attach event listeners to trigger and content
   * @private
   */
  attachListeners() {
    this.#listenerController?.abort();
    this.#listenerController = new AbortController();
    const { signal } = this.#listenerController;

    // Trigger click
    this.#trigger.addEventListener('click', (e) => this.handleTriggerClick(e), { signal });

    // Keyboard on trigger
    this.#trigger.addEventListener('keydown', (e) => this.handleTriggerKeyDown(e), { signal });

    // Item selection
    this.#content.addEventListener('click', (e) => this.handleItemClick(e), { signal });

    // Setup keyboard navigation in content
    this.setupKeyboardNav();

    // Setup click-outside dismissal
    this.setupDismissable();

    // Initialize selected value
    const initialValue = this.getAttribute('value');
    if (initialValue) {
      this.selectValue(initialValue, false);
    }
  }

  /**
   * Setup keyboard navigation for select items
   * @private
   */
  setupKeyboardNav() {
    this.#keyboardNav = createKeyboardNav(this.#content, {
      selector: '[role="option"]',
      orientation: 'vertical',
      loop: true,
      typeahead: true,
      focusOnHover: true,
      onActivate: (item) => {
        // Item is highlighted
      },
      onSelect: (item) => {
        this.selectItem(item);
      },
    });

    // Don't attach yet; will attach when dropdown opens
  }

  /**
   * Setup click-outside and Escape key dismissal
   * @private
   */
  setupDismissable() {
    this.#dismissable = createDismissable(this.#content, {
      triggerElement: this.#trigger,
      escapeKey: true,
      clickOutside: true,
      onDismiss: (reason) => {
        this.close();
      },
    });
  }

  /**
   * Update the list of items from the content element
   * @private
   */
  updateItems() {
    this.#items = Array.from(
      this.#content.querySelectorAll('[role="option"], [data-select-item], .ren-select-item')
    );
  }

  /* ─────────────────────────────────────────────────────────────
     EVENT HANDLERS
     ───────────────────────────────────────────────────────────── */

  /**
   * Handle trigger button click
   * @private
   */
  handleTriggerClick(e) {
    e.preventDefault();
    e.stopPropagation();

    if (this.#isOpen) {
      this.close();
    } else {
      this.open();
    }
  }

  /**
   * Handle keyboard events on trigger
   * @private
   */
  handleTriggerKeyDown(e) {
    const isOpen = this.#isOpen;

    switch (e.key) {
      case 'Enter':
      case ' ':
        e.preventDefault();
        e.stopPropagation();

        if (!isOpen) {
          this.open();
        }
        break;

      case 'ArrowDown':
        e.preventDefault();
        e.stopPropagation();

        if (!isOpen) {
          this.open();
          // Focus first item
          setTimeout(() => {
            this.#keyboardNav?.activateFirst();
          }, 0);
        } else {
          this.#keyboardNav?.activateNext();
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        e.stopPropagation();

        if (!isOpen) {
          this.open();
          // Focus last item
          setTimeout(() => {
            this.#keyboardNav?.activateLast();
          }, 0);
        } else {
          this.#keyboardNav?.activatePrevious();
        }
        break;

      case 'Escape':
        if (isOpen) {
          e.preventDefault();
          e.stopPropagation();
          this.close();
        }
        break;

      case 'ArrowLeft':
      case 'ArrowRight':
      case 'Tab':
        // Don't intercept these; let default behavior
        break;

      default:
        // Let other keys through
        break;
    }
  }

  /**
   * Handle item click
   * @private
   */
  handleItemClick(e) {
    const item = e.target.closest('[role="option"]');
    if (item && !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true') {
      e.preventDefault();
      e.stopPropagation();
      this.selectItem(item);
    }
  }

  /* ─────────────────────────────────────────────────────────────
     OPENING / CLOSING
     ───────────────────────────────────────────────────────────── */

  /**
   * Open the dropdown
   */
  open() {
    if (this.#isOpen || this.#trigger.disabled) return;

    this.#isOpen = true;

    // Show content
    this.#content.classList.add('ren-open');
    this.setAttribute('data-state', 'open');
    this.#trigger.setAttribute('aria-expanded', 'true');

    // Try native popover API
    if ('popover' in HTMLElement.prototype && this.#content.hasAttribute('popover')) {
      try {
        this.#content.showPopover();
      } catch {
        // Fallback to CSS class
      }
    }

    // Attach keyboard nav
    if (this.#keyboardNav) {
      this.#keyboardNav.attach();
      // Activate selected or first item
      const selectedIndex = this.#items.findIndex((item) => {
        const value = item.getAttribute('data-value');
        return this.hasAttribute('multiple')
          ? this.#selectedValue.includes(value)
          : value === this.#selectedValue;
      });
      if (selectedIndex !== -1) {
        this.#keyboardNav.setActiveIndex(selectedIndex);
      } else if (this.#items.length > 0) {
        this.#keyboardNav.activateFirst();
      }
    }

    // Activate dismissable layer
    if (this.#dismissable) {
      this.#dismissable.activate();
    }

    // Position content
    this.positionContent();

    // Reposition on scroll
    this.#scrollController?.abort();
    this.#scrollController = new AbortController();
    window.addEventListener('scroll', this.positionContent, {
      capture: true,
      signal: this.#scrollController.signal,
    });

    // Announce results count for accessibility
    const resultCount = this.#items.length;
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.className = 'ren-sr-only';
    announcement.textContent = `${resultCount} option${resultCount !== 1 ? 's' : ''} available`;
    this.appendChild(announcement);
    setTimeout(() => announcement.remove(), 1000);

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

  /**
   * Close the dropdown
   */
  close() {
    if (!this.#isOpen) return;

    this.#isOpen = false;

    // Hide content
    this.#content.classList.remove('ren-open');
    this.setAttribute('data-state', 'closed');
    this.#trigger.setAttribute('aria-expanded', 'false');

    // Try native popover API
    if ('popover' in HTMLElement.prototype && this.#content.hasAttribute('popover')) {
      try {
        this.#content.hidePopover();
      } catch {
        // Fallback
      }
    }

    // Detach keyboard nav
    if (this.#keyboardNav) {
      this.#keyboardNav.detach();
    }

    // Deactivate dismissable layer
    if (this.#dismissable) {
      this.#dismissable.deactivate();
    }

    // Remove scroll listener
    this.#scrollController?.abort();
    this.#scrollController = null;

    // Return focus to trigger
    this.#trigger.focus();

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

  /**
   * Position dropdown content below or above trigger
   * @private
   */
  positionContent() {
    const placement = normalizeSelectPlacement(this.getAttribute('placement'));
    const { top, left, flipped, side, align } = computePosition(this.#trigger, this.#content, placement);

    this.#content.style.position = 'fixed';
    this.#content.style.top = `${top}px`;
    this.#content.style.left = `${left}px`;
    this.#content.style.width = `${this.#trigger.offsetWidth}px`;
    this.#content.setAttribute('data-flipped', flipped ? 'true' : 'false');
    this.#content.setAttribute('data-side', side);
    this.#content.setAttribute('data-align', align);
    this.setAttribute('data-side', side);
    this.setAttribute('data-align', align);
  }

  /**
   * Mirror preferred placement to public data attributes.
   */
  syncPlacement() {
    const { side, align } = normalizeSelectPlacement(this.getAttribute('placement'));

    this.setAttribute('data-side', side);
    this.setAttribute('data-align', align);
    if (this.#content) {
      this.#content.setAttribute('data-side', side);
      this.#content.setAttribute('data-align', align);
    }
  }

  /* ─────────────────────────────────────────────────────────────
     SELECTION
     ───────────────────────────────────────────────────────────── */

  /**
   * Select an item by element
   * @private
   */
  selectItem(item) {
    const value = item.getAttribute('data-value');
    const label = item.textContent?.trim() || '';

    if (this.hasAttribute('multiple')) {
      const values = [...this.#selectedValue];
      const selectedIndex = values.indexOf(value);
      if (selectedIndex === -1) values.push(value);
      else values.splice(selectedIndex, 1);
      this.selectValue(values, true);
    } else {
      this.selectValue(value, true);
    }

    this.dispatchEvent(
      new CustomEvent('ren-select-change', {
        bubbles: true,
        detail: { value: this.value, label, item },
      })
    );

    // Emit standard change event for forms
    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));

    // Close dropdown (unless multi-select)
    if (!this.hasAttribute('multiple')) {
      this.close();
    }
  }

  /**
   * Select by value
   * @private
   */
  selectValue(value, updateTrigger = true) {
    if (this.hasAttribute('multiple')) {
      const values = Array.isArray(value) ? value : (value == null ? [] : [value]);
      this.#selectedValue = [...new Set(values.map(String))];
    } else {
      this.#selectedValue = value == null ? null : String(value);
    }

    // Find and update the corresponding item
    const selectedValues = this.hasAttribute('multiple') ? this.#selectedValue : [this.#selectedValue];
    const item = this.#items.find((el) => selectedValues.includes(el.getAttribute('data-value')));
    if (item) {
      this.#selectedItem = item;
    } else {
      this.#selectedItem = null;
    }

    // Update ARIA
    this.updateItemsARIA();

    // Update trigger display
    if (updateTrigger) {
      this.updateTriggerDisplay();
    }

    this.syncHiddenInputs();
  }

  syncHiddenInputs() {
    const name = this.getAttribute('name');
    this.querySelectorAll('input[type="hidden"][data-ren-select-input]').forEach((input) => input.remove());
    this.#hiddenInputs = [];
    if (!name) return;

    const values = this.hasAttribute('multiple')
      ? this.#selectedValue
      : [this.#selectedValue || ''];
    values.forEach((value) => {
      const input = document.createElement('input');
      input.type = 'hidden';
      input.name = name;
      input.value = value;
      input.setAttribute('data-ren-select-input', '');
      this.appendChild(input);
      this.#hiddenInputs.push(input);
    });
  }

  /**
   * Update the trigger button text/icon display
   * @private
   */
  updateTriggerDisplay() {
    const placeholder = this.getAttribute('placeholder') || 'Select an option';

    this.querySelector(':scope > .ren-select-chips[data-ren-select-chips]')?.remove();

    // Clear trigger content
    this.#trigger.innerHTML = '';

    if (this.hasAttribute('multiple') && this.#selectedValue.length > 0) {
      const chips = document.createElement('span');
      chips.className = 'ren-select-chips';
      chips.setAttribute('data-ren-select-chips', '');
      const selectedLabels = [];
      this.#selectedValue.forEach((value) => {
        const item = this.#items.find((option) => option.getAttribute('data-value') === value);
        if (!item) return;

        const chip = document.createElement('span');
        chip.className = 'ren-select-chip';
        const label = document.createElement('span');
        label.textContent = item.textContent?.trim() || value;
        selectedLabels.push(label.textContent);
        const remove = document.createElement('button');
        remove.type = 'button';
        remove.className = 'ren-select-chip-remove';
        remove.dataset.value = value;
        remove.setAttribute('aria-label', `Remove ${label.textContent}`);
        remove.textContent = '×';
        remove.addEventListener('click', (event) => {
          event.preventDefault();
          event.stopPropagation();
          this.selectValue(this.#selectedValue.filter((selected) => selected !== value), true);
          this.dispatchEvent(new CustomEvent('ren-select-change', {
            bubbles: true,
            detail: { value: this.value, label: label.textContent, item },
          }));
          this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
        });
        chip.append(label, remove);
        chips.appendChild(chip);
      });
      this.insertBefore(chips, this.#trigger);

      const valueSpan = document.createElement('span');
      valueSpan.className = 'ren-select-value';
      valueSpan.textContent = selectedLabels.join(', ');
      this.#trigger.appendChild(valueSpan);
    } else if (!this.#selectedValue || !this.#selectedItem) {
      // Show placeholder
      const placeholderSpan = document.createElement('span');
      placeholderSpan.className = 'ren-select-placeholder';
      placeholderSpan.textContent = placeholder;
      this.#trigger.appendChild(placeholderSpan);
    } else {
      // Show selected value
      const valueSpan = document.createElement('span');
      valueSpan.className = 'ren-select-value';
      valueSpan.textContent = this.#selectedItem.textContent?.trim() || '';
      this.#trigger.appendChild(valueSpan);
    }

    // Add chevron icon
    const icon = document.createElement('span');
    icon.className = 'ren-select-icon';
    icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
    this.#trigger.appendChild(icon);
  }

  /* ─────────────────────────────────────────────────────────────
     CLEANUP
     ───────────────────────────────────────────────────────────── */

  /**
   * Cleanup event listeners and resources
   * @private
   */
  cleanup() {
    if (this.#keyboardNav) {
      this.#keyboardNav.detach();
    }

    if (this.#dismissable) {
      this.#dismissable.deactivate();
    }

    if (this.#animationFrame) {
      cancelAnimationFrame(this.#animationFrame);
    }

    this.#scrollController?.abort();
    this.#scrollController = null;
    this.#listenerController?.abort();
    this.#listenerController = null;
  }

  /* ─────────────────────────────────────────────────────────────
     PUBLIC API
     ───────────────────────────────────────────────────────────── */

  /**
   * Get current selected value
   * @returns {string|string[]|null} Selected value, ordered values, or null
   */
  get value() {
    return Array.isArray(this.#selectedValue) ? [...this.#selectedValue] : this.#selectedValue;
  }

  /**
   * Set value programmatically
   * @param {string|string[]} value - Value or ordered values to select
   */
  set value(value) {
    this.selectValue(value, true);
  }

  /**
   * Get current selected item element
   * @returns {HTMLElement|null}
   */
  get selectedOption() {
    return this.#selectedItem || null;
  }

  /**
   * Check if dropdown is open
   * @returns {boolean}
   */
  get isOpen() {
    return this.#isOpen;
  }

  /**
   * Get all option items
   * @returns {HTMLElement[]}
   */
  get options() {
    return [...this.#items];
  }

  /**
   * Refresh items from DOM (call after dynamically adding items)
   */
  refresh() {
    this.updateItems();
    this.updateItemsARIA();

    if (this.#keyboardNav) {
      this.#keyboardNav.refresh?.();
    }
  }
}

// Register the custom element
if (!customElements.get('ren-select')) {
  customElements.define('ren-select', RenSelect);
}

data:
{"kind":"composite","selectors":[".ren-select",".ren-select-chip",".ren-select-chip-remove",".ren-select-chips",".ren-select-content",".ren-select-empty",".ren-select-group",".ren-select-icon",".ren-select-item",".ren-select-label",".ren-select-lg",".ren-select-placeholder",".ren-select-separator",".ren-select-sm",".ren-select-trigger",".ren-select-value"],"tokens":["--body-size","--caption-size","--color-accent","--color-accent-subtle","--color-border","--color-fill","--color-input-bg","--color-input-bg-hover","--color-input-border","--color-input-border-focus","--color-input-disabled-bg","--color-input-disabled-text","--color-input-focus-ring","--color-input-placeholder","--color-surface","--color-text","--color-text-muted","--duration-enter","--duration-exit","--duration-micro","--ease-enter","--ease-exit","--radius-lg","--radius-md","--radius-sm","--shadow-lg","--size-sm","--size-xl","--space-1","--space-2","--space-3","--space-4","--stroke-1","--text-lg","--text-sm","--touch-min","--transition-tactile","--z-dropdown"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-sheet
type: component
name: ren-sheet
path: components/composites/ren-sheet
body:
# ren-sheet Component Contract

Edge-attached dialog/surface for secondary workflows.

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

## Purpose

- Provide the agent-facing public contract for the `ren-sheet` 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 Sheet 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 disclosure is an edge-anchored panel (right / left / top / bottom) over the main app."
    - "You need the native <dialog> behavior — modal focus trap, ::backdrop, Escape to dismiss — on a side surface."
    - "You need a slide-in transition controlled by [data-side] with @starting-style fallback."
    - "You need swipe-to-dismiss on touch and outside-click / Escape dismissal."
    - "You need a mobile bottom sheet with a drag handle (.ren-sheet-handle)."
  avoidWhen:
    - "The disclosure should be centered and block the page — use ren-dialog."
    - "The panel is persistent app-shell navigation — use ren-sidebar."
    - "The disclosure is a small anchored popup — use ren-popover, ren-tooltip, or ren-menu."
    - "The notification is transient and must not steal focus — use ren-toast."

canonicalImports:
  css:
    - "rends/components/composites/ren-sheet/ren-sheet.css"
  js:
    - "rends/components/composites/ren-sheet/ren-sheet.js"
  notes:
    - "JS is required: it wraps the consumer's children in a real <dialog class=\"ren-sheet\"> and wires triggers, swipe, and lifecycle events."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "<ren-sheet id=\"...\" side=\"right|left|top|bottom\"> is the host; do not replace the inner <dialog> the component generates with a <div>."
  - "Header / body / footer slots use .ren-sheet-header, .ren-sheet-body, .ren-sheet-footer in that order so the body grows and the footer pins to the bottom."
  - "Provide .ren-sheet-title as a real heading; it supplies the accessible name when no aria-label is set on <ren-sheet>."
  - "Close affordances use [data-sheet-close]; triggers elsewhere on the page use [data-sheet-trigger=\"<id>\"]."
  - "Use size variants via the .-sm / .-md / .-lg / .-xl / .-full classes — do not inline width on the host."

forbiddenPatterns:
  - "<ren-sheet> rendered as a <div role=\"dialog\"> — the component must use the real <dialog> it generates."
  - "Calling .show() / .close() on the inner <dialog> directly; always use the <ren-sheet> .show() / .close() methods."
  - "Hardcoded backdrop colors (rgba(0,0,0,...)); use --color-overlay via the component's ::backdrop rule."
  - "Animating slide-in with custom @keyframes; use the documented [data-side] + @starting-style + --duration-enter / --ease-enter pipeline."
  - "Toggling visibility via display: none / display: block — the [open] attribute drives the @starting-style translate."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-sheet-backdrop, --ren-sheet-bg, --ren-sheet-border, --ren-sheet-duration, --ren-sheet-easing, --ren-sheet-padding, --ren-sheet-radius, --ren-sheet-shadow, --ren-sheet-width."
    - "Semantic surface tokens: --color-surface, --color-text, --color-text-muted, --color-border, --color-overlay, --color-fill, --color-fill-active, --color-fill-hover."
    - "Spacing / motion tokens: --space-*, --radius-full, --duration-enter, --duration-exit, --ease-enter, --ease-exit, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides — even for the box-shadow on the leading edge (use semantic tokens or override --ren-sheet-shadow)."
    - "Hardcoded transition durations; route through --duration-enter / --duration-exit and their paired easings."

accessibility:
  required:
    - "Modality is provided by the inner real <dialog> (focus trap + inert background); do not break the trap with manual focus moves."
    - ".ren-sheet-title supplies the accessible label; if omitted, set aria-label on <ren-sheet>."
    - "Escape closes the sheet (unless dismissible=\"false\"); the close button [data-sheet-close] has a visible aria-label."
    - "Restore focus to the original trigger element after close (handled by the component via the returnFocus reference)."
    - "Swipe-to-dismiss must coexist with keyboard dismissal; do not override touch-action on the dialog."
    - "Respect prefers-reduced-motion: the slide-in collapses to a plain fade automatically — do not reintroduce custom animations there."
```

## Required Imports

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

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

## Canonical Markup

```html
<button type="button" data-sheet-trigger="filters">Open filters</button><ren-sheet id="filters" side="right"><header class="ren-sheet-header"><h2 class="ren-sheet-title">Filters</h2><button type="button" data-sheet-close aria-label="Close">×</button></header><div class="ren-sheet-body">Filter controls</div><footer class="ren-sheet-footer"><button type="button" data-sheet-close="apply">Apply</button></footer></ren-sheet>

```

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

## Variants And Public Selectors

- `.ren-sheet`
- `.ren-sheet-body`
- `.ren-sheet-close`
- `.ren-sheet-description`
- `.ren-sheet-footer`
- `.ren-sheet-handle`
- `.ren-sheet-header`
- `.ren-sheet-title`

## States And Attributes

- `[data-sheet-close]`
- `[data-sheet-trigger]`
- `[data-side]`
- `:active`
- `:hover`

`close(returnValue)` and native `<form method="dialog">` closure emit exactly
one `ren-close` with `detail.returnValue`, while clearing host `open` and
restoring focus to the opener.

## Public Token API

- `--ren-sheet-backdrop`
- `--ren-sheet-bg`
- `--ren-sheet-border`
- `--ren-sheet-duration`
- `--ren-sheet-easing`
- `--ren-sheet-padding`
- `--ren-sheet-radius`
- `--ren-sheet-shadow`
- `--ren-sheet-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-sheet/ren-sheet.css`
- `components/composites/ren-sheet/ren-sheet.js`
- `docs/components/ren-sheet.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.


/* The custom-element host is structural only. Keeping it out of layout prevents
   an empty grid/flex item around the real dialog generated in light DOM. */
ren-sheet {
  display: contents;
}

/* Hide the dialog when it isn't open. The browser default for
   `<dialog>` without `open` is `display: none`, but the rule below
   sets `display: flex` for layout, so without this explicit reset
   the sheet would render in-flow on every page load. */
.ren-sheet:not([open]) {
  display: none;
}

.ren-sheet {
  position: fixed;
  margin: 0;
  padding: 0;
  border: none;

  background: var(--color-surface);
  color: var(--color-text);
  border-radius: 0;

  /* Header/body/footer stack: body grows, footer pins to the bottom */
  display: flex;
  flex-direction: column;

  z-index: 50;
  max-width: 100%;
  max-height: 100%;
  touch-action: none; /* Prevent scroll chaining; swipe-to-dismiss handled by JS */

  &[data-side='right'] {
    inset-inline-end: 0;
    top: 0;
    bottom: 0;

    width: min(24rem, 90vw);
    height: 100dvh;

    @starting-style {
      translate: 100% 0;
    }

    translate: 0 0;
  }

  &[data-side='left'] {
    inset-inline-start: 0;
    top: 0;
    bottom: 0;

    width: min(24rem, 90vw);
    height: 100dvh;

    @starting-style {
      translate: -100% 0;
    }

    translate: 0 0;
  }

  &[data-side='bottom'] {
    bottom: 0;
    inset-inline: 0;

    width: 100vw;
    max-height: 85dvh;

    @starting-style {
      translate: 0 100%;
    }

    translate: 0 0;
  }

  &[data-side='top'] {
    top: 0;
    inset-inline: 0;

    width: 100vw;
    max-height: 85dvh;

    @starting-style {
      translate: 0 -100%;
    }

    translate: 0 0;
  }

  /* Size variants */
  &.-sm {
    width: min(18rem, 90vw);
  }

  &.-md {
    width: min(24rem, 90vw);
  }

  &.-lg {
    width: min(32rem, 90vw);
  }

  &.-xl {
    width: min(42rem, 90vw);
  }

  &.-full {
    width: 100vw;
    height: 100dvh;
  }

  /* Transitions — semantic tokens keep sheet in sync with ren-dialog and
     friends; --duration-enter / --ease-enter collapse to 0ms under
     prefers-reduced-motion via tokens/semantic/motion.css. */
  transition: translate var(--duration-enter) var(--ease-enter),
    opacity var(--duration-enter) var(--ease-enter);
  transition-behavior: allow-discrete;

  /* Reduce motion */
  @media (prefers-reduced-motion: reduce) {
    transition: none;

    @starting-style {
      opacity: 0;
    }

    opacity: 1;
  }

  /* Shadow on appropriate edge */
  &[data-side='right'] {
    box-shadow: -12px 0 32px rgb(0, 0, 0, 0.12);
  }

  &[data-side='left'] {
    box-shadow: 12px 0 32px rgb(0, 0, 0, 0.12);
  }

  &[data-side='bottom'] {
    box-shadow: 0 -12px 32px rgb(0, 0, 0, 0.12);
  }

  &[data-side='top'] {
    box-shadow: 0 12px 32px rgb(0, 0, 0, 0.12);
  }

  /* Backdrop animations — enter uses --duration-enter / --ease-enter,
     close uses the exit pair so the scrim fades at a slightly brisker
     clip than the entry, matching the rest of the overlay family. */
  &[open]::backdrop {
    animation: ren-sheet-backdrop-open var(--duration-enter) var(--ease-enter) forwards;
  }

  &::backdrop {
    @starting-style {
      opacity: 0;
    }

    opacity: 1;
    background: var(--color-overlay);
    backdrop-filter: blur(4px);

    animation: ren-sheet-backdrop-close var(--duration-exit) var(--ease-exit) forwards;
  }

  &:modal::backdrop {
    background: var(--color-overlay);
    backdrop-filter: blur(4px);
  }
}

.ren-sheet-header {
  display: flex;
  align-items: center;
  justify-content: space-between;

  padding: var(--space-6);
  padding-block: var(--space-4);

  border-bottom: 1px solid var(--color-border);
}

.ren-sheet-title {
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--color-text);
  margin: 0;
}

.ren-sheet-description {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  margin: var(--space-2) 0 0 0;
}

.ren-sheet-close {
  display: flex;
  align-items: center;
  justify-content: center;

  width: var(--space-8);
  height: var(--space-8);

  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;

  font-size: 1.5rem;
  color: var(--color-text-muted);

  transition: var(--transition-tactile);

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

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

.ren-sheet-body {
  flex: 1;
  padding: var(--space-6);

  overflow-y: auto;
  overflow-x: hidden;

  /* Scrollbar styling */
  scrollbar-width: thin;
  scrollbar-color: var(--color-fill-active) transparent;

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

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

  &::-webkit-scrollbar-thumb {
    background: var(--color-fill-active);
    border-radius: var(--radius-full);
  }

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

.ren-sheet-footer {
  display: flex;
  gap: var(--space-3);

  padding: var(--space-6);
  padding-block: var(--space-4);

  border-top: 1px solid var(--color-border);
}

.ren-sheet-handle {
  display: flex;
  align-items: center;
  justify-content: center;

  width: 100%;
  height: var(--space-3);

  margin-bottom: var(--space-4);

  &::before {
    content: '';
    display: block;

    width: 40px;
    height: 4px;

    background: var(--color-fill);
    border-radius: var(--radius-full);
  }
}

@keyframes ren-sheet-backdrop-open {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes ren-sheet-backdrop-close {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}
/* Appearance API bridge */
.ren-sheet {
  width: var(--ren-sheet-width, 320px);
  padding: var(--ren-sheet-padding, var(--space-6));
  border-radius: var(--ren-sheet-radius, 0);
  background: var(--ren-sheet-bg, var(--color-surface-overlay));
  border-color: var(--ren-sheet-border, var(--color-border));
  box-shadow: var(--ren-sheet-shadow, var(--shadow-xl));
  transition-duration: var(--ren-sheet-duration, var(--duration-normal));
  transition-timing-function: var(--ren-sheet-easing, var(--ease-out));
}
.ren-sheet::backdrop { background: var(--ren-sheet-backdrop, var(--color-overlay)); }


/**
 * RenDS — <ren-sheet> Web Component
 * ==================================
 * Edge-anchored modal panel. Light DOM only — wraps the consumer's children
 * in a native <dialog class="ren-sheet"> so we get focus trap, ::backdrop,
 * Esc handling, and inert-on-body for free.
 *
 * Markup:
 *   <ren-sheet side="right" id="filters">
 *     <header class="ren-sheet-header">
 *       <h2 class="ren-sheet-title">Filters</h2>
 *       <button class="ren-sheet-close" data-sheet-close aria-label="Close">×</button>
 *     </header>
 *     <div class="ren-sheet-body"> … </div>
 *     <footer class="ren-sheet-footer"> … </footer>
 *   </ren-sheet>
 *
 *   <button data-sheet-trigger="filters">Open</button>
 *
 * Attributes:
 *   side         — "right" | "left" | "top" | "bottom" (default "right")
 *   size         — "sm" | "md" | "lg" | "xl" | "full" (default "md")
 *   open         — reflects show()/close()
 *   dismissible  — if "false", only the close button dismisses
 *
 * Methods:
 *   .show()
 *   .close()
 *   .open  (getter)
 *
 * Events (bubble):
 *   ren-open
 *   ren-close
 *
 * Light-DOM hooks:
 *   [data-sheet-trigger="<id>"]  anywhere on the page  → opens the sheet
 *   [data-sheet-close]           inside the sheet      → closes the sheet
 */

import { autoId } from '../../../utils/id-generator.js';

export class RenSheet extends HTMLElement {
  static get observedAttributes() {
    return ['side', 'size', 'data-side', 'data-size', 'open', 'dismissible'];
  }

  #dialog;
  #titleId;
  #upgraded = false;
  #returnFocus = null;
  #abortController = null;

  // Touch swipe
  #startX = 0;
  #startY = 0;
  #dragging = false;

  connectedCallback() {
    if (!this.#abortController || this.#abortController.signal.aborted) {
      this.#abortController = new AbortController();
    }
    if (this.#upgraded) {
      this.#wire();
      return;
    }
    this.#upgraded = true;
    autoId(this, 'sheet');
    this.#enhance();
    this.#wire();

    // If consumer set open as initial attribute, show
    if (this.hasAttribute('open')) {
      // Defer to ensure dialog is connected
      queueMicrotask(() => this.show());
    }
  }

  disconnectedCallback() {
    this.#dragging = false;
    if (this.#dialog?.open) {
      this.#dialog.close();
      this.#reconcileNativeClose();
    }
    this.#abortController?.abort();
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (!this.#upgraded || !this.#dialog) return;
    if (name === 'side' || name === 'data-side') {
      this.#dialog.setAttribute('data-side', this.#getSide());
    } else if (name === 'size' || name === 'data-size') {
      this.#syncSize();
    } else if (name === 'open') {
      const should = this.hasAttribute('open');
      if (should && !this.#dialog.open) this.show();
      if (!should && this.#dialog.open) this.close();
    }
  }

  /* ─── Enhancement ─── */

  #enhance() {
    // Move existing children into a <dialog>
    const fragment = document.createDocumentFragment();
    while (this.firstChild) fragment.appendChild(this.firstChild);

    this.#dialog = document.createElement('dialog');
    this.#dialog.className = 'ren-sheet';
    this.#dialog.setAttribute('role', 'dialog');
    this.#dialog.setAttribute('data-side', this.#getSide());
    this.#syncSize();

    this.#dialog.appendChild(fragment);

    // Wire aria-labelledby to a heading inside the sheet, if any
    const heading =
      this.#dialog.querySelector('.ren-sheet-title') ||
      this.#dialog.querySelector('h1, h2, h3');
    if (heading) {
      this.#titleId = autoId(heading, 'sheet-title');
      this.#dialog.setAttribute('aria-labelledby', this.#titleId);
    }

    this.appendChild(this.#dialog);
  }

  #getSide() {
    return (
      this.getAttribute('side') ||
      this.getAttribute('data-side') ||
      'right'
    );
  }

  #getSize() {
    return (
      this.getAttribute('size') ||
      this.getAttribute('data-size') ||
      'md'
    );
  }

  #syncSize() {
    if (!this.#dialog) return;
    // Drop any prior -sm/-md/-lg/-xl/-full classes
    this.#dialog.classList.remove('-sm', '-md', '-lg', '-xl', '-full');
    this.#dialog.classList.add(`-${this.#getSize()}`);
  }

  /* ─── Wiring ─── */

  #wire() {
    const { signal } = this.#abortController;
    // Backdrop click: native <dialog> dispatches click on the dialog itself
    // when the user clicks on the backdrop. e.target === dialog → it was the
    // backdrop, not content inside.
    this.#dialog.addEventListener('click', (e) => {
      if (!this.#isDismissible()) return;
      if (e.target === this.#dialog) this.close();
    }, { signal });

    // Esc — handled natively, but we intercept to honor dismissible=false
    this.#dialog.addEventListener('cancel', (e) => {
      if (!this.#isDismissible()) e.preventDefault();
    }, { signal });

    // Escape and <form method="dialog"> close the native dialog directly.
    // Reconcile the host and public lifecycle from the native close event.
    this.#dialog.addEventListener('close', () => {
      this.#reconcileNativeClose();
    }, { signal });

    // [data-sheet-close] anywhere inside
    this.#dialog.addEventListener('click', (e) => {
      const closer = e.target.closest('[data-sheet-close]');
      if (closer && this.#dialog.contains(closer)) {
        this.close(closer.getAttribute('data-sheet-close') || '');
      }
    }, { signal });

    // Swipe to dismiss
    this.#dialog.addEventListener('touchstart', (e) => {
      if (!this.#isDismissible()) return;
      this.#startX = e.touches[0].clientX;
      this.#startY = e.touches[0].clientY;
      this.#dragging = true;
    }, { passive: true, signal });

    this.#dialog.addEventListener('touchmove', (e) => {
      if (!this.#dragging) return;
      const dx = e.touches[0].clientX - this.#startX;
      const dy = e.touches[0].clientY - this.#startY;
      const side = this.#getSide();
      const threshold = 50;
      if (
        (side === 'right' && dx > threshold) ||
        (side === 'left' && dx < -threshold) ||
        (side === 'bottom' && dy > threshold) ||
        (side === 'top' && dy < -threshold)
      ) {
        this.#dragging = false;
        this.close();
      }
    }, { passive: true, signal });

    this.#dialog.addEventListener('touchend', () => {
      this.#dragging = false;
    }, { signal });
  }

  #isDismissible() {
    return this.getAttribute('dismissible') !== 'false';
  }

  /* ─── Public API ─── */

  show() {
    if (!this.#dialog || this.#dialog.open) return;
    this.#returnFocus = document.activeElement;
    this.#dialog.showModal();
    this.setAttribute('open', '');

    // Focus the first usable element inside, or the close button
    const focusable =
      this.#dialog.querySelector('[autofocus]') ||
      this.#dialog.querySelector(
        'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
    if (focusable) {
      focusable.focus({ preventScroll: true });
    }

    this.dispatchEvent(new CustomEvent('ren-open', { bubbles: true }));
  }

  close(returnValue = '') {
    if (!this.#dialog || !this.#dialog.open) return;
    this.#dialog.close(returnValue);
  }

  #reconcileNativeClose() {
    this.removeAttribute('open');

    // Restore focus to the element that opened the sheet
    if (this.#returnFocus && document.contains(this.#returnFocus)) {
      this.#returnFocus.focus({ preventScroll: true });
    }
    this.#returnFocus = null;

    this.dispatchEvent(new CustomEvent('ren-close', {
      bubbles: true,
      composed: true,
      detail: { returnValue: this.#dialog.returnValue || '' },
    }));
  }

  get open() {
    return !!(this.#dialog && this.#dialog.open);
  }
}

if (!customElements.get('ren-sheet')) {
  customElements.define('ren-sheet', RenSheet);
}

/* ─── Global trigger delegation ─── */
/**
 * <button data-sheet-trigger="some-id"> opens <ren-sheet id="some-id">.
 *
 * One global listener handles every trigger on the page so consumers don't
 * need any wiring of their own. Idempotent across module re-imports.
 */
if (typeof window !== 'undefined' && !window.__renSheetTriggerWired) {
  window.__renSheetTriggerWired = true;
  document.addEventListener('click', (e) => {
    const trigger = e.target.closest('[data-sheet-trigger]');
    if (!trigger) return;
    const targetId = trigger.getAttribute('data-sheet-trigger');
    if (!targetId) return;
    const sheet = document.getElementById(targetId);
    if (sheet && typeof sheet.show === 'function') {
      e.preventDefault();
      sheet.show();
    }
  });
}

data:
{"kind":"composite","selectors":[".ren-sheet",".ren-sheet-body",".ren-sheet-close",".ren-sheet-description",".ren-sheet-footer",".ren-sheet-handle",".ren-sheet-header",".ren-sheet-title"],"tokens":["--color-border","--color-fill","--color-fill-active","--color-fill-hover","--color-overlay","--color-surface","--color-surface-overlay","--color-text","--color-text-muted","--duration-enter","--duration-exit","--duration-normal","--ease-enter","--ease-exit","--ease-out","--radius-full","--ren-sheet-backdrop","--ren-sheet-bg","--ren-sheet-border","--ren-sheet-duration","--ren-sheet-easing","--ren-sheet-padding","--ren-sheet-radius","--ren-sheet-shadow","--ren-sheet-width","--shadow-xl","--space-2","--space-3","--space-4","--space-6","--space-8","--text-lg","--text-sm","--transition-tactile"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-slider
type: component
name: ren-slider
path: components/composites/ren-slider
body:
# ren-slider Component Contract

Range input composite for numeric adjustment.

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

## Purpose

- Provide the agent-facing public contract for the `ren-slider` 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 Slider 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:
    - "User needs to pick a continuous numeric value within a known min/max range (volume, opacity, brightness, percentage)."
    - "You need a single-thumb slider or a dual-thumb range slider (.ren-slider-range) with two inputs."
    - "You need a visible value readout (.ren-slider-value via show-value attribute) and/or tick marks (.ren-slider-marks)."
    - "You need semantic color variants for status: .ren-slider-success / .ren-slider-warning / .ren-slider-danger."
    - "You need a size variant (.ren-slider-sm / .ren-slider-lg) or a vertical orientation (.ren-slider-vertical)."
    - "Native <input type=\"range\"> ARIA and keyboard support is sufficient (Arrow keys, Home/End, PageUp/PageDown)."
  avoidWhen:
    - "The choice is from a discrete enumerated list — use ren-select, ren-radio-group, or ren-toggle-group."
    - "The value is text, number-typed, or formatted (currency, date) — use ren-field with a typed input."
    - "The control toggles binary state — use ren-switch, ren-checkbox, or ren-toggle."
    - "You need rating stars — use ren-rating."

canonicalImports:
  css:
    - "rends/components/composites/ren-slider/ren-slider.css"
  js:
    - "rends/components/composites/ren-slider/ren-slider.js"
  notes:
    - "JS is required to compute the --value percentage CSS variable that paints the track fill; without it the gradient stays at 0%."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "<ren-slider> wraps a real <input type=\"range\"> with min / max / step / value set as attributes — never style a <div> as a slider."
  - "Pair the input with a real <label for=\"...\"> or set aria-label / aria-labelledby on the input for an accessible name."
  - "Use show-value attribute on <ren-slider> to opt into the .ren-slider-value readout; use label attribute to inject a .ren-slider-label."
  - "Dual-thumb range mode wraps two <input type=\"range\"> elements inside .ren-slider-range with a .ren-slider-track-input visual rail."
  - "In dual-thumb mode the value property and ren-slider-input / ren-slider-change detail.value are a two-number tuple in input order."
  - "Tick marks use .ren-slider-marks > .ren-slider-mark > (.ren-slider-mark-tick + .ren-slider-mark-label); preserve that structure."

forbiddenPatterns:
  - "Replacing <input type=\"range\"> with a <div> + JS drag — accessibility and form submission depend on the native input."
  - "Setting the track fill via inline background gradients; the JS updates --value and the CSS handles the gradient automatically."
  - "Hardcoding thumb colors (background: #fff / blue) on ::-webkit-slider-thumb or ::-moz-range-thumb — use --color-accent or the .ren-slider-success/warning/danger variants."
  - "Removing the focus-visible outline on ::-webkit-slider-thumb / ::-moz-range-thumb without restoring an equivalent visible focus indicator."
  - "Stripping aria-valuemin / aria-valuemax / aria-valuenow (native input provides these — do not block them via role overrides)."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-slider-duration, --ren-slider-easing, --ren-slider-fill-color, --ren-slider-thumb-bg, --ren-slider-thumb-border, --ren-slider-thumb-shadow, --ren-slider-thumb-size, --ren-slider-track-bg, --ren-slider-track-height, --ren-slider-track-radius."
    - "Semantic state tokens: --color-accent, --color-success, --color-warning, --color-danger, --color-fill-active, --color-fill-subtle, --color-text, --color-text-muted."
    - "Shape / motion tokens: --radius-full, --space-*, --shadow-sm, --transition-tactile, font-size tokens (--font-size-label, --font-size-body, --font-size-xs)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides — including the white thumb border (keep it as a token override if you need to change it)."
    - "Hardcoded transition durations on the thumb hover/active scale; route through --transition-tactile."

accessibility:
  required:
    - "Use a real <input type=\"range\"> so native ARIA (role=slider, aria-valuemin/max/now) and keyboard (Arrow, Home/End, PageUp/Down) work."
    - "Visible focus ring on the thumb via outline: 2px solid var(--color-accent) with outline-offset; never set outline: none without an equivalent."
    - "Provide an accessible name via <label for> or aria-label; the .ren-slider-label class alone does not associate the label."
    - "Color variants must not be the only signal of state — accompany .ren-slider-danger with text or an icon for non-color cues."
    - "Disabled state uses :disabled on the input plus cursor: not-allowed; opacity changes alone are not sufficient on their own for state."
    - "Touch / drag interactions must not block keyboard nudges — both must drive the same change/input events the component dispatches."
```

## Required Imports

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

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

## Canonical Markup

```html
<label for="volume">Volume</label><ren-slider show-value><div class="ren-slider-track"><input id="volume" type="range" min="0" max="100" step="1" value="50"></div></ren-slider>

```

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

## Variants And Public Selectors

- `.ren-slider`
- `.ren-slider-danger`
- `.ren-slider-label`
- `.ren-slider-lg`
- `.ren-slider-mark`
- `.ren-slider-mark-label`
- `.ren-slider-mark-tick`
- `.ren-slider-marks`
- `.ren-slider-range`
- `.ren-slider-sm`
- `.ren-slider-success`
- `.ren-slider-track`
- `.ren-slider-track-input`
- `.ren-slider-value`
- `.ren-slider-vertical`
- `.ren-slider-warning`

## States And Attributes

- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`
- `type="range"`

## Public Token API

- `--ren-slider-duration`
- `--ren-slider-easing`
- `--ren-slider-fill-color`
- `--ren-slider-thumb-bg`
- `--ren-slider-thumb-border`
- `--ren-slider-thumb-shadow`
- `--ren-slider-thumb-size`
- `--ren-slider-track-bg`
- `--ren-slider-track-height`
- `--ren-slider-track-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-slider/ren-slider.css`
- `components/composites/ren-slider/ren-slider.js`
- `docs/components/ren-slider.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 SLIDER COMPONENT ═══ */

/* ═══ BASE WRAPPER ═══ */
.ren-slider {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* ═══ TRACK CONTAINER ═══ */
.ren-slider-track {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  height: 20px;
  cursor: pointer;
}

/* ═══ RANGE INPUT STYLING ═══ */
.ren-slider-track input[type="range"] {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  height: 6px;
  border-radius: var(--radius-full);
  background: var(--color-fill-active);
  outline: none;
  margin: 0;
  padding: 0;

  /* ═══ TRACK FILL WITH CSS VARIABLE ═══ */
  background: linear-gradient(
    to right,
    var(--color-accent) 0%,
    var(--color-accent) var(--value, 0%),
    var(--color-fill-active) var(--value, 0%),
    var(--color-fill-active) 100%
  );

  & ::-webkit-slider-thumb {
    appearance: none;
    -webkit-appearance: none;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--color-accent);
    border: 2px solid white;
    box-shadow: var(--shadow-sm);
    cursor: pointer;
    transition: var(--transition-tactile);

    &:hover {
      transform: scale(1.1);
    }

    &:active {
      transform: scale(0.95);
    }
  }

  & ::-moz-range-thumb {
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--color-accent);
    border: 2px solid white;
    box-shadow: var(--shadow-sm);
    cursor: pointer;
    transition: var(--transition-tactile);

    &:hover {
      transform: scale(1.1);
    }

    &:active {
      transform: scale(0.95);
    }
  }

  & ::-webkit-slider-thumb:focus-visible,
  & ::-moz-range-thumb:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

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

    & ::-webkit-slider-thumb,
    & ::-moz-range-thumb {
      cursor: not-allowed;
    }
  }
}

/* ═══ LABEL ═══ */
.ren-slider-label {
  font-size: var(--font-size-label);
  font-weight: 500;
  color: var(--color-text);
}

/* ═══ VALUE DISPLAY ═══ */
.ren-slider-value {
  font-size: var(--font-size-body-sm);
  color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
}

/* ═══ TICK MARKS ═══ */
.ren-slider-marks {
  display: flex;
  justify-content: space-between;
  gap: 0;
  width: 100%;
  margin-top: var(--space-1);

  & .ren-slider-mark {
    display: flex;
    flex-direction: column;
    align-items: center;
    flex: 0 0 auto;

    & .ren-slider-mark-tick {
      width: 2px;
      height: 6px;
      background: var(--color-fill-subtle);
      border-radius: 1px;
      margin-bottom: var(--space-0-5);
    }

    & .ren-slider-mark-label {
      font-size: var(--font-size-xs);
      color: var(--color-text-muted);
    }
  }
}

/* ═══ SIZE VARIANTS ═══ */
.ren-slider-track {
  &.ren-slider-sm input[type="range"] {
    height: 4px;
    background: linear-gradient(
      to right,
      var(--color-accent) 0%,
      var(--color-accent) var(--value, 0%),
      var(--color-fill-active) var(--value, 0%),
      var(--color-fill-active) 100%
    );

    & ::-webkit-slider-thumb {
      width: 16px;
      height: 16px;
    }

    & ::-moz-range-thumb {
      width: 16px;
      height: 16px;
    }
  }

  &.ren-slider-lg input[type="range"] {
    height: 8px;
    background: linear-gradient(
      to right,
      var(--color-accent) 0%,
      var(--color-accent) var(--value, 0%),
      var(--color-fill-active) var(--value, 0%),
      var(--color-fill-active) 100%
    );

    & ::-webkit-slider-thumb {
      width: 24px;
      height: 24px;
    }

    & ::-moz-range-thumb {
      width: 24px;
      height: 24px;
    }
  }
}

/* ═══ COLOR VARIANTS ═══ */
.ren-slider-track {
  &.ren-slider-success input[type="range"] {
    background: linear-gradient(
      to right,
      var(--color-success) 0%,
      var(--color-success) var(--value, 0%),
      var(--color-fill-active) var(--value, 0%),
      var(--color-fill-active) 100%
    );

    & ::-webkit-slider-thumb {
      background: var(--color-success);
    }

    & ::-moz-range-thumb {
      background: var(--color-success);
    }
  }

  &.ren-slider-warning input[type="range"] {
    background: linear-gradient(
      to right,
      var(--color-warning) 0%,
      var(--color-warning) var(--value, 0%),
      var(--color-fill-active) var(--value, 0%),
      var(--color-fill-active) 100%
    );

    & ::-webkit-slider-thumb {
      background: var(--color-warning);
    }

    & ::-moz-range-thumb {
      background: var(--color-warning);
    }
  }

  &.ren-slider-danger input[type="range"] {
    background: linear-gradient(
      to right,
      var(--color-danger) 0%,
      var(--color-danger) var(--value, 0%),
      var(--color-fill-active) var(--value, 0%),
      var(--color-fill-active) 100%
    );

    & ::-webkit-slider-thumb {
      background: var(--color-danger);
    }

    & ::-moz-range-thumb {
      background: var(--color-danger);
    }
  }
}

/* ═══ VERTICAL ORIENTATION ═══ */
.ren-slider-track {
  &.ren-slider-vertical {
    width: auto;
    height: 200px;
    writing-mode: vertical-lr;

    & input[type="range"] {
      width: auto;
      height: 200px;
      writing-mode: vertical-lr;
      direction: rtl;
    }
  }
}

/* ═══ RANGE MODE (DUAL THUMBS) ═══ */
.ren-slider-range {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  height: 20px;
  cursor: pointer;

  & .ren-slider-track-input {
    position: absolute;
    width: 100%;
    height: 6px;
    border-radius: var(--radius-full);
    background: linear-gradient(
      to right,
      var(--color-fill-active) 0 var(--value-start, 0%),
      var(--ren-slider-fill-color, var(--color-accent)) var(--value-start, 0%) var(--value-end, 100%),
      var(--color-fill-active) var(--value-end, 100%) 100%
    );
    pointer-events: none;
    margin: 0;
    padding: 0;
  }

  & input[type="range"] {
    position: absolute;
    inset: 0;
    width: 100%;
    z-index: 5;
    margin: 0;
    padding: 0;
    appearance: none;
    -webkit-appearance: none;
    background: none;
    height: 20px;
    border-radius: var(--radius-full);
    outline: none;
    pointer-events: none;

    &::-webkit-slider-thumb {
      appearance: none;
      -webkit-appearance: none;
      width: 20px;
      height: 20px;
      border-radius: 50%;
      background: var(--color-accent);
      border: 2px solid white;
      box-shadow: var(--shadow-sm);
      cursor: pointer;
      transition: var(--transition-tactile);
      z-index: 6;
      pointer-events: auto;

      &:hover {
        transform: scale(1.1);
      }

      &:active {
        transform: scale(0.95);
      }
    }

    &::-moz-range-thumb {
      width: 20px;
      height: 20px;
      border-radius: 50%;
      background: var(--color-accent);
      border: 2px solid white;
      box-shadow: var(--shadow-sm);
      cursor: pointer;
      transition: var(--transition-tactile);
      z-index: 6;
      pointer-events: auto;

      &:hover {
        transform: scale(1.1);
      }

      &:active {
        transform: scale(0.95);
      }
    }
  }
}

/* ═══ REDUCED MOTION ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-slider,
  .ren-slider * {
    animation-duration: 0ms !important;
    transition-duration: 0ms !important;
  }
}
/* Appearance API bridge; pseudo-elements intentionally have no selector whitespace. */
.ren-slider-track input[type="range"] {
  block-size: var(--ren-slider-track-height, 6px);
  border-radius: var(--ren-slider-track-radius, var(--radius-full));
  background: var(--ren-slider-track-bg, var(--color-fill-active));
  transition-duration: var(--ren-slider-duration, var(--duration-fast));
  transition-timing-function: var(--ren-slider-easing, var(--ease-out));
}
.ren-slider-track input[type="range"]::-webkit-slider-thumb { inline-size: var(--ren-slider-thumb-size, 20px); block-size: var(--ren-slider-thumb-size, 20px); background: var(--ren-slider-thumb-bg, var(--color-surface)); border-color: var(--ren-slider-thumb-border, var(--color-accent)); box-shadow: var(--ren-slider-thumb-shadow, var(--shadow-sm)); }
.ren-slider-track input[type="range"]::-moz-range-thumb { inline-size: var(--ren-slider-thumb-size, 20px); block-size: var(--ren-slider-thumb-size, 20px); background: var(--ren-slider-thumb-bg, var(--color-surface)); border-color: var(--ren-slider-thumb-border, var(--color-accent)); box-shadow: var(--ren-slider-thumb-shadow, var(--shadow-sm)); }
.ren-slider-track input[type="range"] { background-image: linear-gradient(to right, var(--ren-slider-fill-color, var(--color-accent)) 0%, var(--ren-slider-fill-color, var(--color-accent)) var(--value, 0%), var(--ren-slider-track-bg, var(--color-fill-active)) var(--value, 0%), var(--ren-slider-track-bg, var(--color-fill-active)) 100%); }


/* ═══ REN SLIDER WEB COMPONENT ═══ */

export class RenSlider extends HTMLElement {
  constructor() {
    super();
    this.handleInput = this.handleInput.bind(this);
    this.handleChange = this.handleChange.bind(this);
    this.handleRangePointerDown = this.handleRangePointerDown.bind(this);
    this.handleRangePointerMove = this.handleRangePointerMove.bind(this);
    this.handleRangePointerEnd = this.handleRangePointerEnd.bind(this);
  }

  connectedCallback() {
    this.inputs = [...this.querySelectorAll('input[type="range"]')];
    this.input = this.inputs[0] || null;

    if (!this.input) {
      console.warn('RenSlider: No input[type="range"] found');
      return;
    }

    /* ═══ SET UP TRACK CLASS ═══ */
    const trackContainer = this.input.parentElement || this.input;
    trackContainer.classList.add('ren-slider-track');

    /* ═══ INITIALIZE VALUE ═══ */
    this.updateValue();

    /* ═══ EVENT LISTENERS ═══ */
    this.inputs.forEach((input) => {
      input.addEventListener('input', this.handleInput);
      input.addEventListener('change', this.handleChange);
    });

    this.range = this.inputs.length === 2 ? this.querySelector('.ren-slider-range') : null;
    this.range?.addEventListener('pointerdown', this.handleRangePointerDown, true);
    this.range?.addEventListener('pointermove', this.handleRangePointerMove, true);
    this.range?.addEventListener('pointerup', this.handleRangePointerEnd, true);
    this.range?.addEventListener('pointercancel', this.handleRangePointerEnd, true);

    /* ═══ HANDLE LABEL DISPLAY ═══ */
    const label = this.getAttribute('label');
    if (label && !this.querySelector('.ren-slider-label')) {
      const labelEl = document.createElement('div');
      labelEl.className = 'ren-slider-label';
      labelEl.textContent = label;
      this.insertBefore(labelEl, this.input.parentElement);
    }

    /* ═══ HANDLE VALUE DISPLAY ═══ */
    if (this.hasAttribute('show-value')) {
      this.valueDisplay = this.querySelector('.ren-slider-value');
      if (!this.valueDisplay) {
        const valueEl = document.createElement('div');
        valueEl.className = 'ren-slider-value';
        this.valueDisplay = valueEl;
        this.input.parentElement.appendChild(valueEl);
      }
      this.updateValueDisplay();
    }

    /* ═══ TRANSFER VARIANT CLASSES ═══ */
    this.transferVariantClasses();
  }

  disconnectedCallback() {
    this.inputs?.forEach((input) => {
      input.removeEventListener('input', this.handleInput);
      input.removeEventListener('change', this.handleChange);
    });
    this.range?.removeEventListener('pointerdown', this.handleRangePointerDown, true);
    this.range?.removeEventListener('pointermove', this.handleRangePointerMove, true);
    this.range?.removeEventListener('pointerup', this.handleRangePointerEnd, true);
    this.range?.removeEventListener('pointercancel', this.handleRangePointerEnd, true);
    this.pointerDrag = null;
  }

  /* ═══ UPDATE CSS VARIABLE FOR TRACK FILL ═══ */
  updateValue() {
    if (!this.input) return;

    const percentages = this.inputs.map((input) => {
      const min = Number(input.min || 0);
      const max = Number(input.max || 100);
      const value = input.valueAsNumber;
      const percentage = ((value - min) / (max - min)) * 100;
      input.style.setProperty('--value', `${percentage}%`);
      return percentage;
    });

    if (percentages.length === 2) {
      const track = this.querySelector('.ren-slider-track-input');
      track?.style.setProperty('--value-start', `${Math.min(...percentages)}%`);
      track?.style.setProperty('--value-end', `${Math.max(...percentages)}%`);
    }
  }

  /* ═══ UPDATE VALUE DISPLAY TEXT ═══ */
  updateValueDisplay() {
    if (!this.valueDisplay || !this.input) return;

    const value = this.inputs.length === 2
      ? this.inputs.map((input) => input.value).join(' – ')
      : this.input.value;
    const unit = this.getAttribute('unit') || '';
    this.valueDisplay.textContent = `${value}${unit}`;
  }

  /* ═══ INPUT EVENT HANDLER ═══ */
  handleInput(event) {
    if (this.inputs.length === 2) {
      const [lower, upper] = this.inputs;
      if (event.target === lower && lower.valueAsNumber > upper.valueAsNumber) {
        lower.value = upper.value;
      } else if (event.target === upper && upper.valueAsNumber < lower.valueAsNumber) {
        upper.value = lower.value;
      }
    }
    this.updateValue();
    this.updateValueDisplay();

    this.dispatchEvent(
      new CustomEvent('ren-slider-input', {
        detail: { value: this.value },
        bubbles: true,
        composed: true,
      })
    );
  }

  isVerticalRange() {
    return this.classList.contains('ren-slider-vertical');
  }

  pointerPosition(event) {
    return this.isVerticalRange() ? -event.clientY : event.clientX;
  }

  valueFromPointer(input, event) {
    const rect = this.range.getBoundingClientRect();
    const min = Number(input.min || 0);
    const max = Number(input.max || 100);
    const step = input.step === 'any' ? 0 : Number(input.step || 1);
    const ratio = this.isVerticalRange()
      ? Math.max(0, Math.min(1, (rect.bottom - event.clientY) / rect.height))
      : Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
    let value = min + ratio * (max - min);
    if (step > 0) value = min + Math.round((value - min) / step) * step;
    return Math.max(min, Math.min(max, value));
  }

  handleRangePointerDown(event) {
    if (this.inputs.length !== 2 || this.disabled) return;
    const [lower, upper] = this.inputs;
    if (lower.valueAsNumber !== upper.valueAsNumber) return;
    const enabledInputs = this.inputs.filter((input) => !input.disabled);
    if (enabledInputs.length === 0) return;

    event.preventDefault();
    this.pointerDrag = {
      pointerId: event.pointerId,
      startPosition: this.pointerPosition(event),
      enabledInputs,
      activeInput: null,
    };
    this.range.setPointerCapture?.(event.pointerId);
  }

  handleRangePointerMove(event) {
    const drag = this.pointerDrag;
    if (!drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();

    if (!drag.activeInput) {
      const position = this.pointerPosition(event);
      if (position === drag.startPosition) return;
      const directionalInput = position < drag.startPosition ? this.inputs[0] : this.inputs[1];
      drag.activeInput = directionalInput.disabled ? drag.enabledInputs[0] : directionalInput;
      drag.activeInput.focus();
    }

    drag.activeInput.value = this.valueFromPointer(drag.activeInput, event);
    drag.activeInput.dispatchEvent(new Event('input', { bubbles: true }));
  }

  handleRangePointerEnd(event) {
    const drag = this.pointerDrag;
    if (!drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();
    if (drag.activeInput) drag.activeInput.dispatchEvent(new Event('change', { bubbles: true }));
    this.range.releasePointerCapture?.(event.pointerId);
    this.pointerDrag = null;
  }

  /* ═══ CHANGE EVENT HANDLER ═══ */
  handleChange(event) {
    this.dispatchEvent(
      new CustomEvent('ren-slider-change', {
        detail: { value: this.value },
        bubbles: true,
        composed: true,
      })
    );
  }

  /* ═══ TRANSFER VARIANT CLASSES TO TRACK ═══ */
  transferVariantClasses() {
    if (!this.input) return;

    const trackContainer = this.input.parentElement || this.input;
    const variants = ['sm', 'lg', 'success', 'warning', 'danger', 'vertical'];

    variants.forEach((variant) => {
      if (this.classList.contains(`ren-slider-${variant}`)) {
        trackContainer.classList.add(`ren-slider-${variant}`);
      }
    });
  }

  /* ═══ GETTERS AND SETTERS ═══ */
  get value() {
    if (!this.input) return null;
    const values = this.inputs.map((input) => input.valueAsNumber);
    return values.length === 2 ? values : values[0];
  }

  set value(val) {
    if (!this.input) return;
    const values = Array.isArray(val) ? [...val] : [val];
    if (this.inputs.length === 2 && values.length >= 2) {
      values.splice(0, 2, ...values.slice(0, 2).map(Number).sort((a, b) => a - b));
    }
    this.inputs.forEach((input, index) => {
      if (values[index] !== undefined) input.value = values[index];
    });
    this.updateValue();
    this.updateValueDisplay();
  }

  get min() {
    return this.input ? parseFloat(this.input.min) : null;
  }

  set min(val) {
    if (this.input) {
      this.inputs.forEach((input) => input.min = val);
      this.updateValue();
    }
  }

  get max() {
    return this.input ? parseFloat(this.input.max) : null;
  }

  set max(val) {
    if (this.input) {
      this.inputs.forEach((input) => input.max = val);
      this.updateValue();
    }
  }

  get disabled() {
    return this.input ? this.inputs.every((input) => input.disabled) : false;
  }

  set disabled(val) {
    if (this.input) {
      this.inputs.forEach((input) => input.disabled = val);
    }
  }
}

/* ═══ REGISTER COMPONENT ═══ */
if (!customElements.get('ren-slider')) {
  customElements.define('ren-slider', RenSlider);
}

data:
{"kind":"composite","selectors":[".ren-slider",".ren-slider-danger",".ren-slider-label",".ren-slider-lg",".ren-slider-mark",".ren-slider-mark-label",".ren-slider-mark-tick",".ren-slider-marks",".ren-slider-range",".ren-slider-sm",".ren-slider-success",".ren-slider-track",".ren-slider-track-input",".ren-slider-value",".ren-slider-vertical",".ren-slider-warning"],"tokens":["--color-accent","--color-danger","--color-fill-active","--color-fill-subtle","--color-success","--color-surface","--color-text","--color-text-muted","--color-warning","--duration-fast","--ease-out","--font-size-body-sm","--font-size-label","--font-size-xs","--radius-full","--ren-slider-duration","--ren-slider-easing","--ren-slider-fill-color","--ren-slider-thumb-bg","--ren-slider-thumb-border","--ren-slider-thumb-shadow","--ren-slider-thumb-size","--ren-slider-track-bg","--ren-slider-track-height","--ren-slider-track-radius","--shadow-sm","--space-0-5","--space-1","--transition-tactile","--value","--value-end","--value-start"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-tabs
type: component
name: ren-tabs
path: components/composites/ren-tabs
body:
# ren-tabs Component Contract

Tabbed interface with trigger list and panels.

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

## Purpose

- Provide the agent-facing public contract for the `ren-tabs` 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 Tabs 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 tablist that shows one panel at a time with mutually exclusive triggers."
    - "You need ARIA tablist semantics (role=tablist, role=tab, role=tabpanel) plus arrow-key keyboard navigation."
    - "You need a default-value (index or tab id) to control which panel renders on first paint."
    - "You need manual activation (arrow moves focus, Enter/Space selects) or automatic activation (arrow selects)."
    - "You need a visual variant — .ren-tab-list-underline (default), .ren-tab-list-pills, or .ren-tab-list-enclosed."
    - "The layout must adapt to narrow widths via container query (horizontal scroll on the .ren-tab-list)."
  avoidWhen:
    - "Each section should be visible simultaneously — use ren-accordion (multiple) or simple sections."
    - "The control switches the entire page route, not in-page content — use ren-nav or anchor links."
    - "Only two states are needed — use ren-toggle-group with a single selection."
    - "You need a stepper/wizard with sequential progress — use ren-stepper."

canonicalImports:
  css:
    - "rends/components/composites/ren-tabs/ren-tabs.css"
  js:
    - "rends/components/composites/ren-tabs/ren-tabs.js"
  notes:
    - "JS is required: it sets up roving tabindex, ARIA wiring, and the ren-tab-change event. CSS-only renders triggers but not the selection state."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "<ren-tabs> wraps a <div role=\"tablist\" class=\"ren-tab-list\"> followed by sibling <div role=\"tabpanel\" class=\"ren-tab-panel\"> elements."
  - "Each tab trigger is a real <button role=\"tab\" class=\"ren-tab\">; never use <a> or <div> for the trigger."
  - "Tab triggers and tabpanels must match 1:1 in document order; the component pairs them by index."
  - "Inactive panels carry the hidden attribute (the component toggles it); do not use display: none manually."
  - "Provide aria-label on <ren-tabs> when there are multiple tablists on the page, so screen-readers can distinguish them."

forbiddenPatterns:
  - "Wrapping a <a href> as the tab trigger — tabs change the visible panel, they are not navigation links."
  - "Setting aria-selected manually on a tab; let the component drive it via _selectedIndex."
  - "Hiding inactive panels with display: none in CSS overrides — use the hidden attribute the JS manages."
  - "Adding interactive elements (buttons, links) directly inside .ren-tab — keep the trigger a single activation surface."
  - "Removing the .ren-tab:focus-visible outline without restoring a visible focus indicator inside the inset offset."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-tabs-active-color, --ren-tabs-border-color, --ren-tabs-color, --ren-tabs-duration, --ren-tabs-easing, --ren-tabs-font-size, --ren-tabs-font-weight, --ren-tabs-gap, --ren-tabs-height, --ren-tabs-indicator-color."
    - "Semantic tokens: --color-text, --color-text-muted, --color-accent, --color-border, --color-fill, --color-fill-hover, --color-surface."
    - "Shape / motion tokens: --radius-sm, --radius-md, --space-*, --size-lg, --label-md-size, --label-md-weight, --label-lg-weight, --transition-tactile."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides for the active underline or pill background."
    - "Hardcoded transition durations on the .ren-tab hover; route through --transition-tactile."

accessibility:
  required:
    - "Triggers are real <button role=\"tab\"> with aria-selected reflecting state and tabindex managed by roving (only the selected tab is tabindex=0)."
    - "Tabpanels carry role=\"tabpanel\" and an aria-labelledby pointing at their trigger id (the component fills this in)."
    - "Arrow Left/Right (horizontal) or Up/Down (vertical) moves focus; Home / End jump to first / last; Enter / Space activate in manual mode."
    - "The .ren-tab meets touch target via min-height: var(--size-lg); do not shrink it below this in touch contexts."
    - "Active tab is distinguished by color AND the underline / pill / enclosed visual — never rely on color alone."
    - "Focus-visible ring is provided by outline + outline-offset on .ren-tab and the panel; preserve both."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-tabs aria-label="Account settings">
  <div class="ren-tab-list" role="tablist"><button class="ren-tab" role="tab">Profile</button><button class="ren-tab" role="tab">Security</button></div>
  <div class="ren-tab-panel" role="tabpanel">Profile settings</div><div class="ren-tab-panel" role="tabpanel" hidden>Security settings</div>
</ren-tabs>

```

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

## Variants And Public Selectors

- `.ren-tab`
- `.ren-tab-list`
- `.ren-tab-list-enclosed`
- `.ren-tab-list-pills`
- `.ren-tab-list-underline`
- `.ren-tab-panel`
- `.ren-tabs`

## States And Attributes

- `[aria-disabled]`
- `[aria-selected]`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-tabs-active-color`
- `--ren-tabs-border-color`
- `--ren-tabs-color`
- `--ren-tabs-duration`
- `--ren-tabs-easing`
- `--ren-tabs-font-size`
- `--ren-tabs-font-weight`
- `--ren-tabs-gap`
- `--ren-tabs-height`
- `--ren-tabs-indicator-color`

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-tabs/ren-tabs.css`
- `components/composites/ren-tabs/ren-tabs.js`
- `docs/components/ren-tabs.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.


/* ═══ TABS CONTAINER ═══ */
.ren-tabs {
  container-type: inline-size;
  container-name: ren-tabs;
  display: flex;
  flex-direction: column;
  width: 100%;
}

/* ═══ TAB LIST ═══ */
.ren-tab-list {
  display: flex;
  flex-direction: row;
  gap: var(--space-2);
  border-bottom: 1px solid var(--color-border);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

/* Smooth scrolling for horizontal tabs */
.ren-tab-list {
  scroll-behavior: smooth;
}

/* ═══ TAB ITEM (UNDERLINE VARIANT - DEFAULT) ═══ */
.ren-tab {
  position: relative;
  padding: var(--space-2) var(--space-4);
  font-size: var(--label-md-size);
  font-weight: var(--label-md-weight);
  line-height: 1;
  color: var(--color-text-muted);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  min-height: var(--size-lg);
  display: flex;
  align-items: center;
  white-space: nowrap;
  user-select: none;
  transition: var(--transition-tactile);
}

/* Active tab */
.ren-tab[aria-selected="true"] {
  color: var(--color-text);
  border-bottom-color: var(--color-accent);
  font-weight: var(--label-lg-weight);
}

/* Tab hover state */
.ren-tab:hover:not([aria-disabled="true"]):not(:disabled) {
  color: var(--color-text);
  background-color: var(--color-fill);
}

/* Tab focus ring */
.ren-tab:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: -2px;
  border-radius: var(--radius-sm);
}

/* Disabled tab */
.ren-tab:disabled,
.ren-tab[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
  color: var(--color-text-muted);
}

.ren-tab:disabled:hover,
.ren-tab[aria-disabled="true"]:hover {
  background-color: transparent;
}

/* ═══ TAB PANELS ═══ */
.ren-tab-panel {
  padding-block: var(--space-4);
}

.ren-tab-panel[hidden] {
  display: none;
}

/* Panel focus ring */
.ren-tab-panel:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
  border-radius: var(--radius-md);
}

/* ═══ VARIANT: PILLS ═══ */
.ren-tab-list-pills {
  border-bottom: none;
  gap: var(--space-1);
}

.ren-tab-list-pills .ren-tab {
  border-bottom: none;
  border-radius: var(--radius-md);
  background-color: transparent;
  padding: var(--space-2) var(--space-3);
}

.ren-tab-list-pills .ren-tab[aria-selected="true"] {
  background-color: var(--color-fill);
  border-bottom-color: transparent;
  font-weight: var(--label-lg-weight);
}

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

/* ═══ VARIANT: UNDERLINE (DEFAULT) ═══ */
.ren-tab-list-underline {
  border-bottom: 1px solid var(--color-border);
  gap: var(--space-2);
}

.ren-tab-list-underline .ren-tab {
  border-bottom: 2px solid transparent;
}

.ren-tab-list-underline .ren-tab[aria-selected="true"] {
  border-bottom-color: var(--color-accent);
}

/* ═══ VARIANT: ENCLOSED ═══ */
.ren-tab-list-enclosed {
  border-bottom: none;
  gap: var(--space-2);
}

.ren-tab-list-enclosed .ren-tab {
  border: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
  border-radius: var(--radius-md) var(--radius-md) 0 0;
  background-color: var(--color-fill);
  padding: var(--space-3) var(--space-4);
}

.ren-tab-list-enclosed .ren-tab[aria-selected="true"] {
  border-color: var(--color-border);
  border-bottom-color: var(--color-surface);
  background-color: var(--color-surface);
  color: var(--color-text);
  position: relative;
  z-index: 1;
}

.ren-tab-list-enclosed .ren-tab[aria-selected="true"]::after {
  content: '';
  position: absolute;
  bottom: -1px;
  left: -1px;
  right: -1px;
  height: 1px;
  background-color: var(--color-surface);
}

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

/* ═══ COMPACT LAYOUT (Container Query) ═══ */
@container ren-tabs (max-width: 480px) {
  .ren-tab-list {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    /* Add scrollbar styling for better UX */
    scrollbar-width: thin;
    scrollbar-color: var(--color-border) transparent;
  }

  .ren-tab-list::-webkit-scrollbar {
    height: 4px;
  }

  .ren-tab-list::-webkit-scrollbar-track {
    background: transparent;
  }

  .ren-tab-list::-webkit-scrollbar-thumb {
    background-color: var(--color-border);
    border-radius: 2px;
  }
}

/* ═══ REDUCED MOTION ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-tab {
    transition: none;
  }
}
/* Appearance API bridge */
.ren-tabs {
  min-block-size: var(--ren-tabs-height, var(--size-lg));
  font-size: var(--ren-tabs-font-size, var(--label-size));
  font-weight: var(--ren-tabs-font-weight, var(--label-weight));
  color: var(--ren-tabs-color, var(--color-text-muted));
  border-color: var(--ren-tabs-border-color, var(--color-border));
  gap: var(--ren-tabs-gap, 0);
  transition-duration: var(--ren-tabs-duration, var(--duration-fast));
  transition-timing-function: var(--ren-tabs-easing, var(--ease-out));
}
.ren-tabs [aria-selected="true"], .ren-tabs [data-state="active"] { color: var(--ren-tabs-active-color, var(--color-text)); }
.ren-tabs [data-indicator], .ren-tabs-indicator { background: var(--ren-tabs-indicator-color, var(--color-accent)); }


/**
 * RenDS — <ren-tabs> Web Component
 * ==================================
 * Accessible tabbed interface with keyboard navigation.
 * Implements ARIA tablist pattern with manual or automatic activation.
 *
 * Uses Light DOM — no Shadow DOM.
 * Works with semantic HTML or custom tab markup.
 *
 * Attributes:
 *   activation:    'manual' (default) | 'automatic'
 *                  - manual: arrow keys move focus, Enter/Space selects
 *                  - automatic: arrow keys immediately select tabs
 *   default-value: index or tab ID to select initially
 *   orientation:   'horizontal' (default) | 'vertical'
 *
 * Markup:
 *   <ren-tabs>
 *     <div role="tablist" class="ren-tab-list">
 *       <button role="tab" class="ren-tab">Tab 1</button>
 *       <button role="tab" class="ren-tab">Tab 2</button>
 *     </div>
 *     <div role="tabpanel" class="ren-tab-panel">
 *       Panel 1 content
 *     </div>
 *     <div role="tabpanel" class="ren-tab-panel">
 *       Panel 2 content
 *     </div>
 *   </ren-tabs>
 *
 * Events:
 *   ren-tab-change: Dispatched when a tab is selected
 *   - detail: { tab, panel, index, id }
 */

import { createKeyboardNav } from '../../../utils/keyboard-nav.js';
import { autoId } from '../../../utils/id-generator.js';

export class RenTabs extends HTMLElement {
  static get observedAttributes() {
    return ['activation', 'orientation', 'default-value'];
  }

  constructor() {
    super();
    this._nav = null;
    this._tabList = null;
    this._tabs = [];
    this._panels = [];
    this._selectedIndex = -1;
  }

  /**
   * Lifecycle: Element inserted into DOM
   */
  connectedCallback() {
    this._initialize();
  }

  /**
   * Lifecycle: Element removed from DOM
   */
  disconnectedCallback() {
    if (this._nav) {
      this._nav.detach();
      this._nav = null;
    }
  }

  /**
   * Lifecycle: Observed attribute changed
   */
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'default-value' && this.isConnected) {
      this._selectTabByValue(newValue);
    }
  }

  /* ═══ INITIALIZATION ═══ */

  /**
   * Initialize the tabs component
   * @private
   */
  _initialize() {
    // Find tablist container
    this._tabList = this.querySelector('[role="tablist"]') || this.querySelector('.ren-tab-list');
    if (!this._tabList) {
      console.warn('RenTabs: No tablist found');
      return;
    }

    // Collect tabs and panels
    this._tabs = Array.from(this.querySelectorAll('[role="tab"], .ren-tab'));
    this._panels = Array.from(this.querySelectorAll('[role="tabpanel"], .ren-tab-panel'));

    if (this._tabs.length === 0) {
      console.warn('RenTabs: No tabs found');
      return;
    }

    // Ensure all elements have proper ARIA roles
    this._tabList.setAttribute('role', 'tablist');
    this._tabs.forEach((tab) => {
      if (!tab.hasAttribute('role')) {
        tab.setAttribute('role', 'tab');
      }
    });
    this._panels.forEach((panel) => {
      if (!panel.hasAttribute('role')) {
        panel.setAttribute('role', 'tabpanel');
      }
    });

    // Wire up ARIA relationships
    this._wireAria();

    // Set up keyboard navigation
    this._setupKeyboardNav();

    // Select initial tab
    const defaultValue = this.getAttribute('default-value');
    if (defaultValue !== null) {
      this._selectTabByValue(defaultValue);
    } else {
      // Select first tab by default
      this._selectTab(0);
    }

    // Listen for clicks on tabs
    this._tabList.addEventListener('click', this._handleTabClick.bind(this));
  }

  /**
   * Wire up ARIA attributes between tabs and panels
   * @private
   */
  _wireAria() {
    this._tabs.forEach((tab, index) => {
      // Ensure tab has an ID
      autoId(tab, `tab-${index}`);

      // Find corresponding panel
      const panel = this._panels[index];
      if (panel) {
        // Ensure panel has an ID
        autoId(panel, `tabpanel-${index}`);

        // Wire aria-controls: tab controls panel
        tab.setAttribute('aria-controls', panel.id);

        // Wire aria-labelledby: panel is labeled by tab
        panel.setAttribute('aria-labelledby', tab.id);
      }

      // Initialize aria-selected state
      tab.setAttribute('aria-selected', index === this._selectedIndex ? 'true' : 'false');
    });
  }

  /**
   * Set up keyboard navigation using createKeyboardNav
   * @private
   */
  _setupKeyboardNav() {
    const activation = this.getAttribute('activation') || 'manual';
    const orientation = this.getAttribute('orientation') || 'horizontal';

    this._nav = createKeyboardNav(this._tabList, {
      selector: '[role="tab"]',
      orientation,
      loop: true,
      typeahead: false,
      focusOnHover: false,
      onActivate: (tab, index) => {
        // Arrow key navigation
        if (activation === 'automatic') {
          // Automatic: immediately select the tab
          this._selectTab(index);
        }
        // Manual: just move focus, don't select
        // (User must press Enter/Space to select)
      },
      onSelect: (tab, index) => {
        // Enter/Space pressed on focused tab
        this._selectTab(index);
      },
    });

    this._nav.attach();
  }

  /**
   * Handle direct click on a tab
   * @private
   */
  _handleTabClick(event) {
    const tab = event.target.closest('[role="tab"]');
    if (!tab) return;

    const index = this._tabs.indexOf(tab);
    if (index !== -1) {
      this._selectTab(index);
      // Ensure focus is on the clicked tab
      tab.focus();
    }
  }

  /* ═══ TAB SELECTION ═══ */

  /**
   * Select a tab by index
   * @param {number} index - Tab index
   * @private
   */
  _selectTab(index) {
    if (index < 0 || index >= this._tabs.length) {
      return;
    }

    // Deselect all tabs and hide all panels
    this._tabs.forEach((tab, i) => {
      const isSelected = i === index;
      tab.setAttribute('aria-selected', isSelected ? 'true' : 'false');
      tab.setAttribute('data-state', isSelected ? 'active' : 'inactive');
      if (this._panels[i]) {
        this._panels[i].toggleAttribute('hidden', !isSelected);
      }
    });

    this._selectedIndex = index;

    // Dispatch custom event
    const tab = this._tabs[index];
    const panel = this._panels[index];
    this.dispatchEvent(
      new CustomEvent('ren-tab-change', {
        detail: {
          tab,
          panel,
          index,
          id: tab.id,
        },
        bubbles: true,
        composed: true,
      })
    );
  }

  /**
   * Select a tab by ID or index value
   * @param {string|number} value - Tab ID or index string
   * @private
   */
  _selectTabByValue(value) {
    // Try parsing as index first
    const indexValue = parseInt(value, 10);
    if (!isNaN(indexValue) && indexValue >= 0 && indexValue < this._tabs.length) {
      this._selectTab(indexValue);
      return;
    }

    // Try finding by ID
    const tab = this._tabs.find((t) => t.id === value);
    if (tab) {
      const index = this._tabs.indexOf(tab);
      this._selectTab(index);
    }
  }

  /* ═══ PUBLIC API ═══ */

  /**
   * Get the currently selected tab index
   * @returns {number}
   */
  get selectedIndex() {
    return this._selectedIndex;
  }

  /**
   * Get the currently selected tab element
   * @returns {HTMLElement|null}
   */
  get selectedTab() {
    return this._selectedIndex >= 0 ? this._tabs[this._selectedIndex] : null;
  }

  /**
   * Get the currently selected panel element
   * @returns {HTMLElement|null}
   */
  get selectedPanel() {
    return this._selectedIndex >= 0 ? this._panels[this._selectedIndex] : null;
  }

  /**
   * Programmatically select a tab by index
   * @param {number} index
   */
  selectTabByIndex(index) {
    this._selectTab(index);
  }

  /**
   * Programmatically select a tab by ID
   * @param {string} id
   */
  selectTabById(id) {
    const index = this._tabs.findIndex((t) => t.id === id);
    if (index !== -1) {
      this._selectTab(index);
    }
  }

  /**
   * Get all tabs
   * @returns {HTMLElement[]}
   */
  get tabs() {
    return [...this._tabs];
  }

  /**
   * Get all panels
   * @returns {HTMLElement[]}
   */
  get panels() {
    return [...this._panels];
  }
}

// Register the component
if (!customElements.get('ren-tabs')) {
  customElements.define('ren-tabs', RenTabs);
}

data:
{"kind":"composite","selectors":[".ren-tab",".ren-tab-list",".ren-tab-list-enclosed",".ren-tab-list-pills",".ren-tab-list-underline",".ren-tab-panel",".ren-tabs",".ren-tabs-indicator"],"tokens":["--color-accent","--color-border","--color-fill","--color-fill-hover","--color-surface","--color-text","--color-text-muted","--duration-fast","--ease-out","--label-lg-weight","--label-md-size","--label-md-weight","--label-size","--label-weight","--radius-md","--radius-sm","--ren-tabs-active-color","--ren-tabs-border-color","--ren-tabs-color","--ren-tabs-duration","--ren-tabs-easing","--ren-tabs-font-size","--ren-tabs-font-weight","--ren-tabs-gap","--ren-tabs-height","--ren-tabs-indicator-color","--size-lg","--space-1","--space-2","--space-3","--space-4","--transition-tactile"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-toast
type: component
name: ren-toast
path: components/composites/ren-toast
body:
# ren-toast Component Contract

Transient notification system with live-region announcements.

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

## Purpose

- Provide the agent-facing public contract for the `ren-toast` 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 Toast 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 transient notification anchored to a viewport corner that auto-dismisses after a duration."
    - "The notification must NOT steal focus or block the page (use ren-dialog for that)."
    - "You need a status variant — success / info / warning / danger / loading — with matching icon and subtle status surface."
    - "You need a polite or assertive live-region announcement for screen-readers (handled via utils/live-region)."
    - "You need the imperative window.toast API (toast.success, toast.danger, toast.promise(...)) instead of declarative markup."
    - "You need swipe-to-dismiss, hover-to-pause timers, and an optional inline action button."
  avoidWhen:
    - "The message blocks the workflow and demands a decision — use ren-dialog (alert variant)."
    - "The message is persistent / inline page-level — use ren-banner or ren-alert."
    - "The message describes a form field state — use the helper-text / error slot on ren-field."
    - "The disclosure is a contextual popover from a trigger — use ren-popover or ren-tooltip."

canonicalImports:
  css:
    - "rends/components/composites/ren-toast/ren-toast.css"
  js:
    - "rends/components/composites/ren-toast/ren-toast.js"
  notes:
    - "JS is required: it owns the viewport queue, timers, live-region announcements, and swipe handling. window.toast is exposed as the imperative API."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Place one <ren-toast-viewport> (or <div class=\"ren-toast-viewport\" role=\"region\" aria-label=\"Notifications\">) per position; data-position defaults to bottom-right."
  - "Toasts themselves are appended by the JS as .ren-toast nodes with .ren-toast-icon, .ren-toast-body (.ren-toast-title + .ren-toast-description), optional .ren-toast-actions, and .ren-toast-close."
  - "Status variants are applied as a sibling class on .ren-toast: .ren-toast-success / .ren-toast-info / .ren-toast-warning / .ren-toast-danger / .ren-toast-error / .ren-toast-loading."
  - "Action buttons inside .ren-toast-actions reuse .ren-btn (typically .ren-btn-ghost .ren-btn-sm); .ren-toast-action is only an override hook."
  - "Set duration: 0 (or status: 'loading') for persistent toasts; expose a close button so users can dismiss them."

forbiddenPatterns:
  - "Calling toast.show() with status: 'danger' AND focusing the toast — toasts must not steal focus."
  - "Rendering toasts inline in document body without a .ren-toast-viewport host (timers and stacking will not work)."
  - "Hardcoded position offsets via inline style — use the [data-position] attribute and rely on var(--space-4) inset."
  - "Custom slide animations via @keyframes overriding ren-toast-slide-in-*; route motion through --duration-enter / --ease-enter."
  - "Using colored side borders for status emphasis; variants use a subtle status surface plus icon/text instead."
  - "Using a toast to ask for confirmation (\"Are you sure?\") — confirmations belong in ren-dialog."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-toast-anim-duration, --ren-toast-bg, --ren-toast-border, --ren-toast-duration, --ren-toast-easing, --ren-toast-gap, --ren-toast-padding, --ren-toast-radius, --ren-toast-shadow, --ren-toast-width, plus the shared --ren-btn-* family for inline action buttons."
    - "Semantic state tokens: --color-success, --color-success-subtle, --color-info, --color-info-subtle, --color-warning, --color-warning-subtle, --color-danger, --color-danger-subtle, --color-accent (progress bar), --color-surface, --color-surface-raised, --color-surface-hover, --color-surface-active, --color-text, --color-text-muted, --color-border."
    - "Shape / motion tokens: --space-*, --radius-sm, --radius-lg, --shadow-lg, --duration-enter, --duration-exit, --duration-micro, --ease-enter, --ease-exit, --transition-tactile, --z-toast."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides for the status surface, border, or icon color."
    - "Hardcoded animation timings; route through --duration-enter / --duration-exit and the paired easings."

accessibility:
  required:
    - "Viewport is a role=\"region\" with aria-label=\"Notifications\" so screen-readers can locate it as a landmark."
    - "Success / info messages announce via aria-live=\"polite\"; danger / error messages announce via aria-live=\"assertive\" (handled by announcePolite / announceAssertive)."
    - "Status must be communicated by icon AND text/title — color alone does not satisfy the contrast contract."
    - "Hover and keyboard focus pause auto-dismiss timers; restoring focus elsewhere resumes them."
    - "The .ren-toast-close button has an accessible name (aria-label=\"Close\") and is reachable via keyboard."
    - "Toasts never trap focus; they receive focus only when the user tabs into the viewport region intentionally."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-toast-viewport data-position="bottom-right" aria-label="Notifications"></ren-toast-viewport>

```

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

## Variants And Public Selectors

- `.ren-btn`
- `.ren-toast`
- `.ren-toast-action`
- `.ren-toast-actions`
- `.ren-toast-body`
- `.ren-toast-close`
- `.ren-toast-danger`
- `.ren-toast-description`
- `.ren-toast-error`
- `.ren-toast-icon`
- `.ren-toast-info`
- `.ren-toast-loading`
- `.ren-toast-progress`
- `.ren-toast-success`
- `.ren-toast-title`
- `.ren-toast-viewport`
- `.ren-toast-warning`

## States And Attributes

- `[data-closing]`
- `[data-position]`
- `[data-swiping]`
- `[data-toast-id]`
- `:active`
- `:hover`

## Public Token API

- `--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`
- `--ren-toast-anim-duration`
- `--ren-toast-bg`
- `--ren-toast-border`
- `--ren-toast-duration`
- `--ren-toast-easing`
- `--ren-toast-gap`
- `--ren-toast-padding`
- `--ren-toast-radius`
- `--ren-toast-shadow`
- `--ren-toast-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-toast/ren-toast.css`
- `components/composites/ren-toast/ren-toast.js`
- `docs/components/ren-toast.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 — Toast Notification System CSS
 * =====================================
 * Styles for notification toasts with animations,
 * positioning, variants, and responsive behavior.
 */

/* ═══ VIEWPORT CONTAINER ═══ */
.ren-toast-viewport {
  position: fixed;
  z-index: var(--z-toast, 9999);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  max-height: 100dvh;
  overflow: hidden;
  pointer-events: none;
  max-width: min(420px, calc(100vw - 2rem));
}

/* Position variants */
.ren-toast-viewport[data-position="top-right"] {
  top: var(--space-4);
  right: var(--space-4);
  flex-direction: column;
}

.ren-toast-viewport[data-position="top-left"] {
  top: var(--space-4);
  left: var(--space-4);
  flex-direction: column;
}

.ren-toast-viewport[data-position="top-center"] {
  top: var(--space-4);
  left: 50%;
  transform: translateX(-50%);
  flex-direction: column;
}

.ren-toast-viewport[data-position="bottom-right"] {
  bottom: var(--space-4);
  right: var(--space-4);
  flex-direction: column-reverse;
}

.ren-toast-viewport[data-position="bottom-left"] {
  bottom: var(--space-4);
  left: var(--space-4);
  flex-direction: column-reverse;
}

.ren-toast-viewport[data-position="bottom-center"] {
  bottom: var(--space-4);
  left: 50%;
  transform: translateX(-50%);
  flex-direction: column-reverse;
}

/* ═══ TOAST CONTAINER ═══ */
.ren-toast {
  pointer-events: auto;
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: var(--space-3) var(--space-4);
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  min-width: 20rem;
  max-width: 100%;
  position: relative;
  overflow: hidden;
  /* Enter + exit use semantic tokens so global motion tuning cascades here. */
  animation: ren-toast-fade-in var(--duration-enter) var(--ease-enter);
}

.ren-toast[data-closing] {
  animation: ren-toast-fade-out var(--duration-exit) var(--ease-exit);
  opacity: 0;
}

/* Slide-in animations by position */
.ren-toast-viewport[data-position="top-right"] .ren-toast,
.ren-toast-viewport[data-position="bottom-right"] .ren-toast {
  animation: ren-toast-slide-in-right var(--duration-enter) var(--ease-enter);
}

.ren-toast-viewport[data-position="top-right"] .ren-toast[data-closing],
.ren-toast-viewport[data-position="bottom-right"] .ren-toast[data-closing] {
  animation: ren-toast-slide-out-right var(--duration-exit) var(--ease-exit);
}

.ren-toast-viewport[data-position="top-left"] .ren-toast,
.ren-toast-viewport[data-position="bottom-left"] .ren-toast {
  animation: ren-toast-slide-in-left var(--duration-enter) var(--ease-enter);
}

.ren-toast-viewport[data-position="top-left"] .ren-toast[data-closing],
.ren-toast-viewport[data-position="bottom-left"] .ren-toast[data-closing] {
  animation: ren-toast-slide-out-left var(--duration-exit) var(--ease-exit);
}

.ren-toast-viewport[data-position="top-center"] .ren-toast,
.ren-toast-viewport[data-position="bottom-center"] .ren-toast {
  animation: ren-toast-fade-in var(--duration-enter) var(--ease-enter);
}

/* ═══ TOAST CONTENT STRUCTURE ═══ */
.ren-toast-icon {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  line-height: 1;
}

/* Body wraps title + description + actions */
.ren-toast-body {
  flex: 1;
  min-width: 0;
}

.ren-toast-title {
  font-weight: var(--font-weight-semibold, 600);
  font-size: var(--text-sm);
  line-height: var(--leading-5);
  color: var(--color-text);
  margin: 0;
}

.ren-toast-description {
  font-size: var(--text-xs);
  line-height: 1.5;
  color: var(--color-text-muted);
  margin: var(--space-1) 0 0;
}

.ren-toast-actions {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-2);
  flex-wrap: wrap;
}

/* The action button itself (the docs name it .ren-toast-action; we layer it
   on top of the standard .ren-btn variants for consistent styling). */
.ren-toast-action {
  /* Inherits its visual from .ren-btn ren-btn-ghost ren-btn-sm — this rule
     is just a hook for consumers who want to override per-toast. */
}

.ren-toast-close {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--color-text-muted);
  cursor: pointer;
  padding: var(--space-1);
  font-size: 18px;
  line-height: 1;
  /* Tactile (hover/focus/press) — uses shared semantic preset */
  transition: var(--transition-tactile);
}

.ren-toast-close:hover {
  background-color: var(--color-surface-hover);
  color: var(--color-text);
}

.ren-toast-close:active {
  background-color: var(--color-surface-active);
}

/* ═══ TOAST VARIANTS ═══ */
/* Success */
.ren-toast-success {
  background: color-mix(in srgb, var(--color-success-subtle) 65%, var(--color-surface-raised));
}

.ren-toast-success .ren-toast-icon {
  color: var(--color-success);
}

/* Error / Danger */
.ren-toast-error,
.ren-toast-danger {
  background: color-mix(in srgb, var(--color-danger-subtle) 65%, var(--color-surface-raised));
}

.ren-toast-error .ren-toast-icon,
.ren-toast-danger .ren-toast-icon {
  color: var(--color-danger);
}

/* Warning */
.ren-toast-warning {
  background: color-mix(in srgb, var(--color-warning-subtle) 65%, var(--color-surface-raised));
}

.ren-toast-warning .ren-toast-icon {
  color: var(--color-warning);
}

/* Info */
.ren-toast-info {
  background: color-mix(in srgb, var(--color-info-subtle) 65%, var(--color-surface-raised));
}

.ren-toast-info .ren-toast-icon {
  color: var(--color-info);
}

/* Loading */
.ren-toast-loading .ren-toast-icon {
  animation: ren-spinner-rotate 0.8s linear infinite;
}

/* ═══ PROGRESS BAR ═══ */
.ren-toast-progress {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 2px;
  background-color: var(--color-accent);
  transition: width linear;
}

/* ═══ SWIPE STATE ═══ */
.ren-toast[data-swiping] {
  transition: none;
}

/* ═══ ANIMATIONS ═══ */
@keyframes ren-toast-slide-in-right {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes ren-toast-slide-in-left {
  from {
    transform: translateX(-100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes ren-toast-slide-out-right {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(100%);
    opacity: 0;
  }
}

@keyframes ren-toast-slide-out-left {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}

@keyframes ren-toast-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes ren-toast-fade-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

@keyframes ren-spinner-rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* ═══ REDUCED MOTION ═══
   Belt-and-suspenders: semantic --duration-* already collapse
   to 0ms under reduced-motion (see tokens/semantic/motion.css),
   so the animations above become instant. We keep this block
   to force a plain fade (no slide) as an Apple-HIG alternative,
   and to stop the loading spinner. */
@media (prefers-reduced-motion: reduce) {
  .ren-toast,
  .ren-toast[data-closing],
  .ren-toast-viewport[data-position="top-right"] .ren-toast,
  .ren-toast-viewport[data-position="bottom-right"] .ren-toast,
  .ren-toast-viewport[data-position="top-left"] .ren-toast,
  .ren-toast-viewport[data-position="bottom-left"] .ren-toast,
  .ren-toast-viewport[data-position="top-center"] .ren-toast,
  .ren-toast-viewport[data-position="bottom-center"] .ren-toast {
    animation: ren-toast-fade-in var(--duration-micro) linear !important;
  }

  .ren-toast[data-closing] {
    animation: ren-toast-fade-out var(--duration-micro) linear !important;
  }

  .ren-toast-loading .ren-toast-icon {
    animation: none;
    opacity: 0.6;
  }
}

/* ═══ RESPONSIVE ═══ */
@media (max-width: 640px) {
  .ren-toast-viewport {
    max-width: calc(100vw - 1rem);
    bottom: var(--space-3);
    left: var(--space-2);
    right: var(--space-2);
    width: auto;
  }

  .ren-toast-viewport[data-position="top-right"],
  .ren-toast-viewport[data-position="top-left"],
  .ren-toast-viewport[data-position="top-center"] {
    bottom: auto;
    left: var(--space-2);
    right: var(--space-2);
    width: auto;
    transform: none;
  }

  .ren-toast-viewport[data-position="bottom-right"],
  .ren-toast-viewport[data-position="bottom-left"],
  .ren-toast-viewport[data-position="bottom-center"] {
    bottom: var(--space-3);
    left: var(--space-2);
    right: var(--space-2);
    width: auto;
    transform: none;
  }

  .ren-toast {
    min-width: unset;
    width: 100%;
  }
}
/* Appearance API bridge */
.ren-toast { inline-size: var(--ren-toast-width, 360px); padding: var(--ren-toast-padding, var(--space-4)); border-radius: var(--ren-toast-radius, var(--radius-lg)); background: var(--ren-toast-bg, var(--color-surface-raised)); border-color: var(--ren-toast-border, var(--color-border)); box-shadow: var(--ren-toast-shadow, var(--shadow-lg)); gap: var(--ren-toast-gap, var(--space-3)); animation-duration: var(--ren-toast-anim-duration, var(--duration-normal)); animation-timing-function: var(--ren-toast-easing, var(--ease-out)); }

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


/**
 * RenDS — Toast Notification System
 * ==================================
 * Transient notifications anchored to a corner of the viewport.
 *
 * Light DOM only. The viewport is a plain element with class
 * `.ren-toast-viewport` (or the convenience custom element
 * `<ren-toast-viewport>`). Toasts are plain `.ren-toast` divs
 * appended to it.
 *
 * Public API (also available as `window.toast`):
 *   toast.success(title, options?)
 *   toast.info(title, options?)
 *   toast.warning(title, options?)
 *   toast.danger(title, options?)
 *   toast.show(options)            — { title, description, status, duration, action }
 *   toast.dismiss(id)
 *   toast.dismissAll()
 *   toast.promise(promise, { loading, success, error })
 *
 * Options:
 *   title        — required string
 *   description  — optional secondary line
 *   status       — "success" | "info" | "warning" | "danger" | "loading"
 *   duration     — auto-dismiss ms (0 = persistent, default 4000 / 8000 for danger)
 *   dismissible  — show close button (default true)
 *   action       — { label, onClick } inline button
 */

import { announceAssertive, announcePolite } from '../../../utils/live-region.js';

/* ─── Defaults ─── */

const DEFAULT_DURATIONS = {
  success: 4000,
  info: 4000,
  warning: 5000,
  danger: 8000,
  loading: 0, // persistent
};

const ICONS = {
  success: '✓',
  info: 'ℹ',
  warning: '⚠',
  danger: '✕',
  loading: '⟳',
};

const VALID_POSITIONS = new Set([
  'top-right',
  'top-left',
  'top-center',
  'bottom-right',
  'bottom-left',
  'bottom-center',
]);

/* ─── Custom element (convenience) ─── */

export class RenToastViewport extends HTMLElement {
  connectedCallback() {
    this._listenerController?.abort();
    this._listenerController = new AbortController();
    const { signal } = this._listenerController;

    this.classList.add('ren-toast-viewport');
    if (!this.hasAttribute('data-position')) {
      this.setAttribute('data-position', 'bottom-right');
    }
    if (!VALID_POSITIONS.has(this.getAttribute('data-position'))) {
      this.setAttribute('data-position', 'bottom-right');
    }
    if (!this.hasAttribute('aria-label')) {
      this.setAttribute('aria-label', 'Notifications');
    }
    if (!this.hasAttribute('role')) this.setAttribute('role', 'region');

    // Pause timers on hover or keyboard focus
    this.addEventListener('mouseenter', () => pauseTimersFor(this), { signal });
    this.addEventListener('mouseleave', () => resumeTimersFor(this), { signal });
    this.addEventListener('focusin', () => pauseTimersFor(this), { signal });
    this.addEventListener('focusout', (e) => {
      if (!this.contains(e.relatedTarget)) resumeTimersFor(this);
    }, { signal });
  }

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

if (!customElements.get('ren-toast-viewport')) {
  customElements.define('ren-toast-viewport', RenToastViewport);
}

/* ─── Per-viewport timer book-keeping ─── */

const timerStore = new WeakMap(); // viewport → { timers: Map<id, {timerId, remaining, startedAt}>, paused: bool }

function getStore(viewport) {
  let store = timerStore.get(viewport);
  if (!store) {
    store = { timers: new Map(), paused: false };
    timerStore.set(viewport, store);
  }
  return store;
}

function pauseTimersFor(viewport) {
  const store = getStore(viewport);
  if (store.paused) return;
  store.paused = true;
  for (const [id, info] of store.timers.entries()) {
    if (info.timerId == null) continue;
    clearTimeout(info.timerId);
    info.timerId = null;
    if (info.startedAt && info.remaining) {
      info.remaining = Math.max(0, info.remaining - (Date.now() - info.startedAt));
    }
    // Pause progress bar (freeze its current width)
    const toastEl = viewport.querySelector(`[data-toast-id="${CSS.escape(id)}"]`);
    if (toastEl) {
      const progress = toastEl.querySelector('.ren-toast-progress');
      if (progress) {
        const width = getComputedStyle(progress).width;
        progress.style.transition = 'none';
        progress.style.width = width;
      }
    }
  }
}

function resumeTimersFor(viewport) {
  const store = getStore(viewport);
  if (!store.paused) return;
  store.paused = false;
  for (const [id, info] of store.timers.entries()) {
    if (info.remaining > 0) {
      info.startedAt = Date.now();
      info.timerId = setTimeout(() => dismissToast(viewport, id), info.remaining);
      // Resume progress bar
      const toastEl = viewport.querySelector(`[data-toast-id="${CSS.escape(id)}"]`);
      if (toastEl) {
        const progress = toastEl.querySelector('.ren-toast-progress');
        if (progress) {
          // Force reflow then animate to 0
          progress.style.transition = `width ${info.remaining}ms linear`;
          requestAnimationFrame(() => {
            progress.style.width = '0%';
          });
        }
      }
    }
  }
}

/* ─── Viewport resolution ─── */

let _autoViewport = null;

function resolveViewport(preferredId) {
  if (preferredId) {
    const v = document.getElementById(preferredId);
    if (v) return v;
  }
  if (_autoViewport && document.body.contains(_autoViewport)) {
    return _autoViewport;
  }
  // Look for any existing viewport in the page
  const existing = document.querySelector('.ren-toast-viewport, ren-toast-viewport');
  if (existing) {
    _autoViewport = existing;
    return existing;
  }
  // Auto-create
  _autoViewport = document.createElement('ren-toast-viewport');
  _autoViewport.setAttribute('data-position', 'bottom-right');
  document.body.appendChild(_autoViewport);
  return _autoViewport;
}

/* ─── Toast construction ─── */

function generateId() {
  return `toast-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

function normalize(input, statusOverride) {
  const opts = typeof input === 'string' ? { title: input } : { ...input };
  opts.statusExplicit = statusOverride != null || opts.status != null;
  if (statusOverride) opts.status = statusOverride;
  if (!opts.status) opts.status = 'info';
  opts.durationExplicit = opts.duration != null;
  if (opts.duration == null) opts.duration = DEFAULT_DURATIONS[opts.status] ?? 4000;
  if (opts.dismissible == null) opts.dismissible = true;
  return opts;
}

function buildToast(id, opts) {
  const { title, description, status, duration, dismissible, action } = opts;

  const toast = document.createElement('div');
  toast.className = `ren-toast ren-toast-${status}`;
  toast.setAttribute('data-toast-id', id);
  if (duration > 0) toast.setAttribute('data-duration', String(duration));
  toast.setAttribute('role', status === 'danger' ? 'alert' : 'status');
  toast.setAttribute('tabindex', '0'); // focusable for Esc/F6

  // Icon
  const icon = document.createElement('span');
  icon.className = 'ren-toast-icon';
  icon.setAttribute('aria-hidden', 'true');
  icon.textContent = ICONS[status] ?? ICONS.info;
  toast.appendChild(icon);

  // Body (title + description + action)
  const body = document.createElement('div');
  body.className = 'ren-toast-body';

  if (title) {
    const t = document.createElement('p');
    t.className = 'ren-toast-title';
    t.textContent = title;
    body.appendChild(t);
  }

  if (description) {
    const d = document.createElement('p');
    d.className = 'ren-toast-description';
    d.textContent = description;
    body.appendChild(d);
  }

  if (action && action.label) {
    const actions = document.createElement('div');
    actions.className = 'ren-toast-actions';

    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'ren-toast-action ren-btn ren-btn-ghost ren-btn-sm';
    btn.textContent = action.label;
    btn.addEventListener('click', () => {
      try {
        action.onClick?.();
      } finally {
        // Find the host viewport at click time (toast may have moved? unlikely)
        const viewport = toast.closest('.ren-toast-viewport, ren-toast-viewport');
        if (viewport) dismissToast(viewport, id);
      }
    });
    actions.appendChild(btn);
    body.appendChild(actions);
  }

  toast.appendChild(body);

  // Close button
  if (dismissible) {
    const close = document.createElement('button');
    close.type = 'button';
    close.className = 'ren-toast-close';
    close.setAttribute('aria-label', 'Dismiss notification');
    close.textContent = '×';
    close.addEventListener('click', () => {
      const viewport = toast.closest('.ren-toast-viewport, ren-toast-viewport');
      if (viewport) dismissToast(viewport, id);
    });
    toast.appendChild(close);
  }

  // Progress bar (auto-dismiss countdown)
  if (duration > 0) {
    const progress = document.createElement('div');
    progress.className = 'ren-toast-progress';
    progress.style.width = '100%';
    progress.style.transition = `width ${duration}ms linear`;
    toast.appendChild(progress);
    requestAnimationFrame(() => {
      progress.style.width = '0%';
    });
  }

  // Esc dismisses when toast itself has focus
  toast.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      const viewport = toast.closest('.ren-toast-viewport, ren-toast-viewport');
      if (viewport) dismissToast(viewport, id);
    }
  });

  return toast;
}

/* ─── Show / dismiss ─── */

function showOnViewport(viewport, opts) {
  const id = opts.id || generateId();

  // If a toast with this id exists, replace it
  const existing = viewport.querySelector(`[data-toast-id="${CSS.escape(id)}"]`);
  if (existing) {
    existing.remove();
    const store = getStore(viewport);
    const t = store.timers.get(id);
    if (t?.timerId) clearTimeout(t.timerId);
    store.timers.delete(id);
  }

  const toast = buildToast(id, opts);
  viewport.appendChild(toast);

  // Resolve the closest effective token scope, including overrides on the
  // generated toast itself. Explicit duration/status options remain
  // authoritative.
  if (!opts.durationExplicit && !opts.statusExplicit) {
    const tokenDuration = getComputedStyle(toast)
      .getPropertyValue('--ren-toast-duration')
      .trim();
    const parsedDuration = parseFloat(tokenDuration);
    if (Number.isFinite(parsedDuration) && parsedDuration >= 0) {
      opts.duration = parsedDuration;
      const progress = toast.querySelector('.ren-toast-progress');
      if (opts.duration > 0) {
        toast.setAttribute('data-duration', String(opts.duration));
        if (progress) progress.style.transition = `width ${opts.duration}ms linear`;
      } else if (progress) {
        progress.remove();
        toast.removeAttribute('data-duration');
      }
    }
  }

  if (!opts.durationExplicit && opts.duration > 0) toast.setAttribute('data-duration', String(opts.duration));

  // Schedule auto-dismiss
  if (opts.duration > 0) {
    const store = getStore(viewport);
    const startedAt = Date.now();
    const timerId = store.paused
      ? null
      : setTimeout(() => dismissToast(viewport, id), opts.duration);
    store.timers.set(id, { timerId, remaining: opts.duration, startedAt });
  }

  // Announce via live region (separate from the toast DOM so SRs don't double-up)
  const announcement = [opts.title, opts.description].filter(Boolean).join('. ');
  if (announcement) {
    if (opts.status === 'danger') announceAssertive(announcement);
    else announcePolite(announcement);
  }

  // Enforce optional max-toasts
  enforceMax(viewport);

  return id;
}

function enforceMax(viewport) {
  const max = parseInt(viewport.getAttribute('data-max'), 10);
  if (!max || isNaN(max)) return;
  const toasts = viewport.querySelectorAll('.ren-toast:not([data-closing])');
  if (toasts.length <= max) return;
  for (let i = 0; i < toasts.length - max; i++) {
    dismissToast(viewport, toasts[i].dataset.toastId);
  }
}

function dismissToast(viewport, id) {
  if (!viewport || !id) return;
  const toast = viewport.querySelector(`[data-toast-id="${CSS.escape(id)}"]`);
  if (!toast) return;

  const store = getStore(viewport);
  const t = store.timers.get(id);
  if (t?.timerId) clearTimeout(t.timerId);
  store.timers.delete(id);

  toast.setAttribute('data-closing', '');

  // Wait for the exit animation, then remove
  const dur = parseFloat(getComputedStyle(toast).animationDuration) * 1000 || 200;
  setTimeout(() => toast.remove(), dur);
}

/* ─── F6: focus viewport globally ─── */

function setupGlobalKeys() {
  if (window.__renToastF6Wired) return;
  window.__renToastF6Wired = true;
  document.addEventListener('keydown', (e) => {
    if (e.key === 'F6') {
      const viewport = document.querySelector('ren-toast-viewport, .ren-toast-viewport');
      if (!viewport) return;
      const firstToast = viewport.querySelector('.ren-toast');
      if (firstToast) {
        e.preventDefault();
        firstToast.focus();
      }
    }
  });
}

setupGlobalKeys();

/* ─── Public API ─── */

function show(input) {
  const opts = normalize(input);
  const viewport = resolveViewport();
  return showOnViewport(viewport, opts);
}

function variantFn(status) {
  return function (title, options = {}) {
    const opts =
      typeof title === 'string'
        ? { ...options, title }
        : { ...title, ...options };
    return show(normalize(opts, status));
  };
}

export const toast = Object.assign(show, {
  show,
  success: variantFn('success'),
  info: variantFn('info'),
  warning: variantFn('warning'),
  danger: variantFn('danger'),
  // Common alias for users coming from other libs
  error: variantFn('danger'),
  loading: variantFn('loading'),

  dismiss(id) {
    const viewport = resolveViewport();
    dismissToast(viewport, id);
  },

  dismissAll() {
    const viewport = resolveViewport();
    viewport.querySelectorAll('.ren-toast:not([data-closing])').forEach((t) => {
      dismissToast(viewport, t.dataset.toastId);
    });
  },

  update(id, updates) {
    const viewport = resolveViewport();
    const toastEl = viewport.querySelector(`[data-toast-id="${CSS.escape(id)}"]`);
    if (!toastEl) return;
    if (updates.status) {
      toastEl.className = `ren-toast ren-toast-${updates.status}`;
      toastEl.setAttribute('data-toast-id', id);
      toastEl.setAttribute(
        'role',
        updates.status === 'danger' ? 'alert' : 'status'
      );
      const icon = toastEl.querySelector('.ren-toast-icon');
      if (icon) icon.textContent = ICONS[updates.status] ?? ICONS.info;
    }
    if (updates.title !== undefined) {
      const t = toastEl.querySelector('.ren-toast-title');
      if (t) t.textContent = updates.title;
    }
    if (updates.description !== undefined) {
      const d = toastEl.querySelector('.ren-toast-description');
      if (d) d.textContent = updates.description;
    }
  },

  promise(promise, options = {}) {
    const id = generateId();
    const loadingOpts = normalize(options.loading || 'Loading...', 'loading');
    loadingOpts.id = id;
    const viewport = resolveViewport();
    showOnViewport(viewport, loadingOpts);

    return promise
      .then((data) => {
        const successInput =
          typeof options.success === 'function'
            ? options.success(data)
            : options.success;
        const successOpts = normalize(successInput || 'Done', 'success');
        successOpts.id = id;
        showOnViewport(viewport, successOpts);
        return data;
      })
      .catch((err) => {
        const errorInput =
          typeof options.error === 'function' ? options.error(err) : options.error;
        const errorOpts = normalize(errorInput || 'Failed', 'danger');
        errorOpts.id = id;
        showOnViewport(viewport, errorOpts);
        throw err;
      });
  },
});

// Expose globally — the docs show `window.toast.success(...)` as the primary usage.
if (typeof window !== 'undefined') {
  window.toast = toast;
}

data:
{"kind":"composite","selectors":[".ren-btn",".ren-toast",".ren-toast-action",".ren-toast-actions",".ren-toast-body",".ren-toast-close",".ren-toast-danger",".ren-toast-description",".ren-toast-error",".ren-toast-icon",".ren-toast-info",".ren-toast-loading",".ren-toast-progress",".ren-toast-success",".ren-toast-title",".ren-toast-viewport",".ren-toast-warning"],"tokens":["--color-accent","--color-border","--color-danger","--color-danger-subtle","--color-info","--color-info-subtle","--color-success","--color-success-subtle","--color-surface-active","--color-surface-hover","--color-surface-raised","--color-text","--color-text-muted","--color-warning","--color-warning-subtle","--duration-enter","--duration-exit","--duration-micro","--duration-normal","--ease-enter","--ease-exit","--ease-out","--font-weight-semibold","--leading-5","--radius-lg","--radius-sm","--ren-toast-anim-duration","--ren-toast-bg","--ren-toast-border","--ren-toast-easing","--ren-toast-gap","--ren-toast-padding","--ren-toast-radius","--ren-toast-shadow","--ren-toast-width","--shadow-lg","--space-1","--space-2","--space-3","--space-4","--text-sm","--text-xs","--touch-min","--transition-tactile","--z-toast"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-toggle-group
type: component
name: ren-toggle-group
path: components/composites/ren-toggle-group
body:
# ren-toggle-group Component Contract

Grouped toggle controls for mutually exclusive or multi-select modes.

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

## Purpose

- Provide the agent-facing public contract for the `ren-toggle-group` 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 Toggle Group 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:
    - "User picks from a small set of mutually exclusive options shown as visually grouped buttons (single mode)."
    - "User toggles multiple independent options that share a control (multiple mode, type=\"multiple\")."
    - "The choices are short labels or icons (text alignment, view density, list/grid mode) better surfaced than in a select."
    - "You want a visual variant — default filled pill group or .ren-toggle-group-outline."
    - "You need size variants (.ren-toggle-group-sm / .ren-toggle-group-lg), full-width fill, or vertical orientation."
  avoidWhen:
    - "The choice set is large or sortable — use ren-select."
    - "The choice is binary on/off for a single concept — use ren-switch or ren-checkbox."
    - "Items are commands rather than state — use ren-toolbar."
    - "Items are pages or routes — use ren-tabs or ren-nav."

canonicalImports:
  css:
    - "rends/components/composites/ren-toggle-group/ren-toggle-group.css"
  js:
    - "rends/components/composites/ren-toggle-group/ren-toggle-group.js"
  notes:
    - "JS is required: it wires role=button, roving tabindex, aria-pressed updates, keyboard navigation, and the ren-toggle-group-change event."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "<ren-toggle-group type=\"single|multiple\" value=\"...\"> wraps real <button class=\"ren-toggle-group-item\" data-value=\"...\"> elements (the component will also accept [role=button] nodes)."
  - "Each item must carry a data-value attribute or a stable textContent — the component uses it to drive selection and the change event."
  - "Set type=\"single\" for mutually-exclusive selection (the component clears other aria-pressed values) or type=\"multiple\" for independent toggles."
  - "Variants are applied on the host: .ren-toggle-group-outline, .ren-toggle-group-sm / -lg, .ren-toggle-group-full, .ren-toggle-group-vertical."
  - "Icons inside items must be <svg> or [role=\"img\"] children sized to 1em (handled by the CSS); accompany icons with text or aria-label."

forbiddenPatterns:
  - "<a href> as items — toggle-group is for state, not navigation."
  - "Setting aria-pressed manually on items; let the component drive it via setValue / click handling."
  - "Using ren-btn inside the group as items — they bring their own chrome; use plain <button class=\"ren-toggle-group-item\"> instead."
  - "Hardcoding the pressed background (background: #fff) — use --color-surface / --color-accent semantic tokens and the outline variant for accent fills."
  - "Removing the focus-visible outline without restoring an outline-offset visible ring."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-toggle-active-bg, --ren-toggle-active-color, --ren-toggle-bg, --ren-toggle-color, --ren-toggle-font-size, --ren-toggle-font-weight, --ren-toggle-height, --ren-toggle-padding-x, --ren-toggle-radius."
    - "Semantic tokens: --color-fill, --color-surface, --color-text, --color-text-muted, --color-accent, --color-border, --color-border-strong."
    - "Shape / motion tokens: --radius-md, --radius-lg, --space-*, --shadow-sm, --touch-min, --transition-tactile, font-size tokens (--font-size-label, --font-size-body, --font-size-xs)."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides for the pressed background or accent border."
    - "Hardcoded transition durations on item hover; route through --transition-tactile."

accessibility:
  required:
    - "Items are real <button> elements with role=\"button\" and aria-pressed reflecting state; roving tabindex ensures only one item is tabindex=0 at a time."
    - "Arrow Right/Down moves focus to the next item, Arrow Left/Up to the previous (wrapping); Home / End jump to first / last."
    - "Touch target meets calc(var(--touch-min) - 4px); .ren-toggle-group-sm should only be used in non-touch contexts."
    - "Active state communicates via background AND font-weight / box-shadow — never rely on color alone."
    - "Focus-visible outline is 2px solid var(--color-accent) with outline-offset; preserve it across all variants."
    - "Disabled items use :disabled with pointer-events: none so they remain in tab order semantics but cannot be activated."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-toggle-group type="single" value="left"><button class="ren-toggle-group-item" type="button" data-value="left">Left</button><button class="ren-toggle-group-item" type="button" data-value="center">Center</button><button class="ren-toggle-group-item" type="button" data-value="right">Right</button></ren-toggle-group>

```

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

## Variants And Public Selectors

- `.ren-toggle-group`
- `.ren-toggle-group-full`
- `.ren-toggle-group-item`
- `.ren-toggle-group-lg`
- `.ren-toggle-group-outline`
- `.ren-toggle-group-sm`
- `.ren-toggle-group-vertical`

## States And Attributes

- `[aria-pressed]`
- `[data-state]`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-toggle-active-bg`
- `--ren-toggle-active-color`
- `--ren-toggle-bg`
- `--ren-toggle-color`
- `--ren-toggle-font-size`
- `--ren-toggle-font-weight`
- `--ren-toggle-height`
- `--ren-toggle-padding-x`
- `--ren-toggle-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-toggle-group/ren-toggle-group.css`
- `components/composites/ren-toggle-group/ren-toggle-group.js`
- `docs/components/ren-toggle-group.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 TOGGLE GROUP COMPONENT ═══ */

/* ═══ BASE CONTAINER ═══ */
.ren-toggle-group {
  display: inline-flex;
  gap: var(--space-0-5);
  background: var(--color-fill);
  border-radius: var(--radius-lg);
  padding: 2px;
  align-items: center;

  & .ren-toggle-group-item {
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-md);
    font-size: var(--font-size-label);
    color: var(--color-text-muted);
    cursor: pointer;
    min-height: calc(var(--touch-min, 44px) - 4px);
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1);
    transition: var(--transition-tactile);
    user-select: none;
    border: none;
    background: transparent;
    font-family: inherit;
    font-weight: 400;
    white-space: nowrap;

    /* ═══ HOVER STATE ═══ */
    &:hover:not([aria-pressed="true"]) {
      color: var(--color-text);
    }

    /* ═══ ACTIVE/PRESSED STATE ═══ */
    &[aria-pressed="true"],
    &[data-state="on"] {
      background: var(--color-surface);
      color: var(--color-text);
      font-weight: 500;
      box-shadow: var(--shadow-sm);
    }

    /* ═══ FOCUS VISIBLE ═══ */
    &:focus-visible {
      outline: 2px solid var(--color-accent);
      outline-offset: 2px;
    }

    /* ═══ DISABLED STATE ═══ */
    &:disabled {
      opacity: 0.5;
      cursor: not-allowed;
      pointer-events: none;
    }
  }
}

/* ═══ OUTLINE VARIANT ═══ */
.ren-toggle-group.ren-toggle-group-outline {
  background: transparent;
  padding: 0;
  gap: var(--space-1);

  & .ren-toggle-group-item {
    border: 1px solid var(--color-border);
    background: transparent;
    color: var(--color-text-muted);

    &:hover:not([aria-pressed="true"]) {
      border-color: var(--color-border-strong);
      color: var(--color-text);
    }

    &[aria-pressed="true"],
    &[data-state="on"] {
      border-color: var(--color-accent);
      background: color-mix(in srgb, var(--color-accent) 10%, transparent);
      color: var(--color-accent);
      font-weight: 500;
      box-shadow: none;
    }
  }
}

/* ═══ SIZE VARIANTS ═══ */
.ren-toggle-group {
  &.ren-toggle-group-sm {
    gap: 1px;
    padding: 1px;

    & .ren-toggle-group-item {
      padding: var(--space-0-5) var(--space-2);
      font-size: var(--font-size-xs);
      min-height: calc(var(--touch-min, 44px) - 12px);
    }
  }

  &.ren-toggle-group-lg {
    gap: var(--space-1);
    padding: var(--space-0-5);

    & .ren-toggle-group-item {
      padding: var(--space-2) var(--space-4);
      font-size: var(--font-size-body);
      min-height: calc(var(--touch-min, 44px) + 4px);
    }
  }
}

/* ═══ FULL WIDTH ═══ */
.ren-toggle-group.ren-toggle-group-full {
  display: flex;
  width: 100%;

  & .ren-toggle-group-item {
    flex: 1;
  }
}

/* ═══ VERTICAL ORIENTATION ═══ */
.ren-toggle-group.ren-toggle-group-vertical {
  flex-direction: column;
  display: inline-flex;

  &.ren-toggle-group-full {
    display: flex;
    width: 100%;
  }
}

/* ═══ ICON SUPPORT ═══ */
.ren-toggle-group-item {
  & [role="img"],
  & svg {
    width: 1em;
    height: 1em;
    display: inline-block;
    vertical-align: middle;
  }
}

/* ═══ TRANSITIONS ═══ */
@media (prefers-reduced-motion: reduce) {
  .ren-toggle-group-item {
    transition: none;
  }
}
/* Appearance API bridge */
.ren-toggle-group { min-block-size: var(--ren-toggle-height, var(--size-lg)); padding-inline: var(--ren-toggle-padding-x, var(--space-4)); font-size: var(--ren-toggle-font-size, var(--label-size)); font-weight: var(--ren-toggle-font-weight, var(--label-weight)); border-radius: var(--ren-toggle-radius, var(--radius-md)); background: var(--ren-toggle-bg, var(--color-fill)); color: var(--ren-toggle-color, var(--color-text-muted)); }
.ren-toggle-group [aria-pressed="true"], .ren-toggle-group [data-state="on"] { background: var(--ren-toggle-active-bg, var(--color-surface)); color: var(--ren-toggle-active-color, var(--color-text)); }

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


/* ═══ REN TOGGLE GROUP WEB COMPONENT ═══ */

export class RenToggleGroup extends HTMLElement {
  constructor() {
    super();
    this.type = 'single';
    this.selectedValue = null;
    this.items = [];
    this.handleItemClick = this.handleItemClick.bind(this);
    this.handleKeyDown = this.handleKeyDown.bind(this);
  }

  connectedCallback() {
    /* ═══ READ ATTRIBUTES ═══ */
    this.type = this.getAttribute('type') || 'single';
    const initialValue = this.getAttribute('value');

    /* ═══ FIND ALL ITEMS ═══ */
    this.items = Array.from(
      this.querySelectorAll('.ren-toggle-group-item, [role="button"]')
    );

    if (this.items.length === 0) {
      console.warn('RenToggleGroup: No items found');
      return;
    }

    /* ═══ SET UP ARIA ATTRIBUTES ═══ */
    this.items.forEach((item, index) => {
      item.setAttribute('role', 'button');
      item.setAttribute('tabindex', index === 0 ? '0' : '-1');
      item.setAttribute('aria-pressed', 'false');

      /* ═══ ADD CLASSES ═══ */
      item.classList.add('ren-toggle-group-item');

      /* ═══ EVENT LISTENERS ═══ */
      item.addEventListener('click', this.handleItemClick);
      item.addEventListener('keydown', this.handleKeyDown);
    });

    /* ═══ SET INITIAL VALUE ═══ */
    if (initialValue) {
      this.setValue(initialValue);
    }

    /* ═══ ENSURE CONTAINER HAS CLASS ═══ */
    this.classList.add('ren-toggle-group');
  }

  disconnectedCallback() {
    this.items.forEach((item) => {
      item.removeEventListener('click', this.handleItemClick);
      item.removeEventListener('keydown', this.handleKeyDown);
    });
  }

  /* ═══ ITEM CLICK HANDLER ═══ */
  handleItemClick(event) {
    const item = event.currentTarget;
    const value = item.getAttribute('data-value') || item.textContent;

    if (this.type === 'single') {
      this.setValue(value);
    } else {
      /* ═══ MULTIPLE MODE: TOGGLE ═══ */
      const isPressed = item.getAttribute('aria-pressed') === 'true';
      item.setAttribute('aria-pressed', !isPressed);
      this.dispatchChangeEvent();
    }
  }

  /* ═══ KEYBOARD NAVIGATION ═══ */
  handleKeyDown(event) {
    const currentIndex = this.items.indexOf(event.currentTarget);
    let nextIndex = currentIndex;

    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
      event.preventDefault();
      nextIndex = (currentIndex + 1) % this.items.length;
    } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
      event.preventDefault();
      nextIndex = (currentIndex - 1 + this.items.length) % this.items.length;
    } else if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault();
      event.currentTarget.click();
      return;
    }

    if (nextIndex !== currentIndex) {
      this.items[currentIndex].setAttribute('tabindex', '-1');
      const nextItem = this.items[nextIndex];
      nextItem.setAttribute('tabindex', '0');
      nextItem.focus();
    }
  }

  /* ═══ SET SINGLE VALUE ═══ */
  setValue(value) {
    if (this.type !== 'single') {
      console.warn('RenToggleGroup: setValue only works in single mode');
      return;
    }

    /* ═══ CLEAR PREVIOUS SELECTION ═══ */
    this.items.forEach((item) => {
      item.setAttribute('aria-pressed', 'false');
    });

    /* ═══ SET NEW SELECTION ═══ */
    const targetItem = this.items.find(
      (item) =>
        (item.getAttribute('data-value') || item.textContent) === value
    );

    if (targetItem) {
      targetItem.setAttribute('aria-pressed', 'true');
      this.selectedValue = value;
      this.dispatchChangeEvent();
    }
  }

  /* ═══ DISPATCH CHANGE EVENT ═══ */
  dispatchChangeEvent() {
    const selectedItems =
      this.type === 'single'
        ? [this.items.find((item) => item.getAttribute('aria-pressed') === 'true')]
        : this.items.filter((item) => item.getAttribute('aria-pressed') === 'true');

    const values = selectedItems
      .filter(Boolean)
      .map((item) => item.getAttribute('data-value') || item.textContent);

    this.dispatchEvent(
      new CustomEvent('ren-toggle-change', {
        detail: {
          value: this.type === 'single' ? values[0] : values,
          items: selectedItems.filter(Boolean),
        },
        bubbles: true,
        composed: true,
      })
    );
  }

  /* ═══ GETTERS AND SETTERS ═══ */
  get value() {
    if (this.type === 'single') {
      return this.selectedValue;
    } else {
      return this.items
        .filter((item) => item.getAttribute('aria-pressed') === 'true')
        .map((item) => item.getAttribute('data-value') || item.textContent);
    }
  }

  set value(val) {
    if (this.type === 'single') {
      this.setValue(val);
    }
  }

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

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
      this.items.forEach((item) => item.setAttribute('disabled', ''));
    } else {
      this.removeAttribute('disabled');
      this.items.forEach((item) => item.removeAttribute('disabled'));
    }
  }
}

/* ═══ REGISTER COMPONENT ═══ */
if (!customElements.get('ren-toggle-group')) {
  customElements.define('ren-toggle-group', RenToggleGroup);
}

data:
{"kind":"composite","selectors":[".ren-toggle-group",".ren-toggle-group-full",".ren-toggle-group-item",".ren-toggle-group-lg",".ren-toggle-group-sm"],"tokens":["--color-accent","--color-border","--color-border-strong","--color-fill","--color-surface","--color-text","--color-text-muted","--font-size-body","--font-size-label","--font-size-xs","--label-size","--label-weight","--radius-lg","--radius-md","--ren-toggle-active-bg","--ren-toggle-active-color","--ren-toggle-bg","--ren-toggle-color","--ren-toggle-font-size","--ren-toggle-font-weight","--ren-toggle-height","--ren-toggle-padding-x","--ren-toggle-radius","--shadow-sm","--size-lg","--space-0-5","--space-1","--space-2","--space-3","--space-4","--touch-min","--transition-tactile"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-toolbar
type: component
name: ren-toolbar
path: components/composites/ren-toolbar
body:
# ren-toolbar Component Contract

Grouped command bar with roving keyboard navigation.

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

## Purpose

- Provide the agent-facing public contract for the `ren-toolbar` 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 Toolbar 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 grouped command bar (formatting actions, edit operations, view controls) co-located near content."
    - "You need the WAI-ARIA Toolbar pattern: Arrow keys move between items, Tab moves focus in / out of the toolbar."
    - "You need Home / End to jump to first / last item and skip disabled ones."
    - "Some items are toggleable (aria-pressed / [data-active] for sticky on-states like Bold)."
    - "You need a visual separator (.ren-toolbar-separator) to group related commands within the bar."
    - "You need icon-only items via .ren-toolbar-item-icon (aspect-ratio 1) or a ghost variant (.ren-toolbar-ghost)."
  avoidWhen:
    - "Items represent value selection rather than commands — use ren-toggle-group."
    - "Items represent navigation to other pages — use ren-nav or ren-sidebar."
    - "You need to switch between content panels — use ren-tabs."
    - "There is only one action — use ren-button directly."

canonicalImports:
  css:
    - "rends/components/composites/ren-toolbar/ren-toolbar.css"
  js:
    - "rends/components/composites/ren-toolbar/ren-toolbar.js"
  notes:
    - "JS is required for the roving tabindex pattern: import initToolbar / initAllToolbars from ren-toolbar.js."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "The host is <div class=\"ren-toolbar\" role=\"toolbar\" aria-label=\"<purpose>\">; the role and an aria-label are required so AT can announce it."
  - "Items are real <button class=\"ren-toolbar-item\"> with tabindex=\"0\" on the first item and tabindex=\"-1\" on the rest (roving tabindex)."
  - "Separators are <div class=\"ren-toolbar-separator\" role=\"separator\"> between logical groups within the toolbar."
  - "Sticky toggle items reflect state via aria-pressed=\"true|false\" or [data-active]; do not mix the two on the same item."
  - "Use .ren-toolbar-vertical on the host to switch arrow keys to Up/Down and turn separators horizontal."

forbiddenPatterns:
  - "Omitting role=\"toolbar\" or aria-label on the host — initToolbar bails out and the keyboard pattern will not attach."
  - "Setting tabindex=\"0\" on every item — only the focused item should be tabindex=\"0\"; the JS rewrites the rest to -1."
  - "Using <a href> as items — toolbar items invoke commands, not navigate; use ren-nav for links."
  - "Hardcoded gap / padding in inline styles; use --space-1 via the documented selectors."
  - "Mounting toolbar buttons as .ren-btn — toolbar owns its own item chrome; mixing the two doubles the focus rings and padding."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-toolbar-active-bg, --ren-toolbar-bg, --ren-toolbar-border, --ren-toolbar-height, --ren-toolbar-item-hover, --ren-toolbar-item-radius, --ren-toolbar-item-size, --ren-toolbar-padding, --ren-toolbar-radius."
    - "Semantic tokens: --color-text, --color-accent, --color-border, --color-fill-hover, --color-fill-active, --color-surface, --color-surface-raised, --color-surface-sunken, --color-disabled-text, --color-focus-ring."
    - "Shape / motion tokens: --radius-sm, --radius-md, --space-1, --space-2, --size-sm, --stroke-1, --ring-width, --shadow-xs, --transition-tactile, --weight-medium, --caption-size, --text-sm."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code."
    - "Hardcoded hex / rgb / named color values in overrides for the pressed-state background or active accent color."
    - "Hardcoded transition durations on item hover; route through --transition-tactile."

accessibility:
  required:
    - "Host carries role=\"toolbar\" plus an aria-label describing its purpose (e.g. \"Formatting\")."
    - "Roving tabindex: only one .ren-toolbar-item is tabindex=\"0\" at any time; Tab enters/exits the toolbar instead of cycling items."
    - "Arrow Left/Right (horizontal) or Up/Down (.ren-toolbar-vertical) moves focus; Home / End jump to first / last; disabled items are skipped."
    - "Toggle items expose aria-pressed and a visible non-color cue (box-shadow + accent color) in addition to background fill."
    - "Focus-visible ring is var(--ring-width) solid var(--color-focus-ring) with outline-offset: -1px; preserve it across variants."
    - "Disabled items use :disabled or aria-disabled=\"true\"; both block keyboard activation and are filtered out of the focus rotation."
```

## Required Imports

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

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

## Canonical Markup

```html
<div class="ren-toolbar" role="toolbar" aria-label="Formatting"><button class="ren-toolbar-item" type="button" tabindex="0">Bold</button><div class="ren-toolbar-separator" role="separator"></div><button class="ren-toolbar-item" type="button" tabindex="-1">Italic</button></div>

```

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

## Variants And Public Selectors

- `.ren-toolbar`
- `.ren-toolbar-ghost`
- `.ren-toolbar-item`
- `.ren-toolbar-item-icon`
- `.ren-toolbar-separator`
- `.ren-toolbar-vertical`

## States And Attributes

- `[aria-disabled]`
- `[aria-pressed]`
- `[data-active]`
- `:active`
- `:disabled`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-toolbar-active-bg`
- `--ren-toolbar-bg`
- `--ren-toolbar-border`
- `--ren-toolbar-height`
- `--ren-toolbar-item-hover`
- `--ren-toolbar-item-radius`
- `--ren-toolbar-item-size`
- `--ren-toolbar-padding`
- `--ren-toolbar-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-toolbar/ren-toolbar.css`
- `components/composites/ren-toolbar/ren-toolbar.js`
- `docs/components/ren-toolbar.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 — Toolbar Component
   ============================================
   Grouped actions with roving tabindex pattern.
   CSS-only for layout. JS only for keyboard nav.

   Follows WAI-ARIA Toolbar pattern:
   - Arrow keys move between items
   - Tab moves focus in/out of toolbar
   - Home/End go to first/last item

   Usage:
     <div class="ren-toolbar" role="toolbar" aria-label="Formatting">
       <button class="ren-toolbar-item" tabindex="0">Bold</button>
       <button class="ren-toolbar-item" tabindex="-1">Italic</button>
       <button class="ren-toolbar-item" tabindex="-1">Underline</button>
       <div class="ren-toolbar-separator" role="separator"></div>
       <button class="ren-toolbar-item" tabindex="-1">Left</button>
       <button class="ren-toolbar-item" tabindex="-1">Center</button>
       <button class="ren-toolbar-item" tabindex="-1">Right</button>
     </div>

   Note: Set tabindex="0" on first item, "-1" on rest.
   ren-toolbar.js handles roving tabindex automatically.
   ============================================ */

.ren-toolbar {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1);
  background-color: var(--color-surface-sunken);
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-md);
}

/* ─── Toolbar Items ─── */
.ren-toolbar-item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--size-sm);
  min-height: var(--size-sm);
  padding: var(--space-1) var(--space-2);
  border: none;
  background: none;
  color: var(--color-text);
  font-size: var(--caption-size, var(--text-sm));
  font-weight: var(--weight-medium);
  cursor: pointer;
  border-radius: var(--radius-sm);
  transition: var(--transition-tactile);
}

.ren-toolbar-item:hover {
  background-color: var(--color-fill-hover);
}

.ren-toolbar-item:active {
  background-color: var(--color-fill-active);
}

.ren-toolbar-item:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: -1px;
}

/* Active / Pressed state */
.ren-toolbar-item[aria-pressed="true"],
.ren-toolbar-item[data-active] {
  background-color: var(--color-surface-raised, var(--color-surface));
  box-shadow: var(--shadow-xs);
  color: var(--color-accent);
}

/* Disabled */
.ren-toolbar-item:disabled,
.ren-toolbar-item[aria-disabled="true"] {
  color: var(--color-disabled-text);
  cursor: not-allowed;
}

/* ─── Separator ─── */
.ren-toolbar-separator {
  width: 1px;
  height: 1.25rem;
  background-color: var(--color-border);
  margin-inline: var(--space-1);
  flex-shrink: 0;
}

/* ─── Icon-only items ─── */
.ren-toolbar-item-icon {
  min-width: var(--size-sm);
  padding: var(--space-1);
  aspect-ratio: 1;
}

/* ─── Variants ─── */

/* Ghost — no background on container */
.ren-toolbar-ghost {
  background: none;
  border: none;
  padding: 0;
  gap: var(--space-1);
}

/* Vertical toolbar */
.ren-toolbar-vertical {
  flex-direction: column;
}

.ren-toolbar-vertical .ren-toolbar-separator {
  width: auto;
  height: 1px;
  align-self: stretch;
  margin-block: var(--space-1);
  margin-inline: 0;
}
/* Appearance API bridge */
.ren-toolbar { min-block-size: var(--ren-toolbar-height, var(--size-lg)); padding: var(--ren-toolbar-padding, var(--space-1)); border-radius: var(--ren-toolbar-radius, var(--radius-lg)); background: var(--ren-toolbar-bg, var(--color-surface-raised)); border-color: var(--ren-toolbar-border, var(--color-border)); }
.ren-toolbar button, .ren-toolbar [role="button"] { inline-size: var(--ren-toolbar-item-size, var(--size-md)); block-size: var(--ren-toolbar-item-size, var(--size-md)); border-radius: var(--ren-toolbar-item-radius, var(--radius-sm)); }
.ren-toolbar button:hover, .ren-toolbar [role="button"]:hover { background: var(--ren-toolbar-item-hover, var(--color-fill)); }
.ren-toolbar button[aria-pressed="true"], .ren-toolbar [data-state="on"] { background: var(--ren-toolbar-active-bg, var(--color-accent-subtle)); }


/* ============================================
   RenDS — Toolbar Keyboard Navigation
   ============================================
   Implements WAI-ARIA Toolbar pattern:
   - Arrow Left/Right: move between items
   - Home: first item
   - End: last item
   - Tab: exits toolbar

   Usage:
     import { initToolbar } from './ren-toolbar.js';
     initToolbar(document.querySelector('.ren-toolbar'));

   Or auto-init all:
     import { initAllToolbars } from './ren-toolbar.js';
     initAllToolbars();
   ============================================ */

/**
 * @param {HTMLElement} toolbar
 */
const toolbarControllers = new WeakMap();

export function initToolbar(toolbar) {
  if (!toolbar || toolbar.getAttribute('role') !== 'toolbar') return;

  toolbarControllers.get(toolbar)?.abort();
  const controller = new AbortController();
  toolbarControllers.set(toolbar, controller);

  const getItems = () =>
    [...toolbar.querySelectorAll('.ren-toolbar-item:not(:disabled):not([aria-disabled="true"])')];

  const isVertical = toolbar.classList.contains('ren-toolbar-vertical');
  const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
  const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';

  toolbar.addEventListener('keydown', (e) => {
    const items = getItems();
    const current = items.indexOf(document.activeElement);
    if (current === -1) return;

    let next = -1;

    if (e.key === nextKey) {
      next = (current + 1) % items.length;
    } else if (e.key === prevKey) {
      next = (current - 1 + items.length) % items.length;
    } else if (e.key === 'Home') {
      next = 0;
    } else if (e.key === 'End') {
      next = items.length - 1;
    }

    if (next !== -1) {
      e.preventDefault();
      items.forEach((item) => item.setAttribute('tabindex', '-1'));
      items[next].setAttribute('tabindex', '0');
      items[next].focus();
    }
  }, { signal: controller.signal });
}

/**
 * Auto-init all toolbars in the document.
 */
export function initAllToolbars() {
  document.querySelectorAll('[role="toolbar"]').forEach(initToolbar);
}

data:
{"kind":"composite","selectors":[".ren-toolbar",".ren-toolbar-ghost",".ren-toolbar-item",".ren-toolbar-item-icon",".ren-toolbar-separator",".ren-toolbar-vertical"],"tokens":["--caption-size","--color-accent","--color-accent-subtle","--color-border","--color-disabled-text","--color-fill","--color-fill-active","--color-fill-hover","--color-focus-ring","--color-surface","--color-surface-raised","--color-surface-sunken","--color-text","--radius-lg","--radius-md","--radius-sm","--ren-toolbar-active-bg","--ren-toolbar-bg","--ren-toolbar-border","--ren-toolbar-height","--ren-toolbar-item-hover","--ren-toolbar-item-radius","--ren-toolbar-item-size","--ren-toolbar-padding","--ren-toolbar-radius","--ring-width","--shadow-xs","--size-lg","--size-md","--size-sm","--space-1","--space-2","--stroke-1","--text-sm","--transition-tactile","--weight-medium"],"hasScript":true,"hasDocsPage":true}


id: component:composite:ren-tooltip
type: component
name: ren-tooltip
path: components/composites/ren-tooltip
body:
# ren-tooltip Component Contract

Short accessible description attached to a trigger.

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

## Purpose

- Provide the agent-facing public contract for the `ren-tooltip` 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 Tooltip 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 short, non-interactive description attached to a hoverable / focusable trigger (icon button label, truncated text expansion)."
    - "The content is plain text under ~15rem and provides supplementary info, not essential interaction."
    - "Disclosure is driven by hover, focus, and the popover API with anchor positioning + flip-block fallback."
    - "You need the dark high-contrast pill with an optional arrow caret (.ren-tooltip-arrow)."
    - "You can place [data-side=\"top|right|bottom|left\"] on the .ren-tooltip to indicate preferred side; CSS handles flipping."
  avoidWhen:
    - "The content is interactive (links, buttons, form fields) — use ren-popover or ren-hover-card."
    - "The content is critical for completing the task — use inline helper text or a visible label."
    - "The disclosure is value selection — use ren-select or ren-menu."
    - "The trigger has no accessible name and the tooltip is the only label — give the trigger a real aria-label or visible text instead."

canonicalImports:
  css:
    - "rends/components/composites/ren-tooltip/ren-tooltip.css"
  js:
    - "rends/components/composites/ren-tooltip/ren-tooltip.js"
  notes:
    - "JS is required: it owns the popover toggle, hover/focus timing (--ren-tooltip-delay), and the fallback positioning for browsers without CSS anchor-name support."
    - "If the page already imports rends/components/index.css, do not import the CSS again."

requiredMarkup:
  - "Pair a .ren-tooltip-trigger (real <button> or focusable element) with a .ren-tooltip element referenced via the popover API (popovertarget / id linkage)."
  - "The tooltip carries popover attribute so it lives in the top layer; do not implement it as a plain absolutely-positioned <div>."
  - "Include .ren-tooltip-arrow as a child of .ren-tooltip when an arrow is desired; position is driven by [data-side]."
  - "Set the desired side on the tooltip via data-side=\"top|right|bottom|left\"; CSS uses position-area + position-try-fallbacks: flip-block for safe placement."
  - "Tooltip content must be plain text — no buttons, links, or form fields (those break the contract; use ren-popover instead)."

forbiddenPatterns:
  - "Putting interactive elements inside .ren-tooltip — pointer-events: none on the tooltip means clicks would not work and screen readers would not focus them anyway."
  - "Showing the tooltip only on hover without keyboard focus support — every trigger must reveal the tooltip on :focus-visible."
  - "Using a tooltip as the sole accessible name for an icon button — provide aria-label on the trigger and let the tooltip echo it."
  - "Animating the tooltip via custom @keyframes; rely on the documented opacity + transform transition with @starting-style."
  - "Hardcoding background / color on .ren-tooltip — override --ren-tooltip-bg / --ren-tooltip-color tokens instead."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-tooltip-bg, --ren-tooltip-color, --ren-tooltip-delay, --ren-tooltip-duration, --ren-tooltip-easing, --ren-tooltip-font-size, --ren-tooltip-padding-x, --ren-tooltip-padding-y, --ren-tooltip-radius, --ren-tooltip-shadow."
    - "Shape / motion tokens: --radius-md, --space-1, --space-2, --size-caption, --duration-enter, --ease-enter, --z-tooltip."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code. Note: the current default uses --color-gray-900 as the surface; consumers should override --ren-tooltip-bg with a semantic token (e.g. --color-text) rather than reaching for another --gray-* directly."
    - "Hardcoded hex / rgb / named color values (e.g. background: #111, color: white) in overrides — use --ren-tooltip-bg and --ren-tooltip-color."
    - "Hardcoded transition durations; route through --duration-enter / --ren-tooltip-duration and the paired easings."

accessibility:
  required:
    - "Trigger has its own accessible name (visible text or aria-label); the tooltip supplements but does not replace it."
    - "Tooltip opens on both :hover and keyboard :focus-visible; closing on blur / mouseleave / Escape."
    - "Hover delay respects --ren-tooltip-delay; do not show tooltips instantly on mouse jitter or hide them on the next mousemove."
    - "Tooltip text must meet contrast for the chosen --ren-tooltip-bg / --ren-tooltip-color pair (default dark bg / white text is WCAG AA against typical surfaces)."
    - "Touch users: tooltips should not be the only way to reveal essential info — provide a visible label or alternate disclosure for touch."
    - "Tooltip is pointer-events: none and never receives focus — if the content needs interaction, switch to ren-popover."
    - "The component appends its ID to any existing aria-describedby token list and removes only that owned token on disconnect."
```

## Required Imports

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

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

## Canonical Markup

```html
<button class="ren-tooltip-trigger" type="button" aria-label="More information">Info<ren-tooltip id="info-tip" placement="top">More information</ren-tooltip></button>

```

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

## Variants And Public Selectors

- `.ren-open`
- `.ren-tooltip`
- `.ren-tooltip-arrow`
- `.ren-tooltip-trigger`

## States And Attributes

- `[data-side]`

## Public Token API

- `--ren-tooltip-bg`
- `--ren-tooltip-color`
- `--ren-tooltip-delay`
- `--ren-tooltip-duration`
- `--ren-tooltip-easing`
- `--ren-tooltip-font-size`
- `--ren-tooltip-padding-x`
- `--ren-tooltip-padding-y`
- `--ren-tooltip-radius`
- `--ren-tooltip-shadow`

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-tooltip/ren-tooltip.css`
- `components/composites/ren-tooltip/ren-tooltip.js`
- `docs/components/ren-tooltip.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.


/* ═══ ANCHOR POSITIONING & BASE STYLES ═══ */
.ren-tooltip {
  position: absolute;
  background: var(--ren-tooltip-bg, var(--color-text));
  color: var(--ren-tooltip-color, var(--color-text-inverted));
  border-radius: var(--radius-md);
  padding: var(--space-1) var(--space-2);
  font-size: var(--size-caption, 0.75rem);
  max-width: 15rem;
  pointer-events: none;
  z-index: var(--z-tooltip, 2000);
  overflow-wrap: break-word;
  white-space: normal;
  line-height: 1.4;
  margin: 0;
  inset: auto;
  overflow: visible;

  /* Anchor positioning (modern browsers) */
  position-anchor: --tooltip-anchor;

  /* Default: above the trigger with automatic fallback */
  position-area: top span-all;

  /* Automatic flip when near viewport edges */
  position-try-fallbacks: flip-block;

  /* Smooth transitions — semantic tokens keep tooltip in sync with the
     rest of the overlay family (popover, menu, dialog). Collapses to
     0ms under prefers-reduced-motion via tokens/semantic/motion.css. */
  opacity: 0;
  transform: translateY(4px) scale(0.95);
  transition:
    opacity var(--duration-enter) var(--ease-enter),
    transform var(--duration-enter) var(--ease-enter),
    overlay var(--duration-enter) var(--ease-enter) allow-discrete,
    display var(--duration-enter) var(--ease-enter) allow-discrete;
}

/* ═══ TOOLTIP OPEN STATE ═══ */
.ren-tooltip:popover-open,
.ren-tooltip.ren-open {
  opacity: 1;
  transform: translateY(0) scale(1);
}

/* ═══ TOOLTIP OPEN WITH @starting-style ═══ */
@starting-style {
  .ren-tooltip:popover-open {
    opacity: 0;
    transform: translateY(4px) scale(0.95);
  }
}

/* ═══ TOOLTIP ARROW/CARET ═══ */
.ren-tooltip-arrow {
  position: absolute;
  width: 6px;
  height: 6px;
  background: var(--ren-tooltip-bg, var(--color-text));
  transform: rotate(45deg);
  z-index: -1;
}

/* ═══ PLACEMENT VARIANTS ═══ */
/* Bottom */
.ren-tooltip[data-side="bottom"] {
  position-area: bottom span-all;
  margin-block-start: var(--space-1);
  margin-block-end: 0;
  margin-inline: 0;

  & .ren-tooltip-arrow {
    top: -3px;
    left: 50%;
    transform: translateX(-50%) rotate(225deg);
  }
}

/* Top (default) */
.ren-tooltip[data-side="top"],
.ren-tooltip:not([data-side]) {
  position-area: top span-all;
  margin-block-start: 0;
  margin-block-end: var(--space-1);
  margin-inline: 0;

  & .ren-tooltip-arrow {
    bottom: -3px;
    left: 50%;
    transform: translateX(-50%) rotate(45deg);
  }
}

/* Right */
.ren-tooltip[data-side="right"] {
  position-area: right span-all;
  margin-block: 0;
  margin-inline-start: var(--space-1);
  margin-inline-end: 0;

  & .ren-tooltip-arrow {
    left: -3px;
    top: 50%;
    transform: translateY(-50%) rotate(135deg);
  }
}

/* Left */
.ren-tooltip[data-side="left"] {
  position-area: left span-all;
  margin-block: 0;
  margin-inline-start: 0;
  margin-inline-end: var(--space-1);

  & .ren-tooltip-arrow {
    right: -3px;
    top: 50%;
    transform: translateY(-50%) rotate(315deg);
  }
}

/* ═══ RESPECTS MOTION PREFERENCES ═══
   Semantic tokens already collapse to 0ms under reduce, but we keep
   this belt-and-suspenders block so the transform-half can be skipped
   entirely (no scale bounce) while overlay/display discrete swaps
   still happen. */
@media (prefers-reduced-motion: reduce) {
  .ren-tooltip {
    transition: opacity var(--duration-enter), transform var(--duration-enter),
                overlay var(--duration-enter) allow-discrete,
                display var(--duration-enter) allow-discrete;
  }

  @starting-style {
    .ren-tooltip:popover-open {
      opacity: 0;
      transform: scale(1);
    }
  }
}

/* ═══ FALLBACK FOR BROWSERS WITHOUT COMPLETE ANCHOR POSITIONING ═══ */
@supports not ((anchor-name: --ren-anchor) and
  (position-anchor: --ren-anchor) and
  (position-area: top span-all)) {
  .ren-tooltip {
    position: absolute;
    inset: auto auto auto auto;
  }

  .ren-tooltip-trigger {
    position: relative;
  }

  /* Fallback animations */
  .ren-tooltip {
    animation: ren-tooltip-fallback-open var(--duration-enter) var(--ease-enter);
  }

  .ren-tooltip:popover-open,
  .ren-tooltip.ren-open {
    animation: ren-tooltip-fallback-open var(--duration-enter) var(--ease-enter);
  }

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

@keyframes ren-tooltip-fallback-open {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* Appearance API bridge */
.ren-tooltip {
  background: var(--ren-tooltip-bg, var(--color-text));
  color: var(--ren-tooltip-color, var(--color-text-inverted));
  font-size: var(--ren-tooltip-font-size, var(--caption-sm-size));
  padding-inline: var(--ren-tooltip-padding-x, var(--space-3));
  padding-block: var(--ren-tooltip-padding-y, var(--space-1));
  border-radius: var(--ren-tooltip-radius, var(--radius-md));
  box-shadow: var(--ren-tooltip-shadow, var(--shadow-sm));
  transition-duration: var(--ren-tooltip-duration, var(--duration-fast));
  transition-timing-function: var(--ren-tooltip-easing, var(--ease-out));
}


const PLACEMENTS = new Set(['top', 'right', 'bottom', 'left']);

function supportsAnchorPositioning() {
  return (
    typeof CSS !== 'undefined' &&
    typeof CSS.supports === 'function' &&
    CSS.supports('anchor-name', '--ren-anchor') &&
    CSS.supports('position-anchor', '--ren-anchor') &&
    CSS.supports('position-area', 'top span-all')
  );
}

function normalizePlacement(value, fallback = 'top') {
  return PLACEMENTS.has(value) ? value : fallback;
}

/**
 * Fallback position computation for browsers without CSS anchor positioning.
 * Used only when full CSS anchor positioning support is unavailable.
 *
 * @param {HTMLElement} trigger - The trigger element
 * @param {HTMLElement} tooltip - The tooltip element
 * @param {string} placement - Placement: 'top', 'right', 'bottom', 'left'
 * @param {number} offset - Offset in pixels between trigger and tooltip
 * @returns {Object} Position object with x, y, and finalPlacement properties
 */
function computePosition(trigger, tooltip, placement = 'top', offset = 8) {
  const triggerRect = trigger.getBoundingClientRect();
  const tooltipRect = tooltip.getBoundingClientRect();
  const viewport = { width: window.innerWidth, height: window.innerHeight };

  let x = 0;
  let y = 0;
  let finalPlacement = placement;

  const placements = {
    top: () => {
      x = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
      y = triggerRect.top - tooltipRect.height - offset;
      if (y < 0) {
        finalPlacement = 'bottom';
        return placements.bottom();
      }
      return { x, y };
    },
    bottom: () => {
      x = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
      y = triggerRect.bottom + offset;
      if (y + tooltipRect.height > viewport.height) {
        finalPlacement = 'top';
        return placements.top();
      }
      return { x, y };
    },
    left: () => {
      x = triggerRect.left - tooltipRect.width - offset;
      y = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
      if (x < 0) {
        finalPlacement = 'right';
        return placements.right();
      }
      return { x, y };
    },
    right: () => {
      x = triggerRect.right + offset;
      y = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
      if (x + tooltipRect.width > viewport.width) {
        finalPlacement = 'left';
        return placements.left();
      }
      return { x, y };
    },
  };

  const result = placements[placement]?.() || placements.top();

  // Clamp X position within viewport
  if (result.x < 0) result.x = 8;
  else if (result.x + tooltipRect.width > viewport.width)
    result.x = viewport.width - tooltipRect.width - 8;

  return { ...result, finalPlacement };
}

/**
 * Tooltip component with CSS Anchor Positioning and native Popover API
 *
 * Lightweight tooltip that displays helpful text on hover/focus with automatic
 * viewport collision handling via CSS anchor positioning.
 *
 * @example
 * <button>Hover me
 *   <ren-tooltip placement="top" show-delay="500">Helpful text</ren-tooltip>
 * </button>
 *
 * @fires ren-open - Fired when tooltip opens
 * @fires ren-close - Fired when tooltip closes
 */
export class RenTooltip extends HTMLElement {
  static observedAttributes = ['placement'];
  static supportsAnchor = supportsAnchorPositioning();

  #trigger = null;
  #showTimeout = null;
  #hideTimeout = null;
  #touchTimer = null;
  #listenerController = null;

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'placement' && oldValue !== newValue) {
      this.#syncPlacement();
    }
  }

  connectedCallback() {
    this.setupTooltip();
    this.findTrigger();
    this.attachTriggerListeners();
  }

  disconnectedCallback() {
    this.cleanup();
  }

  /**
   * Setup tooltip element with required attributes and structure
   * @private
   */
  setupTooltip() {
    this.classList.add('ren-tooltip');

    // Add arrow if not present
    if (!this.querySelector('.ren-tooltip-arrow')) {
      const arrow = document.createElement('div');
      arrow.className = 'ren-tooltip-arrow';
      this.appendChild(arrow);
    }

    // Set accessibility attributes
    this.setAttribute('role', 'tooltip');
    this.id = this.id || `ren-tooltip-${Math.random().toString(36).substr(2, 9)}`;

    // Setup native Popover API with manual mode
    if ('popover' in HTMLElement.prototype) {
      this.setAttribute('popover', 'manual');
    }

    this.#syncPlacement();
  }

  /**
   * Find the trigger element (parent that contains the tooltip)
   * @private
   */
  findTrigger() {
    this.#trigger = this.parentElement;

    if (this.#trigger) {
      // Wire aria-describedby relationship
      const describedBy = new Set(
        (this.#trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)
      );
      describedBy.add(this.id);
      this.#trigger.setAttribute('aria-describedby', [...describedBy].join(' '));

      // Set up anchor relationship if CSS anchors are supported
      if (RenTooltip.supportsAnchor) {
        this.#trigger.style.anchorName = '--tooltip-anchor';
      } else {
        // Fallback: ensure trigger can be positioned relative to
        if (getComputedStyle(this.#trigger).position === 'static') {
          this.#trigger.style.position = 'relative';
        }
      }
    }
  }

  /**
   * Attach event listeners to trigger
   * @private
   */
  attachTriggerListeners() {
    if (!this.#trigger) return;

    this.#listenerController?.abort();
    this.#listenerController = new AbortController();
    const { signal } = this.#listenerController;

    // Mouse events
    this.#trigger.addEventListener('mouseenter', () => this.scheduleShow(), { signal });
    this.#trigger.addEventListener('mouseleave', () => this.scheduleHide(), { signal });

    // Focus events (use capture for better timing)
    this.#trigger.addEventListener('focus', () => this.scheduleShow(), { capture: true, signal });
    this.#trigger.addEventListener('blur', () => this.scheduleHide(), { capture: true, signal });

    // Touch events for long-press detection
    this.#trigger.addEventListener('touchstart', (e) => this.handleTouchStart(e), { signal });
    this.#trigger.addEventListener('touchend', (e) => this.handleTouchEnd(e), { signal });
  }

  /**
   * Handle touch start (long press detection)
   * @private
   */
  handleTouchStart(e) {
    this.#touchTimer = setTimeout(() => {
      this.show();
    }, 500);
  }

  /**
   * Handle touch end
   * @private
   */
  handleTouchEnd(e) {
    if (this.#touchTimer) {
      clearTimeout(this.#touchTimer);
      this.#touchTimer = null;
    }

    if (this.isOpen()) {
      this.hide();
    }
  }

  /**
   * Schedule tooltip show with delay
   * @private
   */
  scheduleShow() {
    this.clearTimeouts();

    // Reduced-motion users should not wait through an animation-oriented
    // reveal delay; the tooltip remains available on focus/hover.
    if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) {
      this.show();
      return;
    }

    const tokenDelay = getComputedStyle(this).getPropertyValue('--ren-tooltip-delay').trim();
    const attrDelay = this.getAttribute('show-delay');
    const parsedAttrDelay = attrDelay == null ? Number.NaN : parseInt(attrDelay, 10);
    const parsedTokenDelay = parseInt(tokenDelay, 10);
    const delay = Number.isFinite(parsedAttrDelay)
      ? parsedAttrDelay
      : Number.isFinite(parsedTokenDelay)
        ? parsedTokenDelay
        : 500;
    this.#showTimeout = setTimeout(() => {
      this.show();
    }, delay);
  }

  /**
   * Schedule tooltip hide with delay
   * @private
   */
  scheduleHide() {
    this.clearTimeouts();

    const delay = parseInt(this.getAttribute('hide-delay')) || 0;
    this.#hideTimeout = setTimeout(() => {
      this.hide();
    }, delay);
  }

  /**
   * Clear all pending timeouts
   * @private
   */
  clearTimeouts() {
    if (this.#showTimeout) clearTimeout(this.#showTimeout);
    if (this.#hideTimeout) clearTimeout(this.#hideTimeout);
    if (this.#touchTimer) clearTimeout(this.#touchTimer);

    this.#showTimeout = null;
    this.#hideTimeout = null;
    this.#touchTimer = null;
  }

  /**
   * Position the tooltip relative to trigger (fallback for non-anchor browsers)
   * @private
   */
  positionTooltip() {
    if (!this.#trigger || RenTooltip.supportsAnchor) return;

    const placement = normalizePlacement(this.getAttribute('placement'), 'top');
    const offset = parseInt(this.getAttribute('offset')) || 8;

    const { x, y, finalPlacement } = computePosition(
      this.#trigger,
      this,
      placement,
      offset
    );

    this.style.left = `${x}px`;
    this.style.top = `${y}px`;
    this.setAttribute('data-side', finalPlacement);
  }

  #syncPlacement() {
    const placement = normalizePlacement(this.getAttribute('placement'), 'top');
    this.setAttribute('data-side', placement);
  }

  /**
   * Show the tooltip
   */
  show() {
    if (this.isOpen()) return;

    this.clearTimeouts();
    this.positionTooltip();
    this.setAttribute('data-state', 'open');

    if ('popover' in HTMLElement.prototype) {
      try {
        this.showPopover();
      } catch {
        // Already open or other error
      }
    } else {
      this.classList.add('ren-open');
    }

    this.dispatchEvent(new CustomEvent('ren-open', { bubbles: true }));
  }

  /**
   * Hide the tooltip
   */
  hide() {
    if (!this.isOpen()) return;

    this.clearTimeouts();
    this.setAttribute('data-state', 'closed');

    if ('popover' in HTMLElement.prototype) {
      try {
        this.hidePopover();
      } catch {
        // Already closed or other error
      }
    } else {
      this.classList.remove('ren-open');
    }

    this.dispatchEvent(new CustomEvent('ren-close', { bubbles: true }));
  }

  /**
   * Check if tooltip is currently open
   * @returns {boolean}
   */
  isOpen() {
    if ('popover' in HTMLElement.prototype) {
      return this.matches(':popover-open');
    }
    return this.classList.contains('ren-open');
  }

  /**
   * Cleanup event listeners and timeouts
   * @private
   */
  cleanup() {
    this.clearTimeouts();
    this.#listenerController?.abort();
    this.#listenerController = null;
    if (this.#trigger) {
      const describedBy = (this.#trigger.getAttribute('aria-describedby') || '')
        .split(/\s+/)
        .filter((token) => token && token !== this.id);
      if (describedBy.length > 0) {
        this.#trigger.setAttribute('aria-describedby', describedBy.join(' '));
      } else {
        this.#trigger.removeAttribute('aria-describedby');
      }
    }
  }

  /**
   * Get the trigger element
   * @returns {HTMLElement|null}
   */
  getTrigger() {
    return this.#trigger;
  }
}

if (!customElements.get('ren-tooltip')) {
  customElements.define('ren-tooltip', RenTooltip);
}

data:
{"kind":"composite","selectors":[".ren-tooltip",".ren-tooltip-arrow",".ren-tooltip-trigger"],"tokens":["--caption-sm-size","--color-text","--color-text-inverted","--duration-enter","--duration-fast","--ease-enter","--ease-out","--radius-md","--ren-tooltip-bg","--ren-tooltip-color","--ren-tooltip-duration","--ren-tooltip-easing","--ren-tooltip-font-size","--ren-tooltip-padding-x","--ren-tooltip-padding-y","--ren-tooltip-radius","--ren-tooltip-shadow","--shadow-sm","--size-caption","--space-1","--space-2","--space-3","--z-tooltip"],"hasScript":true,"hasDocsPage":true}


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

AI interaction pattern for prompts, responses, suggestions, and assistant surfaces.

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

## Purpose

- Provide the agent-facing public contract for the `ren-ai` 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 Ai 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 surfaces AI-generated content (chat reply, completion, summary, suggestion)."
    - "You need an AI message bubble with streaming caret (.ren-ai-streaming) or typing indicator."
    - "You need to render citations / source references attached to AI output (.ren-ai-citation, .ren-ai-sources)."
    - "You need a confidence indicator with high/medium/low levels (data-level=\"high|medium|low\")."
    - "You need a chat-style prompt input (.ren-ai-prompt + .ren-ai-prompt-input + .ren-ai-prompt-send)."
    - "You need to mark non-AI content as AI-augmented via an inline slug (.ren-ai-slug)."
  avoidWhen:
    - "The content is a generic human message — use a plain card / message bubble."
    - "The control is a generic submit button — use ren-button, not .ren-ai-prompt-send."
    - "The loading state is for non-AI fetch/page — use ren-skeleton, not .ren-ai-skeleton."

canonicalImports:
  css:
    - "rends/components/patterns/ren-ai/ren-ai.css"
  notes:
    - "CSS-only pattern. No colocated JS — streaming text and feedback handlers live in consumer code."
    - "Animations respect prefers-reduced-motion via fallback rules; do not override."

requiredMarkup:
  - "Wrap AI replies in <div class=\"ren-ai-message\"> with a .ren-ai-message-header (slug + meta) and .ren-ai-message-content."
  - "Streaming responses add the .ren-ai-streaming class on .ren-ai-message; the blinking caret is the ::after on .ren-ai-message-content."
  - "Citations are inline <a class=\"ren-ai-citation\"> with vertical-align: super; source lists use <a class=\"ren-ai-source\"> rows inside .ren-ai-sources."
  - ".ren-ai-confidence MUST carry data-level=\"high|medium|low\"; the fill width and color are driven by that attribute."
  - "The prompt composer must be .ren-ai-prompt wrapping a <textarea class=\"ren-ai-prompt-input\"> and a <button class=\"ren-ai-prompt-send\"> with an accessible name."

forbiddenPatterns:
  - "Using .ren-ai-message for non-AI chat content (use a neutral message component instead)."
  - "Faking confidence levels by inline-styling .ren-ai-confidence-fill width — set data-level."
  - "Replacing .ren-ai-prompt-send with a bare <button> styled as a circle — use the documented selector."
  - "Hardcoding the AI accent (e.g. #8B5CF6, --purple-*) instead of using --color-ai / --color-ai-subtle."
  - "Adding colored side borders to mark AI content; use the AI surface, icon/slug, and semantic tokens instead."
  - "Animating the typing dots or skeleton manually; the pattern ships keyframes + reduced-motion fallbacks."

tokenPolicy:
  allowed:
    - "Semantic tokens: --color-ai, --color-ai-hover, --color-ai-subtle, --color-on-ai, --color-text, --color-text-muted, --color-surface-raised, --color-surface-sunken, --color-fill, --color-border-muted, --color-success, --color-warning, --color-danger."
    - "Layout / type tokens: --space-*, --radius-*, --stroke-1, --body-size, --caption-size, --caption-sm-size, --label-size, --leading-relaxed, --weight-medium, --weight-semibold, --weight-bold."
    - "Motion tokens: --duration-tactile, --duration-state, --ease-enter."
    - "Size tokens for the send button and action chrome: --size-sm, --size-md, --text-lg."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer code or inside the pattern CSS."
    - "Hardcoded hex / named colors (e.g. #8B5CF6, purple, rgb(...))."
    - "Raw transition durations or easings; use --duration-* / --ease-*."

accessibility:
  required:
    - "AI messages must include a visible AI slug or .ren-ai-message-header so screen-reader users know the source is AI-generated, not human."
    - "Streaming caret and typing dots respect prefers-reduced-motion; never strip those fallbacks."
    - ".ren-ai-action-thumbs feedback buttons need accessible names (aria-label=\"Helpful\" / \"Not helpful\"); icon-only is not enough."
    - ".ren-ai-prompt-input is a real <textarea> with an associated <label> (or aria-label); the send button must reflect disabled state via the disabled attribute, not opacity alone."
    - "Confidence must not rely on color alone — keep the numeric / textual level visible next to .ren-ai-confidence-bar."
    - ".ren-ai-citation must be a real <a> with href and an accessible target; do not implement as a styled <span>."
```

## Required Imports

```html
<link rel="stylesheet" href="rends/components/patterns/ren-ai/ren-ai.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-ai-message"><div class="ren-ai-message-header"><span class="ren-ai-slug">AI</span></div><div class="ren-ai-message-content">Here is the summary.</div></div><form class="ren-ai-prompt"><textarea class="ren-ai-prompt-input" aria-label="Message"></textarea><button class="ren-ai-prompt-send" type="submit" aria-label="Send message">Send</button></form>

```

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

## Variants And Public Selectors

- `.ren-ai-action`
- `.ren-ai-action-edit`
- `.ren-ai-action-regenerate`
- `.ren-ai-action-thumbs`
- `.ren-ai-actions`
- `.ren-ai-citation`
- `.ren-ai-confidence`
- `.ren-ai-confidence-bar`
- `.ren-ai-confidence-fill`
- `.ren-ai-error`
- `.ren-ai-error-message`
- `.ren-ai-file-chip`
- `.ren-ai-file-chip-icon`
- `.ren-ai-file-chip-name`
- `.ren-ai-file-chip-remove`
- `.ren-ai-file-chip-size`
- `.ren-ai-message`
- `.ren-ai-message-content`
- `.ren-ai-message-footer`
- `.ren-ai-message-header`
- `.ren-ai-prompt`
- `.ren-ai-prompt-input`
- `.ren-ai-prompt-send`
- `.ren-ai-skeleton`
- `.ren-ai-skeleton-line`
- `.ren-ai-slug`
- `.ren-ai-source`
- `.ren-ai-source-number`
- `.ren-ai-source-title`
- `.ren-ai-source-url`
- `.ren-ai-sources`
- `.ren-ai-streaming`
- `.ren-ai-tool-call`
- `.ren-ai-tool-call-args`
- `.ren-ai-tool-call-header`
- `.ren-ai-tool-call-name`
- `.ren-ai-tool-call-result`
- `.ren-ai-tool-call-status`
- `.ren-ai-typing`
- `.ren-ai-typing-dots`

All selectors are CSS-only — the consumer wires retry handlers, file
upload state, tool execution, and edit-and-resend logic. The pattern
gives you the visuals (and a sane `<details>` baseline for tool calls
so collapse works without JS); state belongs to your app.

## States And Attributes

- `[data-active]`
- `[data-level]` — on `.ren-ai-confidence` (high|medium|low)
- `[data-status]` — on `.ren-ai-tool-call` (pending|running|success|error)
- `[open]` — native on `.ren-ai-tool-call` when expanded
- `:active`
- `: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.
- This contract is CSS-first; do not introduce JavaScript unless the source component already owns that behavior.

## Related Files

- `components/patterns/ren-ai/ren-ai.css`
- `docs/components/ren-ai.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 — Carbon for AI Patterns
   ============================================
   Design patterns for AI-generated content.
   Inspired by IBM Carbon for AI.

   Components:
   - AI Message bubble (streaming + complete)
   - AI Typing indicator
   - AI Citation / Source reference
   - AI Confidence indicator
   - AI Regenerate action
   - AI Prompt input
   - AI Slug (inline AI label)

   All CSS-only where possible, minimal JS for
   streaming text animation.
   ============================================ */

/* ═══════════════════════════════════════════════
   AI SLUG — Inline "AI" label
   ═══════════════════════════════════════════════ */
.ren-ai-slug {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-0-5) var(--space-2);
  font-size: var(--caption-sm-size);
  font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-ai);
  background: color-mix(in srgb, var(--color-ai) 10%, transparent);
  border-radius: var(--radius-sm);
  line-height: 1;
}

.ren-ai-slug::before {
  content: '✦';
  font-size: 0.75em;
}

/* ═══════════════════════════════════════════════
   AI MESSAGE — Chat bubble for AI responses
   ═══════════════════════════════════════════════ */
.ren-ai-message {
  position: relative;
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--color-ai-subtle) 70%, var(--color-surface-raised));
  border: var(--stroke-1) solid var(--color-border);
  line-height: var(--leading-relaxed);
}

.ren-ai-message-header {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
  font-size: var(--label-size);
}

.ren-ai-message-content {
  font-size: var(--body-size);
  color: var(--color-text);
}

.ren-ai-message-content p:last-child {
  margin-bottom: 0;
}

.ren-ai-message-footer {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: var(--stroke-1) solid var(--color-border-muted);
  font-size: var(--caption-size);
  color: var(--color-text-muted);
}

/* ═══════════════════════════════════════════════
   AI STREAMING — Typing animation
   ═══════════════════════════════════════════════ */
.ren-ai-streaming .ren-ai-message-content::after {
  content: '▊';
  display: inline;
  animation: ren-ai-blink 0.8s step-end infinite;
  color: var(--color-ai);
  margin-inline-start: 1px;
}

@keyframes ren-ai-blink {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .ren-ai-streaming .ren-ai-message-content::after {
    animation: none;
    opacity: 0.7;
  }
}

/* ═══════════════════════════════════════════════
   AI TYPING INDICATOR — "AI is thinking..."
   ═══════════════════════════════════════════════ */
.ren-ai-typing {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  font-size: var(--caption-size);
  color: var(--color-text-muted);
}

.ren-ai-typing-dots {
  display: inline-flex;
  gap: 3px;
}

.ren-ai-typing-dots span {
  width: 6px;
  height: 6px;
  border-radius: var(--radius-full);
  background: var(--color-ai);
  animation: ren-ai-dot-bounce 1.4s ease-in-out infinite;
}

.ren-ai-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.ren-ai-typing-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes ren-ai-dot-bounce {
  0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
  40%           { transform: translateY(-4px); opacity: 1; }
}

@keyframes ren-ai-dot-fade {
  0%, 80%, 100% { opacity: 0.3; }
  40%           { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .ren-ai-typing-dots span {
    animation: ren-ai-dot-fade 1.4s ease-in-out infinite;
  }
}

/* ═══════════════════════════════════════════════
   AI CITATION — Source reference
   ═══════════════════════════════════════════════ */
.ren-ai-citation {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-0-5) var(--space-2);
  font-size: var(--caption-sm-size);
  font-weight: var(--weight-medium);
  color: var(--color-ai);
  background: color-mix(in srgb, var(--color-ai) 8%, transparent);
  border-radius: var(--radius-sm);
  text-decoration: none;
  cursor: pointer;
  transition: background var(--duration-tactile) var(--ease-enter);
  vertical-align: super;
  line-height: 1;
}

.ren-ai-citation:hover {
  background: color-mix(in srgb, var(--color-ai) 14%, transparent);
}

/* Citation list at bottom of message */
.ren-ai-sources {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: var(--stroke-1) solid var(--color-border-muted);
}

.ren-ai-source {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  background: var(--color-surface-sunken);
  font-size: var(--caption-size);
  text-decoration: none;
  color: var(--color-text);
  transition: background var(--duration-tactile) var(--ease-enter);
}

.ren-ai-source:hover {
  background: var(--color-fill);
}

.ren-ai-source-number {
  flex-shrink: 0;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--caption-sm-size);
  font-weight: var(--weight-bold);
  color: var(--color-ai);
  background: color-mix(in srgb, var(--color-ai) 10%, transparent);
  border-radius: var(--radius-sm);
}

.ren-ai-source-title {
  font-weight: var(--weight-medium);
}

.ren-ai-source-url {
  color: var(--color-text-muted);
  font-size: var(--caption-sm-size);
}

/* ═══════════════════════════════════════════════
   AI CONFIDENCE — Certainty indicator
   ═══════════════════════════════════════════════ */
.ren-ai-confidence {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--caption-size);
  color: var(--color-text-muted);
}

.ren-ai-confidence-bar {
  width: 3rem;
  height: 4px;
  border-radius: var(--radius-full);
  background: var(--color-fill);
  overflow: hidden;
}

.ren-ai-confidence-fill {
  height: 100%;
  border-radius: var(--radius-full);
  transition: width var(--duration-state) var(--ease-enter);
}

.ren-ai-confidence[data-level="high"] .ren-ai-confidence-fill {
  width: 100%;
  background: var(--color-success);
}

.ren-ai-confidence[data-level="medium"] .ren-ai-confidence-fill {
  width: 66%;
  background: var(--color-warning);
}

.ren-ai-confidence[data-level="low"] .ren-ai-confidence-fill {
  width: 33%;
  background: var(--color-danger);
}

/* ═══════════════════════════════════════════════
   AI ACTIONS — Regenerate, Copy, Feedback
   ═══════════════════════════════════════════════ */
.ren-ai-actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

.ren-ai-action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  font-size: var(--caption-size);
  color: var(--color-text-muted);
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: all var(--duration-tactile) var(--ease-enter);
  min-height: var(--size-sm);
}

.ren-ai-action:hover {
  color: var(--color-text);
  background: var(--color-fill);
}

.ren-ai-action:active {
  transform: scale(0.95);
}

.ren-ai-action[data-active="true"] {
  color: var(--color-ai);
}

/* Feedback buttons (thumbs up/down) */
.ren-ai-action-thumbs {
  display: inline-flex;
  gap: 0;
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.ren-ai-action-thumbs .ren-ai-action {
  border-radius: 0;
  padding: var(--space-1) var(--space-2);
}

.ren-ai-action-thumbs .ren-ai-action + .ren-ai-action {
  border-inline-start: var(--stroke-1) solid var(--color-border);
}

/* ═══════════════════════════════════════════════
   AI PROMPT INPUT — Chat-style input
   ═══════════════════════════════════════════════ */
.ren-ai-prompt {
  display: flex;
  align-items: flex-end;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-xl);
  background: var(--color-surface);
  transition: border-color var(--duration-tactile) var(--ease-enter);
}

.ren-ai-prompt:focus-within {
  border-color: var(--color-ai);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ai) 15%, transparent);
}

.ren-ai-prompt-input {
  flex: 1;
  border: none;
  background: transparent;
  font-family: inherit;
  font-size: var(--body-size);
  color: var(--color-text);
  resize: none;
  outline: none;
  min-height: 1.5rem;
  max-height: 8rem;
  field-sizing: content;
  line-height: var(--leading-normal);
}

.ren-ai-prompt-input::placeholder {
  color: var(--color-text-muted);
}

.ren-ai-prompt-send {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--size-md);
  height: var(--size-md);
  border: none;
  border-radius: var(--radius-full);
  background: var(--color-ai);
  color: var(--color-on-ai);
  cursor: pointer;
  transition: all var(--duration-tactile) var(--ease-enter);
  font-size: var(--text-lg);
}

.ren-ai-prompt-send:hover {
  background: var(--color-ai-hover);
  transform: scale(1.05);
}

.ren-ai-prompt-send:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  transform: none;
}

/* ═══════════════════════════════════════════════
   AI SKELETON — Loading placeholder for AI
   ═══════════════════════════════════════════════ */
.ren-ai-skeleton {
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--color-ai-subtle) 55%, var(--color-surface-raised));
  border: var(--stroke-1) solid var(--color-border-muted);
}

.ren-ai-skeleton-line {
  height: 0.875rem;
  border-radius: var(--radius-sm);
  background: linear-gradient(
    90deg,
    var(--color-fill) 25%,
    color-mix(in srgb, var(--color-ai) 6%, var(--color-fill)) 50%,
    var(--color-fill) 75%
  );
  background-size: 200% 100%;
  animation: ren-ai-shimmer 1.5s ease-in-out infinite;
}

.ren-ai-skeleton-line + .ren-ai-skeleton-line {
  margin-top: var(--space-2);
}

.ren-ai-skeleton-line:last-child {
  width: 60%;
}

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

@media (prefers-reduced-motion: reduce) {
  .ren-ai-skeleton-line {
    animation: none;
  }
}

/* ═══════════════════════════════════════════════
   AI ERROR — Message failure state
   ═══════════════════════════════════════════════ */
.ren-ai-error {
  position: relative;
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--color-danger) 6%, var(--color-surface-raised));
  border: var(--stroke-1) solid color-mix(in srgb, var(--color-danger) 30%, transparent);
  display: grid;
  gap: var(--space-2);
}

.ren-ai-error-message {
  color: var(--color-text);
  font-size: var(--body-size);
  line-height: 1.5;
}

.ren-ai-error-message::before {
  content: '⚠ ';
  color: var(--color-danger);
  margin-inline-end: var(--space-1);
}

/* ═══════════════════════════════════════════════
   AI ACTION VARIANTS — Regenerate, edit-and-resend
   These extend the existing .ren-ai-action base.
   ═══════════════════════════════════════════════ */
.ren-ai-action-regenerate::before {
  content: '↻';
  margin-inline-end: var(--space-1);
  font-size: 1.1em;
  display: inline-block;
  /* Subtle hint that this re-runs inference */
  transition: transform var(--duration-state, 200ms) var(--ease-enter, ease);
}

.ren-ai-action-regenerate:hover::before {
  transform: rotate(-45deg);
}

.ren-ai-action-edit::before {
  content: '✎';
  margin-inline-end: var(--space-1);
  font-size: 1.05em;
}

@media (prefers-reduced-motion: reduce) {
  .ren-ai-action-regenerate::before {
    transition: none;
  }
}

/* ═══════════════════════════════════════════════
   AI FILE CHIP — Uploaded file reference
   Compact pill showing a file the user attached,
   with icon, name, size, and remove affordance.
   ═══════════════════════════════════════════════ */
.ren-ai-file-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  background: var(--color-surface-raised);
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-md);
  font-size: var(--caption-size);
  max-width: 220px;
}

.ren-ai-file-chip-icon {
  flex-shrink: 0;
  width: 1rem;
  height: 1rem;
  color: var(--color-text-muted);
  display: grid;
  place-items: center;
}

.ren-ai-file-chip-name {
  flex: 1 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--color-text);
  font-weight: var(--weight-medium);
}

.ren-ai-file-chip-size {
  flex-shrink: 0;
  color: var(--color-text-muted);
  font-family: var(--font-mono);
  font-size: var(--caption-sm-size);
}

.ren-ai-file-chip-remove {
  flex-shrink: 0;
  width: 1.25rem;
  height: 1.25rem;
  display: grid;
  place-items: center;
  border: 0;
  background: transparent;
  border-radius: var(--radius-sm);
  color: var(--color-text-muted);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}

.ren-ai-file-chip-remove:hover,
.ren-ai-file-chip-remove:focus-visible {
  background: var(--color-fill);
  color: var(--color-text);
}

.ren-ai-file-chip-remove:focus-visible {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
}

/* ═══════════════════════════════════════════════
   AI TOOL CALL — Model invoking a tool / function
   Collapsible block built on native <details>
   so JS is optional. Status via data-status.
   ═══════════════════════════════════════════════ */
.ren-ai-tool-call {
  border: var(--stroke-1) solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
  margin-block: var(--space-2);
  overflow: hidden;
}

.ren-ai-tool-call-header {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--color-surface-sunken);
  font-size: var(--caption-size);
  font-weight: var(--weight-medium);
  color: var(--color-text);
  cursor: pointer;
  user-select: none;
  list-style: none;
}

.ren-ai-tool-call-header::-webkit-details-marker { display: none; }

.ren-ai-tool-call-header::before {
  content: '▸';
  font-size: 0.75em;
  color: var(--color-text-muted);
  transition: transform var(--duration-state, 200ms) var(--ease-enter, ease);
}

.ren-ai-tool-call[open] > .ren-ai-tool-call-header::before {
  transform: rotate(90deg);
}

@media (prefers-reduced-motion: reduce) {
  .ren-ai-tool-call-header::before {
    transition: none;
  }
}

.ren-ai-tool-call-name {
  font-family: var(--font-mono);
  color: var(--color-text);
}

.ren-ai-tool-call-status {
  margin-inline-start: auto;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px var(--space-2);
  border-radius: var(--radius-full);
  font-size: var(--caption-sm-size);
  font-weight: var(--weight-medium);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border: var(--stroke-1) solid transparent;
}

.ren-ai-tool-call[data-status="pending"] .ren-ai-tool-call-status {
  color: var(--color-text-muted);
  background: var(--color-fill);
}

.ren-ai-tool-call[data-status="running"] .ren-ai-tool-call-status {
  color: var(--color-warning);
  background: color-mix(in srgb, var(--color-warning) 15%, transparent);
}

.ren-ai-tool-call[data-status="success"] .ren-ai-tool-call-status {
  color: var(--color-success);
  background: color-mix(in srgb, var(--color-success) 15%, transparent);
}

.ren-ai-tool-call[data-status="error"] .ren-ai-tool-call-status {
  color: var(--color-danger);
  background: color-mix(in srgb, var(--color-danger) 15%, transparent);
}

.ren-ai-tool-call-args,
.ren-ai-tool-call-result {
  padding: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--caption-size);
  line-height: 1.5;
  color: var(--color-text-secondary);
  white-space: pre-wrap;
  overflow-x: auto;
}

.ren-ai-tool-call-args {
  border-bottom: var(--stroke-1) solid var(--color-border-muted);
}

.ren-ai-tool-call-result {
  background: var(--color-surface);
}

.ren-ai-tool-call[data-status="error"] .ren-ai-tool-call-result {
  background: color-mix(in srgb, var(--color-danger) 4%, var(--color-surface));
  color: var(--color-text);
}

data:
{"kind":"pattern","selectors":[".ren-ai-action",".ren-ai-action-edit",".ren-ai-action-regenerate",".ren-ai-action-thumbs",".ren-ai-actions",".ren-ai-citation",".ren-ai-confidence",".ren-ai-confidence-bar",".ren-ai-confidence-fill",".ren-ai-error",".ren-ai-error-message",".ren-ai-file-chip",".ren-ai-file-chip-icon",".ren-ai-file-chip-name",".ren-ai-file-chip-remove",".ren-ai-file-chip-size",".ren-ai-message",".ren-ai-message-content",".ren-ai-message-footer",".ren-ai-message-header",".ren-ai-prompt",".ren-ai-prompt-input",".ren-ai-prompt-send",".ren-ai-skeleton",".ren-ai-skeleton-line",".ren-ai-slug",".ren-ai-source",".ren-ai-source-number",".ren-ai-source-title",".ren-ai-source-url",".ren-ai-sources",".ren-ai-streaming",".ren-ai-tool-call",".ren-ai-tool-call-args",".ren-ai-tool-call-header",".ren-ai-tool-call-name",".ren-ai-tool-call-result",".ren-ai-tool-call-status",".ren-ai-typing",".ren-ai-typing-dots"],"tokens":["--body-size","--caption-size","--caption-sm-size","--color-ai","--color-ai-hover","--color-ai-subtle","--color-border","--color-border-muted","--color-danger","--color-fill","--color-focus-ring","--color-on-ai","--color-success","--color-surface","--color-surface-raised","--color-surface-sunken","--color-text","--color-text-muted","--color-text-secondary","--color-warning","--duration-state","--duration-tactile","--ease-enter","--font-mono","--label-size","--leading-normal","--leading-relaxed","--radius-full","--radius-lg","--radius-md","--radius-sm","--radius-xl","--size-md","--size-sm","--space-0-5","--space-1","--space-2","--space-3","--space-4","--stroke-1","--text-lg","--tracking-wide","--weight-bold","--weight-medium","--weight-semibold"],"hasScript":false,"hasDocsPage":true}


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

Command palette pattern for searchable application actions.

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

## Purpose

- Provide the agent-facing public contract for the `ren-command` 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 Command 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 Ctrl/Cmd+K command palette / Spotlight-style action launcher."
    - "Actions are searchable by title, description, or data-keywords, with keyboard up/down navigation and Enter to activate."
    - "Items are grouped (e.g. \"Navigation\", \"Settings\") via .ren-command-group with .ren-command-group-heading."
    - "You want a global keyboard shortcut (data-shortcut=\"ctrl+k\") that opens the palette from anywhere on the page."
    - "You need a modal overlay with backdrop + Escape-to-close dismissal driven by a real <dialog>."
  avoidWhen:
    - "The list is a navigation menu — use ren-nav or ren-menubar."
    - "The list is a static dropdown of options — use ren-menu / ren-select."
    - "The list is a filter for a data table — use ren-table-toolbar-search inside ren-table."
    - "There is no search input (just a static action list) — use ren-menu."

canonicalImports:
  css:
    - "rends/components/patterns/ren-command/ren-command.css"
  js:
    - "rends/components/patterns/ren-command/ren-command.js"
  notes:
    - "JS registers customElements.define('ren-command', ...) and owns the global shortcut listener, filtering, roving highlight, and ren-command-select event."
    - "The pattern is dialog-based; the underlying <dialog> must be present so showModal() / close() work."

requiredMarkup:
  - "Use a <ren-command> custom element wrapping a real <dialog class=\"ren-command\"> (the JS calls dialog.showModal())."
  - "Inside, include a .ren-command-input-wrapper with a <input class=\"ren-command-input\" type=\"text\"> and an optional .ren-command-kbd hint."
  - "Wrap action rows in <ul class=\"ren-command-list\"> (or container) of <button class=\"ren-command-item\"> rows; items may carry data-keywords, data-value, data-action."
  - "Group rows under <div class=\"ren-command-group\"><div class=\"ren-command-group-heading\">…</div>…</div>; the JS toggles [data-empty] on empty groups."
  - "Provide a .ren-command-empty fallback element; the JS shows it when the filter produces zero matches."
  - "Each .ren-command-item that has a shortcut puts the keys inside .ren-command-item-shortcut with <kbd> elements."

forbiddenPatterns:
  - "Implementing as a <div> with click handlers — must be <ren-command> + <dialog> so Escape, focus, and a11y come from the platform."
  - "Filtering items via display: none from external code — the component owns visibility via _filterItems()."
  - "Building roving focus / highlight manually — the [data-highlighted] attribute is set by the component on the active item."
  - "Binding a second Ctrl+K listener at the document level — use the data-shortcut attribute or registerAction() API."
  - "Submitting selection from a custom click handler that bypasses the ren-command-select event."

tokenPolicy:
  allowed:
    - "Public component tokens listed in Public Token API: --ren-command-bg, --ren-command-border, --ren-command-item-height, --ren-command-item-radius, --ren-command-max-height, --ren-command-radius, --ren-command-shadow, --ren-command-width."
    - "Semantic surface / text tokens consumed by selectors: --color-surface, --color-border, --color-text, --color-text-muted, --color-text-faint, --color-fill, --color-fill-hover, --color-separator."
    - "Spacing / motion tokens: --ren-space-*, --ren-radius-*, --duration-enter, --duration-overlay, --duration-state, --ease-enter, --ease-state-change."
    - "Z-index token: --ren-z-modal."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) in consumer overrides."
    - "Hardcoded hex / named colors for surface, border, fill, or text."
    - "Custom z-index values that bypass --ren-z-modal (the palette must layer above content but below toasts)."

accessibility:
  required:
    - "Render as a real <dialog> so focus trap, Escape, and inert background come from the browser."
    - "The input is a real <input type=\"text\"> with an accessible name (label or aria-label like \"Search commands\")."
    - "The component injects a [role=\"status\"][aria-live=\"polite\"] region announcing result counts on every filter — do not remove it."
    - "Items must be real <button> elements (or role=\"option\" rows) so Enter / Space / click all work uniformly."
    - "Disabled items use aria-disabled=\"true\" AND pointer-events: none (the CSS handles the latter)."
    - "The shortcut hint inside .ren-command-kbd is decorative; do not rely on it as the only affordance — Escape and click-outside must both close."
```

## Required Imports

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

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

## Canonical Markup

```html
<ren-command><dialog class="ren-command"><div class="ren-command-input-wrapper"><input class="ren-command-input" type="text" aria-label="Search commands"></div><ul class="ren-command-list"><li><button class="ren-command-item" type="button" data-value="save">Save</button></li></ul><div class="ren-command-empty" hidden>No commands found</div></dialog></ren-command>

```

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

## Variants And Public Selectors

- `.ren-command`
- `.ren-command-empty`
- `.ren-command-footer`
- `.ren-command-footer-hint`
- `.ren-command-group`
- `.ren-command-group-heading`
- `.ren-command-input`
- `.ren-command-input-wrapper`
- `.ren-command-item`
- `.ren-command-item-content`
- `.ren-command-item-description`
- `.ren-command-item-icon`
- `.ren-command-item-shortcut`
- `.ren-command-item-title`
- `.ren-command-kbd`
- `.ren-command-list`
- `.ren-command-separator`

## States And Attributes

- `[aria-disabled]`
- `[aria-live]`
- `[data-empty]`
- `[data-highlighted]`
- `:active`
- `:focus-visible`
- `:hover`

## Public Token API

- `--ren-command-bg`
- `--ren-command-border`
- `--ren-command-item-height`
- `--ren-command-item-radius`
- `--ren-command-max-height`
- `--ren-command-radius`
- `--ren-command-shadow`
- `--ren-command-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/patterns/ren-command/ren-command.css`
- `components/patterns/ren-command/ren-command.js`
- `docs/components/ren-command.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-command {
  position: fixed;
  top: 50%;
  inset-inline-start: 50%;
  transform: translate(-50%, -50%);
  max-width: 32rem;
  width: 100%;
  max-height: 24rem;
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--ren-radius-xl, 0.75rem);
  box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
  padding: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  z-index: var(--ren-z-modal, 1000);
  margin: auto;
  animation: ren-command-in var(--duration-enter) var(--ease-enter);

  @starting-style {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.98);
  }

  @media (max-width: 32rem) {
    width: calc(100% - 2rem);
    max-height: 18rem;
  }

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

  &::backdrop {
    background-color: rgb(0 0 0 / 0.5);
    backdrop-filter: blur(4px);
    animation: ren-command-backdrop-in var(--duration-overlay) var(--ease-enter);

    @starting-style {
      background-color: rgb(0 0 0 / 0);
      backdrop-filter: blur(0);
    }

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

@keyframes ren-command-in {
  from {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.98);
  }

  to {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
  }
}

@keyframes ren-command-backdrop-in {
  from {
    background-color: rgb(0 0 0 / 0);
    backdrop-filter: blur(0);
  }

  to {
    background-color: rgb(0 0 0 / 0.5);
    backdrop-filter: blur(4px);
  }
}

.ren-command-input-wrapper {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--color-border);
  padding: var(--ren-space-2, 0.5rem) var(--ren-space-3, 0.75rem);
  gap: var(--ren-space-2, 0.5rem);
  flex-shrink: 0;

  & svg {
    width: 18px;
    height: 18px;
    color: var(--color-text-muted);
    flex-shrink: 0;
  }
}

.ren-command-input {
  flex: 1;
  border: none;
  background-color: transparent;
  font-size: var(--ren-body-size, 0.875rem);
  color: var(--color-text);
  outline: none;
  padding: 0;
  font-family: inherit;

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

  &:autofill {
    box-shadow: inset 0 0 0 1000px var(--color-surface);
    -webkit-text-fill-color: var(--color-text);
  }
}

.ren-command-kbd {
  margin-inline-start: auto;
  display: flex;
  gap: var(--ren-space-1, 0.25rem);
  align-items: center;
  font-size: var(--ren-caption-size, 0.75rem);
  color: var(--color-text-muted);
  font-family: var(--ren-font-mono, 'Monaco', 'Menlo', monospace);
  white-space: nowrap;

  & kbd {
    padding: 0.125rem 0.375rem;
    background-color: var(--color-fill);
    border: 1px solid var(--color-border);
    border-radius: 0.25rem;
    font-size: inherit;
  }

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

.ren-command-list {
  overflow-y: auto;
  max-height: 18rem;
  padding: var(--ren-space-1, 0.25rem);
  flex: 1;
  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-command-group {
  margin-bottom: var(--ren-space-2, 0.5rem);
  overflow: hidden;

  &[data-empty] {
    display: none;
  }
}

.ren-command-group-heading {
  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;
  user-select: none;
}

.ren-command-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;
  cursor: pointer;
  transition: background-color var(--duration-state) var(--ease-state-change);
  user-select: none;
  background-color: transparent;
  border: none;
  width: 100%;
  text-align: start;
  font-family: inherit;

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

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

  &[data-highlighted] {
    background-color: var(--color-fill);
  }

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

  &:focus-visible {
    outline: none;
    background-color: var(--color-fill);
  }
}

.ren-command-item-icon {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-muted);

  & svg {
    width: 100%;
    height: 100%;
  }
}

.ren-command-item-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
  min-width: 0;
}

.ren-command-item-title {
  font-size: var(--ren-body-size, 0.875rem);
  color: var(--color-text);
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ren-command-item-description {
  font-size: var(--ren-caption-size, 0.75rem);
  color: var(--color-text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ren-command-item-shortcut {
  margin-inline-start: auto;
  display: flex;
  gap: var(--ren-space-1, 0.25rem);
  font-family: var(--ren-font-mono, 'Monaco', 'Menlo', monospace);
  font-size: var(--ren-caption-size, 0.75rem);
  color: var(--color-text-muted);
  white-space: nowrap;
  flex-shrink: 0;

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

.ren-command-separator {
  height: 1px;
  background-color: var(--color-separator);
  margin: var(--ren-space-1, 0.25rem) 0;
}

.ren-command-empty {
  padding: var(--ren-space-8, 2rem);
  text-align: center;
  color: var(--color-text-muted);
  font-size: var(--ren-body-size, 0.875rem);
}

.ren-command-footer {
  border-top: 1px solid var(--color-border);
  padding: var(--ren-space-2, 0.5rem);
  display: flex;
  gap: var(--ren-space-4, 1rem);
  font-size: var(--ren-caption-size, 0.75rem);
  color: var(--color-text-muted);
  flex-shrink: 0;
  flex-wrap: wrap;
  justify-content: center;

  @media (max-width: 32rem) {
    padding: var(--ren-space-1, 0.25rem) var(--ren-space-2, 0.5rem);
    gap: var(--ren-space-2, 0.5rem);
  }
}

.ren-command-footer-hint {
  display: flex;
  gap: var(--ren-space-1, 0.25rem);
  align-items: center;

  & kbd {
    padding: 0.125rem 0.375rem;
    background-color: var(--color-fill);
    border: 1px solid var(--color-border);
    border-radius: 0.25rem;
    font-size: inherit;
    font-family: var(--ren-font-mono, 'Monaco', 'Menlo', monospace);
  }
}
/* Appearance API bridge */
.ren-command { inline-size: var(--ren-command-width, min(95vw, 560px)); max-block-size: var(--ren-command-max-height, 340px); border-radius: var(--ren-command-radius, var(--radius-xl)); background: var(--ren-command-bg, var(--color-surface-overlay)); border-color: var(--ren-command-border, var(--color-border)); box-shadow: var(--ren-command-shadow, var(--shadow-xl)); }
.ren-command [role="option"], .ren-command-item { min-block-size: var(--ren-command-item-height, var(--size-lg)); border-radius: var(--ren-command-item-radius, var(--radius-md)); }


/**
 * RenCommand Component - Command Palette / Spotlight search
 * Modern dialog-based command palette with keyboard navigation
 */

const KEYBOARD_CODES = {
  Escape: 'Escape',
  Enter: 'Enter',
  ArrowDown: 'ArrowDown',
  ArrowUp: 'ArrowUp'
};

class RenCommand extends HTMLElement {
  constructor() {
    super();
    this._dialog = null;
    this._input = null;
    this._list = null;
    this._items = [];
    this._groups = [];
    this._highlightedIndex = 0;
    this._shortcuts = new Map();
    this._focusTrap = null;
    this._lastQuery = '';
    this._shortcutKey = 'k';
    this._shortcutMeta = 'ctrl';
    this._listenerController = null;
  }

  connectedCallback() {
    this._initDialog();
    this._initElements();
    this._attachEventListeners();
    this._setupGlobalShortcut();
  }

  disconnectedCallback() {
    this._removeEventListeners();
  }

  _initDialog() {
    this._dialog = this.querySelector('dialog') || this;
    if (this._dialog !== this && this._dialog.tagName !== 'DIALOG') {
      // Fallback: ensure we have a dialog
      this._dialog = this;
    }
  }

  _initElements() {
    this._input = this.querySelector('.ren-command-input');
    this._list = this.querySelector('.ren-command-list');
    this._items = Array.from(this.querySelectorAll('.ren-command-item'));
    this._groups = Array.from(this.querySelectorAll('.ren-command-group'));
  }

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

    // Input events
    if (this._input) {
      this._input.addEventListener('input', (e) => this._handleInput(e), { signal });
      this._input.addEventListener('keydown', (e) => this._handleKeydown(e), { signal });
    }

    // Item selection
    this._items.forEach((item, index) => {
      item.addEventListener('click', () => this._selectItem(index), { signal });
      item.addEventListener('mouseenter', () => this._setHighlighted(index), { signal });
      item.addEventListener('focus', () => this._setHighlighted(index), { signal });
    });

    // Dialog close
    if (this._dialog && this._dialog.tagName === 'DIALOG') {
      this._dialog.addEventListener('cancel', () => this._close(), { signal });
    }

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

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

  _setupGlobalShortcut() {
    const shortcut = this.getAttribute('data-shortcut') || 'ctrl+k';
    const [meta, key] = shortcut.toLowerCase().split('+');
    this._shortcutKey = key || 'k';
    this._shortcutMeta = meta || 'ctrl';

    document.addEventListener('keydown', (e) => {
      const isMetaKey = this._shortcutMeta === 'ctrl' ? e.ctrlKey : e.metaKey;
      if (isMetaKey && e.key.toLowerCase() === this._shortcutKey) {
        e.preventDefault();
        this._open();
      }
    }, { signal: this._listenerController?.signal });
  }

  _open() {
    if (this._dialog.tagName === 'DIALOG') {
      this._dialog.showModal?.();
    } else {
      this.style.display = 'block';
    }

    if (this._input) {
      this._input.focus();
      this._input.select();
    }

    this._highlightedIndex = 0;
    this._updateUI();
  }

  _close() {
    if (this._dialog.tagName === 'DIALOG') {
      this._dialog.close?.();
    } else {
      this.style.display = 'none';
    }

    this._input?.blur();
  }

  _isOpen() {
    if (this._dialog.tagName === 'DIALOG') {
      return this._dialog.open;
    }
    return this.style.display !== 'none';
  }

  _handleInput(e) {
    const query = e.target.value.toLowerCase().trim();
    this._lastQuery = query;
    this._filterItems(query);
    this._highlightedIndex = 0;
    this._updateUI();
  }

  _handleKeydown(e) {
    switch (e.key) {
      case KEYBOARD_CODES.ArrowDown:
        e.preventDefault();
        this._moveHighlight(1);
        break;
      case KEYBOARD_CODES.ArrowUp:
        e.preventDefault();
        this._moveHighlight(-1);
        break;
      case KEYBOARD_CODES.Enter:
        e.preventDefault();
        const visibleItem = this._getVisibleItems()[this._highlightedIndex];
        if (visibleItem) {
          this._selectItem(this._items.indexOf(visibleItem));
        }
        break;
      case KEYBOARD_CODES.Escape:
        e.preventDefault();
        this._close();
        break;
    }
  }

  _filterItems(query) {
    this._items.forEach(item => {
      if (!query) {
        item.style.display = '';
        return;
      }

      const title = item.querySelector('.ren-command-item-title')?.textContent || '';
      const description = item.querySelector('.ren-command-item-description')?.textContent || '';
      const keywords = item.getAttribute('data-keywords') || '';
      const searchableText = `${title} ${description} ${keywords}`.toLowerCase();

      const matches = query.split(' ').every(term => searchableText.includes(term));
      item.style.display = matches ? '' : 'none';
    });

    this._updateGroupVisibility();
    this._announceResults();
  }

  _updateGroupVisibility() {
    this._groups.forEach(group => {
      const visibleItems = Array.from(group.querySelectorAll('.ren-command-item'))
        .filter(item => item.style.display !== 'none');

      if (visibleItems.length === 0) {
        group.setAttribute('data-empty', '');
      } else {
        group.removeAttribute('data-empty');
      }
    });

    const hasVisibleItems = this._getVisibleItems().length > 0;
    const empty = this.querySelector('.ren-command-empty');
    if (empty) {
      empty.style.display = hasVisibleItems ? 'none' : 'block';
    }
  }

  _getVisibleItems() {
    return this._items.filter(item => item.style.display !== 'none');
  }

  /**
   * Announce number of search results to screen readers
   * @private
   */
  _announceResults() {
    const visibleItems = this._getVisibleItems();
    const resultCount = visibleItems.length;

    // Find or create announcement region
    let announcement = this.querySelector('[role="status"][aria-live="polite"]');
    if (!announcement) {
      announcement = document.createElement('div');
      announcement.setAttribute('role', 'status');
      announcement.setAttribute('aria-live', 'polite');
      announcement.className = 'ren-sr-only';
      this.appendChild(announcement);
    }

    if (resultCount === 0) {
      announcement.textContent = 'No results found';
    } else {
      announcement.textContent = `${resultCount} result${resultCount !== 1 ? 's' : ''} available`;
    }
  }

  _moveHighlight(direction) {
    const visibleItems = this._getVisibleItems();
    if (visibleItems.length === 0) return;

    let newIndex = this._highlightedIndex + direction;
    if (newIndex < 0) newIndex = visibleItems.length - 1;
    if (newIndex >= visibleItems.length) newIndex = 0;

    this._highlightedIndex = newIndex;
    this._updateUI({ focus: true });

    const item = visibleItems[newIndex];
    item?.scrollIntoView({ block: 'nearest' });
  }

  _setHighlighted(itemIndex) {
    this._highlightedIndex = this._getVisibleItems().indexOf(this._items[itemIndex]);
    if (this._highlightedIndex === -1) this._highlightedIndex = 0;
    this._updateUI();
  }

  _updateUI({ focus = false } = {}) {
    const visibleItems = this._getVisibleItems();
    visibleItems.forEach((item, index) => {
      if (index === this._highlightedIndex) {
        item.setAttribute('data-highlighted', '');
        if (focus) {
          item.focus();
        }
      } else {
        item.removeAttribute('data-highlighted');
      }
    });
  }

  _selectItem(itemIndex) {
    const item = this._items[itemIndex];
    if (!item || item.style.display === 'none') return;

    const value = item.getAttribute('data-value') || item.textContent;
    const action = item.getAttribute('data-action');

    const event = new CustomEvent('ren-command-select', {
      detail: { item, value, action },
      bubbles: true,
      composed: true
    });
    this.dispatchEvent(event);

    // Call registered action handler
    if (action && this._shortcuts.has(action)) {
      this._shortcuts.get(action)();
    }

    this._resetCommand();
    this._close();
  }

  _resetCommand() {
    if (this._input) {
      this._input.value = '';
      this._lastQuery = '';
    }
    this._filterItems('');
    this._highlightedIndex = 0;
    this._updateUI();
  }

  // Public API
  open() {
    this._open();
  }

  close() {
    this._close();
  }

  registerAction(id, handler) {
    if (typeof handler === 'function') {
      this._shortcuts.set(id, handler);
    }
  }

  unregisterAction(id) {
    this._shortcuts.delete(id);
  }

  setItems(items) {
    if (Array.isArray(items)) {
      this._items = items;
      this._initElements();
      this._updateUI();
    }
  }

  get query() {
    return this._lastQuery;
  }

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

customElements.define('ren-command', RenCommand);

data:
{"kind":"pattern","selectors":[".ren-command",".ren-command-empty",".ren-command-footer",".ren-command-footer-hint",".ren-command-group",".ren-command-group-heading",".ren-command-input",".ren-command-input-wrapper",".ren-command-item",".ren-command-item-content",".ren-command-item-description",".ren-command-item-icon",".ren-command-item-shortcut",".ren-command-item-title",".ren-command-kbd",".ren-command-list",".ren-command-separator"],"tokens":["--color-border","--color-fill","--color-fill-hover","--color-separator","--color-surface","--color-surface-overlay","--color-text","--color-text-faint","--color-text-muted","--duration-enter","--duration-overlay","--duration-state","--ease-enter","--ease-state-change","--radius-md","--radius-xl","--ren-body-size","--ren-caption-size","--ren-command-bg","--ren-command-border","--ren-command-item-height","--ren-command-item-radius","--ren-command-max-height","--ren-command-radius","--ren-command-shadow","--ren-command-width","--ren-font-mono","--ren-radius-xl","--ren-space-1","--ren-space-2","--ren-space-3","--ren-space-4","--ren-space-8","--ren-z-modal","--shadow-xl","--size-lg"],"hasScript":true,"hasDocsPage":true}


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

Empty state pattern for no-data, first-run, and recovery surfaces.

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

## Purpose

- Provide the agent-facing public contract for the `ren-empty-state` 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 Empty State 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:
    - "A data container (ren-table, list, ren-card, search result) renders zero items and needs a no-data surface."
    - "A first-run experience needs an icon + title + description + primary action (e.g. \"Add your first product\")."
    - "A search / filter result is empty and the surface should explain how to recover (.ren-empty-state-compact)."
    - "An error / recovery state needs the same centered layout as zero-data (e.g. \"Nothing here yet\")."
    - "You want a dashed-bordered placeholder inside a card or table body (.ren-empty-state-bordered)."
  avoidWhen:
    - "The UI is loading data (still pending) — use ren-skeleton, not an empty state."
    - "The page is a real 404 / 500 error route — use the error-page template, not this inline component."
    - "The container is full but a row is unselected — that is a selection-prompt, not an empty state."
    - "You need a toast / inline alert about a failed action — use ren-alert / ren-toast."

canonicalImports:
  css:
    - "rends/components/patterns/ren-empty-state/ren-empty-state.css"
  notes:
    - "CSS-only pattern. No JS — actions inside .ren-empty-state-actions are real <button> / ren-button elements wired by the consumer."
    - "Drop directly into <tbody>, <ul>, or any container — it centers itself with max-width: 28rem and auto inline margins."

requiredMarkup:
  - "Root is <div class=\"ren-empty-state\"> (or a <section> for a landmark)."
  - "Use an .ren-empty-state-icon block for the glyph/illustration; mark decorative icons aria-hidden=\"true\"."
  - "Heading is an <h2> / <h3> with class .ren-empty-state-title — never put plain text without a heading element."
  - "Description text is a <p class=\"ren-empty-state-description\">; keep within the 28rem prose width by not overriding the max-width."
  - "Actions live in <div class=\"ren-empty-state-actions\"> and should be real <button class=\"ren-btn\"> or <a class=\"ren-btn\"> — not links styled as buttons via <span>."

forbiddenPatterns:
  - "Using .ren-empty-state as a generic centered hero — it is reserved for no-data / zero-state surfaces."
  - "Combining .ren-empty-state-compact with .ren-empty-state-bordered when the container is already a bordered card (double border)."
  - "Decorative icon as the only message — always include .ren-empty-state-title so screen readers understand the state."
  - "Hardcoding padding / max-width via inline style — override --ren-empty-padding / --ren-empty-max-width instead."
  - "Embedding form fields directly inside .ren-empty-state — pair with ren-form / ren-button actions only."

tokenPolicy:
  allowed:
    - "Public component tokens listed in Public Token API: --ren-empty-padding, --ren-empty-gap, --ren-empty-icon-size, --ren-empty-title-size, --ren-empty-desc-color, --ren-empty-max-width."
    - "Semantic tokens consumed by selectors: --color-text, --color-text-muted, --color-fill, --color-border."
    - "Layout / type tokens: --space-*, --radius-full, --radius-lg, --width-prose, --text-xl, --text-3xl, --body-size, --caption-size, --weight-semibold, --leading-normal."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, --red-*, --green-*, --orange-*, --yellow-*, --teal-*, --purple-*, --pink-*) for the icon background or borders."
    - "Hardcoded hex / named colors for text, fill, or border."
    - "Custom widths / margins that bypass --ren-empty-max-width; the component is meant to self-center."

accessibility:
  required:
    - "Title is a real heading element (<h2> / <h3>) so the empty state appears in the document outline."
    - "Decorative icons / emoji use aria-hidden=\"true\"; meaningful illustrations need alt text or aria-label."
    - "Primary action is a real <button> or <a> with keyboard activation and a 44px touch target (inherited from .ren-btn)."
    - "Do not communicate the empty state through icon color alone — the title text must remain the source of truth."
    - "If the empty state is announced after an async load, wrap it in role=\"status\" or aria-live=\"polite\" so screen readers learn the new state."
```

## Required Imports

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

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

## Canonical Markup

```html
<section class="ren-empty-state"><div class="ren-empty-state-icon" aria-hidden="true">□</div><h2 class="ren-empty-state-title">No projects</h2><p class="ren-empty-state-description">Create a project to get started.</p><div class="ren-empty-state-actions"><button class="ren-btn" type="button">Create project</button></div></section>

```

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

## Variants And Public Selectors

- `.ren-empty-state`
- `.ren-empty-state-actions`
- `.ren-empty-state-bordered`
- `.ren-empty-state-compact`
- `.ren-empty-state-description`
- `.ren-empty-state-icon`
- `.ren-empty-state-title`

## States And Attributes

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

## Public Token API

- `--ren-empty-desc-color`
- `--ren-empty-gap`
- `--ren-empty-icon-size`
- `--ren-empty-max-width`
- `--ren-empty-padding`
- `--ren-empty-title-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/patterns/ren-empty-state/ren-empty-state.css`
- `docs/components/ren-empty-state.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 — Empty State Pattern
   ============================================
   Reusable pattern for empty/no-data states.
   CSS-only. No JS needed.

   Use inside any data container (table, list,
   card) when there's nothing to display.

   Inspired by Polaris: every data component
   should have a documented empty state.

   Usage:
     <div class="ren-empty-state">
       <div class="ren-empty-state-icon">📦</div>
       <h3 class="ren-empty-state-title">No products yet</h3>
       <p class="ren-empty-state-description">
         Add your first product to get started.
       </p>
       <div class="ren-empty-state-actions">
         <button class="ren-btn">Add product</button>
       </div>
     </div>

   Compact (inline):
     <div class="ren-empty-state ren-empty-state-compact">
       <p class="ren-empty-state-description">No results found.</p>
     </div>
   ============================================ */

.ren-empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--space-8) var(--space-4);
  max-width: 28rem;
  margin-inline: auto;
}

/* ─── Icon / Illustration ─── */
.ren-empty-state-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 4rem;
  height: 4rem;
  font-size: var(--text-3xl);
  color: var(--color-text-muted);
  background-color: var(--color-fill);
  border-radius: var(--radius-full);
  margin-bottom: var(--space-4);
}

/* ─── Title ─── */
.ren-empty-state-title {
  font-size: var(--title-sm-size, var(--text-lg));
  font-weight: var(--weight-semibold);
  color: var(--color-text);
  margin-bottom: var(--space-2);
}

/* ─── Description ─── */
.ren-empty-state-description {
  font-size: var(--body-size);
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
  margin-bottom: var(--space-4);
  max-width: var(--width-prose);
}

.ren-empty-state-description:last-child {
  margin-bottom: 0;
}

/* ─── Actions ─── */
.ren-empty-state-actions {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
  justify-content: center;
}

/* ─── Compact variant (for inline empty states) ─── */
.ren-empty-state-compact {
  padding: var(--space-6) var(--space-4);
}

.ren-empty-state-compact .ren-empty-state-icon {
  width: 2.5rem;
  height: 2.5rem;
  font-size: var(--text-xl);
  margin-bottom: var(--space-2);
}

.ren-empty-state-compact .ren-empty-state-title {
  font-size: var(--body-size);
}

.ren-empty-state-compact .ren-empty-state-description {
  font-size: var(--caption-size, var(--text-sm));
}

/* ─── Bordered variant (for use inside cards/tables) ─── */
.ren-empty-state-bordered {
  border: 2px dashed var(--color-border);
  border-radius: var(--radius-lg);
  margin: var(--space-4);
}
/* Appearance API bridge */
.ren-empty-state { max-inline-size: var(--ren-empty-max-width, 24rem); padding: var(--ren-empty-padding, var(--space-10)); gap: var(--ren-empty-gap, var(--space-3)); }
.ren-empty-state .ren-empty-state-icon { inline-size: var(--ren-empty-icon-size, 3rem); block-size: var(--ren-empty-icon-size, 3rem); }
.ren-empty-state h1, .ren-empty-state h2, .ren-empty-state h3 { font-size: var(--ren-empty-title-size, var(--title-sm-size)); }
.ren-empty-state p { color: var(--ren-empty-desc-color, var(--color-text-muted)); }

data:
{"kind":"pattern","selectors":[".ren-empty-state",".ren-empty-state-actions",".ren-empty-state-bordered",".ren-empty-state-compact",".ren-empty-state-description",".ren-empty-state-icon",".ren-empty-state-title"],"tokens":["--body-size","--caption-size","--color-border","--color-fill","--color-text","--color-text-muted","--leading-normal","--radius-full","--radius-lg","--ren-empty-desc-color","--ren-empty-gap","--ren-empty-icon-size","--ren-empty-max-width","--ren-empty-padding","--ren-empty-title-size","--space-10","--space-2","--space-3","--space-4","--space-6","--space-8","--text-3xl","--text-lg","--text-sm","--text-xl","--title-sm-size","--weight-semibold","--width-prose"],"hasScript":false,"hasDocsPage":true}


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

Form validation + layout pattern. Wraps a native `<form>` with declarative
field rules, error summaries, multi-step support, and emit-on-submit
events. Pairs with `ren-field` for control-level wiring.

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

## Purpose

The form-level pattern in RenDS. Owns:
- Declarative validation (`data-rules`) with built-in validators
  (`required`, `email`, `min`, `max`, `pattern`, `match`, etc.).
- Validation timing modes (`onSubmit`, `onBlur`, `onChange`, `onTouched`).
- Error summary management at the top of the form.
- Multi-step (wizard) progression via `data-steps`.
- Submitting / disabled state propagation across the form.

Field-level ARIA wiring is delegated to `ren-field`. Layout uses
`ren-stack` / `ren-form-row` / `ren-form-section`.

## Use When

- The page is a real form (login, signup, settings, checkout, multi-step
  wizard) that needs declarative validation rules.
- You want a single error summary at the top of the form, focusable on
  invalid submit.
- You want consistent submitting state and event-driven submission.
- You are using `ren-field` to wrap the controls.

## Do Not Use When

- The "form" is a single search input — use `<form>` + `ren-field` directly.
- The "form" is a chat composer or freeform editor — use a custom flow.
- You need fully-custom field validation that does not fit the
  `data-rules` DSL — use a plain `<form>` with your own JS.
- The interaction is a step-by-step UI without form fields — use
  `ren-tabs` or `ren-empty-state` flows.

## aiHints

```yaml
selectionCriteria:
  useWhen:
    - "The UI is a real form with multiple fields and validation."
    - "You want declarative rules (data-rules=\"required|email|min:8\")."
    - "You want a single error summary that focuses on invalid submit."
    - "You want submitting state to disable the form during async submission."
    - "You want a multi-step wizard with progress indicator."
  avoidWhen:
    - "It is a single-input search/filter — use ren-field alone."
    - "Validation logic is irreducibly custom and the data-rules DSL would not express it."
    - "The flow is not actually a form (chat composer, command palette, etc.)."

canonicalImports:
  css:
    - "rends/components/patterns/ren-form/ren-form.css"
  js:
    - "rends/components/patterns/ren-form/ren-form.js"
  notes:
    - "Always pair with rends/components/primitives/ren-field/ren-field.css + ren-field.js for field wiring."

requiredMarkup:
  - "<ren-form> wraps a real <form>. Do not omit the <form> element."
  - "Each input lives inside <ren-field>; do not use bare <input> at the top level of the form."
  - "Add data-validate=\"onSubmit|onBlur|onChange|onTouched\" on <ren-form>; default is onSubmit."
  - "If using rules, attach data-rules to each <ren-field> (or to the inner control), not to the <form>."
  - "Submit button must be type=\"submit\" inside the <form>."
  - "Provide an empty .ren-form-error-summary with role=\"alert\" tabindex=\"-1\" so the component can populate AND focus it on invalid submit. The component sets tabindex=\"-1\" automatically if you forget."

forbiddenPatterns:
  - "<ren-form> wrapping a <div> instead of a <form>."
  - "Manual aria-invalid / aria-describedby — ren-field owns those."
  - "Submitting via fetch from a custom click handler that bypasses the ren-submit event."
  - "Using a placeholder as the only label."

tokenPolicy:
  allowed:
    - "Component tokens: --ren-field-* for input chrome, --ren-btn-* for actions."
    - "Layout tokens (--space-*) and semantic colors for content."
  forbidden:
    - "Primitive palette tokens (--blue-*, --gray-*, etc.)."
    - "Hardcoded colors / sizes that bypass tokens."

accessibility:
  required:
    - "Native <form> semantics; preserve the submit event."
    - "Each control has a <label> via ren-field."
    - "Error summary has role=\"alert\" AND tabindex=\"-1\" so .focus() actually shifts focus on invalid submit. The component sets tabindex=\"-1\" if missing."
    - "Required-state must be explicit (required attribute), not placeholder-only."
    - "Disabled / submitting state cannot strip focus rings."
```

## Required CSS / JS Imports

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

## Canonical Markup

Single-step login form with onTouched validation:

```html
<ren-form data-validate="onTouched">
  <form class="ren-form ren-stack" novalidate>
    <div class="ren-form-error-summary" role="alert" tabindex="-1" hidden>
      <strong>Please fix the following errors:</strong>
      <ul></ul>
    </div>

    <ren-field data-rules="required|email">
      <label>Email</label>
      <input class="ren-input" type="email" name="email" autocomplete="email" required>
      <span data-error></span>
    </ren-field>

    <ren-field data-rules="required|min:8">
      <label>Password</label>
      <input class="ren-input" type="password" name="password" autocomplete="current-password" required minlength="8">
      <span data-error></span>
    </ren-field>

    <div class="ren-form-actions">
      <button class="ren-btn ren-btn-full" type="submit">Sign in</button>
    </div>
  </form>
</ren-form>

<script type="module">
  document.querySelector('ren-form').addEventListener('ren-submit', (e) => {
    e.preventDefault();
    fetch('/api/login', { method: 'POST', body: JSON.stringify(e.detail.values) });
  });
</script>
```

Multi-step form with progress:

```html
<ren-form data-validate="onSubmit" data-steps="3">
  <form class="ren-form ren-stack">
    <ol class="ren-form-progress" aria-label="Progress">
      <li class="ren-form-step" data-active>
        <span class="ren-form-step-label">Account</span>
      </li>
      <li class="ren-form-step">
        <span class="ren-form-step-label">Profile</span>
      </li>
      <li class="ren-form-step">
        <span class="ren-form-step-label">Review</span>
      </li>
    </ol>
    <!-- step sections … -->
  </form>
</ren-form>
```

## Attributes, Events, and API

- Host attributes:
  - `data-validate="onSubmit|onBlur|onChange|onTouched"` (default: `onSubmit`).
  - `data-steps="N"` enables multi-step mode.
- Field rules: `data-rules="required|email|min:8|max:64|pattern:^[A-Z]+$|match:password"`.
- Submitting state: component sets `[data-submitting]` on `<ren-form>` and
  disables submit buttons while the `ren-submit` consumer is async.
- Events:
  - `ren-submit` — `detail.values` (validated form data). Repeated names are arrays.
    Register async work with `event.detail.waitUntil(promise)`; the
    `[data-submitting]` state remains until every registered promise settles.
    The event is cancelable so consumers may call `preventDefault()` when they
    own submission. A rejected promise emits `ren-submit-error` with
    `detail.error`.
  - `ren-invalid` — `detail.errors` (rule failures).
- Static API: `RenForm.registerValidator(name, fn)` for custom validators.
  Validators may return a Promise; `validateAsync()` and submit await it.
  Use `RenForm.registerMessages(locale, messages)` to localize `required`,
  `email`, `pattern`, `min`, `max`, and `invalid` messages. Set `lang` on
  `<ren-form>` (or `<html>`) for locale selection. `data-persist="key"`
  stores/restores values in `localStorage`; call `reset()` to clear it.

## Variants and Public Selectors

| Class                          | Role                                   |
|--------------------------------|----------------------------------------|
| `.ren-form`                    | Form root chrome.                      |
| `.ren-form-section`            | Visually grouped section.              |
| `.ren-form-section-title`      | Section heading.                       |
| `.ren-form-row`                | Horizontal row of fields.              |
| `.ren-form-actions`            | Footer of action buttons.              |
| `.ren-form-error-summary`      | Top-of-form error summary.             |
| `.ren-form-success`            | Post-submit confirmation slot.         |
| `.ren-form-progress`           | Multi-step progress bar.               |
| `.ren-form-step`               | Single step.                           |
| `.ren-form-step-label`         | Step label text.                       |

## States

| Selector / attr        | Meaning                                                |
|------------------------|--------------------------------------------------------|
| `[data-submitting]`    | Form is processing; submit buttons disabled.           |
| `[data-has-errors]`    | One or more fields are currently invalid.              |
| `[data-invalid]`       | A specific field is invalid.                           |
| `[data-active]`        | Active step in a multi-step form.                      |
| `[data-completed]`     | Completed step.                                        |
| `[data-disabled]`      | A step is locked.                                      |

## Public Token API

Form layout uses `ren-stack` / `--space-*`. Field chrome inherits from
`ren-field`:

- `--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-color`
- `--ren-field-placeholder`
- `--ren-field-focus-color`
- `--ren-field-error-color`
- `--ren-field-success-color`

## Accessibility Contract

- The form is a real `<form>` element. `ren-form` is an enhancement.
- Each control lives in `ren-field` (auto-wires label / description / error
  IDs).
- The error summary has `role="alert"` and `tabindex="-1"` (the component
  sets `tabindex="-1"` automatically if it is missing). It receives focus
  on invalid submit so screen readers and keyboard users land on the
  error list.
- `required` is the source of truth for required state; visual indicators
  pair with it.
- Submit buttons are real `<button type="submit">`.
- During submitting state, focus stays on the form (do not yank focus).

## Anti-Patterns

- ❌ `<ren-form><div>…</div></ren-form>` — wrap a real `<form>`.
- ❌ Bare `<input>` at the top of the form without a `ren-field`.
- ❌ Bypassing `ren-submit` and POSTing from the click handler.
- ❌ Hand-writing `aria-invalid` / `aria-describedby` — `ren-field` owns
  these.
- ❌ Using placeholder as a label.

## Related Files

- `components/patterns/ren-form/ren-form.css`
- `components/patterns/ren-form/ren-form.js`
- `components/primitives/ren-field/component.md`
- `docs/components/ren-form.html`
- `ren-design.md`
- `tokens/tokens.md`

## Test Expectations

- Run component / docs a11y coverage on submit / invalid flows.
- Run `npm run lint` after token / selector changes.
- Manually verify keyboard-only submit and error summary focus.


/* ============================================
   RenDS — Form Validation System Organism
   ============================================
   Multi-step form with field validation,
   error summaries, and accessibility features.

   Includes:
   - Form container with field stack layout
   - Section grouping and horizontal rows
   - Error summary boxes (CSS shown/hidden)
   - Multi-step progress indicators
   - Loading/submitting states
   - Responsive design

   Light DOM — no Shadow DOM.
   ============================================ */

/* ─── Form Container ─── */
.ren-form {
  container-type: inline-size;
  container-name: ren-form;
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
  width: 100%;
  max-width: 100%;
}

/* Submitting state — disable interactions */
.ren-form[data-submitting] {
  opacity: 0.6;
  pointer-events: none;
}

/* ─── Error Summary ─── */
.ren-form-error-summary {
  padding: var(--space-4);
  background-color: var(--color-danger-subtle);
  border: 1px solid var(--color-danger);
  border-radius: var(--radius-md);
  transition: all var(--duration-enter) var(--ease-enter);
}

.ren-form-error-summary[hidden],
.ren-form-error-summary:not([data-has-errors]) {
  display: none;
}

.ren-form-error-summary strong {
  display: block;
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  color: var(--color-danger);
  margin-bottom: var(--space-2);
}

.ren-form-error-summary ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.ren-form-error-summary li {
  font-size: var(--caption-size);
  color: var(--color-danger);
  line-height: var(--leading-normal);
}

.ren-form-error-summary a {
  color: var(--color-danger);
  text-decoration: underline;
  cursor: pointer;
}

.ren-form-error-summary a:hover {
  text-decoration: none;
}

/* ─── Success Message ─── */
.ren-form-success {
  padding: var(--space-4);
  background-color: var(--color-success-subtle);
  border: 1px solid var(--color-success);
  border-radius: var(--radius-md);
  font-size: var(--body-sm-size);
  color: var(--color-success);
  display: flex;
  align-items: center;
  gap: var(--space-3);
  transition: all var(--duration-enter) var(--ease-enter);
}

.ren-form-success[hidden] {
  display: none;
}

/* ─── Form Sections ─── */
.ren-form-section {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.ren-form-section-title {
  font-size: var(--title-sm-size);
  font-weight: var(--title-sm-weight);
  color: var(--color-text);
  margin: 0;
  padding: 0;
  line-height: var(--title-sm-leading);
}

/* ─── Form Row (Horizontal Layout) ─── */
.ren-form-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}

.ren-form-row > .ren-field {
  flex: 1;
  min-width: 250px;
}

/* Stack on narrow containers */
@container ren-form (max-width: 480px) {
  .ren-form-row {
    flex-direction: column;
    gap: var(--space-4);
  }

  .ren-form-row > .ren-field {
    min-width: 100%;
    flex: none;
  }
}

/* ─── Form Actions ─── */
.ren-form-actions {
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  gap: var(--space-3);
  margin-top: var(--space-4);
  flex-wrap: wrap;
}

.ren-form-actions button {
  white-space: nowrap;
}

@container ren-form (max-width: 480px) {
  .ren-form-actions {
    flex-direction: column-reverse;
  }

  .ren-form-actions button {
    width: 100%;
  }
}

/* ─── Multi-Step Progress Indicator ─── */
.ren-form-progress {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  margin-bottom: var(--space-6);
}

.ren-form-step {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* Step circle */
.ren-form-step::before {
  content: attr(data-step);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  min-width: 2.5rem;
  border-radius: 50%;
  background-color: var(--color-border);
  color: var(--color-text-muted);
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  transition: all var(--duration-state) var(--ease-enter);
  border: 2px solid transparent;
}

/* Step label */
.ren-form-step-label {
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  color: var(--color-text-muted);
  white-space: nowrap;
  transition: all var(--duration-state) var(--ease-enter);
}

/* Divider between steps */
.ren-form-step:not(:last-child)::after {
  content: '';
  flex: 1;
  height: 2px;
  background-color: var(--color-border);
  min-width: 1rem;
  transition: background-color var(--duration-state) var(--ease-enter);
}

/* Active step */
.ren-form-step[data-active] {
  /* Step circle */
}

.ren-form-step[data-active]::before {
  background-color: var(--color-accent);
  color: var(--color-on-accent);
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-accent-subtle);
}

.ren-form-step[data-active] .ren-form-step-label {
  color: var(--color-accent);
  font-weight: var(--weight-semibold);
}

/* Completed step */
.ren-form-step[data-completed] {
  /* Step circle */
}

.ren-form-step[data-completed]::before {
  background-color: var(--color-success);
  color: var(--color-on-success);
  border-color: var(--color-success);
  content: '✓';
  font-size: 1.25rem;
  font-weight: var(--weight-bold);
}

.ren-form-step[data-completed] .ren-form-step-label {
  color: var(--color-text-muted);
}

/* Divider after completed step is colored */
.ren-form-step[data-completed]:not(:last-child)::after {
  background-color: var(--color-success);
}

/* ─── Disabled step ─── */
.ren-form-step[data-disabled] {
  pointer-events: none;
}

.ren-form-step[data-disabled]::before {
  background-color: var(--color-fill);
  color: var(--color-text-faint);
  opacity: 0.5;
}

.ren-form-step[data-disabled] .ren-form-step-label {
  color: var(--color-text-faint);
  opacity: 0.5;
}

/* Stack steps vertically on narrow containers */
@container ren-form (max-width: 360px) {
  .ren-form-progress {
    flex-direction: column;
    gap: var(--space-3);
  }

  .ren-form-step:not(:last-child)::after {
    content: '';
    width: 2px;
    height: 1.5rem;
    background-color: var(--color-border);
    min-width: 0;
  }
}

/* ─── Smooth focus management ─── */
.ren-form:has(.ren-field[data-invalid]) {
  scroll-behavior: smooth;
}

/* ─── Animation for error entry ─── */
@keyframes slideInError {
  from {
    opacity: 0;
    transform: translateY(-0.5rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.ren-form-error-summary[data-has-errors] {
  animation: slideInError var(--duration-enter) var(--ease-enter);
}

@media (prefers-reduced-motion: reduce) {
  .ren-form-error-summary[data-has-errors],
  .ren-form-step::before,
  .ren-form-step-label,
  .ren-form-step:not(:last-child)::after {
    animation: none;
    transition: none;
  }
}
/* Appearance API bridge delegates field chrome to the primitive family. */
.ren-form input, .ren-form select, .ren-form textarea { min-block-size: var(--ren-field-height, var(--size-lg)); padding-inline: var(--ren-field-padding-x, var(--space-3)); padding-block: var(--ren-field-padding-y, var(--space-2)); font-size: var(--ren-field-font-size, var(--body-size)); border-radius: var(--ren-field-radius, var(--radius-md)); border: var(--ren-field-border-width, var(--stroke-1)) solid var(--ren-field-border-color, var(--color-border-interactive)); background: var(--ren-field-bg, var(--color-input-bg)); color: var(--ren-field-color, var(--color-text)); }
.ren-form input::placeholder, .ren-form textarea::placeholder { color: var(--ren-field-placeholder, var(--color-input-placeholder)); }
.ren-form input:focus, .ren-form select:focus, .ren-form textarea:focus { border-color: var(--ren-field-focus-color, var(--color-input-border-focus)); }
.ren-form [aria-invalid="true"] { border-color: var(--ren-field-error-color, var(--color-input-error-border)); }
.ren-form [aria-invalid="false"] { border-color: var(--ren-field-success-color, var(--color-success)); }


import { serializeFormEntries } from './serialize.js';

/**
 * RenDS — <ren-form> Form Validation System
 * ==========================================
 * Multi-step form with field validation, error summaries,
 * and accessibility features. Supports custom validators,
 * multiple validation modes, and server-side errors.
 *
 * Light DOM — no Shadow DOM.
 *
 * Usage:
 *   <ren-form data-validate="onTouched" data-steps="3">
 *     <form class="ren-form">
 *       <div class="ren-form-error-summary" hidden>
 *         <strong>Please fix the following errors:</strong>
 *         <ul></ul>
 *       </div>
 *
 *       <div class="ren-form-progress">
 *         <div class="ren-form-step" data-step="1" data-active>
 *           <span class="ren-form-step-label">Account</span>
 *         </div>
 *         <!-- ... -->
 *       </div>
 *
 *       <div class="ren-form-section">
 *         <div class="ren-field" data-rules="required|email">
 *           <label class="ren-field-label" data-required>Email</label>
 *           <input class="ren-input" type="email" name="email" required>
 *           <span class="ren-field-error" hidden></span>
 *         </div>
 *       </div>
 *
 *       <div class="ren-form-actions">
 *         <button type="button">Previous</button>
 *         <button type="submit">Submit</button>
 *       </div>
 *     </form>
 *   </ren-form>
 */

const DEFAULT_VALIDATION_MODE = 'onSubmit';
const DEBOUNCE_DELAY = 300;
const DEFAULT_MESSAGES = { required: 'This field is required', email: 'Please enter a valid email address', pattern: 'Invalid format', invalid: 'Please correct this field', min: (n) => `Must be at least ${n} characters`, max: (n) => `Must be no more than ${n} characters` };


/** Built-in validators */
const builtInValidators = {
  required: (value) => {
    return value && value.trim().length > 0
      ? null
      : 'This field is required';
  },

  email: (value) => {
    if (!value) return null;
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(value) ? null : 'Please enter a valid email address';
  },

  min: (min) => (value) => {
    if (!value) return null;
    return value.length >= parseInt(min, 10)
      ? null
      : `Must be at least ${min} characters`;
  },

  max: (max) => (value) => {
    if (!value) return null;
    return value.length <= parseInt(max, 10)
      ? null
      : `Must be no more than ${max} characters`;
  },

  pattern: (pattern) => (value) => {
    if (!value) return null;
    try {
      const regex = new RegExp(pattern);
      return regex.test(value) ? null : 'Invalid format';
    } catch {
      return 'Invalid regex pattern';
    }
  },

  match: (fieldName) => (value, form) => {
    const targetField = Array.from(form?.elements || []).find(
      (element) => element.name === fieldName
    );
    if (!targetField) return 'Target field not found';
    return value === targetField.value ? null : 'Fields do not match';
  },
};

export class RenForm extends HTMLElement {
  static #customValidators = new Map();
  static #messages = new Map();

  constructor() {
    super();
    this._form = null;
    this._fields = [];
    this._errors = new Map();
    this._touched = new Set();
    this._validationMode = DEFAULT_VALIDATION_MODE;
    this._debounceTimers = new Map();
    this._currentStep = 1;
    this._totalSteps = 0;
    this._isSubmitting = false;
    this._errorSummary = null;
    this._successMessage = null;
    this._listenerController = null;
    this._persistKey = null;
    this._submitButtonStates = new Map();
  }

  static registerValidator(name, fn) {
    RenForm.#customValidators.set(name, fn);
  }
  static registerMessages(locale, messages) { RenForm.#messages.set(locale, { ...messages }); }

  connectedCallback() {
    this._form = this.querySelector('form.ren-form');
    if (!this._form) return;
    if (!this.hasAttribute('dir')) {
      const locale = this.getAttribute('lang') || document.documentElement.lang || '';
      if (/^(ar|fa|he|ur)(-|$)/i.test(locale)) this.setAttribute('dir', 'rtl');
    }

    this._initElements();
    this._attachEventListeners();
    this._initMultiStep();
    this._restorePersisted();
  }

  disconnectedCallback() {
    this._removeEventListeners();
    this._clearDebounceTimers();
  }

  _initElements() {
    this._validationMode = this.getAttribute('data-validate') ?? DEFAULT_VALIDATION_MODE;
    this._fields = Array.from(this._form.querySelectorAll('ren-field, .ren-field'))
      .filter((field, index, fields) => fields.indexOf(field) === index);
    this._errorSummary = this._form.querySelector('.ren-form-error-summary');
    this._successMessage = this._form.querySelector('.ren-form-success');

    // The error summary must be focusable so .focus() actually moves caret /
    // SR position when validation fails. A plain <div role="alert"> is not
    // focusable by default; set tabindex="-1" if the author did not.
    if (this._errorSummary && !this._errorSummary.hasAttribute('tabindex')) {
      this._errorSummary.setAttribute('tabindex', '-1');
    }
  }

  _initMultiStep() {
    const stepsAttr = this.getAttribute('data-steps');
    if (!stepsAttr) return;

    this._totalSteps = parseInt(stepsAttr, 10);
    this._currentStep = 1;
    this._updateProgressIndicators();
  }

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

    // Form submit
    this._form.addEventListener('submit', (e) => this._handleSubmit(e), { signal });

    // Field validation
    this._fields.forEach((field) => {
      const input = field.querySelector('input, select, textarea');
      if (!input) return;
      input.addEventListener('input', () => this._persist(), { signal });

      switch (this._validationMode) {
        case 'onBlur':
          input.addEventListener('blur', () => this._validateField(field, input), { signal });
          break;

        case 'onChange':
          input.addEventListener('input', () => {
            this._debounceValidation(field, input);
          }, { signal });
          break;

        case 'onTouched':
          input.addEventListener('blur', () => {
            this._touched.add(input.name);
            this._validateField(field, input);
          }, { signal });
          input.addEventListener('input', () => {
            if (this._touched.has(input.name)) {
              this._debounceValidation(field, input);
            }
          }, { signal });
          break;

        case 'onSubmit':
        default:
          // Only validate on submit
          break;
      }
    });
  }

  _debounceValidation(field, input) {
    // Clear existing timer
    if (this._debounceTimers.has(input.name)) {
      clearTimeout(this._debounceTimers.get(input.name));
    }

    // Set new timer
    const timer = setTimeout(() => {
      this._validateField(field, input);
      this._debounceTimers.delete(input.name);
    }, DEBOUNCE_DELAY);

    this._debounceTimers.set(input.name, timer);
  }

  _clearDebounceTimers() {
    this._debounceTimers.forEach((timer) => clearTimeout(timer));
    this._debounceTimers.clear();
  }

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

  async _handleSubmit(e) {
    e.preventDefault();
    if (this._isSubmitting) return;

    this._isSubmitting = true;

    let result;
    try {
      result = await this.validateAsync();
    } catch (error) {
      this._isSubmitting = false;
      this._setSubmittingState(false);
      this.dispatchEvent(new CustomEvent('ren-submit-error', {
        detail: { error },
        bubbles: true,
        composed: true,
      }));
      return;
    }

    if (!result.valid) {
      this._isSubmitting = false;
      this.dispatchEvent(
        new CustomEvent('ren-invalid', {
          detail: { errors: result.errors },
          bubbles: true,
          composed: true,
        })
      );
      this._showErrorSummary(result.errors);
      this._scrollToFirstError();
      return;
    }

    // Success
    this._hideErrorSummary();
    this._setSubmittingState(true);

    const values = this.getValues();
    const completions = [];
    const event = new CustomEvent('ren-submit', {
      detail: { values, form: this._form, waitUntil: (promise) => {
        if (promise && typeof promise.then === 'function') completions.push(promise);
      } },
      bubbles: true,
      composed: true,
      cancelable: true,
    });

    this.dispatchEvent(event);

    try { await Promise.all(completions); }
    catch (error) {
      this.dispatchEvent(new CustomEvent('ren-submit-error', { detail: { error }, bubbles: true, composed: true }));
    } finally {
      this._setSubmittingState(false);
      this._isSubmitting = false;
    }
  }

  _setSubmittingState(isSubmitting) {
    this.toggleAttribute('data-submitting', isSubmitting);
    this._form?.toggleAttribute('data-submitting', isSubmitting);

    if (isSubmitting) {
      this._submitButtonStates.clear();
      const submitButtons = this._form?.querySelectorAll(
        'button:not([type]), button[type="submit"], input[type="submit"], input[type="image"]'
      ) || [];
      submitButtons.forEach((button) => {
        this._submitButtonStates.set(button, button.disabled);
        button.disabled = true;
      });
      return;
    }

    this._submitButtonStates.forEach((wasDisabled, button) => {
      if (button.isConnected) button.disabled = wasDisabled;
    });
    this._submitButtonStates.clear();
  }

  _validateField(field, input) {
    const rules = field.getAttribute('data-rules') ?? input.getAttribute('data-rules') ?? '';
    const error = this._nativeValidationError(input) || this._runValidators(rules, input.value, input.name);

    if (error) {
      this._setFieldError(field, input, error);
      this._errors.set(input.name, error);
    } else {
      this._clearFieldError(field, input);
      this._errors.delete(input.name);
    }

    // Dispatch field validation event
    this.dispatchEvent(
      new CustomEvent('ren-field-validated', {
        detail: {
          name: input.name,
          valid: !error,
          error,
        },
        bubbles: true,
        composed: true,
      })
    );
  }

  _runValidators(rulesString, value, fieldName) {
    if (!rulesString) return null;

    const rules = rulesString.split('|').map((r) => r.trim());

    for (const rule of rules) {
      let validator = null;
      let param = null;

      if (rule.includes(':')) {
        [validator, param] = rule.split(':');
      } else {
        validator = rule;
      }

      // Get validator function
      let validatorFn = builtInValidators[validator];
      if (!validatorFn && RenForm.#customValidators.has(validator)) {
        validatorFn = RenForm.#customValidators.get(validator);
      }

      if (!validatorFn) continue;

      // Run validator
      let error;
      if (param) {
        if (validator === 'match') {
          error = validatorFn(param)(value, this._form);
        } else {
          error = validatorFn(param)(value);
        }
      } else {
        error = validatorFn(value);
      }

      if (error) return this._message(error, validator, param);
    }

    return null;
  }

  async _runValidatorsAsync(rulesString, value) {
    if (!rulesString) return null;
    for (const rule of rulesString.split('|').map((r) => r.trim())) {
      const [name, param] = rule.split(':');
      const fn = builtInValidators[name] || RenForm.#customValidators.get(name);
      if (!fn) continue;
      let result = param ? fn(param)(value, this._form) : fn(value, this._form);
      if (result && typeof result.then === 'function') result = await result;
      if (result) return this._message(result, name, param);
    }
    return null;
  }

  _message(message, rule, param) {
    const locale = this.getAttribute('lang') || document.documentElement.lang || 'en';
    const custom = RenForm.#messages.get(locale) || RenForm.#messages.get(locale.split('-')[0]);
    const value = custom?.[rule] ?? DEFAULT_MESSAGES[rule];
    return typeof value === 'function' ? value(param) : (value || message);
  }

  _setFieldError(field, input, message) {
    field.setAttribute('data-invalid', '');
    input.setAttribute('aria-invalid', 'true');

    const errorEl = field.querySelector('.ren-field-error');
    if (errorEl) {
      errorEl.textContent = message;
      errorEl.hidden = false;
      if (!errorEl.id) {
        errorEl.id = `error-${input.name}`;
      }
      input.setAttribute('aria-errormessage', errorEl.id);
    }
  }

  _clearFieldError(field, input) {
    field.removeAttribute('data-invalid');
    input.removeAttribute('aria-invalid');

    const errorEl = field.querySelector('.ren-field-error');
    if (errorEl) {
      errorEl.hidden = true;
      errorEl.textContent = '';
    }
  }

  _showErrorSummary(errors) {
    if (!this._errorSummary) return;

    const ul = this._errorSummary.querySelector('ul');
    if (!ul) return;

    ul.replaceChildren();
    errors.forEach((error) => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      const field = this._findFieldInput(error.name);

      link.href = field?.id ? `#${field.id}` : '#';
      link.textContent = `${error.name}: ${error.message}`;
      link.addEventListener('click', (e) => {
        e.preventDefault();
        if (field) field.focus();
      });

      li.appendChild(link);
      ul.appendChild(li);
    });

    this._errorSummary.setAttribute('data-has-errors', '');
    this._errorSummary.removeAttribute('hidden');
    this._errorSummary.focus();
  }

  _findFieldInput(name) {
    return Array.from(this._form?.elements || []).find(
      (element) => element.name === name
    );
  }

  _hideErrorSummary() {
    if (!this._errorSummary) return;
    this._errorSummary.removeAttribute('data-has-errors');
    this._errorSummary.setAttribute('hidden', '');
  }

  _scrollToFirstError() {
    const firstInvalid = this._form.querySelector('[data-invalid]');
    if (firstInvalid) {
      firstInvalid.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
    }
  }

  _updateProgressIndicators() {
    const steps = this.querySelectorAll('.ren-form-step');
    steps.forEach((step, idx) => {
      const stepNum = idx + 1;
      step.setAttribute('data-step', stepNum);

      if (stepNum < this._currentStep) {
        step.setAttribute('data-completed', '');
        step.removeAttribute('data-active');
        step.removeAttribute('data-disabled');
      } else if (stepNum === this._currentStep) {
        step.removeAttribute('data-completed');
        step.setAttribute('data-active', '');
        step.removeAttribute('data-disabled');
      } else {
        step.removeAttribute('data-completed');
        step.removeAttribute('data-active');
        step.setAttribute('data-disabled', '');
      }
    });
    this.querySelectorAll('.ren-form-section[data-step], [data-ren-step-panel]').forEach((panel) => {
      const step = Number(panel.getAttribute('data-step') || panel.getAttribute('data-ren-step-panel'));
      panel.hidden = step !== this._currentStep;
      panel.toggleAttribute('data-active', step === this._currentStep);
    });
  }

  // ─── Public API ───

  validate() {
    const errors = [];

    this._fields.forEach((field) => {
      const input = field.querySelector('input, select, textarea');
      if (!input) return;

      const rules = field.getAttribute('data-rules') ?? input.getAttribute('data-rules') ?? '';
      const error = this._nativeValidationError(input) || this._runValidators(rules, input.value, input.name);

      if (error) {
        this._setFieldError(field, input, error);
        errors.push({ name: input.name, message: error });
      } else {
        this._clearFieldError(field, input);
      }
    });

    return {
      valid: errors.length === 0,
      errors,
    };
  }

  async validateAsync() {
    const errors = [];
    for (const field of this._fields) {
      const input = field.querySelector('input, select, textarea');
      if (!input) continue;
      const rules = field.getAttribute('data-rules') ?? input.getAttribute('data-rules') ?? '';
      const error = this._nativeValidationError(input) || await this._runValidatorsAsync(rules, input.value);
      if (error) { this._setFieldError(field, input, error); this._errors.set(input.name, error); errors.push({ name: input.name, message: error }); }
      else this._clearFieldError(field, input);
    }
    return { valid: errors.length === 0, errors };
  }

  reset() {
    this._form.reset();
    this._setSubmittingState(false);
    this._isSubmitting = false;
    this._errors.clear();
    this._touched.clear();
    this._debounceTimers.forEach((timer) => clearTimeout(timer));
    this._debounceTimers.clear();

    this._fields.forEach((field) => {
      const input = field.querySelector('input, select, textarea');
      if (input) {
        this._clearFieldError(field, input);
      }
    });

    this._hideErrorSummary();
    this._updateProgressIndicators();
    this._clearPersisted();
  }

  _restorePersisted() {
    this._persistKey = this.getAttribute('data-persist');
    if (!this._persistKey) return;
    try {
      const values = JSON.parse(localStorage.getItem(this._persistKey) || '{}');
      this._form.querySelectorAll('[name]').forEach((input) => {
        const value = values[input.name]; if (value == null) return;
        if (input.type === 'checkbox' || input.type === 'radio') input.checked = Array.isArray(value) ? value.includes(input.value) : Boolean(value);
        else input.value = Array.isArray(value) ? value[0] : value;
      });
    } catch { /* storage is optional */ }
  }

  _persist() {
    if (!this._persistKey) return;
    try { localStorage.setItem(this._persistKey, JSON.stringify(this.getValues())); } catch { /* private mode/quota */ }
  }

  _clearPersisted() { if (this._persistKey) { try { localStorage.removeItem(this._persistKey); } catch { /* optional */ } } }

  getValues() {
    const formData = new FormData(this._form);
    return serializeFormEntries(formData.entries());
  }

  setErrors(errors) {
    this._errors.clear();

    errors.forEach((error) => {
      const field = this._form.querySelector(
        `.ren-field:has([name="${error.name}"])`
      );
      const input = field?.querySelector('input, select, textarea');

      if (field && input) {
        this._setFieldError(field, input, error.message);
        this._errors.set(error.name, error.message);
      }
    });

    this._showErrorSummary(errors);
  }

  setFieldError(name, message) {
    const field = this._form.querySelector(
      `.ren-field:has([name="${name}"])`
    );
    const input = field?.querySelector('input, select, textarea');

    if (field && input) {
      this._setFieldError(field, input, message);
      this._errors.set(name, message);
    }
  }

  clearFieldError(name) {
    const field = this._form.querySelector(
      `.ren-field:has([name="${name}"])`
    );
    const input = field?.querySelector('input, select, textarea');

    if (field && input) {
      this._clearFieldError(field, input);
      this._errors.delete(name);
    }
  }

  // ─── Multi-Step API ───

  nextStep() {
    if (this._currentStep >= this._totalSteps) return false;

    // Validate current step fields
    const currentStepFields = this._getStepFields(this._currentStep);
    const errors = [];

    currentStepFields.forEach((field) => {
      const input = field.querySelector('input, select, textarea');
      if (!input) return;

      const rules = field.getAttribute('data-rules') ?? input.getAttribute('data-rules') ?? '';
      const error = this._nativeValidationError(input) || this._runValidators(rules, input.value, input.name);

      if (error) {
        this._setFieldError(field, input, error);
        errors.push({ name: input.name, message: error });
      } else {
        this._clearFieldError(field, input);
      }
    });

    if (errors.length > 0) {
      return false;
    }

    this._currentStep++;
    this._updateProgressIndicators();
    this._hideErrorSummary();

    this.dispatchEvent(
      new CustomEvent('ren-step-change', {
        detail: { step: this._currentStep, totalSteps: this._totalSteps },
        bubbles: true,
        composed: true,
      })
    );

    return true;
  }

  prevStep() {
    if (this._currentStep <= 1) return false;

    this._currentStep--;
    this._updateProgressIndicators();
    this._hideErrorSummary();

    this.dispatchEvent(
      new CustomEvent('ren-step-change', {
        detail: { step: this._currentStep, totalSteps: this._totalSteps },
        bubbles: true,
        composed: true,
      })
    );

    return true;
  }

  goToStep(stepNum) {
    if (stepNum < 1 || stepNum > this._totalSteps) return false;

    this._currentStep = stepNum;
    this._updateProgressIndicators();
    this._hideErrorSummary();

    this.dispatchEvent(
      new CustomEvent('ren-step-change', {
        detail: { step: this._currentStep, totalSteps: this._totalSteps },
        bubbles: true,
        composed: true,
      })
    );

    return true;
  }

  get currentStep() {
    return this._currentStep;
  }

  get totalSteps() {
    return this._totalSteps;
  }

  _getStepFields(stepNum) {
    const panel = this.querySelector(`.ren-form-section[data-step="${stepNum}"], [data-ren-step-panel="${stepNum}"]`);
    if (panel) return this._fields.filter((field) => panel.contains(field));
    return this._fields.filter((field) => {
      const owner = field.closest('[data-step]');
      return !owner || owner.getAttribute('data-step') === String(stepNum);
    });
  }

  _nativeValidationError(input) {
    if (input.validity?.valid) return null;
    if (input.validity?.valueMissing) return this._message('', 'required');
    if (input.validity?.typeMismatch) return this._message('', 'email');
    if (input.validity?.patternMismatch) return this._message('', 'pattern');
    if (input.validity?.tooShort) return this._message('', 'min', input.minLength);
    if (input.validity?.tooLong) return this._message('', 'max', input.maxLength);
    if (input.validity?.rangeUnderflow) return this._message(`Value must be at least ${input.min}`, 'invalid');
    if (input.validity?.rangeOverflow) return this._message(`Value must be no more than ${input.max}`, 'invalid');
    return this._message('', 'invalid');
  }
}

if (!customElements.get('ren-form')) {
  customElements.define('ren-form', RenForm);
}

data:
{"kind":"pattern","selectors":[".ren-field",".ren-form",".ren-form-actions",".ren-form-error-summary",".ren-form-progress",".ren-form-row",".ren-form-section",".ren-form-section-title",".ren-form-step",".ren-form-step-label",".ren-form-success"],"tokens":["--body-size","--body-sm-size","--caption-size","--color-accent","--color-accent-subtle","--color-border","--color-border-interactive","--color-danger","--color-danger-subtle","--color-fill","--color-input-bg","--color-input-border-focus","--color-input-error-border","--color-input-placeholder","--color-on-accent","--color-on-success","--color-success","--color-success-subtle","--color-text","--color-text-faint","--color-text-muted","--duration-enter","--duration-state","--ease-enter","--label-size","--label-weight","--leading-normal","--radius-md","--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-lg","--space-1","--space-2","--space-3","--space-4","--space-6","--stroke-1","--title-sm-leading","--title-sm-size","--title-sm-weight","--weight-bold","--weight-semibold"],"hasScript":true,"hasDocsPage":true}


