/**
 * Non-side-effect class export for `<color-picker-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/color-picker`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <color-picker-ui value="#3b82f6" format="hex"></color-picker-ui>
 *
 * OKLCH-native color picker with 2D area + H/C/L sliders.
 *
 * Layout:
 *   [═══════════ color area (chroma x lightness) ═══════════]
 *   H [────────────── slider ──────────────] 260
 *   C [────────────── slider ──────────────] 0.188
 *   L [────────────── slider ──────────────] 0.623
 *   [───────────────── hue track ──────────────────]
 *   oklch(0.62 0.188 260)                        [copy]
 *   #3b82f6                                      [copy]
 *
 * Dogfoods: slider-ui (H/C/L channels)
 * Form-associated via UIFormElement + ElementInternals.
 */

import { UIFormElement } from '../../core/form.js';

// ── OKLCH ↔ sRGB conversion ──────────────────────────────

const MAX_CHROMA = 0.4;

function oklchToOklab(L, C, H) {
  const hRad = H * Math.PI / 180;
  return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
}

function oklabToLinearSrgb(L, a, b) {
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  const l = l_ * l_ * l_;
  const m = m_ * m_ * m_;
  const s = s_ * s_ * s_;
  return [
    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  ];
}

function linearToSrgb(c) {
  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
}

function srgbToLinear(c) {
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

function oklchToRgb(L, C, H) {
  const { a, b } = oklchToOklab(L, C, H);
  const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
  return [
    Math.max(0, Math.min(1, linearToSrgb(lr))),
    Math.max(0, Math.min(1, linearToSrgb(lg))),
    Math.max(0, Math.min(1, linearToSrgb(lb))),
  ];
}

function rgbToHex(r, g, b) {
  const h = c => Math.round(c * 255).toString(16).padStart(2, '0');
  return `#${h(r)}${h(g)}${h(b)}`;
}

function oklchToHex(L, C, H) {
  const [r, g, b] = oklchToRgb(L, C, H);
  return rgbToHex(r, g, b);
}

function hexToOklch(hex) {
  hex = hex.replace('#', '');
  if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
  const r = srgbToLinear(parseInt(hex.slice(0,2),16)/255);
  const g = srgbToLinear(parseInt(hex.slice(2,4),16)/255);
  const b = srgbToLinear(parseInt(hex.slice(4,6),16)/255);
  const l_ = Math.cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b);
  const m_ = Math.cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b);
  const s_ = Math.cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b);
  const L = 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_;
  const a = 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_;
  const bv = 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_;
  const C = Math.sqrt(a*a + bv*bv);
  let H = Math.atan2(bv, a) * 180 / Math.PI;
  if (H < 0) H += 360;
  return { L, C, H };
}

function isInGamut(r, g, b) {
  const e = 0.001;
  return r >= -e && r <= 1+e && g >= -e && g <= 1+e && b >= -e && b <= 1+e;
}

function gamutMapChroma(L, C, H) {
  const { a, b } = oklchToOklab(L, C, H);
  const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
  if (isInGamut(linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb))) return C;
  let lo = 0, hi = C;
  for (let i = 0; i < 8; i++) {
    const mid = (lo + hi) / 2;
    const { a: ma, b: mb } = oklchToOklab(L, mid, H);
    const [mr, mg, mbb] = oklabToLinearSrgb(L, ma, mb);
    if (isInGamut(linearToSrgb(mr), linearToSrgb(mg), linearToSrgb(mbb))) lo = mid;
    else hi = mid;
  }
  return lo;
}

// ── Component ────────────────────────────────────────────

export class UIColorPicker extends UIFormElement {
  // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
  // consumer markup). Aggregated by installIconLoadersForRegistered()
  // across all defined elements. Audited by check-required-icons.mjs
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
  static requiredIcons = ['copy', 'check', 'warning'];

