# nodes

id: file:components/composites/ren-sheet/ren-sheet.js
type: javascript
name: ren-sheet.js
path: components/composites/ren-sheet/ren-sheet.js
data: {}
body:
/**
 * 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();
    }
  });
}


id: file:components/composites/ren-slider/ren-slider.css
type: css
name: ren-slider.css
path: components/composites/ren-slider/ren-slider.css
data: {}
body:
/* ═══ 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%); }


id: file:components/composites/ren-slider/ren-slider.js
type: javascript
name: ren-slider.js
path: components/composites/ren-slider/ren-slider.js
data: {}
body:
/* ═══ 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);
}


id: file:components/composites/ren-tabs/ren-tabs.css
type: css
name: ren-tabs.css
path: components/composites/ren-tabs/ren-tabs.css
data: {}
body:
/* ═══ 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)); }


id: file:components/composites/ren-tabs/ren-tabs.js
type: javascript
name: ren-tabs.js
path: components/composites/ren-tabs/ren-tabs.js
data: {}
body:
/**
 * 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);
}


id: file:components/composites/ren-toast/ren-toast.css
type: css
name: ren-toast.css
path: components/composites/ren-toast/ren-toast.css
data: {}
body:
/**
 * 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);
  }
}


id: file:components/composites/ren-toast/ren-toast.js
type: javascript
name: ren-toast.js
path: components/composites/ren-toast/ren-toast.js
data: {}
body:
/**
 * 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;
}


id: file:components/composites/ren-toggle-group/ren-toggle-group.css
type: css
name: ren-toggle-group.css
path: components/composites/ren-toggle-group/ren-toggle-group.css
data: {}
body:
/* ═══ 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);
  }
}


id: file:components/composites/ren-toggle-group/ren-toggle-group.js
type: javascript
name: ren-toggle-group.js
path: components/composites/ren-toggle-group/ren-toggle-group.js
data: {}
body:
/* ═══ 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);
}


id: file:components/composites/ren-toolbar/ren-toolbar.css
type: css
name: ren-toolbar.css
path: components/composites/ren-toolbar/ren-toolbar.css
data: {}
body:
/* ============================================
   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)); }


id: file:components/composites/ren-toolbar/ren-toolbar.js
type: javascript
name: ren-toolbar.js
path: components/composites/ren-toolbar/ren-toolbar.js
data: {}
body:
/* ============================================
   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);
}


id: file:components/composites/ren-tooltip/ren-tooltip.css
type: css
name: ren-tooltip.css
path: components/composites/ren-tooltip/ren-tooltip.css
data: {}
body:
/* ═══ 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));
}


id: file:components/composites/ren-tooltip/ren-tooltip.js
type: javascript
name: ren-tooltip.js
path: components/composites/ren-tooltip/ren-tooltip.js
data: {}
body:
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);
}


id: file:components/patterns/ren-ai/ren-ai.css
type: css
name: ren-ai.css
path: components/patterns/ren-ai/ren-ai.css
data: {}
body:
/* ============================================
   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);
}


id: file:components/patterns/ren-command/ren-command.css
type: css
name: ren-command.css
path: components/patterns/ren-command/ren-command.css
data: {}
body:
.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)); }


id: file:components/patterns/ren-command/ren-command.js
type: javascript
name: ren-command.js
path: components/patterns/ren-command/ren-command.js
data: {}
body:
/**
 * 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);


id: file:components/patterns/ren-empty-state/ren-empty-state.css
type: css
name: ren-empty-state.css
path: components/patterns/ren-empty-state/ren-empty-state.css
data: {}
body:
/* ============================================
   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)); }


id: file:components/patterns/ren-form/ren-form.css
type: css
name: ren-form.css
path: components/patterns/ren-form/ren-form.css
data: {}
body:
/* ============================================
   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)); }


id: file:components/patterns/ren-form/ren-form.js
type: javascript
name: ren-form.js
path: components/patterns/ren-form/ren-form.js
data: {}
body:
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);
}


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

  connectedCallback() {
    this.initialize();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    this.activateItem(item);
  }

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

    if (!item) return;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    this.typeaheadBuffer += char.toLowerCase();

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

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

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

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

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

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

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

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


id: file:components/patterns/ren-nav/ren-nav.css
type: css
name: ren-nav.css
path: components/patterns/ren-nav/ren-nav.css
data: {}
body:
.ren-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--ren-space-2, 0.5rem) var(--ren-space-4, 1rem);
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  min-height: 3.5rem;
  transition: backdrop-filter var(--duration-overlay) var(--ease-state-change), background-color var(--duration-overlay) var(--ease-state-change);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

.ren-nav-dropdown {
  position: relative;

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

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

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


id: file:components/patterns/ren-nav/ren-nav.js
type: javascript
name: ren-nav.js
path: components/patterns/ren-nav/ren-nav.js
data: {}
body:
/**
 * RenNav Component - Responsive Navigation Bar
 * Modern web component with keyboard navigation and mobile support
 */

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

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

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

  disconnectedCallback() {
    this._removeEventListeners();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/patterns/ren-sidebar/ren-sidebar.css
type: css
name: ren-sidebar.css
path: components/patterns/ren-sidebar/ren-sidebar.css
data: {}
body:
.ren-sidebar-layout {
  display: flex;
  min-height: 100dvh;
  background-color: var(--color-surface);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/patterns/ren-sidebar/ren-sidebar.js
type: javascript
name: ren-sidebar.js
path: components/patterns/ren-sidebar/ren-sidebar.js
data: {}
body:
/**
 * RenSidebar Component - Collapsible sidebar for app layouts
 * Supports desktop collapse and mobile overlay modes
 */

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

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

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

  disconnectedCallback() {
    this._removeEventListeners();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  get isMobileOpen() {
    return this._isOpen;
  }

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  &:empty {
    display: none;
  }
}

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

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

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

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

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

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

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

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

  &:empty {
    display: none;
  }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

  constructor() {
    super();
  }

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

  disconnectedCallback() {
    this._removeEventListeners();
  }

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

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

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

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

    // Initialize rows
    this._refreshRows();

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

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

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

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

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

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

    // Column resizing
    this._setupColumnResize(signal);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    this.#lastSelectedRowIndex = rowIndex;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (columnIndex === -1) return;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    // Update pagination
    this._setupPagination();

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

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/primitives/ren-avatar/ren-avatar.css
type: css
name: ren-avatar.css
path: components/primitives/ren-avatar/ren-avatar.css
data: {}
body:
/* ============================================
   RenDS — Avatar
   ============================================
   Circular (or square) user image with fallback
   text, status indicator and group (overlap).

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

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/primitives/ren-badge/ren-badge.css
type: css
name: ren-badge.css
path: components/primitives/ren-badge/ren-badge.css
data: {}
body:
/* ============================================
   RenDS — Badge
   ============================================
   Small inline label for status, counts, or tags.
   CSS-only, no JS needed.

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/primitives/ren-banner/ren-banner.css
type: css
name: ren-banner.css
path: components/primitives/ren-banner/ren-banner.css
data: {}
body:
/* ============================================
   RenDS — Banner / Alert Component
   ============================================
   Inline messages for success, warning, error, info.
   CSS-only. No JS needed (dismiss is optional JS).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* ─── Variants ─── */

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

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

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

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

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

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

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

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

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

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


id: file:components/primitives/ren-breadcrumb/ren-breadcrumb.css
type: css
name: ren-breadcrumb.css
path: components/primitives/ren-breadcrumb/ren-breadcrumb.css
data: {}
body:
/* ============================================
   RenDS — Breadcrumb Component
   ============================================
   Hierarchical navigation trail.
   CSS-only. No JS needed.

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

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

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

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

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

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

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

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

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

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

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

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


id: file:components/primitives/ren-button/ren-button.css
type: css
name: ren-button.css
path: components/primitives/ren-button/ren-button.css
data: {}
body:
/* ============================================
   RenDS — Button Component
   ============================================
   Accessible, multi-variant button.
   Works as pure CSS classes OR as <ren-button>.

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

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

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

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

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

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

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

/* ─── Variants ─── */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* ─── Sizes ─── */

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

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

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

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

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

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

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

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

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

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

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

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

/* ─── States ─── */

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

    this._button.className = classes;
  }

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

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

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

  // ─── Programmatic API ───

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

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

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

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

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

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

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


id: file:components/primitives/ren-card/ren-card.css
type: css
name: ren-card.css
path: components/primitives/ren-card/ren-card.css
data: {}
body:
/* ============================================
   RenDS — Card Component
   ============================================
   Versatile container with header, body, footer.
   CSS-only. No JS needed.

   Constraint-driven: limited variants on purpose
   to ensure consistent UI across projects.

   Usage:
     <div class="ren-card">
       <div class="ren-card-header">
         <h3 class="ren-card-title">Title</h3>
         <p class="ren-card-description">Description here</p>
       </div>
       <div class="ren-card-body">
         Content
       </div>
       <div class="ren-card-footer">
         <button class="ren-btn">Action</button>
       </div>
     </div>

   Minimal:
     <div class="ren-card">
       <p>Just content, no sections needed.</p>
     </div>
   ============================================ */

/* ─── Base Card ─── */
.ren-card {
  background-color: var(--ren-card-bg);
  border: var(--ren-card-border-width) solid var(--ren-card-border-color);
  border-radius: var(--ren-card-radius);
  box-shadow: var(--ren-card-shadow);
  overflow: hidden;
}

/* ─── Sections ─── */

.ren-card-header {
  display: flex;
  flex-direction: column;
  gap: var(--ren-card-header-gap);
  padding: var(--ren-card-padding) var(--ren-card-padding) 0;
}

/* Title and description are typically applied to <h2>/<h3>/<h4> and <p>
   respectively, which carry browser-default margins that fight the header's
   `gap`. Reset them here so .ren-card-header's flex gap is the actual visual
   gap, regardless of the underlying tag. */
.ren-card-title {
  font-size: var(--title-sm-size, var(--text-lg));
  font-weight: var(--weight-semibold);
  color: var(--color-text);
  line-height: var(--leading-snug);
  margin: 0;
}

.ren-card-description {
  font-size: var(--caption-size, var(--text-sm));
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
  margin: 0;
}

.ren-card-body {
  padding: var(--ren-card-padding);
}

/* Remove top padding when body follows header */
.ren-card-header + .ren-card-body {
  padding-top: var(--space-3);
}

/* Optional spacing between documented card sections without forcing a new
   flex/grid layout on the card root. */
.ren-card > :is(.ren-card-header, .ren-card-body, .ren-card-footer, .ren-card-footer-border)
  + :is(.ren-card-header, .ren-card-body, .ren-card-footer, .ren-card-footer-border) {
  margin-block-start: var(--ren-card-gap);
}

.ren-card-footer {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--ren-card-padding) var(--ren-card-padding);
}

/* Footer at bottom with border (for action-heavy cards) */
.ren-card-footer-border {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--ren-card-padding);
  border-top: var(--stroke-1) solid var(--color-border-muted);
}

/* When the bordered footer follows the header directly (no body in between),
   tighten the top padding so the divider line sits visually close to the
   description, not floating ~17px below it (which is what you get from the
   default --space-3 padding plus line-height extra of the description text). */
.ren-card-header + .ren-card-footer-border {
  padding-top: var(--space-2);
}

/* When card has no sections, just padding */
.ren-card > :first-child:not(.ren-card-header):not(.ren-card-body):not(.ren-card-footer) {
  padding-top: var(--ren-card-padding);
}

.ren-card > :last-child:not(.ren-card-header):not(.ren-card-body):not(.ren-card-footer) {
  padding-bottom: var(--ren-card-padding);
}

/* Simple card (no sections) */
.ren-card-simple {
  padding: var(--ren-card-padding);
}

/* ─── Variants ─── */

/* Elevated — more shadow, no border */
.ren-card-elevated {
  border-color: transparent;
  box-shadow: var(--shadow-md);
}

/* Outline — emphasis border, no background difference */
.ren-card-outline {
  background-color: transparent;
}

/* Ghost — no border, no bg (content grouping) */
.ren-card-ghost {
  background-color: transparent;
  border: none;
}

/* Sunken — recessed appearance */
.ren-card-sunken {
  background-color: var(--color-surface-sunken);
  border-color: transparent;
}

/* ─── Interactive Card ─── */

.ren-card-interactive {
  cursor: pointer;
  transition:
    box-shadow var(--duration-state) var(--ease-enter),
    border-color var(--duration-state) var(--ease-enter),
    transform var(--duration-tactile) var(--ease-enter);
}

.ren-card-interactive:hover {
  border-color: var(--color-border-strong);
  box-shadow: var(--shadow-md);
}

.ren-card-interactive:active {
  transform: scale(0.99);
}

.ren-card-interactive:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* ─── Selectable Card ─── */

.ren-card-selectable {
  cursor: pointer;
  transition:
    border-color var(--duration-state) var(--ease-enter),
    box-shadow var(--duration-state) var(--ease-enter);
}

.ren-card-selectable:hover {
  border-color: var(--color-accent);
}

.ren-card-selectable[aria-selected="true"],
.ren-card-selectable[data-selected] {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 1px var(--color-accent);
}

/* ─── Status accent (top border) ─── */

.ren-card[data-status="success"] { border-top: 3px solid var(--color-success); }
.ren-card[data-status="warning"] { border-top: 3px solid var(--color-warning); }
.ren-card[data-status="danger"]  { border-top: 3px solid var(--color-danger); }
.ren-card[data-status="info"]    { border-top: 3px solid var(--color-info); }

/* ─── Card with cover image ─── */

.ren-card-cover {
  width: 100%;
  height: auto;
  object-fit: cover;
  display: block;
}

/* ─── Card Group (grid of cards) ─── */

.ren-card-group {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
  gap: var(--space-4);
}


id: file:components/primitives/ren-checkbox/ren-checkbox.css
type: css
name: ren-checkbox.css
path: components/primitives/ren-checkbox/ren-checkbox.css
data: {}
body:
/* ============================================
   RenDS — Checkbox Component
   ============================================
   Custom-styled checkbox built on native
   <input type="checkbox">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <label class="ren-checkbox">
       <input type="checkbox">
       <span class="ren-checkbox-control"></span>
       <span>Accept terms</span>
     </label>

   For toggle switches, see ren-switch.
   ============================================ */

.ren-checkbox {
  display: inline-flex;
  align-items: center;
  gap: var(--ren-checkbox-gap);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  /* Ensure touch target */
  min-height: var(--touch-min);
}

/* Hide native checkbox visually but keep accessible */
.ren-checkbox > input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Custom checkbox visual */
.ren-checkbox-control {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--ren-checkbox-size);   /* 20px */
  height: var(--ren-checkbox-size);
  flex-shrink: 0;
  border: 2px solid var(--ren-checkbox-border);
  border-radius: var(--ren-checkbox-radius);
  background-color: transparent;
  transition:
    background-color var(--ren-checkbox-duration) var(--ease-enter),
    border-color var(--ren-checkbox-duration) var(--ease-enter),
    transform var(--duration-tactile) var(--ren-checkbox-easing);
}

/* Checkmark (hidden by default) */
.ren-checkbox-control::after {
  content: '';
  display: block;
  width: 0.45rem;
  height: 0.7rem;
  border: solid var(--ren-checkbox-checked-color);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform var(--ren-checkbox-duration) var(--ren-checkbox-easing);
  margin-top: -2px;
}

/* ─── States ─── */

/* Checked */
.ren-checkbox > input:checked + .ren-checkbox-control {
  background-color: var(--ren-checkbox-checked-bg);
  border-color: var(--ren-checkbox-checked-bg);
}

.ren-checkbox > input:checked + .ren-checkbox-control::after {
  transform: rotate(45deg) scale(1);
}

/* Hover */
.ren-checkbox:hover > .ren-checkbox-control {
  border-color: var(--color-accent);
}

.ren-checkbox:hover > input:checked + .ren-checkbox-control {
  background-color: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

/* Focus */
.ren-checkbox > input:focus-visible + .ren-checkbox-control {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active / Press */
.ren-checkbox:active > .ren-checkbox-control {
  transform: scale(0.9);
}

/* Disabled */
.ren-checkbox:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Indeterminate */
.ren-checkbox > input:indeterminate + .ren-checkbox-control {
  background-color: var(--ren-checkbox-checked-bg);
  border-color: var(--ren-checkbox-checked-bg);
}

.ren-checkbox > input:indeterminate + .ren-checkbox-control::after {
  border: none;
  width: 0.6rem;
  height: 2px;
  background-color: var(--ren-checkbox-checked-color);
  transform: none;
  margin: 0;
  border-radius: 1px;
}


id: file:components/primitives/ren-field/ren-field.css
type: css
name: ren-field.css
path: components/primitives/ren-field/ren-field.css
data: {}
body:
/* ============================================
   RenDS — Field Component (Input + Label + Error)
   ============================================
   Wraps any form control with label, description,
   and error message — all properly wired with ARIA.

   Works as pure CSS classes OR as <ren-field>.

   Structure:
   .ren-field
     .ren-field-label       (label element)
     .ren-field-control     (input/select/textarea wrapper)
     .ren-field-description (helper text)
     .ren-field-error       (error message)
   ============================================ */

/* ─── Field Container ─── */
.ren-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* ─── Label ─── */
.ren-field-label {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  color: var(--color-text);
}

/* Required indicator */
.ren-field-label[data-required]::after {
  content: '*';
  color: var(--color-danger);
  font-weight: var(--weight-bold);
}

/* ─── Input / Control ─── */
.ren-input {
  display: block;
  width: 100%;
  min-height: var(--ren-field-height);
  padding: var(--ren-field-padding-y) var(--ren-field-padding-x);
  font-size: var(--ren-field-font-size);
  font-family: inherit;
  color: var(--ren-field-color);
  background-color: var(--ren-field-bg);
  border: var(--ren-field-border-width) solid var(--ren-field-border-color);
  border-radius: var(--ren-field-radius);
  transition:
    border-color var(--duration-state) var(--ease-enter),
    background-color var(--duration-state) var(--ease-enter),
    box-shadow var(--duration-state) var(--ease-enter);
}

.ren-input:hover:not(:disabled):not(:focus) {
  background-color: var(--color-input-bg-hover);
}

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

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

.ren-input::placeholder {
  color: var(--ren-field-placeholder);
  opacity: 1;
}

/* ─── Textarea ─── */
textarea.ren-input {
  field-sizing: content;  /* Auto-grow with content — no JS needed */
  min-height: calc(var(--touch-min) * 2);
  max-height: 20rem;      /* Prevent runaway growth */
  resize: vertical;
}

/* ─── Select ─── */
select.ren-input {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238E8E93' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-3) center;
  padding-inline-end: var(--space-10);
}

@supports (appearance: base-select) {
  /* ── Opt-in ── */
  select.ren-input,
  select.ren-input::picker(select) {
    appearance: base-select;
  }

  /* ── Trigger ── */
  select.ren-input {
    display: flex;
    align-items: center;
    background-image: none;
    padding-inline-end: var(--space-3);
  }

  select.ren-input::picker-icon {
    color: var(--color-text-muted);
    opacity: 0.6;
    margin-inline-start: auto;
    padding-inline-start: var(--space-3);
    transition: rotate var(--duration-tactile) var(--ease-enter);
  }

  select.ren-input:open::picker-icon {
    rotate: 180deg;
  }

  select.ren-input option::checkmark {
    display: none;
  }

  /* ── Dropdown ── */
  select.ren-input::picker(select) {
    background: var(--color-surface-overlay);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    box-shadow: 0 4px 20px rgb(0, 0, 0, 0.08),
                0 1px 3px rgb(0, 0, 0, 0.06);
    padding: var(--space-1);
    margin-block-start: var(--space-1);
    overflow: clip;
  }

  /* ── Options ── */
  select.ren-input option {
    display: flex;
    align-items: center;
    padding: var(--space-2) var(--space-3);
    border-radius: 4px;
    color: var(--color-text);
    background: transparent;
    cursor: pointer;
  }

  select.ren-input option + option {
    margin-block-start: 1px;
  }

  select.ren-input option:hover {
    background: light-dark(var(--gray-50), var(--gray-800));
  }

  select.ren-input option:focus-visible,
  select.ren-input option:active {
    outline: 2px solid var(--color-accent);
    outline-offset: -2px;
    background: light-dark(var(--gray-50), var(--gray-800));
  }

  select.ren-input option:checked:not(:disabled) {
    background: light-dark(var(--blue-50), var(--blue-900));
    color: light-dark(var(--blue-700), var(--blue-200));
    font-weight: var(--weight-medium);
  }

  select.ren-input option:checked:not(:disabled)::after {
    content: "✓";
    margin-inline-start: auto;
    color: light-dark(var(--blue-600), var(--blue-300));
    font-weight: var(--weight-bold);
  }

  select.ren-input option:checked:hover {
    background: light-dark(var(--blue-100), var(--blue-800));
  }

  select.ren-input option:disabled {
    color: light-dark(var(--gray-500), var(--gray-400));
    cursor: not-allowed;
  }

  select.ren-input option[hidden] {
    display: none;
  }

  /* ── Optgroups ── */
  select.ren-input optgroup {
    padding: var(--space-1) 0;
    padding-inline-start: 0;
  }

  select.ren-input optgroup:not(:first-of-type) {
    border-block-start: 1px solid var(--color-border);
    margin-block-start: var(--space-2);
    padding-block-start: var(--space-2);
  }

  select.ren-input optgroup > legend,
  select.ren-input optgroup > label {
    padding: var(--space-1) var(--space-3);
    margin-inline-start: var(--space-3);
  }

  /* ── Open state ── */
  select.ren-input:open {
    border-color: var(--color-input-border-focus);
    box-shadow: 0 0 0 3px var(--color-input-focus-ring);
  }
}

/* ─── Input Sizes ─── */
.ren-input-sm {
  min-height: var(--size-sm);
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-sm);
  border-radius: var(--radius-sm);
}

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

/* ─── Input with icon ─── */
.ren-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}

