🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

Autodraw Script

Enjoy, and farewell drawaria!

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         Autodraw Script
// @namespace    http://tampermonkey.net/
// @version      2025-11-07
// @description  Enjoy, and farewell drawaria!
// @license MIT
// @author       Toluwerr
// @match        https://drawaria.online/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=drawaria.online
// @grant        none
// ==/UserScript==


(() => {
  const SCRIPT_HANDLE = '__drawariaImageAutodraw';
  const MAX_COLOUR_CAPACITY = 1300;

  if (window[SCRIPT_HANDLE]?.cleanup) {
    try {
      window[SCRIPT_HANDLE].cleanup();
    } catch (err) {
      console.warn('drawaria image autodraw: cleanup error from previous run', err);
    }
  }

  const state = {
    running: false,
    abortRequested: false,
    prepared: false,
    previewDataUrl: null,
    pixelWidth: 0,
    pixelHeight: 0,
    palette: [],
    paletteUsage: [],
    assignments: null,
    sourceImageData: null,
    paletteSortMode: 'dark-first',
    selection: null,
    settings: {
      smoothnessPercent: 40,
      laneFanMultiplier: 100,
      coverageBoost: 100,
      detailMode: 'balanced',
      lowResEnhancer: true,
      edgeDetail: true,
      microDetail: true,
      autoStart: false,
      ditherStrength: 100,
      adaptiveTheme: true,
      spectralBoost: 120,
      highlightGlaze: true,
      textureWeave: false,
      gradientEcho: true,
    },
    metrics: {
      pixelCount: 0,
      paletteCount: 0,
      estimatedStrokes: 0,
      estimatedDurationMs: 0,
      laneCount: 0,
      scaleFactor: 1,
      boardWidth: 0,
      boardHeight: 0,
      targetWidth: 0,
      targetHeight: 0,
      selectionActive: false,
    },
    commandCache: null,
  };

  const funState = {
    running: false,
    abortRequested: false,
    pointerId: 8807,
    activeFeature: null,
  };

  const funSettings = {
    density: 70,
    tempo: 60,
    mirror: true,
    jitter: true,
  };

  const cleanupCallbacks = [];

  const wsBridge = installSocketBridge();
  cleanupCallbacks.push(() => wsBridge.release());

  function registerCleanup(fn) {
    cleanupCallbacks.push(fn);
  }

  function runCleanup() {
    while (cleanupCallbacks.length) {
      const fn = cleanupCallbacks.pop();
      try {
        fn();
      } catch (err) {
        console.warn('drawaria image autodraw: cleanup callback failed', err);
      }
    }
    delete window[SCRIPT_HANDLE];
  }

  function hexToRgb(hex) {
    const normalised = hex.replace('#', '');
    if (normalised.length !== 6) {
      return { r: 37, g: 99, b: 235 };
    }
    return {
      r: parseInt(normalised.slice(0, 2), 16),
      g: parseInt(normalised.slice(2, 4), 16),
      b: parseInt(normalised.slice(4, 6), 16),
    };
  }

  function rgbToHex(r, g, b) {
    return `#${[r, g, b]
      .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0'))
      .join('')}`;
  }

  function mixHex(baseHex, targetHex, amount) {
    const base = hexToRgb(baseHex);
    const target = hexToRgb(targetHex);
    const ratio = Math.max(0, Math.min(1, amount));
    const inv = 1 - ratio;
    return rgbToHex(
      base.r * inv + target.r * ratio,
      base.g * inv + target.g * ratio,
      base.b * inv + target.b * ratio
    );
  }

  function lightenHex(hex, amount) {
    return mixHex(hex, '#ffffff', amount);
  }

  function darkenHex(hex, amount) {
    return mixHex(hex, '#000000', amount);
  }

  function computeColourProfile(colour) {
    if (!colour) {
      return {
        luminance: 0,
        saturation: 0,
        value: 0,
        lightness: 0,
      };
    }
    const r = (colour.r ?? 0) / 255;
    const g = (colour.g ?? 0) / 255;
    const b = (colour.b ?? 0) / 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const chroma = max - min;
    const saturation = max === 0 ? 0 : chroma / max;
    const lightness = (max + min) / 2;
    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
    return {
      luminance,
      saturation,
      value: max,
      lightness,
    };
  }

  function formatNumber(value) {
    if (typeof value !== 'number' || Number.isNaN(value)) {
      return '—';
    }
    return value.toLocaleString('en-US');
  }

  function formatDuration(ms) {
    if (!ms || ms <= 0) {
      return '0s';
    }
    const seconds = ms / 1000;
    if (seconds < 60) {
      return `${seconds.toFixed(Math.max(0, seconds >= 10 ? 0 : 1))}s`;
    }
    const minutes = Math.floor(seconds / 60);
    const remaining = Math.round(seconds % 60);
    return `${minutes}m ${remaining.toString().padStart(2, '0')}s`;
  }

  class AbortPainting extends Error {
    constructor() {
      super('Drawing aborted');
      this.name = 'AbortPainting';
    }
  }

  class FunAbort extends Error {
    constructor() {
      super('Fun effect aborted');
      this.name = 'FunAbort';
    }
  }

  function ensureNotAborted() {
    if (state.abortRequested) {
      throw new AbortPainting();
    }
  }

  function ensureFunNotAborted() {
    if (funState.abortRequested) {
      throw new FunAbort();
    }
  }

  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  const ui = createPanel();
  ui.paletteOrderSelect.value = state.paletteSortMode;
  registerCleanup(() => {
    ui.panel.remove();
    ui.style.remove();
  });

  registerCleanup(() => {
    funState.abortRequested = true;
  });

  const hiddenCanvas = document.createElement('canvas');
  const hiddenCtx = hiddenCanvas.getContext('2d', { willReadFrequently: true });

  let metricsUpdateScheduled = false;

  async function loadImageFromFile(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = (event) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = reject;
        img.src = event.target.result;
      };
      reader.onerror = reject;
      reader.readAsDataURL(file);
    });
  }

  function resizeImageToFit(img, maxDimension) {
    const scale = Math.min(1, maxDimension / Math.max(img.width, img.height));
    const width = Math.max(1, Math.round(img.width * scale));
    const height = Math.max(1, Math.round(img.height * scale));
    hiddenCanvas.width = width;
    hiddenCanvas.height = height;
    hiddenCtx.clearRect(0, 0, width, height);
    hiddenCtx.imageSmoothingEnabled = true;
    hiddenCtx.imageSmoothingQuality = 'high';
    hiddenCtx.drawImage(img, 0, 0, width, height);
    return hiddenCtx.getImageData(0, 0, width, height);
  }

  function previewImage(img, width, height) {
    const ctx = ui.previewCanvas.getContext('2d');
    const { width: previewW, height: previewH } = ui.previewCanvas;
    ctx.clearRect(0, 0, previewW, previewH);
    ctx.fillStyle = '#0f172a';
    ctx.fillRect(0, 0, previewW, previewH);
    ctx.save();
    ctx.shadowColor = 'rgba(15,23,42,0.45)';
    ctx.shadowBlur = 28;
    const scale = Math.min((previewW - 40) / width, (previewH - 40) / height);
    const drawW = width * scale;
    const drawH = height * scale;
    ctx.drawImage(hiddenCanvas, 0, 0, width, height, (previewW - drawW) / 2, (previewH - drawH) / 2, drawW, drawH);
    ctx.restore();
  }

  function quantizeToPalette(imageData, width, height, maxColors) {
    const data = imageData.data;
    const totalPixels = width * height;

    const map = new Map();

    for (let i = 0; i < totalPixels; i++) {
      const offset = i * 4;
      const alpha = data[offset + 3];
      if (alpha < 16) {
        continue;
      }
      const r = data[offset];
      const g = data[offset + 1];
      const b = data[offset + 2];
      const key = (r << 16) | (g << 8) | b;
      const record = map.get(key);
      if (record) {
        record.count += 1;
      } else {
        map.set(key, { r, g, b, count: 1 });
      }
    }

    if (!map.size) {
      const fallbackPalette = [{ r: 0, g: 0, b: 0, hex: '#000000' }];
      return {
        palette: fallbackPalette,
        assignments: new Uint16Array(totalPixels).fill(0xffff),
      };
    }

    const colours = Array.from(map.values());
    const targetColors = Math.max(1, Math.min(maxColors, colours.length));

    const boxes = [createColorBox(colours.map((_, idx) => idx), colours)];

    while (boxes.length < targetColors) {
      boxes.sort((a, b) => b.score - a.score);
      const box = boxes.shift();
      if (!box || box.indices.length <= 1) {
        if (box) {
          boxes.unshift(box);
        }
        break;
      }
      const split = splitBox(box, colours);
      if (!split || !split.low.indices.length || !split.high.indices.length) {
        boxes.unshift(box);
        break;
      }
      boxes.push(split.low, split.high);
    }

    const palette = boxes.map((box) => {
      let total = 0;
      let rSum = 0;
      let gSum = 0;
      let bSum = 0;
      for (const idx of box.indices) {
        const colour = colours[idx];
        total += colour.count;
        rSum += colour.r * colour.count;
        gSum += colour.g * colour.count;
        bSum += colour.b * colour.count;
      }
      if (!total) {
        total = 1;
      }
      const r = Math.round(rSum / total);
      const g = Math.round(gSum / total);
      const b = Math.round(bSum / total);
      return {
        r,
        g,
        b,
        hex: `#${[r, g, b]
          .map((component) => component.toString(16).padStart(2, '0'))
          .join('')}`,
      };
    });

    const assignments = assignPaletteWithDithering(imageData, width, height, palette);

    return { palette, assignments };
  }

  function createColorBox(indices, colours) {
    let rMin = 255;
    let rMax = 0;
    let gMin = 255;
    let gMax = 0;
    let bMin = 255;
    let bMax = 0;
    let population = 0;

    for (const idx of indices) {
      const colour = colours[idx];
      rMin = Math.min(rMin, colour.r);
      rMax = Math.max(rMax, colour.r);
      gMin = Math.min(gMin, colour.g);
      gMax = Math.max(gMax, colour.g);
      bMin = Math.min(bMin, colour.b);
      bMax = Math.max(bMax, colour.b);
      population += colour.count;
    }

    const rRange = rMax - rMin;
    const gRange = gMax - gMin;
    const bRange = bMax - bMin;
    const maxRange = Math.max(rRange, gRange, bRange, 1);

    return {
      indices,
      population,
      rRange,
      gRange,
      bRange,
      score: maxRange * Math.log(population + 1),
    };
  }

  function splitBox(box, colours) {
    const { rRange, gRange, bRange } = box;
    let component = 'r';
    if (gRange >= rRange && gRange >= bRange) {
      component = 'g';
    } else if (bRange >= rRange && bRange >= gRange) {
      component = 'b';
    }

    const sorted = [...box.indices].sort((a, b) => colours[a][component] - colours[b][component]);
    if (!sorted.length) {
      return null;
    }
    const total = sorted.reduce((acc, idx) => acc + colours[idx].count, 0);
    let midpoint = total / 2;
    let low = [];
    let high = [];
    let accumulator = 0;

    for (const idx of sorted) {
      if (accumulator < midpoint) {
        low.push(idx);
      } else {
        high.push(idx);
      }
      accumulator += colours[idx].count;
    }

    if (!low.length || !high.length) {
      const half = Math.ceil(sorted.length / 2);
      low = sorted.slice(0, half);
      high = sorted.slice(half);
    }

    return {
      low: createColorBox(low, colours),
      high: createColorBox(high, colours),
    };
  }

  function perceptualColourDistance(r1, g1, b1, r2, g2, b2) {
    const rMean = (r1 + r2) / 2;
    const dR = r1 - r2;
    const dG = g1 - g2;
    const dB = b1 - b2;
    return (
      (2 + rMean / 256) * dR * dR +
      4 * dG * dG +
      (2 + (255 - rMean) / 256) * dB * dB
    );
  }

  function clampChannel(value) {
    return Math.max(0, Math.min(255, value));
  }

  function assignPaletteWithDithering(imageData, width, height, palette) {
    const totalPixels = width * height;
    const assignments = new Uint16Array(totalPixels);
    if (!palette.length) {
      assignments.fill(0xffff);
      return assignments;
    }

    const ditherScale = (state.settings?.ditherStrength ?? 100) / 100;
    const data = imageData.data;
    const alphaChannel = new Uint8Array(totalPixels);
    const rBuffer = new Float32Array(totalPixels);
    const gBuffer = new Float32Array(totalPixels);
    const bBuffer = new Float32Array(totalPixels);

    for (let i = 0; i < totalPixels; i++) {
      const offset = i * 4;
      alphaChannel[i] = data[offset + 3];
      rBuffer[i] = data[offset];
      gBuffer[i] = data[offset + 1];
      bBuffer[i] = data[offset + 2];
    }

    const findNearest = (r, g, b) => {
      let bestIndex = 0;
      let bestDistance = Infinity;
      for (let j = 0; j < palette.length; j++) {
        const colour = palette[j];
        const distance = perceptualColourDistance(r, g, b, colour.r, colour.g, colour.b);
        if (distance < bestDistance) {
          bestDistance = distance;
          bestIndex = j;
        }
      }
      return bestIndex;
    };

    const distributeError = (x, y, errR, errG, errB, factor) => {
      if (ditherScale <= 0) {
        return;
      }
      if (x < 0 || x >= width || y < 0 || y >= height) {
        return;
      }
      const idx = y * width + x;
      if (alphaChannel[idx] < 16) {
        return;
      }
      const scaledFactor = factor * ditherScale;
      rBuffer[idx] += errR * scaledFactor;
      gBuffer[idx] += errG * scaledFactor;
      bBuffer[idx] += errB * scaledFactor;
    };

    for (let y = 0; y < height; y++) {
      const serpentine = y % 2 === 1;
      if (serpentine) {
        for (let x = width - 1; x >= 0; x--) {
          const idx = y * width + x;
          if (alphaChannel[idx] < 16) {
            assignments[idx] = 0xffff;
            continue;
          }
          const sourceR = clampChannel(rBuffer[idx]);
          const sourceG = clampChannel(gBuffer[idx]);
          const sourceB = clampChannel(bBuffer[idx]);
          const paletteIndex = findNearest(sourceR, sourceG, sourceB);
          assignments[idx] = paletteIndex;
          const target = palette[paletteIndex];
          const errR = sourceR - target.r;
          const errG = sourceG - target.g;
          const errB = sourceB - target.b;
          distributeError(x - 1, y, errR, errG, errB, 7 / 16);
          distributeError(x + 1, y + 1, errR, errG, errB, 3 / 16);
          distributeError(x, y + 1, errR, errG, errB, 5 / 16);
          distributeError(x - 1, y + 1, errR, errG, errB, 1 / 16);
        }
      } else {
        for (let x = 0; x < width; x++) {
          const idx = y * width + x;
          if (alphaChannel[idx] < 16) {
            assignments[idx] = 0xffff;
            continue;
          }
          const sourceR = clampChannel(rBuffer[idx]);
          const sourceG = clampChannel(gBuffer[idx]);
          const sourceB = clampChannel(bBuffer[idx]);
          const paletteIndex = findNearest(sourceR, sourceG, sourceB);
          assignments[idx] = paletteIndex;
          const target = palette[paletteIndex];
          const errR = sourceR - target.r;
          const errG = sourceG - target.g;
          const errB = sourceB - target.b;
          distributeError(x + 1, y, errR, errG, errB, 7 / 16);
          distributeError(x - 1, y + 1, errR, errG, errB, 3 / 16);
          distributeError(x, y + 1, errR, errG, errB, 5 / 16);
          distributeError(x + 1, y + 1, errR, errG, errB, 1 / 16);
        }
      }
    }

    return assignments;
  }

  function renderPaletteSwatches(palette, usage = []) {
    if (!ui.paletteStrip) {
      return;
    }
    ui.paletteStrip.innerHTML = '';
    let dominantIndex = 0;
    if (usage.length) {
      let best = -Infinity;
      usage.forEach((value, index) => {
        if (value > best) {
          best = value;
          dominantIndex = index;
        }
      });
    }
    const totalUsage = usage.reduce((acc, value) => acc + value, 0) || 1;
    palette.forEach((color, index) => {
      const swatch = document.createElement('div');
      swatch.className = 'pxa-swatch';
      swatch.style.background = color.hex;
      if (index === dominantIndex) {
        swatch.dataset.dominant = 'true';
      }
      const percentage = usage[index] ? ((usage[index] / totalUsage) * 100).toFixed(2) : null;
      swatch.title = `${index + 1}. ${color.hex}${percentage ? ` • ${percentage}%` : ''}`;
      ui.paletteStrip.appendChild(swatch);
    });
    const summaryText = `${palette.length} colours (max ${MAX_COLOUR_CAPACITY})`;
    if (ui.paletteSummary) {
      ui.paletteSummary.textContent = summaryText;
    }
    if (ui.paletteSummarySecondary) {
      ui.paletteSummarySecondary.textContent = summaryText;
    }
  }

  function renderPaletteInsights(palette, usage = []) {
    if (!ui.paletteInsights) {
      return;
    }
    ui.paletteInsights.innerHTML = '';
    if (!palette.length) {
      const emptyCard = document.createElement('div');
      emptyCard.className = 'pxa-insight-card';
      emptyCard.innerHTML = '<strong>Palette pending</strong><span>Import an image to unlock coverage analytics and colour rankings.</span>';
      ui.paletteInsights.appendChild(emptyCard);
      return;
    }
    const totalUsage = usage.reduce((acc, value) => acc + value, 0) || 1;
    const ranked = palette
      .map((colour, index) => ({
        index,
        hex: colour.hex,
        percent: usage[index] ? (usage[index] / totalUsage) * 100 : 0,
      }))
      .sort((a, b) => b.percent - a.percent)
      .slice(0, 4);
    const topColoursCard = document.createElement('div');
    topColoursCard.className = 'pxa-insight-card';
    topColoursCard.innerHTML = `<strong>Dominant colours</strong>${ranked
      .map((entry) => `<span>${entry.index + 1}. ${entry.hex} — ${entry.percent.toFixed(2)}%</span>`)
      .join('')}`;
    ui.paletteInsights.appendChild(topColoursCard);

    const cadenceCard = document.createElement('div');
    cadenceCard.className = 'pxa-insight-card';
    cadenceCard.innerHTML = `<strong>Stroke cadence</strong><span>${formatNumber(
      state.metrics.estimatedStrokes
    )} planned strokes • lane fan ×${state.metrics.laneCount || 1}</span><span>Smoothness ${state.settings.smoothnessPercent}% • Coverage ${state.settings.coverageBoost}%</span>`;
    ui.paletteInsights.appendChild(cadenceCard);

    const detailLabels = {
      balanced: 'Balanced detail',
      max: 'Maximum detail',
      minimal: 'Minimal detail',
    };
    const detailCard = document.createElement('div');
    detailCard.className = 'pxa-insight-card';
    detailCard.innerHTML = `<strong>Detail profile</strong><span>${
      detailLabels[state.settings.detailMode] || state.settings.detailMode
    }</span><span>Dither strength ${state.settings.ditherStrength}% • Low-res enhancer ${
      state.settings.lowResEnhancer ? 'on' : 'off'
    }</span>`;
    ui.paletteInsights.appendChild(detailCard);

    const harmonyCard = document.createElement('div');
    harmonyCard.className = 'pxa-insight-card';
    harmonyCard.innerHTML = `<strong>Harmony engines</strong><span>Spectral accent ${
      state.settings.spectralBoost
    }%</span><span>Glow ${state.settings.highlightGlaze !== false ? 'on' : 'off'} • Texture ${
      state.settings.textureWeave ? 'on' : 'off'
    } • Echo ${state.settings.gradientEcho !== false ? 'on' : 'off'}</span>`;
    ui.paletteInsights.appendChild(harmonyCard);
  }

  function applyPanelThemeFromPalette(palette, usage = []) {
    if (!ui.panel) {
      return;
    }
    if (state.settings.adaptiveTheme === false || !palette.length) {
      ui.panel.style.setProperty('--pxa-accent', '#2563eb');
      ui.panel.style.setProperty('--pxa-accent-dark', '#1e3a8a');
      ui.panel.style.setProperty('--pxa-accent-soft', 'rgba(37,99,235,0.16)');
      ui.panel.style.setProperty('--pxa-ambient', 'rgba(148,163,184,0.22)');
      return;
    }
    let dominantIndex = 0;
    if (usage.length) {
      let best = -Infinity;
      usage.forEach((value, index) => {
        if (value > best) {
          best = value;
          dominantIndex = index;
        }
      });
    }
    const accent = palette[dominantIndex]?.hex || '#2563eb';
    ui.panel.style.setProperty('--pxa-accent', accent);
    ui.panel.style.setProperty('--pxa-accent-dark', darkenHex(accent, 0.35));
    ui.panel.style.setProperty('--pxa-accent-soft', lightenHex(accent, 0.75));
    ui.panel.style.setProperty('--pxa-ambient', lightenHex(accent, 0.9));
  }

  function updateModeLabel() {
    if (!ui.modeLabel) {
      return;
    }
    const detailLabels = {
      balanced: 'Balanced detail',
      max: 'Maximum detail',
      minimal: 'Minimal detail',
    };
    const detailLabel = detailLabels[state.settings.detailMode] || state.settings.detailMode;
    const extras = [];
    if (state.settings.highlightGlaze !== false) {
      extras.push('glow glaze');
    }
    if (state.settings.textureWeave) {
      extras.push('texture weave');
    }
    if (state.settings.gradientEcho !== false) {
      extras.push('edge echo');
    }
    ui.modeLabel.textContent = `${detailLabel} • ${state.settings.smoothnessPercent}% smooth${
      extras.length ? ` • ${extras.join(' + ')}` : ''
    }`;
  }

  const funFeatures = {
    aurora: {
      label: 'Aurora sweep',
      description: 'Layered sine ribbons glide horizontally with pointer-drawn passes.',
      estimate(region, options) {
        const stripes = Math.max(3, Math.round((options.density || 0) / 12));
        return Math.max(1, stripes * (options.mirror ? 2 : 1));
      },
      async run(ctx) {
        const stripes = Math.max(3, Math.round((ctx.options.density || 0) / 12));
        const width = Math.max(1, ctx.region.width);
        const height = Math.max(1, ctx.region.height);
        const baseAmplitude = Math.max(4, height / Math.max(6, stripes * 2));
        const steps = Math.max(60, Math.round(width / 3));
        for (let lane = 0; lane < stripes; lane += 1) {
          ctx.ensureActive();
          const amplitude = baseAmplitude * (1 + lane / (stripes * 1.25));
          const frequency = 1.6 + lane * 0.35;
          const centerY = ctx.region.y + (height / (stripes + 1)) * (lane + 0.6);
          const path = [];
          for (let step = 0; step <= steps; step += 1) {
            const t = step / steps;
            let x = ctx.region.x + t * width;
            let y = centerY + Math.sin(t * Math.PI * frequency + lane * 0.4) * amplitude;
            if (ctx.options.jitter) {
              y += Math.cos((t * Math.PI * 6) + lane) * amplitude * 0.18;
            }
            x = clamp(x, ctx.minCanvasX, ctx.maxCanvasX);
            y = clamp(y, ctx.minCanvasY, ctx.maxCanvasY);
            path.push({ x, y });
          }
          await ctx.stroke(path, 0.58);
          ctx.advanceProgress();
          if (ctx.options.mirror) {
            const mirrored = path.map((point, index) => {
              const offset = ctx.options.jitter ? Math.sin(index * 0.25) * amplitude * 0.08 : 0;
              return {
                x: clamp(ctx.region.x + ctx.region.width - (point.x - ctx.region.x), ctx.minCanvasX, ctx.maxCanvasX),
                y: clamp(point.y + offset, ctx.minCanvasY, ctx.maxCanvasY),
              };
            });
            await ctx.stroke(mirrored, 0.58);
            ctx.advanceProgress();
          }
        }
      },
    },
    vortex: {
      label: 'Vortex bloom',
      description: 'Spirals orbit from the centre to sketch swirling blooms with pointer strokes.',
      estimate(region, options) {
        const arms = options.mirror ? 4 : 3;
        return Math.max(arms, 1);
      },
      async run(ctx) {
        const arms = ctx.options.mirror ? 4 : 3;
        const loops = Math.max(3, Math.round((ctx.options.density || 0) / 15));
        const steps = loops * 160;
        for (let arm = 0; arm < arms; arm += 1) {
          ctx.ensureActive();
          const offset = (2 * Math.PI * arm) / arms;
          const path = [];
          for (let step = 0; step <= steps; step += 1) {
            const t = step / steps;
            const angle = t * loops * Math.PI * 2 + offset;
            let radius = (Math.min(ctx.region.width, ctx.region.height) / 2) * Math.pow(t, 0.9);
            if (ctx.options.jitter) {
              radius += Math.sin(angle * 0.6) * radius * 0.08;
            }
            let x = ctx.region.x + ctx.region.width / 2 + Math.cos(angle) * radius;
            let y = ctx.region.y + ctx.region.height / 2 + Math.sin(angle) * radius * (ctx.options.mirror ? 0.9 : 1);
            x = clamp(x, ctx.minCanvasX, ctx.maxCanvasX);
            y = clamp(y, ctx.minCanvasY, ctx.maxCanvasY);
            path.push({ x, y });
          }
          await ctx.stroke(path, 0.5);
          ctx.advanceProgress();
        }
      },
    },
    firefly: {
      label: 'Firefly scatter',
      description: 'Launches sparkling bursts of short pointer flutters across the region.',
      estimate(region, options) {
        const base = Math.max(12, Math.round((options.density || 0) * 0.8));
        return Math.max(1, base * (options.mirror ? 2 : 1));
      },
      async run(ctx) {
        const base = Math.max(12, Math.round((ctx.options.density || 0) * 0.8));
        for (let i = 0; i < base; i += 1) {
          ctx.ensureActive();
          const originX = ctx.region.x + ctx.random() * ctx.region.width;
          const originY = ctx.region.y + ctx.random() * ctx.region.height;
          const heading = ctx.random() * Math.PI * 2;
          const length = Math.max(10, Math.min(ctx.region.width, ctx.region.height) * (0.18 + ctx.random() * 0.12));
          const segments = 4 + Math.floor(ctx.random() * 4);
          const path = [];
          for (let s = 0; s <= segments; s += 1) {
            const t = s / segments;
            const curve = Math.sin(t * Math.PI);
            let x = originX + Math.cos(heading) * length * t;
            let y = originY + Math.sin(heading) * length * t + curve * length * 0.25;
            if (ctx.options.jitter) {
              x += (ctx.random() - 0.5) * length * 0.18;
              y += (ctx.random() - 0.5) * length * 0.12;
            }
            x = clamp(x, ctx.minCanvasX, ctx.maxCanvasX);
            y = clamp(y, ctx.minCanvasY, ctx.maxCanvasY);
            path.push({ x, y });
          }
          await ctx.stroke(path, 0.45);
          ctx.advanceProgress();
          if (ctx.options.mirror) {
            const mirrored = path.map((point) => ({
              x: clamp(ctx.region.x + ctx.region.width - (point.x - ctx.region.x), ctx.minCanvasX, ctx.maxCanvasX),
              y: point.y,
            }));
            await ctx.stroke(mirrored, 0.45);
            ctx.advanceProgress();
          }
        }
      },
    },
    cascade: {
      label: 'Cascade drapery',
      description: 'Pointer sweeps unfurl silky waterfall ribbons that ripple across the canvas.',
      estimate(region, options) {
        const rows = Math.max(4, Math.round((options.density || 0) / 10));
        return Math.max(1, rows * (options.mirror ? 2 : 1));
      },
      async run(ctx) {
        const rows = Math.max(4, Math.round((ctx.options.density || 0) / 10));
        const width = Math.max(1, ctx.region.width);
        const height = Math.max(1, ctx.region.height);
        const amplitudeBase = Math.max(6, height * 0.08);
        for (let row = 0; row < rows; row += 1) {
          ctx.ensureActive();
          const baseY = ctx.region.y + (height / (rows + 1)) * (row + 1);
          const waveAmp = amplitudeBase * (1 + row / (rows * 0.9));
          const steps = Math.max(80, Math.round(width / 4));
          const path = [];
          for (let step = 0; step <= steps; step += 1) {
            const t = step / steps;
            const x = clamp(ctx.region.x + t * width, ctx.minCanvasX, ctx.maxCanvasX);
            let y = baseY + Math.sin(t * Math.PI * (1.6 + row * 0.25)) * waveAmp;
            if (ctx.options.jitter) {
              y += (ctx.random() - 0.5) * waveAmp * 0.35;
            }
            y = clamp(y, ctx.minCanvasY, ctx.maxCanvasY);
            path.push({ x, y });
          }
          await ctx.stroke(path, 0.52);
          ctx.advanceProgress();
          if (ctx.options.mirror) {
            const mirrored = path.map((point, index) => ({
              x: clamp(ctx.region.x + ctx.region.width - (point.x - ctx.region.x), ctx.minCanvasX, ctx.maxCanvasX),
              y: clamp(
                ctx.region.y + ctx.region.height - (point.y - ctx.region.y) + Math.sin(index * 0.08) * waveAmp * 0.12,
                ctx.minCanvasY,
                ctx.maxCanvasY
              ),
            }));
            await ctx.stroke(mirrored, 0.48);
            ctx.advanceProgress();
          }
        }
      },
    },
  };

  function updateFunDescription() {
    if (!ui.funDescription || !ui.funModeSelect) {
      return;
    }
    const effect = funFeatures[ui.funModeSelect.value];
    if (effect) {
      ui.funDescription.textContent = effect.description;
    } else {
      ui.funDescription.textContent = 'Select an effect to view its details.';
    }
  }

  function resolveFunRegion(canvas) {
    const width = canvas?.width || 0;
    const height = canvas?.height || 0;
    if (!state.selection) {
      return { x: 0, y: 0, width, height };
    }
    const sel = state.selection;
    const x = clamp(Math.round(sel.normX * width), 0, width);
    const y = clamp(Math.round(sel.normY * height), 0, height);
    const w = Math.max(1, Math.round(sel.normWidth * width));
    const h = Math.max(1, Math.round(sel.normHeight * height));
    const maxWidth = Math.max(1, width - x);
    const maxHeight = Math.max(1, height - y);
    return {
      x,
      y,
      width: clamp(w, 1, maxWidth),
      height: clamp(h, 1, maxHeight),
    };
  }

  function canvasPointToClient(canvas, x, y) {
    const rect = canvas.getBoundingClientRect();
    const scaleX = rect.width / canvas.width || 1;
    const scaleY = rect.height / canvas.height || 1;
    return {
      clientX: rect.left + x * scaleX,
      clientY: rect.top + y * scaleY,
    };
  }

  function dispatchCanvasPointer(canvas, type, pointerId, x, y, options = {}) {
    const coords = canvasPointToClient(canvas, x, y);
    const eventInit = {
      pointerId,
      pointerType: 'pen',
      clientX: coords.clientX,
      clientY: coords.clientY,
      buttons: type === 'pointerup' ? 0 : 1,
      pressure: type === 'pointerup' ? 0 : options.pressure ?? 0.6,
      tiltX: options.tiltX ?? 0,
      tiltY: options.tiltY ?? 0,
      bubbles: true,
      cancelable: true,
    };
    const event = new PointerEvent(type, eventInit);
    canvas.dispatchEvent(event);
  }

  async function performPointerStroke(canvas, pointerId, points, stepDelay, pressure = 0.6) {
    if (!points.length) {
      return;
    }
    ensureFunNotAborted();
    const first = points[0];
    dispatchCanvasPointer(canvas, 'pointerdown', pointerId, first.x, first.y, { pressure });
    try {
      if (canvas.setPointerCapture) {
        try {
          canvas.setPointerCapture(pointerId);
        } catch (err) {
          // ignore capture failures
        }
      }
      for (let i = 1; i < points.length; i += 1) {
        ensureFunNotAborted();
        const point = points[i];
        dispatchCanvasPointer(canvas, 'pointermove', pointerId, point.x, point.y, { pressure });
        await wait(stepDelay);
      }
    } finally {
      const last = points[points.length - 1];
      dispatchCanvasPointer(canvas, 'pointerup', pointerId, last.x, last.y, { pressure: 0 });
      if (canvas.releasePointerCapture) {
        try {
          canvas.releasePointerCapture(pointerId);
        } catch (err) {
          // ignore release failures
        }
      }
    }
  }

  function computeFunTiming(tempo) {
    const raw = Number(tempo);
    const clampedTempo = clamp(Number.isFinite(raw) ? raw : 60, 10, 160);
    const stepDelay = Math.max(2, Math.round(22 - clampedTempo / 6));
    const strokeGap = Math.max(6, Math.round(stepDelay * 2));
    return { stepDelay, strokeGap };
  }

  function createFunContext(canvas, region, options, stepDelay, strokeGap, onProgress) {
    const pointerId = funState.pointerId;
    const minCanvasX = Math.max(0, Math.min(canvas.width, region.x));
    const maxCanvasX = Math.max(minCanvasX + 1, Math.min(canvas.width, region.x + region.width));
    const minCanvasY = Math.max(0, Math.min(canvas.height, region.y));
    const maxCanvasY = Math.max(minCanvasY + 1, Math.min(canvas.height, region.y + region.height));
    let completed = 0;
    const clampPoint = (point) => ({
      x: clamp(point.x, minCanvasX, maxCanvasX),
      y: clamp(point.y, minCanvasY, maxCanvasY),
    });
    return {
      canvas,
      region,
      options,
      pointerId,
      stepDelay,
      strokeGap,
      minCanvasX,
      maxCanvasX,
      minCanvasY,
      maxCanvasY,
      random: Math.random,
      ensureActive: ensureFunNotAborted,
      async stroke(points, pressure = 0.6) {
        ensureFunNotAborted();
        if (!points || points.length < 2) {
          return;
        }
        const safePoints = points.map(clampPoint);
        await performPointerStroke(canvas, pointerId, safePoints, stepDelay, pressure);
        await wait(strokeGap);
      },
      advanceProgress(increment = 1) {
        completed += increment;
        if (typeof onProgress === 'function') {
          onProgress(completed);
        }
      },
      setProgress(value) {
        completed = value;
        if (typeof onProgress === 'function') {
          onProgress(completed);
        }
      },
    };
  }

  async function startFunEffect() {
    if (!ui.funModeSelect || !ui.funRunButton) {
      return;
    }
    if (state.running) {
      updateFunStatus('Stop the image painter before launching a fun effect.');
      return;
    }
    if (funState.running) {
      updateFunStatus('A fun effect is already running.');
      return;
    }
    const effectKey = ui.funModeSelect.value;
    const effect = funFeatures[effectKey];
    if (!effect) {
      updateFunStatus('Select a fun effect to begin.');
      return;
    }
    const canvas = selectLargestCanvas();
    if (!canvas) {
      updateFunStatus('Canvas not found — join a Drawaria room first.');
      return;
    }
    if (!canvas.width || !canvas.height) {
      updateFunStatus('Canvas is not ready yet — wait for it to load.');
      return;
    }
    const region = resolveFunRegion(canvas);
    if (!region.width || !region.height) {
      updateFunStatus('Selection is too small for the fun lab.');
      return;
    }
    const options = {
      density: funSettings.density,
      tempo: funSettings.tempo,
      mirror: funSettings.mirror,
      jitter: funSettings.jitter,
    };
    const { stepDelay, strokeGap } = computeFunTiming(options.tempo);
    const total = Math.max(1, effect.estimate(region, options));
    funState.running = true;
    funState.abortRequested = false;
    funState.activeFeature = effectKey;
    ui.funRunButton.disabled = true;
    if (ui.funStopButton) {
      ui.funStopButton.disabled = false;
    }
    updateFunProgress(0);
    updateFunStatus(`Running ${effect.label}…`);
    let aborted = false;
    try {
      const context = createFunContext(canvas, region, options, stepDelay, strokeGap, (completed) => {
        const percent = (completed / total) * 100;
        updateFunProgress(percent);
      });
      await effect.run(context);
      aborted = funState.abortRequested;
      if (aborted) {
        updateFunStatus('Fun effect aborted.');
      } else {
        updateFunProgress(100);
        updateFunStatus(`${effect.label} complete!`);
      }
    } catch (err) {
      if (err instanceof FunAbort) {
        updateFunStatus('Fun effect aborted.');
      } else {
        console.error('drawaria image autodraw fun lab error', err);
        updateFunStatus(`Fun effect error: ${err.message || err}`);
      }
    } finally {
      funState.running = false;
      funState.activeFeature = null;
      funState.abortRequested = false;
      if (ui.funRunButton) {
        ui.funRunButton.disabled = false;
      }
      if (ui.funStopButton) {
        ui.funStopButton.disabled = true;
      }
    }
  }

  function stopFunEffect() {
    if (!funState.running) {
      updateFunStatus('No fun effect is currently running.');
      return;
    }
    funState.abortRequested = true;
    updateFunStatus('Stopping fun effect…');
  }

  function updateFunProgress(percent) {
    if (!ui.funProgressBar) {
      return;
    }
    const numeric = Number(percent);
    const clampedPercent = clamp(Number.isFinite(numeric) ? numeric : 0, 0, 100);
    ui.funProgressBar.style.width = `${clampedPercent}%`;
  }

  function updateFunStatus(message) {
    if (!ui.funStatus) {
      return;
    }
    ui.funStatus.textContent = message;
  }

  function updateSelectionUI() {
    if (!ui.selectionDetails) {
      return;
    }
    if (state.selection) {
      const sel = state.selection;
      const boardW = state.metrics.boardWidth || sel.boardWidth || 0;
      const boardH = state.metrics.boardHeight || sel.boardHeight || 0;
      const widthPx = Math.round(boardW * sel.normWidth);
      const heightPx = Math.round(boardH * sel.normHeight);
      const startX = Math.round(boardW * sel.normX);
      const startY = Math.round(boardH * sel.normY);
      ui.selectionDetails.textContent = `Region ${widthPx}×${heightPx}px @ (${startX}, ${startY})`;
      ui.selectionDetails.dataset.state = 'active';
      if (ui.clearRegionButton) {
        ui.clearRegionButton.disabled = false;
      }
    } else {
      ui.selectionDetails.textContent = 'Full canvas coverage';
      ui.selectionDetails.dataset.state = 'inactive';
      if (ui.clearRegionButton) {
        ui.clearRegionButton.disabled = true;
      }
    }
  }

  function updateMetricsUI() {
    if (!ui.metricResolution) {
      return;
    }
    if (state.pixelWidth && state.pixelHeight) {
      ui.metricResolution.textContent = `${state.pixelWidth}×${state.pixelHeight}`;
    } else {
      ui.metricResolution.textContent = '—';
    }
    const scale = state.metrics.scaleFactor || 1;
    const targetWidth = state.metrics.targetWidth || state.metrics.boardWidth;
    const targetHeight = state.metrics.targetHeight || state.metrics.boardHeight;
    if (scale && targetWidth && targetHeight) {
      const label = state.metrics.selectionActive ? `selection ${Math.round(targetWidth)}×${Math.round(targetHeight)}` : `${Math.round(targetWidth)}×${Math.round(targetHeight)}`;
      ui.metricScale.textContent = `Scaled ×${scale.toFixed(2)} into ${label}`;
    } else {
      ui.metricScale.textContent = 'Fit-to-canvas ready';
    }
    ui.metricPalette.textContent = formatNumber(state.metrics.paletteCount || state.palette.length || 0);
    const orderLabels = {
      'dark-first': 'Dark → Light',
      'light-first': 'Light → Dark',
      coverage: 'Coverage priority',
    };
    ui.metricPaletteNote.textContent = orderLabels[state.paletteSortMode] || state.paletteSortMode;
    ui.metricStrokes.textContent = formatNumber(state.metrics.estimatedStrokes || 0);
    ui.metricLanes.textContent = `Lane fan ×${state.metrics.laneCount || 1}`;
    ui.metricEta.textContent = formatDuration(state.metrics.estimatedDurationMs || 0);
    ui.metricDelay.textContent = `8ms stroke delay • ${formatNumber(state.metrics.estimatedStrokes || 0)} strokes`;
  }

  function getSettingsSignature() {
    const s = state.settings;
    return [
      state.paletteSortMode,
      s.smoothnessPercent,
      s.laneFanMultiplier,
      s.coverageBoost,
      s.detailMode,
      s.lowResEnhancer,
      s.edgeDetail,
      s.microDetail,
      s.ditherStrength,
      s.spectralBoost,
      s.highlightGlaze,
      s.textureWeave,
      s.gradientEcho,
    ].join('|');
  }

  function getSelectionSignature() {
    if (!state.selection) {
      return 'full';
    }
    const sel = state.selection;
    const toFixed = (value, fallback) => {
      const num = Number(value);
      if (!Number.isFinite(num)) {
        return fallback;
      }
      return num.toFixed(4);
    };
    return [
      toFixed(sel.normX, '0.0000'),
      toFixed(sel.normY, '0.0000'),
      toFixed(sel.normWidth, '1.0000'),
      toFixed(sel.normHeight, '1.0000'),
    ].join('|');
  }

  async function scheduleMetricsUpdate() {
    if (metricsUpdateScheduled || !state.prepared || state.running) {
      return;
    }
    metricsUpdateScheduled = true;
    try {
      await wait(80);
      const canvas = selectLargestCanvas();
      if (!canvas) {
        updateMetricsUI();
        return;
      }
      const commands = buildPixelCommands(canvas.width, canvas.height);
      if (!commands.length) {
        updateMetricsUI();
      }
    } finally {
      metricsUpdateScheduled = false;
    }
  }

  async function prepareFromFile(file) {
    if (!file) {
      throw new Error('Select an image file to begin.');
    }

    ui.previewLoading.classList.add('visible');
    ui.progressBar.style.width = '0%';
    ui.progressLabel.textContent = '0%';
    ui.status.textContent = 'Loading image…';

    try {
      await wait(10);

      const maxDimension = Number(ui.dimensionInput.value) || 500;
      const image = await loadImageFromFile(file);
      const imageData = resizeImageToFit(image, maxDimension);
      const { width, height } = imageData;
      state.sourceImageData = imageData;
      state.pixelWidth = width;
      state.pixelHeight = height;
      previewImage(image, width, height);

      ui.status.textContent = `Quantising colours (≤${MAX_COLOUR_CAPACITY})…`;
      await wait(10);
      const { palette, assignments } = quantizeToPalette(
        imageData,
        width,
        height,
        MAX_COLOUR_CAPACITY
      );

      state.palette = palette;
      state.assignments = assignments;
      state.prepared = true;
      state.previewDataUrl = hiddenCanvas.toDataURL('image/png');
      state.commandCache = null;
      const usage = new Float64Array(palette.length);
      for (let i = 0; i < assignments.length; i++) {
        const paletteIndex = assignments[i];
        if (paletteIndex !== 0xffff && usage[paletteIndex] !== undefined) {
          usage[paletteIndex] += 1;
        }
      }
      state.paletteUsage = Array.from(usage);
      state.metrics.pixelCount = width * height;
      state.metrics.paletteCount = palette.length;
      state.metrics.estimatedStrokes = 0;
      state.metrics.estimatedDurationMs = 0;
      state.metrics.scaleFactor = 0;
      state.metrics.boardWidth = 0;
      state.metrics.boardHeight = 0;
      state.metrics.targetWidth = 0;
      state.metrics.targetHeight = 0;
      state.metrics.selectionActive = false;
      renderPaletteSwatches(palette, state.paletteUsage);
      renderPaletteInsights(palette, state.paletteUsage);
      applyPanelThemeFromPalette(palette, state.paletteUsage);
      updateModeLabel();
      updateMetricsUI();
      ui.status.textContent = `Ready: ${width}×${height}px, ${palette.length} colours.`;
      await scheduleMetricsUpdate();
    } finally {
      ui.previewLoading.classList.remove('visible');
    }
  }

  async function waitForSocket(timeout = 5000) {
    const start = performance.now();
    while (performance.now() - start < timeout) {
      const socket = wsBridge.getSocket();
      if (socket && socket.readyState === WebSocket.OPEN) {
        return socket;
      }
      await wait(120);
    }
    return null;
  }



  function buildPixelCommands(canvasWidth, canvasHeight) {
    const assignments = state.assignments;
    const palette = state.palette;
    const width = state.pixelWidth;
    const height = state.pixelHeight;
    const imageData = state.sourceImageData;

    if (
      !assignments ||
      !palette.length ||
      !width ||
      !height ||
      !canvasWidth ||
      !canvasHeight
    ) {
      return [];
    }

    const boardWidth = canvasWidth;
    const boardHeight = canvasHeight;
    let targetX = 0;
    let targetY = 0;
    let targetWidth = boardWidth;
    let targetHeight = boardHeight;
    let selectionActive = false;

    if (state.selection) {
      const sel = state.selection;
      const normWidth = clamp(sel.normWidth ?? 1, 1 / Math.max(1, boardWidth), 1);
      const normHeight = clamp(sel.normHeight ?? 1, 1 / Math.max(1, boardHeight), 1);
      const normX = clamp(sel.normX ?? 0, 0, 1 - normWidth);
      const normY = clamp(sel.normY ?? 0, 0, 1 - normHeight);
      targetWidth = Math.max(1, normWidth * boardWidth);
      targetHeight = Math.max(1, normHeight * boardHeight);
      targetX = clamp(normX * boardWidth, 0, Math.max(0, boardWidth - targetWidth));
      targetY = clamp(normY * boardHeight, 0, Math.max(0, boardHeight - targetHeight));
      selectionActive = true;
      sel.boardWidth = boardWidth;
      sel.boardHeight = boardHeight;
    }

    const scaleX = targetWidth / width;
    const scaleY = targetHeight / height;
    const scale = Math.min(scaleX, scaleY);
    const drawWidth = width * scale;
    const drawHeight = height * scale;
    const offsetX = clamp(targetX + (targetWidth - drawWidth) / 2, 0, Math.max(0, boardWidth - drawWidth));
    const offsetY = clamp(targetY + (targetHeight - drawHeight) / 2, 0, Math.max(0, boardHeight - drawHeight));

    state.metrics.scaleFactor = scale;
    state.metrics.boardWidth = boardWidth;
    state.metrics.boardHeight = boardHeight;
    state.metrics.targetWidth = targetWidth;
    state.metrics.targetHeight = targetHeight;
    state.metrics.selectionActive = selectionActive;

    const settingsSignature = getSettingsSignature();
    const selectionSignature = getSelectionSignature();
    const cacheKey = `${width}x${height}|${boardWidth}x${boardHeight}|${targetWidth.toFixed(2)}x${targetHeight.toFixed(2)}|${settingsSignature}|${selectionSignature}`;
    if (state.commandCache && state.commandCache.key === cacheKey) {
      Object.assign(state.metrics, state.commandCache.metrics);
      updateMetricsUI();
      updateSelectionUI();
      return state.commandCache.commands;
    }

    const colourCommands = Array.from({ length: palette.length }, () => []);
    const paletteProfiles = palette.map((colour) => computeColourProfile(colour));
    const colourCoverage = new Float64Array(palette.length);
    const phaseOrder = {
      fill: 0,
      'fill-secondary': 1,
      detail: 2,
      'detail-edge': 3,
      glaze: 4,
      texture: 5,
      echo: 6,
    };

    const alphaData = imageData?.data ?? null;
    const detailMask = new Uint8Array(width * height);
    if (alphaData) {
      const threshold = 4200;
      for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
          const idx = y * width + x;
          const paletteIndex = assignments[idx];
          if (paletteIndex === 0xffff) {
            continue;
          }
          const offset = idx * 4;
          const r = alphaData[offset];
          const g = alphaData[offset + 1];
          const b = alphaData[offset + 2];

          let maxDiff = 0;
          if (x > 0) {
            const left = offset - 4;
            maxDiff = Math.max(
              maxDiff,
              perceptualColourDistance(r, g, b, alphaData[left], alphaData[left + 1], alphaData[left + 2])
            );
          }
          if (x + 1 < width) {
            const right = offset + 4;
            maxDiff = Math.max(
              maxDiff,
              perceptualColourDistance(r, g, b, alphaData[right], alphaData[right + 1], alphaData[right + 2])
            );
          }
          if (y > 0) {
            const up = offset - width * 4;
            maxDiff = Math.max(
              maxDiff,
              perceptualColourDistance(r, g, b, alphaData[up], alphaData[up + 1], alphaData[up + 2])
            );
          }
          if (y + 1 < height) {
            const down = offset + width * 4;
            maxDiff = Math.max(
              maxDiff,
              perceptualColourDistance(r, g, b, alphaData[down], alphaData[down + 1], alphaData[down + 2])
            );
          }

          if (maxDiff > threshold) {
            detailMask[idx] = 1;
          }
        }
      }
    }

    const edgeMask = new Uint8Array(width * height);
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const idx = y * width + x;
        const paletteIndex = assignments[idx];
        if (paletteIndex === 0xffff) {
          continue;
        }
        let isEdge = false;
        if (x > 0) {
          const neighbour = assignments[idx - 1];
          if (neighbour !== paletteIndex && neighbour !== 0xffff) {
            isEdge = true;
          }
        }
        if (x + 1 < width) {
          const neighbour = assignments[idx + 1];
          if (neighbour !== paletteIndex && neighbour !== 0xffff) {
            isEdge = true;
          }
        }
        if (y > 0) {
          const neighbour = assignments[idx - width];
          if (neighbour !== paletteIndex && neighbour !== 0xffff) {
            isEdge = true;
          }
        }
        if (y + 1 < height) {
          const neighbour = assignments[idx + width];
          if (neighbour !== paletteIndex && neighbour !== 0xffff) {
            isEdge = true;
          }
        }
        if (isEdge) {
          edgeMask[idx] = 1;
        }
      }
    }

    const pushStroke = (paletteIndex, x1, y1, x2, y2, orientation, phase = 'fill') => {
      if (paletteIndex < 0 || paletteIndex >= colourCommands.length) {
        return;
      }

      const minX = Math.min(x1, x2);
      const maxX = Math.max(x1, x2);
      const minY = Math.min(y1, y2);
      const maxY = Math.max(y1, y2);

      if (maxX <= 0 || maxY <= 0 || minX >= boardWidth || minY >= boardHeight) {
        return;
      }

      let nx1 = clamp(x1 / boardWidth, 0, 1);
      let ny1 = clamp(y1 / boardHeight, 0, 1);
      let nx2 = clamp(x2 / boardWidth, 0, 1);
      let ny2 = clamp(y2 / boardHeight, 0, 1);

      const epsilonX = boardWidth > 0 ? 0.75 / boardWidth : 0;
      const epsilonY = boardHeight > 0 ? 0.75 / boardHeight : 0;

      if (Math.abs(nx1 - nx2) < 1e-5) {
        nx2 = clamp(nx2 + (nx2 >= nx1 ? epsilonX : -epsilonX), 0, 1);
      }
      if (Math.abs(ny1 - ny2) < 1e-5) {
        ny2 = clamp(ny2 + (ny2 >= ny1 ? epsilonY : -epsilonY), 0, 1);
      }

      colourCommands[paletteIndex].push({
        nx1: nx1.toFixed(6),
        ny1: ny1.toFixed(6),
        nx2: nx2.toFixed(6),
        ny2: ny2.toFixed(6),
        orientation,
        phase,
      });
    };

    const smoothingPercent = state.settings.smoothnessPercent ?? 40;
    const laneMultiplier = state.settings.laneFanMultiplier ?? 100;
    const coverageBoost = (state.settings.coverageBoost ?? 100) / 100;
    const detailMode = state.settings.detailMode || 'balanced';
    const allowLowRes = state.settings.lowResEnhancer !== false;
    const allowEdgeDetail = state.settings.edgeDetail !== false;
    const allowMicroDetail = state.settings.microDetail !== false;
    const spectralBoost = Math.max(10, state.settings.spectralBoost ?? 120) / 100;
    const highlightGlaze = state.settings.highlightGlaze !== false;
    const weaveEnabled = state.settings.textureWeave === true;
    const gradientEcho = state.settings.gradientEcho !== false;

    const baseLaneSpacing = (() => {
      if (scale >= 24) {
        return 0.95;
      }
      if (scale >= 12) {
        return 0.88;
      }
      if (scale >= 6) {
        return 0.78;
      }
      return 0.64;
    })();

    const smoothingFactor = 1 + smoothingPercent / 100;
    const laneDensityFactor = Math.max(10, laneMultiplier) / 100;
    const laneSpacing = (baseLaneSpacing / smoothingFactor) / laneDensityFactor;

    const coveragePad = Math.min(
      scale * 0.45 * coverageBoost,
      Math.max(0.9, baseLaneSpacing * 1.5 * coverageBoost)
    );
    const detailMultiplier = detailMode === 'max' ? 1.35 : detailMode === 'minimal' ? 0.75 : 1;
    const detailLaneOffset = Math.min(
      scale * 0.28 * detailMultiplier,
      Math.max(0.45, baseLaneSpacing * 1.4 * detailMultiplier)
    );
    const microThreshold = detailMode === 'max' ? 4 : detailMode === 'minimal' ? 1 : 2;

    let laneCount = Math.max(1, Math.ceil((scale + laneSpacing * 0.5) / laneSpacing));
    if (allowLowRes) {
      if (scale < 1.5) {
        laneCount = Math.max(laneCount, 5);
      } else if (scale < 2.5) {
        laneCount = Math.max(laneCount, 4);
      } else if (scale < 4) {
        laneCount = Math.max(laneCount, 3);
      }
    }

    const laneOffsets = [];
    if (laneCount === 1) {
      laneOffsets.push(0);
    } else {
      const totalSpan = (laneCount - 1) * laneSpacing;
      const startOffset = -totalSpan / 2;
      for (let laneIndex = 0; laneIndex < laneCount; laneIndex++) {
        laneOffsets.push(startOffset + laneIndex * laneSpacing);
      }
    }
    state.metrics.laneCount = laneOffsets.length;

    for (let y = 0; y < height; y++) {
      let x = 0;
      while (x < width) {
        const idx = y * width + x;
        const paletteIndex = assignments[idx];
        if (paletteIndex === 0xffff) {
          x += 1;
          continue;
        }

        let runStart = x;
        let runEnd = x;
        let runDetail = false;
        let runEdge = false;
        while (runEnd < width && assignments[y * width + runEnd] === paletteIndex) {
          const runIdx = y * width + runEnd;
          if (detailMask[runIdx]) {
            runDetail = true;
          }
          if (edgeMask[runIdx]) {
            runEdge = true;
          }
          runEnd += 1;
        }

        const runLength = runEnd - runStart;
        const startX = offsetX + runStart * scale - coveragePad;
        const endX = offsetX + runEnd * scale + coveragePad;
        const centerY = offsetY + (y + 0.5) * scale;
        const profile = paletteProfiles[paletteIndex] || null;

        colourCoverage[paletteIndex] += runLength * laneOffsets.length;

        laneOffsets.forEach((laneOffset, laneIndex) => {
          pushStroke(
            paletteIndex,
            startX,
            centerY + laneOffset,
            endX,
            centerY + laneOffset,
            laneIndex === 0 ? 'run-primary' : 'run-lane',
            laneIndex === 0 ? 'fill' : 'fill-secondary'
          );
        });

        if (allowMicroDetail && runLength <= microThreshold) {
          const centerX = offsetX + (runStart + runLength / 2) * scale;
          const microHalf = Math.max(scale * 0.55, 0.85);
          pushStroke(
            paletteIndex,
            centerX - microHalf,
            centerY,
            centerX + microHalf,
            centerY,
            'micro-detail',
            'detail'
          );
        } else if ((runDetail && detailMode !== 'minimal') || (runEdge && allowEdgeDetail)) {
          pushStroke(
            paletteIndex,
            startX,
            centerY + detailLaneOffset,
            endX,
            centerY + detailLaneOffset,
            'detail-offset',
            'detail'
          );
        }

        if (runEdge && allowEdgeDetail) {
          const centerX = offsetX + (runStart + runLength / 2) * scale;
          const edgeHalf = Math.max(scale * 0.6, 0.9);
          pushStroke(
            paletteIndex,
            centerX - edgeHalf,
            centerY,
            centerX + edgeHalf,
            centerY,
            'edge-center',
            'detail-edge'
          );
        }

        let extraCoverage = 0;
        if (highlightGlaze && profile && profile.value > 0.62) {
          const glazeOffset = Math.max(scale * 0.35, detailLaneOffset * 0.45);
          pushStroke(
            paletteIndex,
            startX,
            centerY - glazeOffset,
            endX,
            centerY - glazeOffset,
            'glaze-upper',
            'glaze'
          );
          pushStroke(
            paletteIndex,
            startX,
            centerY + glazeOffset,
            endX,
            centerY + glazeOffset,
            'glaze-lower',
            'glaze'
          );
          extraCoverage += runLength * spectralBoost * 0.65;
        }

        if (gradientEcho && (runDetail || runEdge)) {
          const echoOffset = Math.max(scale * 0.42, detailLaneOffset * 0.6);
          pushStroke(
            paletteIndex,
            startX,
            centerY - echoOffset,
            endX,
            centerY - echoOffset,
            'echo-upper',
            'echo'
          );
          pushStroke(
            paletteIndex,
            startX,
            centerY + echoOffset,
            endX,
            centerY + echoOffset,
            'echo-lower',
            'echo'
          );
          extraCoverage += runLength * 0.55;
        }

        if (weaveEnabled && profile && profile.saturation > 0.4) {
          const weaveStep = Math.max(1, Math.round(4 / Math.max(0.5, spectralBoost)));
          const weaveSpan = Math.max(scale * 0.6, 0.9);
          const weaveTail = Math.max(scale * 0.3, 0.5);
          let weaveCount = 0;
          for (let px = runStart, alt = 0; px < runEnd; px += weaveStep, alt += 1) {
            const centerX = offsetX + (px + 0.5) * scale;
            const direction = alt % 2 === 0 ? 1 : -1;
            pushStroke(
              paletteIndex,
              centerX - weaveSpan,
              centerY - weaveTail * direction,
              centerX + weaveSpan,
              centerY + weaveTail * direction,
              'texture-weave',
              'texture'
            );
            weaveCount += 1;
          }
          if (weaveCount) {
            extraCoverage += runLength * 0.25 * spectralBoost + weaveCount * 0.6;
          }
        }

        if (extraCoverage > 0) {
          colourCoverage[paletteIndex] += extraCoverage;
        }

        x = runEnd;
      }
    }

    const paletteEntries = palette.map((colour, index) => ({
      index,
      coverage: colourCoverage[index],
      luminance: 0.2126 * colour.r + 0.7152 * colour.g + 0.0722 * colour.b,
    }));

    const sortMode = state.paletteSortMode || 'dark-first';
    paletteEntries.sort((a, b) => {
      if (sortMode === 'light-first') {
        if (b.luminance !== a.luminance) {
          return b.luminance - a.luminance;
        }
        return b.coverage - a.coverage;
      }
      if (sortMode === 'coverage') {
        if (b.coverage !== a.coverage) {
          return b.coverage - a.coverage;
        }
        return a.luminance - b.luminance;
      }
      if (a.luminance !== b.luminance) {
        return a.luminance - b.luminance;
      }
      return b.coverage - a.coverage;
    });

    const paletteOrder = paletteEntries.map((entry) => entry.index);

    const commands = [];
    for (const paletteIndex of paletteOrder) {
      const colour = palette[paletteIndex];
      const strokes = colourCommands[paletteIndex];
      strokes.sort((a, b) => (phaseOrder[a.phase] ?? 99) - (phaseOrder[b.phase] ?? 99));
      for (const stroke of strokes) {
        const { phase: _phase, ...rest } = stroke;
        commands.push({
          color: colour.hex,
          ...rest,
          pass: 'forward',
        });
      }
    }

    state.metrics.estimatedStrokes = commands.length;
    state.metrics.estimatedDurationMs = commands.length * 8;
    const metricsSnapshot = {
      pixelCount: state.metrics.pixelCount,
      paletteCount: palette.length,
      estimatedStrokes: state.metrics.estimatedStrokes,
      estimatedDurationMs: state.metrics.estimatedDurationMs,
      laneCount: state.metrics.laneCount,
      scaleFactor: scale,
      boardWidth,
      boardHeight,
      targetWidth,
      targetHeight,
      selectionActive,
    };
    Object.assign(state.metrics, metricsSnapshot);
    state.commandCache = { key: cacheKey, commands, metrics: metricsSnapshot };
    updateMetricsUI();
    updateSelectionUI();
    renderPaletteInsights(palette, state.paletteUsage);

    return commands;
  }

  async function streamCommands(commands, socket, delayMs) {
    const total = commands.length;
    let completed = 0;
    ui.progressBar.style.width = '0%';

    for (const command of commands) {
      ensureNotAborted();
      try {
        socket.send(
          `42["drawcmd",0,[${command.nx1},${command.ny1},${command.nx2},${command.ny2},false,-1,"${command.color}",0,0,{}]]`
        );
      } catch (err) {
        console.warn('drawaria image autodraw: socket send failed', err);
      }
      completed += 1;
      if (completed % 50 === 0 || completed === total) {
        const progress = (completed / total) * 100;
        ui.progressBar.style.width = `${progress}%`;
        ui.progressLabel.textContent = `${progress.toFixed(1)}%`;
        ui.status.textContent = `Drawing pixels… ${completed}/${total}`;
      }
      await wait(delayMs);
    }

    ui.progressBar.style.width = '100%';
    ui.progressLabel.textContent = '100%';
    ui.status.textContent = 'Drawing complete.';
  }

  async function runDrawing() {
    if (state.running) {
      return;
    }
    state.running = true;
    state.abortRequested = false;
    ui.startButton.disabled = true;
    ui.stopButton.disabled = false;
    ui.panel.classList.add('running');

    try {
      if (!state.prepared) {
        const file = ui.fileInput.files[0];
        await prepareFromFile(file);
      }
      ensureNotAborted();
      ui.status.textContent = 'Waiting for websocket…';
      const socket = await waitForSocket(5000);
      if (!socket) {
        throw new Error('Could not detect Drawaria websocket. Join a room and try again.');
      }
      const canvas = selectLargestCanvas();
      if (!canvas) {
        throw new Error('Canvas not found. Wait for Drawaria to finish loading.');
      }
      ensureNotAborted();
      ui.status.textContent = 'Mapping pixels to strokes…';
      await wait(10);
      const commands = buildPixelCommands(canvas.width, canvas.height);
      if (!commands.length) {
        throw new Error('No drawable pixels were detected.');
      }
      ensureNotAborted();
      ui.status.textContent = `Streaming ${commands.length} pixels…`;
      await streamCommands(commands, socket, 8);
      ensureNotAborted();
      ui.status.textContent = 'Image rendered successfully!';
    } catch (err) {
      if (err instanceof AbortPainting) {
        ui.status.textContent = 'Drawing aborted.';
      } else {
        console.error('drawaria image autodraw: error', err);
        ui.status.textContent = `Error: ${err.message || err}`;
      }
    } finally {
      state.running = false;
      state.abortRequested = false;
      ui.startButton.disabled = false;
      ui.stopButton.disabled = true;
      ui.panel.classList.remove('running');
    }
  }

  function handleStop() {
    if (!state.running) {
      return;
    }
    state.abortRequested = true;
    ui.status.textContent = 'Finishing current stroke…';
  }

  async function handleStartClick() {
    if (!ui.fileInput.files.length && !state.prepared) {
      ui.status.textContent = 'Please choose an image before drawing.';
      ui.fileInput.classList.add('shake');
      setTimeout(() => ui.fileInput.classList.remove('shake'), 500);
      return;
    }
    await runDrawing();
  }

  async function handleGeneratePreview() {
    const file = ui.fileInput.files[0];
    try {
      await prepareFromFile(file);
      if (state.settings.autoStart && !state.running) {
        ui.status.textContent = 'Auto start enabled — beginning draw…';
        await runDrawing();
      }
    } catch (err) {
      console.error('drawaria image autodraw: preview error', err);
      ui.status.textContent = `Error: ${err.message || err}`;
      ui.previewLoading.classList.remove('visible');
    }
  }

  const handleFileChange = () => {
    state.prepared = false;
    state.commandCache = null;
    if (ui.fileInput.files.length) {
      handleGeneratePreview();
    } else {
      ui.status.textContent = 'Select an image to begin (max 500px).';
      ui.paletteStrip.innerHTML = '';
      ui.paletteSummary.textContent = '0 colours';
      if (ui.paletteSummarySecondary) {
        ui.paletteSummarySecondary.textContent = '0 colours';
      }
      ui.progressBar.style.width = '0%';
      ui.progressLabel.textContent = '0%';
      applyPanelThemeFromPalette([], []);
      renderPaletteInsights([], []);
      updateMetricsUI();
    }
  };

  ui.fileInput.addEventListener('change', handleFileChange);
  registerCleanup(() => ui.fileInput.removeEventListener('change', handleFileChange));

  const handleDimensionInput = () => {
    ui.dimensionValue.textContent = `${ui.dimensionInput.value}px`;
    state.prepared = false;
    state.commandCache = null;
    if (ui.fileInput.files.length) {
      ui.status.textContent = 'Dimension changed — regenerate preview.';
    }
  };

  ui.dimensionInput.addEventListener('input', handleDimensionInput);
  registerCleanup(() => ui.dimensionInput.removeEventListener('input', handleDimensionInput));

  const handlePaletteOrderChange = () => {
    state.paletteSortMode = ui.paletteOrderSelect.value;
    state.commandCache = null;
    if (state.prepared) {
      const labelMap = {
        'dark-first': 'Dark → Light',
        'light-first': 'Light → Dark',
        coverage: 'Coverage Priority',
      };
      ui.status.textContent = `Colour order set to ${labelMap[state.paletteSortMode] || state.paletteSortMode}.`;
      scheduleMetricsUpdate();
    }
  };

  ui.paletteOrderSelect.addEventListener('change', handlePaletteOrderChange);
  registerCleanup(() => ui.paletteOrderSelect.removeEventListener('change', handlePaletteOrderChange));

  if (ui.selectRegionButton) {
    const handleSelectRegion = () => beginCanvasSelection();
    ui.selectRegionButton.addEventListener('click', handleSelectRegion);
    registerCleanup(() => ui.selectRegionButton.removeEventListener('click', handleSelectRegion));
  }

  if (ui.clearRegionButton) {
    const handleClearRegion = () => clearCanvasSelection();
    ui.clearRegionButton.addEventListener('click', handleClearRegion);
    registerCleanup(() => ui.clearRegionButton.removeEventListener('click', handleClearRegion));
  }

  function bindSlider(input, valueEl, key, suffix = '%', options = {}) {
    if (!input || !valueEl) {
      return;
    }
    if (state.settings[key] != null) {
      input.value = state.settings[key];
    }
    const update = () => {
      const rawValue = Number(input.value);
      const value = Number.isFinite(rawValue) ? rawValue : 0;
      valueEl.textContent = `${value}${suffix}`;
      state.settings[key] = value;
      if (options.onChange) {
        options.onChange(value);
      }
      if (options.requiresReprepare) {
        state.prepared = false;
        state.commandCache = null;
        ui.status.textContent = `${options.label || 'Setting'} changed — regenerate preview.`;
      } else {
        state.commandCache = null;
        if (state.prepared) {
          scheduleMetricsUpdate();
        }
      }
      updateModeLabel();
    };
    input.addEventListener('input', update);
    registerCleanup(() => input.removeEventListener('input', update));
    update();
  }

  function bindToggle(button, key, options = {}) {
    if (!button) {
      return;
    }
    const applyState = (active) => {
      button.dataset.active = active ? 'true' : 'false';
      button.setAttribute('aria-pressed', active ? 'true' : 'false');
      state.settings[key] = active;
      if (options.onChange) {
        options.onChange(active);
      }
      if (options.requiresReprepare) {
        state.prepared = false;
        state.commandCache = null;
        ui.status.textContent = `${options.label || 'Setting'} changed — regenerate preview.`;
      } else {
        state.commandCache = null;
        if (state.prepared) {
          scheduleMetricsUpdate();
        }
      }
      updateModeLabel();
    };
    const handleClick = () => {
      const nextState = button.dataset.active !== 'true';
      applyState(nextState);
    };
    button.addEventListener('click', handleClick);
    registerCleanup(() => button.removeEventListener('click', handleClick));
    const initial = options.initial !== undefined ? options.initial : state.settings[key] !== false;
    applyState(initial);
  }

  function bindFunSlider(input, valueEl, key) {
    if (!input || !valueEl) {
      return;
    }
    if (funSettings[key] != null) {
      input.value = funSettings[key];
    }
    const update = () => {
      const rawValue = Number(input.value);
      const value = Number.isFinite(rawValue) ? rawValue : 0;
      funSettings[key] = value;
      valueEl.textContent = `${value}`;
    };
    input.addEventListener('input', update);
    registerCleanup(() => input.removeEventListener('input', update));
    update();
  }

  function bindFunToggle(button, key) {
    if (!button) {
      return;
    }
    const apply = (active) => {
      const stateValue = !!active;
      button.dataset.active = stateValue ? 'true' : 'false';
      button.setAttribute('aria-pressed', stateValue ? 'true' : 'false');
      funSettings[key] = stateValue;
    };
    const handleClick = () => {
      apply(button.dataset.active !== 'true');
    };
    button.addEventListener('click', handleClick);
    registerCleanup(() => button.removeEventListener('click', handleClick));
    apply(funSettings[key]);
  }

  bindSlider(ui.smoothnessInput, ui.smoothnessValue, 'smoothnessPercent');
  bindSlider(ui.laneDensityInput, ui.laneDensityValue, 'laneFanMultiplier');
  bindSlider(ui.coverageInput, ui.coverageValue, 'coverageBoost');
  bindSlider(ui.ditherInput, ui.ditherValue, 'ditherStrength', '%', {
    requiresReprepare: true,
    label: 'Dither strength',
  });
  bindSlider(ui.spectralInput, ui.spectralValue, 'spectralBoost');

  if (ui.detailModeSelect) {
    ui.detailModeSelect.value = state.settings.detailMode;
    const handleDetailModeChange = () => {
      state.settings.detailMode = ui.detailModeSelect.value;
      state.commandCache = null;
      updateModeLabel();
      if (state.prepared) {
        scheduleMetricsUpdate();
      }
    };
    ui.detailModeSelect.addEventListener('change', handleDetailModeChange);
    registerCleanup(() => ui.detailModeSelect.removeEventListener('change', handleDetailModeChange));
  }

  bindToggle(ui.toggleLowRes, 'lowResEnhancer', { label: 'Low-res enhancer' });
  bindToggle(ui.toggleEdge, 'edgeDetail', { label: 'Edge emphasis' });
  bindToggle(ui.toggleMicro, 'microDetail', { label: 'Micro detail' });
  bindToggle(ui.toggleGlaze, 'highlightGlaze', { label: 'Glow glazing' });
  bindToggle(ui.toggleWeave, 'textureWeave', { label: 'Texture weave' });
  bindToggle(ui.toggleEcho, 'gradientEcho', { label: 'Gradient echo' });
  bindToggle(ui.toggleTheme, 'adaptiveTheme', {
    label: 'Adaptive theme',
    onChange(active) {
      applyPanelThemeFromPalette(active ? state.palette : [], active ? state.paletteUsage : []);
    },
  });

  bindFunSlider(ui.funDensityInput, ui.funDensityValue, 'density');
  bindFunSlider(ui.funTempoInput, ui.funTempoValue, 'tempo');
  bindFunToggle(ui.funMirrorToggle, 'mirror');
  bindFunToggle(ui.funJitterToggle, 'jitter');

  if (ui.funModeSelect) {
    const handleFunModeChange = () => {
      updateFunDescription();
      const effect = funFeatures[ui.funModeSelect.value];
      if (effect) {
        updateFunStatus(`${effect.label} ready — press play.`);
      } else {
        updateFunStatus('Select an effect and press play.');
      }
    };
    ui.funModeSelect.addEventListener('change', handleFunModeChange);
    registerCleanup(() => ui.funModeSelect.removeEventListener('change', handleFunModeChange));
    handleFunModeChange();
  } else {
    updateFunDescription();
    updateFunStatus('Select an effect and press play.');
  }

  if (ui.funRunButton) {
    const handleFunRun = () => {
      startFunEffect();
    };
    ui.funRunButton.addEventListener('click', handleFunRun);
    registerCleanup(() => ui.funRunButton.removeEventListener('click', handleFunRun));
  }

  if (ui.funStopButton) {
    const handleFunStop = () => {
      stopFunEffect();
    };
    ui.funStopButton.addEventListener('click', handleFunStop);
    registerCleanup(() => ui.funStopButton.removeEventListener('click', handleFunStop));
    ui.funStopButton.disabled = true;
  }

  updateFunProgress(0);

  if (ui.toggleAutoStart) {
    const setAutoStart = (active) => {
      ui.toggleAutoStart.dataset.active = active ? 'true' : 'false';
      ui.toggleAutoStart.setAttribute('aria-pressed', active ? 'true' : 'false');
      state.settings.autoStart = active;
    };
    const handleAutoStart = () => {
      const next = ui.toggleAutoStart.dataset.active !== 'true';
      setAutoStart(next);
      ui.status.textContent = next
        ? 'Auto start enabled — previews will launch drawing automatically.'
        : 'Auto start disabled.';
    };
    ui.toggleAutoStart.addEventListener('click', handleAutoStart);
    registerCleanup(() => ui.toggleAutoStart.removeEventListener('click', handleAutoStart));
    setAutoStart(state.settings.autoStart);
  }

  ui.previewButton.addEventListener('click', handleGeneratePreview);
  registerCleanup(() => ui.previewButton.removeEventListener('click', handleGeneratePreview));

  ui.startButton.addEventListener('click', handleStartClick);
  registerCleanup(() => ui.startButton.removeEventListener('click', handleStartClick));

  ui.stopButton.addEventListener('click', handleStop);
  registerCleanup(() => ui.stopButton.removeEventListener('click', handleStop));

  ui.closeButton.addEventListener('click', () => {
    state.abortRequested = true;
    runCleanup();
  });

  const socketWatcher = setInterval(() => {
    const socket = wsBridge.getSocket();
    let status = 'searching';
    let label = 'Socket: searching…';
    if (socket) {
      if (socket.readyState === WebSocket.OPEN) {
        status = 'connected';
        label = 'Socket: live';
      } else if (socket.readyState === WebSocket.CONNECTING) {
        status = 'searching';
        label = 'Socket: connecting…';
      } else {
        status = 'offline';
        label = 'Socket: offline';
      }
    }
    if (ui.socketChip && ui.socketChip.dataset.status !== status) {
      ui.socketChip.dataset.status = status;
    }
    if (ui.socketLabel && ui.socketLabel.textContent !== label) {
      ui.socketLabel.textContent = label;
    }
  }, 600);
  registerCleanup(() => clearInterval(socketWatcher));

  const handleExportPreview = () => {
    if (!state.previewDataUrl) {
      ui.status.textContent = 'Generate a preview before exporting.';
      return;
    }
    const link = document.createElement('a');
    link.href = state.previewDataUrl;
    const fileName = `autodraw-${state.pixelWidth || 'image'}x${state.pixelHeight || ''}.png`;
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    ui.status.textContent = 'Preview PNG downloaded.';
  };

  if (ui.exportPreviewButton) {
    ui.exportPreviewButton.addEventListener('click', handleExportPreview);
    registerCleanup(() => ui.exportPreviewButton.removeEventListener('click', handleExportPreview));
  }

  const handleCopyPalette = async () => {
    if (!state.palette.length) {
      ui.status.textContent = 'No palette to copy yet — load an image first.';
      return;
    }
    const paletteText = state.palette.map((colour) => colour.hex).join('\n');
    try {
      await navigator.clipboard.writeText(paletteText);
      ui.status.textContent = 'Palette copied to clipboard.';
    } catch (err) {
      try {
        const textarea = document.createElement('textarea');
        textarea.value = paletteText;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        document.body.removeChild(textarea);
        ui.status.textContent = 'Palette copied to clipboard.';
      } catch (fallbackErr) {
        console.warn('drawaria image autodraw: clipboard copy failed', err, fallbackErr);
        ui.status.textContent = 'Clipboard copy failed. Please copy manually from the console.';
      }
    }
  };

  if (ui.copyPaletteButton) {
    ui.copyPaletteButton.addEventListener('click', handleCopyPalette);
    registerCleanup(() => ui.copyPaletteButton.removeEventListener('click', handleCopyPalette));
  }

  applyPanelThemeFromPalette(state.palette, state.paletteUsage);
  renderPaletteInsights(state.palette, state.paletteUsage);
  updateModeLabel();
  updateMetricsUI();

  window[SCRIPT_HANDLE] = {
    cleanup: runCleanup,
    state,
  };

  ui.status.textContent = 'Select an image to begin (max 500px).';
  updateSelectionUI();

  function selectLargestCanvas() {
    const canvases = Array.from(document.querySelectorAll('canvas'));
    if (!canvases.length) {
      return null;
    }
    return canvases.reduce((largest, candidate) => {
      const largestArea = largest.width * largest.height;
      const candidateArea = candidate.width * candidate.height;
      return candidateArea > largestArea ? candidate : largest;
    }, canvases[0]);
  }

  function clamp(value, min, max) {
    return Math.max(min, Math.min(max, value));
  }

  let activeSelectionTeardown = null;

  function clearActiveSelectionOverlay() {
    if (activeSelectionTeardown) {
      try {
        activeSelectionTeardown();
      } catch (err) {
        console.warn('drawaria image autodraw: selection overlay cleanup failed', err);
      }
      activeSelectionTeardown = null;
    }
  }

  function beginCanvasSelection() {
    if (state.running) {
      ui.status.textContent = 'Stop the current render before selecting a region.';
      return;
    }
    const canvas = selectLargestCanvas();
    if (!canvas) {
      ui.status.textContent = 'Canvas not found — join a room before selecting a region.';
      return;
    }

    clearActiveSelectionOverlay();

    const rect = canvas.getBoundingClientRect();
    const overlay = document.createElement('div');
    overlay.id = 'pxa-selection-overlay';
    overlay.style.left = `${rect.left}px`;
    overlay.style.top = `${rect.top}px`;
    overlay.style.width = `${rect.width}px`;
    overlay.style.height = `${rect.height}px`;

    const selectionBox = document.createElement('div');
    selectionBox.id = 'pxa-selection-box';
    selectionBox.style.display = 'none';
    overlay.appendChild(selectionBox);

    document.body.appendChild(overlay);
    ui.status.textContent = 'Drag a rectangle to place the artwork.';

    let dragging = false;
    let startX = 0;
    let startY = 0;

    const clampToOverlay = (value, axis) => {
      const bounds = overlay.getBoundingClientRect();
      if (axis === 'x') {
        return clamp(value, bounds.left, bounds.right);
      }
      return clamp(value, bounds.top, bounds.bottom);
    };

    const updateBox = (currentX, currentY) => {
      const bounds = overlay.getBoundingClientRect();
      const clampedX = clampToOverlay(currentX, 'x');
      const clampedY = clampToOverlay(currentY, 'y');
      const left = Math.min(startX, clampedX) - bounds.left;
      const top = Math.min(startY, clampedY) - bounds.top;
      const width = Math.max(1, Math.abs(clampedX - startX));
      const height = Math.max(1, Math.abs(clampedY - startY));
      selectionBox.style.left = `${left}px`;
      selectionBox.style.top = `${top}px`;
      selectionBox.style.width = `${width}px`;
      selectionBox.style.height = `${height}px`;
      selectionBox.style.display = 'block';
    };

    const finishSelection = (apply) => {
      window.removeEventListener('mousemove', handlePointerMove, true);
      window.removeEventListener('mouseup', handlePointerUp, true);
      window.removeEventListener('keydown', handleKeyDown, true);

      if (!apply || !dragging) {
        overlay.remove();
        activeSelectionTeardown = null;
        if (apply) {
          ui.status.textContent = 'Canvas region selection cancelled.';
        }
        return;
      }

      const bounds = overlay.getBoundingClientRect();
      const cssWidth = bounds.width || 1;
      const cssHeight = bounds.height || 1;
      const boxRect = selectionBox.getBoundingClientRect();
      const leftPx = clamp(boxRect.left - bounds.left, 0, cssWidth);
      const topPx = clamp(boxRect.top - bounds.top, 0, cssHeight);
      const widthPx = clamp(boxRect.width, 1, cssWidth);
      const heightPx = clamp(boxRect.height, 1, cssHeight);

      const normX = leftPx / cssWidth;
      const normY = topPx / cssHeight;
      const normWidth = widthPx / cssWidth;
      const normHeight = heightPx / cssHeight;

      overlay.remove();
      activeSelectionTeardown = null;

      if (normWidth >= 0.995 && normHeight >= 0.995 && normX <= 0.002 && normY <= 0.002) {
        state.selection = null;
        ui.status.textContent = 'Canvas region reset to full coverage.';
      } else {
        state.selection = {
          normX,
          normY,
          normWidth,
          normHeight,
          boardWidth: canvas.width,
          boardHeight: canvas.height,
        };
        ui.status.textContent = `Region locked (${Math.round(normWidth * canvas.width)}×${Math.round(normHeight * canvas.height)}px).`;
      }

      state.commandCache = null;
      updateSelectionUI();
      if (state.prepared) {
        scheduleMetricsUpdate();
      }
    };

    const handlePointerDown = (event) => {
      if (event.button !== 0) {
        return;
      }
      dragging = true;
      startX = clampToOverlay(event.clientX, 'x');
      startY = clampToOverlay(event.clientY, 'y');
      updateBox(event.clientX, event.clientY);
      event.preventDefault();
    };

    const handlePointerMove = (event) => {
      if (!dragging) {
        return;
      }
      updateBox(event.clientX, event.clientY);
      event.preventDefault();
    };

    const handlePointerUp = (event) => {
      if (event.button !== 0) {
        return;
      }
      event.preventDefault();
      finishSelection(true);
    };

    const handleKeyDown = (event) => {
      if (event.key === 'Escape') {
        event.preventDefault();
        finishSelection(false);
      }
    };

    overlay.addEventListener('mousedown', handlePointerDown, { capture: true, passive: false });
    window.addEventListener('mousemove', handlePointerMove, true);
    window.addEventListener('mouseup', handlePointerUp, true);
    window.addEventListener('keydown', handleKeyDown, true);

    activeSelectionTeardown = () => {
      overlay.removeEventListener('mousedown', handlePointerDown, true);
      window.removeEventListener('mousemove', handlePointerMove, true);
      window.removeEventListener('mouseup', handlePointerUp, true);
      window.removeEventListener('keydown', handleKeyDown, true);
      if (overlay.parentNode) {
        overlay.parentNode.removeChild(overlay);
      }
    };

  }

  function clearCanvasSelection() {
    clearActiveSelectionOverlay();
    if (!state.selection) {
      ui.status.textContent = 'Canvas region already at full coverage.';
      return;
    }
    state.selection = null;
    state.commandCache = null;
    state.metrics.selectionActive = false;
    state.metrics.targetWidth = state.metrics.boardWidth || 0;
    state.metrics.targetHeight = state.metrics.boardHeight || 0;
    updateSelectionUI();
    ui.status.textContent = 'Canvas region reset to full coverage.';
    if (state.prepared) {
      scheduleMetricsUpdate();
    }
  }

  registerCleanup(() => clearActiveSelectionOverlay());

  function createPanel() {
    const style = document.createElement('style');
    style.textContent = `
      #pxa-panel { position: fixed; top: 24px; right: 24px; width: 520px; max-width: calc(100vw - 40px); max-height: calc(100vh - 40px); z-index: 999999; font-family: 'Inter', 'Segoe UI', sans-serif; color: #0f172a; border-radius: 24px; overflow: hidden; box-shadow: 0 32px 110px rgba(15, 23, 42, 0.45); background: linear-gradient(165deg, rgba(15,23,42,0.92), rgba(15,23,42,0.88)), linear-gradient(140deg, var(--pxa-ambient, rgba(148,163,184,0.18)), rgba(226,232,240,0.9)); backdrop-filter: blur(30px); border: 1px solid rgba(148,163,184,0.28); --pxa-accent: #2563eb; --pxa-accent-soft: rgba(37,99,235,0.16); --pxa-accent-dark: #1e3a8a; --pxa-chip-bg: rgba(255,255,255,0.16); --pxa-ambient: rgba(148,163,184,0.22); display: flex; flex-direction: column; }
      #pxa-panel::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(120deg, rgba(255,255,255,0.08), transparent 45%, rgba(255,255,255,0.12)); opacity: 0.9; }
      #pxa-panel::after { content: ''; position: absolute; inset: -40%; background: radial-gradient(circle at 20% 20%, rgba(37,99,235,0.25), transparent 55%), radial-gradient(circle at 80% 10%, rgba(6,182,212,0.18), transparent 45%); filter: blur(0); opacity: 0.75; animation: pxa-ambient 18s ease-in-out infinite alternate; pointer-events: none; }
      #pxa-panel.running { box-shadow: 0 48px 140px rgba(37, 99, 235, 0.55); }
      @keyframes pxa-ambient { 0% { transform: rotate(0deg) scale(1); opacity: 0.8; } 50% { transform: rotate(6deg) scale(1.08); opacity: 0.9; } 100% { transform: rotate(-4deg) scale(1.02); opacity: 0.75; } }
      @media (prefers-reduced-motion: reduce) { #pxa-panel::after { animation: none; } }
      #pxa-head { position: relative; display: flex; align-items: flex-start; gap: 16px; padding: 20px 28px 16px; cursor: grab; color: white; }
      #pxa-logo { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 22px; background: linear-gradient(140deg, rgba(255,255,255,0.18), rgba(255,255,255,0.02)); border: 1px solid rgba(255,255,255,0.24); box-shadow: inset 0 1px 12px rgba(15,23,42,0.45), 0 12px 34px rgba(15,23,42,0.4); font-weight: 700; letter-spacing: 0.12em; font-size: 14px; text-transform: uppercase; }
      #pxa-logo span { display: block; text-align: center; }
      #pxa-logo .pxa-logo-icon { font-size: 20px; }
      #pxa-logo .pxa-logo-sub { font-size: 11px; opacity: 0.7; letter-spacing: 0.24em; }
      #pxa-title { flex: 1; min-width: 0; padding-top: 6px; }
      #pxa-title h1 { margin: 0; font-size: 22px; letter-spacing: 0.08em; font-weight: 700; text-transform: uppercase; }
      #pxa-title p { margin: 6px 0 10px; font-size: 12px; opacity: 0.85; letter-spacing: 0.18em; text-transform: uppercase; }
      #pxa-headline-band { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
      .pxa-chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; background: var(--pxa-chip-bg); border: 1px solid rgba(255,255,255,0.16); box-shadow: inset 0 1px 1px rgba(255,255,255,0.22); backdrop-filter: blur(12px); }
      .pxa-chip-dot { width: 8px; height: 8px; border-radius: 50%; background: #facc15; box-shadow: 0 0 0 6px rgba(250,204,21,0.18); position: relative; }
      #pxa-chip-socket[data-status="connected"] .pxa-chip-dot { background: #34d399; box-shadow: 0 0 0 6px rgba(52,211,153,0.22); }
      #pxa-chip-socket[data-status="offline"] .pxa-chip-dot { background: #f87171; box-shadow: 0 0 0 6px rgba(248,113,113,0.22); }
      #pxa-chip-socket[data-status="searching"] .pxa-chip-dot { animation: pxa-pulse 1.8s ease infinite; }
      @keyframes pxa-pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.35); opacity: 0.6; } }
      .pxa-chip-icon { font-size: 13px; filter: drop-shadow(0 2px 6px rgba(15,23,42,0.45)); }
      #pxa-close { border: none; background: rgba(15,23,42,0.45); color: white; width: 38px; height: 38px; border-radius: 12px; font-size: 16px; cursor: pointer; transition: transform 0.2s ease, background 0.2s ease; box-shadow: 0 12px 22px rgba(15,23,42,0.4); }
      #pxa-close:hover { transform: translateY(-2px) scale(1.04); background: rgba(15,23,42,0.65); }
      #pxa-body { position: relative; padding: 0 28px 20px; display: flex; flex-direction: column; gap: 18px; flex: 1; overflow: hidden; }
      #pxa-body::before { content: ''; position: absolute; inset: 12px 18px 18px; border-radius: 22px; background: linear-gradient(150deg, rgba(248,250,252,0.94), rgba(226,232,240,0.82)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); }
      #pxa-body::after { content: ''; position: absolute; inset: 20px 24px 24px; border-radius: 18px; background: linear-gradient(120deg, rgba(255,255,255,0.25), transparent 60%); opacity: 0.35; pointer-events: none; }
      #pxa-body > * { position: relative; z-index: 2; }
      #pxa-scroll-area { position: relative; flex: 1; overflow-y: auto; padding: 12px 4px 16px 4px; display: flex; flex-direction: column; gap: 18px; scrollbar-color: var(--pxa-accent) rgba(148,163,184,0.16); }
      #pxa-scroll-area::-webkit-scrollbar { width: 8px; }
      #pxa-scroll-area::-webkit-scrollbar-track { background: rgba(148,163,184,0.12); border-radius: 999px; }
      #pxa-scroll-area::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--pxa-accent), var(--pxa-accent-dark)); border-radius: 999px; box-shadow: 0 4px 12px rgba(37,99,235,0.32); }
      .pxa-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; background: rgba(255,255,255,0.72); border-radius: 18px; padding: 8px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.6); border: 1px solid rgba(148,163,184,0.24); }
      .pxa-tab { border: none; border-radius: 12px; padding: 12px 0; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; font-size: 11px; cursor: pointer; color: rgba(30,41,59,0.72); background: rgba(148,163,184,0.12); transition: all 0.22s ease; }
      .pxa-tab:hover { filter: brightness(1.06); }
      .pxa-tab.active { background: linear-gradient(135deg, var(--pxa-accent), var(--pxa-accent-dark)); color: white; box-shadow: 0 14px 28px rgba(37,99,235,0.32); }
      .pxa-tab-panels { display: flex; flex-direction: column; gap: 18px; }
      .pxa-tab-panel { display: none; }
      .pxa-tab-panel.active { display: block; }
      .pxa-section { background: rgba(255,255,255,0.85); border-radius: 22px; padding: 22px 24px 24px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.65), 0 22px 40px rgba(15,23,42,0.1); border: 1px solid rgba(148,163,184,0.28); }
      .pxa-section h2 { margin: 0 0 16px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--pxa-accent-dark); }
      .pxa-controls { display: grid; gap: 16px; }
      .pxa-field { display: grid; gap: 8px; }
      .pxa-label { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: #475569; font-weight: 700; }
      .pxa-input, .pxa-slider, .pxa-select { width: 100%; border: 1px solid rgba(148,163,184,0.45); border-radius: 14px; padding: 11px 14px; font-size: 13px; background: rgba(255,255,255,0.86); color: #0f172a; box-shadow: inset 0 1px 1px rgba(255,255,255,0.9); transition: border 0.2s ease, box-shadow 0.2s ease; }
      .pxa-input:focus, .pxa-slider:focus, .pxa-select:focus { border-color: var(--pxa-accent); box-shadow: 0 0 0 3px rgba(37,99,235,0.18); outline: none; }
      .pxa-slider { -webkit-appearance: none; height: 8px; padding: 0; border-radius: 999px; background: linear-gradient(90deg, rgba(37,99,235,0.75), rgba(6,182,212,0.75)); position: relative; }
      .pxa-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: white; border-radius: 50%; border: 3px solid var(--pxa-accent); box-shadow: 0 6px 16px rgba(37,99,235,0.35); cursor: grab; }
      .pxa-slider::-moz-range-thumb { width: 20px; height: 20px; background: white; border-radius: 50%; border: 3px solid var(--pxa-accent); box-shadow: 0 6px 16px rgba(37,99,235,0.35); cursor: grab; }
      .pxa-value { font-size: 11px; letter-spacing: 0.12em; color: #0f172a; text-transform: uppercase; display: inline-flex; justify-content: flex-end; }
      .pxa-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 12px; }
      .pxa-btn { border: none; border-radius: 14px; padding: 14px 0; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; font-size: 11px; cursor: pointer; transition: transform 0.22s ease, box-shadow 0.22s ease, filter 0.22s ease; position: relative; overflow: hidden; }
      .pxa-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none !important; transform: none !important; filter: none !important; }
      .pxa-btn::after { content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(255,255,255,0.18), transparent 55%); opacity: 0; transition: opacity 0.22s ease; }
      .pxa-btn:hover::after { opacity: 1; }
      .pxa-btn.primary { background: linear-gradient(135deg, var(--pxa-accent), var(--pxa-accent-dark)); color: white; box-shadow: 0 18px 32px rgba(37,99,235,0.35); }
      .pxa-btn.primary:hover { transform: translateY(-2px); box-shadow: 0 24px 42px rgba(30,64,175,0.45); }
      .pxa-btn.secondary { background: rgba(255,255,255,0.92); color: #0f172a; border: 1px solid rgba(148,163,184,0.35); box-shadow: 0 16px 30px rgba(15,23,42,0.12); }
      .pxa-btn.secondary:hover { transform: translateY(-2px); filter: brightness(1.02); }
      .pxa-btn.danger { background: linear-gradient(135deg, #f43f5e, #be123c); color: white; box-shadow: 0 18px 34px rgba(244,63,94,0.36); }
      .pxa-btn.danger:hover { transform: translateY(-2px); }
      .pxa-toggle-row { display: flex; flex-wrap: wrap; gap: 18px; }
      .pxa-toggle-group { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 14px; background: rgba(148,163,184,0.14); border: 1px solid rgba(148,163,184,0.28); }
      .pxa-selection-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 18px; border-radius: 18px; background: rgba(148,163,184,0.18); border: 1px solid rgba(148,163,184,0.32); box-shadow: inset 0 1px 0 rgba(255,255,255,0.18); }
      .pxa-selection-info { display: grid; gap: 6px; min-width: 200px; }
      #pxa-selection-details { font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: #1f2937; }
      #pxa-selection-details[data-state="active"] { color: var(--pxa-accent-dark); }
      .pxa-selection-actions { display: flex; flex-wrap: wrap; gap: 10px; }
      .pxa-mini-btn { border: none; border-radius: 999px; padding: 10px 18px; font-weight: 700; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer; background: rgba(255,255,255,0.92); color: #0f172a; box-shadow: 0 12px 24px rgba(15,23,42,0.16); transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease; }
      .pxa-mini-btn.primary { background: linear-gradient(135deg, var(--pxa-accent), var(--pxa-accent-dark)); color: white; box-shadow: 0 16px 30px rgba(37,99,235,0.32); }
      .pxa-mini-btn:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.02); }
      .pxa-mini-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
      .pxa-switch { position: relative; width: 54px; height: 28px; border-radius: 999px; border: none; cursor: pointer; background: rgba(148,163,184,0.4); transition: background 0.22s ease, box-shadow 0.22s ease; padding: 0; }
      .pxa-switch[data-active="true"] { background: linear-gradient(135deg, var(--pxa-accent), var(--pxa-accent-dark)); box-shadow: 0 10px 20px rgba(37,99,235,0.35); }
      .pxa-switch-handle { position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: white; box-shadow: 0 6px 12px rgba(15,23,42,0.25); transition: transform 0.22s ease; }
      .pxa-switch[data-active="true"] .pxa-switch-handle { transform: translateX(26px); }
      .pxa-preview-grid { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 20px; align-items: stretch; }
      @media (max-width: 720px) { .pxa-preview-grid { grid-template-columns: 1fr; } }
      #pxa-preview-wrapper { position: relative; border-radius: 18px; overflow: hidden; background: linear-gradient(145deg, rgba(15,23,42,0.96), rgba(15,23,42,0.86)); min-height: 220px; border: 1px solid rgba(15,23,42,0.4); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); }
      #pxa-preview { width: 100%; height: 100%; display: block; }
      #pxa-preview::selection { background: transparent; }
      #pxa-preview-loading { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(15,23,42,0.72); color: white; font-weight: 600; font-size: 14px; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
      #pxa-preview-loading.visible { opacity: 1; }
      .pxa-metrics-grid { display: grid; gap: 12px; align-content: flex-start; }
      .pxa-metric-card { background: rgba(15,23,42,0.05); border-radius: 16px; padding: 14px 16px; border: 1px solid rgba(148,163,184,0.25); box-shadow: inset 0 1px 0 rgba(255,255,255,0.65); display: grid; gap: 4px; }
      .pxa-metric-label { font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: #64748b; }
      .pxa-metric-value { font-size: 20px; font-weight: 700; color: #0f172a; letter-spacing: 0.02em; }
      .pxa-metric-sub { font-size: 11px; letter-spacing: 0.1em; color: #475569; text-transform: uppercase; }
      .pxa-secondary-actions { display: grid; gap: 10px; margin-top: 14px; }
      .pxa-secondary-actions .pxa-btn { font-size: 10px; padding: 12px; }
      .pxa-meta { display: flex; justify-content: space-between; align-items: center; font-size: 11px; text-transform: uppercase; color: #475569; letter-spacing: 0.08em; margin-top: 12px; }
      #pxa-progress { position: relative; height: 12px; border-radius: 999px; background: rgba(15,23,42,0.08); overflow: hidden; border: 1px solid rgba(148,163,184,0.24); }
      #pxa-progress-bar { position: absolute; inset: 0; width: 0%; background: linear-gradient(90deg, #22d3ee, var(--pxa-accent)); box-shadow: 0 8px 22px rgba(59,130,246,0.38); transition: width 0.2s ease; }
      #pxa-progress-label { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 11px; font-weight: 700; color: rgba(15,23,42,0.82); letter-spacing: 0.12em; }
      #pxa-status { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: #1f2937; font-weight: 700; margin-top: 12px; }
      #pxa-selection-overlay { position: fixed; z-index: 999998; border: 1px solid rgba(37,99,235,0.45); background: rgba(37,99,235,0.08); box-shadow: 0 0 0 1px rgba(37,99,235,0.32), 0 32px 80px rgba(15,23,42,0.25); cursor: crosshair; backdrop-filter: blur(4px); }
      #pxa-selection-overlay::after { content: 'Drag to place the artwork'; position: absolute; top: 12px; left: 50%; transform: translateX(-50%); padding: 6px 14px; border-radius: 999px; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: white; background: rgba(15,23,42,0.65); box-shadow: 0 12px 24px rgba(15,23,42,0.35); pointer-events: none; }
      #pxa-selection-box { position: absolute; border: 2px dashed rgba(255,255,255,0.85); border-radius: 12px; background: rgba(37,99,235,0.18); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.28), 0 18px 40px rgba(37,99,235,0.25); pointer-events: none; }
      #pxa-footer { padding: 16px 28px 22px; display: grid; gap: 10px; background: rgba(15,23,42,0.04); border-top: 1px solid rgba(148,163,184,0.25); box-shadow: inset 0 1px 0 rgba(255,255,255,0.6); }
      #pxa-palette-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(22px, 1fr)); gap: 6px; border-radius: 16px; padding: 12px; background: rgba(15,23,42,0.06); border: 1px solid rgba(148,163,184,0.32); max-height: 200px; overflow-y: auto; }
      .pxa-swatch { width: 100%; padding-bottom: 100%; border-radius: 10px; position: relative; box-shadow: inset 0 0 0 1px rgba(15,23,42,0.12); }
      .pxa-swatch::after { content: ''; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 1px 0 rgba(255,255,255,0.35); }
      .pxa-swatch[data-dominant="true"] { box-shadow: 0 0 0 2px var(--pxa-accent), inset 0 0 0 1px rgba(15,23,42,0.2); }
      .pxa-fun-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 18px; margin-bottom: 18px; }
      .pxa-fun-note { margin-top: -4px; margin-bottom: 18px; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(15,23,42,0.6); }
      .pxa-fun-actions { display: flex; flex-wrap: wrap; gap: 12px; }
      .pxa-fun-progress { position: relative; width: 100%; height: 12px; border-radius: 999px; background: rgba(148,163,184,0.28); overflow: hidden; margin-top: 20px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.45); }
      .pxa-fun-progress-bar { position: absolute; inset: 0; width: 0%; background: linear-gradient(135deg, rgba(37,99,235,0.85), rgba(6,182,212,0.85)); box-shadow: 0 10px 24px rgba(37,99,235,0.35); transition: width 0.3s ease; }
      .pxa-fun-status { margin-top: 12px; font-size: 12px; letter-spacing: 0.09em; text-transform: uppercase; color: #1f2937; }
      .pxa-fun-description { margin-bottom: 12px; font-size: 12px; letter-spacing: 0.06em; color: rgba(15,23,42,0.75); }
      #pxa-palette-insights { display: grid; gap: 12px; margin-top: 16px; }
      .pxa-insight-card { border-radius: 16px; padding: 16px; background: rgba(37,99,235,0.08); border: 1px solid rgba(37,99,235,0.16); box-shadow: inset 0 1px 0 rgba(255,255,255,0.45); display: grid; gap: 6px; }
      .pxa-insight-card strong { font-size: 14px; letter-spacing: 0.02em; color: var(--pxa-accent-dark); }
      .pxa-insight-card span { font-size: 12px; color: #334155; letter-spacing: 0.04em; }
      .pxa-advanced-grid { display: grid; gap: 18px; }
      .pxa-two-column { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
      #pxa-advanced-note { font-size: 11px; letter-spacing: 0.08em; color: #475569; text-transform: uppercase; }
      input[type='file'].shake { animation: pxa-shake 0.45s ease; }
      @keyframes pxa-shake { 0%, 100% { transform: translateX(0); } 20%, 60% { transform: translateX(-6px); } 40%, 80% { transform: translateX(6px); } }
    `;
    document.head.appendChild(style);

    const panel = document.createElement('div');
    panel.id = 'pxa-panel';
    panel.innerHTML = `
      <div id="pxa-head">
        <div id="pxa-logo"><span class="pxa-logo-icon">PX</span><span class="pxa-logo-sub">Studio</span></div>
        <div id="pxa-title">
          <h1>Autodraw Studio</h1>
          <p>WebSocket Pixel Renderer</p>
          <div id="pxa-headline-band">
            <div class="pxa-chip" id="pxa-chip-socket" data-status="searching"><span class="pxa-chip-dot"></span><span id="pxa-socket-label">Socket: searching…</span></div>
            <div class="pxa-chip" id="pxa-chip-mode"><span class="pxa-chip-icon">✨</span><span id="pxa-mode-label">Balanced detail</span></div>
          </div>
        </div>
        <button id="pxa-close">✕</button>
      </div>
      <div id="pxa-body">
        <div class="pxa-tabs">
          <button class="pxa-tab active" data-tab="setup">Setup</button>
          <button class="pxa-tab" data-tab="preview">Preview</button>
          <button class="pxa-tab" data-tab="palette">Palette</button>
          <button class="pxa-tab" data-tab="advanced">Advanced</button>
          <button class="pxa-tab" data-tab="fun">Fun Lab</button>
        </div>
        <div id="pxa-scroll-area">
          <div class="pxa-tab-panels">
          <section class="pxa-section pxa-tab-panel active" data-tab="setup">
            <h2>Setup</h2>
            <div class="pxa-controls">
              <label class="pxa-field">
                <span class="pxa-label">Image File</span>
                <input id="pxa-file" type="file" accept="image/*" class="pxa-input" />
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Max Dimension (px)</span>
                <input id="pxa-dimension" type="range" min="64" max="500" value="500" class="pxa-slider" />
                <div class="pxa-meta"><span>64px</span><span id="pxa-dimension-value">500px</span></div>
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Colour Order</span>
                <select id="pxa-colour-order" class="pxa-select">
                  <option value="dark-first" selected>Dark → Light</option>
                  <option value="light-first">Light → Dark</option>
                  <option value="coverage">Coverage Priority</option>
                </select>
              </label>
              <div class="pxa-toggle-row">
                <div class="pxa-toggle-group">
                  <span class="pxa-label">Auto start after preview</span>
                  <button type="button" class="pxa-switch" id="pxa-toggle-autostart" data-active="false" aria-pressed="false"><span class="pxa-switch-handle"></span></button>
                </div>
                <div class="pxa-toggle-group">
                  <span class="pxa-label">Adaptive theme</span>
                  <button type="button" class="pxa-switch" id="pxa-toggle-theme" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
                </div>
              </div>
              <div class="pxa-selection-control">
                <div class="pxa-selection-info">
                  <span class="pxa-label">Canvas region</span>
                  <span id="pxa-selection-details">Full canvas coverage</span>
                </div>
                <div class="pxa-selection-actions">
                  <button class="pxa-mini-btn primary" id="pxa-select-region" type="button">Set region</button>
                  <button class="pxa-mini-btn" id="pxa-clear-region" type="button" disabled>Reset</button>
                </div>
              </div>
              <div class="pxa-buttons">
                <button class="pxa-btn secondary" id="pxa-preview-btn">Generate Preview</button>
                <button class="pxa-btn primary" id="pxa-start">Start Drawing</button>
                <button class="pxa-btn danger" id="pxa-stop" disabled>Stop</button>
              </div>
            </div>
          </section>

          <section class="pxa-section pxa-tab-panel" data-tab="preview">
            <h2>Preview & Metrics</h2>
            <div class="pxa-preview-grid">
              <div id="pxa-preview-wrapper">
                <canvas id="pxa-preview" width="420" height="260"></canvas>
                <div id="pxa-preview-loading">Processing…</div>
              </div>
              <div class="pxa-metrics-grid">
                <div class="pxa-metric-card">
                  <span class="pxa-metric-label">Resolution</span>
                  <span class="pxa-metric-value" id="pxa-metric-resolution">—</span>
                  <span class="pxa-metric-sub" id="pxa-metric-scale">Fit-to-canvas ready</span>
                </div>
                <div class="pxa-metric-card">
                  <span class="pxa-metric-label">Palette</span>
                  <span class="pxa-metric-value" id="pxa-metric-palette">0</span>
                  <span class="pxa-metric-sub" id="pxa-metric-palette-note">Up to 1300 colours</span>
                </div>
                <div class="pxa-metric-card">
                  <span class="pxa-metric-label">Stroke Estimate</span>
                  <span class="pxa-metric-value" id="pxa-metric-strokes">0</span>
                  <span class="pxa-metric-sub" id="pxa-metric-lanes">Lane fan ×1</span>
                </div>
                <div class="pxa-metric-card">
                  <span class="pxa-metric-label">Estimated Runtime</span>
                  <span class="pxa-metric-value" id="pxa-metric-eta">0s</span>
                  <span class="pxa-metric-sub" id="pxa-metric-delay">8ms stroke delay</span>
                </div>
              </div>
            </div>
            <div class="pxa-secondary-actions">
              <button class="pxa-btn secondary" id="pxa-export-preview">Download processed PNG</button>
              <button class="pxa-btn secondary" id="pxa-copy-palette">Copy palette to clipboard</button>
            </div>
            <div class="pxa-meta"><span id="pxa-palette-summary">0 colours</span><span>1px brush</span></div>
          </section>

          <section class="pxa-section pxa-tab-panel" data-tab="palette">
            <h2>Palette Insights</h2>
            <div class="pxa-meta"><span id="pxa-palette-summary-secondary">0 colours</span><span>Order linked to setup tab</span></div>
            <div id="pxa-palette-strip"></div>
            <div id="pxa-palette-insights"></div>
          </section>

          <section class="pxa-section pxa-tab-panel" data-tab="advanced">
            <h2>Advanced Painter Controls</h2>
            <div class="pxa-advanced-grid">
              <div class="pxa-two-column">
                <label class="pxa-field">
                  <span class="pxa-label">Smoothness boost</span>
                  <input id="pxa-smoothness" type="range" min="0" max="100" value="40" class="pxa-slider" />
                  <span class="pxa-value" id="pxa-smoothness-value">40%</span>
                </label>
                <label class="pxa-field">
                  <span class="pxa-label">Lane density</span>
                  <input id="pxa-lane-density" type="range" min="60" max="200" value="100" class="pxa-slider" />
                  <span class="pxa-value" id="pxa-lane-density-value">100%</span>
                </label>
              <label class="pxa-field">
                <span class="pxa-label">Coverage padding</span>
                <input id="pxa-coverage" type="range" min="80" max="180" value="100" class="pxa-slider" />
                <span class="pxa-value" id="pxa-coverage-value">100%</span>
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Dither strength</span>
                <input id="pxa-dither" type="range" min="0" max="200" value="100" class="pxa-slider" />
                <span class="pxa-value" id="pxa-dither-value">100%</span>
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Spectral accent</span>
                <input id="pxa-spectral" type="range" min="50" max="200" value="120" class="pxa-slider" />
                <span class="pxa-value" id="pxa-spectral-value">120%</span>
              </label>
            </div>
            <div class="pxa-two-column">
              <label class="pxa-field">
                <span class="pxa-label">Detail cadence</span>
                <select id="pxa-detail-mode" class="pxa-select">
                    <option value="balanced" selected>Balanced</option>
                    <option value="max">Maximum</option>
                    <option value="minimal">Minimal</option>
                  </select>
                </label>
                <div class="pxa-toggle-group">
                  <span class="pxa-label">Low-res enhancer</span>
                  <button type="button" class="pxa-switch" id="pxa-toggle-lowres" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
                </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Edge emphasis</span>
                <button type="button" class="pxa-switch" id="pxa-toggle-edge" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Micro detail</span>
                <button type="button" class="pxa-switch" id="pxa-toggle-micro" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Glow glazing</span>
                <button type="button" class="pxa-switch" id="pxa-toggle-glaze" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Texture weave</span>
                <button type="button" class="pxa-switch" id="pxa-toggle-weave" data-active="false" aria-pressed="false"><span class="pxa-switch-handle"></span></button>
              </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Gradient echo</span>
                <button type="button" class="pxa-switch" id="pxa-toggle-echo" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
            </div>
            <div id="pxa-advanced-note">Changes apply to the next draw and live estimates will update automatically.</div>
          </div>
        </section>
          <section class="pxa-section pxa-tab-panel" data-tab="fun">
            <h2>Fun Lab Experiments</h2>
            <div class="pxa-fun-description" id="pxa-fun-description">Layer playful pointer-driven effects over your canvas without touching the websocket painter.</div>
            <div class="pxa-fun-grid">
              <label class="pxa-field">
                <span class="pxa-label">Effect</span>
                <select id="pxa-fun-mode" class="pxa-select">
                  <option value="aurora">Aurora sweep</option>
                  <option value="vortex">Vortex bloom</option>
                  <option value="firefly">Firefly scatter</option>
                  <option value="cascade">Cascade drapery</option>
                </select>
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Density</span>
                <input id="pxa-fun-density" type="range" min="10" max="120" value="70" class="pxa-slider" />
                <span class="pxa-value" id="pxa-fun-density-value">70</span>
              </label>
              <label class="pxa-field">
                <span class="pxa-label">Tempo</span>
                <input id="pxa-fun-tempo" type="range" min="10" max="160" value="60" class="pxa-slider" />
                <span class="pxa-value" id="pxa-fun-tempo-value">60</span>
              </label>
            </div>
            <div class="pxa-toggle-row">
              <div class="pxa-toggle-group">
                <span class="pxa-label">Mirror sweeps</span>
                <button type="button" class="pxa-switch" id="pxa-fun-toggle-mirror" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
              <div class="pxa-toggle-group">
                <span class="pxa-label">Jitter accents</span>
                <button type="button" class="pxa-switch" id="pxa-fun-toggle-jitter" data-active="true" aria-pressed="true"><span class="pxa-switch-handle"></span></button>
              </div>
            </div>
            <div class="pxa-fun-note">Prep your brush size &amp; colour in Drawaria before pressing play — the fun lab reuses whatever is active.</div>
            <div class="pxa-fun-actions">
              <button class="pxa-btn primary" id="pxa-fun-run" type="button">Play effect</button>
              <button class="pxa-btn danger" id="pxa-fun-stop" type="button" disabled>Stop</button>
            </div>
            <div class="pxa-fun-progress"><div class="pxa-fun-progress-bar" id="pxa-fun-progress-bar"></div></div>
            <div class="pxa-fun-status" id="pxa-fun-status">Select an effect and press play.</div>
          </section>
          </div>
        </div>
      </div>
      <div id="pxa-footer">
        <div id="pxa-progress"><div id="pxa-progress-bar"></div><div id="pxa-progress-label">0%</div></div>
        <div id="pxa-status">Select an image to begin (max 500px).</div>
      </div>
    `;
    document.body.appendChild(panel);

    const head = panel.querySelector('#pxa-head');
    let dragOffsetX = 0;
    let dragOffsetY = 0;
    let dragging = false;

    const handlePointerDown = (event) => {
      dragging = true;
      const rect = panel.getBoundingClientRect();
      dragOffsetX = event.clientX - rect.left;
      dragOffsetY = event.clientY - rect.top;
      panel.style.transition = 'none';
      head.style.cursor = 'grabbing';
      event.preventDefault();
    };

    const handlePointerMove = (event) => {
      if (!dragging) return;
      panel.style.left = `${event.clientX - dragOffsetX}px`;
      panel.style.top = `${event.clientY - dragOffsetY}px`;
      panel.style.right = 'auto';
      panel.style.bottom = 'auto';
    };

    const handlePointerUp = () => {
      dragging = false;
      head.style.cursor = 'grab';
      panel.style.transition = '';
    };

    head.addEventListener('mousedown', handlePointerDown);
    window.addEventListener('mousemove', handlePointerMove);
    window.addEventListener('mouseup', handlePointerUp);

    registerCleanup(() => {
      head.removeEventListener('mousedown', handlePointerDown);
      window.removeEventListener('mousemove', handlePointerMove);
      window.removeEventListener('mouseup', handlePointerUp);
    });

    const tabButtons = Array.from(panel.querySelectorAll('.pxa-tab'));
    const tabPanels = Array.from(panel.querySelectorAll('.pxa-tab-panel'));

    const activateTab = (name) => {
      tabButtons.forEach((btn) => {
        btn.classList.toggle('active', btn.dataset.tab === name);
      });
      tabPanels.forEach((tabPanel) => {
        tabPanel.classList.toggle('active', tabPanel.dataset.tab === name);
      });
    };

    tabButtons.forEach((btn) => {
      const onClick = () => activateTab(btn.dataset.tab);
      btn.addEventListener('click', onClick);
      registerCleanup(() => btn.removeEventListener('click', onClick));
    });

    activateTab('setup');

    return {
      panel,
      style,
      fileInput: panel.querySelector('#pxa-file'),
      dimensionInput: panel.querySelector('#pxa-dimension'),
      previewCanvas: panel.querySelector('#pxa-preview'),
      previewButton: panel.querySelector('#pxa-preview-btn'),
      startButton: panel.querySelector('#pxa-start'),
      stopButton: panel.querySelector('#pxa-stop'),
      closeButton: panel.querySelector('#pxa-close'),
      status: panel.querySelector('#pxa-status'),
      progressBar: panel.querySelector('#pxa-progress-bar'),
      progressLabel: panel.querySelector('#pxa-progress-label'),
      paletteStrip: panel.querySelector('#pxa-palette-strip'),
      paletteSummary: panel.querySelector('#pxa-palette-summary'),
      paletteSummarySecondary: panel.querySelector('#pxa-palette-summary-secondary'),
      paletteInsights: panel.querySelector('#pxa-palette-insights'),
      selectionDetails: panel.querySelector('#pxa-selection-details'),
      selectRegionButton: panel.querySelector('#pxa-select-region'),
      clearRegionButton: panel.querySelector('#pxa-clear-region'),
      previewLoading: panel.querySelector('#pxa-preview-loading'),
      dimensionValue: panel.querySelector('#pxa-dimension-value'),
      paletteOrderSelect: panel.querySelector('#pxa-colour-order'),
      socketChip: panel.querySelector('#pxa-chip-socket'),
      socketLabel: panel.querySelector('#pxa-socket-label'),
      modeLabel: panel.querySelector('#pxa-mode-label'),
      metricResolution: panel.querySelector('#pxa-metric-resolution'),
      metricScale: panel.querySelector('#pxa-metric-scale'),
      metricPalette: panel.querySelector('#pxa-metric-palette'),
      metricPaletteNote: panel.querySelector('#pxa-metric-palette-note'),
      metricStrokes: panel.querySelector('#pxa-metric-strokes'),
      metricLanes: panel.querySelector('#pxa-metric-lanes'),
      metricEta: panel.querySelector('#pxa-metric-eta'),
      metricDelay: panel.querySelector('#pxa-metric-delay'),
      exportPreviewButton: panel.querySelector('#pxa-export-preview'),
      copyPaletteButton: panel.querySelector('#pxa-copy-palette'),
      smoothnessInput: panel.querySelector('#pxa-smoothness'),
      smoothnessValue: panel.querySelector('#pxa-smoothness-value'),
      laneDensityInput: panel.querySelector('#pxa-lane-density'),
      laneDensityValue: panel.querySelector('#pxa-lane-density-value'),
      coverageInput: panel.querySelector('#pxa-coverage'),
      coverageValue: panel.querySelector('#pxa-coverage-value'),
      ditherInput: panel.querySelector('#pxa-dither'),
      ditherValue: panel.querySelector('#pxa-dither-value'),
      spectralInput: panel.querySelector('#pxa-spectral'),
      spectralValue: panel.querySelector('#pxa-spectral-value'),
      detailModeSelect: panel.querySelector('#pxa-detail-mode'),
      toggleLowRes: panel.querySelector('#pxa-toggle-lowres'),
      toggleEdge: panel.querySelector('#pxa-toggle-edge'),
      toggleMicro: panel.querySelector('#pxa-toggle-micro'),
      toggleGlaze: panel.querySelector('#pxa-toggle-glaze'),
      toggleWeave: panel.querySelector('#pxa-toggle-weave'),
      toggleEcho: panel.querySelector('#pxa-toggle-echo'),
      toggleTheme: panel.querySelector('#pxa-toggle-theme'),
      toggleAutoStart: panel.querySelector('#pxa-toggle-autostart'),
      funModeSelect: panel.querySelector('#pxa-fun-mode'),
      funDensityInput: panel.querySelector('#pxa-fun-density'),
      funDensityValue: panel.querySelector('#pxa-fun-density-value'),
      funTempoInput: panel.querySelector('#pxa-fun-tempo'),
      funTempoValue: panel.querySelector('#pxa-fun-tempo-value'),
      funMirrorToggle: panel.querySelector('#pxa-fun-toggle-mirror'),
      funJitterToggle: panel.querySelector('#pxa-fun-toggle-jitter'),
      funRunButton: panel.querySelector('#pxa-fun-run'),
      funStopButton: panel.querySelector('#pxa-fun-stop'),
      funProgressBar: panel.querySelector('#pxa-fun-progress-bar'),
      funStatus: panel.querySelector('#pxa-fun-status'),
      funDescription: panel.querySelector('#pxa-fun-description'),
    };
  }

  function installSocketBridge() {
    const HANDLE = '__drawariaAutodrawSocketBridge';
    if (window[HANDLE]) {
      window[HANDLE].refCount += 1;
      return window[HANDLE];
    }

    const sockets = new Set();
    const originalSend = WebSocket.prototype.send;

    function track(socket) {
      if (sockets.has(socket)) {
        return;
      }
      sockets.add(socket);
      socket.addEventListener('close', () => sockets.delete(socket));
      socket.addEventListener('error', () => sockets.delete(socket));
    }

    function patchedSend(...args) {
      track(this);
      return originalSend.apply(this, args);
    }

    WebSocket.prototype.send = patchedSend;

    const bridge = {
      refCount: 1,
      release() {
        bridge.refCount -= 1;
        if (bridge.refCount <= 0) {
          WebSocket.prototype.send = originalSend;
          sockets.clear();
          delete window[HANDLE];
        }
      },
      getSocket() {
        const list = Array.from(sockets);
        for (let i = list.length - 1; i >= 0; i--) {
          const socket = list[i];
          if (socket && socket.readyState === WebSocket.OPEN) {
            return socket;
          }
        }
        return null;
      },
    };

    window[HANDLE] = bridge;
    return bridge;
  }
})();