  // §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
  // input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
  // + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
  // FEEDBACK-14 §2 (co-credited).
  static #warnedBadParse = new WeakSet();

  static properties = {
    ...UIFormElement.properties,
    value:    { type: String,  default: '#3b82f6', reflect: true },
    format:   { type: String,  default: 'hex',     reflect: true },
    disabled: { type: Boolean, default: false,      reflect: true },
    /**
     * v0.4.9 §99h — generation-constraint props (FEEDBACK-02 #7).
     * When set, the picker clamps OKLCH channels to the consumer's
     * declared bounds before committing. Out-of-bound mutations
     * round-trip to the nearest in-bound equivalent + fire a
     * `constraint-clamp` event with `{ axis, requested, clamped, reason }`
     * so the consumer can surface UX feedback (toast, validity warning,
     * scope-drift indicator). Default values disable the constraint.
     */
    maxChroma:    { type: Number, default: Infinity, reflect: true, attribute: 'max-chroma' },
    maxL:         { type: Number, default: 1,        reflect: true, attribute: 'max-l' },
    minL:         { type: Number, default: 0,        reflect: true, attribute: 'min-l' },
    /** Maximum allowed hue deviation in degrees from the picker's `baseHue`. NaN = no constraint. */
    hueDriftMax:  { type: Number, default: NaN, reflect: true, attribute: 'hue-drift-max' },
    /** Reference hue (degrees) for [hue-drift-max]. NaN = use the value parsed at first commit. */
    baseHue:      { type: Number, default: NaN, reflect: true, attribute: 'base-hue' },
  };

  #L = 0.6; #C = 0.15; #H = 230;
  /** Cached reference hue for hue-drift constraint. Set on first commit if [base-hue] isn't authored. */
  #resolvedBaseHue = NaN;
  #bound = false;
  #dragging = null; // 'area' | 'hue' | null
  #internalUpdate = false;

  // DOM refs
  #canvas = null;
  #ctx = null;
  #areaEl = null;
  #areaThumb = null;
  #hueTrack = null;
  #hueThumb = null;
  #oklchVal = null;
  #hexVal = null;
  #areaResizeObs = null;
  #hueSlider = null;
  #chromaSlider = null;
  #lightnessSlider = null;
  #copyButtons = []; // [{ el, handler }]
  #copyTimer = null;

  static template = () => null;

  // ── Stable listeners ──