.ren-input-icon {
  position: absolute;
  left: var(--space-3);
  color: var(--color-text-muted);
  pointer-events: none;
  display: flex;
}

.ren-input-icon + .ren-input,
.ren-input-wrapper > .ren-input:has(+ .ren-input-icon) {
  padding-inline-start: calc(var(--space-3) + var(--icon-lg) + var(--space-2));
}

.ren-input-icon-end {
  position: absolute;
  right: var(--space-3);
  color: var(--color-text-muted);
  pointer-events: none;
  display: flex;
}

/* ─── Description ─── */
.ren-field-description {
  font-size: var(--caption-size);
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
  margin: 0;
}

/* ─── Error Message ─── */
.ren-field-error {
  font-size: var(--caption-size);
  color: var(--ren-field-error-color);
  line-height: var(--leading-normal);
  margin: 0;
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

/* Error state on input */
.ren-field[data-invalid] .ren-input,
.ren-input[aria-invalid="true"],
.ren-input-error {
  border-color: var(--ren-field-error-color);
}

.ren-field[data-invalid] .ren-input:focus,
.ren-input[aria-invalid="true"]:focus,
.ren-input-error:focus {
  box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
  border-color: var(--ren-field-error-color);
}

/* Error state on label */
.ren-field[data-invalid] .ren-field-label {
  color: var(--ren-field-error-color);
}

/* ─── Success state ─── */
.ren-field[data-valid] .ren-input,
.ren-input-success {
  border-color: var(--ren-field-success-color);
}

.ren-field[data-valid] .ren-input:focus,
.ren-input-success:focus {
  box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
}

/* ─── Modern Validation (no JS needed) ─── */
/* Only show validation state AFTER user interaction */
.ren-input:user-invalid {
  border-color: var(--ren-field-error-color);
}

.ren-input:user-invalid:focus {
  box-shadow: 0 0 0 3px rgb(255, 59, 48, 0.15);
  border-color: var(--ren-field-error-color);
}

.ren-input:user-valid {
  border-color: var(--ren-field-success-color);
}

.ren-input:user-valid:focus {
  box-shadow: 0 0 0 3px rgb(52, 199, 89, 0.15);
}

/* Show/hide error and success messages based on validation */
.ren-field:has(.ren-input:user-invalid) .ren-field-label {
  color: var(--ren-field-error-color);
}

.ren-field:has(.ren-input:user-invalid) .ren-field-error {
  display: flex;
}

.ren-field:has(.ren-input:user-valid) .ren-field-error {
  display: none;
}


id: file:components/primitives/ren-field/ren-field.js
type: javascript
name: ren-field.js
path: components/primitives/ren-field/ren-field.js
data: {}
body:
/**
 * RenDS — <ren-field> Web Component
 * ===================================
 * Auto-wires ARIA relationships between label, input,
 * description, and error message.
 *
 * Light DOM — no Shadow DOM. Just enhances existing HTML.
 *
 * Usage:
 *   <ren-field>
 *     <label>Email</label>
 *     <input type="email" placeholder="name@example.com">
 *     <span data-description>We'll never share your email.</span>
 *     <span data-error>Please enter a valid email.</span>
 *   </ren-field>
 *
 * The component automatically:
 * - Generates IDs and wires aria-labelledby
 * - Wires aria-describedby to description
 * - Wires aria-errormessage to error
 * - Sets aria-invalid when error is visible
 * - Adds data-required to label when input is required
 * - Adds .ren-field, .ren-field-label, .ren-input, etc. classes
 */

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

export class RenField extends HTMLElement {
  connectedCallback() {
    this._listenerController?.abort();
    this._listenerController = new AbortController();
    const { signal } = this._listenerController;
    // Add base class
    this.classList.add('ren-field');

    // Find child elements
    const label = this.querySelector('label');
    const input = this.querySelector('input, select, textarea');
    const description = this.querySelector('[data-description]');
    const error = this.querySelector('[data-error]');

    if (!input) return;

    // ─── Add classes ───
    label?.classList.add('ren-field-label');
    input.classList.add('ren-input');
    description?.classList.add('ren-field-description');
    error?.classList.add('ren-field-error');

    // ─── Generate IDs ───
    const inputId = autoId(input, 'input');

    // Wire label → input (using for attribute)
    if (label && !label.getAttribute('for')) {
      label.setAttribute('for', inputId);
    }

    // Wire description → input (aria-describedby)
    if (description) {
      const descId = autoId(description, 'desc');
      const existing = input.getAttribute('aria-describedby');
      if (!existing?.includes(descId)) {
        input.setAttribute(
          'aria-describedby',
          existing ? `${existing} ${descId}` : descId
        );
      }
    }

    // Wire error → input (aria-errormessage)
    if (error) {
      const errorId = autoId(error, 'error');
      input.setAttribute('aria-errormessage', errorId);
      error.setAttribute('role', 'alert');
      error.setAttribute('aria-live', 'polite');

      // Initially hide error if not invalid
      if (!this.hasAttribute('data-invalid')) {
        error.hidden = true;
      }
    }

    // ─── Required indicator ───
    if (input.hasAttribute('required') && label) {
      label.setAttribute('data-required', '');
    }

    // ─── Observe validity changes ───
    this._input = input;
    this._error = error;

    // Listen for custom validation
    input.addEventListener('invalid', () => this._setInvalid(true), { signal });
    input.addEventListener('input', () => {
      if (this.hasAttribute('data-invalid') && input.validity.valid) {
        this._setInvalid(false);
      }
    }, { signal });
  }

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

  _setInvalid(invalid) {
    if (invalid) {
      this.setAttribute('data-invalid', '');
      this._input?.setAttribute('aria-invalid', 'true');
      if (this._error) this._error.hidden = false;
    } else {
      this.removeAttribute('data-invalid');
      this._input?.removeAttribute('aria-invalid');
      if (this._error) this._error.hidden = true;
    }
  }

  // ─── Public API ───

  /** Set field to invalid state with a message */
  setError(message) {
    if (this._error && message) {
      this._error.textContent = message;
    }
    this._setInvalid(true);
  }

  /** Clear error state */
  clearError() {
    this._setInvalid(false);
  }

  /** Check if field is currently invalid */
  get invalid() {
    return this.hasAttribute('data-invalid');
  }

  set invalid(val) {
    this._setInvalid(val);
  }
}

if (!customElements.get('ren-field')) {
  customElements.define('ren-field', RenField);
}


id: file:components/primitives/ren-icon/ren-icon.css
type: css
name: ren-icon.css
path: components/primitives/ren-icon/ren-icon.css
data: {}
body:
/* ============================================
   RenDS — Icon Component
   ============================================
   Icon sizing, coloring, and animation utilities.
   Works with any SVG or icon font.

   The base class provides inline layout,
   color inheritance, and shrink-to-fit.

   Usage:
     <span class="ren-icon ren-icon-md">
       <svg>...</svg>
     </span>

     <button>
       <span class="ren-icon ren-icon-lg ren-icon-success">
         <svg>...</svg>
       </span>
       Export
     </button>

     <span class="ren-icon ren-icon-spin ren-icon-xl">
       <svg>...</svg>
     </span>
   ============================================ */

/* ═══════════════════════════════
   ICON BASE
   ═══════════════════════════════ */

.ren-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--ren-icon-size);   /* 20px — default md */
  height: var(--ren-icon-size);
  color: var(--ren-icon-color);
  fill: var(--ren-icon-fill);
}

.ren-icon > svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* ─── Sizes ─── */

.ren-icon-xs {
  width: 0.75rem;   /* 12px */
  height: 0.75rem;
}

.ren-icon-sm {
  width: 1rem;      /* 16px */
  height: 1rem;
}

.ren-icon-md {
  width: 1.25rem;   /* 20px */
  height: 1.25rem;
}

.ren-icon-lg {
  width: 1.5rem;    /* 24px */
  height: 1.5rem;
}

.ren-icon-xl {
  width: 2rem;      /* 32px */
  height: 2rem;
}

.ren-icon-2xl {
  width: 3rem;      /* 48px */
  height: 3rem;
}

/* ─── Color Variants ─── */

.ren-icon-primary {
  color: var(--color-accent);
}

.ren-icon-success {
  color: var(--color-success);
}

.ren-icon-warning {
  color: var(--color-warning);
}

.ren-icon-danger {
  color: var(--color-danger);
}

.ren-icon-muted {
  color: var(--color-text-secondary);
}

/* ─── Animation ─── */

.ren-icon-spin {
  animation: ren-icon-spin var(--duration-loop) var(--ease-loop) infinite;
}

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

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .ren-icon-spin {
    animation: none;
  }
}


id: file:components/primitives/ren-kbd/ren-kbd.css
type: css
name: ren-kbd.css
path: components/primitives/ren-kbd/ren-kbd.css
data: {}
body:
/* ============================================
   RenDS — Kbd (keyboard shortcut)
   ============================================
   Inline chip showing a keyboard key or shortcut
   combo. Uses native <kbd> semantics.

   Usage:
     <kbd class="ren-kbd">⌘</kbd>
     <kbd class="ren-kbd">K</kbd>

   Combo:
     <kbd class="ren-kbd">⌘</kbd> + <kbd class="ren-kbd">K</kbd>
   ============================================ */

.ren-kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.5em;
  padding: var(--ren-kbd-padding);
  font-family: var(--ren-kbd-font-family);
  font-size: var(--ren-kbd-font-size);
  font-weight: var(--weight-medium);
  line-height: 1.4;
  color: var(--ren-kbd-color);
  background-color: var(--ren-kbd-bg);
  border: var(--stroke-1) solid var(--ren-kbd-border);
  border-bottom-width: 2px;
  border-radius: var(--ren-kbd-radius);
  white-space: nowrap;
}


id: file:components/primitives/ren-link/ren-link.css
type: css
name: ren-link.css
path: components/primitives/ren-link/ren-link.css
data: {}
body:
/* ============================================
   RenDS — Link Component
   ============================================
   Styled anchor with variants.
   CSS-only. No JS needed.

   44px touch target on mobile via min-height.
   Accessible focus ring on keyboard navigation.

   Usage:
     <a href="/page" class="ren-link">Standard link</a>
     <a href="/page" class="ren-link ren-link-muted">Subtle link</a>
     <a href="/page" class="ren-link ren-link-external">External ↗</a>
   ============================================ */

/* ─── Base Link ─── */
.ren-link {
  display: inline;
  color: var(--ren-link-color);
  font-weight: var(--ren-link-font-weight, inherit);
  background: none;
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--color-text-link), transparent 60%);
  text-underline-offset: 0.15em;
  text-decoration-thickness: var(--ren-link-underline);
  cursor: pointer;
  transition:
    color var(--duration-tactile) var(--ease-enter),
    text-decoration-color var(--duration-tactile) var(--ease-enter);
  /* Inline elements: ensure touch target via padding */
  -webkit-tap-highlight-color: transparent;
}

.ren-link:hover {
  color: var(--ren-link-hover-color);
  background: none;
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--color-text-link-hover), transparent 30%);
  text-decoration-thickness: calc(var(--ren-link-underline) * 2);
}

.ren-link:active {
  opacity: 0.8;
}

.ren-link:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ─── Muted / Subtle ─── */
.ren-link-muted {
  color: var(--color-text-muted);
  text-decoration: none;
}

.ren-link-muted:hover {
  color: var(--color-text);
  text-decoration: underline;
}

/* ─── Unstyled (inherits parent color) ─── */
.ren-link-plain {
  color: inherit;
  text-decoration: none;
}

.ren-link-plain:hover {
  text-decoration: underline;
}

/* ─── External link indicator ─── */
.ren-link-external::after {
  content: ' ↗';
  font-size: 0.85em;
  text-decoration: none;
  display: inline;
}

/* ─── Nav link (block-level, with padding for touch target) ─── */
.ren-link-nav {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--touch-min);
  padding: var(--space-1) var(--space-2);
  color: var(--color-text);
  text-decoration: none;
  border-radius: var(--radius-md);
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    color var(--duration-tactile) var(--ease-enter);
}

.ren-link-nav:hover {
  background-color: var(--color-fill);
  color: var(--color-text);
  text-decoration: none;
}

.ren-link-nav[aria-current="page"],
.ren-link-nav[data-active] {
  background-color: var(--color-accent-subtle);
  color: var(--color-accent);
  font-weight: var(--weight-medium);
}

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

/* ─── Skip link (a11y) ─── */
.ren-link-skip {
  position: absolute;
  top: -100%;
  left: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background-color: var(--color-accent);
  color: var(--color-on-accent);
  border-radius: var(--radius-md);
  font-weight: var(--weight-semibold);
  text-decoration: none;
  z-index: 9999;
  transition: top var(--duration-tactile) var(--ease-enter);
}

.ren-link-skip:focus {
  top: var(--space-3);
}


id: file:components/primitives/ren-pagination/ren-pagination.css
type: css
name: ren-pagination.css
path: components/primitives/ren-pagination/ren-pagination.css
data: {}
body:
/* ============================================
   RenDS — Pagination Component
   ============================================
   Page navigation for lists/tables.
   CSS-only. No JS needed for styling.

   Semantic <nav> + links for accessibility.
   44px touch targets on all interactive elements.

   Usage (simple):
     <nav aria-label="Pagination" class="ren-pagination">
       <a href="?p=1" class="ren-pagination-prev" aria-label="Previous page">&lsaquo;</a>
       <a href="?p=1" class="ren-pagination-item">1</a>
       <a href="?p=2" class="ren-pagination-item" aria-current="page">2</a>
       <a href="?p=3" class="ren-pagination-item">3</a>
       <a href="?p=3" class="ren-pagination-next" aria-label="Next page">&rsaquo;</a>
     </nav>

   Usage (minimal — just prev/next):
     <nav aria-label="Pagination" class="ren-pagination ren-pagination-simple">
       <a href="?p=1" class="ren-pagination-prev">Previous</a>
       <span class="ren-pagination-info">Page 2 of 10</span>
       <a href="?p=3" class="ren-pagination-next">Next</a>
     </nav>
   ============================================ */

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

/* ─── Page items ─── */
.ren-pagination-item,
.ren-pagination-prev,
.ren-pagination-next {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--ren-pagination-size);
  min-height: var(--ren-pagination-size);
  padding: var(--space-1) var(--space-2);
  font-size: var(--ren-pagination-font-size);
  font-weight: var(--weight-medium);
  color: var(--color-text);
  text-decoration: none;
  border-radius: var(--ren-pagination-radius);
  border: var(--stroke-1) solid transparent;
  background-color: var(--ren-pagination-bg);
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    border-color var(--duration-tactile) var(--ease-enter);
  user-select: none;
}

.ren-pagination-item:hover,
.ren-pagination-prev:hover,
.ren-pagination-next:hover {
  background-color: var(--ren-pagination-hover-bg);
}

.ren-pagination-item:active,
.ren-pagination-prev:active,
.ren-pagination-next:active {
  background-color: var(--color-fill-hover);
}

.ren-pagination-item:focus-visible,
.ren-pagination-prev:focus-visible,
.ren-pagination-next:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Current page */
.ren-pagination-item[aria-current="page"] {
  background-color: var(--ren-pagination-active-bg);
  color: var(--ren-pagination-active-color);
  pointer-events: none;
}

/* Disabled prev/next */
.ren-pagination-prev[aria-disabled="true"],
.ren-pagination-next[aria-disabled="true"],
.ren-pagination-prev:disabled,
.ren-pagination-next:disabled {
  color: var(--color-disabled-text);
  pointer-events: none;
  opacity: 0.5;
}

/* Ellipsis */
.ren-pagination-ellipsis {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  user-select: none;
}

/* ─── Simple variant (prev + info + next) ─── */
.ren-pagination-simple {
  gap: var(--space-2);
}

.ren-pagination-info {
  font-size: var(--caption-size, var(--text-sm));
  color: var(--color-text-muted);
  white-space: nowrap;
  padding: 0 var(--space-2);
}

/* ─── Compact variant (smaller touch targets) ─── */
.ren-pagination-compact .ren-pagination-item,
.ren-pagination-compact .ren-pagination-prev,
.ren-pagination-compact .ren-pagination-next {
  min-width: var(--size-sm);
  min-height: var(--size-sm);
  padding: var(--space-1);
  font-size: var(--caption-size, var(--text-sm));
}

/* ─── Centered (for standalone pagination) ─── */
.ren-pagination-centered {
  justify-content: center;
}


