/**
 * Non-side-effect class export for `<swatch-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/swatch`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <swatch-ui> — color / style sample primitive.
 *
 * A small inline tile representing one visual sample — a chart series's
 * legend marker, a design-token step in a color or spacing scale, a
 * theme-preview pill. Replaces docs-only `<span data-swatch>` usage and
 * the per-shape CSS that used to live inside chart-legend-ui.
 *
 * v0.4.9 extends the primitive with the design-token-tile use case
 * (FEEDBACK-04 #2): badge marker, second-line detail, copy-to-clipboard
 * button, keyboard-selectable + reflected `[selected]` state.
 *
 * Usage:
 *   <swatch-ui shape="dot" color="var(--a-data-0)" label="Revenue"></swatch-ui>
 *   <swatch-ui shape="block" size="lg" color="var(--a-neutral-50)" label="50"></swatch-ui>
 *   <swatch-ui shape="line" color="#0ea5e9">Forecast</swatch-ui>
 *
 *   <!-- design-token tile with full feature set -->
 *   <swatch-ui
 *     shape="block" size="lg"
 *     color="oklch(0.53 0.18 240)"
 *     label="500"
 *     detail="oklch(0.53 0.18 240)"
 *     badge="apca-pass"
 *     copyable
 *     selectable
 *   ></swatch-ui>
 *
 * Attributes:
 *   shape       block | dot | square | line | dashed                (default square)
 *   size        sm | md | lg                                         (default md)
 *   color       any CSS color (or var() ref)
 *   label       text label rendered beside / below the swatch
 *   detail      secondary line of text (e.g. raw token value)
 *   badge       out-of-gamut | p3-only | apca-pass | apca-fail       (optional marker)
 *   copyable    boolean — render an inline copy button
 *   copy-value  string — value to copy (defaults to [color] when copyable)
 *   selectable  boolean — make the swatch focusable + clickable
 *   selected    boolean — reflected visual selected state
 *
 * Events:
 *   select      dispatched when a selectable swatch is activated
 *                 (click / Enter / Space). detail: { value, color, label }.
 *
 * Slots:
 *   default     rich label content; takes precedence over [label] attr
 *
 * Stamping is light-DOM with up to five children:
 *   <span data-tile></span>            colored shape
 *   <span data-badge></span>           gamut / contrast marker (optional)
 *   <span data-label></span>           label text
 *   <span data-detail></span>          secondary line (optional)
 *   <button data-copy></button>        copy-to-clipboard (optional)
 *
 * Accessibility:
 *   - When [selectable], host is role="button" + tabindex="0";
 *     Enter / Space activate.
 *   - When [selected], aria-pressed="true" reflects the state.
 *   - The badge has aria-label (mapped per variant) so screen readers
 *     surface "out of gamut", "P3-only", etc.
 */

import { UIElement } from '../../core/element.js';

const SHAPES = new Set(['block', 'dot', 'square', 'line', 'dashed']);
const SIZES = new Set(['sm', 'md', 'lg']);
const BADGE_VARIANTS = new Set(['out-of-gamut', 'p3-only', 'apca-pass', 'apca-fail']);

const BADGE_SYMBOLS = {
  'out-of-gamut': '△',
  'p3-only':      '✦',
  'apca-pass':    '✓',
  'apca-fail':    '✗',
};

const BADGE_LABELS = {
  'out-of-gamut': 'Outside sRGB gamut',
  'p3-only':      'Display-P3 only',
  'apca-pass':    'Contrast passes APCA',
  'apca-fail':    'Contrast fails APCA',
};

/**
 * Parse the [badge] attribute into a normalized array. Supports:
 *   badge="apca-pass"                  → ['apca-pass']
 *   badge="out-of-gamut, apca-fail"    → ['out-of-gamut', 'apca-fail']
 *   badge="out-of-gamut apca-fail"     → ['out-of-gamut', 'apca-fail']
 *   badge=""                           → []
 * Drops unknown variants silently (consumer-typed-by-attr defense).
 */
function parseBadges(value) {
  if (!value) return [];
  return value
    .split(/[\s,]+/)
    .map((s) => s.trim())
    .filter((s) => s && BADGE_VARIANTS.has(s));
}