  #onSliderInput = (e) => {
    const slider = e.currentTarget;
    const channel = slider.getAttribute('data-channel');
    if (channel === 'hue') this.#H = +slider.value;
    else if (channel === 'chroma') this.#C = +slider.value;
    else if (channel === 'lightness') this.#L = +slider.value;
    this.#commit('input');
  };

  #onAreaPointerDown = (e) => {
    if (this.disabled) return;
    this.#dragging = 'area';
    this.#areaEl.setPointerCapture(e.pointerId);
    this.#updateAreaFromPointer(e);
  };

  #onAreaPointerMove = (e) => {
    if (this.#dragging === 'area') this.#updateAreaFromPointer(e);
  };

  #onAreaPointerUp = () => {
    if (this.#dragging === 'area') { this.#dragging = null; this.#commit('change'); }
  };

  #onHuePointerDown = (e) => {
    if (this.disabled) return;
    this.#dragging = 'hue';
    this.#hueTrack.setPointerCapture(e.pointerId);
    this.#updateHueFromPointer(e);
  };

  #onHuePointerMove = (e) => {
    if (this.#dragging === 'hue') this.#updateHueFromPointer(e);
  };

  #onHuePointerUp = () => {
    if (this.#dragging === 'hue') { this.#dragging = null; this.#commit('change'); }
  };

  #onAreaKeydown = (e) => {
    if (this.disabled) return;
    const step = e.shiftKey ? 0.05 : 0.01;
    const cStep = e.shiftKey ? 0.04 : 0.004;
    switch (e.key) {
      case 'ArrowRight': e.preventDefault(); this.#C = Math.min(MAX_CHROMA, this.#C + cStep); break;
      case 'ArrowLeft':  e.preventDefault(); this.#C = Math.max(0, this.#C - cStep); break;
      case 'ArrowUp':    e.preventDefault(); this.#L = Math.min(1, this.#L + step); break;
      case 'ArrowDown':  e.preventDefault(); this.#L = Math.max(0, this.#L - step); break;
      default: return;
    }
    this.#commit('change');
  };

  #onHueKeydown = (e) => {
    if (this.disabled) return;
    const step = e.shiftKey ? 10 : 1;
    switch (e.key) {
      case 'ArrowRight': case 'ArrowUp':   e.preventDefault(); this.#H = (this.#H + step) % 360; break;
      case 'ArrowLeft':  case 'ArrowDown':  e.preventDefault(); this.#H = (this.#H - step + 360) % 360; break;
      default: return;
    }
    this.#commit('change');
  };

  connected() {
    super.connected();
    this.#parseValue(this.value);

    if (this.#bound) return;
    this.#bound = true;

    // ── Build DOM ──

    // Color area
    this.#areaEl = document.createElement('div');
    this.#areaEl.setAttribute('data-area', '');
    this.#areaEl.setAttribute('tabindex', '0');
    this.#areaEl.setAttribute('role', 'slider');
    this.#areaEl.setAttribute('aria-label', 'Color area: chroma and lightness');

    this.#canvas = document.createElement('canvas');
    this.#canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:inherit;';
    this.#areaEl.appendChild(this.#canvas);
    this.#ctx = this.#canvas.getContext('2d', { willReadFrequently: true });

    this.#areaThumb = document.createElement('div');
    this.#areaThumb.setAttribute('data-area-thumb', '');
    this.#areaEl.appendChild(this.#areaThumb);

    this.appendChild(this.#areaEl);

    // Slider rows
    const sliders = document.createElement('div');
    sliders.setAttribute('data-sliders', '');

    const makeSlider = (label, channel, min, max, step) => {
      const slider = document.createElement('slider-ui');
      slider.setAttribute('data-channel', channel);
      slider.setAttribute('label', label);
      slider.setAttribute('min', min);
      slider.setAttribute('max', max);
      slider.setAttribute('step', step);

      slider.addEventListener('input', this.#onSliderInput);

      sliders.appendChild(slider);
      return slider;
    };

    this.#hueSlider = makeSlider('H', 'hue', 0, 360, 1);
    this.#chromaSlider = makeSlider('C', 'chroma', 0, MAX_CHROMA, 0.001);
    this.#lightnessSlider = makeSlider('L', 'lightness', 0, 1, 0.001);
    this.appendChild(sliders);

    // Hue gradient track
    this.#hueTrack = document.createElement('div');
    this.#hueTrack.setAttribute('data-hue-track', '');
    this.#hueTrack.setAttribute('tabindex', '0');
    this.#hueTrack.setAttribute('role', 'slider');
    this.#hueTrack.setAttribute('aria-label', 'Hue');
    this.#hueThumb = document.createElement('div');
    this.#hueThumb.setAttribute('data-hue-thumb', '');
    this.#hueTrack.appendChild(this.#hueThumb);
    this.appendChild(this.#hueTrack);

    // Output rows
    const output = document.createElement('div');
    output.setAttribute('data-output', '');

    const makeOutput = (format) => {
      const group = document.createElement('div');
      group.setAttribute('data-output-group', '');

      const val = document.createElement('span');
      val.setAttribute('data-output-value', '');
      val.setAttribute('data-format', format);

      const btn = document.createElement('div');
      btn.setAttribute('role', 'button');
      btn.setAttribute('tabindex', '0');
      btn.setAttribute('data-copy', format);
      btn.setAttribute('aria-label', `Copy ${format}`);
      btn.innerHTML = '<icon-ui name="copy"></icon-ui>';
      const handler = () => this.#copy(format, btn);
      btn.addEventListener('click', handler);
      this.#copyButtons.push({ el: btn, handler });

      group.appendChild(val);
      group.appendChild(btn);
      output.appendChild(group);
      return val;
    };

    this.#oklchVal = makeOutput('oklch');
    this.#hexVal = makeOutput('hex');
    this.appendChild(output);

    // ── Area drag ──

    this.#areaEl.addEventListener('pointerdown', this.#onAreaPointerDown);
    this.#areaEl.addEventListener('pointermove', this.#onAreaPointerMove);
    this.#areaEl.addEventListener('pointerup', this.#onAreaPointerUp);

    // ── Hue track drag ──

    this.#hueTrack.addEventListener('pointerdown', this.#onHuePointerDown);
    this.#hueTrack.addEventListener('pointermove', this.#onHuePointerMove);
    this.#hueTrack.addEventListener('pointerup', this.#onHuePointerUp);

    // ── Keyboard on area ──

    this.#areaEl.addEventListener('keydown', this.#onAreaKeydown);

    // ── Keyboard on hue ──

    this.#hueTrack.addEventListener('keydown', this.#onHueKeydown);

    // Observe area resize to redraw canvas
    this.#areaResizeObs = new ResizeObserver(() => this.#drawArea());
    this.#areaResizeObs.observe(this.#areaEl);
  }

  disconnected() {
    super.disconnected();
    if (this.#copyTimer != null) {
      clearTimeout(this.#copyTimer);
      this.#copyTimer = null;
    }
    if (this.#areaResizeObs) {
      this.#areaResizeObs.disconnect();
      this.#areaResizeObs = null;
    }
    if (this.#areaEl) {
      this.#areaEl.removeEventListener('pointerdown', this.#onAreaPointerDown);
      this.#areaEl.removeEventListener('pointermove', this.#onAreaPointerMove);
      this.#areaEl.removeEventListener('pointerup', this.#onAreaPointerUp);
      this.#areaEl.removeEventListener('keydown', this.#onAreaKeydown);
    }
    if (this.#hueTrack) {
      this.#hueTrack.removeEventListener('pointerdown', this.#onHuePointerDown);
      this.#hueTrack.removeEventListener('pointermove', this.#onHuePointerMove);
      this.#hueTrack.removeEventListener('pointerup', this.#onHuePointerUp);
      this.#hueTrack.removeEventListener('keydown', this.#onHueKeydown);
    }
    this.#hueSlider?.removeEventListener('input', this.#onSliderInput);
    this.#chromaSlider?.removeEventListener('input', this.#onSliderInput);
    this.#lightnessSlider?.removeEventListener('input', this.#onSliderInput);
    for (const { el, handler } of this.#copyButtons) {
      el.removeEventListener('click', handler);
    }
    this.#copyButtons = [];
    this.#bound = false;
  }

  // ── Pointer helpers ──

  #updateAreaFromPointer(e) {
    const rect = this.#areaEl.getBoundingClientRect();
    const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
    this.#C = x * MAX_CHROMA;
    this.#L = 1 - y;
    this.#commit('input');
  }

  #updateHueFromPointer(e) {
    const rect = this.#hueTrack.getBoundingClientRect();
    const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    this.#H = x * 360;
    this.#commit('input');
  }

  // ── Value parsing ──

  #parseValue(val) {
    if (!val) return;
    if (val.startsWith('#')) {
      const o = hexToOklch(val);
      this.#L = o.L; this.#C = o.C; this.#H = o.H;
      return;
    }
    if (val.startsWith('oklch(')) {
      // §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
      // on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
      // powerless-component syntax). Channel-level normalization: NaN/none → 0;
      // percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
      // (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
      // is the spec-defined resolution when chroma is 0 or near-0.
      const m = val.match(
        /oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
      );
      if (m) {
        const parseChan = (s, isL) => {
          if (s === 'none' || /^NaN$/i.test(s)) return 0;
          if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
          if (s.endsWith('%')) return +s.slice(0, -1) / 100;
          return +s;
        };
        this.#L = parseChan(m[1], true);
        this.#C = parseChan(m[2], false);
        this.#H = parseChan(m[3], false);
        return;
      }
      // No regex match — warn once per element so consumers can correct.
      // WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
      if (!UIColorPicker.#warnedBadParse.has(this)) {
        UIColorPicker.#warnedBadParse.add(this);
        // eslint-disable-next-line no-console
        console.warn(
          `<color-picker-ui>: could not parse value=${JSON.stringify(val)}. ` +
          `Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
          `powerless), or % channels. Picker is keeping prior state.`,
        );
      }
    }
  }

  // ── Commit: clamp to consumer constraints + gamut-map + update value + fire events ──

  #commit(eventType) {
    // Apply consumer-declared constraints BEFORE gamut mapping. Each constraint
    // axis records a `constraint-clamp` event-payload entry when it actually
    // moved a value; we dispatch one event per commit so the consumer can
    // surface a single UX response (toast / validity warning / etc.).
    const clamps = [];
    if (this.#L > this.maxL) {
      clamps.push({ axis: 'l', requested: this.#L, clamped: this.maxL, reason: 'max-l' });
      this.#L = this.maxL;
    }
    if (this.#L < this.minL) {
      clamps.push({ axis: 'l', requested: this.#L, clamped: this.minL, reason: 'min-l' });
      this.#L = this.minL;
    }
    if (this.#C > this.maxChroma) {
      clamps.push({ axis: 'c', requested: this.#C, clamped: this.maxChroma, reason: 'max-chroma' });
      this.#C = this.maxChroma;
    }
    if (Number.isFinite(this.hueDriftMax)) {
      const base = Number.isFinite(this.baseHue)
        ? this.baseHue
        : (Number.isFinite(this.#resolvedBaseHue) ? this.#resolvedBaseHue : this.#H);
      if (!Number.isFinite(this.#resolvedBaseHue)) this.#resolvedBaseHue = base;
      // Hue is circular — find the signed shortest-path drift in [-180, 180].
      let drift = ((this.#H - base + 540) % 360) - 180;
      const limit = this.hueDriftMax;
      if (drift > limit) {
        const requestedH = this.#H;
        this.#H = (base + limit + 360) % 360;
        clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
      } else if (drift < -limit) {
        const requestedH = this.#H;
        this.#H = (base - limit + 360) % 360;
        clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
      }
    }

    const clampedC = gamutMapChroma(this.#L, this.#C, this.#H);
    const hexVal = oklchToHex(this.#L, clampedC, this.#H);
    const oklchStr = this.#oklchStr();

    this.#internalUpdate = true;
    this.value = this.format === 'oklch' ? oklchStr : hexVal;
    this.syncValue();

    if (clamps.length) {
      this.dispatchEvent(new CustomEvent('constraint-clamp', { bubbles: true, detail: { clamps } }));
    }

    const detail = { value: this.value, l: this.#L, c: this.#C, h: this.#H, hex: hexVal, oklch: oklchStr };
    this.dispatchEvent(new CustomEvent(eventType, { bubbles: true, detail }));
  }

  #oklchStr() {
    return `oklch(${this.#L.toFixed(2)} ${this.#C.toFixed(3)} ${Math.round(this.#H)})`;
  }

  // ── Copy ──

  #copy(fmt, btn) {
    const text = fmt === 'oklch'
      ? this.#oklchStr()
      : oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);

    const iconEl = btn.querySelector('icon-ui');
    const onDone = (ok) => {
      if (iconEl) {
        iconEl.setAttribute('name', ok ? 'check' : 'warning');
        if (this.#copyTimer != null) clearTimeout(this.#copyTimer);
        this.#copyTimer = setTimeout(() => {
          this.#copyTimer = null;
          iconEl.setAttribute('name', 'copy');
        }, 1500);
      }
    };

    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(text).then(
        () => { if (!this.isConnected) return; onDone(true); },
        () => { if (!this.isConnected) return; onDone(false); },
      );
    } else {
      const ta = Object.assign(document.createElement('textarea'), { value: text });
      ta.style.cssText = 'position:fixed;opacity:0';
      document.body.appendChild(ta);
      ta.select();
      onDone(document.execCommand('copy'));
      ta.remove();
    }
  }

  // ── Canvas rendering ──

  #drawArea() {
    if (!this.#canvas || !this.#ctx || !this.#areaEl) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2) * 0.5;
    const w = Math.round(this.#areaEl.clientWidth * dpr);
    const h = Math.round(this.#areaEl.clientHeight * dpr);
    if (w <= 0 || h <= 0) return;

    this.#canvas.width = w;
    this.#canvas.height = h;

    const img = this.#ctx.createImageData(w, h);
    const data = img.data;
    const hue = this.#H;

    for (let y = 0; y < h; y++) {
      const L = 1 - y / (h - 1);
      for (let x = 0; x < w; x++) {
        const C = (x / (w - 1)) * MAX_CHROMA;
        const clampedC = gamutMapChroma(L, C, hue);
        const [r, g, b] = oklchToRgb(L, clampedC, hue);
        const i = (y * w + x) * 4;
        data[i]     = Math.round(r * 255);
        data[i + 1] = Math.round(g * 255);
        data[i + 2] = Math.round(b * 255);
        data[i + 3] = 255;
      }
    }

    this.#ctx.putImageData(img, 0, 0);
  }

  // ── Render ──

  render() {
    if (this.#internalUpdate) {
      this.#internalUpdate = false;
    } else {
      this.#parseValue(this.value);
    }

    this.#drawArea();

    // Area thumb
    if (this.#areaThumb) {
      const xPct = (this.#C / MAX_CHROMA) * 100;
      const yPct = (1 - this.#L) * 100;
      this.#areaThumb.style.left = `${xPct}%`;
      this.#areaThumb.style.top = `${yPct}%`;
      this.#areaThumb.style.background = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
    }

    // Hue track gradient
    if (this.#hueTrack) {
      const stops = [];
      for (let h = 0; h <= 360; h += 30) stops.push(oklchToHex(0.7, 0.15, h));
      this.#hueTrack.style.background = `linear-gradient(to right, ${stops.join(', ')})`;
      this.#hueTrack.setAttribute('aria-valuenow', String(Math.round(this.#H)));
    }

    // Hue thumb
    if (this.#hueThumb) {
      this.#hueThumb.style.left = `${(this.#H / 360) * 100}%`;
      this.#hueThumb.style.background = oklchToHex(0.7, 0.15, this.#H);
    }

    // ARIA
    if (this.#areaEl) {
      this.#areaEl.setAttribute('aria-valuetext', this.#oklchStr());
    }

    // Slider values
    const hueSlider = this.querySelector('slider-ui[data-channel="hue"]');
    const chromaSlider = this.querySelector('slider-ui[data-channel="chroma"]');
    const lightnessSlider = this.querySelector('slider-ui[data-channel="lightness"]');
    if (hueSlider) hueSlider.value = Math.round(this.#H);
    if (chromaSlider) chromaSlider.value = +this.#C.toFixed(3);
    if (lightnessSlider) lightnessSlider.value = +this.#L.toFixed(3);

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

    // Output values
    const hex = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
    if (this.#oklchVal) this.#oklchVal.textContent = this.#oklchStr();
    if (this.#hexVal) this.#hexVal.textContent = hex;
  }

  onFormReset() {
    this.value = '#3b82f6';
  }
}