id: file:components/primitives/ren-progress/ren-progress.css
type: css
name: ren-progress.css
path: components/primitives/ren-progress/ren-progress.css
data: {}
body:
/* ============================================
   RenDS — Progress Bar & Meter Components
   ============================================
   Progress indicators and meter displays.
   CSS-only, works with <progress> and <meter>.

   Supports multiple sizes, colors, and states.
   Includes indeterminate animations.

   Usage:
     <div class="ren-progress">
       <div class="ren-progress-bar" style="width: 65%"></div>
     </div>

     <div class="ren-progress ren-progress-lg ren-progress-success">
       <div class="ren-progress-bar" style="width: 85%"></div>
     </div>

     <meter class="ren-meter" value="6" min="0" max="10"></meter>
   ============================================ */

/* ═══════════════════════════════
   PROGRESS BAR
   ═══════════════════════════════ */

.ren-progress {
  display: flex;
  width: 100%;
  height: var(--ren-progress-height);   /* default / md */
  background-color: var(--ren-progress-bg);
  border-radius: var(--ren-progress-radius);
  overflow: hidden;
}

.ren-progress-bar {
  height: 100%;
  width: 0%;
  background-color: var(--ren-progress-fill);
  border-radius: var(--ren-progress-radius);
  transition: width var(--ren-progress-duration) var(--ren-progress-easing);
}

/* ─── Sizes ─── */

.ren-progress-sm {
  height: 0.25rem;
}

.ren-progress-lg {
  height: 0.75rem;
}

.ren-progress-xl {
  height: 1rem;
}

/* ─── Color Variants ─── */

.ren-progress-success .ren-progress-bar {
  background-color: var(--color-success);
}

.ren-progress-warning .ren-progress-bar {
  background-color: var(--color-warning);
}

.ren-progress-danger .ren-progress-bar {
  background-color: var(--color-danger);
}

.ren-progress-info .ren-progress-bar {
  background-color: var(--color-info);
}

/* ─── Indeterminate Animation ─── */

.ren-progress-indeterminate .ren-progress-bar {
  width: 30% !important;
  animation: ren-progress-slide 1.5s var(--ease-enter) infinite;
}

@keyframes ren-progress-slide {
  0% {
    transform: translateX(-100%);
  }
  50% {
    transform: translateX(500%);
  }
  100% {
    transform: translateX(500%);
  }
}

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .ren-progress-indeterminate .ren-progress-bar {
    animation: none;
    width: 50% !important;
  }

  .ren-progress-bar {
    transition: none;
  }
}

/* ═══════════════════════════════
   PROGRESS LABELS
   ═══════════════════════════════ */

.ren-progress-label {
  display: block;
  font-size: var(--label-size);
  font-weight: var(--weight-medium);
  color: var(--color-text);
  margin: 0 0 var(--space-1);
}

.ren-progress-value {
  display: block;
  font-size: var(--label-size);
  font-weight: var(--weight-regular);
  color: var(--color-text-secondary);
  margin: var(--space-1) 0 0;
  text-align: end;
}

/* ═══════════════════════════════
   METER ELEMENT
   ═══════════════════════════════ */

.ren-meter {
  display: block;
  width: 100%;
  height: 0.5rem;
  border: none;
  border-radius: var(--radius-full);
  overflow: hidden;
  background-color: var(--color-fill);
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
}

/* Webkit browsers (Chrome, Safari) */
.ren-meter::-webkit-meter-inner-element {
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-bar {
  background-color: var(--color-fill);
  border-radius: var(--radius-full);
  overflow: hidden;
  border: none;
}

.ren-meter::-webkit-meter-optimum-value {
  background-color: var(--color-success);
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-suboptimum-value {
  background-color: var(--color-warning);
  border-radius: var(--radius-full);
}

.ren-meter::-webkit-meter-even-less-good-value {
  background-color: var(--color-danger);
  border-radius: var(--radius-full);
}

/* Firefox */
.ren-meter::-moz-meter-bar {
  background: var(--color-success);
  border-radius: var(--radius-full);
  border: none;
}

/* ─── Meter Sizes ─── */

.ren-meter-sm {
  height: 0.25rem;
}

.ren-meter-lg {
  height: 0.75rem;
}

.ren-meter-xl {
  height: 1rem;
}


id: file:components/primitives/ren-radio/ren-radio.css
type: css
name: ren-radio.css
path: components/primitives/ren-radio/ren-radio.css
data: {}
body:
/* ============================================
   RenDS — Radio Button & Radio Group Components
   ============================================
   Custom-styled radio buttons and radio groups
   built on native <input type="radio">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <div class="ren-radio-group" role="radiogroup">
       <label class="ren-radio">
         <input type="radio" name="option" value="a">
         <span class="ren-radio-control"></span>
         <span>Option A</span>
       </label>
       <label class="ren-radio">
         <input type="radio" name="option" value="b">
         <span class="ren-radio-control"></span>
         <span>Option B</span>
       </label>
     </div>
   ============================================ */

/* ═══════════════════════════════
   RADIO BUTTON
   ═══════════════════════════════ */

.ren-radio {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  /* Ensure touch target */
  min-height: var(--touch-min);
}

/* Hide native radio visually but keep accessible */
.ren-radio > input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Custom radio visual */
.ren-radio-control {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;   /* 20px */
  height: 1.25rem;
  flex-shrink: 0;
  border: 2px solid var(--color-border-interactive);
  border-radius: var(--radius-full);
  background-color: transparent;
  transition:
    background-color var(--duration-state) var(--ease-enter),
    border-color var(--duration-state) var(--ease-enter),
    transform var(--duration-tactile) var(--ease-playful);
}

/* Radio dot (hidden by default) */
.ren-radio-control::after {
  content: '';
  display: block;
  width: 0.5rem;
  height: 0.5rem;
  background-color: var(--color-on-accent);
  border-radius: var(--radius-full);
  transform: scale(0);
  transition: transform var(--duration-state) var(--ease-playful);
}

/* ─── States ─── */

/* Checked */
.ren-radio > input:checked + .ren-radio-control {
  background-color: var(--color-accent);
  border-color: var(--color-accent);
}

.ren-radio > input:checked + .ren-radio-control::after {
  transform: scale(1);
}

/* Hover */
.ren-radio:hover > .ren-radio-control {
  border-color: var(--color-accent);
}

.ren-radio:hover > input:checked + .ren-radio-control {
  background-color: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

/* Focus */
.ren-radio > input:focus-visible + .ren-radio-control {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active / Press */
.ren-radio:active > .ren-radio-control {
  transform: scale(0.9);
}

/* Disabled */
.ren-radio:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* ═══════════════════════════════
   RADIO GROUP
   ═══════════════════════════════ */

.ren-radio-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.ren-radio-group-horizontal {
  display: flex;
  flex-direction: row;
  gap: var(--space-4);
}


id: file:components/primitives/ren-radio/ren-radio.js
type: javascript
name: ren-radio.js
path: components/primitives/ren-radio/ren-radio.js
data: {}
body:
/**
 * RenDS — <ren-radio-group> Web Component
 * ========================================
 * Accessible radio group with keyboard navigation.
 * Implements arrow key navigation and roving tabindex.
 *
 * Uses Light DOM — no Shadow DOM.
 * Works with native <input type="radio"> elements.
 *
 * Attributes:
 *   orientation: 'vertical' (default) | 'horizontal'
 *   role:        'radiogroup' (auto-set)
 *
 * Usage:
 *   <ren-radio-group>
 *     <label class="ren-radio">
 *       <input type="radio" name="option" value="a">
 *       <span class="ren-radio-control"></span>
 *       <span>Option A</span>
 *     </label>
 *     <label class="ren-radio">
 *       <input type="radio" name="option" value="b">
 *       <span class="ren-radio-control"></span>
 *       <span>Option B</span>
 *     </label>
 *   </ren-radio-group>
 *
 * Arrow keys navigate between radios.
 * Selecting a radio via arrow keys also checks it.
 */

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

export class RenRadioGroup extends HTMLElement {
  static get observedAttributes() {
    return ['orientation'];
  }

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

  connectedCallback() {
    // Set ARIA role
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'radiogroup');
    }

    // Find all radio inputs
    const radios = this.querySelectorAll('input[type="radio"]');
    if (radios.length === 0) return;

    // Set up keyboard navigation using roving tabindex
    const orientation = this.getAttribute('orientation') || 'vertical';

    this._nav = createKeyboardNav(this, {
      selector: 'input[type="radio"]',
      orientation,
      loop: true,
      typeahead: false,
      focusOnHover: false,
      onActivate: (radio, index) => {
        // When arrow key navigation moves to a radio, check it
        radio.checked = true;
        // Dispatch change event to match native behavior
        radio.dispatchEvent(new Event('change', { bubbles: true }));
      },
      onSelect: null,
    });

    this._nav.attach();

    // Update class based on orientation
    this._updateOrientationClass();
  }

  disconnectedCallback() {
    if (this._nav) {
      this._nav.detach();
      this._nav = null;
    }
  }

  attributeChangedCallback(name) {
    if (name === 'orientation') {
      this._updateOrientationClass();
      // Detach and reattach nav with new orientation
      if (this._nav) {
        this._nav.detach();
        const orientation = this.getAttribute('orientation') || 'vertical';
        this._nav = createKeyboardNav(this, {
          selector: 'input[type="radio"]',
          orientation,
          loop: true,
          typeahead: false,
          focusOnHover: false,
          onActivate: (radio, index) => {
            radio.checked = true;
            radio.dispatchEvent(new Event('change', { bubbles: true }));
          },
          onSelect: null,
        });
        this._nav.attach();
      }
    }
  }

  _updateOrientationClass() {
    const orientation = this.getAttribute('orientation') || 'vertical';

    // Remove existing orientation classes
    this.classList.remove('ren-radio-group', 'ren-radio-group-horizontal');

    // Add appropriate class
    if (orientation === 'horizontal') {
      this.classList.add('ren-radio-group-horizontal');
    } else {
      this.classList.add('ren-radio-group');
    }
  }

  // ─── Programmatic API ───

  /**
   * Get the currently checked radio value
   */
  get value() {
    const checked = this.querySelector('input[type="radio"]:checked');
    return checked ? checked.value : null;
  }

  /**
   * Set which radio is checked by value
   */
  set value(val) {
    const radio = this.querySelector(`input[type="radio"][value="${val}"]`);
    if (radio) {
      radio.checked = true;
      radio.dispatchEvent(new Event('change', { bubbles: true }));
    }
  }

  /**
   * Get all radio inputs
   */
  get radios() {
    return Array.from(this.querySelectorAll('input[type="radio"]'));
  }
}

// Register
if (!customElements.get('ren-radio-group')) {
  customElements.define('ren-radio-group', RenRadioGroup);
}


id: file:components/primitives/ren-separator/ren-separator.css
type: css
name: ren-separator.css
path: components/primitives/ren-separator/ren-separator.css
data: {}
body:
/* ============================================
   RenDS — Separator
   ============================================
   Horizontal or vertical divider with optional
   label. CSS-only, no JS needed.

   Usage:
     <hr class="ren-separator">
     <div class="ren-separator-vertical"></div>
     <div class="ren-separator-label">or</div>
   ============================================ */

.ren-separator {
  border: none;
  background-color: var(--ren-separator-color);
}

/* Horizontal (default) */
.ren-separator,
.ren-separator-horizontal {
  width: 100%;
  height: var(--ren-separator-width);
  margin-block: var(--ren-separator-margin);
}

/* Vertical */
.ren-separator-vertical {
  width: var(--ren-separator-width);
  height: auto;
  align-self: stretch;
  margin-inline: var(--space-3);
}

/* With label (e.g. "or" between auth options) */
.ren-separator-label {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--caption-size);
  margin-block: var(--ren-separator-margin);
}

.ren-separator-label::before,
.ren-separator-label::after {
  content: '';
  flex: 1;
  height: var(--ren-separator-width);
  background-color: var(--ren-separator-color);
}


id: file:components/primitives/ren-skeleton/ren-skeleton.css
type: css
name: ren-skeleton.css
path: components/primitives/ren-skeleton/ren-skeleton.css
data: {}
body:
/* ============================================
   RenDS — Skeleton
   ============================================
   Loading placeholder with shimmer animation.
   Respects prefers-reduced-motion (static fill).

   Usage:
     <div class="ren-skeleton ren-skeleton-heading"></div>
     <div class="ren-skeleton ren-skeleton-text"></div>
     <div class="ren-skeleton ren-skeleton-circle"></div>
     <div class="ren-skeleton ren-skeleton-rect"></div>

   Accessibility:
     - Wrap multiple skeletons in a container with
       aria-busy="true" aria-live="polite"
     - Replace with real content when loaded
   ============================================ */

.ren-skeleton {
  display: block;
  background: linear-gradient(
    90deg,
    var(--ren-skeleton-bg) 25%,
    var(--ren-skeleton-shine) 50%,
    var(--ren-skeleton-bg) 75%
  );
  background-size: 200% 100%;
  animation: ren-skeleton-pulse var(--ren-skeleton-speed) var(--ease-loop-pulse) infinite;
  border-radius: var(--ren-skeleton-radius);
  min-height: 1rem;
}

/* ─── Variants ─── */
.ren-skeleton-text {
  height: 1em;
  width: 100%;
  border-radius: var(--radius-sm);
}

.ren-skeleton-heading {
  height: 1.5em;
  width: 60%;
  border-radius: var(--radius-sm);
}

.ren-skeleton-circle {
  width: var(--avatar-md);
  height: var(--avatar-md);
  border-radius: var(--radius-full);
}

.ren-skeleton-rect {
  width: 100%;
  height: 8rem;
  border-radius: var(--radius-md);
}

@keyframes ren-skeleton-pulse {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .ren-skeleton {
    animation: none;
    background: var(--ren-skeleton-bg);
  }
}


id: file:components/primitives/ren-spinner/ren-spinner.css
type: css
name: ren-spinner.css
path: components/primitives/ren-spinner/ren-spinner.css
data: {}
body:
/* ============================================
   RenDS — Spinner
   ============================================
   Circular loading indicator. CSS-only animation
   that respects prefers-reduced-motion (falls
   back to a gentle opacity pulse instead of
   rotating).

   Usage:
     <span class="ren-spinner" role="status"
           aria-label="Loading"></span>
     <span class="ren-spinner ren-spinner-lg"></span>

   Accessibility:
     - Provide role="status" and aria-label for SR
     - On dark surfaces use .ren-spinner-light
   ============================================ */

.ren-spinner {
  display: inline-block;
  width: var(--ren-spinner-size);
  height: var(--ren-spinner-size);
  border: var(--ren-spinner-width) solid var(--color-fill-active);
  border-top-color: var(--ren-spinner-color);
  border-radius: var(--radius-full);
  animation: ren-spin var(--ren-spinner-speed) var(--ease-loop-smooth) infinite;
}

/* ─── Sizes ─── */
.ren-spinner-xs { width: 0.875rem; height: 0.875rem; border-width: 2px; }
.ren-spinner-sm { width: 1rem;     height: 1rem;     border-width: 2px; }
.ren-spinner-lg { width: 2rem;     height: 2rem;     border-width: 3px; }
.ren-spinner-xl { width: 3rem;     height: 3rem;     border-width: 3px; }

/* ─── Color variants ─── */
.ren-spinner-light {
  border-color: rgb(255, 255, 255, 0.3);
  border-top-color: var(--white);
}

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

@keyframes ren-spin-gentle {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}

/* Reduced motion: pulse opacity instead of rotating */
@media (prefers-reduced-motion: reduce) {
  .ren-spinner {
    animation: ren-spin-gentle var(--duration-loop-gentle) var(--ease-loop-pulse) infinite;
  }
}


id: file:components/primitives/ren-switch/ren-switch.css
type: css
name: ren-switch.css
path: components/primitives/ren-switch/ren-switch.css
data: {}
body:
/* ============================================
   RenDS — Switch / Toggle Component
   ============================================
   Custom-styled toggle switch built on native
   <input type="checkbox" role="switch">.

   Preserves native semantics and accessibility.
   Custom visuals via CSS only.

   Usage:
     <label class="ren-switch">
       <input type="checkbox" role="switch">
       <span class="ren-switch-track"></span>
       <span>Dark mode</span>
     </label>
   ============================================ */

.ren-switch {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--body-size);
  font-weight: var(--weight-regular);
  color: var(--color-text);
  user-select: none;
  min-height: var(--touch-min);
}

/* Hide native input */
.ren-switch > input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border-width: 0;
}

/* Track */
.ren-switch-track {
  position: relative;
  width: var(--ren-switch-width);    /* 51px — Apple HIG */
  height: var(--ren-switch-height);   /* 31px */
  flex-shrink: 0;
  background-color: var(--ren-switch-bg);
  border-radius: var(--radius-full);
  transition:
    background-color var(--ren-switch-duration) var(--ren-switch-easing);
}

/* Thumb */
.ren-switch-track::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: var(--ren-switch-thumb-size);   /* 27px */
  height: var(--ren-switch-thumb-size);
  background-color: var(--ren-switch-thumb-color);
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-sm);
  transition:
    transform var(--ren-switch-duration) var(--ren-switch-easing),
    box-shadow var(--ren-switch-duration) var(--ren-switch-easing);
}

/* ─── States ─── */

/* Checked (on) */
.ren-switch > input:checked + .ren-switch-track {
  background-color: var(--ren-switch-checked-bg);
}

.ren-switch > input:checked + .ren-switch-track::after {
  transform: translateX(calc(var(--ren-switch-width) - var(--ren-switch-height)));
}

/* Hover */
.ren-switch:hover > .ren-switch-track {
  background-color: var(--color-fill-hover);
}

.ren-switch:hover > input:checked + .ren-switch-track {
  background-color: var(--color-success-strong);
}

/* Focus */
.ren-switch > input:focus-visible + .ren-switch-track {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Active */
.ren-switch:active > .ren-switch-track::after {
  width: calc(var(--ren-switch-thumb-size) + 6px);
}

.ren-switch:active > input:checked + .ren-switch-track::after {
  transform: translateX(calc(var(--ren-switch-width) - var(--ren-switch-height) - 6px));
}

/* Disabled */
.ren-switch:has(input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}


id: file:components/primitives/ren-tag/ren-tag.css
type: css
name: ren-tag.css
path: components/primitives/ren-tag/ren-tag.css
data: {}
body:
/* ============================================
   RenDS — Tag / Chip Component
   ============================================
   Interactive badge with optional dismiss button.
   CSS-only (dismiss needs minimal JS).

   Different from Badge: Tag is interactive,
   dismissable, and represents user-selected items.

   Usage:
     <span class="ren-tag">
       HTML
     </span>

     <span class="ren-tag ren-tag-removable">
       React
       <button class="ren-tag-dismiss" aria-label="Remove React">&times;</button>
     </span>

     <span class="ren-tag ren-tag-clickable" tabindex="0" role="button">
       Filter: Active
     </span>
   ============================================ */

.ren-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--ren-tag-gap);
  min-height: var(--ren-tag-height);
  padding: 0.25em var(--ren-tag-padding-x);
  font-size: var(--ren-tag-font-size);
  font-weight: var(--ren-tag-font-weight);
  line-height: 1.5;
  color: var(--ren-tag-color);
  background-color: var(--ren-tag-bg);
  border: var(--stroke-1) solid var(--ren-tag-border-color);
  border-radius: var(--ren-tag-radius);
  white-space: nowrap;
  user-select: none;
  max-width: 100%;
}

