Chat Deck — 5x3 (images)

5x3 square deck;

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Chat Deck — 5x3 (images)
// @namespace    aravvn.tools
// @version      3.4.0
// @description  5x3 square deck;
// @author       aravvn
// @match        https://chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @match        https://www.testbed.cb.dev/*
// @exclude      https://chaturbate.com/
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @icon         https://chaturbate.com/favicon.ico
// @license      CC-BY-NC-SA-4.0
// ==/UserScript==

(() => {
  "use strict";

  /* ---------- storage keys ---------- */
  const K_ROOMS  = 'chatdeck.rooms.v3'; // bump: now includes {img}
  const K_UI     = 'chatdeck.ui.v3';
  const K_KEYS   = 'chatdeck.keys.v1';

  /* ---------- utils ---------- */
  const S = {
    get: (k,d)=>Promise.resolve(GM_getValue(k,d)),
    set: (k,v)=>Promise.resolve(GM_setValue(k,v)),
    el: (t,o={})=>Object.assign(document.createElement(t), o),
    clamp:(n,a,b)=>Math.max(a,Math.min(b,n)),
    nowTime:()=>new Date().toLocaleTimeString()
  };
  const qs = (sel,root=document)=>root.querySelector(sel);
  const esc = (s)=> (s??'').toString().replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  const EMPTY15 = () => Array.from({length:15}, ()=>({label:'', text:'', img:''}));

  /* ---------- image helpers ---------- */
  async function fileToDataUrl(file){
    const buf = await file.arrayBuffer();
    const blob = new Blob([buf], {type: file.type || 'application/octet-stream'});
    return await new Promise(res=>{
      const r = new FileReader();
      r.onload = () => res(r.result);
      r.readAsDataURL(blob);
    });
  }
  async function resizeImageDataURL(dataURL, maxSide=320, quality=0.85){
    // draw to canvas and export JPEG (unless original is webp and smaller side)
    const img = new Image();
    img.crossOrigin = 'anonymous';
    const p = new Promise((resolve, reject)=>{
      img.onload = () => resolve();
      img.onerror = reject;
    });
    img.src = dataURL;
    await p;
    const {width:w, height:h} = img;
    const scale = Math.min(1, maxSide / Math.max(w,h));
    const tw = Math.max(1, Math.round(w * scale));
    const th = Math.max(1, Math.round(h * scale));
    const can = document.createElement('canvas');
    can.width = tw; can.height = th;
    const ctx = can.getContext('2d', {alpha: false});
    ctx.drawImage(img, 0, 0, tw, th);
    // prefer JPEG to keep size tiny; if you want WebP, switch mime below
    return can.toDataURL('image/jpeg', quality);
  }
  async function fileToSmallDataUrl(file){
    const raw = await fileToDataUrl(file);
    // if already tiny (<150KB), keep; else downscale
    const estSize = Math.ceil((raw.length * 3) / 4); // base64 to bytes approx
    if (estSize < 150 * 1024) return raw;
    return await resizeImageDataURL(raw, 320, 0.85);
  }

  /* ---------- defaults ---------- */
  const DEFAULT_UI = {
    x: 20, y: 80,
    opacity: 0.20,
    hoverOpacity: 0.99,
    zIndex: 2147483646,
    gap: 6,
    cellMin: 96,
    fontSize: 12,
    minimized: false,
    idleScale: 0.90,
    hoverScale: 1.00,
    hotkeysEnabled: true
  };
  const DEFAULT_KEYS = Array.from({ length: 15 }, () => null);

  /* ---------- state ---------- */
  let UI, ROOMS, KEYS, ROOM, DATA, HOTKEYS;
  let root, shadow;

  /* ---------- hotkey helpers ---------- */
  function hk(code, ctrl=false, alt=false, shift=false, meta=false){
    return { code, ctrl, alt, shift, meta };
  }
  function codeToHuman(code){ if(!code) return ''; return code.replace(/^Key/,'').replace(/^Digit/,''); }
  function hotkeyToText(h){
    if(!h) return '—';
    const mods=[];
    if(h.ctrl) mods.push('Ctrl');
    if(h.alt) mods.push('Alt');
    if(h.shift) mods.push('Shift');
    if(h.meta) mods.push(navigator.platform.includes('Mac')?'⌘':'Meta');
    const base = h.code ? codeToHuman(h.code) : '';
    return (mods.concat([base]).filter(Boolean)).join('+') || '—';
  }
  function matchHotkey(e, h){
    if(!h) return false;
    return (
      !!e.code && e.code === h.code &&
      (!!e.ctrlKey)  === !!h.ctrl &&
      (!!e.altKey)   === !!h.alt &&
      (!!e.shiftKey) === !!h.shift &&
      (!!e.metaKey)  === !!h.meta
    );
  }

  /* ---------- room detection ---------- */
  function detectRoom(){
    const parts = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
    if (!parts[0]) return '';
    if (parts[0]==='b') return parts[1]||'';
    return parts[0];
  }

  /* ---------- CSRF + username ---------- */
  function getCSRF(){
    const m = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
    if (m) return decodeURIComponent(m[1]);
    const inp = document.querySelector('input[name="csrfmiddlewaretoken"]');
    if (inp?.value) return inp.value;
    const meta = document.querySelector('meta[name="csrf-token"], meta[name="csrfmiddlewaretoken"]');
    if (meta?.content) return meta.content;
    return '';
  }
  function getUsername(){
    const cookieMap = Object.fromEntries(document.cookie.split(';').map(s=>{
      const i=s.indexOf('='); if(i<0) return [s.trim(),''];
      return [decodeURIComponent(s.slice(0,i).trim()), decodeURIComponent(s.slice(i+1))];
    }));
    for (const k of ['username','cb_username','cb_user']) if (cookieMap[k]) return cookieMap[k];
    try{
      const w=window;
      if (w.CB?.user?.username) return String(w.CB.user.username);
      if (w.user?.username) return String(w.user.username);
      if (w.username) return String(w.username);
      if (w.currentUser?.username) return String(w.currentUser.username);
    }catch{}
    const el = document.querySelector('[data-username],[data-user-name]');
    if (el?.dataset?.username) return el.dataset.username;
    if (el?.dataset?.userName) return el.dataset.userName;
    return '';
  }

  /* ---------- API send (push_service only) ---------- */
  async function sendMessage(text){
    if (!text) return;
    const csrf = getCSRF();
    ROOM = detectRoom();
    if (!ROOM) { alert('ChatDeck: cannot detect room from URL.'); return; }
    if (!csrf) { alert('ChatDeck: CSRF token not found.'); return; }

    const body = new URLSearchParams();
    body.set('room', ROOM);
    body.set('message', JSON.stringify({ m: text.replace(/\{time\}/g, S.nowTime()) }));
    const user = getUsername(); if (user) body.set('username', user);
    body.set('csrfmiddlewaretoken', csrf);

    const res = await fetch('/push_service/publish_chat_message_live/', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'X-CSRFToken': csrf,
        'X-Requested-With': 'XMLHttpRequest',
      },
      body
    });
    if (!res.ok){
      const t=await res.text().catch(()=>String(res.status));
      alert('ChatDeck: push_service error: ' + res.status);
      console.error('[ChatDeck] push_service error:', res.status, t?.slice(0,300));
    }
  }

  /* ---------- UI ---------- */
  function ensureUI(){
    if (root) return;
    root=S.el('div'); root.attachShadow({mode:'open'}); shadow=root.shadowRoot; document.documentElement.appendChild(root);
    const style=S.el('style'); shadow.append(style);

    const wrap=S.el('div',{id:'deck'}); shadow.append(wrap);
    const fab=S.el('button',{id:'fab', title:'Open Chat Deck'}); fab.textContent='Chat Deck';
    shadow.append(fab);

    wrap.innerHTML = `
      <div id="hdr">
        <span class="title">Chat Deck</span>
        <div class="actions">
          <button id="hotkeysBtn" title="Edit hotkeys for this room">Hotkeys</button>
          <button id="editRoom"   title="Edit labels/texts/images for this room">Edit Room</button>
          <button id="copyDefault" title="Copy default into this room">Copy Default Here</button>
          <button id="saveDefault" title="Use this room setup as default">Use as Default</button>
          <button id="minBtn"     title="Minimize">—</button>
        </div>
      </div>
      <div id="grid"></div>
    `;

    wireDrag(wrap, qs('#hdr', shadow));

    // handlers
    qs('#hotkeysBtn', shadow).onclick = openHotkeyEditor;
    qs('#editRoom',   shadow).onclick = openEditorForRoom;
    qs('#copyDefault',shadow).onclick = copyDefaultHere;
    qs('#saveDefault',shadow).onclick = saveAsDefault;
    qs('#minBtn',     shadow).onclick = minimize;
    fab.onclick = restore;

    renderAll();
    applyMinimized();
    registerMenu();
    window.addEventListener('keydown', onKeyDown, true);
  }

  function css(){
    const u=UI, gap=u.gap, fs=u.fontSize, cellMin=u.cellMin;
    return `
:host{ all:initial }
#deck{
  position:fixed; top:${u.y}px; left:${u.x}px;
  z-index:${u.zIndex};
  font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
  color:#e5e7eb; user-select:none;
  border-radius:12px;
  opacity:${u.opacity};
  transform: scale(${u.idleScale});
  transform-origin: top left;
  background: rgba(0,0,0,.06);
  border: 1px dashed rgba(255,255,255,.14);
  transition: opacity .14s ease, transform .14s ease, box-shadow .14s ease, background .14s ease, border-color .14s ease;
}
#deck:hover{
  opacity:${u.hoverOpacity};
  transform: scale(${u.hoverScale});
  background: rgba(0,0,0,.65);
  border-color: rgba(255,255,255,.22);
  box-shadow: 0 16px 44px rgba(0,0,0,.38);
}
:host([data-min]) #deck{ display:none; }

/* FAB */
#fab{
  position:fixed; right:16px; bottom:16px;
  display:none;
  padding:8px 12px; font-size:12px;
  background: rgba(0,0,0,.70);
  color:#e5e7eb; border:1px solid rgba(255,255,255,.28);
  border-radius:999px; cursor:pointer; z-index:${u.zIndex};
  box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
#fab:hover{ background: rgba(0,0,0,.84); }
:host([data-min]) #fab{ display:block; }

/* Header */
#hdr{
  display:flex; align-items:center; justify-content:space-between;
  padding:6px 8px; margin-bottom:${gap}px;
  background: rgba(0,0,0,.14);
  border: 1px solid rgba(255,255,255,.12);
  border-radius:10px;
  cursor:move;
}
#deck:hover #hdr{
  background: rgba(255,255,255,.06);
  border-color: rgba(255,255,255,.24);
}

/* Controls */
#hdr .actions button{
  margin-left:6px; padding:3px 8px; font-size:12px;
  background: rgba(0,0,0,.35); color:#e5e7eb;
  border:1px solid rgba(255,255,255,.18); border-radius:8px; cursor:pointer;
}
#hdr .actions button:hover{ background: rgba(0,0,0,.55); }

/* Grid */
#grid{
  display:grid;
  grid-template-columns: repeat(5, minmax(${cellMin}px, 1fr));
  gap:${gap}px;
  padding:${gap}px;
  background: rgba(255,255,255,.04);
  border: 1px dashed rgba(255,255,255,.12);
  border-radius:12px;
  width: max-content;
  max-width: 95vw;
  transition: background .14s ease, border-color .14s ease;
}
#deck:hover #grid{
  background: rgba(255,255,255,.08);
  border-color: rgba(255,255,255,.22);
}

/* Buttons */
.btn{
  position:relative;
  display:flex; align-items:center; justify-content:center;
  aspect-ratio: 1 / 1;
  min-width:${cellMin}px;
  font-size:${fs}px; text-align:center;
  background: rgba(51,65,85,.22);
  color:#f1f5f9;
  border:1px solid rgba(255,255,255,.12);
  border-radius:10px;
  padding:6px; box-sizing:border-box;
  cursor:pointer;
  transition: background .12s ease, border-color .12s ease, transform .06s, box-shadow .12s ease;
  overflow:hidden;
  -webkit-mask-image:-webkit-radial-gradient(white, black);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
#deck:hover .btn{
  background-color: rgba(51,65,85,.34);
  border-color: rgba(255,255,255,.20);
}
.btn:hover{
  background-color: rgba(51,65,85,.78);
  border-color: rgba(255,255,255,.42);
  box-shadow: 0 6px 16px rgba(0,0,0,.35);
}
.btn:active{ transform: scale(.98); }

.btn.hasimg{
  color:#f8fafc;
}
.btn::before{
  content:"";
  position:absolute; inset:0;
  background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,.10));
  pointer-events:none;
  opacity:0; transition:opacity .12s ease;
}
#deck:hover .btn.hasimg::before{ opacity:1; }

.btn .name{
  position:relative;
  z-index:1;
  pointer-events:none;
  display:block;
  line-height:1.15;
  max-width:100%;
  max-height:90%;
  word-break:break-word;
  overflow:hidden;
  text-shadow: 0 1px 2px rgba(0,0,0,.6);
}

/* Modal (shared) */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:2147483647}
.modal{ width:min(920px,96vw);max-height:90vh;overflow:auto;background:#0b1324;color:#e5e7eb;border:1px solid #334155;border-radius:12px;padding:16px }
.modal h2{margin:0 0 10px}
.modal .grid15{ display:grid; grid-template-columns: repeat(5, 1fr); gap:8px; }
.tile{ background:#0b1426; border:1px solid #1f2940; border-radius:10px; padding:8px; }
.tile label{ display:block; font-size:11px; opacity:.8; margin-bottom:4px }
.tile input, .tile textarea{ width:100%; box-sizing:border-box; background:#091021; color:#e5e7eb; border:1px solid #334155; border-radius:8px; padding:6px 8px; font-family:inherit }
.tile input{ margin-bottom:6px }
.tile textarea{ height:64px; resize:vertical }
.modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.modal button{background:#0b1324;color:#e5e7eb;border:1px solid #334155;border-radius:8px;padding:6px 10px;cursor:pointer}
.modal .danger{border-color:#7f1d1d;color:#fecaca}

/* Hotkey editor */
.hkrow{ display:flex; align-items:center; justify-content:space-between; background:#0b1426; border:1px solid #1f2940; border-radius:10px; padding:8px; }
.hkname{ font-weight:600; opacity:.9 }
.hklabel{ font-family:monospace; background:#111827; border:1px solid #374151; border-radius:6px; padding:4px 8px; font-size:12px; }

/* Image picker in tiles */
.imgpick{ display:flex; align-items:center; gap:6px; margin-top:6px; }
.imgpick input[type="file"]{ padding:4px 6px; }
.thumb{ width:100%; height:72px; object-fit:cover; border:1px solid #334155; border-radius:8px; background:#0a0f1e; }
.clearbtn{ margin-left:auto; }
`;
  }

  function ensureDataShape(arr){
    if (!Array.isArray(arr)) return EMPTY15();
    if (arr.length !== 15) arr = Array.from({length:15}, (_,i)=>arr[i] ?? {label:'', text:'', img:''});
    if (typeof arr[0] === 'string'){
      return arr.map((t,i)=>({label: t ? `Button ${i+1}` : '', text: String(t), img:''}));
    }
    return arr.map((o,i)=>({
      label: o?.label ?? (o?.text ? `Button ${i+1}` : ''),
      text:  o?.text ?? '',
      img:   o?.img ?? ''
    }));
  }
  function ensureKeysShape(arr){
    if (!Array.isArray(arr)) return Array.from({length:15}, (_,i)=>DEFAULT_KEYS[i] ?? null);
    const out = Array.from({length:15}, (_,i)=>arr[i] ?? null);
    return out.map(h=>{
      if (!h) return null;
      if (typeof h.code !== 'string') return null;
      return { code: h.code, ctrl:!!h.ctrl, alt:!!h.alt, shift:!!h.shift, meta:!!h.meta };
    });
  }

  function renderAll(){
    const style = qs('style', shadow); style.textContent = css();
    const deck = qs('#deck', shadow);
    deck.style.top = UI.y+'px';
    deck.style.left= UI.x+'px';
    renderGrid();
  }

  function renderGrid(){
    const grid = qs('#grid', shadow); grid.innerHTML='';
    DATA.forEach((it, i)=>{
      const el=S.el('div',{className:'btn', title:`${hotkeyToText(HOTKEYS[i])} — Click=send • Shift+Click=edit`});
      const span=S.el('span',{className:'name'});
      span.textContent = it.label || `Button ${i+1}`;

      if (it.img){
        el.classList.add('hasimg');
        // two-layer bg: gradient handled by ::before, here we just set the image
        el.style.backgroundImage = `url(${it.img})`;
      }else{
        el.style.backgroundImage = '';
      }

      el.append(span);
      el.addEventListener('click', (e)=>{
        if (e.shiftKey){ openSingleEditor(i); return; }
        sendMessage(it.text);
      });
      grid.append(el);
    });
  }

  /* ---------- modal base ---------- */
  function modal(){
    const back=S.el('div',{className:'modal-backdrop'});
    const box=S.el('div',{className:'modal'});
    back.append(box);
    return { box, mount(){ shadow.append(back); }, unmount(){ back.remove(); } };
  }

  /* ---------- single editor ---------- */
  function openSingleEditor(idx){
    const {box, mount, unmount} = modal();
    const it = DATA[idx] || {label:'', text:'', img:''};
    box.innerHTML = `
      <h2>Edit Button ${idx+1} — Room “${esc(ROOM)}”</h2>
      <div class="tile">
        <label>Label (shown on button)</label>
        <input id="lbl" value="${esc(it.label)}" placeholder="Button ${idx+1}">
        <label>Text to send (supports {time})</label>
        <textarea id="txt" rows="4" placeholder="Enter message...">${esc(it.text)}</textarea>
        <label>Background image (optional, stored locally)</label>
        <img id="thumb" class="thumb" src="${it.img?esc(it.img):''}" alt="">
        <div class="imgpick">
          <input id="file" type="file" accept="image/*">
          <button id="clearImg" class="danger clearbtn" ${it.img?'':'disabled'}>Clear Image</button>
        </div>
      </div>
      <div class="actions">
        <button id="cancel">Cancel</button>
        <button id="save">Save</button>
      </div>
    `;
    mount();

    const $file = box.querySelector('#file');
    const $thumb= box.querySelector('#thumb');
    const $clear= box.querySelector('#clearImg');
    let newImg = it.img || '';

    $file.onchange = async (e)=>{
      const f = e.target.files?.[0];
      if (!f) return;
      try{
        const small = await fileToSmallDataUrl(f);
        newImg = small;
        $thumb.src = small;
        $clear.disabled = false;
      }catch(err){
        alert('Failed to load image');
        console.error(err);
      }
    };
    $clear.onclick = ()=>{
      newImg = '';
      $thumb.removeAttribute('src');
      $clear.disabled = true;
    };

    box.querySelector('#cancel').onclick = unmount;
    box.querySelector('#save').onclick = async ()=>{
      const lbl = box.querySelector('#lbl').value.trim();
      const txt = box.querySelector('#txt').value;
      DATA[idx] = { label: lbl, text: txt, img: newImg };
      ROOMS[ROOM] = DATA.slice();
      await S.set(K_ROOMS, ROOMS);
      renderGrid(); unmount();
    };
  }

  /* ---------- room editor ---------- */
  function openEditorForRoom(){
    const {box, mount, unmount} = modal();
    const tiles = DATA.map((it,i)=>`
      <div class="tile" data-i="${i}">
        <label>Button ${i+1} — Label</label>
        <input data-k="label" value="${esc(it.label)}" placeholder="Button ${i+1}">
        <label>Text (supports {time})</label>
        <textarea data-k="text" placeholder="Message to send...">${esc(it.text)}</textarea>
        <label>Background image (optional)</label>
        <img class="thumb" data-role="thumb" src="${it.img?esc(it.img):''}" alt="">
        <div class="imgpick">
          <input type="file" accept="image/*" data-role="file">
          <button class="danger clearbtn" data-role="clear" ${it.img?'':'disabled'}>Clear</button>
        </div>
      </div>
    `).join('');
    box.innerHTML = `
      <h2>Edit Room “${esc(ROOM)}”</h2>
      <div class="grid15">${tiles}</div>
      <div class="actions">
        <button id="cancel">Cancel</button>
        <button id="clear" class="danger">Clear All</button>
        <button id="save">Save</button>
      </div>
    `;
    mount();

    // image handlers per tile
    box.querySelectorAll('.tile').forEach(tile=>{
      const i = +tile.dataset.i;
      const file = tile.querySelector('[data-role="file"]');
      const clear= tile.querySelector('[data-role="clear"]');
      const thumb= tile.querySelector('[data-role="thumb"]');

      file.onchange = async (e)=>{
        const f = e.target.files?.[0];
        if (!f) return;
        try{
          const small = await fileToSmallDataUrl(f);
          thumb.src = small;
          clear.disabled = false;
          // keep in temp Data until Save
          DATA[i] = {...DATA[i], img: small};
        }catch(err){
          alert('Failed to load image');
          console.error(err);
        }
      };
      clear.onclick = ()=>{
        thumb.removeAttribute('src');
        clear.disabled = true;
        DATA[i] = {...DATA[i], img: ''};
      };
    });

    box.querySelector('#cancel').onclick = unmount;
    box.querySelector('#clear').onclick = ()=> {
      box.querySelectorAll('input[data-k="label"]').forEach(i=>i.value='');
      box.querySelectorAll('textarea[data-k="text"]').forEach(t=>t.value='');
      box.querySelectorAll('[data-role="thumb"]').forEach(img=>img.removeAttribute('src'));
      box.querySelectorAll('[data-role="clear"]').forEach(b=>b.disabled=true);
      DATA = DATA.map(()=>({label:'', text:'', img:''}));
    };
    box.querySelector('#save').onclick = async ()=>{
      const next = DATA.map(x=>({...x}));
      box.querySelectorAll('.tile').forEach(tile=>{
        const i = +tile.dataset.i;
        const lbl = tile.querySelector('input[data-k="label"]').value.trim();
        const txt = tile.querySelector('textarea[data-k="text"]').value;
        const imgEl = tile.querySelector('[data-role="thumb"]');
        next[i].label = lbl;
        next[i].text  = txt;
        next[i].img   = imgEl?.getAttribute('src') || next[i].img || '';
      });
      DATA = next;
      ROOMS[ROOM] = DATA.slice();
      await S.set(K_ROOMS, ROOMS);
      renderGrid(); unmount();
    };
  }

  /* ---------- hotkey editor ---------- */
  function openHotkeyEditor(){
    const {box, mount, unmount} = modal();
    const rows = HOTKEYS.map((h,i)=>`
      <div class="hkrow" data-i="${i}">
        <span class="hkname">Btn ${i+1}</span>
        <span class="hklabel" data-role="label">${esc(hotkeyToText(h))}</span>
        <span>
          <button data-role="set">Set</button>
          <button data-role="clr">Clear</button>
        </span>
      </div>
    `).join('');
    box.innerHTML = `
      <h2>Hotkeys — Room “${esc(ROOM)}”</h2>
      <div class="grid15">${rows}</div>
      <div class="actions">
        <button id="cancel">Close</button>
        <button id="defaults">Use Default Mapping</button>
        <button id="save">Save</button>
      </div>
      <div class="small" style="opacity:.8;margin-top:6px">
        Tip: Hold modifiers (Ctrl/Alt/Shift/${navigator.platform.includes('Mac')?'⌘':'Meta'}) and press a key. Press Esc to cancel capture.
      </div>
    `;
    mount();

    let capturing = null;
    function onCapKey(e){
      if (!capturing) return;
      e.preventDefault(); e.stopPropagation();
      const {row, label} = capturing;
      if (e.key === 'Escape'){ stopCap(); return; }
      if (/^(Shift|Control|Alt|Meta)/.test(e.code)) return;
      const h = { code: e.code, ctrl:e.ctrlKey, alt:e.altKey, shift:e.shiftKey, meta:e.metaKey };
      HOTKEYS[row] = h;
      label.textContent = hotkeyToText(h);
      stopCap();
    }
    function stopCap(){
      window.removeEventListener('keydown', onCapKey, true);
      capturing = null;
    }

    box.querySelectorAll('.hkrow').forEach(rowEl=>{
      const i = +rowEl.dataset.i;
      const setBtn = rowEl.querySelector('[data-role="set"]');
      const clrBtn = rowEl.querySelector('[data-role="clr"]');
      const label  = rowEl.querySelector('[data-role="label"]');
      setBtn.onclick = ()=>{
        if (capturing) return;
        capturing = { row: i, label };
        label.textContent = 'Press keys… (Esc cancel)';
        window.addEventListener('keydown', onCapKey, true);
      };
      clrBtn.onclick = ()=>{
        HOTKEYS[i] = null;
        label.textContent = '—';
      };
    });

    box.querySelector('#defaults').onclick = ()=>{
      HOTKEYS = DEFAULT_KEYS.slice();
      box.querySelectorAll('.hkrow').forEach((rowEl,idx)=>{
        rowEl.querySelector('[data-role="label"]').textContent = hotkeyToText(HOTKEYS[idx]);
      });
    };

    box.querySelector('#cancel').onclick = ()=>{ stopCap(); unmount(); };
    box.querySelector('#save').onclick = async ()=>{
      stopCap();
      KEYS[ROOM] = HOTKEYS.slice();
      await S.set(K_KEYS, KEYS);
      renderGrid();
      unmount();
    };
  }

  /* ---------- default copy/save ---------- */
  async function copyDefaultHere(){
    const defData = ensureDataShape(ROOMS._default ?? EMPTY15());
    const defKeys = ensureKeysShape(KEYS._default ?? DEFAULT_KEYS);

    ROOMS[ROOM] = defData.slice();
    DATA = ensureDataShape(ROOMS[ROOM]);

    KEYS[ROOM]  = defKeys.slice();
    HOTKEYS = ensureKeysShape(KEYS[ROOM]);

    await S.set(K_ROOMS, ROOMS);
    await S.set(K_KEYS,  KEYS);
    renderGrid();
  }
  async function saveAsDefault(){
    ROOMS._default = DATA.slice();
    KEYS._default  = HOTKEYS.slice();
    await S.set(K_ROOMS, ROOMS);
    await S.set(K_KEYS,  KEYS);
    alert('Saved this room as default (labels/texts/images + hotkeys).');
  }

  /* ---------- hotkey runtime ---------- */
  function onKeyDown(e){
    if (!UI?.hotkeysEnabled) return;
    const t = e.target;
    const insideShadow = shadow && shadow.contains(t);
    const isEditable = t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/i.test(t?.tagName||'');
    if (isEditable && !insideShadow) return;

    for (let i=0;i<15;i++){
      const h = HOTKEYS[i];
      if (h && matchHotkey(e, h)){
        e.preventDefault(); e.stopPropagation();
        const it = DATA[i];
        if (it) sendMessage(it.text);
        break;
      }
    }
  }

  /* ---------- minimize / restore ---------- */
  function minimize(){ UI.minimized = true;  saveUI(); applyMinimized(); }
  function restore(){  UI.minimized = false; saveUI(); applyMinimized(); }
  function applyMinimized(){
    if (UI.minimized) shadow.host.setAttribute('data-min','');
    else shadow.host.removeAttribute('data-min');
  }

  /* ---------- drag move ---------- */
  function wireDrag(container, handle){
    let sx=0, sy=0, ox=0, oy=0, dragging=false;
    handle.addEventListener('mousedown', e=>{
      dragging=true; sx=e.clientX; sy=e.clientY; ox=UI.x; oy=UI.y; e.preventDefault();
    });
    window.addEventListener('mousemove', e=>{
      if(!dragging) return;
      UI.x = S.clamp(ox+(e.clientX-sx), 0, window.innerWidth-100);
      UI.y = S.clamp(oy+(e.clientY-sy), 0, window.innerHeight-60);
      const deck = qs('#deck', shadow);
      deck.style.left = UI.x+'px';
      deck.style.top  = UI.y+'px';
    });
    window.addEventListener('mouseup', async()=>{ if(dragging){ dragging=false; await saveUI(); } });
  }

  /* ---------- storage ---------- */
  async function loadAll(){
    UI    = await S.get(K_UI, DEFAULT_UI);
    // migrate old rooms without img to new shape
    const oldRooms = await S.get('chatdeck.rooms.v2', null);
    const fallback = oldRooms ? ensureDataShape(oldRooms) : EMPTY15();
    ROOMS = await S.get(K_ROOMS, { _default: fallback });
    KEYS  = await S.get(K_KEYS,  { _default: DEFAULT_KEYS.slice() });

    ROOM  = detectRoom() || '_site';
    DATA  = ensureDataShape(ROOMS[ROOM] ?? ROOMS._default ?? EMPTY15());
    HOTKEYS = ensureKeysShape(KEYS[ROOM] ?? KEYS._default ?? DEFAULT_KEYS);

    // if we migrated, persist v3 so we stop reading v2 next time
    if (oldRooms && !await S.get(K_ROOMS, null)) {
      await S.set(K_ROOMS, { _default: fallback, [ROOM]: DATA });
    }
  }
  async function saveUI(){ await S.set(K_UI, UI); }

  /* ---------- menu ---------- */
  function registerMenu(){
    try{
      GM_registerMenuCommand('Show Chat Deck', ()=>{ UI.minimized=false; applyMinimized(); }, 's');
      GM_registerMenuCommand('Edit current room', ()=>openEditorForRoom(), 'e');
      GM_registerMenuCommand('Edit hotkeys', ()=>openHotkeyEditor(), 'h');
      GM_registerMenuCommand('Use this room as default', ()=>saveAsDefault(), 'd');
      GM_registerMenuCommand('Copy default here', ()=>copyDefaultHere(), 'c');

      GM_registerMenuCommand(UI.hotkeysEnabled?'Disable hotkeys':'Enable hotkeys', async()=>{
        UI.hotkeysEnabled = !UI.hotkeysEnabled; await saveUI();
        alert('Hotkeys ' + (UI.hotkeysEnabled?'enabled':'disabled'));
      }, 'k');

      GM_registerMenuCommand('Idle scale 0.85', async()=>{ UI.idleScale=0.85; await saveUI(); renderAll(); }, '5');
      GM_registerMenuCommand('Idle scale 0.90', async()=>{ UI.idleScale=0.90; await saveUI(); renderAll(); }, '6');
      GM_registerMenuCommand('Idle scale 0.95', async()=>{ UI.idleScale=0.95; await saveUI(); renderAll(); }, '7');

      GM_registerMenuCommand('Squares 90px',  async()=>{ UI.cellMin=90;  await saveUI(); renderAll(); }, '1');
      GM_registerMenuCommand('Squares 96px',  async()=>{ UI.cellMin=96;  await saveUI(); renderAll(); }, '2');
      GM_registerMenuCommand('Squares 110px', async()=>{ UI.cellMin=110; await saveUI(); renderAll(); }, '3');

      GM_registerMenuCommand('Increase idle opacity', async()=>{ UI.opacity = S.clamp(UI.opacity+0.05, 0.05, 1); await saveUI(); renderAll(); }, '+');
      GM_registerMenuCommand('Decrease idle opacity', async()=>{ UI.opacity = S.clamp(UI.opacity-0.05, 0.05, 1); await saveUI(); renderAll(); }, '-');
    }catch{}
  }

  /* ---------- init ---------- */
  (async function init(){
    await loadAll();
    ensureUI();
  })();

})();