☰

Scratch Text → Blocks

Type or paste scratchblocks-style text and turn it into real blocks, sprites, costumes and backdrops in the Scratch editor (also works on TurboWarp).

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         Scratch Text → Blocks
// @namespace    https://scratch.mit.edu/
// @version      1.3.0
// @description  Type or paste scratchblocks-style text and turn it into real blocks, sprites, costumes and backdrops in the Scratch editor (also works on TurboWarp).
// @match        https://scratch.mit.edu/projects/*
// @match        https://turbowarp.org/*
// @grant        none
// #author       djjsman
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  /* =====================================================================
   * 1. VALUE MAPS  (what you type in a dropdown -> Scratch's internal value)
   * ===================================================================== */
  const lc = s => String(s).trim().toLowerCase();
  const MAPS = {
    target: { 'random position': '_random_', 'random': '_random_', 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_' },
    touch: { 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_', 'edge': '_edge_' },
    clone: { 'myself': '_myself_' },
    stage: { 'stage': '_stage_' },
    effect: { color: 'COLOR', colour: 'COLOR', fisheye: 'FISHEYE', whirl: 'WHIRL', pixelate: 'PIXELATE', mosaic: 'MOSAIC', brightness: 'BRIGHTNESS', ghost: 'GHOST' },
    seffect: { pitch: 'PITCH', 'pan left/right': 'PAN', pan: 'PAN' },
    mathop: { abs: 'abs', floor: 'floor', ceiling: 'ceiling', sqrt: 'sqrt', sin: 'sin', cos: 'cos', tan: 'tan', asin: 'asin', acos: 'acos', atan: 'atan', ln: 'ln', log: 'log', 'e ^': 'e ^', 'e^': 'e ^', '10 ^': '10 ^', '10^': '10 ^' },
    current: { year: 'YEAR', month: 'MONTH', date: 'DATE', 'day of week': 'DAYOFWEEK', dayofweek: 'DAYOFWEEK', hour: 'HOUR', minute: 'MINUTE', second: 'SECOND' },
    gt: { loudness: 'LOUDNESS', timer: 'TIMER' },
    pencol: { color: 'color', colour: 'color', saturation: 'saturation', brightness: 'brightness', transparency: 'transparency' },
    numname: { number: 'number', '#': 'number', name: 'name' },
    frontback: { front: 'front', back: 'back' },
    fwdback: { forward: 'forward', backward: 'backward', backwards: 'backward' },
  };
  const DEFAULTS = { target: '_random_', touch: '_mouse_', clone: '_myself_', stage: '_stage_', key: 'space', pencol: 'color' };

  function mapHas(map, raw) { return !!MAPS[map] && Object.prototype.hasOwnProperty.call(MAPS[map], lc(raw)); }
  function mapValue(map, raw) {
    if (map === 'key' || map === 'lower') return lc(raw);
    if (mapHas(map, raw)) return MAPS[map][lc(raw)];
    return String(raw).trim();
  }

  /* =====================================================================
   * 2. BLOCK SPECS
   *   [opcode, shape, ...patterns]
   *   shapes: s=stack h=hat r=reporter b=boolean c=C-block cc=C-cap (forever) cap=cap
   *   slot {NAME:type}
   *     num pnum wnum int ang txt col   -> input with shadow
   *     bool                            -> boolean input
   *     f:map[!]                        -> dropdown field on the block (! = value must be in map)
   *     m:menuOpcode:FIELD[:map]        -> dropdown input (shadow menu block)
   *     var / list / bc                 -> variable / list / broadcast field
   *     bcm                             -> broadcast menu input
   * ===================================================================== */
  const SPECS = [
    // ---- Motion
    ['motion_movesteps', 's', 'move {STEPS:num} steps'],
    ['motion_turnright', 's', 'turn right {DEGREES:num} degrees', 'turn cw {DEGREES:num} degrees', 'turn ↻ {DEGREES:num} degrees', 'turn @turnright {DEGREES:num} degrees'],
    ['motion_turnleft', 's', 'turn left {DEGREES:num} degrees', 'turn ccw {DEGREES:num} degrees', 'turn ↺ {DEGREES:num} degrees', 'turn @turnleft {DEGREES:num} degrees'],
    ['motion_gotoxy', 's', 'go to x: {X:num} y: {Y:num}'],
    ['motion_goto', 's', 'go to {TO:m:motion_goto_menu:TO:target}'],
    ['motion_glidesecstoxy', 's', 'glide {SECS:num} secs to x: {X:num} y: {Y:num}', 'glide {SECS:num} seconds to x: {X:num} y: {Y:num}'],
    ['motion_glideto', 's', 'glide {SECS:num} secs to {TO:m:motion_glideto_menu:TO:target}', 'glide {SECS:num} seconds to {TO:m:motion_glideto_menu:TO:target}'],
    ['motion_pointindirection', 's', 'point in direction {DIRECTION:ang}'],
    ['motion_pointtowards', 's', 'point towards {TOWARDS:m:motion_pointtowards_menu:TOWARDS:target}'],
    ['motion_changexby', 's', 'change x by {DX:num}'],
    ['motion_setx', 's', 'set x to {X:num}'],
    ['motion_changeyby', 's', 'change y by {DY:num}'],
    ['motion_sety', 's', 'set y to {Y:num}'],
    ['motion_ifonedgebounce', 's', 'if on edge, bounce'],
    ['motion_setrotationstyle', 's', 'set rotation style {STYLE:f:lower}'],
    ['motion_xposition', 'r', 'x position'],
    ['motion_yposition', 'r', 'y position'],
    ['motion_direction', 'r', 'direction'],

    // ---- Looks
    ['looks_sayforsecs', 's', 'say {MESSAGE:txt} for {SECS:num} seconds', 'say {MESSAGE:txt} for {SECS:num} secs'],
    ['looks_say', 's', 'say {MESSAGE:txt}'],
    ['looks_thinkforsecs', 's', 'think {MESSAGE:txt} for {SECS:num} seconds', 'think {MESSAGE:txt} for {SECS:num} secs'],
    ['looks_think', 's', 'think {MESSAGE:txt}'],
    ['looks_switchcostumeto', 's', 'switch costume to {COSTUME:m:looks_costume:COSTUME}'],
    ['looks_nextcostume', 's', 'next costume'],
    ['looks_switchbackdroptoandwait', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP} and wait'],
    ['looks_switchbackdropto', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP}'],
    ['looks_nextbackdrop', 's', 'next backdrop'],
    ['looks_changesizeby', 's', 'change size by {CHANGE:num}'],
    ['looks_setsizeto', 's', 'set size to {SIZE:num} %', 'set size to {SIZE:num}'],
    ['looks_changeeffectby', 's', 'change {EFFECT:f:effect!} effect by {CHANGE:num}'],
    ['looks_seteffectto', 's', 'set {EFFECT:f:effect!} effect to {VALUE:num}'],
    ['looks_cleargraphiceffects', 's', 'clear graphic effects'],
    ['looks_show', 's', 'show'],
    ['looks_hide', 's', 'hide'],
    ['looks_gotofrontback', 's', 'go to {FRONT_BACK:f:frontback} layer'],
    ['looks_goforwardbackwardlayers', 's', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layers', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layer'],
    ['looks_costumenumbername', 'r', 'costume {NUMBER_NAME:f:numname}'],
    ['looks_backdropnumbername', 'r', 'backdrop {NUMBER_NAME:f:numname}'],
    ['looks_size', 'r', 'size'],

    // ---- Sound
    ['sound_playuntildone', 's', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU} until done'],
    ['sound_play', 's', 'start sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}'],
    ['sound_stopallsounds', 's', 'stop all sounds'],
    ['sound_changeeffectby', 's', 'change {EFFECT:f:seffect!} effect by {VALUE:num}'],
    ['sound_seteffectto', 's', 'set {EFFECT:f:seffect!} effect to {VALUE:num}'],
    ['sound_cleareffects', 's', 'clear sound effects'],
    ['sound_changevolumeby', 's', 'change volume by {VOLUME:num}'],
    ['sound_setvolumeto', 's', 'set volume to {VOLUME:num} %', 'set volume to {VOLUME:num}'],
    ['sound_volume', 'r', 'volume'],

    // ---- Events
    ['event_whenflagclicked', 'h', 'when flag clicked', 'when green flag clicked', 'when @greenflag clicked', 'when ⚑ clicked', 'when gf clicked'],
    ['event_whenkeypressed', 'h', 'when {KEY_OPTION:f:key} key pressed'],
    ['event_whenthisspriteclicked', 'h', 'when this sprite clicked'],
    ['event_whenstageclicked', 'h', 'when stage clicked'],
    ['event_whenbackdropswitchesto', 'h', 'when backdrop switches to {BACKDROP:f}'],
    ['event_whengreaterthan', 'h', 'when {WHENGREATERTHANMENU:f:gt} > {VALUE:num}'],
    ['event_whenbroadcastreceived', 'h', 'when i receive {BROADCAST_OPTION:bc}'],
    ['event_broadcastandwait', 's', 'broadcast {BROADCAST_INPUT:bcm} and wait'],
    ['event_broadcast', 's', 'broadcast {BROADCAST_INPUT:bcm}'],

    // ---- Control
    ['control_wait', 's', 'wait {DURATION:pnum} seconds', 'wait {DURATION:pnum} secs', 'wait {DURATION:pnum}'],
    ['control_repeat', 'c', 'repeat {TIMES:wnum}'],
    ['control_forever', 'cc', 'forever'],
    ['control_if', 'c', 'if {CONDITION:bool} then', 'if {CONDITION:bool}'],
    ['control_wait_until', 's', 'wait until {CONDITION:bool}'],
    ['control_repeat_until', 'c', 'repeat until {CONDITION:bool}'],
    ['control_while', 'c', 'while {CONDITION:bool}'],
    ['control_stop', 'cap', 'stop {STOP_OPTION:f:lower}'],
    ['control_start_as_clone', 'h', 'when i start as a clone'],
    ['control_create_clone_of', 's', 'create clone of {CLONE_OPTION:m:control_create_clone_of_menu:CLONE_OPTION:clone}'],
    ['control_delete_this_clone', 'cap', 'delete this clone'],

    // ---- Sensing
    ['sensing_touchingcolor', 'b', 'touching color {COLOR:col}', 'touching colour {COLOR:col}'],
    ['sensing_touchingobject', 'b', 'touching {TOUCHINGOBJECTMENU:m:sensing_touchingobjectmenu:TOUCHINGOBJECTMENU:touch}'],
    ['sensing_coloristouchingcolor', 'b', 'color {COLOR:col} is touching {COLOR2:col}', 'colour {COLOR:col} is touching {COLOR2:col}'],
    ['sensing_distanceto', 'r', 'distance to {DISTANCETOMENU:m:sensing_distancetomenu:DISTANCETOMENU:touch}'],
    ['sensing_askandwait', 's', 'ask {QUESTION:txt} and wait'],
    ['sensing_answer', 'r', 'answer'],
    ['sensing_keypressed', 'b', 'key {KEY_OPTION:m:sensing_keyoptions:KEY_OPTION:key} pressed'],
    ['sensing_mousedown', 'b', 'mouse down'],
    ['sensing_mousex', 'r', 'mouse x'],
    ['sensing_mousey', 'r', 'mouse y'],
    ['sensing_setdragmode', 's', 'set drag mode {DRAG_MODE:f:lower}'],
    ['sensing_loudness', 'r', 'loudness'],
    ['sensing_timer', 'r', 'timer'],
    ['sensing_resettimer', 's', 'reset timer'],
    ['sensing_current', 'r', 'current {CURRENTMENU:f:current}'],
    ['sensing_dayssince2000', 'r', 'days since 2000'],
    ['sensing_username', 'r', 'username'],

    // ---- Variables & lists (before operators so "[list v] contains" / "length of [list v]" win)
    ['data_setvariableto', 's', 'set {VARIABLE:var} to {VALUE:txt}'],
    ['data_changevariableby', 's', 'change {VARIABLE:var} by {VALUE:num}'],
    ['data_showvariable', 's', 'show variable {VARIABLE:var}'],
    ['data_hidevariable', 's', 'hide variable {VARIABLE:var}'],
    ['data_addtolist', 's', 'add {ITEM:txt} to {LIST:list}'],
    ['data_deletealloflist', 's', 'delete all of {LIST:list}'],
    ['data_deleteoflist', 's', 'delete {INDEX:int} of {LIST:list}'],
    ['data_insertatlist', 's', 'insert {ITEM:txt} at {INDEX:int} of {LIST:list}'],
    ['data_replaceitemoflist', 's', 'replace item {INDEX:int} of {LIST:list} with {ITEM:txt}'],
    ['data_itemoflist', 'r', 'item {INDEX:int} of {LIST:list}'],
    ['data_itemnumoflist', 'r', 'item # of {ITEM:txt} in {LIST:list}'],
    ['data_lengthoflist', 'r', 'length of {LIST:list}'],
    ['data_listcontainsitem', 'b', '{LIST:list} contains {ITEM:txt}'],
    ['data_showlist', 's', 'show list {LIST:list}'],
    ['data_hidelist', 's', 'hide list {LIST:list}'],

    // ---- Operators
    ['operator_add', 'r', '{NUM1:num} + {NUM2:num}'],
    ['operator_subtract', 'r', '{NUM1:num} - {NUM2:num}'],
    ['operator_multiply', 'r', '{NUM1:num} * {NUM2:num}', '{NUM1:num} x {NUM2:num}', '{NUM1:num} × {NUM2:num}'],
    ['operator_divide', 'r', '{NUM1:num} / {NUM2:num}', '{NUM1:num} ÷ {NUM2:num}'],
    ['operator_random', 'r', 'pick random {FROM:num} to {TO:num}'],
    ['operator_gt', 'b', '{OPERAND1:txt} > {OPERAND2:txt}'],
    ['operator_lt', 'b', '{OPERAND1:txt} < {OPERAND2:txt}'],
    ['operator_equals', 'b', '{OPERAND1:txt} = {OPERAND2:txt}'],
    ['operator_and', 'b', '{OPERAND1:bool} and {OPERAND2:bool}'],
    ['operator_or', 'b', '{OPERAND1:bool} or {OPERAND2:bool}'],
    ['operator_not', 'b', 'not {OPERAND:bool}'],
    ['operator_join', 'r', 'join {STRING1:txt} {STRING2:txt}'],
    ['operator_letter_of', 'r', 'letter {LETTER:wnum} of {STRING:txt}'],
    ['operator_length', 'r', 'length of {STRING:txt}'],
    ['operator_contains', 'b', '{STRING1:txt} contains {STRING2:txt}'],
    ['operator_mod', 'r', '{NUM1:num} mod {NUM2:num}'],
    ['operator_round', 'r', 'round {NUM:num}'],
    ['operator_mathop', 'r', '{OPERATOR:f:mathop!} of {NUM:num}'],
    ['sensing_of', 'r', '{PROPERTY:f} of {OBJECT:m:sensing_of_object_menu:OBJECT:stage}'],

    // ---- Pen extension (auto-loaded when used)
    ['pen_clear', 's', 'erase all', 'clear'],
    ['pen_stamp', 's', 'stamp'],
    ['pen_penDown', 's', 'pen down'],
    ['pen_penUp', 's', 'pen up'],
    ['pen_setPenColorToColor', 's', 'set pen color to {COLOR:col}', 'set pen colour to {COLOR:col}'],
    ['pen_changePenColorParamBy', 's', 'change pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} by {VALUE:num}'],
    ['pen_setPenColorParamTo', 's', 'set pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} to {VALUE:num}'],
    ['pen_changePenSizeBy', 's', 'change pen size by {SIZE:num}'],
    ['pen_setPenSizeTo', 's', 'set pen size to {SIZE:num}'],
  ];

  const normWord = w => w.toLowerCase().replace(/[,?:]/g, '');
  const SIG = new Map();
  for (const [opcode, shape, ...pats] of SPECS) {
    for (const pat of pats) {
      const args = [], sig = [];
      for (const tok of pat.split(/\s+/)) {
        const m = tok.match(/^\{(\w+):(.+)\}$/);
        if (m) { args.push(parseArgDef(m[1], m[2])); sig.push('_'); }
        else { const w = normWord(tok); if (w) sig.push(w); }
      }
      const key = sig.join(' ');
      if (!SIG.has(key)) SIG.set(key, []);
      SIG.get(key).push({ opcode, shape, args });
    }
  }
  function parseArgDef(name, t) {
    let strict = false;
    if (t.endsWith('!')) { strict = true; t = t.slice(0, -1); }
    const p = t.split(':');
    const d = { name, type: p[0], strict };
    if (d.type === 'f') d.map = p[1];
    if (d.type === 'm') { d.menu = p[1]; d.field = p[2]; d.map = p[3]; }
    return d;
  }

  /* ---------- Extension blocks, read live from whatever extensions are loaded ---------- */
  const DYN_DONE = new Set();
  function addSig(key, spec) { if (!SIG.has(key)) SIG.set(key, []); SIG.get(key).push(spec); }
  function dynArgDef(cat, name, ai) {
    if (ai.menu) {
      const mi = (cat.menuInfo || {})[ai.menu] || {};
      const mapName = `ext_${cat.id}_${ai.menu}`;
      let first = '';
      if (Array.isArray(mi.items)) {
        const o = {};
        for (const it of mi.items) {
          if (typeof it === 'string') { o[lc(it)] = it; if (!first) first = it; }
          else if (it && typeof it === 'object') {
            const v = String(it.value);
            o[lc(String(it.text))] = v; o[lc(v)] = v;
            o[lc(String(it.text).replace(/^\(\d+\)\s*/, ''))] = v; // "(2) Bass Drum" -> also "bass drum"
            if (!first) first = v;
          }
        }
        MAPS[mapName] = o;
      }
      const def = ai.defaultValue !== undefined ? String(ai.defaultValue) : first;
      if (mi.acceptReporters) return { name, type: 'm', menu: `${cat.id}_menu_${ai.menu}`, field: ai.menu, map: mapName, def, strict: false };
      return { name, type: 'f', map: mapName, strict: false };
    }
    const t = { number: 'num', angle: 'ang', string: 'txt', Boolean: 'bool', color: 'col', note: 'note', matrix: 'matrix' }[ai.type] || 'txt';
    return { name, type: t, strict: false };
  }
  function refreshDynamicSpecs(vm) {
    const cats = (vm && vm.runtime && vm.runtime._blockInfo) || [];
    for (const cat of cats) {
      for (const b of cat.blocks || []) {
        const info = b && b.info;
        if (!info || !info.opcode || typeof info.blockType !== 'string') continue;
        const text = Array.isArray(info.text) ? info.text[0] : info.text;
        if (typeof text !== 'string') continue;
        const opcode = `${cat.id}_${info.opcode}`;
        const key = opcode + '|' + text;
        if (DYN_DONE.has(key)) continue;
        DYN_DONE.add(key);
        const bt = info.blockType;
        const shape = bt === 'command' ? (info.isTerminal ? 'cap' : 's')
          : bt === 'reporter' ? 'r'
          : bt === 'Boolean' ? 'b'
          : (bt === 'hat' || bt === 'event') ? 'h'
          : bt === 'conditional' ? 'c'
          : bt === 'loop' ? (info.isTerminal ? 'cc' : 'c')
          : null;
        if (!shape) continue;
        const args = [], sig = [];
        const re = /\[([^\]]+)\]|([^[\]]+)/g;
        let m;
        while ((m = re.exec(text))) {
          if (m[1] !== undefined) {
            const ai = (info.arguments || {})[m[1]] || {};
            if (ai.type === 'image') continue; // icons, not inputs
            args.push(dynArgDef(cat, m[1], ai));
            sig.push('_');
          } else {
            for (const w of m[2].split(/\s+/)) { const nw = normWord(w); if (nw) sig.push(nw); }
          }
        }
        if (!sig.length) continue;
        addSig(sig.join(' '), { opcode, shape, args, branches: info.branchCount || (shape === 'c' || shape === 'cc' ? 1 : 0), ext: cat.id });
      }
    }
  }

  // Friendly names -> built-in extension ids
  const EXT_NAMES = {
    pen: 'pen', music: 'music', video: 'videoSensing', 'video sensing': 'videoSensing', videosensing: 'videoSensing',
    'text to speech': 'text2speech', 'text-to-speech': 'text2speech', text2speech: 'text2speech', tts: 'text2speech',
    translate: 'translate', 'makey makey': 'makeymakey', makeymakey: 'makeymakey',
    'micro:bit': 'microbit', microbit: 'microbit', 'micro bit': 'microbit',
    ev3: 'ev3', 'lego ev3': 'ev3', 'lego mindstorms ev3': 'ev3', boost: 'boost', 'lego boost': 'boost',
    wedo: 'wedo2', wedo2: 'wedo2', 'lego wedo': 'wedo2', 'lego wedo 2.0': 'wedo2', 'lego education wedo 2.0': 'wedo2',
    gdxfor: 'gdxfor', 'force and acceleration': 'gdxfor', 'go direct force & acceleration': 'gdxfor', 'force & acceleration': 'gdxfor',
  };
  function extensionRef(name) {
    const raw = name.trim();
    if (/^https?:\/\//i.test(raw) || raw.startsWith('data:')) return raw;
    const k = lc(raw);
    if (EXT_NAMES[k]) return EXT_NAMES[k];
    if (raw.includes('/')) return `https://extensions.turbowarp.org/${raw.replace(/\.js$/, '')}.js`; // TurboWarp gallery, e.g. Lily/TempVariables2
    return raw; // assume it's already an extension id
  }
  // If a line isn't recognized, these hints tell us which built-in extension it probably needs
  const EXT_HINTS = [
    ['pen', /^(pen (up|down)|erase all|stamp\b|set pen |change pen )/],
    ['music', /^(play drum|rest for|play note|set instrument|set tempo|change tempo)\b|\(tempo\)/],
    ['videoSensing', /\bvideo\b/],
    ['text2speech', /^speak\b|set voice to|set language to/],
    ['translate', /\btranslate\b|\(language\)/],
    ['makeymakey', /pressed in order/],
    ['microbit', /button pressed|^display\b|clear display|\btilted\b|tilt angle|when pin\b/],
    ['gdxfor', /force sensor|\bforce\b|acceleration|spin speed|free fall|when shaken/],
  ];

  /* =====================================================================
   * 3. PARSER
   * ===================================================================== */
  class ParseError extends Error {}
  const PErr = msg => new ParseError(msg);

  // Is the "<" or ">" at s[j] a comparison operator (sitting between two inputs)?
  function isOpAt(s, j, lo) {
    let a = j - 1; while (a >= lo && s[a] === ' ') a--;
    let b = j + 1; while (b < s.length && s[b] === ' ') b++;
    if (a < lo || b >= s.length) return false;
    const spaced = s[j - 1] === ' ' || s[j + 1] === ' ';
    return spaced && ')]>'.includes(s[a]) && /[(\[<\d\-.]/.test(s[b]);
  }
  function findClose(s, i) {
    const open = s[i];
    if (open === '[') {
      const j = s.indexOf(']', i + 1);
      if (j < 0) throw PErr('Missing a closing "]"');
      return j;
    }
    const close = open === '(' ? ')' : '>';
    let j = i + 1;
    while (j < s.length) {
      const c = s[j];
      if (c === '[' || c === '(') { j = findClose(s, j) + 1; continue; }
      if (c === '<' && !isOpAt(s, j, i + 1)) { j = findClose(s, j) + 1; continue; }
      if (c === close && !(c === '>' && isOpAt(s, j, i + 1))) return j;
      j++;
    }
    throw PErr(`Missing a closing "${close}"`);
  }
  function parseParts(s) {
    const parts = [];
    let i = 0, word = '';
    const flush = () => { for (const w of word.split(/\s+/)) if (w) parts.push({ t: 'w', v: w }); word = ''; };
    while (i < s.length) {
      const c = s[i];
      if (c === '(' || c === '[' || (c === '<' && !isOpAt(s, i, 0))) {
        flush();
        const j = findClose(s, i);
        parts.push({ t: c, raw: s.slice(i + 1, j) });
        i = j + 1;
      } else { word += c; i++; }
    }
    flush();
    // Drop scratchblocks "::category" annotations
    const out = [];
    let skip = false;
    for (const p of parts) {
      if (p.t === 'w' && p.v.includes('::')) {
        const pre = p.v.split('::')[0];
        if (pre) out.push({ t: 'w', v: pre });
        skip = true; continue;
      }
      if (skip && p.t === 'w') continue;
      skip = false; out.push(p);
    }
    return out;
  }

  const NUM_RE = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;

  function interpretArg(p, ctx) {
    if (p.t === '[') {
      const m = p.raw.match(/^(.*?)\s+v$/);
      if (m) return { lit: true, value: m[1].trim(), menu: true };
      return { lit: true, value: p.raw, menu: false };
    }
    if (p.t === '(') {
      const raw = p.raw.trim();
      if (raw === '' || NUM_RE.test(raw)) return { lit: true, value: raw, menu: false };
      const m = raw.match(/^(.*?)\s+v$/);
      if (m) return { lit: true, value: m[1].trim(), menu: true };
      if (ctx.procArgs && ctx.procArgs.s.has(raw)) return { block: { kind: 'argr', name: raw, bool: false } };
      const blk = tryParseBlock(raw, 'r', ctx);
      if (blk) return { block: blk };
      if (ctx.lists.has(raw) && !ctx.vars.has(raw)) return { block: { kind: 'listr', name: raw } };
      return { block: { kind: 'var', name: raw } };
    }
    if (p.t === '<') {
      const raw = p.raw.trim();
      if (!raw) return { empty: true };
      if (ctx.procArgs && ctx.procArgs.b.has(raw)) return { block: { kind: 'argr', name: raw, bool: true } };
      const blk = tryParseBlock(raw, 'b', ctx);
      if (blk) return { block: blk };
      throw PErr(`I don't recognize the boolean block <${raw}>`);
    }
    throw PErr('Unexpected input');
  }

  function argFits(d, a) {
    if (['f', 'var', 'list', 'bc'].includes(d.type)) {
      if (d.type === 'var' && a.block && a.block.kind === 'var') return true;
      if (!a.lit) return false;
      if (d.type === 'list' && !a.menu) return false;
      if (d.strict && !mapHas(d.map, a.value)) return false;
      return true;
    }
    if (d.type === 'bool') return !a.lit;
    return true;
  }

  const SHAPES_FOR = { s: ['s', 'h', 'c', 'cc', 'cap'], r: ['r', 'b'], b: ['b'] };

  function matchOnce(parts, want, ctx) {
    const sig = parts.map(p => (p.t === 'w' ? normWord(p.v) : '_')).filter(Boolean).join(' ');
    const argParts = parts.filter(p => p.t !== 'w');
    const cands = (SIG.get(sig) || []).filter(c => SHAPES_FOR[want].includes(c.shape));
    let args = null;
    for (const c of cands) {
      if (!args) args = argParts.map(p => interpretArg(p, ctx));
      if (c.args.every((d, k) => argFits(d, args[k]))) {
        return { kind: 'std', opcode: c.opcode, shape: c.shape, spec: c, args };
      }
    }
    if (want === 's' && ctx.procs.has(sig)) {
      const proc = ctx.procs.get(sig);
      if (!args) args = argParts.map(p => interpretArg(p, ctx));
      return { kind: 'call', shape: 's', proc, args };
    }
    return null;
  }
  function matchParts(parts, want, ctx) {
    let r = matchOnce(parts, want, ctx);
    if (!r) {
      // Be forgiving: "move 10 steps" -> "move (10) steps"
      const alt = parts.map(p => (p.t === 'w' && NUM_RE.test(p.v) ? { t: '(', raw: p.v } : p));
      if (alt.some((p, i) => p !== parts[i])) r = matchOnce(alt, want, ctx);
    }
    return r;
  }
  function tryParseBlock(raw, want, ctx) { return matchParts(parseParts(raw), want, ctx); }

  function parseDefine(line) {
    let body = line.replace(/^define\s+/i, '');
    let warp = false;
    if (/run without screen refresh/i.test(body)) {
      warp = true;
      body = body.replace(/(::)?\s*run without screen refresh/i, '');
    }
    const parts = parseParts(body);
    const code = [], names = [], types = [], sig = [];
    for (const p of parts) {
      if (p.t === 'w') { code.push(p.v); const w = normWord(p.v); if (w) sig.push(w); }
      else {
        const bool = p.t === '<';
        code.push(bool ? '%b' : '%s');
        names.push(p.raw.trim());
        types.push(bool ? 'b' : 's');
        sig.push('_');
      }
    }
    if (!code.length) throw PErr('"define" needs a block name');
    return {
      proccode: code.join(' '), names, types, warp, sig: sig.join(' '),
      ids: names.map(() => uid()), fromText: true,
    };
  }

  function parseLine(text, ctx, isFirst) {
    if (/^define\s/i.test(text)) {
      const d = parseDefine(text);
      const proc = ctx.procs.get(d.sig) || d;
      if (isFirst) {
        ctx.procArgs = { s: new Set(), b: new Set() };
        proc.names.forEach((n, k) => ctx.procArgs[proc.types[k] === 'b' ? 'b' : 's'].add(n));
      }
      return { kind: 'define', shape: 'h', proc };
    }
    // A lone reporter / boolean on its own line
    if ((text[0] === '(' || text[0] === '<') && findClose(text, 0) === text.length - 1) {
      const a = interpretArg({ t: text[0], raw: text.slice(1, -1) }, ctx);
      if (a.block) return Object.assign({ shape: a.block.shape || 'r' }, a.block);
    }
    const node = matchParts(parseParts(text), 's', ctx);
    if (!node) throw PErr(`I don't recognize the block "${text}"`);
    if (node.opcode === 'control_stop') {
      const v = lc(node.args[0].value);
      if (v.startsWith('other')) { node.shape = 's'; node.hasNext = true; }
    }
    return node;
  }

  function parseStack(L, i, ctx, depth) {
    const nodes = [];
    while (i < L.length) {
      const ln = L[i];
      if (ln.blank) {
        if (depth === 0 && nodes.length) return { nodes, i: i + 1, term: null };
        i++; continue;
      }
      const low = ln.text.toLowerCase();
      if (low === 'end') {
        if (depth === 0) { i++; continue; }
        return { nodes, i: i + 1, term: 'end' };
      }
      if (low === 'else') {
        if (depth === 0) throw lineErr(ln.n, '"else" without an "if"');
        return { nodes, i: i + 1, term: 'else' };
      }
      if (depth === 0 && nodes.length === 0) ctx.procArgs = null;
      let node;
      try { node = parseLine(ln.text, ctx, depth === 0 && nodes.length === 0); }
      catch (e) { throw lineErr(ln.n, e.message); }
      // A hat block in the middle of a script starts a new script
      if (node.shape === 'h' && depth === 0 && nodes.length) return { nodes, i, term: null };
      node.line = ln.n; node.text = ln.text;
      i++;
      if (node.shape === 'c' || node.shape === 'cc') {
        const r = parseStack(L, i, ctx, depth + 1);
        node.sub = r.nodes; i = r.i;
        if (r.term === 'else') {
          const twoBranch = node.spec && node.spec.branches >= 2;
          if (node.opcode !== 'control_if' && !twoBranch) throw lineErr(ln.n, '"else" only works with an "if" block');
          if (node.opcode === 'control_if') node.opcode = 'control_if_else';
          const r2 = parseStack(L, i, ctx, depth + 1);
          node.sub2 = r2.nodes; i = r2.i;
        }
      }
      nodes.push(node);
    }
    return { nodes, i, term: null };
  }

  function lineErr(n, msg) { const e = new ParseError(`Line ${n}: ${msg}`); e.line = n; return e; }

  function validate(nodes, top) {
    nodes.forEach((nd, k) => {
      if (nd.shape === 'h' && !(top && k === 0)) throw lineErr(nd.line, `"${nd.text}" is a hat block, so it has to be at the top of a script`);
      if ((nd.shape === 'r' || nd.shape === 'b') && (!top || nodes.length > 1)) throw lineErr(nd.line, `"${nd.text}" is a reporter, so it can't be stacked with other blocks`);
      if ((nd.shape === 'cap' || nd.shape === 'cc') && k < nodes.length - 1) throw lineErr(nodes[k + 1].line, `Nothing can go after "${nd.text}"`);
      if (nd.sub) validate(nd.sub, false);
      if (nd.sub2) validate(nd.sub2, false);
    });
  }

  function parseText(text, ctx) {
    const L = text.replace(/\r/g, '').split('\n').map((t, idx) => {
      const s = t.replace(/\s\/\/.*$/, '').trim();
      if (s.startsWith('//')) return null; // comment-only lines are skipped (they don't split scripts)
      return s ? { text: s, n: idx + 1 } : { blank: true, n: idx + 1 };
    }).filter(Boolean);
    // Pre-pass: collect custom block definitions so calls can appear anywhere
    for (const ln of L) {
      if (!ln.blank && /^define\s/i.test(ln.text)) {
        try { const d = parseDefine(ln.text); ctx.procs.set(d.sig, d); }
        catch (e) { throw lineErr(ln.n, e.message); }
      }
    }
    const scripts = [];
    let i = 0;
    while (i < L.length) {
      if (L[i].blank) { i++; continue; }
      const r = parseStack(L, i, ctx, 0);
      if (r.nodes.length) { validate(r.nodes, true); scripts.push(r.nodes); }
      if (r.i === i) i++; else i = r.i;
    }
    return scripts;
  }

  /* =====================================================================
   * 4. XML GENERATION (Blockly XML, which Scratch turns into real blocks)
   * ===================================================================== */
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  function uid() {
    const c = '!#%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let s = ''; for (let i = 0; i < 20; i++) s += c[Math.floor(Math.random() * c.length)];
    return s;
  }
  const SHADOW = {
    num: ['math_number', 'NUM'], pnum: ['math_positive_number', 'NUM'], wnum: ['math_whole_number', 'NUM'],
    int: ['math_integer', 'NUM'], ang: ['math_angle', 'NUM'], txt: ['text', 'TEXT'], col: ['colour_picker', 'COLOUR'],
    note: ['note', 'NOTE'], matrix: ['matrix', 'MATRIX'],
  };
  function normColor(v) {
    let m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
    if (m) return '#' + m[1].toLowerCase();
    m = String(v).trim().match(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i);
    if (m) return ('#' + m[1] + m[1] + m[2] + m[2] + m[3] + m[3]).toLowerCase();
    return '#000000';
  }

  function varFieldXml(g, field, name, type) {
    return `<field name="${field}" id="${esc(g.varId(name, type))}" variabletype="${type}">${esc(name)}</field>`;
  }
  const valueXml = (name, inner) => `<value name="${name}">${inner}</value>`;

  function argXml(d, a, g) {
    const blk = a.block ? blockXml(a.block, g, '') : '';
    switch (d.type) {
      case 'f': return `<field name="${d.name}">${esc(mapValue(d.map, a.value))}</field>`;
      case 'var': return varFieldXml(g, d.name, a.lit ? a.value : a.block.name, '');
      case 'list': return varFieldXml(g, d.name, a.value, 'list');
      case 'bc': return varFieldXml(g, d.name, a.value, 'broadcast_msg');
      case 'bool': return blk ? valueXml(d.name, blk) : '';
      case 'bcm': {
        const name = a.lit && a.value ? a.value : 'message1';
        return valueXml(d.name, `<shadow type="event_broadcast_menu">${varFieldXml(g, 'BROADCAST_OPTION', name, 'broadcast_msg')}</shadow>${blk}`);
      }
      case 'm': {
        const v = a.lit ? mapValue(d.map, a.value) : (DEFAULTS[d.map] || d.def || '');
        return valueXml(d.name, `<shadow type="${d.menu}"><field name="${d.field}">${esc(v)}</field></shadow>${blk}`);
      }
      default: {
        const [st, f] = SHADOW[d.type];
        let v = a.lit ? a.value : '';
        if (d.type === 'col') v = normColor(v);
        return valueXml(d.name, `<shadow type="${st}"><field name="${f}">${esc(v)}</field></shadow>${blk}`);
      }
    }
  }

  function procMutation(proc, withNames) {
    let m = `<mutation proccode="${esc(proc.proccode)}" argumentids="${esc(JSON.stringify(proc.ids))}"`;
    if (withNames) {
      m += ` argumentnames="${esc(JSON.stringify(proc.names))}" argumentdefaults="${esc(JSON.stringify(proc.types.map(t => (t === 'b' ? 'false' : ''))))}"`;
    }
    return m + ` warp="${proc.warp ? 'true' : 'false'}"></mutation>`;
  }

  function blockXml(node, g, next) {
    const nx = next ? `<next>${next}</next>` : '';
    g.opcodes.add(node.opcode || node.kind);
    switch (node.kind) {
      case 'var':
        return `<block type="data_variable">${varFieldXml(g, 'VARIABLE', node.name, '')}</block>`;
      case 'listr':
        return `<block type="data_listcontents">${varFieldXml(g, 'LIST', node.name, 'list')}</block>`;
      case 'argr':
        return `<block type="${node.bool ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(node.name)}</field></block>`;
      case 'define': {
        const p = node.proc;
        const args = p.ids.map((id, k) =>
          valueXml(esc(id), `<shadow type="${p.types[k] === 'b' ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(p.names[k])}</field></shadow>`)
        ).join('');
        return `<block type="procedures_definition"><value name="custom_block"><shadow type="procedures_prototype">${procMutation(p, true)}${args}</shadow></value>${nx}</block>`;
      }
      case 'call': {
        const p = node.proc;
        const args = p.ids.map((id, k) => {
          const a = node.args[k] || { lit: true, value: '' };
          const blk = a.block ? blockXml(a.block, g, '') : '';
          if (p.types[k] === 'b') return blk ? valueXml(esc(id), blk) : '';
          const sh = p.types[k] === 'n' ? ['math_number', 'NUM'] : ['text', 'TEXT'];
          return valueXml(esc(id), `<shadow type="${sh[0]}"><field name="${sh[1]}">${esc(a.lit ? a.value : '')}</field></shadow>${blk}`);
        }).join('');
        return `<block type="procedures_call">${procMutation(p, false)}${args}${nx}</block>`;
      }
      default: {
        let inner = '';
        if (node.opcode === 'control_stop') inner += `<mutation hasnext="${node.hasNext ? 'true' : 'false'}"></mutation>`;
        node.spec.args.forEach((d, k) => { inner += argXml(d, node.args[k], g); });
        if (node.sub && node.sub.length) inner += `<statement name="SUBSTACK">${stackXml(node.sub, g)}</statement>`;
        if (node.sub2 && node.sub2.length) inner += `<statement name="SUBSTACK2">${stackXml(node.sub2, g)}</statement>`;
        return `<block type="${node.opcode}">${inner}${nx}</block>`;
      }
    }
  }
  function stackXml(nodes, g) {
    let xml = '';
    for (let k = nodes.length - 1; k >= 0; k--) xml = blockXml(nodes[k], g, xml);
    return xml;
  }

  /* =====================================================================
   * 5. EDITOR HOOKUP
   * ===================================================================== */
  function getEditor() {
    let el = document.querySelector('[class*="blocks_blocks"]');
    if (!el) { const inj = document.querySelector('.injectionDiv'); el = inj && inj.parentElement; }
    for (let node = el, up = 0; node && up < 6; up++, node = node.parentElement) {
      const key = Object.keys(node).find(k => k.startsWith('__reactInternalInstance$') || k.startsWith('__reactFiber$'));
      if (!key) continue;
      for (let f = node[key]; f; f = f.return) {
        const sn = f.stateNode;
        if (sn && sn.ScratchBlocks && sn.workspace) return { SB: sn.ScratchBlocks, ws: sn.workspace, vm: sn.props && sn.props.vm };
      }
    }
    return null;
  }

  function xmlToDom(SB, text) {
    if (SB.Xml && typeof SB.Xml.textToDom === 'function') return SB.Xml.textToDom(text);
    if (SB.utils && SB.utils.xml && typeof SB.utils.xml.textToDom === 'function') return SB.utils.xml.textToDom(text);
    const doc = new DOMParser().parseFromString(text, 'text/xml');
    const err = doc.getElementsByTagName('parsererror')[0];
    if (err) throw new Error('Internal XML error: ' + err.textContent);
    return doc.documentElement;
  }

  function buildCtx(ws) {
    const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
    try {
      for (const v of ws.getAllVariables()) {
        if (v.type === '') ctx.vars.add(v.name);
        if (v.type === 'list') ctx.lists.add(v.name);
      }
    } catch (e) { /* ignore */ }
    try {
      for (const b of ws.getAllBlocks()) {
        if (b.type !== 'procedures_prototype') continue;
        const m = b.mutationToDom();
        const proccode = m.getAttribute('proccode');
        const ids = JSON.parse(m.getAttribute('argumentids') || '[]');
        const names = JSON.parse(m.getAttribute('argumentnames') || '[]');
        const toks = proccode.split(' ');
        const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
        const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
        ctx.procs.set(sig, { proccode, ids, names, types, warp: m.getAttribute('warp') === 'true', sig });
      }
    } catch (e) { /* ignore */ }
    return ctx;
  }

  function collectOpcodes(nodes, set) {
    for (const n of nodes) {
      if (n.opcode) set.add(n.opcode);
      (n.args || []).forEach(a => a.block && collectOpcodes([a.block], set));
      if (n.sub) collectOpcodes(n.sub, set);
      if (n.sub2) collectOpcodes(n.sub2, set);
    }
    return set;
  }

  /* ---------- sections: "sprite: Name" / "stage:" ---------- */
  const HEAD_SPRITE = /^sprite\s*:\s*(.+?)\s*$/i;
  const HEAD_STAGE = /^stage\s*:?\s*$/i;
  const DIRECTIVE = /^(costume|backdrop|sound|at|position|size|direction|hidden|visible|draggable|extensions?)\s*:\s*(.*)$/i;

  function splitSections(text) {
    const lines = text.replace(/\r/g, '').split('\n');
    const secs = [];
    let cur = { kind: 'current', name: null, line: 0, owned: new Set(), directives: [] };
    secs.push(cur);
    lines.forEach((raw, idx) => {
      const t = raw.trim();
      let m;
      if ((m = t.match(HEAD_SPRITE))) {
        cur = { kind: 'sprite', name: m[1], line: idx + 1, owned: new Set(), directives: [] };
        secs.push(cur); return;
      }
      if (HEAD_STAGE.test(t)) {
        cur = { kind: 'stage', name: 'Stage', line: idx + 1, owned: new Set(), directives: [] };
        secs.push(cur); return;
      }
      if ((m = t.match(DIRECTIVE))) { cur.directives.push({ key: m[1].toLowerCase(), val: m[2].trim(), n: idx + 1 }); return; }
      cur.owned.add(idx);
    });
    for (const s of secs) s.text = lines.map((l, i) => (s.owned.has(i) ? l : '//')).join('\n');
    return secs.filter(s => s.kind !== 'current' || s.directives.length || /\S/.test(s.text.replace(/^\/\/$/gm, '')));
  }

  /* ---------- costumes made from simple shapes ---------- */
  const CAT = [
    { name: 'costume1', assetId: 'bcf454acf82e4504149f7ffe07081dbc', dataFormat: 'svg', cx: 48, cy: 50 },
    { name: 'costume2', assetId: '0fb9be3e8397c983338cb71dc84d0b25', dataFormat: 'svg', cx: 46, cy: 53 },
  ];
  const SOUNDS = {
    pop: { assetId: '83a9787d4cb6f3b7632b4ddfebf74367', dataFormat: 'wav', rate: 48000, sampleCount: 1032 },
    meow: { assetId: '83c36d806dc92327b9e7049a565c6bff', dataFormat: 'wav', rate: 48000, sampleCount: 40682 },
  };
  const COLOR_RE = /^(#[0-9a-f]{3,8}|[a-z]+)$/i;

  function tokenizeArgs(s) {
    const out = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m;
    while ((m = re.exec(s))) out.push(m[1] !== undefined ? { q: m[1] } : m[2] !== undefined ? { q: m[2] } : { w: m[3].replace(/,$/, '') });
    return out;
  }
  function parseLook(val, isStage, n) {
    let name = null, body = val;
    const eq = val.match(/^([^=]+?)\s*=\s*(.+)$/);
    if (eq) { name = eq[1].trim(); body = eq[2]; }
    const toks = tokenizeArgs(body);
    if (!toks.length) throw lineErr(n, 'Missing a costume description');
    let shape = (toks[0].w || '').toLowerCase();
    let rest = toks.slice(1);
    if (!toks[0].w || toks[0].w.startsWith('#')) {
      shape = isStage ? 'color' : 'square'; rest = toks; // e.g. "backdrop: #000000"
    }
    const colors = [], nums = []; let text = null;
    for (const t of rest) {
      if (t.q !== undefined) { text = t.q; continue; }
      if (NUM_RE.test(t.w)) nums.push(Number(t.w));
      else if (COLOR_RE.test(t.w)) colors.push(t.w);
      else if (shape === 'text' && text === null) text = t.w;
      else throw lineErr(n, `I don't understand "${t.w}" (colors look like #ff0000 or red)`);
    }
    const look = { name, shape, colors, nums, text };
    if (isStage && !['color', 'colour', 'solid', 'gradient'].includes(shape)) throw lineErr(n, 'Backdrops can be: #color, color #hex, or gradient #top #bottom');
    if (!isStage && !['circle', 'square', 'rect', 'rectangle', 'triangle', 'star', 'text', 'cat', 'cat2'].includes(shape)) throw lineErr(n, `Unknown costume shape "${shape}". Try circle, square, rect, triangle, star, text, or cat`);
    return look;
  }
  function lookSvg(l) {
    const c1 = l.colors[0] || (l.shape === 'gradient' ? '#4c97ff' : l.shape.startsWith('colo') || l.shape === 'solid' ? '#ffffff' : '#4c97ff');
    const stroke = 'stroke="rgba(0,0,0,0.35)" stroke-width="2"';
    const svg = (w, h, inner) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${inner}</svg>`;
    const p = 2;
    switch (l.shape) {
      case 'color': case 'colour': case 'solid':
        return { svg: svg(480, 360, `<rect width="480" height="360" fill="${c1}"/>`), cx: 240, cy: 180 };
      case 'gradient': {
        const c2 = l.colors[1] || '#ffffff';
        return { svg: svg(480, 360, `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs><rect width="480" height="360" fill="url(#g)"/>`), cx: 240, cy: 180 };
      }
      case 'circle': { const d = l.nums[0] || 40, w = d + p * 2; return { svg: svg(w, w, `<circle cx="${w / 2}" cy="${w / 2}" r="${d / 2}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
      case 'square': { const s = l.nums[0] || 40, w = s + p * 2; return { svg: svg(w, w, `<rect x="${p}" y="${p}" width="${s}" height="${s}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
      case 'rect': case 'rectangle': {
        const rw = l.nums[0] || 80, rh = l.nums[1] || 20, w = rw + p * 2, h = rh + p * 2;
        return { svg: svg(w, h, `<rect x="${p}" y="${p}" width="${rw}" height="${rh}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: h / 2 };
      }
      case 'triangle': { // points right, so it faces the way the sprite moves
        const s = l.nums[0] || 40, w = s + p * 2;
        return { svg: svg(w, w, `<polygon points="${p},${p} ${s + p},${s / 2 + p} ${p},${s + p}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 };
      }
      case 'star': {
        const s = l.nums[0] || 40, w = s + p * 2, R = s / 2, r = R * 0.45, cx = w / 2;
        const pts = [];
        for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r : R; pts.push(`${(cx + rr * Math.cos(a)).toFixed(2)},${(cx + rr * Math.sin(a)).toFixed(2)}`); }
        return { svg: svg(w, w, `<polygon points="${pts.join(' ')}" fill="${c1}" ${stroke}/>`), cx, cy: cx };
      }
      case 'text': {
        const t = l.text || 'Text', f = l.nums[0] || 24;
        const w = Math.ceil(t.length * f * 0.62) + 8, h = Math.ceil(f * 1.4);
        return { svg: svg(w, h, `<text x="${w / 2}" y="${(f * 1.05).toFixed(1)}" font-family="Sans Serif" font-size="${f}" font-weight="bold" text-anchor="middle" fill="${l.colors[0] || '#000000'}">${esc(t)}</text>`), cx: w / 2, cy: h / 2 };
      }
    }
    return null;
  }

  function makeCostume(vm, look, defaultName) {
    if (look.shape === 'cat' || look.shape === 'cat2') {
      const c = CAT[look.shape === 'cat' ? 0 : 1];
      return { name: look.name || defaultName, assetId: c.assetId, dataFormat: 'svg', md5ext: c.assetId + '.svg', cx: c.cx, cy: c.cy, asset: null };
    }
    const { svg, cx, cy } = lookSvg(look);
    const st = vm.runtime.storage;
    const data = new TextEncoder().encode(svg);
    const asset = st.createAsset(st.AssetType.ImageVector, st.DataFormat.SVG, data, null, true);
    // Cache it so a new sprite's JSON can reference it by id
    try {
      if (typeof st.cache === 'function') st.cache(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
      else if (st.builtinHelper && st.builtinHelper._store) st.builtinHelper._store(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
    } catch (e) { /* ignore */ }
    return { name: look.name || defaultName, assetId: asset.assetId, dataFormat: 'svg', md5ext: asset.assetId + '.svg', cx, cy, asset };
  }

  function findSprite(vm, name) {
    return vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name === name) ||
      vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name.toLowerCase() === name.toLowerCase());
  }

  function ctxFromVM(vm, target) {
    const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
    const stage = vm.runtime.getTargetForStage();
    for (const t of [stage, target]) {
      if (!t) continue;
      for (const v of Object.values(t.variables || {})) {
        if (v.type === '') ctx.vars.add(v.name);
        if (v.type === 'list') ctx.lists.add(v.name);
      }
    }
    if (target && target.blocks && target.blocks._blocks) {
      for (const b of Object.values(target.blocks._blocks)) {
        if (b.opcode !== 'procedures_prototype' || !b.mutation) continue;
        try {
          const proccode = b.mutation.proccode;
          const ids = JSON.parse(b.mutation.argumentids || '[]');
          const names = JSON.parse(b.mutation.argumentnames || '[]');
          const toks = proccode.split(' ');
          const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
          const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
          ctx.procs.set(sig, { proccode, ids, names, types, warp: String(b.mutation.warp) === 'true', sig });
        } catch (e) { /* ignore */ }
      }
    }
    return ctx;
  }

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const yes = v => /^(yes|true|on|1)$/i.test(v.trim());

  const plainId = () => { const c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let r = ''; for (let i = 0; i < 20; i++) r += c[Math.floor(Math.random() * c.length)]; return r; };
  function countRows(nodes) {
    let n = 0;
    for (const nd of nodes) { n += 1; if (nd.sub) n += countRows(nd.sub) + 1; if (nd.sub2) n += countRows(nd.sub2) + 1; }
    return n;
  }

  // Put scripts straight into a sprite's (or the stage's) code in the project,
  // without needing that sprite to be open in the editor.
  function insertScriptsVM(vm, target, scripts, opts, created) {
    const stage = vm.runtime.getTargetForStage();
    const g = {
      opcodes: new Set(),
      varId(name, type) {
        let v = target.lookupVariableByNameAndType(name, type);
        if (!v) {
          const id = plainId();
          const owner = (opts.local && type !== 'broadcast_msg' && !target.isStage) ? target : stage;
          owner.createVariable(id, name, type);
          v = owner.variables[id];
          if (type !== 'broadcast_msg') created.add(name);
        }
        return v.id;
      },
    };
    // Place new scripts to the right of any code already there
    let x = 40, y = 40;
    const existing = (target.blocks._scripts || []).map(id => target.blocks._blocks[id]).filter(Boolean);
    if (existing.length) x = Math.max(...existing.map(b => Number(b.x) || 0)) + 520;
    for (const sc of scripts) {
      let xml = stackXml(sc, g);
      const topId = plainId();
      let first = true;
      xml = xml.replace(/<(block|shadow) type=/g, (m, tag) => {
        if (first) { first = false; return `<${tag} id="${topId}" x="${Math.round(x)}" y="${Math.round(y)}" type=`; }
        return `<${tag} id="${plainId()}" type=`;
      });
      target.blocks.blocklyListen({ type: 'create', blockId: topId, xml: { outerHTML: xml } });
      y += countRows(sc) * 44 + 70;
    }
  }

  async function createBlocks(text, opts) {
    const ed = getEditor();
    if (!ed) throw new Error("Couldn't find the Scratch code area. Open the project in the editor (See inside) and stay on the Code tab.");
    const vm = ed.vm;
    if (!vm) throw new Error("Couldn't reach the Scratch project (VM).");
    const stage = vm.runtime.getTargetForStage();

    // ---- 1. Parse everything first; nothing changes if there's a mistake
    const secs = splitSections(text);
    if (!secs.length) throw new Error('Nothing to build. Type some blocks first.');
    // Extensions: load any listed with "extension: name", then read their blocks
    const em = vm.extensionManager;
    const isLoaded = id => { try { return em.isExtensionLoaded(id); } catch (e) { return false; } };
    const addedExt = [];
    async function loadExt(ref, n) {
      if (!/^(https?:|data:)/i.test(ref) && isLoaded(ref)) return;
      try { await em.loadExtensionURL(ref); }
      catch (e) { throw lineErr(n, `Couldn't load the extension "${ref}"${e && e.message ? ': ' + e.message : ''}`); }
      addedExt.push(ref);
      await sleep(150);
    }
    for (const sec of secs) {
      for (const d of sec.directives) {
        if (d.key !== 'extension' && d.key !== 'extensions') continue;
        for (const name of d.val.split(',').map(x => x.trim()).filter(Boolean)) await loadExt(extensionRef(name), d.n);
      }
    }
    refreshDynamicSpecs(vm);

    const parseAll = () => {
      for (const sec of secs) {
        const target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
        const isStage = sec.kind === 'stage' || (sec.kind === 'current' && target && target.isStage);
        sec.looks = []; sec.sounds = []; sec.props = {};
        for (const d of sec.directives) {
          if (d.key === 'costume' || d.key === 'backdrop') {
            if (isStage && d.key === 'costume') throw lineErr(d.n, 'The stage has backdrops, not costumes. Use "backdrop:"');
            if (!isStage && d.key === 'backdrop') throw lineErr(d.n, '"backdrop:" goes under "stage:"');
            sec.looks.push(parseLook(d.val, isStage, d.n));
          } else if (d.key === 'sound') {
            const k = d.val.toLowerCase();
            if (!SOUNDS[k]) throw lineErr(d.n, 'Built-in sounds you can add: pop, meow');
            sec.sounds.push(k);
          } else if (d.key === 'extension' || d.key === 'extensions') {
            // handled before parsing
          } else {
            if (isStage) throw lineErr(d.n, `"${d.key}:" only works for sprites`);
            if (d.key === 'at' || d.key === 'position') {
              const nums = d.val.split(/[\s,]+/).filter(Boolean).map(Number);
              if (nums.length !== 2 || nums.some(isNaN)) throw lineErr(d.n, 'Use "at: x, y", like "at: 0, -120"');
              sec.props.xy = nums;
            } else if (d.key === 'size' || d.key === 'direction') {
              const v = Number(d.val); if (isNaN(v)) throw lineErr(d.n, `"${d.key}:" needs a number`);
              sec.props[d.key] = v;
            } else if (d.key === 'hidden') sec.props.visible = !yes(d.val);
            else if (d.key === 'visible') sec.props.visible = yes(d.val);
            else if (d.key === 'draggable') sec.props.draggable = yes(d.val);
          }
        }
        sec.scripts = parseText(sec.text, ctxFromVM(vm, target));
      }
    };
    // If a line isn't recognized, see if a built-in extension would know it, add it, and try again
    const srcLines = text.replace(/\r/g, '').split('\n');
    for (let attempt = 0; ; attempt++) {
      try { parseAll(); break; } catch (e) {
        if (!(e instanceof ParseError) || !e.line || attempt > 10) throw e;
        const lineText = lc(srcLines[e.line - 1] || '');
        const hit = EXT_HINTS.find(([id, re]) => !isLoaded(id) && re.test(lineText));
        if (!hit) throw e;
        await loadExt(hit[0], e.line);
        refreshDynamicSpecs(vm);
      }
    }

    // ---- 2. Build
    const created = new Set(), report = [], cleared = new Set();
    let total = 0;
    for (const sec of secs) {
      let target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
      let isNew = false;

      if (sec.kind === 'sprite' && !target) {
        const looks = sec.looks.length ? sec.looks : [{ shape: 'cat' }, { shape: 'cat2', name: 'costume2' }];
        const costumes = looks.map((l, i) => makeCostume(vm, l, `costume${i + 1}`));
        const p = sec.props;
        const json = {
          isStage: false, name: sec.name, variables: {}, lists: {}, broadcasts: {}, blocks: {}, comments: {},
          currentCostume: 0,
          costumes: costumes.map(c => ({ name: c.name, bitmapResolution: 1, dataFormat: c.dataFormat, assetId: c.assetId, md5ext: c.md5ext, rotationCenterX: c.cx, rotationCenterY: c.cy })),
          sounds: sec.sounds.map(k => ({ name: k, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, format: '', rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount, md5ext: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat })),
          volume: 100, visible: p.visible !== false,
          x: p.xy ? p.xy[0] : Math.round(Math.random() * 200 - 100), y: p.xy ? p.xy[1] : Math.round(Math.random() * 160 - 80),
          size: p.size || 100, direction: p.direction || 90, draggable: !!p.draggable, rotationStyle: 'all around',
        };
        const before = new Set(vm.runtime.targets.map(t => t.id));
        await vm.addSprite(JSON.stringify(json));
        target = findSprite(vm, sec.name) || vm.runtime.targets.find(t => t.isOriginal && !t.isStage && !before.has(t.id));
        if (!target) throw lineErr(sec.line, `Scratch didn't create the sprite "${sec.name}"`);
        isNew = true;
        report.push(`made sprite "${target.sprite.name}"`);
      } else {
        if (!target) continue;
        // Add costumes/backdrops/sounds that aren't there yet
        let firstName = null;
        for (let i = 0; i < sec.looks.length; i++) {
          const base = target.isStage ? 'backdrop' : 'costume';
          const c = makeCostume(vm, sec.looks[i], `${base}${target.getCostumes().length + 1}`);
          if (!firstName) firstName = c.name;
          if (target.getCostumeIndexByName(c.name) >= 0) continue;
          await vm.addCostume(c.md5ext, {
            name: c.name, dataFormat: c.dataFormat, asset: c.asset || undefined, md5: c.md5ext, assetId: c.assetId,
            rotationCenterX: c.cx, rotationCenterY: c.cy, bitmapResolution: 1, skinId: null,
          }, target.id);
        }
        if (firstName) { const ix = target.getCostumeIndexByName(firstName); if (ix >= 0) target.setCostume(ix); }
        for (const k of sec.sounds) {
          if (target.sprite.sounds.some(s => s.name === k)) continue;
          await vm.addSound({ name: k, md5: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount }, target.id);
        }
        const p = sec.props;
        if (p.xy) target.setXY(p.xy[0], p.xy[1]);
        if (p.size !== undefined) target.setSize(p.size);
        if (p.direction !== undefined) target.setDirection(p.direction);
        if (p.visible !== undefined) target.setVisible(p.visible);
        if (p.draggable !== undefined) target.setDraggable(p.draggable);
        if (sec.looks.length || sec.sounds.length || Object.keys(p).length) report.push(`updated ${target.isStage ? 'the stage' : `"${target.sprite.name}"`}`);
      }

      if (sec.scripts.length) {
        if (opts.replace && !isNew && !cleared.has(target.id)) {
          cleared.add(target.id);
          for (const id of (target.blocks._scripts || []).slice()) target.blocks.deleteBlock(id);
        }
        insertScriptsVM(vm, target, sec.scripts, opts, created);
        total += sec.scripts.length;
        report.push(`${sec.scripts.length} script${sec.scripts.length === 1 ? '' : 's'} → ${target.isStage ? 'Stage' : target.sprite.name}`);
      }
    }
    try { vm.runtime.emitProjectChanged(); } catch (e) { /* ignore */ }
    try { vm.emitTargetsUpdate(); } catch (e) { /* ignore */ }
    try { vm.emitWorkspaceUpdate(); } catch (e) { /* ignore */ }
    if (addedExt.length) report.unshift(`added extension${addedExt.length === 1 ? '' : 's'}: ${addedExt.join(', ')}`);
    return { count: total, created: [...created], report };
  }

  /* =====================================================================
   * 6. UI
   * ===================================================================== */
  const EXAMPLE = `// A whole game in one script: Catch the falling ball!
stage:
backdrop: Sky = gradient #7ec8ff #ffffff

sprite: Paddle
costume: paddle = rect #3366ff 100 18
at: 0, -150

when flag clicked
go to x: (0) y: (-150)
set [score v] to (0)
forever
  if <key (left arrow v) pressed?> then
    change x by (-9)
  end
  if <key (right arrow v) pressed?> then
    change x by (9)
  end
end

sprite: Ball
costume: ball = circle #ff4444 26
sound: pop

when flag clicked
set [speed v] to (4)
go to x: (pick random (-200) to (200)) y: (170)
forever
  change y by ((0) - (speed))
  if <touching (Paddle v)?> then
    change [score v] by (1)
    change [speed v] by (0.5)
    start sound (pop v)
    go to x: (pick random (-200) to (200)) y: (170)
  end
  if <(y position) < (-170)> then
    broadcast [game over v]
    stop [this script v]
  end
end

sprite: GameOver
costume: text "GAME OVER" #222222 40
hidden: yes
at: 0, 40

when flag clicked
hide

when I receive [game over v]
show
stop [all v]`;

  function initUI() {
    const css = `
      #ttb-toggle{position:fixed;left:72px;bottom:16px;z-index:9999;background:#855cd6;color:#fff;border:none;border-radius:20px;
        padding:8px 14px;font:600 13px/1 "Helvetica Neue",Helvetica,Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.25)}
      #ttb-toggle:hover{background:#7448c9}
      #ttb-panel{position:fixed;left:72px;bottom:60px;width:470px;z-index:10000;background:#fff;border:1px solid #d9d9d9;border-radius:10px;
        box-shadow:0 6px 24px rgba(0,0,0,.25);font:13px "Helvetica Neue",Helvetica,Arial,sans-serif;color:#575e75;display:none}
      #ttb-panel header{background:#855cd6;color:#fff;padding:8px 12px;border-radius:10px 10px 0 0;cursor:move;display:flex;justify-content:space-between;align-items:center;font-weight:600}
      #ttb-panel header button{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1}
      #ttb-panel .ttb-body{padding:10px 12px}
      #ttb-panel textarea{width:100%;box-sizing:border-box;height:260px;font:12.5px/1.45 Menlo,Consolas,monospace;border:1px solid #ccc;border-radius:6px;padding:8px;resize:vertical;tab-size:2}
      #ttb-panel .ttb-row{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
      #ttb-panel .ttb-btn{border:none;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
      #ttb-panel .ttb-go{background:#4c97ff;color:#fff}
      #ttb-panel .ttb-go:hover{background:#3373cc}
      #ttb-panel .ttb-sec{background:#e9eef2;color:#575e75}
      #ttb-panel label{display:flex;gap:4px;align-items:center;font-size:12px}
      #ttb-status{margin-top:8px;min-height:18px;font-size:12px;white-space:pre-wrap}
      #ttb-status.err{color:#d4352b}#ttb-status.ok{color:#0b8e69}
      #ttb-panel .ttb-hint{font-size:11px;color:#8a8fa3;margin-top:6px}`;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    const toggle = document.createElement('button');
    toggle.id = 'ttb-toggle';
    toggle.textContent = '✎ Text → Blocks';
    toggle.style.display = 'none';
    document.body.appendChild(toggle);

    const panel = document.createElement('div');
    panel.id = 'ttb-panel';
    panel.innerHTML = `
      <header><span>Text → Blocks</span><button title="Close">×</button></header>
      <div class="ttb-body">
        <textarea spellcheck="false" placeholder="Type scratchblocks text here…"></textarea>
        <div class="ttb-row">
          <button class="ttb-btn ttb-go">Create blocks</button>
          <button class="ttb-btn ttb-sec" data-a="example">Example</button>
          <button class="ttb-btn ttb-sec" data-a="clear">Clear</button>
          <label title="New variables/lists are made 'For this sprite only'"><input type="checkbox" data-o="local"> Sprite-only vars</label>
          <label title="Delete the code already in each sprite the text lists before adding the new code (so rebuilding doesn't make duplicates)"><input type="checkbox" data-o="replace"> Replace code</label>
        </div>
        <div id="ttb-status"></div>
        <div class="ttb-hint">(round) = number/reporter · [square] = text · [thing v] = dropdown · &lt;pointy&gt; = boolean · close C-blocks with "end". Start a section with "sprite: Name" or "stage:". Ctrl+Enter to build.</div>
      </div>`;
    document.body.appendChild(panel);

    const ta = panel.querySelector('textarea');
    const status = panel.querySelector('#ttb-status');
    const local = panel.querySelector('[data-o=local]');
    const replace = panel.querySelector('[data-o=replace]');
    try {
      ta.value = localStorage.getItem('ttb-text') || '';
      local.checked = localStorage.getItem('ttb-local') === '1';
      replace.checked = localStorage.getItem('ttb-replace') === '1';
    } catch (e) { /* ignore */ }

    const setStatus = (msg, cls) => { status.textContent = msg; status.className = cls || ''; };
    const save = () => { try { localStorage.setItem('ttb-text', ta.value); localStorage.setItem('ttb-local', local.checked ? '1' : '0'); localStorage.setItem('ttb-replace', replace.checked ? '1' : '0'); } catch (e) { /* ignore */ } };

    async function go() {
      save();
      setStatus('Building…');
      try {
        const { count, created, report } = await createBlocks(ta.value, { local: local.checked, replace: replace.checked });
        let msg = `✓ Done: ${report.length ? report.join(' · ') : `added ${count} script${count === 1 ? '' : 's'}`}.`;
        if (created.length) msg += `\nNew variables/lists made: ${[...new Set(created)].join(', ')} (if one is a typo of a block name, fix the text and try again).`;
        setStatus(msg, 'ok');
      } catch (e) {
        setStatus(e.message || String(e), 'err');
        if (e.line) selectLine(e.line);
        if (!(e instanceof ParseError)) console.error('[Text → Blocks]', e);
      }
    }
    function selectLine(n) {
      const lines = ta.value.split('\n');
      let start = 0;
      for (let k = 0; k < n - 1; k++) start += lines[k].length + 1;
      ta.focus();
      ta.setSelectionRange(start, start + (lines[n - 1] || '').length);
    }

    toggle.onclick = () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; if (panel.style.display === 'block') ta.focus(); };
    panel.querySelector('header button').onclick = () => { panel.style.display = 'none'; };
    panel.querySelector('.ttb-go').onclick = go;
    panel.querySelector('[data-a=example]').onclick = () => { ta.value = EXAMPLE; save(); };
    panel.querySelector('[data-a=clear]').onclick = () => { ta.value = ''; save(); setStatus(''); ta.focus(); };
    local.onchange = save;
    replace.onchange = save;
    ta.addEventListener('input', save);
    ta.addEventListener('keydown', e => {
      e.stopPropagation();
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); go(); }
      if (e.key === 'Tab') {
        e.preventDefault();
        const s = ta.selectionStart;
        ta.setRangeText('  ', s, ta.selectionEnd, 'end');
      }
    });
    ['keyup', 'keypress'].forEach(t => ta.addEventListener(t, e => e.stopPropagation()));

    // Dragging the panel by its header
    const header = panel.querySelector('header');
    header.addEventListener('mousedown', e => {
      if (e.target.tagName === 'BUTTON') return;
      const r = panel.getBoundingClientRect();
      const dx = e.clientX - r.left, dy = e.clientY - r.top;
      const move = ev => {
        panel.style.left = Math.max(0, ev.clientX - dx) + 'px';
        panel.style.top = Math.max(0, ev.clientY - dy) + 'px';
        panel.style.bottom = 'auto';
      };
      const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
      e.preventDefault();
    });

    // Only show the button while the editor's code area is on screen
    setInterval(() => {
      const inEditor = !!document.querySelector('[class*="blocks_blocks"], .injectionDiv');
      toggle.style.display = inEditor ? 'block' : 'none';
      if (!inEditor) panel.style.display = 'none';
    }, 1000);
  }

  if (typeof window !== 'undefined' && window.document && !window.__TTB_TEST__) initUI();
  if (typeof window !== 'undefined' && window.__TTB_TEST__) window.__TTB__ = { refreshDynamicSpecs, insertScriptsVM, parseText, stackXml, buildCtx, splitSections, parseLook, lookSvg };
})();