☰

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).

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

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

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

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

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

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

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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

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

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

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

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

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

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

// ==UserScript==
// @name         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 };
})();