/**
 * Compute perceived lightness (OKLCH L, 0–1) of a CSS color string. Returns
 * null when the color isn't a hex / oklch / rgb / named CSS color that we
 * can parse. Used to pick a light vs dark label color for auto-contrast.
 *
 * Strategy: render the color into a temporary canvas, read the sRGB pixel,
 * convert to linear-light sRGB → OKLab L. This is ~3x cheaper than parsing
 * the CSS color string ourselves and handles every CSS color form the
 * browser supports (oklch, hsl, named, hex, currentColor via fallback).
 */
let _lumProbeCtx = null;
function colorLuminance(cssColor) {
  if (!cssColor || typeof cssColor !== 'string') return null;
  if (typeof document === 'undefined') return null;
  try {
    if (!_lumProbeCtx) {
      const canvas = document.createElement('canvas');
      canvas.width = 1;
      canvas.height = 1;
      _lumProbeCtx = canvas.getContext('2d');
    }
    _lumProbeCtx.fillStyle = '#000';
    _lumProbeCtx.clearRect(0, 0, 1, 1);
    _lumProbeCtx.fillStyle = cssColor;
    _lumProbeCtx.fillRect(0, 0, 1, 1);
    const [r, g, b] = _lumProbeCtx.getImageData(0, 0, 1, 1).data;
    if (r === 0 && g === 0 && b === 0 && cssColor !== '#000' && cssColor !== 'black' && cssColor !== '#000000') {
      // Canvas rejected the color string (e.g. var() that doesn't resolve in this context).
      return null;
    }
    // sRGB → linear-light, then OKLab L (Björn Ottosson's approximation).
    const lin = (c) => {
      const x = c / 255;
      return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
    };
    const lr = lin(r), lg = lin(g), lb = lin(b);
    const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
    const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
    const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
    return 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
  } catch {
    return null;
  }
}

export class UISwatch extends UIElement {
  static properties = {
    shape:        { type: String,  default: 'square', reflect: true },
    size:         { type: String,  default: 'md',     reflect: true },
    color:        { type: String,  default: '',       reflect: true },
    label:        { type: String,  default: '',       reflect: true },
    detail:       { type: String,  default: '',       reflect: true },
    badge:        { type: String,  default: '',       reflect: true },
    copyable:     { type: Boolean, default: false,    reflect: true },
    copyValue:    { type: String,  default: '' },  // not reflected — long color strings as attrs read poorly
    selectable:   { type: Boolean, default: false,    reflect: true },
    selected:     { type: Boolean, default: false,    reflect: true },
    autoContrast: { type: Boolean, default: false,    reflect: true },
    // §253 (v0.5.12, FB-23 §1): label position relative to the tile.
    // `below` (default) = current shape="block" stacked layout.
    // `overlay` = label sits ON the tile via absolute positioning;
    // pair with `[auto-contrast]` for OKLab-L-driven legibility.
    // Only meaningful when shape="block"; other shapes ignore this prop.
    // CSS-only behavior; no JS runtime delta beyond yaml-declared
    // reflective attribute. Unblocks Tokens Studio C1.3 dogfood.
    labelPosition: { type: String, default: 'below', reflect: true, attribute: 'label-position' },
  };

  static template = () => null;

  #tileEl = null;
  #labelEl = null;
  #detailEl = null;
  #badgeEl = null;
  #copyEl = null;
  #stamped = false;
  #lastColorForContrast = null;

  // Bound handlers — held so we can remove them in disconnected().
  #onHostClick = null;
  #onHostKey = null;
  #onCopyClick = null;
  #copyResetTimer = null;
  #chromeObserver = null;