/* Tag text truncation */
.ren-tag > span {
  overflow: hidden;
  text-overflow: ellipsis;
}

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

.ren-tag-dismiss:hover {
  color: var(--color-text);
  background-color: var(--color-fill-hover);
}

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

/* ─── Clickable / Selectable ─── */
.ren-tag-clickable {
  cursor: pointer;
  transition:
    background-color var(--duration-tactile) var(--ease-enter),
    border-color var(--duration-tactile) var(--ease-enter);
}

.ren-tag-clickable:hover {
  background-color: var(--color-fill-hover);
  border-color: var(--color-border-strong);
}

.ren-tag-clickable:active {
  background-color: var(--color-fill-active);
}

.ren-tag-clickable:focus-visible {
  outline: var(--ring-width) solid var(--color-focus-ring);
  outline-offset: var(--ring-offset-width);
}

/* Selected state */
.ren-tag-clickable[aria-pressed="true"],
.ren-tag-clickable[data-selected] {
  background-color: var(--color-accent);
  color: var(--color-on-accent);
  border-color: var(--color-accent);
}

/* ─── Color variants ─── */
.ren-tag-primary {
  background-color: var(--color-accent-subtle);
  border-color: color-mix(in oklch, var(--color-accent), transparent 70%);
  color: var(--color-accent);
}

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

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

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

/* ─── Size variants ─── */
.ren-tag-sm {
  padding: 0.1em 0.45em;
  font-size: var(--text-xs);
  gap: 0.15em;
}

.ren-tag-lg {
  padding: 0.35em 0.8em;
  font-size: var(--body-size);
}

/* ─── Tag group ─── */
.ren-tag-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  align-items: center;
}


id: foundation:component-router
type: component-router
name: Component router
path: components/components.md
data: {}
body:
# Component Router

Load this file when deciding which RenDS component or pattern to use. After
choosing, load the exact colocated `component.md` or `pattern.md` before
writing markup, CSS, or JavaScript.

## Counts

- Primitives: 19
- Composites: 26
- Patterns: 8
- Total: 53

## Routing Rules

- Use primitives for atomic UI: buttons, badges, cards, fields, links, status,
  and simple form controls.
- Use composites when behavior needs JS enhancement, ARIA relationships,
  popover/dialog behavior, typeahead, roving focus, or state coordination.
- Use patterns for page-level structures and repeated product workflows.
- Do not invent component variants from memory. The colocated contract and CSS
  file are the public source of truth.
- Import component CSS explicitly unless the page already imports
  `rends/components/index.css`.

## Component Contracts

Every `component.md` and `pattern.md` follows the same schema:

- Purpose
- Use when
- Do not use when
- Required imports
- Canonical markup
- Variants
- States and attributes
- Public token API
- Accessibility contract
- Related files
- Test expectations

## Related Files

- `components/index.css` - full component CSS bundle.
- `components/primitives/*/component.md`
- `components/composites/*/component.md`
- `components/patterns/*/pattern.md`
- `docs/components.html`
- `docs/components-showcase.html`


id: foundation:design-contract
type: design-contract
name: RenDS design contract
path: ren-design.md
data: {}
body:
---
# ────────────────────────────────────────────────────────────
# RenDS — ren-design.md
# A vanilla, accessible, atomic design system.
# v0.12.0 · Default theme · WCAG 2.1 AA baseline, AAA opt-in
# ────────────────────────────────────────────────────────────
#
# This is the RenDS-specific design contract for agents. It is not
# generic external design-system file. Use it as the root router: load
# this first, then load only the component, pattern, token, layout, or
# foundation contract required for the current task.
#
# If a token is omitted here, it still lives in `tokens/**/*.css`
# — this document privileges the tokens that compose day-to-day
# UI decisions over exhaustive coverage.
# ────────────────────────────────────────────────────────────

system:
  name: RenDS
  version: 0.12.0
  package: ren10
  license: MIT
  repo: https://github.com/Rensoconese/ren10
  paradigm: vanilla-light-dom-atomic
  accessibility: WCAG 2.1 AA (baseline), AAA (opt-in)
  philosophy: >
    Web standards first. Custom elements + CSS custom properties
    only. No framework, no Shadow DOM, no runtime. Tokens are
    layered: primitive → semantic → component. Components reach
    for semantic tokens; primitives are the raw palette.
  entry-css: index.css
  import-style: ES module CSS import

themes:
  default: default
  variants:
    - amber-editorial  # warm, serif-friendly, editorial surfaces
    - cyber            # neon, dark-first, high-chroma
    - minimal-mono     # neutral, grayscale, typographic
  mechanism: data-theme attribute on :root + light-dark() CSS function
  generator: rends/create  # Generate AA-safe theme from a single hex

# ═════════════════════════════════════════════════════════════
# COLOR
# ═════════════════════════════════════════════════════════════
# Primitives: Apple-HIG-derived scales (50→900 per hue).
# Semantics: mapped with light-dark() so one token serves both modes.
# Dark mode is honored via color-scheme + [data-theme="dark"] override.
# ═════════════════════════════════════════════════════════════

colors:

  primitives:
    blue:
      50:  "#EBF5FF"
      100: "#D1E9FF"
      200: "#A3D3FF"
      300: "#75BDFF"
      400: "#47A7FF"
      500: "#007AFF"   # Apple system blue
      600: "#0063D1"
      700: "#004DA3"
      800: "#003775"
      900: "#002147"
    gray:
      50:  "#F9F9FB"
      100: "#F2F2F7"
      200: "#E5E5EA"
      300: "#D1D1D6"
      400: "#C7C7CC"
      500: "#AEAEB2"
      600: "#8E8E93"
      700: "#636366"
      800: "#48484A"
      900: "#3A3A3C"
      950: "#2C2C2E"
      1000: "#1C1C1E"
    green:
      50:  "#E8FAF0"
      100: "#C2F0D5"
      200: "#7DDBA3"
      300: "#4ECB71"
      400: "#34C759"   # Apple system green
      500: "#28A745"
      600: "#1E8E3E"
      700: "#167A32"
      800: "#0F5B25"
      900: "#083D19"
    red:
      50:  "#FFF0EF"
      100: "#FFD6D4"
      200: "#FFA8A3"
      300: "#FF7A72"
      400: "#FF3B30"   # Apple system red
      500: "#D70015"
      600: "#B80012"
      700: "#990010"
      800: "#7A000D"
      900: "#5C000A"
    orange:
      50:  "#FFF5EB"
      100: "#FFE5C7"
      200: "#FFCB8F"
      300: "#FFB157"
      400: "#FF9500"   # Apple system orange
      500: "#D97E00"
      600: "#B36700"
      700: "#8C5100"
      800: "#663B00"
      900: "#402500"
    yellow:
      50:  "#FFFBE5"
      100: "#FFF4B8"
      200: "#FFEA7A"
      300: "#FFE03C"
      400: "#FFCC00"   # Apple system yellow
      500: "#D4AA00"
      600: "#AA8800"
      700: "#806600"
      800: "#554400"
      900: "#2B2200"
    teal:
      50:  "#E5FAFE"
      100: "#BFF2FC"
      200: "#80E5F9"
      300: "#40D8F6"
      400: "#5AC8FA"   # Apple system teal
      500: "#32ACD6"
      600: "#1E90B3"
      700: "#14748F"
      800: "#0C586C"
      900: "#063C48"
    purple:
      50:  "#F5EEFF"
      100: "#E5D4FF"
      200: "#CBAAFF"
      300: "#B180FF"
      400: "#AF52DE"   # Apple system purple
      500: "#8E3EBE"
      600: "#6F2E9E"
      700: "#52207E"
      800: "#38155E"
      900: "#200C3E"
    pink:
      50:  "#FFF0F5"
      100: "#FFD6E5"
      200: "#FFADCB"
      300: "#FF85B1"
      400: "#FF2D55"   # Apple system pink
      500: "#D4234A"
      600: "#AA1C3E"
      700: "#801532"
      800: "#550E26"
      900: "#2B071A"
    absolutes:
      white: "#FFFFFF"
      black: "#000000"

  # Semantic tokens are what components reach for. Each is
  # expressed as light-dark(light-value, dark-value); both
  # sides are tuned to pass WCAG AA against expected contexts.
  semantic:
    accent:
      base:    light-dark(blue-600, blue-400)      # solid bg
      hover:   light-dark(blue-700, blue-300)
      active:  light-dark(blue-800, blue-200)
      subtle:  light-dark(blue-50,  blue-900)
      strong:  light-dark(blue-700, blue-300)      # AA text on light surface
      on:      light-dark(white, black)            # fg on solid bg
    status:
      success:        light-dark(green-700,  green-400)
      success-subtle: light-dark(green-50,   green-900)
      success-strong: light-dark(green-700,  green-300)   # AA on surface
      on-success:     light-dark(white, black)
      warning:        light-dark(orange-700, orange-400)
      warning-subtle: light-dark(orange-50,  orange-900)
      warning-strong: light-dark(orange-700, orange-300)
      on-warning:     light-dark(white, black)
      danger:         light-dark(red-500,    red-400)
      danger-subtle:  light-dark(red-50,     red-900)
      danger-strong:  light-dark(red-500,    red-300)
      on-danger:      light-dark(white, black)
      info:           light-dark(teal-700,   teal-400)
      info-subtle:    light-dark(teal-50,    teal-900)
      info-strong:    light-dark(teal-700,   teal-300)
      on-info:        light-dark(white, black)
    ai:
      base:    light-dark(purple-500, purple-300)
      hover:   light-dark(purple-600, purple-200)
      subtle:  light-dark(purple-50,  purple-900)
      on:      light-dark(white, black)
    text:
      primary:    light-dark(black, white)
      secondary:  light-dark(gray-700, gray-300)
      muted:      light-dark(gray-700, gray-500)   # AA: 7.24:1 / 4.78:1
      faint:      light-dark(gray-400, gray-800)   # INCIDENTAL ONLY (below AA)
      inverted:   light-dark(white, black)
      link:       accent-strong
      link-hover: accent-active
    surface:
      base:    light-dark(white, black)
      raised:  light-dark(white, gray-1000)
      sunken:  light-dark(gray-100, black)
      overlay: light-dark(white, gray-1000)
      contrast: black
      on-contrast: white
    border:
      base:        light-dark(gray-200, gray-900)   # decorative (may fail 3:1)
      strong:      light-dark(gray-300, gray-800)
      muted:       light-dark(gray-100, gray-950)
      interactive: light-dark(gray-500, gray-500)   # WCAG 1.4.11 ≥3:1
      accent:      accent.base
    fill:
      base:    light-dark(gray-100, gray-950)
      hover:   light-dark(gray-200, gray-900)
      active:  light-dark(gray-300, gray-800)
      subtle:  light-dark(gray-50,  gray-950)
    overlay-scrim: light-dark(rgba(0,0,0,0.4), rgba(0,0,0,0.6))
    separator:     light-dark(gray-200, gray-900)
    focus-ring:    accent.base
    selection:
      bg:   light-dark(blue-100, blue-900)
      text: light-dark(black, white)
    input:
      bg:              light-dark(gray-100, gray-1000)
      bg-hover:        light-dark(white, gray-950)
      border:          border.interactive
      border-focus:    accent.base
      placeholder:     light-dark(gray-700, gray-500)
      disabled-bg:     light-dark(gray-200, gray-950)
      disabled-text:   light-dark(gray-400, gray-800)
      error-border:    status.danger
      focus-ring:      light-dark(rgba(0,122,255,0.15), rgba(10,132,255,0.25))
    disabled:
      bg:   light-dark(gray-200, gray-950)
      text: light-dark(gray-400, gray-800)

# ═════════════════════════════════════════════════════════════
# TYPOGRAPHY
# ═════════════════════════════════════════════════════════════

typography:

  families:
    sans: >
      -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
      Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif,
      "Apple Color Emoji", "Segoe UI Emoji"
    mono: >
      "SF Mono", ui-monospace, "Cascadia Code", "Source Code Pro",
      Menlo, Monaco, Consolas, "Courier New", monospace

  sizes:
    # --text-* · absolute scale (rem). Floor is 11px (Apple HIG minimum).
    xs:   0.6875rem   # 11px — minimum
    sm:   0.8125rem   # 13px — footnote, caption
    base: 1rem        # 16px — body default
    md:   1.0625rem   # 17px — headline
    lg:   1.125rem    # 18px — title-sm
    xl:   1.25rem     # 20px — title-md
    2xl:  1.375rem    # 22px — title-lg
    3xl:  1.625rem    # 26px — display-sm
    4xl:  1.75rem     # 28px — display-md
    5xl:  2.125rem    # 34px — display-lg
    6xl:  2.5rem      # 40px — hero
    7xl:  3rem        # 48px — hero-lg
    8xl:  3.75rem     # 60px — display-hero

  weights:
    thin:       100
    light:      300
    regular:    400
    medium:     500
    semibold:   600
    bold:       700
    extrabold:  800
    black:      900

  leading:
    none:    1
    tight:   1.2    # headlines / display
    snug:    1.375  # titles
    normal:  1.5    # body default
    relaxed: 1.625
    loose:   1.75
    double:  2

  tracking:
    tighter: -0.05em
    tight:   -0.025em
    normal:   0
    wide:     0.025em
    wider:    0.05em
    widest:   0.1em

  roles:
    # Pre-composed styles — pick a role, not a raw size.
    display-lg:   { size: 5xl, weight: bold,     leading: tight, tracking: tight }
    display-md:   { size: 4xl, weight: bold,     leading: tight, tracking: tight }
    display-sm:   { size: 3xl, weight: semibold, leading: tight, tracking: tight }
    title-lg:     { size: 2xl, weight: semibold, leading: snug }
    title-md:     { size: xl,  weight: semibold, leading: snug }
    title-sm:     { size: lg,  weight: semibold, leading: snug }
    headline:     { size: md,  weight: semibold, leading: snug }
    lead:         { size: xl,  weight: regular,  leading: relaxed }
    body-lg:      { size: md,  weight: regular,  leading: normal }
    body:         { size: base, weight: regular, leading: normal }
    body-sm:      { size: sm,  weight: regular,  leading: normal }
    label-lg:     { size: base, weight: medium }
    label:        { size: sm,   weight: medium }
    label-sm:     { size: xs,   weight: medium }
    caption:      { size: sm,   weight: regular, leading: normal }
    caption-sm:   { size: xs,   weight: regular }
    code:         { size: 0.875em, weight: regular, family: mono }

  modular-scale:
    # Optional: projects that prefer a strict ratio over the absolute
    # scale can set `<html data-type-ratio="X">`.
    default: major-third          # 1.250 · classic web
    available:
      - { name: minor-second,   ratio: 1.067 }
      - { name: major-second,   ratio: 1.125 }
      - { name: minor-third,    ratio: 1.200 }
      - { name: major-third,    ratio: 1.250 }   # default
      - { name: perfect-fourth, ratio: 1.333 }
      - { name: aug-fourth,     ratio: 1.414 }
      - { name: perfect-fifth,  ratio: 1.500 }
      - { name: minor-sixth,    ratio: 1.600 }
      - { name: golden-ratio,   ratio: 1.618 }
      - { name: major-sixth,    ratio: 1.667 }
      - { name: octave,         ratio: 2.000 }

# ═════════════════════════════════════════════════════════════
# LAYOUT — Spacing, sizing, grid
# ═════════════════════════════════════════════════════════════

layout:

  grid: 8pt         # Apple HIG. All spacing derives from multiples of 8.

  spacing:
    # --space-* · rem-based; 8px is the fundamental unit.
    unit: 0.5rem       # 8px
    scale:
      0:     0
      px:    1px
      0-25:  0.0625rem    # 1px
      0-5:   0.125rem     # 2px · hairline
      1:     0.25rem      # 4px · half unit
      1-5:   0.375rem     # 6px
      2:     0.5rem       # 8px · 1u
      3:     0.75rem      # 12px
      4:     1rem         # 16px · standard
      5:     1.25rem      # 20px
      6:     1.5rem       # 24px
      7:     1.75rem
      8:     2rem         # 32px
      9:     2.25rem
      10:    2.5rem
      12:    3rem         # 48px
      14:    3.5rem
      16:    4rem
      20:    5rem
      24:    6rem
      32:    8rem
      40:    10rem
      48:    12rem
      56:    14rem
      64:    16rem        # 256px

  spacing-semantic:
    # Intent-based tokens for day-to-day layout.
    card-padding:        space-6
    card-padding-sm:     space-4
    card-padding-lg:     space-8
    panel-padding:       space-6
    dialog-padding:      space-6
    toast-padding:       space-4
    form-gap:            space-5   # between fields
    form-gap-tight:      space-3
    form-gap-loose:      space-8   # between sections
    field-label-gap:     space-2
    field-helper-gap:    space-1
    fieldset-gap:        space-6
    list-gap:            space-2
    list-gap-tight:      space-1
    menu-item-gap:       space-1
    inline-tight:        space-2   # button + icon
    inline:              space-3
    inline-loose:        space-4
    section:             space-12  # between sections
    section-lg:          space-16
    section-sm:          space-8
    page-top:            space-12
    page-bottom:         space-16
    stack-tight:         space-2
    stack:               space-4
    stack-loose:         space-6

  density:
    # Shift the semantic values via [data-density] on <html>.
    comfortable: default                   # card-padding = space-6
    compact:     { card-padding: space-4, form-gap: space-3 }
    spacious:    { card-padding: space-8, form-gap: space-6 }

  sizing:
    touch-min: 2.75rem      # 44px — Apple HIG minimum touch target
    component-heights:
      xs:  1.5rem            # 24px
      sm:  2rem              # 32px
      md:  2.25rem           # 36px
      lg:  2.75rem           # 44px · default / touch
      xl:  3.25rem           # 52px
      2xl: 3.75rem
    icon-sizes:
      xs:  0.75rem           # 12px
      sm:  1rem              # 16px
      md:  1.25rem           # 20px · default
      lg:  1.5rem            # 24px
      xl:  2rem
      2xl: 3rem
    avatar-sizes:
      xs:  1.5rem
      sm:  2rem
      md:  2.5rem
      lg:  3rem
      xl:  4rem
      2xl: 5rem
    content-widths:
      xs:    20rem           # 320px
      sm:    24rem
      md:    28rem
      lg:    32rem
      xl:    36rem
      2xl:   42rem
      3xl:   48rem
      4xl:   56rem
      5xl:   64rem           # 1024px
      6xl:   72rem
      7xl:   80rem           # 1280px
      full:  100%
      prose: 65ch            # optimal reading line length
    stroke-widths:
      0: 0
      1: 1px
      2: 2px
      3: 3px
      4: 4px

# ═════════════════════════════════════════════════════════════
# SHAPES — Border radius
# ═════════════════════════════════════════════════════════════

