diff --git a/assets/videos/arid-surface-03.mp4 b/assets/videos/arid-surface-03.mp4 new file mode 100644 index 0000000..5fa08a2 Binary files /dev/null and b/assets/videos/arid-surface-03.mp4 differ diff --git a/assets/videos/arid-takeoff-03.mp4 b/assets/videos/arid-takeoff-03.mp4 new file mode 100644 index 0000000..0fccd44 Binary files /dev/null and b/assets/videos/arid-takeoff-03.mp4 differ diff --git a/data/actionbar.json b/data/actionbar.json index 54956cc..17397ec 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -72,6 +72,16 @@ "intervalMs": [3500, 9000], "durationMs": [240, 480], "slices": [3, 6] + }, + "shimmer": { + "_comment": "The RESEARCH slot's READY cue (GameScene._applyResearchCue arms it while a tech can start — one cue on screen at a time; the console's category tabs wear the same recipe, data/research.json → fx.tabShimmer): a yellow band sweeps the face, then a comet runs the cut-corner edge (js/ui/ShimmerCue.js). color is the comm pulse's yellow family (data/comms.json → box.pulse); periodMs = faceMs + gapMs + edgeMs + the silent rest.", + "enabled": true, + "color": "#ffd84d", + "periodMs": 4200, + "faceMs": 700, + "gapMs": 140, + "edgeMs": 1500, + "dashFrac": 0.3 } } } diff --git a/data/landing.json b/data/landing.json index 9b0a450..909fc15 100644 --- a/data/landing.json +++ b/data/landing.json @@ -75,7 +75,7 @@ { "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, - { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, + { "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" }, { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, diff --git a/data/research.json b/data/research.json index d1f2f2e..2493780 100644 --- a/data/research.json +++ b/data/research.json @@ -16,9 +16,19 @@ "aspect": [2, 3] }, "fx": { - "_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed.", + "_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed; tabShimmer = the READY-RESEARCH cue on the category tabs (the deck's RESEARCH-slot shimmer's twin — data/actionbar.json → animation.shimmer, js/ui/ShimmerCue.js).", "glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] }, - "sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 } + "sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 }, + "tabShimmer": { + "_comment": "Per frame while the console is open + no project in flight (maxConcurrent 1): every category tab whose tree still holds an available tech wears the edge comet (ResearchWindow._paintCues). The face sweep is deck-only (faceMs/gapMs 0 — the comet owns its window); tabs are staggered i*420 ms. color is the comm pulse's yellow family.", + "enabled": true, + "color": "#ffd84d", + "periodMs": 4200, + "faceMs": 0, + "gapMs": 0, + "edgeMs": 1500, + "dashFrac": 0.3 + } }, "_nodeTemplate": { "label": "Tech name", diff --git a/dev/research-cue-shot.html b/dev/research-cue-shot.html new file mode 100644 index 0000000..72aae2c --- /dev/null +++ b/dev/research-cue-shot.html @@ -0,0 +1,17 @@ + + + + + + Orbit — READY-RESEARCH cue (dev verify) + + + + +
+ + + diff --git a/dev/research-cue-shot.mjs b/dev/research-cue-shot.mjs new file mode 100644 index 0000000..d987078 --- /dev/null +++ b/dev/research-cue-shot.mjs @@ -0,0 +1,225 @@ +/** + * Dev-only: boot GameScene and VERIFY the READY-RESEARCH attention cue + * end-to-end in a real browser (the node tests pin the model + the deck; + * this drives the live scene + the console): + * + * 1. deck (window closed) — the RESEARCH slot's cue is armed (a tech + * can start), and the cycle paints: face band → edge comet → rest; + * 2. console (window open) — the deck's slot rests, the ready category + * tabs wear the edge shimmer, and the system's "Unlock Jumpgates" + * tech wears the ACK pulse (forced chart-complete): on its plate + * while the SYSTEM tree is on screen, on the SYSTEM tab otherwise; + * 3. in-flight — starting a project rests BOTH halves (maxConcurrent 1). + * + * The report (window.__CUE_SHOT + the #report
) lists every check;
+ * the two screenshots pin the two faces of the cue (each capture freezes
+ * its cycle mid-window so the live clock paints the same frame):
+ *
+ *   node dev/server.mjs 8091
+ *   /…/chrome --headless --disable-gpu --remote-debugging-port=9333 about:blank
+ *   node dev/cdp-shot.mjs http://127.0.0.1:8091/dev/research-cue-shot.html \
+ *     /tmp/cue-deck.png "window.__CUE_SHOT && window.__CUE_SHOT.ready" 90000 \
+ *     "window.__CUE_SHOT.captureDeck()"
+ *   node dev/cdp-shot.mjs http://127.0.0.1:8091/dev/research-cue-shot.html \
+ *     /tmp/cue-console.png "window.__CUE_SHOT && window.__CUE_SHOT.ready" 90000 \
+ *     "window.__CUE_SHOT.captureConsole()"
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+import { gatesReady } from '../js/research/SystemCategory.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+const errors = [];
+const origErr = console.error.bind(console);
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
+window.addEventListener('error', (e) => errors.push(String(e.message)));
+window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
+
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:75%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+  for (let i = 0; i < 100; i++) {
+    const s = game.scene.getScene('GameScene');
+    if (s && s.ship && s.researchWindow) return s;
+    await sleep(100);
+  }
+  throw new Error('GameScene never booted');
+}
+
+// Recorder over a Phaser Graphics (counts the cue's own call surface).
+const recorder = (g) => {
+  const r = { fillRect: 0, strokePoints: 0, fillPoints: 0, clear: 0 };
+  const orig = {
+    fillRect: g.fillRect.bind(g),
+    strokePoints: g.strokePoints.bind(g),
+    fillPoints: g.fillPoints.bind(g),
+    clear: g.clear.bind(g),
+  };
+  g.fillRect = (...a) => { r.fillRect++; return orig.fillRect(...a); };
+  g.strokePoints = (...a) => { r.strokePoints++; return orig.strokePoints(...a); };
+  g.fillPoints = (...a) => { r.fillPoints++; return orig.fillPoints(...a); };
+  g.clear = (...a) => { r.clear++; return orig.clear(...a); };
+  r.reset = () => { r.fillRect = 0; r.strokePoints = 0; r.fillPoints = 0; r.clear = 0; };
+  return r;
+};
+
+try {
+  const s = await waitScene();
+  await sleep(900); // let the boot settle
+  const lines = [];
+  const check = (label, cond) => lines.push(`${cond ? '✔' : '✘ FAIL'} ${label}`);
+  const bar = s.actionBar;
+  const win = s.researchWindow;
+  const st = s.researchState;
+  const slot = bar.slots.find((x) => x.id === 'research');
+
+  // ---- 1. the deck's RESEARCH slot (the window is closed) --------------
+  s._applyResearchCue(); // the scene's per-frame beat
+  check('deck: cue armed on the RESEARCH slot (fresh run — a tech can start)', bar.shimmerId === 'research');
+  const r = recorder(bar.shimmerG);
+  const cfg = bar.cfgShimmer;
+  const off = slot.phase * 350; // the bar's per-slot stagger
+  const P = cfg.periodMs;
+  const at = (u) => (((u % P) + P) % P);
+  const step = (t) => { r.reset(); bar.update(t, 16); return { ...r }; };
+
+  let a = step(at(cfg.faceMs / 2 - off));
+  check(`deck: face window — the band paints (fillRects ${a.fillRect} ≥ 3)`, a.fillRect >= 3 && a.strokePoints === 0);
+  a = step(at(cfg.faceMs + cfg.gapMs + cfg.edgeMs / 2 - off));
+  check(`deck: edge window — the comet runs (stroke passes ${a.strokePoints} ≥ 4)`, a.strokePoints >= 4 && a.fillRect === 0);
+  a = step(at(P - 1 - off));
+  check(`deck: rest — the layer idles (cleared=${a.clear})`, a.fillRect === 0 && a.strokePoints === 0 && a.clear >= 1);
+
+  // ---- 2. the console takes the cue over --------------------------------
+  s.deckAction('research');
+  for (let i = 0; i < 50 && win.openState !== 'open'; i++) await sleep(60);
+  await sleep(800); // the boot reveal finishes
+  s._applyResearchCue();
+  check('deck: the slot rests while the console is open', bar.shimmerId === null);
+
+  // Force the SYSTEM chart complete (the scene's isNavComplete hook) so
+  // the "Unlock Jumpgates" tech is ready to be researched.
+  s.systemTree.available = () => true;
+  s.systemTree.lockNote = () => null; // the readout agrees (AVAILABLE, not LOCKED)
+  win.refresh();
+  const gid = gatesReady(s.systemTree, st);
+  check('console: the gates tech is READY (chart forced complete)', !!gid);
+
+  const rw = recorder(win.cueG);
+  const wcfg = win.cfgShimmer;
+  const W = (t) => { rw.reset(); win.update(t); return { ...rw }; };
+  // A time inside the comet window (the tabs stagger i*420 — the first
+  // tab's window is [0, edgeMs); every tab's is within a few hundred ms).
+  const tw = Math.min(200, wcfg.edgeMs);
+
+  let w = W(s.time.now + tw);
+  check(`console: the ready tabs wear the edge shimmer (strokes ${w.strokePoints} ≥ 6)`, w.strokePoints >= 6);
+  w = W(s.time.now + tw);
+  check(`console: the gates plate wears the ACK pulse (halo fills ${w.fillPoints} ≥ 1)`, w.fillPoints >= 1);
+
+  // Off the SYSTEM tab → the pulse moves to the SYSTEM tab itself.
+  win.switchCategory('exploration');
+  await sleep(30);
+  w = W(s.time.now + tw);
+  check(`console: on another tab the pulse moves to the SYSTEM tab (halo fills ${w.fillPoints} ≥ 1)`, w.fillPoints >= 1);
+  win.switchCategory('system');
+  await sleep(30);
+
+  // ---- 3. in-flight — nothing can start, the cue rests everywhere ------
+  s.beginResearch('exploration', 'tether_l2');
+  s._applyResearchCue();
+  await sleep(30);
+  check('deck: in flight — the slot rests (maxConcurrent 1)', bar.shimmerId === null);
+  w = W(s.time.now + tw);
+  check(`console: in flight — the cues rest (strokes ${w.strokePoints}, fills ${w.fillPoints})`,
+    w.strokePoints === 0 && w.fillPoints === 0);
+  st.active = null; // dev poke — put the run back to "a tech can start"
+  win.refresh();
+
+  // ---- the capture poses (beforeCaptureExpr) ---------------------------
+  // The scene's live clock keeps redrawing, so each capture widens the
+  // cycle's window around the live time — every frame paints the same
+  // (frozen-mid-window) pose until the restore lands well after the shot.
+  window.__CUE_SHOT = {
+    ready: true,
+    lines,
+    errors,
+    captureDeck: async () => {
+      // The deck's face: the comms quiet (the intro's ACK queue is
+      // dev-drained — in play the hold owns the screen until the player
+      // presses through, and the cue rests under it by design), the
+      // console closed, the face window frozen mid-sweep.
+      const h = s.comms;
+      if (h) {
+        for (const q of h.queue) if (q?.def?.id) h.played.add(q.def.id);
+        h.queue.length = 0;
+        if (h.current) h._finish(); // the one in play: end it (the pause lifts)
+        await sleep(60);
+      }
+      if (win.isOpen) win.close(); // straight to the window's API (deckAction's input guard may still hold the ACK click)
+      for (let i = 0; i < 30 && win.isOpen; i++) await sleep(60);
+      const now = s.time.now;
+      const saved = { ...cfg };
+      Object.assign(cfg, { periodMs: now * 6, faceMs: now * 1.5, gapMs: 40, edgeMs: 500 });
+      await sleep(120); // a couple of scene frames at the pose
+      window.__CUE_SHOT.dbg = {
+        shimmerId: bar.shimmerId,
+        enabled: bar.enabled,
+        slotDisabled: slot?.disabled,
+        highlightId: bar.highlightId,
+        anyAvail: s.researchAnyAvailable(),
+        commsPaused: s.comms?.paused === true,
+        winOpen: win.openState,
+        now,
+        cfg: { ...cfg },
+        layer: typeof bar.shimmerG.geometry?.commands?.length,
+      };
+      document.getElementById('report').style.display = 'none';
+      setTimeout(() => Object.assign(cfg, saved), 2500);
+      return 'deck pose set';
+    },
+    captureConsole: async () => {
+      // The console's face: open on SYSTEM, gates ready, comet mid-lap.
+      if (!win.isOpen) win.open();
+      for (let i = 0; i < 50 && win.openState !== 'open'; i++) await sleep(60);
+      await sleep(900); // the reveal
+      s.systemTree.available = () => true;
+      s.systemTree.lockNote = () => null;
+      win.refresh();
+      win.switchCategory('system');
+      const now = s.time.now;
+      const savedW = { ...wcfg };
+      // edgeMs = 2·now → u = now/(2·now) = 0.5 (the comet mid-lap, full
+      // envelope) for every staggered tab; periodMs > now keeps ct = now.
+      Object.assign(wcfg, { periodMs: now * 4, faceMs: 0, gapMs: 0, edgeMs: Math.max(400, now * 2) });
+      await sleep(120);
+      document.getElementById('report').style.display = 'none';
+      setTimeout(() => Object.assign(wcfg, savedW), 2500);
+      return 'console pose set';
+    },
+  };
+  setReport(lines);
+  console.info('research-cue-shot: report painted');
+} catch (err) {
+  errors.push(`FATAL: ${err.message}`);
+  setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
+  window.__CUE_SHOT = { ready: true, fatal: String(err.message), errors };
+  console.error('research-cue-shot: fatal', err);
+}
diff --git a/dev/research-cue.test.mjs b/dev/research-cue.test.mjs
new file mode 100644
index 0000000..29a241f
--- /dev/null
+++ b/dev/research-cue.test.mjs
@@ -0,0 +1,335 @@
+/**
+ * READY-RESEARCH attention-cue test (dev tool, run with Node — no browser):
+ *
+ *   node dev/research-cue.test.mjs
+ *
+ * Pins the contract behind the "research is ready" cue (one cue on screen
+ * at a time — GameScene._applyResearchCue picks the surface):
+ *   - the deck's RESEARCH slot (ActionBar.setShimmer): a periodic yellow
+ *     band sweeps the face, then a comet runs the cut-corner edge — the
+ *     cycle model (js/ui/ShimmerCue.js: shimmerPhase/drawShimmer) is pure
+ *     and is checked here, and the slot's draw/hold behavior through the
+ *     REAL ActionBar with the headless scene (standby / slot lock / the
+ *     tutorial's finger all suppress the shimmer; unknown ids no-op);
+ *   - the console's half (ResearchWindow._paintCues): the category tabs'
+ *     shimmers ask ResearchModel.hasAvailable (the any-ready rule), and
+ *     the jump-gate tech's ACK pulse asks SystemCategory.gatesReady (the
+ *     chart gate + the in-flight gate) — both checked here against the
+ *     real ResearchState. (The window's per-frame paint is the same
+ *     ShimmerCue recipe + the existing ACK-pulse painter; its Phaser
+ *     wiring is exercised in the browser.)
+ */
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import fs from 'node:fs';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = join(__dirname, '..');
+const dataDir = join(root, 'data');
+const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
+
+let failures = 0;
+const check = (label, cond) => {
+  console.log(`${cond ? '✔' : '✘ FAIL'}  ${label}`);
+  if (!cond) failures++;
+};
+
+// ----------------------------------------------------------------------
+// Config — the cue's knobs exist in the right files, yellow family
+// ----------------------------------------------------------------------
+const comms = read('comms.json');
+const actionbar = read('actionbar.json');
+const research = read('research.json');
+
+check('comms: the ACK pulse color is the cue yellow (the family both surfaces borrow)',
+  comms.box?.pulse?.color === '#ffd84d');
+check('actionbar: animation.shimmer exists (the RESEARCH slot ready cue)',
+  !!actionbar.animation?.shimmer && typeof actionbar.animation.shimmer === 'object');
+const abShim = actionbar.animation?.shimmer ?? {};
+check('actionbar: the shimmer is enabled + yellow + a sane cycle',
+  abShim.enabled === true && abShim.color === '#ffd84d' &&
+  abShim.periodMs > 0 && abShim.faceMs > 0 && abShim.edgeMs > 0 &&
+  abShim.faceMs + abShim.gapMs + abShim.edgeMs < abShim.periodMs);
+check('research: fx.tabShimmer exists (the console tabs ready cue)',
+  !!research.fx?.tabShimmer && typeof research.fx.tabShimmer === 'object');
+const rwShim = research.fx?.tabShimmer ?? {};
+check('research: the tab shimmer is enabled + yellow (deck-only face → faceMs 0)',
+  rwShim.enabled === true && rwShim.color === '#ffd84d' && rwShim.faceMs === 0 && rwShim.edgeMs > 0);
+
+// ----------------------------------------------------------------------
+// The pure cycle model (js/ui/ShimmerCue.js)
+// ----------------------------------------------------------------------
+import { shimmerPhase, drawShimmer } from '../js/ui/ShimmerCue.js';
+import { config } from '../js/config/Config.js';
+
+config.init({ research, gates: read('gates.json'), exploration: read('research/exploration.json') });
+const { categories, loadCategory, isAvailable, hasAvailable } = await import('../js/research/ResearchModel.js');
+const { buildSystemTree, gatesReady, gatesNodeId, SYSTEM_CATEGORY } = await import('../js/research/SystemCategory.js');
+const { ResearchState } = await import('../js/research/ResearchState.js');
+const { ActionBar } = await import('../js/ui/ActionBar.js');
+
+// -- shimmerPhase: the windows + envelopes ------------------------------
+{
+  const cfg = abShim; // the deck's cycle (face → edge → rest)
+  const P = cfg.periodMs, F = cfg.faceMs, G = cfg.gapMs, E = cfg.edgeMs;
+  const at = (t) => shimmerPhase(t, cfg);
+
+  check('phase: t=0 is the face start (envelope 0 — the band is born)',
+    at(0).faceU === 0 && at(0).faceA === 0);
+  check('phase: the face window runs [0, faceMs)',
+    at(1).faceU !== null && at(F - 1).faceU !== null && at(F).faceU === null);
+  check('phase: the face envelope peaks mid-window (sine)',
+    Math.abs(at(F / 2).faceA - 1) < 1e-9);
+  check('phase: the gap is silent (no face, no edge)',
+    at(F).faceA === 0 && at(F).edgeA === 0 &&
+    at(F + G / 2).faceA === 0 && at(F + G / 2).edgeA === 0 &&
+    at(F + G - 1).edgeA === 0);
+  check('phase: the edge window runs [faceMs+gapMs, +edgeMs)',
+    at(F + G).edgeU === 0 && at(F + G).edgeA === 0 &&
+    at(F + G + E - 1).edgeU !== null && at(F + G + E).edgeU === null);
+  check('phase: the edge envelope peaks mid-lap (sine)',
+    Math.abs(at(F + G + E / 2).edgeA - 1) < 1e-9);
+  const rest = F + G + E;
+  check('phase: the rest of the period is silence',
+    at(rest).faceA === 0 && at(rest).edgeA === 0 &&
+    at(P - 1).faceA === 0 && at(P - 1).edgeA === 0 &&
+    at(P).faceU === 0, `rest ${rest}..${P}`);
+  check('phase: the cycle is periodic (pure in time)',
+    JSON.stringify(at(123)) === JSON.stringify(at(123 + P)));
+  check('phase: an offset staggers the cycle (per-slot / per-tab phase)',
+    JSON.stringify(at(0)) !== JSON.stringify(shimmerPhase(0, cfg, 500)));
+  // The tab cycle: faceMs 0 — the comet owns the window from t=0.
+  const tab = shimmerPhase(1, rwShim);
+  check('phase: faceMs 0 (the tabs) → no face, the edge runs from t=0',
+    tab.faceU === null && tab.edgeU !== null && tab.edgeA > 0);
+}
+
+// -- drawShimmer: the call surface (a recorder stands in for Graphics) ---
+{
+  const calls = [];
+  const g = {
+    fillStyle: (c, a) => calls.push(['fillStyle', c, a]),
+    fillRect: (x, y, w, h) => calls.push(['fillRect', x, y, w, h]),
+    lineStyle: (w, c, a) => calls.push(['lineStyle', w, c, a]),
+    strokePoints: (pts, close) => calls.push(['strokePoints', pts.length, close]),
+  };
+  const cfg = abShim;
+  const COLOR = 0xffd84d;
+
+  calls.length = 0;
+  drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), {});
+  const faces = calls.filter((c) => c[0] === 'fillRect');
+  const edges = calls.filter((c) => c[0] === 'strokePoints');
+  check('draw: the face pass paints band + core + white line (3 fillRects), no edge',
+    faces.length === 3 && edges.length === 0);
+  check('draw: the face rects stay clamped to the plate face',
+    faces.every(([, x, y, w, h]) => x >= 100 - 0.5 && x + w <= 100 + 160 + 0.5 && y === 50 && h === 44));
+
+  calls.length = 0;
+  drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs + cfg.gapMs + cfg.edgeMs / 2, cfg), {});
+  const rim = calls.filter((c) => c[0] === 'strokePoints' && c[2] === true);
+  const bands = calls.filter((c) => c[0] === 'strokePoints' && c[2] === false);
+  check('draw: the edge pass paints the rim glow (1 closed stroke) + the comet (3 bands × halo+core)',
+    rim.length === 1 && bands.length === 6);
+  check('draw: the face is absent during the edge window',
+    calls.filter((c) => c[0] === 'fillRect').length === 0);
+
+  calls.length = 0;
+  drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.periodMs - 1, cfg), {});
+  check('draw: the rest of the cycle paints nothing', calls.length === 0);
+
+  calls.length = 0;
+  drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), { face: false });
+  check('draw: face:false (the tabs) skips the band even mid-face-window',
+    calls.filter((c) => c[0] === 'fillRect').length === 0);
+}
+
+// ----------------------------------------------------------------------
+// The any-ready rule (ResearchModel.hasAvailable — the deck cue + the
+// tabs' shimmers ask it)
+// ----------------------------------------------------------------------
+{
+  const state = new ResearchState();
+  const tree = loadCategory('exploration');
+  check('hasAvailable: fresh run — the first tech is ready (true)', hasAvailable(tree, state) === true);
+  state.unlock('exploration', tree.order[0]);
+  check('hasAvailable: after the first tech, its child is ready (true)', hasAvailable(tree, state) === true);
+  for (const id of tree.order) state.unlock('exploration', id);
+  check('hasAvailable: everything researched — nothing ready (false)', hasAvailable(tree, state) === false);
+  check('hasAvailable: null/missing state is a safe false', hasAvailable(tree, null) === false);
+}
+
+// ----------------------------------------------------------------------
+// The jump-gate rule (SystemCategory.gatesReady — the ACK pulse target)
+// ----------------------------------------------------------------------
+{
+  const mk = () => buildSystemTree({ id: 'testsys', name: 'Testys' });
+  const state = new ResearchState();
+  const tree = mk();
+  check('gatesReady: fresh system (no map) — not ready (null)', gatesReady(tree, state) === null);
+  state.unlock(SYSTEM_CATEGORY, tree.order[0]); // the map tech (granted on entry)
+  check('gatesReady: map owned but chart incomplete — not ready (null)', gatesReady(tree, state) === null);
+  check('gatesReady: no gates node on a foreign tree — safe null',
+    gatesReady({ id: 'x', order: ['a'], nodes: { a: {} } }, state) === null);
+  // Chart complete: the availability hook passes (the scene's isNavComplete).
+  const done = { ...mk(), available: () => true };
+  state.unlock(SYSTEM_CATEGORY, done.order[0]);
+  check('gatesReady: chart complete + map owned → the gates node id',
+    gatesReady(done, state) === gatesNodeId(done));
+  check('hasAvailable: the ready gates count as an available tech', hasAvailable(done, state) === true);
+  // In flight — maxConcurrent 1: the pulse must rest.
+  state.start(SYSTEM_CATEGORY, gatesNodeId(done), 60000, 0);
+  check('gatesReady: the gates tech IN FLIGHT — not ready (null)', gatesReady(done, state) === null);
+  // A different project in flight (the same gate, through the deck).
+  const state2 = new ResearchState();
+  const done2 = { ...mk(), available: () => true };
+  state2.unlock(SYSTEM_CATEGORY, done2.order[0]);
+  const ex = loadCategory('exploration');
+  state2.start('exploration', ex.order[0], 60000, 0);
+  check('gatesReady: ANOTHER project in flight — not ready (null)', gatesReady(done2, state2) === null);
+}
+
+// ----------------------------------------------------------------------
+// The deck's RESEARCH slot (ActionBar.setShimmer, real bar + headless scene)
+// ----------------------------------------------------------------------
+{
+  const stubObject = (extra = {}) => ({
+    x: 0, y: 0, width: 60, height: 18, alpha: 1,
+    setOrigin() { return this; },
+    setAlpha(a) { this.alpha = a; return this; },
+    setScale() { return this; },
+    setTint() { return this; },
+    setPosition(x, y) { this.x = x; this.y = y; return this; },
+    setX(v) { this.x = v; return this; },
+    setY(v) { this.y = v; return this; },
+    setStrokeStyle() { return this; },
+    setSize(w, h) { this.width = w; this.height = h; return this; },
+    setBlendMode() { return this; },
+    setScrollFactor() { return this; },
+    setDepth() { return this; },
+    setInteractive(opts) { this._interactive = opts; return this; },
+    disableInteractive() { this._interactive = null; return this; },
+    setDisplaySize() { return this; },
+    on() { return this; },
+    once() { return this; },
+    off() { return this; },
+    setText(s) { this.text = String(s); return this; },
+    setColor(c) { this.color = c; return this; },
+    setFontSize() { return this; },
+    clear() { this._clears = (this._clears ?? 0) + 1; return this; },
+    fillStyle() { return this; },
+    lineStyle() { return this; },
+    fillPoints() { return this; },
+    strokePoints() { return this; },
+    fillRect() { return this; },
+    strokeRect() { return this; },
+    lineBetween() { return this; },
+    destroy() { this.destroyed = true; },
+    ...extra,
+  });
+  const scene = {
+    scale: { width: 1280, height: 720 },
+    add: {
+      existing: (o) => o,
+      text: (x, y, str) => stubObject({ x, y, text: String(str) }),
+      image: (x, y, key) => stubObject({ x, y, key }),
+      graphics: () => stubObject({}),
+      rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
+      tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
+      circle: (x, y, r) => stubObject({ x, y, radius: r }),
+    },
+    tweens: {
+      add: (t) => {
+        const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
+        if (Array.isArray(t?.targets)) t.targets.forEach(apply);
+        else if (t?.targets) apply(t.targets);
+        return { destroy() {}, stop() {} };
+      },
+      killTweensOf() {},
+    },
+    time: {
+      now: 1000,
+      delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
+    },
+    textures: { exists: () => true },
+  };
+  // The FLIGHT deck (the default buttons — it has the RESEARCH slot).
+  const bar = new ActionBar(scene, {});
+  const slot = bar.slots.find((s) => s.id === 'research');
+  check('bar: the flight deck has a live RESEARCH slot', !!slot && slot.live === true);
+
+  // A recorder over the shimmer layer (the bar's own — the real methods,
+  // wrapped to count).
+  const g = bar.shimmerG;
+  let rects = 0, strokes = 0, clears = 0;
+  const orig = { fillRect: g.fillRect, strokePoints: g.strokePoints, clear: g.clear };
+  g.fillRect = (...a) => { rects += 1; return orig.fillRect.apply(g, a); };
+  g.strokePoints = (...a) => { strokes += 1; return orig.strokePoints.apply(g, a); };
+  g.clear = (...a) => { clears += 1; return orig.clear.apply(g, a); };
+  const step = (t) => { rects = 0; strokes = 0; const before = clears; bar.update(t, 16); return { rects, strokes, cleared: clears > before }; };
+  // The bar staggers the cycle per slot (phase * 350 ms — the RGB idiom);
+  // the test times are the cycle windows shifted by that same offset.
+  const P = abShim.periodMs;
+  const off = (slot.phase ?? 0) * 350;
+  const at = (u) => (((u % P) + P) % P);
+  const T_FACE = at(abShim.faceMs / 2 - off);
+  const T_EDGE = at(abShim.faceMs + abShim.gapMs + abShim.edgeMs / 2 - off);
+  const T_REST = at(abShim.periodMs - 1 - off);
+  check('bar: no shimmer at construction (nothing drawn, no live flag)',
+    bar.shimmerId === null && bar._shimLive === false);
+  let r = step(100);
+  check('bar: update draws nothing on the shimmer layer when disarmed', r.rects === 0 && r.strokes === 0);
+
+  // Arm the cue — like GameScene._applyResearchCue does per frame.
+  bar.setShimmer('research');
+  check('bar: setShimmer arms the RESEARCH slot', bar.shimmerId === 'research');
+  bar.setShimmer('research');
+  check('bar: setShimmer is idempotent (the scene re-applies per frame)', bar.shimmerId === 'research');
+
+  r = step(T_FACE);
+  check('bar: the face window paints the band (fillRects) on the shimmer layer', r.rects >= 2 && r.strokes === 0);
+  r = step(T_EDGE);
+  check('bar: the edge window paints the comet (strokes) on the shimmer layer', r.strokes >= 4 && r.rects === 0);
+  r = step(T_REST);
+  check('bar: the rest of the cycle is quiet (the layer idles)', r.rects === 0 && r.strokes === 0);
+
+  // The holds: standby (the 'ack' comm), a locked slot, the tutorial's finger.
+  bar.setEnabled(false);
+  r = step(T_FACE);
+  check('bar: the deck standby suppresses the shimmer (the ACK hold owns the screen)', r.rects === 0 && r.strokes === 0);
+  bar.setEnabled(true);
+  bar.setSlotEnabled('research', false);
+  r = step(T_FACE);
+  check('bar: a locked slot suppresses the shimmer', r.rects === 0 && r.strokes === 0);
+  bar.setSlotEnabled('research', true);
+  bar.setHighlight('research');
+  r = step(T_FACE);
+  check('bar: the tutorial finger wins the shared slot (no double cue)', r.rects === 0 && r.strokes === 0);
+  bar.setHighlight(null);
+  r = step(T_FACE);
+  check('bar: the shimmer returns once the finger lifts', r.rects >= 2);
+
+  // Clearing + unknown ids.
+  bar.setShimmer(null);
+  check('bar: null clears the shimmer', bar.shimmerId === null);
+  r = step(T_FACE);
+  check('bar: …and the layer goes quiet', r.rects === 0 && r.strokes === 0);
+  bar.setShimmer('nope');
+  check('bar: an unknown id (not on this deck) is a safe no-op', bar.shimmerId === null);
+
+  // The SURFACE deck — the slot is 'shop', not 'research': the scene's
+  // cue must degrade to a clear (SurfaceScene replaces Research).
+  const surface = [
+    { id: 'shop', label: 'Shop' },
+    { id: 'build', label: 'Build' },
+    { id: 'quests', label: 'Quests' },
+    { id: 'menu', label: 'Menu' },
+  ];
+  const bar2 = new ActionBar(scene, { buttons: surface });
+  bar2.setShimmer('research');
+  check('bar: the surface deck has no RESEARCH slot — the cue degrades to clear',
+    bar2.shimmerId === null);
+}
+
+console.log(failures === 0 ? '\nAll research-cue checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/js/research/ResearchModel.js b/js/research/ResearchModel.js
index b51b2d8..f43b974 100644
--- a/js/research/ResearchModel.js
+++ b/js/research/ResearchModel.js
@@ -182,6 +182,23 @@ export function isAvailable(tree, state, id) {
   return true;
 }
 
+/**
+ * Does this tree hold ANY tech ready to research right now — an
+ * unresearched node whose requires are all met and whose extra gate
+ * (the `available` hook) holds? The scene's "any research available"
+ * cue (the deck's RESEARCH-slot shimmer) and the console's per-tab
+ * shimmers ask this. The caller may additionally demand
+ * `!state.getActive()` — with maxConcurrent 1 nothing can START while a
+ * project runs (isAvailable doesn't see the in-flight slot).
+ */
+export function hasAvailable(tree, state) {
+  if (!tree || !Array.isArray(tree.order) || typeof state?.isUnlocked !== 'function') return false;
+  for (const id of tree.order) {
+    if (isAvailable(tree, state, id)) return true;
+  }
+  return false;
+}
+
 /** The parents this node is still missing (for the LOCKED readout). */
 export function missingRequires(tree, state, id) {
   return (tree.nodes[id]?.requires ?? []).filter(
diff --git a/js/research/SystemCategory.js b/js/research/SystemCategory.js
index 27fa43c..cc68ecb 100644
--- a/js/research/SystemCategory.js
+++ b/js/research/SystemCategory.js
@@ -40,6 +40,7 @@
  * Pure (no Phaser) — Node-testable (dev/system-category.test.mjs).
  */
 import { config } from '../config/Config.js';
+import { isAvailable } from './ResearchModel.js';
 
 /** The category id (data/research.json → categories[]). */
 export const SYSTEM_CATEGORY = 'system';
@@ -67,6 +68,39 @@ export function systemIdOfGatesNode(nodeId) {
   return String(nodeId ?? '').replace(/_gates$/, '');
 }
 
+/**
+ * The tree's "Unlock Jumpgates" node id, or null (its
+ * `effects.activateGates` flag is the marker — the id otherwise carries
+ * no shape the tree can introspect, its key is the id).
+ * @param {object} tree a SystemCategory tree
+ * @returns {string|null}
+ */
+export function gatesNodeId(tree) {
+  for (const id of tree?.order ?? []) {
+    if (tree.nodes[id]?.effects?.activateGates === true) return id;
+  }
+  return null;
+}
+
+/**
+ * Is this system's jump-gate tech ready to research right now? Returns
+ * the node id when yes — the map tech owned, the chart complete (the
+ * tree's `available` hook), the tech not yet researched, and NOTHING
+ * in flight (maxConcurrent 1 — nothing can start while a project runs) —
+ * else null. The console's attention cue (the ACK pulse on the gates
+ * plate, ResearchWindow._paintCues) asks this.
+ * @param {object} tree a SystemCategory tree (buildSystemTree's shape)
+ * @param {object} state a ResearchState
+ * @returns {string|null} the gates node id, or null
+ */
+export function gatesReady(tree, state) {
+  if (!tree || typeof state?.isUnlocked !== 'function') return null;
+  if (typeof state.getActive === 'function' && state.getActive()) return null;
+  const id = gatesNodeId(tree);
+  if (!id) return null;
+  return isAvailable(tree, state, id) ? id : null;
+}
+
 const fillTemplate = (template, systemName) =>
   String(template ?? '').split('{system}').join(systemName);
 
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 4490e53..ed44b43 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -57,7 +57,7 @@ import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy
 import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
 import { planRoute } from '../galaxy/Route.js';
 import { ResearchState } from '../research/ResearchState.js';
-import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
+import { categories, loadCategory, isAvailable, hasAvailable, buildDefs } from '../research/ResearchModel.js';
 import {
   SYSTEM_CATEGORY,
   MAP_NODE,
@@ -2090,6 +2090,7 @@ export class GameScene extends Phaser.Scene {
     this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
     this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
     this._deckResearchBar(_time); // the RESEARCH slot's progress bar
+    this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
     // Builds: a build started on a surface keeps running in space if the
     // player took off mid-build (game-loop clock — see beginBuild); when
     // its deadline passes HERE we apply the effect (the world change is
@@ -4869,6 +4870,46 @@ export class GameScene extends Phaser.Scene {
     return loadCategory(catId);
   }
 
+  /**
+   * Any tech in ANY category ready to research right now — none in
+   * flight (maxConcurrent 1 — nothing can START while a project runs)
+   * and at least one node with its requires met (the SYSTEM chart's
+   * extra gate included — the tree's availability hook says so).
+   * Drives the READY-RESEARCH cue: the deck's RESEARCH slot's shimmer
+   * (this scene) and the console's per-tab shimmers + gates pulse
+   * (ResearchWindow._paintCues).
+   */
+  researchAnyAvailable() {
+    const state = this.researchState;
+    if (!state || typeof state.getActive !== 'function' || state.getActive()) return false;
+    for (const cat of categories()) {
+      const tree = this._treeFor(cat.id);
+      if (tree && hasAvailable(tree, state)) return true;
+    }
+    return false;
+  }
+
+  /**
+   * The READY-RESEARCH cue's scene-side switch (per frame, update): one
+   * cue on screen at a time —
+   *   - console CLOSED: the deck's RESEARCH slot wears the periodic
+   *     yellow shimmer (face sweep → edge run, js/ui/ShimmerCue.js);
+   *   - console OPEN: the console wears it instead (its tabs' edge
+   *     shimmers + the jump-gate tech's ACK pulse — _paintCues), and
+   *     the deck's slot rests.
+   * setShimmer is idempotent, so the per-frame call is a no-op when
+   * the beat is unchanged. The cue also rests while a project runs
+   * (researchAnyAvailable) and under the deck's standby (the ActionBar
+   * owns that hold — the 'ack' comm) and over the tutorial's finger
+   * (same slot — the finger already owns the eyes).
+   */
+  _applyResearchCue() {
+    const bar = this.actionBar;
+    if (!bar || typeof bar.setShimmer !== 'function') return;
+    const on = this.researchAnyAvailable() && this.researchWindow?.isOpen !== true;
+    bar.setShimmer(on ? 'research' : null);
+  }
+
   /** Entering a system begins its NAV chart: the SYSTEM category's
    * '{System} Map' tech is granted (duration 0 — never researched via the
    * console). Idempotent — create() calls it for the starting system (and
diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js
index d18f7aa..3970834 100644
--- a/js/ui/ActionBar.js
+++ b/js/ui/ActionBar.js
@@ -4,6 +4,7 @@ import { toColor, toCss } from '../utils/Color.js';
 import { fontStack, themeColor } from '../utils/Theme.js';
 import { canvasTexture } from '../utils/Textures.js';
 import { CyberShape } from './CyberShape.js';
+import { shimmerPhase, drawShimmer } from './ShimmerCue.js';
 
 const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
 
@@ -102,6 +103,21 @@ export class ActionBar extends Phaser.GameObjects.Container {
       slices: [3, 6],
       ...(anim.glitch ?? {}),
     };
+    // The READY-RESEARCH cue (GameScene arms the RESEARCH slot while a
+    // tech can start): a yellow band sweeps the face, then a comet runs
+    // the cut-corner edge (js/ui/ShimmerCue.js — the console's tabs wear
+    // the same recipe). The yellow is the comm pulse's family (#ffd84d).
+    this.cfgShimmer = {
+      enabled: true,
+      color: 0xffd84d,
+      periodMs: 4200,
+      faceMs: 700,
+      gapMs: 140,
+      edgeMs: 1500,
+      dashFrac: 0.3,
+      ...(anim.shimmer ?? {}),
+    };
+    this.cfgShimmer.color = toColor(this.cfgShimmer.color, 0xffd84d);
     this.bootStagger = anim.bootStagger ?? 70;
 
     this.railTop = null;
@@ -128,6 +144,14 @@ export class ActionBar extends Phaser.GameObjects.Container {
     this.highlightG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
     this.add(this.highlightG);
     this._hlLive = false;
+    // The READY-RESEARCH shimmer (setShimmer — the scene arms the RESEARCH
+    // slot while a tech can start): drawn every frame in update(), above
+    // the highlight rim (both are additive cues, the finger wins on a
+    // shared slot — see the update() gate).
+    this.shimmerId = null;
+    this.shimmerG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
+    this.add(this.shimmerG);
+    this._shimLive = false;
     this.boot();
   }
 
@@ -598,6 +622,22 @@ export class ActionBar extends Phaser.GameObjects.Container {
     this.highlightId = want ? want.id : null;
   }
 
+  /**
+   * setShimmer(id) — the READY-RESEARCH cue on one slot (GameScene's
+   * _applyResearchCue arms the RESEARCH slot while a tech can start
+   * and the console is closed): a periodic yellow band sweeps the face,
+   * then a comet runs the cut-corner edge (js/ui/ShimmerCue.js — the
+   * console's category tabs wear the same recipe). `null` clears it.
+   * Idempotent (the scene applies its beat per frame). The shimmer rests
+   * under the deck's standby (setEnabled(false) — the 'ack' comm's hold),
+   * over a locked slot, and under the tutorial's pointing finger (which
+   * already owns that slot's eyes).
+   */
+  setShimmer(id) {
+    const want = id != null ? (this.slots ?? []).find((s) => s.live && s.id === id) : null;
+    this.shimmerId = want ? want.id : null;
+  }
+
   // ------------------------------------------------------------------
   // Boot
   // ------------------------------------------------------------------
@@ -790,6 +830,32 @@ export class ActionBar extends Phaser.GameObjects.Container {
       this._hlLive = false;
     }
 
+    // The READY-RESEARCH shimmer (setShimmer) — the yellow band across
+    // the face, then the comet around the edge (js/ui/ShimmerCue.js,
+    // the console's tab recipe). Rests under the deck's standby, over a
+    // locked slot, and under the tutorial's finger on the same slot.
+    const shimSlot = this.shimmerId ? this.slots.find((s) => s.live && s.id === this.shimmerId) : null;
+    if (
+      this.cfgShimmer.enabled !== false &&
+      shimSlot &&
+      shimSlot.disabled !== true &&
+      this.enabled !== false &&
+      this.highlightId !== shimSlot.id
+    ) {
+      const { bw, bh, notch } = this.style;
+      this.shimmerG.clear(); // a fresh frame — the rest window paints nothing, and a stale comet must never linger
+      const ph = shimmerPhase(time, this.cfgShimmer, shimSlot.phase * 350);
+      drawShimmer(this.shimmerG, shimSlot.slot.x - bw / 2, shimSlot.slot.y - bh / 2, bw, bh, notch, this.cfgShimmer.color, ph, {
+        face: true,
+        edge: true,
+        dashFrac: this.cfgShimmer.dashFrac,
+      });
+      this._shimLive = true;
+    } else if (this._shimLive) {
+      this.shimmerG.clear();
+      this._shimLive = false;
+    }
+
     // The bar itself: the panel outline pulled apart in magenta/cyan while
     // the burst is live — the RGB separation on the bar, not just the text.
     if (L > 0.02) {
diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js
index f4d9d16..9963d78 100644
--- a/js/ui/ResearchWindow.js
+++ b/js/ui/ResearchWindow.js
@@ -46,10 +46,13 @@ import {
   loadCategory,
   layoutTree,
   isAvailable,
+  hasAvailable,
   missingRequires,
   unlocksOf,
   buildDefs,
 } from '../research/ResearchModel.js';
+import { gatesReady } from '../research/SystemCategory.js';
+import { shimmerPhase, drawShimmer } from './ShimmerCue.js';
 import { ensureIcon } from '../research/ResearchIcons.js';
 
 const HEADER = fontStack('header');
@@ -386,6 +389,26 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
     this.highlight = null; // { catId, nodeId }
     this._hlLive = false;
 
+    // The READY-RESEARCH cues (_paintCues, per frame while open — the
+    // console takes the scene's cue over while it is open):
+    //   - the edge shimmer on every category tab whose tree still holds
+    //     an available tech (js/ui/ShimmerCue.js — the deck's RESEARCH
+    //     slot's recipe, edge only),
+    //   - the ACK pulse (comms.box.pulse — the same one the tutorial's
+    //     finger paints) on the system's "Unlock Jumpgates" tech when
+    //     its chart is complete: its plate while the SYSTEM tree is on
+    //     screen, the SYSTEM tab otherwise.
+    // Both rest while a project runs (nothing can start — maxConcurrent
+    // 1) and while the tutorial's finger is on the same target.
+    this.cfgShimmer = config.section('research.fx.tabShimmer', {});
+    this.cueG = s
+      .graphics()
+      .setScrollFactor(0)
+      .setDepth(8)
+      .setBlendMode(Phaser.BlendModes.ADD);
+    this.add(this.cueG);
+    this._cueLive = false;
+
     // glitch layers (top of the window)
     this.glitchG = G(10);
     this.titleGhostA = this._ghost();
@@ -1489,6 +1512,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
     this.video?.pause?.();
     this.bursts.length = 0; // no ignitions over a closing window
     this.burstG?.clear();
+    this.cueG?.clear(); // the READY cues rest with the window (the deck's slot takes over)
+    this._cueLive = false;
     this.highlightG?.clear(); // the pointing finger rests with the window
     this._hlLive = false;
     this.sfx('ui_close');
@@ -1723,6 +1748,96 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
     this._hlLive = true;
   }
 
+  /**
+   * The console's READY-RESEARCH cues — per frame (update), the window's
+   * half of the scene's cue (GameScene._applyResearchCue rests the
+   * deck's slot while the console is open). While a tech can start (none
+   * in flight — maxConcurrent 1):
+   *   - every category tab whose tree still holds an available tech
+   *     wears the edge shimmer (the deck's RESEARCH-slot recipe —
+   *     js/ui/ShimmerCue.js, edge only; staggered per tab so the row
+   *     doesn't pulse in lockstep),
+   *   - the system's "Unlock Jumpgates" tech — its chart complete and
+   *     not yet owned — wears the ACKNOWLEDGE pulse (comms.box.pulse —
+   *     the same one the tutorial's finger paints): on its plate while
+   *     the SYSTEM tree is on screen, on the SYSTEM tab otherwise.
+   * The tutorial's finger wins a shared target (no double pulse). All of
+   * it rests while closed / revealing / a project runs.
+   */
+  _paintCues(time) {
+    const g = this.cueG;
+    if (!g) return;
+    const state = this.state;
+    const on =
+      this.openState === 'open' &&
+      !this.reveal.length &&
+      !!state &&
+      typeof state.getActive === 'function' &&
+      !state.getActive();
+    if (!on) {
+      if (this._cueLive) {
+        g.clear();
+        this._cueLive = false;
+      }
+      return;
+    }
+    g.clear();
+    this._cueLive = true;
+
+    // The tabs' edge shimmers (the ready categories light at a glance).
+    if (this.cfgShimmer.enabled !== false) {
+      const color = toColor(this.cfgShimmer.color, 0xffd84d);
+      this.tabs.forEach((t, i) => {
+        const entry = this.trees.get(t.id);
+        if (!entry || !hasAvailable(entry.tree, state)) return;
+        const { bodyY, tabH } = this.geo;
+        const ph = shimmerPhase(time, this.cfgShimmer, i * 420);
+        drawShimmer(g, t.x, bodyY + 1, t.w, tabH - 2, 8, color, ph, {
+          face: false, // the tabs are the button in miniature — edge only
+          dashFrac: this.cfgShimmer.dashFrac,
+        });
+      });
+    }
+
+    // The jump-gate tech's ACK pulse (the gates' "ready to be
+    // researched" — chart complete, not yet owned, nothing in flight).
+    const gid = this.systemTree ? gatesReady(this.systemTree, state) : null;
+    if (gid) {
+      const target = this._gatesCueRect(gid);
+      if (target && !this._highlightSameTarget(target)) {
+        this._paintAckPulse(g, target.cx, target.cy, target.w, target.h, target.notch, time);
+      }
+    }
+  }
+
+  /** The gates cue's target rect: the node's plate (SYSTEM on screen)
+   *  or the SYSTEM tab (otherwise) — the finger's own two choices. */
+  _gatesCueRect(gid) {
+    const catId = this.systemTree?.id;
+    if (!catId) return null;
+    const entry = this.trees.get(catId);
+    if (!entry) return null;
+    if (this.activeCat === catId) {
+      const node = entry.nodes.get(gid);
+      if (!node) return null;
+      return { kind: 'node', catId, nodeId: gid, cx: node.cont.x, cy: node.cont.y, w: node.w, h: node.h, notch: 12 };
+    }
+    const tab = this.tabs.find((t) => t.id === catId);
+    if (!tab) return null;
+    const { bodyY, tabH } = this.geo;
+    const y = bodyY + 1;
+    return { kind: 'tab', catId, nodeId: null, cx: tab.x + tab.w / 2, cy: y + (tabH - 2) / 2, w: tab.w, h: tabH - 2, notch: 10 };
+  }
+
+  /** True when the tutorial's pointing finger already paints this target
+   *  (same node plate, or same category tab) — the finger wins then. */
+  _highlightSameTarget(target) {
+    const h = this.highlight;
+    if (!h) return false;
+    if (h.nodeId) return h.nodeId === target.nodeId && h.catId === target.catId;
+    return target.kind === 'tab' && h.catId === target.catId;
+  }
+
   /** Tier lines for one category tree (re-issuable). */
   _paintRows(entry) {
     const { rightX, rightW, treeTop, treeH } = this.geo;
@@ -1915,6 +2030,9 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
     this._paintBursts(time);
     // the tutorial's pointing finger (the tab's or the node's rim)
     this._paintHighlight(time);
+    // the READY-RESEARCH cues: the tabs' edge shimmers + the gates' ACK
+    // pulse (the console wears them while open — the deck's slot rests)
+    this._paintCues(time);
 
     // tab pips: the in-progress one pulses (the rest are static — painted
     // in _paintTabs on state flips)
diff --git a/js/ui/ShimmerCue.js b/js/ui/ShimmerCue.js
new file mode 100644
index 0000000..7906b56
--- /dev/null
+++ b/js/ui/ShimmerCue.js
@@ -0,0 +1,134 @@
+/**
+ * ShimmerCue — the READY-RESEARCH attention cue, shared by the two
+ * surfaces that wear it (the scene arms exactly one at a time — GameScene
+ *._applyResearchCue keeps the cue on one screen):
+ *
+ *   - the command deck's RESEARCH slot (ActionBar.setShimmer) — while a
+ *     tech can start, a yellow band sweeps the button's FACE, then a
+ *     comet runs its cut-corner EDGE (face + edge);
+ *   - the research console's category tabs (ResearchWindow._paintCues) —
+ *     the same edge comet on every tab whose tree still holds an
+ *     available tech (edge only; the tabs are the deck's button in
+ *     miniature, without the face sweep).
+ *
+ * The cycle is a PURE function of the absolute clock (the PerimeterShimmer
+ * idiom — a repaint mid-cycle never makes the cue jump):
+ *
+ *   periodMs = faceMs + gapMs + edgeMs + rest (the rest is silence);
+ *   face — a soft yellow band + white-hot core crossing the face L→R;
+ *   edge — a faint full-rim glow + a bright dash with a fading tail
+ *          running one full lap around the cut-corner outline.
+ *
+ * `shimmerPhase` is pure (node-testable, dev/research-cue.test.mjs). The
+ * draw helpers take ANY object with the Phaser Graphics surface they use
+ * (fillStyle/fillRect/lineStyle/strokePoints) — a recorder suffices for
+ * the tests; the arc math is PerimeterShimmer's own (perimeterPath /
+ * arcPolyline), so the comet traces the SAME cut-corner polygon as the
+ * plates (top-left + bottom-right notched).
+ */
+import { perimeterPath, arcPolyline } from './PerimeterShimmer.js';
+
+/** `Number(v)` when finite, else `fallback` (config-safe). */
+const num = (v, fallback) => {
+  const n = Number(v);
+  return Number.isFinite(n) ? n : fallback;
+};
+
+/**
+ * The cycle's phase at `time` (see the header). Pure.
+ * Returns { faceU, faceA, edgeU, edgeA } — `*U` the 0..1 position,
+ * `*A` the 0..1 envelope (sine — zero at both ends of its window);
+ * the inactive half carries a null position + zero envelope.
+ * @param {number} time ms (the scene clock)
+ * @param {object} cfg { periodMs, faceMs, gapMs, edgeMs }
+ * @param {number} [offsetMs] a per-target phase offset (staggered rows)
+ */
+export function shimmerPhase(time, cfg = {}, offsetMs = 0) {
+  const P = Math.max(400, num(cfg.periodMs, 4200));
+  const faceMs = Math.max(0, num(cfg.faceMs, 700));
+  const gapMs = Math.max(0, num(cfg.gapMs, 140));
+  const edgeMs = Math.max(200, num(cfg.edgeMs, 1500));
+  const ct = (((num(time, 0) + num(offsetMs, 0)) % P) + P) % P;
+  const out = { faceU: null, faceA: 0, edgeU: null, edgeA: 0 };
+  if (faceMs > 0 && ct < faceMs) {
+    out.faceU = ct / faceMs;
+    out.faceA = Math.sin(Math.PI * out.faceU);
+  }
+  const e0 = faceMs + gapMs;
+  if (ct >= e0 && ct < e0 + edgeMs) {
+    out.edgeU = (ct - e0) / edgeMs;
+    out.edgeA = Math.sin(Math.PI * out.edgeU);
+  }
+  return out;
+}
+
+/**
+ * One shimmer pass over the plate rect (x, y = top-left; w, h; `notch`
+ * the corner-cut size, 0 = plain rectangle) into `g` (a Graphics, or a
+ * recorder with fillStyle/fillRect/lineStyle/strokePoints).
+ * @param {object} o { face?: boolean (default true — pass false for the
+ *   tabs), dashFrac?: the comet's dash as a fraction of the perimeter }
+ */
+export function drawShimmer(g, x, y, w, h, notch, color, ph, o = {}) {
+  if (!ph || w <= 0 || h <= 0) return;
+
+  // FACE — the sweep band: a soft wide pass + a hot core + a 2 px white
+  // line at the band's centre. The band travels from just off the left
+  // edge to just off the right; its visible part is clamped to the face
+  // (a Graphics has no clip — clamping IS the clip).
+  if (o.face !== false && ph.faceA > 0.01) {
+    const margin = Math.max(18, w * 0.25);
+    const bx = x - margin + (w + 2 * margin) * ph.faceU;
+    const band = Math.max(10, w * 0.18);
+    let lo = Math.max(x, bx - band);
+    let hi = Math.min(x + w, bx + band);
+    if (hi > lo) {
+      g.fillStyle(color, 0.16 * ph.faceA);
+      g.fillRect(lo, y, hi - lo, h);
+      lo = Math.max(x, bx - 3);
+      hi = Math.min(x + w, bx + 3);
+      if (hi > lo) {
+        g.fillStyle(color, 0.5 * ph.faceA);
+        g.fillRect(lo, y, hi - lo, h);
+      }
+      const c0 = Math.max(x, Math.min(x + w, bx - 1));
+      const cw = Math.min(2, x + w - c0);
+      if (cw > 0) {
+        g.fillStyle(0xffffff, 0.55 * ph.faceA);
+        g.fillRect(c0, y, cw, h);
+      }
+    }
+  }
+
+  // EDGE — the faint full-rim glow (the rim is "armed" while the comet
+  // passes) + the comet itself: 3 tail→head bands of falling alpha, the
+  // same comet as PerimeterShimmer's READY rim, one lap per cycle.
+  if (o.edge !== false && ph.edgeA > 0.01) {
+    const n = Math.max(0, Math.min(num(notch, 0), w / 2, h / 2));
+    const { segs, P } = perimeterPath(x, y, w, h, n);
+    const pts = [{ x: segs[0].ax, y: segs[0].ay }];
+    for (const s of segs) pts.push({ x: s.bx, y: s.by });
+    g.lineStyle(2, color, 0.28 * ph.edgeA);
+    g.strokePoints(pts, true);
+    const dashFrac = Math.min(0.5, Math.max(0.08, num(o.dashFrac, 0.3)));
+    const head = (((ph.edgeU * P) % P) + P) % P;
+    drawComet(g, segs, P, head, dashFrac * P, color, ph.edgeA);
+  }
+}
+
+/** The comet: 3 tail→head bands, each a halo pass + a core pass. */
+const BANDS = [0.16, 0.45, 1.0];
+function drawComet(g, segs, P, head, dashLen, color, env) {
+  const bandLen = dashLen / BANDS.length;
+  for (let i = 0; i < BANDS.length; i++) {
+    const d0 = head - dashLen + i * bandLen;
+    const poly = arcPolyline(segs, P, d0, bandLen + 1e-6);
+    if (poly.length < 2) continue;
+    const a = env * BANDS[i];
+    if (a < 0.004) continue;
+    g.lineStyle(3.5, color, a * 0.3); // the halo (soft width, low alpha)
+    g.strokePoints(poly, false);
+    g.lineStyle(1.8, color, a); // the core
+    g.strokePoints(poly, false);
+  }
+}