  connected() {
    this.#stamp();
    this.#wireInteraction();
    // §-TBD (v0.5.19, FB-44): observe late-arriving slot="chrome" children
    // and re-run #absorbChromeSlot(). The parent template's update() walks
    // attribute/property parts BEFORE child node parts (per core/template.js
    // scan(): TreeWalker visits element.attributes before descending), so
    // property assignments fire render() → #syncCore() → #absorbChromeSlot()
    // while chrome interpolations are still empty placeholder text nodes.
    // The once-only absorb pass therefore sees an empty set and the chrome
    // — arriving moments later — sits at its arrival position rather than
    // the canonical post-tile region. MutationObserver closes this gap for
    // every arrival path: single-template ordering skew, separate render
    // cycle, or imperative host.appendChild().
    this.#chromeObserver = new MutationObserver((mutations) => {
      let hasChrome = false;
      for (const m of mutations) {
        for (const n of m.addedNodes) {
          if (n.nodeType !== 1) continue;
          // Direct chrome child OR a display:contents wrapper-span carrying chrome.
          if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
          if (
            n.tagName === 'SPAN' &&
            n.style?.display === 'contents' &&
            n.querySelector?.('[slot="chrome"]')
          ) { hasChrome = true; break; }
        }
        if (hasChrome) break;
      }
      if (!hasChrome) return;
      // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
      // generates childList mutations on the host. Without this guard, every
      // absorb pass would re-trigger the observer indefinitely. takeRecords()
      // drains the queue without dispatch so the next observation cycle starts
      // clean.
      this.#absorbChromeSlot();
      this.#chromeObserver.takeRecords();
    });
    this.#chromeObserver.observe(this, { childList: true });
  }

  render() {
    this.#stamp();
    this.#syncCore();
    this.#syncBadge();
    this.#syncDetail();
    this.#syncCopy();
    this.#syncSelectable();
    this.#syncAutoContrast();
  }

  // ── Stamping ──────────────────────────────────────────────────────

  #stamp() {
    if (this.#stamped) return;

    // v0.5.13 §-TBD (FB-34 §1) — `[slot="chrome"]` children are sibling-of-tile
    // chrome (gamut badges, override/tracked dots, custom indicators) that must
    // survive stamping as direct host siblings, NOT funnel into #labelEl where
    // absolute-positioning anchors against the label text bbox. Pull them out
    // FIRST so the innerHTML='' wipe below doesn't lose them.
    //
    // §331 (v0.5.16, FB-39): also walk `display: contents` wrapper spans
    // inserted by AdiaUI's `html\`\`` template engine (`core/template.js`
    // `wrap()` at line 212). When the parent template interpolates child
    // fragments via `${badgeFrag}` / `${dotFrag}`, each fragment lands
    // INSIDE a wrapper span that's a direct child of <swatch-ui>; the
    // chrome elements end up as GRANDCHILDREN, not direct children. The
    // pre-§331 direct-children-only filter found `chromeSlot = []` →
    // innerHTML='' wiped the wrapper spans + their chrome content →
    // `#absorbChromeSlot()` in `#syncCore()` had nothing left to absorb.
    // Deep-walk via querySelectorAll catches both shapes:
    //   - static-HTML / pre-template: direct-child <span slot="chrome">
    //   - template-render: <span slot="chrome"> nested in wrapper span
    const chromeSlot = Array.from(this.children).filter(
      n => n.getAttribute && n.getAttribute('slot') === 'chrome'
    );
    for (const n of chromeSlot) n.remove();

    // §341 (v0.5.x, FB-43): preserve display:contents wrapper-spans that
    // already contain chrome at #stamp() time (rare — typically when a
    // setup script pre-built the subtree before connect). AdiaUI's `html\`\``
    // template engine wraps each `${...}` interpolation in a `<span
    // style="display: contents">` (per `core/template.js:212` `wrap()`);
    // re-renders update wrapper contents via `part.n`. Detach + re-attach
    // at canonical position so they survive innerHTML='' below.
    const chromeWrappers = Array.from(this.children).filter(n =>
      n.tagName === 'SPAN' &&
      n.style?.display === 'contents' &&
      n.querySelector?.('[slot="chrome"]')
    );
    for (const w of chromeWrappers) w.remove();

    // §341 (FB-43): preserve template-engine text-node + comment
    // placeholders. AdiaUI's `scan()` (template.js:113) replaces
    // `<!--p:N-->` comments with empty text nodes; `wrap()` later
    // creates `<span style="display: contents">` and replaces the text
    // node in-place via `replaceWith(part.n, wrapper)`. For that
    // replaceWith to work, the text node MUST still be attached when
    // parent's update() runs (it runs AFTER our connectedCallback
    // synchronously, so AFTER #stamp()). Pre-§341, innerHTML='' below
    // wiped the text node → replaceWith no-op → wrapper detached →
    // chrome stamped into a detached subtree (never reaches DOM).
    //
    // Fix: extract empty text + comment nodes before the wipe, re-attach
    // at the canonical chrome region (between tile and badge). Parent's
    // update() then replaces them with live wrapper-spans in place;
    // subsequent re-renders update the wrappers in-place too.
    const placeholders = [];
    for (const n of Array.from(this.childNodes)) {
      if ((n.nodeType === 3 && !n.data) || n.nodeType === 8) {
        placeholders.push(n);
      }
    }
    for (const p of placeholders) p.remove();

    // Capture pre-existing default-slot content so consumer-authored
    // children (e.g. <swatch-ui>Forecast</swatch-ui>) survive stamping.
    // `[slot="chrome"]` children are already removed above, so won't appear here.
    //
    // §325 (v0.5.16, FB-36): filter pure-whitespace text nodes from the
    // capture. Pre-§325, indented multi-line HTML (`<swatch-ui
    // label="500">\n  <span>tip</span>\n</swatch-ui>`) captured the
    // leading whitespace text node, which then made #labelEl.firstChild
    // a nodeType=3 text node. #syncCore()'s "stale-text-node" branch
    // matched the whitespace + did textContent = this.label, wiping
    // BOTH the whitespace AND the consumer's <span>. Filter here so
    // #labelEl.firstChild is the actual element/non-whitespace text,
    // and #syncCore's stale-text branch only fires for genuinely
    // attr-driven cases.
    const slotted = Array.from(this.childNodes).filter(n =>
      !(n.nodeType === 1 && n.dataset && (
        n.dataset.tile !== undefined ||
        n.dataset.label !== undefined ||
        n.dataset.detail !== undefined ||
        n.dataset.badge !== undefined ||
        n.dataset.copy !== undefined
      )) && !(n.nodeType === 3 && !n.textContent.trim())
    );
    this.innerHTML = '';

    this.#tileEl = document.createElement('span');
    this.#tileEl.setAttribute('data-tile', '');
    this.#tileEl.setAttribute('aria-hidden', 'true');
    this.appendChild(this.#tileEl);

    // Chrome-slot children re-attach IMMEDIATELY after the tile so they can
    // position absolutely against the host (host carries position: relative
    // when shape="block" + label-position="overlay" per swatch.css). Keeping
    // them sibling-of-tile (not descendant-of-tile) lets consumer CSS use
    // `position: absolute; top: 2px; right: 2px` and anchor against the host's
    // padding-box, which matches the tile's geometry in the block layout.
    for (const n of chromeSlot) this.appendChild(n);
    // §341 (FB-43): re-attach chrome-bearing wrapper-spans at canonical
    // position. Wrapper preserves the template-engine's `part.n`
    // reference so parent re-renders can update chrome content in-place
    // without detaching the wrapper.
    for (const w of chromeWrappers) this.appendChild(w);
    // §341 (FB-43): re-attach template-engine placeholders at canonical
    // chrome region. Parent's pending update() will replace each
    // placeholder with a live wrapper-span (via `wrap(part)`'s
    // `replaceWith`), landing chrome content visually between tile +
    // badge. Without this re-attach, the placeholders would have been
    // wiped by `innerHTML = ''` and the parent's `replaceWith` would
    // no-op against a detached node.
    for (const p of placeholders) this.appendChild(p);

    // Badge container — holds one or more <span data-badge-variant="..."> children.
    // Multi-badge support added in v0.4.9 §92 (FEEDBACK-04 follow-up).
    this.#badgeEl = document.createElement('span');
    this.#badgeEl.setAttribute('data-badge', '');
    this.#badgeEl.setAttribute('hidden', '');
    this.appendChild(this.#badgeEl);

    this.#labelEl = document.createElement('span');
    this.#labelEl.setAttribute('data-label', '');
    if (slotted.length) {
      for (const n of slotted) this.#labelEl.appendChild(n);
    }
    this.appendChild(this.#labelEl);

    this.#detailEl = document.createElement('span');
    this.#detailEl.setAttribute('data-detail', '');
    this.#detailEl.setAttribute('hidden', '');
    this.appendChild(this.#detailEl);

    this.#copyEl = document.createElement('button');
    this.#copyEl.setAttribute('data-copy', '');
    this.#copyEl.setAttribute('type', 'button');
    this.#copyEl.setAttribute('aria-label', 'Copy value');
    this.#copyEl.setAttribute('hidden', '');
    this.#copyEl.textContent = '⧉';
    this.appendChild(this.#copyEl);

    this.#stamped = true;
  }

  // ── Sync ──────────────────────────────────────────────────────────

  /**
   * §326 (v0.5.16, FB-37): Late-arriving `[slot="chrome"]` children may
   * land on the host AFTER `#stamp()` runs — happens in any template
   * engine (lit-html, AdiaUI `html\`\``, etc.) that batches child appends
   * separately from element creation. `#stamp()` is gated by
   * `this.#stamped` and won't re-run; this method picks up the late
   * arrivals on every `render()` pass + moves them to the canonical
   * sibling-of-tile position (between `#tileEl` and `#badgeEl`).
   *
   * Idempotent: chrome children already in the right slot are left
   * alone (the `insertBefore` only moves them if they're not the
   * #badgeEl's immediate previousSibling chain).
   */
  #absorbChromeSlot() {
    if (!this.#tileEl || !this.#badgeEl) return;
    const chromeChildren = [];
    // wrapper-trap-ok: swatch chrome-slot children are always a single literal element (e.g. a selected-state checkmark); interpolated chrome collections are not a documented pattern
    for (const child of this.children) {
      if (child === this.#tileEl) continue;
      if (child === this.#badgeEl) break; // hit the badge — done scanning the head region
      if (child.getAttribute?.('slot') === 'chrome') chromeChildren.push(child);
    }
    // Walk the rest of the children looking for chrome children that landed
    // AFTER the internal structure (the common template-late-append case).
    for (const child of Array.from(this.children)) {
      if (
        child !== this.#tileEl &&
        child !== this.#badgeEl &&
        child !== this.#labelEl &&
        child !== this.#detailEl &&
        child !== this.#copyEl &&
        child.getAttribute?.('slot') === 'chrome'
      ) {
        // Move to the canonical position: immediately after #tileEl.
        // insertBefore is idempotent — moving an already-positioned child
        // to the same spot is a no-op.
        this.insertBefore(child, this.#badgeEl);
      }
    }
    // §331 (v0.5.16, FB-39): handle wrapper-spanned chrome children that
    // arrive AFTER #stamp() (parent template's late update() pass).
    //
    // §341 (FB-43) refinement: move the WRAPPER to canonical position
    // rather than hoisting chrome OUT of it. The wrapper is the parent
    // template engine's `part.n` reference; if we extract chrome, the
    // next parent re-render lands new chrome inside the now-empty
    // wrapper at the wrapper's original (non-canonical) position. By
    // moving the wrapper itself to canonical position, future parent
    // updates land at the right visual location automatically.
    // Wrapper is `display: contents` → no layout box → chrome anchors
    // against the swatch's padding-box correctly.
    for (const child of Array.from(this.children)) {
      if (
        child === this.#tileEl ||
        child === this.#badgeEl ||
        child === this.#labelEl ||
        child === this.#detailEl ||
        child === this.#copyEl
      ) continue;
      // Only move display:contents wrappers that actually carry chrome.
      const isWrapper = child.tagName === 'SPAN' && child.style?.display === 'contents';
      if (!isWrapper) continue;
      const hasNested = child.querySelector?.('[slot="chrome"]');
      if (!hasNested) continue;
      this.insertBefore(child, this.#badgeEl);
    }
  }

  #syncCore() {
    if (!this.#tileEl || !this.#labelEl) return;

    // §326 (v0.5.16, FB-37): pick up `[slot="chrome"]` children that
    // landed after #stamp() ran (template-render path).
    this.#absorbChromeSlot();

    // Normalize enum values; fall back silently when a consumer sets a typo.
    const shape = SHAPES.has(this.shape) ? this.shape : 'square';
    const size  = SIZES.has(this.size)   ? this.size  : 'md';
    if (shape !== this.shape) this.setAttribute('shape', shape);
    if (size  !== this.size)  this.setAttribute('size', size);

    // Color: only write the inline custom property when [color] is set
    // explicitly; otherwise leave whatever the consumer wrote via
    // style="--swatch-color: …" alone.
    if (this.color) {
      this.style.setProperty('--swatch-color', this.color);
    }

    // Label: prefer slotted content (already in #labelEl from #stamp);
    // when label-attr is set and the slot is empty, write the attribute.
    if (!this.#labelEl.firstChild && this.label) {
      this.#labelEl.textContent = this.label;
    } else if (this.#labelEl.firstChild?.nodeType === 3 && this.label) {
      // Pure-text node from a previous label-attr render — keep it
      // synced to the current attr value.
      this.#labelEl.textContent = this.label;
    }

    // Hide the label box when there's no content. Empty-but-stamped span
    // would otherwise eat the layout gap.
    const hasLabel = this.#labelEl.textContent.trim().length > 0
      || (this.#labelEl.firstElementChild != null);
    this.#labelEl.toggleAttribute('hidden', !hasLabel);
  }

  #syncBadge() {
    if (!this.#badgeEl) return;
    const badges = parseBadges(this.badge);
    if (badges.length === 0) {
      this.#badgeEl.setAttribute('hidden', '');
      this.#badgeEl.replaceChildren();
      return;
    }
    this.#badgeEl.removeAttribute('hidden');
    // Diff against existing children — replace only if the variant set changed.
    const declared = Array.from(this.#badgeEl.children).map((c) => c.dataset.badgeVariant);
    const same = declared.length === badges.length && declared.every((v, i) => v === badges[i]);
    if (same) return;
    const frag = document.createDocumentFragment();
    for (const variant of badges) {
      const pip = document.createElement('span');
      pip.setAttribute('data-badge-variant', variant);
      pip.setAttribute('role', 'img');
      pip.setAttribute('aria-label', BADGE_LABELS[variant]);
      pip.textContent = BADGE_SYMBOLS[variant];
      frag.appendChild(pip);
    }
    this.#badgeEl.replaceChildren(frag);
  }

  /**
   * Auto-contrast — switch the label color between light/dark when the
   * resolved swatch color crosses an OKLab L threshold. Uses a 1px canvas
   * probe to handle any CSS color form (oklch / hex / hsl / named / var()).
   * Pre-resolves currentColor by way of computed style so consumers can
   * pass var() refs and get correct math.
   */
  #syncAutoContrast() {
    if (!this.#labelEl) return;
    if (!this.autoContrast) {
      this.#labelEl.removeAttribute('data-on-light');
      this.#labelEl.removeAttribute('data-on-dark');
      this.#lastColorForContrast = null;
      return;
    }
    // Resolve the actual rendered color. For var(...) refs the inline
    // [color] attr is what was set; we read from computed style so that
    // CSS-resolved tokens (e.g. var(--a-accent)) get their final value.
    const probe = this.color || getComputedStyle(this).getPropertyValue('--swatch-color').trim();
    if (probe === this.#lastColorForContrast) return;
    this.#lastColorForContrast = probe;
    const L = colorLuminance(probe);
    if (L == null) {
      this.#labelEl.removeAttribute('data-on-light');
      this.#labelEl.removeAttribute('data-on-dark');
      return;
    }
    // Threshold ~0.62 OKLab L matches WCAG-aligned light/dark text choice
    // for sRGB. Adjust by --swatch-auto-contrast-threshold if needed.
    const onDark = L < 0.62;
    this.#labelEl.toggleAttribute('data-on-dark', onDark);
    this.#labelEl.toggleAttribute('data-on-light', !onDark);
  }

  #syncDetail() {
    if (!this.#detailEl) return;
    const text = (this.detail || '').trim();
    if (!text) {
      this.#detailEl.setAttribute('hidden', '');
      this.#detailEl.textContent = '';
      return;
    }
    this.#detailEl.removeAttribute('hidden');
    if (this.#detailEl.textContent !== text) this.#detailEl.textContent = text;
  }

  #syncCopy() {
    if (!this.#copyEl) return;
    if (!this.copyable) {
      this.#copyEl.setAttribute('hidden', '');
      return;
    }
    this.#copyEl.removeAttribute('hidden');
    // aria-label reflects what will be copied so screen readers
    // announce the target value.
    const value = this.#copyTargetValue();
    if (value) this.#copyEl.setAttribute('aria-label', `Copy ${value}`);
    else this.#copyEl.setAttribute('aria-label', 'Copy value');
  }

  #syncSelectable() {
    if (this.selectable) {
      if (!this.hasAttribute('role')) this.setAttribute('role', 'button');
      if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
      this.setAttribute('aria-pressed', this.selected ? 'true' : 'false');
    } else {
      // Only remove ARIA we set; respect consumer-set roles.
      if (this.getAttribute('role') === 'button') this.removeAttribute('role');
      if (this.getAttribute('tabindex') === '0') this.removeAttribute('tabindex');
      this.removeAttribute('aria-pressed');
    }
  }

  // ── Interaction wiring ────────────────────────────────────────────

  #wireInteraction() {
    this.#onHostClick = (e) => {
      if (!this.selectable) return;
      // Ignore clicks that originated in the copy button — that's a
      // separate affordance and should not also toggle selection.
      const path = e.composedPath ? e.composedPath() : [];
      if (path.includes(this.#copyEl)) return;
      this.#activate();
    };
    this.#onHostKey = (e) => {
      if (!this.selectable) return;
      if (e.key !== 'Enter' && e.key !== ' ') return;
      // Don't steal Space when focus is in the copy button.
      if (e.target === this.#copyEl) return;
      e.preventDefault();
      this.#activate();
    };
    this.addEventListener('click', this.#onHostClick);
    this.addEventListener('keydown', this.#onHostKey);

    this.#onCopyClick = (e) => {
      e.stopPropagation();
      if (!this.copyable) return;
      const value = this.#copyTargetValue();
      if (!value) return;
      this.#doCopy(value);
    };
    this.#copyEl.addEventListener('click', this.#onCopyClick);
  }

  #activate() {
    this.dispatchEvent(new CustomEvent('select', {
      bubbles: true,
      detail: {
        value: this.color || this.label,
        color: this.color,
        label: this.label,
      },
    }));
  }

  #copyTargetValue() {
    if (this.copyValue) return this.copyValue;
    if (this.color) return this.color;
    return '';
  }

  async #doCopy(value) {
    let ok = false;
    try {
      if (navigator?.clipboard?.writeText) {
        await navigator.clipboard.writeText(value);
        ok = true;
      }
    } catch { /* ignore — feedback below */ }
    this.#flashCopyResult(ok);
  }

  #flashCopyResult(ok) {
    if (!this.#copyEl) return;
    if (this.#copyResetTimer != null) clearTimeout(this.#copyResetTimer);
    this.#copyEl.setAttribute('data-copy-state', ok ? 'ok' : 'fail');
    this.#copyEl.textContent = ok ? '✓' : '⚠';
    this.#copyResetTimer = setTimeout(() => {
      if (!this.#copyEl) return;
      this.#copyEl.removeAttribute('data-copy-state');
      this.#copyEl.textContent = '⧉';
      this.#copyResetTimer = null;
    }, 1200);
  }

  // ── Teardown ──────────────────────────────────────────────────────

  disconnected() {
    if (this.#copyResetTimer != null) {
      clearTimeout(this.#copyResetTimer);
      this.#copyResetTimer = null;
    }
    if (this.#chromeObserver) {
      this.#chromeObserver.disconnect();
      this.#chromeObserver = null;
    }
    if (this.#onHostClick) this.removeEventListener('click', this.#onHostClick);
    if (this.#onHostKey)   this.removeEventListener('keydown', this.#onHostKey);
    if (this.#copyEl && this.#onCopyClick) this.#copyEl.removeEventListener('click', this.#onCopyClick);
    this.#tileEl = null;
    this.#labelEl = null;
    this.#detailEl = null;
    this.#badgeEl = null;
    this.#copyEl = null;
    this.#stamped = false;
  }
}