shapes:
  radius:
    none: 0
    xs:   0.125rem    # 2px · subtle
    sm:   0.25rem     # 4px · small controls
    md:   0.5rem      # 8px · standard (Apple default)
    lg:   0.75rem     # 12px · cards, modals
    xl:   1rem        # 16px · large panels
    2xl:  1.25rem     # 20px · sheets
    3xl:  1.5rem      # 24px · large sheets
    full: 9999px      # pill / circle

# ═════════════════════════════════════════════════════════════
# ELEVATION — Shadows and z-index
# ═════════════════════════════════════════════════════════════

elevation:

  # Shadow colors are defined via light-dark() so the entire
  # ladder swaps cleanly between modes. Light uses subtle black
  # alpha; dark amps up to 0.3–0.5 alpha for visibility on dark
  # surfaces.
  shadow:
    xs:
      light: "0 1px 2px rgba(0,0,0,0.05)"
      dark:  "0 1px 2px rgba(0,0,0,0.30)"
    sm:
      light: "0 1px 3px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06)"
      dark:  "0 1px 3px rgba(0,0,0,0.40), 0 1px 2px rgba(0,0,0,0.30)"
    md:
      light: "0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.06)"
      dark:  "0 4px 6px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.30)"
    lg:
      light: "0 10px 15px rgba(0,0,0,0.10), 0 4px 6px rgba(0,0,0,0.05)"
      dark:  "0 10px 15px rgba(0,0,0,0.40), 0 4px 6px rgba(0,0,0,0.30)"
    xl:
      light: "0 20px 25px rgba(0,0,0,0.10), 0 8px 10px rgba(0,0,0,0.05)"
      dark:  "0 20px 25px rgba(0,0,0,0.40), 0 8px 10px rgba(0,0,0,0.30)"
    2xl:
      light: "0 25px 50px rgba(0,0,0,0.15), 0 12px 24px rgba(0,0,0,0.08)"
      dark:  "0 25px 50px rgba(0,0,0,0.50), 0 12px 24px rgba(0,0,0,0.40)"
    none:  none
    inset:
      light: "inset 0 2px 4px rgba(0,0,0,0.06)"
      dark:  "inset 0 2px 4px rgba(0,0,0,0.30)"

  z-index:
    behind:    -1
    base:       0
    raised:     1     # slightly above siblings
    dropdown:  10     # dropdown menus
    sticky:    20     # sticky headers, navbars
    overlay:   30     # overlays, backdrops
    modal:     40     # modals, dialogs
    popover:   50     # popovers, floating elements
    toast:     60     # toast notifications
    tooltip:   70     # tooltips (always on top)
    max:     9999     # escape hatch (use sparingly)

  focus-ring:
    width:  2px
    offset: 2px
    color:  accent.base
    # Rendered as: box-shadow: 0 0 0 offset surface, 0 0 0 (offset+width) accent

# ═════════════════════════════════════════════════════════════
# MOTION — Durations, easings, presets
# ═════════════════════════════════════════════════════════════
# All durations collapse to 0ms under prefers-reduced-motion.
# ═════════════════════════════════════════════════════════════

motion:

  durations:
    # --duration-* primitives (ms)
    primitives:
      0:       0
      fastest: 50
      fast:    100
      normal:  150
      moderate: 250
      slow:    300
      slower:  450
      slowest: 600
    # Intent-named semantic durations
    semantic:
      micro:     50       # pixel-level feedback
      tactile:   100      # hover / focus / press
      state:     150      # toggle / checkbox
      enter:     250      # widget appearing
      exit:      100      # widget leaving (always < enter)
      emphasize: 450      # hero transitions
      route:     300      # page change
      overlay:   250      # dialog / sheet / popover scrim

  easings:
    linear:        linear
    default:       cubic-bezier(0.25, 0.10, 0.25, 1.00)
    decelerate:    cubic-bezier(0.00, 0.00, 0.20, 1.00)   # arriving
    accelerate:    cubic-bezier(0.40, 0.00, 1.00, 1.00)   # leaving
    standard:      cubic-bezier(0.20, 0.00, 0.00, 1.00)   # in-flight
    emphasized:    cubic-bezier(0.20, 0.00, 0.00, 1.00)
    spring-soft:   cubic-bezier(0.34, 1.20, 0.64, 1.00)
    spring-snappy: cubic-bezier(0.20, 1.50, 0.40, 1.00)
    bounce:        cubic-bezier(0.34, 1.56, 0.64, 1.00)
    # Intent-named:
    enter:         decelerate      # --ease-enter
    exit:          accelerate      # --ease-exit
    state-change:  standard        # --ease-state-change
    attention:     cubic-bezier(0.05, 0.70, 0.10, 1.00)
    playful:       spring-soft

  presets:
    # Ready-to-paste `transition:` values.
    tactile: >
      color, background-color, border-color, box-shadow, opacity
      — all at duration.tactile · ease.state-change
    state: >
      color, background-color, border-color, box-shadow, transform,
      opacity — all at duration.state · ease.state-change
    enter: >
      opacity, transform, filter — duration.enter · ease.enter
      (use with @starting-style for open animations)
    exit: >
      opacity, transform, filter — duration.exit · ease.exit
    overlay: >
      opacity, background-color, backdrop-filter, overlay (allow-discrete),
      display (allow-discrete) — duration.overlay · ease.enter
    emphasize: >
      opacity, transform — duration.emphasize · ease.attention

  choreography:
    stagger-step: 40ms    # delay between sibling entries
    stagger-max:  10      # cap before running serially

# ═════════════════════════════════════════════════════════════
# COMPONENT REGISTRY
# ═════════════════════════════════════════════════════════════
# Tier · atomic classification used inside the package.
# Each entry carries: class, element tag (if web component),
# a11y pattern, and the motion/token sub-surface it uses.
# ═════════════════════════════════════════════════════════════

components:

  primitives:
    - { name: ren-avatar,     tag: none,              type: CSS-only }
    - { name: ren-badge,      tag: none,              type: CSS-only }
    - { name: ren-banner,     tag: none,              type: CSS-only }
    - { name: ren-breadcrumb, tag: none,              type: CSS-only }
    - { name: ren-button,     tag: button.ren-btn,    type: CSS-first + JS }
    - { name: ren-card,       tag: none,              type: CSS-only }
    - { name: ren-checkbox,   tag: native input,      type: CSS-only }
    - { name: ren-field,      tag: ren-field,         type: web component }
    - { name: ren-icon,       tag: none,              type: CSS-only }
    - { name: ren-kbd,        tag: kbd,               type: CSS-only }
    - { name: ren-link,       tag: a,                 type: CSS-only }
    - { name: ren-pagination, tag: nav,               type: CSS-only }
    - { name: ren-progress,   tag: progress,          type: CSS-only }
    - { name: ren-radio,      tag: ren-radio-group,   type: web component }
    - { name: ren-separator,  tag: hr,                type: CSS-only }
    - { name: ren-skeleton,   tag: none,              type: CSS-only (animated) }
    - { name: ren-spinner,    tag: none,              type: CSS-only (animated) }
    - { name: ren-switch,     tag: native input,      type: CSS-only }
    - { name: ren-tag,        tag: none,              type: CSS-only }

  composites:
    - { name: ren-accordion,          tag: ren-accordion,       a11y: native details/summary }
    - { name: ren-alert-dialog,       tag: ren-alert-dialog,    a11y: non-dismissible dialog }
    - { name: ren-calendar,           tag: ren-calendar,        a11y: ARIA grid }
    - { name: ren-carousel,           tag: ren-carousel,        a11y: scroll-snap + dots }
    - { name: ren-collapsible,        tag: details,             a11y: native }
    - { name: ren-color-picker,       tag: ren-color-picker,    a11y: keyboard + EyeDropper }
    - { name: ren-combobox,           tag: ren-combobox,        a11y: ARIA combobox }
    - { name: ren-context-menu,       tag: ren-context-menu,    a11y: extends ren-menu }
    - { name: ren-date-picker,        tag: ren-date-picker,     a11y: calendar popover }
    - { name: ren-date-range-picker,  tag: ren-date-range-picker, a11y: range selection }
    - { name: ren-dialog,             tag: ren-dialog,          a11y: native <dialog> + focus trap }
    - { name: ren-dropzone,           tag: none,                a11y: input fallback + DnD }
    - { name: ren-hover-card,         tag: none,                a11y: CSS anchor positioning }
    - { name: ren-menu,               tag: ren-menu,            a11y: Popover API + roving tabindex }
    - { name: ren-number-field,       tag: ren-number-field,    a11y: stepper }
    - { name: ren-otp,                tag: ren-otp,             a11y: slot focus + paste }
    - { name: ren-popover,            tag: ren-popover,         a11y: Popover API + anchor }
    - { name: ren-scroll-area,        tag: none,                a11y: native scroll, styled bar }
    - { name: ren-select,             tag: ren-select,          a11y: keyboard + typeahead }
    - { name: ren-sheet,              tag: ren-sheet,           a11y: focus trap + Escape }
    - { name: ren-slider,             tag: ren-slider,          a11y: native range + labels }
    - { name: ren-tabs,               tag: ren-tabs,            a11y: ARIA tablist + roving tabindex }
    - { name: ren-toast,              tag: none,                a11y: aria-live + dismissible }
    - { name: ren-toggle-group,       tag: ren-toggle-group,    a11y: aria-pressed + roving tabindex }
    - { name: ren-toolbar,            tag: none,                a11y: WAI-ARIA toolbar }
    - { name: ren-tooltip,            tag: ren-tooltip,         a11y: hover + focus + long-press }

  patterns:
    - { name: ren-ai,          tag: none,         a11y: semantic HTML + reduced-motion }
    - { name: ren-command,     tag: ren-command,  a11y: dialog + command palette shortcut }
    - { name: ren-empty-state, tag: none,         a11y: semantic heading + text }
    - { name: ren-form,        tag: ren-form,     a11y: aria-invalid + errormessage }
    - { name: ren-menubar,     tag: ren-menubar,  a11y: WAI-ARIA menubar + typeahead }
    - { name: ren-nav,         tag: ren-nav,      a11y: aria-expanded toggle + aria-current }
    - { name: ren-sidebar,     tag: ren-sidebar,  a11y: aria-current + tooltips when collapsed }
    - { name: ren-table,       tag: ren-table,    a11y: aria-sort + aria-selected + keyboard }

counts:
  primitives: 19
  composites: 26
  patterns:    8
  total:      53
---

# RenDS

A vanilla, accessible, atomic design system. No framework required, no Shadow
DOM, no runtime — just CSS custom properties, custom elements, and web
standards that have been in Chrome, Firefox, and Safari stable for years.

## Agent Routing Contract

`ren-design.md` is the root contract for AI agents working with RenDS. Read
this file first, then load only the smallest colocated contract needed for the
task:

| Task | Load next |
|---|---|
| Native HTML or classless styling | `base/primitive-zero.md` |
| Tokens, themes, colors, spacing, type, motion | `tokens/tokens.md` |
| Page skeleton, responsive layout, utility classes | `base/layouts.md` |
| Picking a component | `components/components.md` |
| Editing or using a primitive/composite | `components/{tier}/{ren-name}/component.md` |
| Editing or using a pattern | `components/patterns/{ren-name}/pattern.md` |

Canonical order for UI generation:

1. Load `ren-design.md`.
2. Load `tokens/tokens.md` when choosing any visual value.
3. Load `base/layouts.md` before writing custom layout CSS.
4. Load `base/primitive-zero.md` when using native HTML elements without a
   `ren-` class.
5. Load the exact `component.md` or `pattern.md` for every RenDS part used.

Agents must not invent RenDS APIs from this root file alone. Component and
pattern contracts near the implementation are the public API for markup,
variants, states, tokens, accessibility, JavaScript imports, and test
expectations.

### AI Compliance Layer

RenDS ships an enforcement layer aimed specifically at AI agents:

- **Lowercase contract filenames only.** `ren-design.md`, `tokens.md`,
  `layouts.md`, `primitive-zero.md`, `components.md`, `component.md`,
  `pattern.md`. The uppercase variants (DESIGN, COMPONENT, TOKENS,
  LAYOUTS, PRIMITIVE-ZERO, COMPONENTS) are forbidden and validated by the
  routing checks listed in the repo-root `AGENTS.md`.
- **`aiHints` blocks** in every high-value contract. Each
  `component.md` / `pattern.md` for buttons, cards, fields, dialogs,
  forms, and sidebars carries a YAML `aiHints` block with
  `selectionCriteria`, `canonicalImports`, `requiredMarkup`,
  `forbiddenPatterns`, `tokenPolicy`, and `accessibility`. Agents
  should read the `aiHints` block first when deciding between two
  components.
- **Stylelint + token-policy lint.** `npm run lint` runs Stylelint
  (`stylelint.config.mjs`) plus `scripts/lint-tokens.mjs`. Both block
  primitive palette tokens (`--blue-*`, `--gray-*`, …) and hardcoded
  hex / non-grayscale rgba in component CSS. Each documented exemption is
  recorded in both files.
- **Golden examples** under `rends/examples/*.html`. Concrete,
  copy-correct flows (auth, dashboard shell, settings, data table, dialog
  workflow, app sidebar, AI panel). Read these before generating a similar
  flow.
- **Evals** under `rends/evals/`. `prompts.json` is a deterministic
  prompt set; `checklist.md` is the rule list an agent must self-check
  before reporting completion.

The front-matter above is the wire format: tokens, names, values. Everything
below this line is the prose that explains *why* those values exist, when to
reach for which one, and how components layer on top of them. Agents building
UI with RenDS should treat the two halves as complementary — the YAML gives
exact values, the Markdown gives the editorial rationale.

---

## Overview

### What RenDS is

RenDS is a ~5,000-line, fully auditable design system that ships as a single
CSS file plus a handful of custom elements. Components render in Light DOM so
the host app can style, slot, and select them with ordinary CSS. There is no
build step required on the consumer side — `import 'rends'` and every token
listed above is globally available.

The system is organized in the classic atomic taxonomy: **primitives** (19)
are the smallest cohesive units (buttons, badges, fields); **composites**
(26) combine primitives with behavior (dialog, menu, tabs, select);
**patterns** (8) are page-level organisms (nav, sidebar, table, form,
command palette). Each tier leans on the same token layer, so swapping a
theme cascades cleanly from atoms to pages.

### Philosophy

1. **Tokens before components.** Every value a component uses — a color, a
   radius, a duration — is a custom property. Components are opinionated
   about *structure*; they are deliberately neutral about *values*. Override
   tokens at `:root`, at `[data-theme]`, or at the component root to reskin
   anything.

2. **Semantic, not primitive.** Primitives (`--blue-600`, `--space-4`,
   `--duration-normal`) exist so semantic tokens (`--color-accent`,
   `--space-card-padding`, `--duration-enter`) can point at them. Components
   consume the semantic layer. If a name describes *what* it is instead of
   *what value* it holds, it belongs in the semantic layer.

3. **Light DOM, always. No Shadow DOM, no exceptions.** Styling a web
   component with the host's own CSS should be boring. Shadow DOM is
   powerful but hostile to the everyday designer who wants to tweak padding
   in DevTools, and it breaks the cascade-layer architecture that makes
   overrides effortless. Every RenDS custom element keeps its markup in
   light DOM and scopes through class names.

   The pattern is **progressive enhancement** in two layers:

   1. **CSS-only is usable on its own.** A button renders correctly as
      `<button class="ren-btn">Save</button>` without any JavaScript. A
      combobox renders as `<div class="ren-combobox"><input
      class="ren-combobox-input"><ul class="ren-combobox-list">…</ul></div>`
      without any JavaScript — it just won't filter or trap focus until JS
      loads.
   2. **Custom elements layer on top.** `<ren-button>` enhances the
      underlying `<button>` with loading/variant state. `<ren-combobox>`
      enhances its children by wiring ARIA, keyboard nav, and a hidden
      form input. The element never replaces the markup; it improves it.

   Composites that need encapsulated behavior (toast viewport, sheet,
   combobox listbox) follow the same rule: they render their internals into
   the light DOM of the host, declare ARIA via attributes, and expose state
   through `data-*` attributes (`data-state`, `data-side`, `data-toast-id`)
   so DevTools and external CSS can see and override everything. If you
   reach for `attachShadow()`, stop — there is a way to do it in light DOM,
   and the rest of the system depends on you finding it.

4. **Accessibility is the default, not an add-on.** WCAG 2.1 AA is the
   baseline: every text/background pair in the default theme meets 4.5:1
   (small text) or 3:1 (large text / UI components). AAA is a one-line
   opt-in via the theme generator or `[data-contrast="aaa"]`. Focus rings
   are visible, touch targets are 44px, and `prefers-reduced-motion`
   collapses every duration to 0ms at the token layer.

5. **Reduced-motion first.** Motion tokens are designed so that disabling
   them doesn't break a single interaction — state still changes, just
   instantly. Components never animate via hard-coded values.

### Default theme

This document describes the **default** theme: Apple-HIG-derived palette,
system font stack, 8pt grid, major-third type scale, decelerate-on-arrival
easings. It is the theme every component is tuned and regression-tested
against.

Three variant themes ship alongside the default and can be activated via
`<html data-theme="...">`:

- **amber-editorial** · warm neutrals, serif-friendly, designed for reading
  surfaces (blogs, long-form, docs).
- **cyber** · dark-first, saturated neon accents, high-contrast UI.
- **minimal-mono** · grayscale / single-hue, typographic, deliberately
  restrained.

A hex-to-theme generator (`rends/create`) derives an AA-safe theme from any
single brand hex. All three variants stay within the semantic token names
documented here — nothing about a component changes when you swap themes.

### How to read this document

Each section below documents one token family — **Colors**, **Typography**,
**Layout**, **Shapes**, **Elevation & Depth**, **Motion** — followed by the
component catalog and a consolidated **Do's and Don'ts** list. The tone is
prescriptive: these are the decisions that have already been made, so an
agent generating RenDS UI can stop making them.

---

## Colors

RenDS' palette derives from Apple Human Interface Guidelines system colors.
Each hue ships as a 10-step scale (50 → 900, plus a 950/1000 on gray for
dark-mode surfaces). The scales are expressive enough for marketing
illustration and tame enough for dashboard UI — the rule is that you never
pick from them directly in a component.

### Two layers, one discipline

- **Primitive colors** (`--blue-600`, `--gray-100`, `--red-500`) are the raw
  palette. They live in `tokens/primitives/colors.css` and exist so that
  semantic tokens have something to resolve to. Components never consume
  primitives.
- **Semantic colors** (`--color-accent`, `--color-text`, `--color-surface`,
  `--color-border-interactive`, `--color-success-subtle`, …) describe *what
  a color is for*. These are the only tokens a component touches. If a
  semantic name does not exist for the job, add one in
  `tokens/semantic/colors.css` before falling back to a primitive.

The reason for the split is simple: you can reskin the entire system by
rewriting ~80 semantic tokens, because they all point into the primitive
palette. You never have to audit a component for hard-coded colors.

### light-dark() and color-scheme

Every semantic token is defined with the CSS `light-dark()` function:

```css
--color-text:    light-dark(var(--black), var(--white));
--color-surface: light-dark(var(--white), var(--black));
```

`:root` declares `color-scheme: light dark`, which lets the browser pick the
active side based on the user's OS preference. Manual overrides are applied
with `<html data-theme="light">` or `<html data-theme="dark">`, which set
`color-scheme` explicitly. There is no separate `.dark` class, no
duplicated declaration blocks, no JS required. Dark mode is free.

### Accent, status, AI

Four semantic color families are tuned for maximum legibility on their solid
backgrounds:

- **Accent** (`--color-accent` / `-hover` / `-active` / `-subtle` / `-on` /
  `-strong`) — the brand color. Used for primary buttons, focused borders,
  links, and selection states. `--color-on-accent` is the guaranteed-AA
  text color to place on top of `--color-accent` (white in light, black in
  dark). `--color-accent-strong` is the variant to use for accent-colored
  *text* on a surface (blue-700 / blue-300); the base is calibrated as a
  background, not as text on white.
- **Status** (`success`, `warning`, `danger`, `info`) — each has the same
  family of tokens (`-subtle`, `-strong`, `-on-X`). Semantics map to
  intent: success = confirmations, warning = needs attention but not
  blocking, danger = destructive / error, info = neutral system message.
- **AI** (`--color-ai-*`) — the purple family used by `ren-ai` patterns
  (slug, streaming cursor, citations). Carved out so AI surfaces are
  visually distinct from ordinary accent UI even when a theme remaps the
  accent family.

### Text tokens and the `--color-text-faint` carve-out

Text colors are a short ladder: `primary` (full black/white),
`secondary` (high-emphasis muted), `muted` (standard muted — AA at 7.24:1
light and 4.78:1 dark), `faint` (below AA), `inverted`, `link`, and
`link-hover`.

`--color-text-faint` (gray-400 light, gray-800 dark) **does not meet WCAG
AA** and is reserved for incidental, WCAG-exempt content: disabled labels,
placeholder hints, decorative punctuation (breadcrumb separators, ellipsis,
divider dots), and the outside-month dates in a calendar grid. It must
**never** be used for captions, eyebrows, table headers, step numbering,
or any text the user is expected to read. Use `--color-text-muted` for
those cases — it is AA in both modes.

### Borders and the 3:1 rule

Borders serve two roles with very different contrast requirements:

- **Decorative separators** (`--color-border`, `--color-border-muted`,
  `--color-separator`) live between regions (card edges, dividers, section
  lines). They intentionally fail the 3:1 UI-component contrast threshold
  because they're not conveying state — they're visually tidying the
  layout.
- **Interactive borders** (`--color-border-interactive`) outline actionable
  controls: inputs, unchecked checkboxes, toggle buttons. These must meet
  WCAG 1.4.11 (≥3:1 vs their surface). `--color-border-interactive`
  resolves to `gray-500` in both modes — 3.07:1 on white, 7.42:1 on black
  — which clears AA everywhere the system expects a border to communicate
  affordance.

Components enforce this split: `ren-field`, `ren-checkbox`, `ren-radio`,
`ren-select`, `ren-toggle-group`, and `ren-button.ren-btn-outline` all use
`--color-border-interactive`. `ren-card`, `ren-separator`, and
`ren-sidebar` use `--color-border`. If you're deciding which to use, the
test is: *does removing this border make the component ambiguous about
being interactive?* If yes, interactive. If no, decorative.

### Fills, surfaces, and hover states

Surfaces (`--color-surface`, `--color-surface-raised`, `--color-surface-sunken`,
`--color-surface-overlay`) describe painted backgrounds at different
elevation tiers. Dark mode surfaces use `gray-1000` (#1C1C1E) for raised
content so shadows are still visible — pure black swallows shadow softly.

Fills (`--color-fill`, `-hover`, `-active`, `-subtle`) are the grayscale
ramp used for subtle backgrounds: menu-item hover, row zebra-striping,
tag backgrounds, switch track off-state. They step through
gray-100 → gray-200 → gray-300 in light, and gray-950 → gray-900 → gray-800
in dark, so any hover/active pair reads as a single visual step.

### Focus rings and selection

`--color-focus-ring` is aliased to `--color-accent`. The focus ring is
rendered as `box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-focus-ring)`
so that the offset adapts to whatever surface the control sits on — you get
the right "hollow" effect in light and dark without redefinition.

Selection uses `--color-selection-bg` (blue-100 light, blue-900 dark) with
`--color-selection-text` as the complement. Applied via
`::selection { background: var(--color-selection-bg); color: var(--color-selection-text); }`
in `base/reset.css`.

### AAA mode

`[data-contrast="aaa"]` on `:root` (or generated by the theme tool) lifts
every strong-variant and text-muted pair to WCAG AAA (≥7:1 for small text,
≥4.5:1 for UI). The mechanism is a thin override on the `--color-*-strong`
tokens — no component changes. Use AAA when targeting audiences with low
vision or when a client spec demands it; the system has been regression
tested under AAA with the `smoke-create-generator` script.

The shipped `[data-contrast="aaa"]` scope also sets normal foreground,
surface, and on-accent pairs to a computed contrast of at least 7:1 in light
and dark color schemes. It is intentionally ordered after named theme rules,
so the opt-in wins when both attributes are present.

Custom elements are progressive enhancements: server-rendered light-DOM
content is visible before registration and remains usable without JavaScript.
Applications may opt into a temporary hidden state with `[data-ren-pending]`;
RenDS does not hide every `:not(:defined)` custom element globally.

---

## Typography

The type system is derived from the Apple HIG hierarchy: display → title →
headline → body → label → caption → code. Each role carries a pre-composed
bundle of size, weight, line-height, and (where relevant) tracking, so a
component styles `.ren-title-md` rather than reaching for six individual
properties.

### System font stack

RenDS defaults to the OS system font — `-apple-system`, `BlinkMacSystemFont`,
`Segoe UI`, `Roboto`, etc. This produces native-feeling UI across macOS,
Windows, Linux, iOS, and Android with no web-font payload. The mono stack
favors `SF Mono` and `ui-monospace` for the same reason: consistent metrics
with zero download.

Projects that want a custom face override `--font-sans` or `--font-mono` at
`:root` — not at the component level. Every component inherits through the
token chain, so overriding once reskins every text surface.

### Size scale: 11 → 60 px

The primitive scale `--text-xs` through `--text-8xl` covers 11px (the Apple
HIG minimum legible size) up to 60px (display hero). Values are expressed
in `rem` so user-agent font-size scaling works out of the box. Never go
below `--text-xs` in UI — accessibility audits will flag it.

The 11 → 22px band (xs through 2xl) does the bulk of interface work: body
text, labels, titles. The 26 → 60px band (3xl through 8xl) is reserved for
editorial and marketing surfaces: display headlines, heroes, splash
screens.

### Semantic roles

Instead of choosing a raw size, compose from one of the role bundles:

- **Display** (`display-lg/md/sm`) · large marketing or hero copy; tight
  leading, tight tracking, bold weight.
- **Title** (`title-lg/md/sm`) · section headings; semibold + snug leading.
  Use these for H2/H3 inside app views.
- **Headline** (`headline`) · emphasized body (17px semibold) — for
  dialog titles, card titles, form section heads.
- **Lead** (`lead`) · introductory paragraphs (20px regular, relaxed
  leading). Good for the first paragraph of an article or the subhead
  under a hero.
- **Body** (`body-lg/body/body-sm`) · default reading text. `body` is
  16px, which sets the browser-default baseline.
- **Label** (`label-lg/label/label-sm`) · UI control text — button labels,
  field labels, menu items. Always `weight-medium` so buttons read crisply
  at small sizes without anti-alias fuzz.
- **Caption** (`caption/caption-sm`) · secondary text — helper text,
  timestamps, metadata.
- **Code** (`code`) · `0.875em` monospace for inline code. Uses `em` so it
  scales with surrounding prose.

Each role has its own `--{role}-size`, `--{role}-weight`,
`--{role}-leading`, and (where tuned) `--{role}-tracking` tokens. There are
alias tokens (`--font-size-body`, `--line-height-caption`) kept for legacy
compatibility — prefer the canonical `--body-size`, `--caption-leading`
forms in new code.

### Dynamic Type

`tokens/semantic/dynamic-type.css` exposes a parallel set of fluid sizes
that interpolate between a min and max viewport width, so the same
component can stay readable on mobile and breathe on desktop without
manual breakpoints. Opt in by consuming `--body-size-fluid` instead of
`--body-size`. Not used by default — components ship with static sizes so
QA across breakpoints is predictable.

### Modular scale (optional)

Projects that prefer a strict modular scale over the absolute values can
set `<html data-type-ratio="perfect-fourth">` and the `--scale-step-*`
tokens will interpolate between base and their ratio. The default ratio
is **major-third (1.250)** — classic web, balanced, friendly. Switching
to `golden-ratio` or `octave` is a one-line change.

### Line length

The content-width token `--width-prose` is defined as `65ch`, which is the
accessibility guideline for optimal reading line length. Long-form text
surfaces (`ren-card` with article body, docs pages) should constrain to
this width. App UI surfaces (forms, tables) use the `--width-*` pixel
ladder instead because line length there is driven by data, not reading
comfort.

### Tracking

The `--tracking-*` tokens are tuned for display and label roles.
Display/title sizes benefit from `tracking-tight` to counteract optical
loosening at scale. Labels at the `xs` size can bump to `tracking-wide`
for uppercase treatments (eyebrows, category tags, form-step labels) — the
widened spacing rescues legibility where lowercase x-height disappears.

---

## Layout

Every layout decision in RenDS flows from the **8pt grid**. If a value isn't
a multiple of 8 (or a half-unit 4), it shouldn't be in a layout declaration.
The spacing scale, component heights, touch targets, and icon sizes are all
derived from this unit so stacks, clusters, and grids snap together without
pixel-hunting.

### Spacing scale

The primitive scale `--space-0` through `--space-64` goes 0 → 256px in 8pt
steps, with half-units at 4/6/12/20px for tight spacing. In practice,
`--space-2` (8px), `--space-3` (12px), `--space-4` (16px), `--space-6`
(24px), and `--space-8` (32px) do most of the work. The hairline tokens
(`--space-px`, `--space-0-5`) exist for borders and icon fine-tuning.

### Semantic spacing

Layouts consume spacing through intent-named tokens in
`tokens/semantic/spacing.css`:

- `--space-card-padding` / `-sm` / `-lg` · padding inside cards, panels,
  dialogs.
- `--space-form-gap` / `-tight` / `-loose` · vertical rhythm between
  fields. `--space-field-label-gap` and `--space-field-helper-gap` set
  the within-field rhythm.
- `--space-inline` / `-tight` / `-loose` · horizontal gap inside clusters
  (button + icon, tag + dismiss).
- `--space-list-gap` / `-tight` · vertical gap in menus and lists.
- `--space-section` / `-lg` / `-sm` · rhythm between page sections.
- `--space-page-top` / `-bottom` · page-level breathing room.
- `--space-stack` / `-tight` / `-loose` · defaults for `ren-stack`
  utilities.

Prefer these over raw `--space-*` in custom layouts — the resulting CSS
reads as intent ("card has a `card-padding`") rather than magic numbers.

### Density

Three densities are driven by `[data-density]` on `<html>`:

- `comfortable` (default) — card-padding = 24px, form-gap = 20px.
- `compact` — card-padding = 16px, form-gap = 12px. Good for data-dense
  admin dashboards.
- `spacious` — card-padding = 32px, form-gap = 24px. Good for marketing
  pages and onboarding.

Density only touches the semantic spacing tokens; the primitive scale is
invariant. This means custom components written against the semantic
layer pick up density switches automatically.

### Touch targets

`--touch-min` is 44px (2.75rem), matching the Apple HIG minimum. Every
interactive primitive honors this — `ren-button`, `ren-field`,
`ren-checkbox`, `ren-radio`, `ren-tag[clickable]`, pagination items, menu
items, etc. — regardless of visual size. Small buttons use padding
top/bottom to *look* compact while keeping the hit area at 44px.

Designs that need a visually smaller control (icon button in a toolbar,
inline close button on a chip) may drop below 44px visually, but must
still provide a 44px hit zone via `:before`/`:after` expanded padding or a
transparent overlay.

### Component heights

The `--size-*` ladder (24 → 60px) is the canonical height for buttons and
inputs:

- `xs` 24px · compact inline (chips, small tags)
- `sm` 32px · small button / badge
- `md` 36px · medium
- `lg` 44px · **default** and touch target
- `xl` 52px · large button
- `2xl` 60px · extra-large / marketing CTA

Keep a single row of interleaved buttons and inputs at the same size tier
so they baseline-align.

### Icon and avatar sizes

Icons follow a 12 → 48px scale; `--icon-md` (20px) is the default for most
contexts. Inline icons inside body text use `--icon-sm` (16px) to match
cap-height. Avatars share a parallel 24 → 80px scale; pair `--avatar-sm`
(32px) with `--size-md` buttons, `--avatar-md` (40px) with navigation
chrome, and reserve `--avatar-xl` and up for profile cards.

### Content widths

Page- and container-level max-widths live in `--width-*` (320 → 1280px).
Use these at the layout level, not on individual components. The
`--width-prose` (65ch) token is specifically for reading text surfaces.

### Responsive strategy

RenDS is mobile-first. Container-based responsive rules (using
`container-type: inline-size`) are preferred over viewport media queries
because they let a component adapt to whatever column it's placed in.
`ren-form`, `ren-sidebar`, and `ren-table` already use this pattern.
Viewport media queries are reserved for truly page-level shifts (e.g.,
flipping a nav bar from horizontal to hamburger).

### Utilities

`base/utilities.css` ships a minimal atomic layer: `.ren-stack`,
`.ren-cluster`, `.ren-sidebar-layout`, `.ren-grid`, visibility helpers,
and `sr-only`. These are deliberately small — the system leans on custom
elements for structure, not utility stacks.

---

## Elevation & Depth

RenDS uses shadow tiers, not surface colors, to communicate depth. Surfaces
are mostly flat (white, near-black, gray-1000); the hierarchy of stacked
content reads through the shadow ladder.

### Shadow ladder

Six tiers from `--shadow-xs` to `--shadow-2xl`, plus `--shadow-inset` and
`--shadow-none`:

- **xs** · hairline lift (1px) · buttons in resting state, tags.
- **sm** · card at rest · the typical card or input shadow.
- **md** · raised card on hover · also used for dropdown/select panels.
- **lg** · popover, dropdown menu, hover-card, tooltip.
- **xl** · dialog panel, sheet.
- **2xl** · modal over important content, full-screen overlays.
- **inset** · pressed state for toggle-able buttons and track fills.

Each tier uses two stacked shadows — one for ambient occlusion (wide, low
opacity) and one for the key light (tight, slightly higher opacity). This
mimics the Apple layered-light model and reads as depth rather than a
drop-shadow artifact.

### Dark-mode shadow compensation

Shadows on dark surfaces have to work harder to be visible. The system
decomposes each tier into `--shadow-color-1` through `--shadow-color-6` —
theme-aware color tokens defined with `light-dark()` — and composes the
final `--shadow-xs` … `--shadow-2xl` values from them. In practice, light
mode uses 5–15% black alpha; dark mode uses 30–50% black alpha. The
component author writes `box-shadow: var(--shadow-md)` and gets the right
visual in both modes for free.

A note on pure-black surfaces: dark mode surfaces use `gray-1000`
(`#1C1C1E`), not pure black, for anything that needs to receive a shadow.
`--color-surface` is true black in dark mode (matching the OLED-friendly
HIG pattern), but `--color-surface-raised` steps up to `gray-1000` so
shadows have something to fall onto.

### Z-index ladder

Layering uses a named scale instead of arbitrary numbers:

- `--z-behind` (-1) · content pushed below the page (background shapes).
- `--z-base` (0) · normal document flow.
- `--z-raised` (1) · sibling lift (hover cards inside a grid).
- `--z-dropdown` (10) · dropdown menus.
- `--z-sticky` (20) · sticky headers, sticky nav.
- `--z-overlay` (30) · overlays, backdrops, scrims.
- `--z-modal` (40) · dialogs.
- `--z-popover` (50) · popovers, floating UI.
- `--z-toast` (60) · toast notifications.
- `--z-tooltip` (70) · tooltips — always on top of interactive layers.
- `--z-max` (9999) · escape hatch; use sparingly.

Each layer is a full decimal step apart so components inside a layer can
nudge themselves without colliding. New features should pick the
closest-named token instead of inventing numbers.

### Focus ring

Focus visibility is part of the depth story. The ring is 2px wide with a
2px offset, colored `--color-focus-ring` (= `--color-accent`). Rendered as
a stacked `box-shadow` so the offset color matches the underlying surface:

```css
box-shadow:
  0 0 0 var(--focus-ring-offset) var(--color-surface),
  0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--color-focus-ring);
```

This guarantees a visible halo regardless of whether the control sits on
white, gray, or a dark surface. Every interactive RenDS component uses
`:focus-visible` (never `:focus`) so mouse users don't see the ring on
click.

---

## Shapes

Rounding is functional, not decorative. RenDS defaults to softly-rounded
corners — small enough that rectangles still read as rectangles, large
enough that the UI doesn't feel pixel-sharp.

### Radius scale

The nine-step ladder runs from `--radius-none` to `--radius-full`. Each
tier has a job:

- **none** (0) · rare; use for full-bleed surfaces and hairline dividers.
- **xs** (2px) · subtle rounding for tags, kbd keys, micro-badges.
- **sm** (4px) · small controls — buttons (xs/sm), inputs (sm), checkbox.
- **md** (8px) · **standard**, Apple default — button (md/lg), input (md),
  badge, most "pill but not fully round" surfaces.
- **lg** (12px) · cards, dropdown menus, popovers, modal dialogs.
- **xl** (16px) · large panels, side sheets, oversized cards.
- **2xl** (20px) · sheets that feel like a drawer drawn up over the
  viewport (iOS-style).
- **3xl** (24px) · extra-large sheets, splash panels.
- **full** (9999px) · pills and circles — avatars, round buttons,
  progress bars, skeleton text lines.

### Picking a radius

The internal rule: *smaller controls carry smaller radii*. A 24px chip
with a 12px radius reads as a blob; the same chip with a 4px radius reads
as a clean tag. Match the radius to the smallest dimension of the
control — the radius should usually be no more than one-third of the
height for non-pill shapes.

Cards and modals break this rule deliberately: `--radius-lg` (12px) on a
200px-tall dialog looks correct because the corner sweep is a small
fraction of the overall surface. The rule is about *small controls*, not
all surfaces.

### Pills and circles

`--radius-full` is the only value that ignores size matching. Use it for:

- Avatars, user badges.
- Progress bars (track and fill both).
- Loading spinner borders.
- Capsule buttons, status chips that want to emphasize their "pill" shape.
- Skeleton placeholder lines — a pill-shaped skeleton reads as
  generic-content faster than a sharp rectangle.

Never mix pill and non-pill shapes within the same component family. A
button group with three `--radius-md` items and one `--radius-full` item
will read as broken.

### Per-component overrides

Every component that cares about corners exposes a component-level token
(`--ren-card-radius`, `--ren-button-radius`, etc.) in
`tokens/component/tokens.css`. Override the token, not the component CSS.
This keeps the system consistent if someone later flips `--radius-md`
globally.

---

## Motion

Motion in RenDS is task-oriented: every transition carries information
about state change. Nothing animates for flavor. The semantic motion
tokens describe the *intent* (`enter`, `exit`, `state`, `tactile`,
`overlay`, `route`, `emphasize`), and each intent resolves to a
`(duration, easing)` pair that has already been tuned correctly.

### Durations by intent

- **micro** (50ms) — pixel-level feedback, e.g., opacity flicker on a
  pressed checkbox dot.
- **tactile** (100ms) — hover, focus, and press transitions. Fast enough
  to feel responsive; slow enough to avoid snapping.
- **state** (150ms) — on/off toggles, expanded/collapsed state,
  checked/unchecked. The "something changed" interval.
- **enter** (250ms) — widgets *arriving* on screen (dialog open, menu
  open, toast enter, popover open).
- **exit** (100ms) — widgets *leaving*. Exits are always faster than
  entrances — the UX rule of thumb is that leaving should feel like it
  took you at your word.
- **overlay** (250ms) — backdrops, scrims, dialog bodies composited with
  a dim layer.
- **route** (300ms) — page-level transitions (route changes in SPAs).
- **emphasize** (450ms) — hero / onboarding / attention-seeking flows.

### Easings by intent

- **enter** (`cubic-bezier(0, 0, 0.2, 1)`) — decelerate, for anything
  arriving at rest. Default for every open animation.
- **exit** (`cubic-bezier(0.4, 0, 1, 1)`) — accelerate, for anything
  leaving the viewport.
- **state-change** (`cubic-bezier(0.2, 0, 0, 1)`) — symmetric, for motion
  that happens in-flight between two on-screen states.
- **attention** (emphasized-decelerate) — slow start, crisp stop, for
  hero moments.
- **playful** (spring-soft `cubic-bezier(0.34, 1.2, 0.64, 1)`) — gentle
  overshoot. Use on primitives that benefit from a little personality
  (active checkbox dot, tag appearing).

### Presets

Ready-to-paste `transition:` values, composed from the intent tokens:

- `--transition-tactile` — color/bg/border/shadow/opacity at `tactile`.
- `--transition-state` — same plus `transform`, at `state`.
- `--transition-enter` — opacity/transform/filter at `enter` · use with
  `@starting-style` for open animations.
- `--transition-exit` — opacity/transform/filter at `exit`.
- `--transition-overlay` — opacity/bg/backdrop-filter/overlay/display at
  `overlay` with `allow-discrete` for discrete-property animation.
- `--transition-emphasize` — opacity/transform at `emphasize`.

Most of the time a component reaches for one of these presets rather than
composing a transition by hand.

### `@starting-style` and `allow-discrete`

Open animations (dialog, menu, popover, toast) use `@starting-style` to
animate *into* the rendered state, and `transition-behavior: allow-discrete`
so that `display: none → block` interpolates cleanly. Every RenDS overlay
composite uses this pattern; consumers can rely on it rather than
hand-rolling `requestAnimationFrame` hacks.

### Choreography

When multiple siblings enter at once (a grid of cards, a menu's items),
they stagger by `--motion-stagger-step` (40ms). The stagger caps at
`--motion-stagger-max` (10 items) to keep total delay bounded. Applied
either via the `.ren-stagger > *` utility or the `motion.js`
`staggerChildren()` helper.

### Reduced motion

Every primitive duration collapses to `0ms` under
`@media (prefers-reduced-motion: reduce)`, which cascades through every
semantic duration automatically. The semantic layer reapplies the same
collapse so both layers stay in sync, and `--motion-stagger-step` drops
to `0ms` so stagger also disappears. State changes still fire — an
accordion still opens, a dialog still appears — they just do so
instantly. No component has to opt in; reduced-motion is a system
property.

---

## Components

RenDS ships 53 components across three tiers. Every component is
accessible by default (keyboard, screen reader, reduced motion, 44px
touch), and every component consumes the token layer — swap a theme and
every component follows.

### Primitives (19)

Primitives are the smallest cohesive unit: a button, a badge, a field. They
compose freely and never require another primitive to be present. Most are
CSS-only; the handful with JS are there purely to wire ARIA relationships.

#### ren-avatar

Circular user picture with fallback initials and optional presence dot.
Ships six sizes (`xs` 24px → `2xl` 80px) matching the `--avatar-*` scale.
Place inside `ren-avatar-group` for overlap-and-pile clusters.

- **Variants:** solid, outline, gradient.
- **A11y:** decorative by default — provide `alt` on the inner image, or
  `aria-label` on the initials variant.

#### ren-badge

Small atomic label for status, category, or count. CSS-only. Pair with
text ("Active") or use the `.ren-badge-dot` variant for a pure indicator.

- **Variants:** `primary`, `secondary`, `success`, `warning`, `danger`,
  `info`, `outline`, `dot`.
- **A11y:** semantic `<span>`. Add `role="status"` if the badge
  communicates a live update (e.g., a notification count that changes).

#### ren-banner

Persistent inline message for alerts, status updates, and notifications.
Unlike toast, banners do not auto-dismiss — they occupy layout until the
user acknowledges them.

- **Variants:** `success`, `warning`, `danger`, `neutral`; plus
  `-compact`, `-full`, `-dismiss`, and `-actions` modifiers.
- **States:** `[data-dismissing]` plays the exit animation.
- **A11y:** `role="status"` for passive updates, `role="alert"` for
  urgent ones. The dismiss button carries `aria-label`.

#### ren-breadcrumb

Hierarchical navigation trail. Built on `<nav><ol>` so screen readers
announce it as a list. Default separator is `›` via the
`--breadcrumb-separator` variable (override to "/", "→", "·", etc.).

- **Special:** `.ren-breadcrumb-truncated` collapses middle items for
  deep paths.
- **A11y:** `aria-label="Breadcrumb"` on the `<nav>`, `aria-current="page"`
  on the last item.

#### ren-button

The workhorse. Seven variants × three sizes × loading/disabled states. Uses
`--size-md` (36px) by default and honors `--touch-min` (44px) at `lg`.

- **Variants:** `primary` (solid accent), `secondary` (filled gray),
  `ghost` (transparent + hover fill), `outline` (border only), `danger`,
  `link`, `accent`.
- **Sizes:** `sm` / `md` (default) / `lg`.
- **Special:** `.ren-btn-icon` (square icon-only), `.ren-btn-full`
  (100% width), `.ren-btn-group` (segmented).
- **States:** `disabled`, `[data-loading]` (sets `aria-busy=true` and
  shows spinner), `[aria-disabled="true"]`.
- **A11y:** visible focus ring, 44px touch hit area at all sizes,
  spinner announces loading state via `aria-busy`.

#### ren-card

Versatile container for grouped content. Supports header / body / footer
sections and accent-border status variants. The most-used primitive after
`ren-button`.

- **Variants:** `elevated` (shadow-md), `outline` (border only), `ghost`
  (no chrome), `sunken` (gray fill).
- **Special:** `.ren-card-interactive` (full hover lift),
  `.ren-card-selectable` (radio-behavior + aria-pressed),
  `.ren-card-simple` (minimal padding).
- **States:** `[data-status="success|warning|danger|info"]` adds a
  4px accent border on the leading edge.
- **A11y:** use real heading tags inside `.ren-card-header`. The whole
  card becomes a link via nested-anchor technique or full-card click
  handler.

#### ren-checkbox

Custom-styled checkbox built on native `<input type="checkbox">` — the
control is visually restyled while the input keeps all native semantics
(form submission, `:checked`, tab focus).

- **States:** `:checked`, `:indeterminate`, `:disabled`, `:focus-visible`.
- **A11y:** the native input is hidden via `position: absolute; opacity: 0`
  so screen readers still see it; the visible `.ren-checkbox-control` is
  pure decoration.

For instant on/off toggles (apply immediately, no submit), use the
sibling primitive `ren-switch` instead.

#### ren-switch

Toggle switch built on native `<input type="checkbox" role="switch">` with
a pill-shaped track and animated thumb. Same hidden-input technique as
`ren-checkbox`; the `role="switch"` announces toggle semantics to assistive
technology.

- **Use when:** the value takes effect immediately (settings, preferences).
  Use `ren-checkbox` when the value only applies on form submit.
- **States:** `:checked`, `:disabled`, `:focus-visible`, `:hover`, `:active`.
- **A11y:** `role="switch"` on the input; the label wrapper carries the full
  44px touch target via `min-height: var(--touch-min)`.

#### ren-field

Wrapper custom element that auto-wires the ARIA relationships between a
label, control, description, and error message. Write natural HTML inside
it and get `aria-labelledby`, `aria-describedby`, and `aria-errormessage`
assembled for you.

- **States:** `[data-invalid]`, `[data-valid]`, plus the native
  `:user-invalid` / `:user-valid` pseudos.
- **Sizes on inputs:** `.ren-input-sm`, `.ren-input-lg`.
- **Icons:** wrap the input in `.ren-input-wrapper` with a leading/trailing
  `.ren-input-icon`.
- **A11y:** auto-generated stable IDs via `utils/id-generator.js`;
  required state uses `aria-required`; disabled state uses
  `aria-disabled`.

#### ren-icon

Sizing, coloring, and animation utilities for any SVG. Ships as a CSS
class on a wrapper `<span>` (or applied to an inline `<svg>`).

- **Sizes:** `xs` / `sm` / `md` (default) / `lg` / `xl` / `2xl`.
- **Colors:** `primary`, `success`, `warning`, `danger`, `muted` —
  applied via `currentColor`.
- **Animation:** `.ren-icon-spin` (respects reduced motion).
- **A11y:** decorative by default (`aria-hidden="true"` recommended).
  Meaningful icons need an adjacent text label or `aria-label`.

#### ren-kbd

Keyboard shortcut badge. Styles `<kbd>` as a rounded chip with a slight
bevel to read like a physical key. Group consecutive keys with `+`
separators.

- **A11y:** preserves native `<kbd>` semantics — screen readers announce
  "keyboard input: Command K".

#### ren-link

Styled `<a>` with variants for different contexts.

- **Variants:** default (underlined accent), `muted` (secondary color),
  `plain` (no underline), `external` (trailing arrow + `rel="noopener"`),
  `nav` (for nav bars), `skip` (skip-to-main link).
- **A11y:** `aria-current="page"` for active nav link; skip link becomes
  visible on focus and is the first tab stop.

#### ren-pagination

Page navigation for long lists and tables. Hit-tested at 44px minimum.

- **Variants:** default (full numbered set), `simple` (prev + info +
  next only), `compact` (smaller targets), `centered`.
- **States:** `[aria-disabled="true"]` or `:disabled` on prev/next,
  `[aria-current="page"]` on the active number.
- **A11y:** `<nav aria-label="Pagination">`, real links on each page so
  the navigation is shareable, `aria-label` on prev/next.

#### ren-progress

Linear progress bar and meter. Use `<progress>` or `<meter>` semantically;
the CSS styles their tracks and fills.

- **Sizes:** default, `-sm`, `-lg`, `-xl`.
- **Variants:** `success`, `warning`, `danger`, `info` recolor the fill.
- **Special:** `.ren-progress-indeterminate` animates an indeterminate
  loading bar.
- **A11y:** `<progress value max>` is already perfect — screen readers
  announce the percentage. Provide a visible label via
  `.ren-progress-label`.

#### ren-radio

Radio button group with keyboard navigation (arrow keys, Home/End, roving
tabindex). Use the `ren-radio-group` custom element as the container; each
radio is a native `<input type="radio">` in a styled wrapper.

- **Attributes:** `orientation="vertical"` (default) or `"horizontal"`.
- **A11y:** `role="radiogroup"` on the container, arrow-key navigation
  auto-selects the focused radio, disabled radios skip in the roving
  cycle.

#### ren-separator

Thin horizontal or vertical divider. Pure CSS, no custom element — style
`<hr class="ren-separator">`.

- **Variants:** default, `-strong`, `-dashed`.
- **A11y:** native `<hr>` already announces as a separator. Use
  `aria-orientation="vertical"` on vertical separators inside
  toolbars / menubars.

#### ren-skeleton

Shimmer placeholder for loading states. Apply to any element to replace
its content with an animated gradient.

- **Shapes:** default rectangle, `.ren-skeleton-text` (pill), 
  `.ren-skeleton-circle` (avatar), `.ren-skeleton-line` (width + height
  props).
- **A11y:** announce the skeleton region as busy via `aria-busy="true"`
  on the parent container; drop a `sr-only` "Loading…" for screen
  readers.

#### ren-spinner

Circular indeterminate loading indicator. 1s infinite rotation, respects
reduced motion (falls back to a pulsing dot).

- **Sizes:** `sm`, `md` (default), `lg`.
- **Colors:** inherits `currentColor`; compose with `ren-icon-*` color
  modifiers.
- **A11y:** give it an `aria-label="Loading"` or pair with a visible
  status message in an `aria-live` region.

#### ren-tag

Interactive badge for selected items, filters, or categories. Unlike
`ren-badge`, tags are actionable — they can be clicked or dismissed.

- **Variants:** color (`primary` / `success` / `warning` / `danger`),
  size (`sm` / `lg`).
- **Special:** `.ren-tag-removable` includes a dismiss button,
  `.ren-tag-clickable` treats the tag as a button (`role="button"`,
  `tabindex="0"`).
- **States:** `[aria-pressed="true"]` or `[data-selected]` for selected
  filter chips.
- **A11y:** dismiss button needs `aria-label="Remove {tag name}"`.

### Composites (26)

Composites combine primitives with interaction logic — dialogs, menus,
tabs, date pickers. Most ship as custom elements (`<ren-dialog>`,
`<ren-menu>`) that wrap semantic HTML and layer behavior on top.

#### ren-accordion

CSS-first accordion built on native `<details><summary>`. Two modes:
`single` (only one item open at a time) and `multiple`.

- **Variants:** default, `.ren-accordion-bordered`, `.ren-accordion-flush`.
- **Attributes:** `type`, `collapsible`, `default-value`.
- **API:** `openItem(i)`, `closeItem(i)`, `toggleItem(i)`,
  `getOpenItems()`.
- **Events:** `ren-accordion-change`.
- **A11y:** native `<details>` semantics; keyboard Enter/Space on the
  summary toggles.

#### ren-alert-dialog

Non-dismissible confirmation dialog. Extends `ren-dialog` but blocks
click-outside and (optionally) Escape. Use for destructive confirmations
where the user must choose an action.

- **Differences from `ren-dialog`:** `alert` attribute, no click-outside
  dismissal, optional `no-escape` attribute.
- **A11y:** `role="alertdialog"`, focus trap, title + description wired
  via `aria-labelledby` / `aria-describedby`.

#### ren-calendar

Fully accessible calendar grid with single / range / multiple selection
modes. Keyboard-navigable via arrow keys; ARIA grid pattern.

- **Attributes:** `mode` (`single` / `range` / `multiple`), `format`,
  `min-date`, `max-date`.
- **API:** `getSelectedDate()`, `setSelectedDate(d)`.
- **A11y:** days are buttons inside `role="grid"`; today is announced
  via `aria-label`; outside-month days are `aria-disabled` and use
  `--color-text-faint` (decorative context).

#### ren-carousel

Scroll-snap carousel with keyboard nav, autoplay, loop, and pagination
dots. Built on `scroll-snap-type` for native-feel momentum on touch.

- **Attributes:** `autoplay` (ms), `loop`, `slides-per-view`, `fade`.
- **API:** `next()`, `prev()`, `goTo(i)`, `pause()`, `resume()`.
- **Events:** `ren-slide-change`.
- **A11y:** arrow key navigation, auto-pauses on hover/focus, dots are
  real buttons with `aria-label="Go to slide N"`.

#### ren-collapsible

Single-item CSS-only expand/collapse using `<details>`. For cases where
an accordion would be overkill.

- **A11y:** native.

#### ren-color-picker

HSV canvas + hue slider + alpha + swatches. Supports HEX / RGB / HSL
output formats and the EyeDropper API where available.

- **Attributes:** `value`, `alpha`, `format`, `swatches`.
- **API:** `getColor()`, `setColor(hex)`.
- **A11y:** keyboard-navigable canvas (arrow keys = pixel movement, Shift
  = large steps), all slider inputs are real `<input type="range">`.

#### ren-combobox

Searchable autocomplete input with filtering. Implements the ARIA
combobox pattern (1.2 variant — listbox popup).

- **A11y:** `role="combobox"` on the input, `role="listbox"` on the
  popup, `aria-activedescendant` for keyboard navigation without moving
  focus.

#### ren-context-menu

Right-click menu. Extends `ren-menu` with `contextmenu` event handling
and viewport-edge collision positioning.

- **Events:** `ren-context-menu-open { x, y, target }`.
- **A11y:** reachable via keyboard through the target's context menu
  key (Menu key or Shift+F10).

#### ren-date-picker

Date input with calendar popover. The field accepts typed input; the
calendar gives visual selection; both stay in sync.

- **Attributes:** `format`, `placeholder`, `min-date`, `max-date`.
- **API:** `open()`, `close()`, `getDate()`, `setDate(d)`.
- **A11y:** field is a native date-formatted input with validation; the
  popover uses Popover API so dismissal and focus restoration are
  native.

#### ren-date-range-picker

Two-calendar range selector. Start and end dates highlight with a
shaded span between them; keyboard and pointer both supported.

- **API:** `getRange()`, `setRange({ start, end })`.
- **A11y:** range is announced as "Start date: X, End date: Y" on
  selection change.

#### ren-dialog

Modal dialog using the native `<dialog>` element. Size variants, focus
trap, Escape dismissal, click-outside dismissal, mobile bottom-sheet
behavior.

- **Classes:** `.ren-dialog-sm / md / lg / xl / full`,
  `.ren-dialog-header / body / footer`.
- **API:** `show()`, `close()`, `isOpen`.
- **Events:** `ren-open`, `ren-close`.
- **A11y:** native `<dialog>` provides focus management; title and
  description wired via `aria-labelledby` / `aria-describedby`.

#### ren-dropzone

Drag-and-drop file upload area with visual feedback. Works with a hidden
`<input type="file">` as the fallback / click-to-browse path.

- **Events:** `ren-files-added { files }`.
- **A11y:** the file input remains keyboard-focusable; drag-over state
  announced via `aria-live`.

#### ren-hover-card

Rich preview card on hover. Positioned with CSS anchor positioning;
optional arrow indicator.

- **A11y:** delayed show/hide to avoid flicker; hidden from keyboard users
  by default (hover-only). For critical info, use `ren-popover` or
  `ren-tooltip` instead.

#### ren-menu

Dropdown menu with keyboard navigation, typeahead, checkbox / radio
items. Uses Popover API where available for native dismissal.

- **Classes:** `.ren-menu-item`, `.ren-menu-item-danger`,
  `.ren-menu-separator`, `.ren-menu-label`, `.ren-menu-icon`,
  `.ren-menu-shortcut`.
- **Attributes:** `placement` (top/bottom-start/end), `trigger-id`.
- **API:** `open()`, `close()`, `isOpen()`.
- **Events:** `ren-menu-select`, `ren-menu-open`, `ren-menu-close`.
- **A11y:** WAI-ARIA menu pattern, arrow-key nav, typeahead, Escape to
  close, returns focus to the trigger.

#### ren-number-field

Numeric input with increment/decrement buttons, min/max bounds, step.

- **API:** `value`, `increment()`, `decrement()`.
- **A11y:** input is `<input type="number">`; buttons are
  `aria-label`-ed "Increment" / "Decrement"; keyboard Up/Down also works
  on the input.

#### ren-otp

One-time password input with auto-advancing slots and clipboard paste.

- **Attributes:** `length`, `type` (`numeric` / `alphanumeric`),
  `disabled`.
- **API:** `getValue()`, `setValue(str)`, `clear()`.
- **A11y:** each slot is a real input with `autocomplete="one-time-code"`
  where supported; pasting a full code fills all slots.

#### ren-popover

Floating popover with CSS anchor positioning and automatic viewport
collision handling. Uses Popover API for native dismissal.

- **Classes:** `.ren-popover-arrow`, `.ren-popover-header`,
  `.ren-popover-body`, `.ren-popover-footer`.
- **Attributes:** `placement`, `offset`, `trigger-id`.
- **API:** `open()`, `close()`, `toggle()`, `isOpen()`.
- **Events:** `ren-open`, `ren-close`.
- **A11y:** click-outside dismissal, Escape key, `aria-modal`.

#### ren-scroll-area

Custom scrollbar styling with native scroll behavior. Does not
re-implement scrolling — keyboard, momentum, wheel, and touch all use
the browser's native scroll.

- **Variants:** `.ren-scroll-area-auto` (hide bar, show on scroll).
- **A11y:** native; bar is decorative.

#### ren-select

Custom select/dropdown with keyboard navigation, multi-select, and
option groups. Not a native `<select>` — rendered in markup so it can
show rich item content (icons, descriptions, shortcuts).

- **Classes:** `.ren-select-trigger`, `.ren-select-icon`,
  `.ren-select-item`, `.ren-select-label`, `.ren-select-separator`.
- **Attributes:** `placeholder`, `name`, `multiple`, `size` (`sm`/`lg`).
- **API:** `open()`, `close()`, `getValue()`, `setValue(val)`.
- **Events:** `ren-select-change`.
- **A11y:** `role="combobox"` + `role="listbox"`; typeahead; arrow keys
  navigate options; Enter selects; Escape closes and returns focus.

#### ren-sheet

Side / bottom sheet with slide-in animation and swipe-to-dismiss. Uses
Popover API fallback.

- **Attributes:** `data-side` (`left` / `right` / `top` / `bottom`),
  `data-size` (`sm` / `md` / `lg` / `xl` / `full`).
- **API:** `show()`, `close()`.
- **A11y:** focus trap, Escape dismissal, `aria-labelledby` /
  `aria-describedby` via the header/description slots.

#### ren-slider

Range input with styled track and thumb. Wraps a real
`<input type="range">`, so it remains a native form control.

- **Variants:** size (`sm` / `lg`), color (`success` / `warning` /
  `danger`), vertical orientation.
- **API:** `value` property.
- **Events:** `ren-slider-input`, `ren-slider-change`.
- **A11y:** native `<input type="range">` with `aria-valuenow` mirrored
  to the visible `.ren-slider-value` label; keyboard arrow keys step.

#### ren-tabs

Accessible tabbed interface. Arrow keys navigate; Home/End jump to first
/ last; `activation="manual"` (Enter/Space to activate) or `"automatic"`
(focus = active).

- **Variants:** `.ren-tab-list-underline` (default), `-pills`,
  `-enclosed`.
- **API:** `selectTabByIndex(i)`, `selectTabById(id)`, `selectedIndex`,
  `selectedTab`, `selectedPanel`.
- **Events:** `ren-tab-change`.
- **A11y:** WAI-ARIA tablist pattern with roving tabindex; panels have
  `role="tabpanel"` and `aria-labelledby` pointing to their tab.

#### ren-toast

Toast notification system. Imperative API (`toast(msg)`) creates a
viewport-positioned stack with auto-dismiss.

- **Types:** `success`, `error`, `warning`, `info`, `loading`.
- **Positions:** six (top/bottom × left/center/right).
- **A11y:** renders into a persistent `aria-live="polite"` region
  (or `assertive` for `error`); each toast has a real dismiss button.

#### ren-toggle-group

Grouped toggle buttons for single or multiple selection. Like radio
button group, but button-shaped.

- **Classes:** `.ren-toggle-group-item`, plus `-outline`, `-full`,
  `-vertical` modifiers.
- **Attributes:** `type` (`single` / `multiple`), `value`.
- **A11y:** roving tabindex, arrow-key nav, `aria-pressed` on each
  item (single mode uses `role="radiogroup"` + `aria-checked`).

#### ren-toolbar

Grouped actions with roving tabindex and arrow-key keyboard nav.
Separators break the group visually and in screen reader announcements.

- **Classes:** `.ren-toolbar-item`, `.ren-toolbar-separator`.
- **A11y:** WAI-ARIA toolbar pattern, arrow keys, Home/End.

#### ren-tooltip

Lightweight tooltip with hover / focus triggers and configurable delay.

- **Attributes:** `placement`, `show-delay`, `hide-delay`, `offset`.
- **API:** `show()`, `hide()`, `isOpen()`.
- **A11y:** appears on hover *and* focus; auto-hides on blur /
  mouseleave; long-press on touch. Tooltip content is wired to the
  trigger via `aria-describedby`. Do not use a tooltip for
  essential-to-read content — tooltips are progressive enhancement.

### Patterns (8)

Patterns are page-scale organisms: nav bars, sidebars, tables, full
forms. They bring layout responsibilities of their own (responsive
breakpoints, container queries, mobile overlays) and compose multiple
composites and primitives inside.

#### ren-ai

CSS-only visual patterns for AI-generated content, inspired by IBM
Carbon for AI. Provides the vocabulary — slug, message bubble, streaming
cursor, typing dots, citation marker, sources list, confidence bar,
skeleton — without prescribing a chat framework.

- **Classes:** `.ren-ai-slug`, `.ren-ai-message`, `.ren-ai-streaming`,
  `.ren-ai-typing`, `.ren-ai-citation`, `.ren-ai-sources`,
  `.ren-ai-confidence` (with `data-level="high|medium|low"`),
  `.ren-ai-prompt`, `.ren-ai-skeleton`.
- **A11y:** every animated element respects reduced motion. The slug
  reads as "AI"; citations link to their source. The streaming cursor
  should be paired with an `aria-live` region that gets the finished
  text, not the character-by-character stream.

#### ren-command

Command palette / Spotlight. Built on `<dialog>` with real-time
filtering, keyboard navigation, groups, empty state, and a global
keyboard shortcut.

- **Classes:** `.ren-command`, `.ren-command-input-wrapper`,
  `.ren-command-input`, `.ren-command-list`, `.ren-command-group`
  (hides with `data-empty`), `.ren-command-item` (highlighted via
  `data-highlighted`), `.ren-command-empty`.
- **Attributes:** `data-shortcut` (e.g., `ctrl+k`).
- **API:** `open()`, `close()`, `registerAction(name, fn)`.
- **Events:** `ren-command-select { item, value, action }`.
- **A11y:** focus moves to the input on open; arrow keys walk the
  (flat, across groups) item list; Escape closes and returns focus;
  selection is announced by the input's aria-activedescendant.

#### ren-empty-state

Reusable layout for empty / no-data states. CSS-only.

- **Classes:** `.ren-empty-state`, `.ren-empty-state-icon`,
  `.ren-empty-state-title`, `.ren-empty-state-description`,
  `.ren-empty-state-actions`; plus `-compact` and `-bordered`
  modifiers.
- **A11y:** semantic heading inside the pattern (H2 or H3 depending on
  page context). Text is load-bearing — do not communicate the empty
  state through icon alone.

#### ren-form

Multi-step form with validation. Supports field-level rules, error
summaries, step progress, and server-side error injection.

- **Classes:** `.ren-form`, `.ren-form-section`, `.ren-form-row`
  (responsive field columns), `.ren-form-step` (`data-active`,
  `data-completed`, `data-disabled`), `.ren-form-error-summary`
  (shows with `data-has-errors`), `.ren-form-actions`.
- **Validation rules:** `required`, `email`, `min:N`, `max:N`,
  `pattern:/regex/`, `match:fieldName`, plus user-registered custom
  rules via `RenForm.registerValidator(name, fn)`.
- **API:** `validate()`, `getValues()`, `reset()`, `setErrors(...)`,
  `setFieldError(name, message)`, `nextStep()`, `prevStep()`.
- **Events:** `ren-submit`, `ren-invalid`, `ren-step-change`,
  `ren-field-validated`.
- **A11y:** `aria-invalid` on bad fields, `aria-errormessage` linking
  to the error text, required fields marked with `data-required`, the
  error summary receives focus on validation failure so screen readers
  announce it and keyboard users land where they need to be.

#### ren-menubar

Desktop-style horizontal menu bar (File / Edit / View). Implements the
full WAI-ARIA menubar pattern with submenus, checkbox / radio items,
separators, labels, typeahead, and keyboard shortcuts display.

- **Classes:** `.ren-menubar`, `.ren-menubar-trigger`,
  `.ren-menubar-menu` (uses `hidden` attribute), `.ren-menubar-item`,
  `.ren-menubar-separator`, `.ren-menubar-label`,
  `.ren-menubar-submenu` (with chevron), `.ren-menubar-checkbox`
  (with `aria-checked`), `.ren-menubar-radio`.
- **API:** `closeAll()`, `openMenu(triggerIndex)`.
- **Events:** `ren-menubar-select { item, value, checked? }`.
- **Keyboard:** arrow Right/Left between triggers, arrow Down/Up inside
  menus, Enter/Space to activate, Escape to close, Home/End to jump,
  typeahead to land on an item by first letter.
- **A11y:** full menubar pattern; `aria-expanded` on triggers,
  `aria-checked` on checkbox/radio items; roving focus with visible
  indicators.

#### ren-nav

Responsive top navigation bar with mobile hamburger menu, dropdown
support, and sticky / transparent variants.

- **Classes:** `.ren-nav`, `.ren-nav-sticky` (sticky + backdrop blur),
  `.ren-nav-transparent`, `.ren-nav-links`, `.ren-nav-link`,
  `.ren-nav-dropdown`, `.ren-nav-brand`, `.ren-nav-actions`,
  `.ren-nav-toggle` (hamburger).
- **API:** `setActiveLink(href)`, `isOpen` getter.
- **Keyboard:** Escape closes mobile menu; Tab navigates.
- **A11y:** `aria-expanded` on the toggle, `aria-current="page"` on
  the active link, skip-link friendly (nav is not trapped in focus
  order).

#### ren-sidebar

App-shell sidebar with desktop collapse, mobile overlay, state
persistence, and tooltips when collapsed.

- **Classes:** `.ren-sidebar` (uses `data-collapsed`, `data-open`),
  `.ren-sidebar-header`, `.ren-sidebar-content`, `.ren-sidebar-nav`,
  `.ren-sidebar-item`, `.ren-sidebar-item-icon`,
  `.ren-sidebar-item-text`, `.ren-sidebar-section-label`,
  `.ren-sidebar-overlay`, `.ren-sidebar-toggle`.
- **API:** `toggleMenu()`, `setActiveItem(href)`, `openMobileMenu()`,
  `isCollapsed` getter.
- **Events:** `ren-sidebar-toggle { collapsed }`.
- **Keyboard:** Escape closes mobile overlay.
- **A11y:** `aria-current="page"` on the active item; when collapsed,
  each item exposes its label via `data-tooltip` → `ren-tooltip` so
  the sidebar remains usable.

#### ren-table

Data table with sorting, row selection, column resize, pagination, and
density variants. The heaviest pattern in the system.

- **Classes:** `.ren-table`, `.ren-table-wrapper`, `.ren-table-toolbar`,
  `.ren-table-header`, `.ren-th`, `.ren-th-sortable` (gets
  `data-sort="asc|desc"`), `.ren-table-body`, `.ren-tr` (gets
  `aria-selected`), `.ren-td`, `.ren-table-select` (checkbox column),
  `.ren-table-pagination`; plus `-compact` and `-comfortable` density
  modifiers.
- **API:** `setLoading(bool)` (shimmer overlay), `setDensity(mode)`,
  `getSelectedRows()`, `clearSelection()`, `filter(colIdx, value)`.
- **Events:** `ren-sort`, `ren-select`, `ren-page`, `ren-filter`.
- **Keyboard:** arrow keys navigate cells, Space toggles row selection,
  Shift+Click range-selects, Enter on a sortable header sorts.
- **A11y:** `aria-sort` on sortable headers, `aria-selected` on rows,
  checkbox column has a clear `aria-label` on the select-all
  checkbox.

---

## Do's and Don'ts

Short, prescriptive rules distilled from the sections above. An agent
generating RenDS UI should treat these as non-negotiable.

### Tokens

**Do** reach for the semantic layer (`--color-accent`, `--space-card-padding`,
`--duration-enter`). Those names exist so components don't have to make
the decision twice.

**Do** add a new semantic token when no existing name fits. Prefer
extending the semantic layer over hard-coding a primitive value.

**Don't** consume primitive tokens directly in a component (`--blue-600`,
`--space-4`, `--duration-normal`). Primitives are the palette the
semantic layer paints from — they are not the API surface for
components.

**Don't** hard-code hex, rem, or px values inside a component stylesheet.
If a value is worth writing, it's worth naming.

### Colors

**Do** use `--color-border-interactive` on any control that's actionable
(inputs, unchecked checkboxes, toggle-outline buttons). It's the only
border token guaranteed to pass WCAG 1.4.11 ≥3:1.

**Do** use `--color-*-strong` for accent/status *text* on neutral
surfaces. The base variants are tuned as *backgrounds*, not as
foreground text on white.

**Do** place `--color-on-X` text on `--color-X` backgrounds. Those pairs
are contrast-tuned together.

**Don't** use `--color-text-faint` for anything the user is supposed to
read. It's for breadcrumb separators, outside-month calendar dates, and
placeholder hints only — reserved as the WCAG-exempt incidental ramp.

**Don't** write `.dark { ... }` overrides. Use `light-dark()` in the
semantic layer instead.

**Don't** invent a new accent color per feature. Lean on the status
family (success/warning/danger/info) or the AI family (`ren-ai`) before
reaching for a primitive hue.

### Typography

**Do** compose from the semantic roles (`body`, `title-md`, `label`,
etc.). Each bundles the right weight, leading, and tracking.

**Do** keep body text at `--body-size` (16px). The browser default
exists for a reason, and accessibility audits check it.

**Don't** drop below `--text-xs` (11px) anywhere in the UI. Apple HIG's
minimum is audited against iOS; the web equivalent is the same rule.

**Don't** pick sizes from the primitive scale in component CSS. Use the
role tokens so density and fluid-type overrides cascade.

### Layout

**Do** snap to the 8pt grid. All spacing should be a multiple of 8 (or a
half-step of 4 for tight groups).

**Do** prefer container queries over viewport media queries when
responsive behavior depends on the component's own width.

**Do** honor the 44px minimum touch target, even when the visual control
is smaller — use padding or `::before` to expand the hit area.

**Don't** use `margin` between siblings when a parent `gap` would do.
Gap respects flow direction and RTL automatically.

**Don't** set fixed widths on text containers unless you're constraining
to `--width-prose` (65ch).

### Elevation & Shapes

**Do** communicate depth with the shadow ladder, not with surface color.
The system is tuned to stay legible in dark mode via the shadow-color
decomposition — you get depth for free.

**Do** match radius to control size. Small controls get `--radius-sm`
(4px); cards get `--radius-lg` (12px); sheets get `--radius-2xl` (20px).

**Don't** mix pill and non-pill shapes within the same component family.
Button groups with one round and three square items look broken.

**Don't** set `z-index` to an arbitrary number. Pick the closest
`--z-*` token — modal, popover, tooltip, etc.

### Motion

**Do** use the semantic durations (`--duration-enter`, `--duration-exit`,
`--duration-tactile`, `--duration-state`). They encode the exit-faster-
than-enter rule.

**Do** pair durations with their matching easing: enter → `--ease-enter`
(decelerate), exit → `--ease-exit` (accelerate), state → `--ease-state-change`
(standard).

**Do** animate via the preset `--transition-*` variables when you need
a composed value — `--transition-tactile`, `--transition-state`,
`--transition-enter`, `--transition-exit`, `--transition-overlay`.

**Don't** write raw durations or easings in a component. The primitive
values exist to back the semantic layer, not for component consumption.

**Don't** add animation that doesn't communicate a state change.
Decorative motion is noise; every transition should answer "what just
became true?".

### Accessibility

**Do** use `:focus-visible`, never `:focus`. Mouse users don't need to
see the ring.

**Do** wire ARIA relationships via `aria-labelledby` /
`aria-describedby` / `aria-errormessage`. Let `ren-field` do this for
forms — it already handles ID generation.

**Do** label every icon-only button with `aria-label`.

**Do** announce dynamic content via `aria-live` — errors, toasts, loading
completions. Polite by default, assertive only when the user must stop
and attend.

**Don't** trap keyboard focus outside of modal dialogs and sheets. Every
other component should let Tab walk out of it.

**Don't** convey state through color alone. Pair danger red with an
icon, a `danger` label, or `aria-invalid`. Same for success green.

**Don't** use `autofocus` outside of modal dialogs. It fights with
screen readers and keyboard users on normal pages.

### Theming

**Do** set themes with `<html data-theme="name">` and honor
`color-scheme: light dark` on `:root`.

**Do** use the `rends/create` generator to derive an AA-safe theme from
a brand hex. It handles strong-variant tuning automatically.

**Don't** override semantic tokens inside a component — override at
`:root` or at a theme scope so every component adopts the change.

**Don't** ship multiple theme CSS files to the browser. RenDS expects one
active theme at a time via `[data-theme]`.

### Reduced motion

**Do** rely on the token-level collapse — every `--duration-*` becomes
`0ms` under `prefers-reduced-motion`, and every component that uses
semantic durations inherits the behavior.

**Don't** add `@media (prefers-reduced-motion: reduce) { animation: none }`
as a patch. If the motion token system is being bypassed, fix that
instead.

### Web components

**Do** keep markup in light DOM. Style components with the host's own
CSS; don't tuck interaction-critical structure inside Shadow DOM.

**Do** expose state via attributes (`data-state`, `aria-expanded`,
`data-loading`) so consumers can style and query without JS.

**Don't** require JS for presentation. Every RenDS component works
visually with JS disabled — behaviors layer on top, they don't gate the
paint.

### Astro integration

**Do** use `@ren10/astro` for Astro 7 projects. Import production components
from direct subpaths such as `@ren10/astro/components/Button`; the generated
wrappers preserve canonical Light DOM markup, CSS, and vanilla behavior.

**Do** use `@ren10/astro/catalog.json` or `npx ren10 manifest --json` for
machine discovery. Read the catalog's colocated contract before composing a
component.

**Don't** recreate RenDS components as framework islands. Astro is a build-time
delivery layer here, not a replacement component API. See `docs/astro.md`.


