PageSourceSearch

https://cristiangabriel.dev/easter-eggs.js?v=f9b641b7

js cristiangabriel.dev collected 2026-10-02 01:52:20 UTC 76,538 bytes, 1,886 lines download raw bytes

1(function () {
2  /* global React, CHARACTER_FRAMES, PixelGrid, document */
3  // ─────────────────────────────────────────────────────────────
4  // Easter eggs:
5  //  1. Leaf particles drift from cursor
6  //  2. Tiny pixel character walks along bottom of viewport
7  //  2½. Idle garden — pixel plants grow from the bottom after 10s of stillness
8  //  3. Terminal overlay (type "cg" to open)
9  //  4. Mini-game (catch falling leaves) — launched from terminal
10  // ─────────────────────────────────────────────────────────────
11
12  const {
13    useState,
14    useEffect,
15    useLayoutEffect,
16    useRef,
17    useCallback
18  } = React;
19
20  // ─────────── 1. Leaf particles ───────────
21  function LeafParticles({
22    enabled = true
23  }) {
24    const layerRef = useRef(null);
25    const particlesRef = useRef([]);
26    const rafRef = useRef(null);
27    useEffect(() => {
28      if (!enabled) return;
29      const layer = layerRef.current;
30      if (!layer) return;
31      // Same spawn rate and physics as before — only the per-leaf cost changed:
32      //  • The leaf SVG is built ONCE and cloned per spawn (cloneNode), instead
33      //    of re-parsing an innerHTML string for every leaf. That string parse +
34      //    style recalc was the intermittent hitch.
35      //  • The leaf element is created in the rAF loop, not in the mousemove
36      //    handler, so pointer events never do DOM work and never stall. The
37      //    mousemove handler only records the latest position; spawning stays
38      //    gated to the original 180ms cadence and only fires while moving.
39      const tmpl = document.createElement("div");
40      tmpl.innerHTML = '<svg viewBox="0 0 16 16" width="100%" height="100%">' + '<path d="M8 1 C 13 4, 14 10, 8 15 C 2 10, 3 4, 8 1 Z" opacity="0.85"/>' + '<path d="M8 1 L 8 15" stroke-width="0.8"/>' + "</svg>";
41      const leafSvg = tmpl.firstChild;
42      let pos = {
43        x: -100,
44        y: -100
45      };
46      let moved = false; // only emit on frames where the cursor actually moved
47      function onMove(e) {
48        pos.x = e.clientX;
49        pos.y = e.clientY;
50        moved = true;
51      }
52      function spawn(x, y) {
53        const el = document.createElement("div");
54        el.className = "leaf";
55        const hue = 80 + Math.random() * 30;
56        const sat = 30 + Math.random() * 25;
57        const lite = 35 + Math.random() * 15;
58        const size = 8 + Math.random() * 10;
59        const left = x - size / 2;
60        const top = y - size / 2;
61        el.style.cssText = `width:${size}px;height:${size}px;left:${left}px;top:${top}px`;
62        const svg = leafSvg.cloneNode(true);
63        svg.children[0].setAttribute("fill", `hsl(${hue},${sat}%,${lite}%)`);
64        svg.children[1].setAttribute("stroke", `hsl(${hue},${sat}%,${lite - 12}%)`);
65        el.appendChild(svg);
66        layer.appendChild(el);
67        particlesRef.current.push({
68          el,
69          x,
70          y,
71          // Transform baseline = un-shifted spawn coords. The CSS left/top is
72          // offset by -size/2 to center the leaf on the cursor; using that as
73          // the translate baseline made every leaf jump by (size/2, size/2)
74          // on its first frame.
75          ox: x,
76          oy: y,
77          vx: (Math.random() - 0.5) * 1.6,
78          vy: 0.3 + Math.random() * 1.2,
79          rot: Math.random() * 360,
80          vr: (Math.random() - 0.5) * 8,
81          life: 0,
82          maxLife: 1500 + Math.random() * 1200,
83          sway: Math.random() * Math.PI * 2,
84          swayFreq: 0.002 + Math.random() * 0.003
85        });
86      }
87      let last = performance.now();
88      let lastEmit = 0;
89      function tick(now) {
90        // Halved spawn rate (was 90ms) — 50% fewer leaves on the cursor trail.
91        if (moved && now - lastEmit > 180) {
92          lastEmit = now;
93          moved = false;
94          spawn(pos.x, pos.y);
95        }
96        // Clamp dt: after a long frame gap (tab blur, GC pause, scroll jank)
97        // the next dt can be hundreds of ms, which makes particles teleport.
98        // 50ms is a 20fps floor — the motion slows briefly instead of jumping.
99        let dt = now - last;
100        last = now;
101        if (dt > 50) dt = 50;
102        // Normalize per-frame velocities to a 60fps baseline so motion is
103        // smooth at any framerate (the dt scaling makes 30fps look the same as
104        // 120fps, just rendered at half the cadence).
105        const tScale = dt / 16.67;
106        const ps = particlesRef.current;
107        for (let i = ps.length - 1; i >= 0; i--) {
108          const p = ps[i];
109          p.life += dt;
110          p.sway += p.swayFreq * dt;
111          p.x += (p.vx + Math.sin(p.sway) * 0.4) * tScale;
112          p.y += p.vy * tScale;
113          p.vy += 0.0003 * dt; // gentle gravity (already time-scaled)
114          p.rot += p.vr * 0.02 * tScale;
115          const alpha = Math.max(0, 1 - p.life / p.maxLife);
116          p.el.style.transform = `translate3d(${p.x - p.ox}px, ${p.y - p.oy}px, 0) rotate(${p.rot}deg)`;
117          p.el.style.opacity = alpha;
118          if (p.life > p.maxLife) {
119            p.el.remove();
120            ps.splice(i, 1);
121          }
122        }
123        rafRef.current = requestAnimationFrame(tick);
124      }
125      rafRef.current = requestAnimationFrame(tick);
126      window.addEventListener("mousemove", onMove);
127      return () => {
128        window.removeEventListener("mousemove", onMove);
129        cancelAnimationFrame(rafRef.current);
130        particlesRef.current.forEach(p => p.el.remove());
131        particlesRef.current = [];
132      };
133    }, [enabled]);
134    return /*#__PURE__*/React.createElement("div", {
135      ref: layerRef,
136      className: "leaf-layer"
137    });
138  }
139
140  // ─────────── 2. Pixel character ───────────
141  function PixelCharacter({
142    enabled = true
143  }) {
144    const [x, setX] = useState(40);
145    const [flip, setFlip] = useState(false);
146    const [frame, setFrame] = useState(0);
147    const [scrolling, setScrolling] = useState(false);
148    const dirRef = useRef(1);
149
150    // ###### WALK + SPRITE-FRAME LOOP ######
151    useEffect(() => {
152      if (!enabled) return;
153      let raf;
154      let last = performance.now();
155      let frameAcc = 0;
156      let stepAcc = 0;
157      function loop(now) {
158        const dt = now - last;
159        last = now;
160        stepAcc += dt;
161        frameAcc += dt;
162        if (stepAcc > 16) {
163          stepAcc = 0;
164          setX(prev => {
165            let next = prev + dirRef.current * 0.6;
166            const maxX = window.innerWidth - 60;
167            const minX = 220; // past sidebar
168            if (next > maxX) {
169              dirRef.current = -1;
170              setFlip(true);
171              next = maxX;
172            }
173            if (next < minX) {
174              dirRef.current = 1;
175              setFlip(false);
176              next = minX;
177            }
178            return next;
179          });
180        }
181        if (frameAcc > 220) {
182          frameAcc = 0;
183          setFrame(f => (f + 1) % 2);
184        }
185        raf = requestAnimationFrame(loop);
186      }
187      raf = requestAnimationFrame(loop);
188      return () => cancelAnimationFrame(raf);
189    }, [enabled]);
190
191    // ###### HIDE WHILE SCROLLING ######
192    // Hide instantly on scroll, fade back in 800ms after the last scroll event.
193    useEffect(() => {
194      if (!enabled) return;
195      let idleTimer;
196      function onScroll() {
197        setScrolling(true);
198        clearTimeout(idleTimer);
199        idleTimer = setTimeout(() => setScrolling(false), 800);
200      }
201      window.addEventListener("scroll", onScroll, {
202        passive: true
203      });
204      return () => {
205        window.removeEventListener("scroll", onScroll);
206        clearTimeout(idleTimer);
207      };
208    }, [enabled]);
209    if (!enabled) return null;
210    return /*#__PURE__*/React.createElement("div", {
211      className: "pixel-character" + (scrolling ? " hidden" : ""),
212      style: {
213        transform: `translateX(${x}px) scaleX(${flip ? -1 : 1})`
214      }
215    }, /*#__PURE__*/React.createElement(PixelGrid, {
216      rows: CHARACTER_FRAMES[frame],
217      scale: 3
218    }));
219  }
220
221  // ─────────── 2½. Idle garden ───────────
222  // After 10s of stillness, leafy vector plants grow slowly up from the bottom
223  // edge in irregular packs and sway in a gentle breeze. The moment the visitor
224  // moves, types, scrolls or taps, the whole garden retreats back into the soil.
225  //
226  // Each plant is procedurally composed of smooth SVG leaves/blades (see the
227  // vector plant kit below) and handed to <PlantSVG>. Growth is pure CSS: rather
228  // than wiping a clip-path, each plant scales up organically from its base
229  // (thin shoot → unfurling leaves → settle) on its own slow, randomized timer,
230  // so they sprout in bursts. Retreat shrinks back into the seed state. Sway is a
231  // separate, always-on CSS rotation about each plant's base.
232
233  function rint(a, b) {
234    return a + Math.floor(Math.random() * (b - a + 1));
235  }
236  function rfloat(a, b) {
237    return a + Math.random() * (b - a);
238  }
239  function pick(arr) {
240    return arr[Math.floor(Math.random() * arr.length)];
241  }
242
243  // ── Vector plant kit ──────────────────────────────────────────────────────
244  // The idle garden is drawn in the SAME art language as the cursor-trail leaves
245  // — smooth hsl-green SVG leaves with a soft midrib — rather than as pixel
246  // blocks, so a still page grows the very foliage the cursor scatters, just
247  // large and at rest. Each plant is composed from three primitives (broad
248  // leaves, grass blades, a flower head) around a base at local (0,0) with
249  // up = -y. We track the drawn extent so the SVG can size + centre on its base.
250
251  function clamp(v, lo, hi) {
252    return v < lo ? lo : v > hi ? hi : v;
253  }
254
255  // A pointed-lens leaf, mirrored about its own axis, pointing along +x with the
256  // base at the origin — the grown-up cousin of the cursor leaf's bezier.
257  function leafD(L, Wd) {
258    const m = (L * 0.5).toFixed(1);
259    return `M0 0Q${m} ${(-Wd).toFixed(1)} ${L.toFixed(1)} 0Q${m} ${Wd.toFixed(1)} 0 0Z`;
260  }
261
262  // A single grass blade: base centred at the origin, curving up to a leaning,
263  // tapering tip. `lean` is the tip's horizontal travel, `wb` its base half-width.
264  function bladeD(h, wb, lean) {
265    const cLx = (lean * 0.3 - wb).toFixed(1);
266    const cRx = (lean * 0.6 + wb).toFixed(1);
267    return `M${(-wb).toFixed(1)} 0Q${cLx} ${(-h * 0.6).toFixed(1)} ${lean.toFixed(1)} ${(-h).toFixed(1)}
267` + `Q${cRx} ${(-h * 0.5).toFixed(1)} ${wb.toFixed(1)} 0Z`;
268  }
269
270  // Foliage colour. Hues stay in the cursor-leaf green band (~80–112°, warm
271  // yellow-green — capped before it drifts into cool emerald/blue) and are
272  // fixed (not theme variables) so the garden reads identically in light + dark.
273  // `t` runs 0 (shaded, near the soil) → 1 (sunlit tip): higher = lighter and a
274  // touch more saturated, so a clump catches light through its upper leaves.
275  function greenFill(baseHue, t) {
276    const h = baseHue + rint(-7, 7);
277    const s = clamp(34 + Math.round(t * 22) + rint(-4, 4), 26, 64);
278    const l = clamp(25 + Math.round(t * 24) + rint(-3, 3), 20, 54);
279    return `hsl(${h} ${s}% ${l}%)`;
280  }
281
282  // Grow one plant into a render spec: { w, h, cx, baseY, parts }. `parts` are
283  // SVG path/circle descriptors in a base-at-origin frame; PlantSVG drops them
284  // into a <g> translated to (cx, baseY) so the base sits centred on the soil
285  // line. Varieties are biased toward leafy foliage and grass tufts.
286  function makePlant() {
287    const parts = [];
288    let minY = 0;
289    let maxAbsX = 5;
290    const note = (x, y, reach = 0) => {
291      if (y < minY) minY = y;
292      const ax = Math.abs(x) + reach;
293      if (ax > maxAbsX) maxAbsX = ax;
294    };
295    const finalize = () => {
296      const padX = 7;
297      const padTop = 7;
298      const padBottom = 2;
299      const cx = maxAbsX + padX;
300      const baseY = Math.ceil(-minY) + padTop;
301      return {
302        w: Math.ceil(cx * 2),
303        h: baseY + padBottom,
304        cx,
305        baseY,
306        parts
307      };
308    };
309
310    // ── primitives ──
311    const addLeaf = (bx, by, deg, L, Wd, baseHue, t) => {
312      const tr = `translate(${bx.toFixed(1)} ${by.toFixed(1)}) rotate(${deg.toFixed(1)})`;
313      parts.push({
314        d: leafD(L, Wd),
315        fill: greenFill(baseHue, t),
316        opacity: rfloat(0.82, 0.96),
317        transform: tr
318      });
319      // Midrib — a hair darker, the same accent the cursor leaf carries.
320      parts.push({
321        d: `M0 0L${(L * 0.92).toFixed(1)} 0`,
322        stroke: greenFill(baseHue, Math.max(0, t - 0.4)),
323        sw: Math.max(0.6, Wd * 0.16).toFixed(1),
324        cap: "round",
325        opacity: 0.5,
326        transform: tr
327      });
328      const r = deg * Math.PI / 180;
329      note(bx, by);
330      note(bx + Math.cos(r) * L, by + Math.sin(r) * L, Wd);
331    };
332    const addBlade = (bx, h, wb, lean, baseHue, t) => {
333      parts.push({
334        d: bladeD(h, wb, lean),
335        fill: greenFill(baseHue, t),
336        opacity: rfloat(0.85, 0.97),
337        transform: `translate(${bx.toFixed(1)} 0)`
338      });
339      note(bx, 0);
340      note(bx + lean, -h, wb);
341    };
342    const addStem = (bend, h) => {
343      const topX = bend;
344      const topY = -h;
345      parts.push({
346        d: `M0 0Q${(bend * 0.25).toFixed(1)} ${(-h * 0.5).toFixed(1)} ${topX.toFixed(1)} ${topY.toFixed(1)}`,
347        stroke: "hsl(96 30% 24%)",
348        sw: rfloat(1.6, 2.8).toFixed(1),
349        cap: "round",
350        opacity: 0.92
351      });
352      note(0, 0);
353      note(topX, topY);
354      return {
355        topX,
356        topY
357      };
358    };
359    const addFlower = (cx, cy, baseHue) => {
360      const petal = pick(["hsl(330 56% 78%)", "hsl(282 40% 80%)", "hsl(45 78% 74%)", "hsl(38 34% 92%)"]);
361      const n = rint(5, 8);
362      const pr = rfloat(5, 8);
363      for (let i = 0; i < n; i++) {
364        const deg = 360 / n * i + rfloat(-8, 8);
365        parts.push({
366          d: leafD(pr, pr * 0.52),
367          fill: petal,
368          opacity: 0.95,
369          transform: `translate(${cx.toFixed(1)} ${cy.toFixed(1)}) rotate(${deg.toFixed(1)})`
370        });
371      }
372      parts.push({
373        tag: "circle",
374        cx,
375        cy,
376        r: rfloat(1.8, 3),
377        fill: "hsl(46 80% 60%)"
378      });
379      note(cx, cy, pr * 1.3);
380    };
381    const baseHue = rint(80, 112);
382    const variety = pick(["leafy", "leafy", "leafy", "grass", "grass", "grass", "fern", "fern", "flower", "bud"]);
383    if (variety === "grass") {
384      // A loose tuft of curving blades at varied heights and leans.
385      const H = rint(54, 150);
386      const blades = rint(8, 15);
387      const spread = H * 0.24;
388      for (let b = 0; b < blades; b++) {
389        addBlade(rfloat(-spread, spread), rfloat(H * 0.5, H), rfloat(1.6, 3.4), rfloat(-H * 0.3, H * 0.3), baseHue, rfloat(0.25, 1));
390      }
391      return finalize();
392    }
393    const hasHead = variety === "flower" || variety === "bud";
394    const H = rint(hasHead ? 90 : 78, hasHead ? 175 : 215);
395    const {
396      topX,
397      topY
398    } = addStem(rfloat(-H * 0.16, H * 0.16), H);
399    if (variety === "fern") {
400      // Paired leaflets marching up a curved rachis, longest near the base.
401      const pairs = rint(8, 13);
402      for (let i = 0; i < pairs; i++) {
403        const t = i / (pairs - 1 || 1);
404        const sx = topX * t;
405        const sy = -H * t;
406        const len = (1 - t) * rfloat(11, 20) + 5;
407        addLeaf(sx, sy, -34 - t * 28, len, len * 0.26, baseHue, 0.4 + t * 0.5);
408        addLeaf(sx, sy, -146 + t * 28, len, len * 0.26, baseHue, 0.4 + t * 0.5);
409      }
410      addLeaf(topX, topY, -90, rfloat(8, 14), rfloat(2.5, 4), baseHue, 0.95);
411      return finalize();
412    }
413
414    // Leafy stem: broad leaves alternating up the stem, larger toward the base,
415    // frequently paired for a full bush, crowned by an upright leaf.
416    const nodes = rint(4, 7);
417    for (let i = 0; i < nodes; i++) {
418      const t = (i + 0.5) / nodes;
419      const sx = topX * t;
420      const sy = -H * t;
421      const side = i % 2 === 0 ? 1 : -1;
422      const L = (1 - t * 0.55) * rfloat(20, 36);
423      const Wd = L * rfloat(0.4, 0.55);
424      const deg = side > 0 ? -(20 + t * 30) : -(160 - t * 30);
425      addLeaf(sx, sy, deg, L, Wd, baseHue, 0.3 + t * 0.6);
426      if (Math.random() < 0.55) {
427        addLeaf(sx, sy, side >
427 0 ? -(150 - t * 30) : -(30 + t * 30), L * 0.8, Wd * 0.8, baseHue, 0.3 + t * 0.6);
428      }
429    }
430    addLeaf(topX, topY, -90, rfloat(16, 26), rfloat(7, 11), baseHue, 0.95);
431    if (hasHead) addFlower(topX, topY + (variety === "bud" ? 2 : -2), baseHue);
432    return finalize();
433  }
434
435  // Render a plant spec into crisp vector SVG. Parts carry an optional `tag`
436  // ('circle'); everything else is a <path>. Stroked-only parts (stems, midribs)
437  // have no `fill`, so we default fill to 'none' to avoid SVG's black default.
438  function PlantSVG({
439    spec
440  }) {
441    const children = spec.parts.map((p, i) => p.tag === "circle" ? /*#__PURE__*/React.createElement("circle", {
442      key: i,
443      cx: p.cx,
444      cy: p.cy,
445      r: p.r,
446      fill: p.fill
447    }) : /*#__PURE__*/React.createElement("path", {
448      key: i,
449      d: p.d,
450      fill: p.fill || "none",
451      stroke: p.stroke,
452      strokeWidth: p.sw,
453      strokeLinecap: p.cap,
454      opacity: p.opacity,
455      transform: p.transform
456    }));
457    return /*#__PURE__*/React.createElement("svg", {
458      width: spec.w,
459      height: spec.h,
460      viewBox: `0 0 ${spec.w} ${spec.h}`,
461      style: {
462        display: "block",
463        overflow: "visible"
464      }
465    }, /*#__PURE__*/React.createElement("g", {
466      transform: `translate(${spec.cx} ${spec.baseY})`
467    }, children));
468  }
469
470  // Build one plant placed at viewport x. Growth is deliberately slow and each
471  // plant gets its own duration + delay so the garden emerges irregularly rather
472  // than as a tidy left-to-right sweep. Retreat is a touch quicker but still soft.
473  function makePlantObj(x) {
474    return {
475      x,
476      spec: makePlant(),
477      swayDur: rfloat(4, 8),
478      swayDelay: -rfloat(0, 8),
479      swayDeg: rfloat(1.2, 3),
480      growDur: rfloat(6, 13),
481      growDelay: rfloat(0, 8),
482      retreatDur: rfloat(0.9, 1.8),
483      retreatDelay: rfloat(0, 0.7),
484      // Direction + amount each plant leans as it wilts, so a clump folds down
485      // every-which-way rather than all tipping the same side.
486      wiltLean: rfloat(2.5, 6) * (Math.random() < 0.5 ? -1 : 1)
487    };
488  }
489
490  // Lay plants out in irregular packs separated by bare gaps, rather than evenly
491  // spaced. A pack is a few plants crowded together (overlapping into a clump);
492  // between packs there's a wide stretch of empty soil.
493  function makePlantSet(width) {
494    const plants = [];
495    let x = rfloat(-12, 36);
496    while (x < width + 12) {
497      const packSize = rint(1, 5);
498      for (let p = 0; p < packSize && x < width + 12; p++) {
499        plants.push(makePlantObj(x));
500        x += rfloat(16, 44); // tight, overlapping spacing inside a pack
501      }
502      x += rfloat(60, 260); // wide empty gap before the next pack
503    }
504    return plants;
505  }
506  function IdleGarden({
507    enabled = true,
508    paused = false
509  }) {
510    const [active, setActive] = useState(false);
511    const [plants, setPlants] = useState(() => enabled ? makePlantSet(window.innerWidth) : []);
512    // True once the garden has grown at least once. Gates the wilt-retreat
513    // animation so it never plays on the initial (never-grown) render — plants
514    // should only wilt back down after they've actually risen.
515    const hasGrownRef = useRef(false);
516
517    // ###### IDLE WATCH ######
518    // Re-arm a 20s timer on any interaction; firing it grows the garden. Any
519    // interaction while grown retreats it. Backgrounded tabs retreat immediately.
520    useEffect(() => {
521      if (!enabled) return;
522      if (paused) {
523        setActive(false);
524        return;
525      }
526      let timer;
527      const arm = () => {
528        clearTimeout(timer);
529        timer = setTimeout(() => {
530          hasGrownRef.current = true;
531          setActive(true);
532        }, 20000);
533      };
534      const onActivity = () => {
535        setActive(false);
536        arm();
537      };
538      const onVisibility = () => {
539        if (document.hidden) {
540          clearTimeout(timer);
541          setActive(false);
542        } else {
543          arm();
544        }
545      };
546      const evs = ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "scroll"];
547      evs.forEach(e => window.addEventListener(e, onActivity, {
548        passive: true
549      }));
550      document.addEventListener("visibilitychange", onVisibility);
551      arm();
552      return () => {
553        clearTimeout(timer);
554        evs.forEach(e => window.removeEventListener(e, onActivity));
555        document.removeEventListener("visibilitychange", onVisibility);
556      };
557    }, [enabled, paused]);
558
559    // Re-flow the garden to a new viewport width (debounced).
560    useEffect(() => {
561      if (!enabled) return;
562      let t;
563      const onResize = () => {
564        clearTimeout(t);
565        t = setTimeout(() => setPlants(makePlantSet(window.innerWidth)), 250);
566      };
567      window.addEventListener("resize", onResize);
568      return () => {
569        clearTimeout(t);
570        window.removeEventListener("resize", onResize);
571      };
572    }, [enabled]);
573    if (!enabled) return null;
574    return /*#__PURE__*/React.createElement("div", {
575      className: "idle-garden",
576      "aria-hidden": "true"
577    }, plants.map((p, i) => /*#__PURE__*/React.createElement("div", {
578      key: i,
579      className: "idle-plant" + (active ? " grown" : hasGrownRef.current ? " retreating" : ""),
580      style: {
581        left: p.x + "px"
582      }
583    }, /*#__PURE__*/React.createElement("div", {
584      className: "idle-plant-sway",
585      style: {
586        "--sway-dur": p.swayDur + "s",
587        "--sway-delay": p.swayDelay + "s",
588        "--sway-deg": p.swayDeg + "deg"
589      }
590    }, /*#__PURE__*/React.createElement("div", {
591      className: "idle-plant-reveal",
592      style: {
593        "--rev-dur": (active ? p.growDur : p.retreatDur) + "s",
594        "--rev-delay": (active ? p.growDelay : p.retreatDelay) + "s",
595        "--wilt-lean": p.wiltLean + "deg"
596      }
597    }, /*#__PURE__*/React.createElement(PlantSVG, {
598      spec: p.spec
599    }))))));
600  }
601
602  // ─────────── 3. Terminal + 4. Mini-game ───────────
603
604  // Lazy loader for the Forest Wanderer adventure module. The game's data + parser
605  // is ~1000 LOC;
605 we don't want to parse it on initial page load just to support a
606  // rarely-typed command. The script is injected on demand the first time `forest`
607  // is invoked; subsequent invocations resolve instantly (browser cache).
608  let forestLoaderPromise = null;
609  function loadForestAdventure() {
610    if (window.ForestAdventure) return Promise.resolve(window.ForestAdventure);
611    if (forestLoaderPromise) return forestLoaderPromise;
612    forestLoaderPromise = new Promise((resolve, reject) => {
613      const s = document.createElement("script");
614      s.src = "forest-adventure.js";
615      s.async = true;
616      s.onload = () => resolve(window.ForestAdventure);
617      s.onerror = () => {
618        forestLoaderPromise = null;
619        reject(new Error("load failed"));
620      };
621      document.head.appendChild(s);
622    });
623    return forestLoaderPromise;
624  }
625  const TERMINAL_BANNER = [" ___  ___  _ _ ", "|  _|/ __|| | |", "| |_ \\__ \\| | |", "|___||___/|_|_|", "", "┌── cg.term v1.0  ────────────────────────────────────────", "│  type `help` for commands. `exit` or Esc to close.", "└──────────────────────────────────────────────────────────", ""];
626
627  // 8-row figlet-style "Big" font for the `ascii` command. Each glyph is an
628  // array of 8 strings, all the same width within a glyph. Letters are
629  // concatenated row-by-row to form the rendered art.
630  const BIG_FONT = {
631    " ": ["    ", "    ", "    ", "    ", "    ", "    ", "    ", "    "],
632    ".": ["   ", "   ", "   ", "   ", " _ ", "(_)", "   ", "   "],
633    ",": ["   ", "   ", "   ", "   ", "   ", " _ ", "( )", "|/ "],
634    "!": [" _ ", "| |", "| |", "| |", "|_|", "(_)", "   ", "   "],
635    "?": [" ___  ", "|__ \\ ", "   ) |", "  / / ", " |_|  ", " (_)  ", "      ", "      "],
636    "-": ["      ", "      ", "      ", " ____ ", "|____|", "      ", "      ", "      "],
637    _: ["        ", "        ", "        ", "        ", "        ", "        ", "        ", " ______ "],
638    ":": ["   ", "   ", " _ ", "(_)", " _ ", "(_)", "   ", "   "],
639    "'": [" _ ", "( )", "|/ ", "   ", "   ", "   ", "   ", "   "],
640    A: ["     _    ", "    / \\   ", "   / _ \\  ", "  / ___ \\ ", " /_/   \\_\\", "          ", "          ", "          "],
641    B: [" ____  ", "|  _ \\ ", "| |_) |", "|  _ < ", "| |_) |", "|____/ ", "       ", "       "],
642    C: ["  _____ ", " / ____|", "| |     ", "| |     ", "| |____ ", " \\_____|", "        ", "        "],
643    D: [" _____  ", "|  __ \\ ", "| |  | |", "| |  | |", "| |__| |", "|_____/ ", "        ", "        "],
644    E: [" ______ ", "|  ____|", "| |__   ", "|  __|  ", "| |____ ", "|______|", "        ", "        "],
645    F: [" ______ ", "|  ____|", "| |__   ", "|  __|  ", "| |     ", "|_|     ", "        ", "        "],
646    G: ["  _____ ", " / ____|", "| |  __ ", "| | |_ |", "| |__| |", " \\_____|", "        ", "        "],
647    H: [" _    _ ", "| |  | |", "| |__| |", "|  __  |", "| |  | |", "|_|  |_|", "        ", "        "],
648    I: [" _____ ", "|_   _|", "  | |  ", "  | |  ", " _| |_ ", "|_____|", "       ", "       "],
649    J: ["       _ ", "      | |", "      | |", "  _   | |", " | |__| |", "  \\____/ ", "         ", "         "],
650    K: [" _  __ ", "| |/ / ", "| ' /  ", "|  <   ", "| . \\  ", "|_|\\_\\ ", "       ", "       "],
651    L: [" _      ", "| |     ", "| |     ", "| |     ", "| |____ ", "|______|", "        ", "        "],
652    M: [" __  __ ", "|  \\/  |", "| \\  / |", "| |\\/| |", "| |  | |", "|_|  |_|", "        ", "        "],
653    N: [" _   _ ", "| \\ | |", "|  \\| |", "| . ` |", "| |\\  |", "|_| \\_|", "       ", "       "],
654    O: ["  ____  ", " / __ \\ ", "| |  | |", "| |  | |", "| |__| |", " \\____/ ", "        ", "        "],
655    P: [" _____  ", "|  __ \\ ", "| |__) |", "|  ___/ ", "| |     ", "|_|     ", "        ", "        "],
656    Q: ["  ____   ", " / __ \\  ", "| |  | | ", "| |  | | ", "| |__| | ", " \\___\\_\\ ", "         ", "         "],
657    R: [" _____  ", "|  __ \\ ", "| |__) |", "|  _  / ", "| | \\ \\ ", "|_|  \\_\\", "        ", "        "],
658    S: ["  _____ ", " / ____|", "| (___  ", " \\___ \\ ", " ____) |", "|_____/ ", "        ", "        "],
659    T: [" _______ ", "|__   __|", "   | |   ", "   | |   ", "   | |   ", "   |_|   ", "         ", "         "],
660    U: [" _    _ ", "| |  | |", "| |  | |", "| |  | |", "| |__| |", " \\____/ ", "        ", "        "],
661    V: ["__      __", "\\ \\    / /", " \\ \\  / / ", "  \\ \\/ /  ", "   \\  /   ", "    \\/    ", "          ", "          "],
662    W: ["__          __", "\\ \\        / /", " \\ \\  /\\  / / ", "  \\ \\/  \\/ /  ", "   \\  /\\  /   ", "    \\/  \\/    ", "              ", "              "],
663    X: ["__   __", "\\ \\ / /", " \\ V / ", "  > <  ", " / . \\ ", "/_/ \\_\\", "       ", "       "],
664    Y: ["__     __", "\\ \\   / /", " \\ \\_/ / ", "  \\   /  ", "   | |   ", "   |_|   ", "         ", "         "],
665    Z: [" ______ ", "|___  / ", "   / /  ", "  / /   ", " / /__  ", "/_____| ", "        ", "        "],
666    a: ["        ", "        ", "   __ _ ", "  / _` |", " | (_| |", "  \\__,_|", "        ", "        "],
667    b: [" _      ", "| |     ", "| |__   ", "| '_ \\  ", "| |_) | ", "|_.__/  ", "        ", "        "],
668    c: ["       ", "       ", "  ___  ", " / __| ", "| (__  ", " \\___| ", "       ", "       "],
669    d: ["      _ ", "     | |", "   __| |", "  / _` |", " | (_| |", "  \\__,_|", "        ", "        "],
670    e: ["       ", "       ", "  ___  ", " / _ \\ ", "|  __/ ", " \\___| ", "       ", "       "],
671    f: ["   __ ", "  / _|", " | |_ ", " |  _|", " | |  ", " |_|  ", "      ", "      "],
672    g: ["        ", "        ", "   __ _ ", "  / _` |", " | (_| |", "  \\__, |", "   __/ |", "  |___/ "],
673    h: [" _      ", "| |     ", "| |__   ", "| '_ \\  ", "| | | | ", "|_| |_| ", "        ", "        "],
674    i: [" _ ", "(_)", " _ ", "| |", "| |", "|_|", "   ", "   "],
675    j: ["    _ ", "   (_)", "    _ ", "   | |", "   | |", "   | |", " _ | |", "|__/  "],
676    k: [" _    ", "| |   ", "| | __", "| |/ /", "|   < ", "|_|\\_\\", "      ", "      "],
677    l: [" _ ", "| |", "| |", "| |", "| |", "|_|", "   ", "   "],
678    m: ["            ", "            ", " _ __ ___   ", "| '_ ` _ \\  ", "| | | | | | ", "|_| |_| |_| ", "            ", "            "],
679    n: ["        ", "        ", " _ __   ", "| '_ \\  ", "| | | | ", "|_| |_| ", "        ", "        "],
680    o: ["        ", "        ", "  ___   ", " / _ \\  ", "| (_) | ", " \\___/  ", "        ", "        "],
681    p: ["        ", "        ", " _ __   ", "| '_ \\  ", "| |_) | ", "| .__/  ", "| |     ", "|_|     "],
682    q: ["        ", "        ", "   __ _ ", "  / _` |", " | (_| |", "  \\__, |", "     | |", "     |_|"],
683    r: ["       ", "       ", " _ __  ", "| '__| ", "| |    ", "|_|    ", "       ", "       "],
684    s: ["       ", "       ", "  ___  ", " / __| ", " \\__ \\ ", " |___/ ", "       ", "       "],
685    t: [" _    ", "| |   ", "| |_  ", "| __| ", "| |_  ", " \\__| ", "      ", "      "],
686    u: ["        ", "        ", " _   _  ", "| | | | ", "| |_| | ", " \\__,_| ", "        ", "        "],
687    v: ["        ", "        ", "__   __ ", "\\ \\ / / ", " \\ V /  ", "  \\_/   ", "        ", "        "],
688    w: ["           ", "           ", "__      __ ", "\\ \\ /\\ / / ", " \\ V  V /  ", "  \\_/\\_/   ", "           ", "           "],
689    x: ["        ", "        ", "__   __ ", "\\ \\ / / ", " >   <  ", "/_/\\_\\  ", "        ", "        "],
690    y: ["        ", "        ", " _   _  ", "| | | | ", "| |_| | ", " \\__, | ", "  __/ | ", " |___/  "],
691    z: ["       ", "       ", " ____  ", "|_  / ", " / /   ", "/___|  ", "       ", "       "],
692    0: ["  ___  ", " / _ \\ ", "| | | |", "| | | |", "| |_| |", " \\___/ ", "       ", "       "],
693    1: [" __ ", "/_ |", " | |", " | |", " | |", " |_|", "    ", "    "],
694    2: [" ___  ", "|__ \\ ", "   ) |", "  / / ", " / /_ ", "|____|", "      ", "      "],
695    3: [" ____  ", "|___ \\ ", "  __) |", " |__ < ", " ___) |", "|____/ ", "       ", "       "],
696    4: [" _  _   ", "| || |  ", "| || |_ ", "|__   _|", "   | |  ", "   |_|  ", "        ", "        "],
697    5: [" _____ ", "| ____|", "| |__  ", "|___ \\ ", " ___) |", "|____/ ", "       ", "       "],
698    6: ["   __  ", "  / /  ", " / /_  ", "|  _ \\ ", "| (_) |", " \\___/ ", "       ", "       "],
699    7: [" ______ ", "|____  |", "    / / ", "   / /  ", "  / /   ", " /_/    ", "        ", "        "],
700    8: ["  ___  ", " / _ \\ ", "| (_) |", " > _ < ", "| (_) |", " \\___/ ", "       ", "       "],
701    9: ["  ___  ", " / _ \\ ", "| (_) |", " \\__, |", "   / / ", "  /_/  ", "       ", "       "]
702  };
703  function renderBigText(text) {
704    const glyphs = [];
705    for (const ch of text) {
706      glyphs.push(BIG_FONT[ch] || BIG_FONT[ch.toUpperCase()] || BIG_FONT["?"] || BIG_FONT[" "]);
707    }
708    if (!glyphs.length) return [];
709    const lines = [];
710    for (let r = 0; r < 8; r++) {
711      let line = "";
712      for (const g of glyphs) line += g[r] || "";
713      lines.push(line.replace(/\s+$/, ""));
714    }
715    // Trim leading/trailing all-empty rows so words without descenders or
716    // ascenders render compactly.
717    while (lines.length && !lines[0].trim()) lines.shift();
718    while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
719    return lines;
720  }
721  function Terminal({
722    open,
723    onClose,
724    autoLaunchGame = false
725  }) {
726    const [history, setHistory] = useState([]);
727    const [gameOpen, setGameOpen] = useState(false);
728    const [minimized, setMinimized] = useState(false);
729    const [maximized, setMaximized] = useState(false);
730    const [asciiMode, setAsciiMode] = useState(null); // null | 'text'
731    const [gameMode, setGameMode] = useState(null); // null | 'forest' | 'forest-menu' | 'forest-seed'
732    const wasKonamiRef = useRef(false);
733    const inputRef = useRef(null);
734    const bodyRef = useRef(null);
735    // Ghost-text autocomplete overlay: the element we paint the dim suffix into,
736    // and the current suffix (accepted with → or Tab).
737    const ghostRef = useRef(null);
738    const ghostSuffixRef = useRef("");
739    // Live forest-adventure state — ref'd (not state) so the per-turn updates
740    // don't trigger React re-renders of the history list.
741    const gameStateRef = useRef(null);
742    // Typewriter controller: a queue of lines to type out, plus the current
743    // skip function and intervalId so we can fast-forward or cancel cleanly.
744    const typewriterCtrl = useRef({
745      queue: [],
746      busy: false,
747      skipFn: null,
748      started: null
749    });
750    // Track which history indices have already kicked off their reveal so the
751    // callback ref doesn't restart the animation on every re-render.
752    const revealStartedRef = useRef(new Set());
753    // Command-history recall. Past commands oldest->newest; a cursor into that list
754    // (-1 = the live line, i.e. not navigating); and the draft we were typing before
755    // pressing Up, restored when we navigate back down past the newest entry.
756    const cmdHistoryRef = useRef([]);
757    const histCursorRef = useRef(-1);
758    const histDraftRef = useRef("");
759
760    // Uncontrolled input: the <input> owns its value via the DOM. We only read
761    // it on submit (via inputRef.current.value) and clear it the same way.
762    // This avoids re-rendering the entire history list on every keystroke,
763    // which is what causes the typing to feel sluggish.
764    function clearInput() {
765      if (inputRef.current) inputRef.current.value = "";
766      clearGhost();
767    }
768
769    // Set the input value and drop the caret at the end (used by arrow recall).
770    function setInputValue(v) {
771      const el = inputRef.current;
772      if (!el) return;
773      el.value = v;
774      el.setSelectionRange(v.length, v.length);
775      updateGhost();
776    }
777
778    // ── Ghost-text autocomplete ──────────────────────────────────
779    // Paint a dim suffix after the caret showing the predicted completion.
780    // The typed text is mirrored transparently so the suffix lines up.
781    function clearGhost() {
782      ghostSuffixRef.current = "";
783      if (ghostRef.current) ghostRef.current.textContent = "";
784    }
785    function updateGhost() {
786      const el = inputRef.current;
787      const g = ghostRef.current;
788      if (!el || !g) return;
789      const val = el.value;
790      let suffix = "";
791      // Only when there's input and the caret sits at the very end.
792      if (val && el.selectionStart === val.length && el.selectionEnd === val.length) {
793        const F = window.ForestAdventure;
794        const res = gameMode === "forest" && F && F.complete ? F.complete(val, gameStateRef.current) : completeCommand(val);
795        if (res && res.completion && res.completion.length > val.length && res.completion.indexOf(val) === 0) {
796          suffix = res.completion.slice(val.length);
797        }
798      }
799      ghostSuffixRef.current = suffix;
800      g.textContent = "";
801      if (suffix) {
802        const typed = document.createElement("span");
803        typed.className = "term-ghost-typed";
804        typed.textContent = val;
805        const suf = document.createElement("span");
806        suf.className = "term-ghost-suffix";
807        suf.textContent = suffix;
808        g.appendChild(typed);
809        g.appendChild(suf);
810      }
811    }
812
813    // Render a history line's text, expanding `§x{…}` emphasis tokens emitted
814    // by the forest game into coloured spans. Plain text passes straight through.
815    function renderLineText(text) {
816      if (!text || text.indexOf("§") === -1) return text;
817      const parts = [];
818      const re = /§([ids])\{([^}]*)\}/g;
819      let last = 0;
820      let m;
821      let key = 0;
822      while (m = re.exec(text)) {
823        if (m.index > last) parts.push(text.slice(last, m.index));
824        const cls = m[1] === "i" ? "tok-item" : m[1] === "d" ? "tok-dir" : "tok-secret";
825        parts.push(/*#__PURE__*/React.createElement("span", {
826          key: key++,
827          className: cls
828        }, m[2]));
829        last = re.lastIndex;
830      }
831      if (last < text.length) parts.push(text.slice(last));
832      return parts;
833    }
834
835    // Record a submitted line into the recall history and reset the cursor.
836    // Shared by the normal command line and the forest game so Up/Down recalls
837    // work the same in both.
838    function pushHistory(raw) {
839      const entry = (raw || "").trim();
840      const hist = cmdHistoryRef.current;
841      if (entry && hist[hist.length - 1] !== entry) {
842        hist.push(entry);
843        if (hist.length > 100) hist.shift();
844      }
845      histCursorRef.current = -1;
846      histDraftRef.current = "";
847    }
848
849    // Tab-completion for the normal command line (the forest game supplies its
850    // own completer via window.ForestAdventure.complete).
851    const TERM_COMMANDS = ["about", "ascii", "clear", "contact", "date", "exit", "forest", "help", "projects", "snake", "stack", "whoami"];
852    function completeCommand(val) {
853      const endsSpace = /\s$/.test(val);
854      const words = val.trim() ? val.trim().split(/\s+/) : [];
855      if (words.length !== 1 || endsSpace) return {
856        completion: null,
857        candidates: []
858      };
859      const prefix = words[0].toLowerCase();
860      const matches = TERM_COMMANDS.filter(c => c.indexOf(prefix) === 0);
861      if (!matches.length) return {
862        completion: null,
863        candidates: []
864      };
865      if (matches.length === 1) return {
866        completion: matches[0],
867        candidates: matches
868      };
869      let common = matches[0];
870      for (const m of matches) {
871        let i = 0;
872        while (i < common.length && i < m.length && common[i] === m[i]) i++;
873        common = common.slice(0, i);
874      }
875      return {
876        completion: common.length > prefix.length ? common : null,
877        candidates: matches
878      };
879    }
880
881    // Cancel any in-flight typewriter (e.g. on `clear` or terminal close).
882    function cancelAllTypewriters() {
883      if (typewriterCtrl.current.skipFn) typewriterCtrl.current.skipFn();
884      typewriterCtrl.current.queue.length = 0;
885      typewriterCtrl.current.busy = false;
886      typewriterCtrl.current.skipFn = null;
887    }
888
889    // Append a batch of game-emitted lines to history all at once. The
890    // forest adventure prints instantly — no typewriter reveal — so the
891    // ASCII tables and room descriptions land legibly in one go.
892    function enqueueGameLines(lines) {
893      if (!lines || !lines.length) return;
894      setHistory(h => {
895        const next = h.slice();
896        for (const ln of lines) {
897          next.push({
898            kind: ln.kind || "ok",
899            text: ln.text || ""
900          });
901        }
902        return next;
903      });
904    }
905
906    // The forest title-screen items, in display order. `resume` only appears
907    // when a save exists — the rest stay contiguous so the printed numbers are
908    // always 1, 2, 3, … with no gaps.
909    function forestMenuItems(F) {
910      const hasSave = F.hasSave && F.hasSave();
911      const items = [{
912        id: "new",
913        label: "new game",
914        aliases: ["new", "new game"]
915      }];
916      if (hasSave) items.push({
917        id: "resume",
918        label: "resume your walk",
919        aliases: ["resume"]
920      });
921      items.push({
922        id: "endings",
923        label: "endings & achievements",
924        aliases: ["endings", "achievements"]
925      });
926      items.push({
927        id: "help",
928        label: "how to play",
929        aliases: ["help", "how to play"]
930      });
931      items.push({
932        id: "seed",
933        label: "new game with a seed",
934        aliases: ["seed"]
935      });
936      return items;
937    }
938    // The forest title screen, numbered from the active items above.
939    function forestMenuLines(F) {
940      const out = [{
941        text: "",
942        kind: "game"
943      }, {
944        text: "F O R E S T   W A N D E R E R",
945        kind: "room"
946      }, {
947        text: "a small adventure in the woods.",
948        kind: "dim"
949      }, {
950        text: "",
951        kind: "game"
952      }];
953      forestMenuItems(F).forEach((it, i) => {
954        out.push({
955          text: "  " + (i + 1) + ") " + it.label,
956          kind: "choice"
957        });
958      });
959      out.push({
960        text: "",
961        kind: "game"
962      });
963      out.push({
964        text: "(type a number.)",
965        kind: "dim"
966      });
967      return out;
968    }
969    // Resolve a typed answer (a menu number or a word alias) to a menu item.
970    function resolveForestMenuChoice(F, ans) {
971      const items = forestMenuItems(F);
972      if (/^\d+$/.test(ans)) {
973        const n = parseInt(ans, 10);
974        return n >= 1 && n <= items.length ? items[n - 1] : null;
975      }
976      return items.filter(it => it.aliases.indexOf(ans) !== -1)[0] || null;
977    }
978    // Begin a fresh forest run (optionally seeded) and enter play mode.
979    function startForestGame(F, opts) {
980      if (F.clearSave) F.clearSave();
981      const result = F.start(opts || {});
982      gameStateRef.current = result.state;
983      setGameMode("forest");
984      enqueueGameLines(result.lines);
985    }
986    // Resume the saved run and re-orient the player with a fresh `look`.
987    function resumeForestGame(F) {
988      const loaded = F.load();
989      if (!loaded) {
990        startForestGame(F);
991        return;
992      }
993      gameStateRef.current = loaded;
994      setGameMode("forest");
995      const ok = F.parse("look", gameStateRef.current);
996      gameStateRef.current = ok.state;
997      enqueueGameLines([{
998        text: "(resumed.)",
999        kind: "dim"
1000      }].concat(ok.lines));
1001    }
1002
1003    // Reveal `text` character-by-character on the given DOM element. Calls
1004    // onDone() when finished (either organically or via skipFn).
1005    function revealOnElement(el, text, lineIdx, onDone) {
1006      typewriterCtrl.current.busy = true;
1007      let i = 0;
1008      const id = setInterval(() => {
1009        i++;
1010        el.textContent = text.slice(0, i);
1011        if (i >= text.length) finalize();
1012      }, 25);
1013      function finalize() {
1014        clearInterval(id);
1015        el.textContent = text;
1016        typewriterCtrl.current.skipFn = null;
1017        typewriterCtrl.current.busy = false;
1018        // Mark the history line as no longer typing so the ref doesn't re-fire.
1019        setHistory(h => h.map((l, i2) => i2 === lineIdx ? {
1020          ...l,
1021          text,
1022          isTyping: false,
1023          fullText: undefined
1024        } : l));
1025        // Process next queued reveal.
1026        const next = typewriterCtrl.current.queue.shift();
1027        if (next) revealOnElement(next.el, next.text, next.lineIdx, next.onDone);else if (onDone) onDone();
1028      }
1029      typewriterCtrl.current.skipFn = finalize;
1030    }
1031
1032    // Start (or queue) a reveal for a freshly-mounted typing line.
1033    function startReveal(el, text, lineIdx) {
1034      if (typewriterCtrl.current.busy) {
1035        typewriterCtrl.current.queue.push({
1036          el,
1037          text,
1038          lineIdx
1039        });
1040        return;
1041      }
1042      revealOnElement(el, text, lineIdx);
1043    }
1044
1045    // Reset on open. If autoLaunchGame is true (Konami code path), jump straight into the game.
1046    useEffect(() => {
1047      if (open) {
1048        cancelAllTypewriters();
1049        revealStartedRef.current = new Set();
1050        setHistory(TERMINAL_BANNER.map(l => ({
1051          kind: "ok",
1052          text: l
1053        })));
1054        clearInput();
1055        histCursorRef.current = -1;
1056        histDraftRef.current = "";
1057        setMinimized(false);
1058        setMaximized(false);
1059        setAsciiMode(null);
1060        setGameMode(null);
1061        gameStateRef.current = null;
1062        if (autoLaunchGame) {
1063          setGameOpen(true);
1064          wasKonamiRef.current = true;
1065        } else {
1066          setGameOpen(false);
1067          wasKonamiRef.current = false;
1068        }
1069        setTimeout(() => inputRef.current?.focus(), 30);
1070      }
1071    }, [open, autoLaunchGame]);
1072
1073    // Auto-scroll — runs synchronously after DOM mutation so new lines and the
1074    // scroll-to-bottom happen in the same paint (no visible jump).
1075    useLayoutEffect(() => {
1076      if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
1077    }, [history]);
1078
1079    // Esc closes
1080    useEffect(() => {
1081      if (!open) return;
1082      const onKey = e => {
1083        if (e.key === "Escape") onClose();
1084      };
1085      window.addEventListener("keydown", onKey);
1086      return () => window.removeEventListener("keydown", onKey);
1087    }, [open, onClose]);
1088    function print(text, kind = "ok") {
1089      setHistory(h => h.concat([{
1090        kind,
1091        text
1092      }]));
1093    }
1094    function run(raw) {
1095      const cmd = raw.trim().toLowerCase();
1096      print("$ " + raw, "cmd");
1097      if (!cmd) return;
1098      if (cmd === "help") {
1099        print("Available commands:");
1100        print("  about     · quick bio");
1101        print("  ascii     · ASCII art generator");
1102        print("  clear     · clear the screen");
1103        print("  contact   · how to reach me");
1104        print("  date      · current date");
1105        print("  exit      · close terminal");
1106        print("  forest    · explore the woods");
1107        print("  projects  · list of projects");
1108        print("  snake     · play snake");
1109        print("  stack     · tech stack");
1110        print("  whoami    · guess");
1111      } else if (cmd === "forest") {
1112        // Lazy-load, then show the title menu.
1113        const onReady = F => {
1114          enqueueGameLines(forestMenuLines(F));
1115          setGameMode("forest-menu");
1116        };
1117        if (window.ForestAdventure) {
1118          onReady(window.ForestAdventure);
1119        } else {
1120          print("loading the woods…", "dim");
1121          loadForestAdventure().then(onReady).catch(() => {
1122            print("failed to load the woods. try again?", "err");
1123          });
1124        }
1125      } else if (cmd === "ascii" || cmd.startsWith("ascii ")) {
1126        const inline = raw.trim().slice(5).trim();
1127        if (inline) {
1128          print("");
1129          renderBigText(inline).forEach(line => print(line, "art"));
1130          print("");
1131        } else {
1132          print("what should I render? (or `cancel`)");
1133          setAsciiMode("text");
1134        }
1135      } else if (cmd === "about") {
1136        print("Cristian Gabriel. Software developer, Bucharest, RO.");
1137        print("Works mostly with Java + Spring Boot. Likes plants.");
1138      } else if (cmd === "projects") {
1139        print("  1. Impostor            · social party game (live)");
1140        print("  2. Loopretto           · transcription tool for musicians");
1141        print("  3. QuickPaste          · Chrome extension for clip-collecting");
1142        print("  4. Trendalizer         · keyword interest grapher");
1143        print("  5. Furniture Boutique  · landing page");
1144      } else if (cmd === "stack") {
1145        print("Java · Spring Boot · Thymeleaf · PostgreSQL · MySQL");
1146        print("Linux · Git · GitHub · HTML · CSS · JS · Bootstrap");
1147      } else if (cmd === "contact") {
1148        print("email   : [email protected]", "ok");
1149        print("github  : github.com/cristiangabriel88", "ok");
1150        print("linkedin: in/cristian-gabriel-constantinescu-781a6b237", "ok");
1151      } else if (cmd === "whoami") {
1152        print("[email protected]");
1153      } else if (cmd === "date") {
1154        print(new Date().toString());
1155      } else if (cmd === "snake") {
1156        print("Launching snake... arrow keys / WASD to steer. Esc to exit.", "dim");
1157        setTimeout(() => setGameOpen(true), 300);
1158      } else if (cmd === "clear") {
1159        cancelAllTypewriters();
1160        revealStartedRef.current = new Set();
1161        setHistory([]);
1162      } else if (cmd === "exit" || cmd === "close" || cmd === "q") {
1163        onClose();
1164      } else if (cmd === "sudo") {
1165        print("Nice try.", "err");
1166      } else if (cmd === "ls") {
1167        print("home/  about/  projects/  .secret/");
1168      } else if (cmd === "cat .secret") {
1169        print("You found it. There's a tiny snake hungry for fruit. Try `game`.", "ok");
1170      } else {
1171        print(`cg: command not found: ${cmd}`, "err");
1172        print("try `help`", "dim");
1173      }
1174    }
1175    function onInputKeyDown(e) {
1176      // Tab completes the current word — forest verbs/nouns in the game, or
1177      // the top-level commands on the normal line. Never let Tab move focus.
1178      if (e.key === "Tab") {
1179        e.preventDefault();
1180        if (asciiMode || gameOpen || gameMode && gameMode !== "forest") return;
1181        const el = inputRef.current;
1182        if (!el) return;
1183        const F = window.ForestAdventure;
1184        const res = gameMode === "forest" && F && F.complete ? F.complete(el.value, gameStateRef.current) : completeCommand(el.value);
1185        if (res && res.completion) {
1186          setInputValue(res.completion);
1187        } else if (res && res.candidates && res.candidates.length > 1) {
1188          enqueueGameLines([{
1189            text: res.candidates.join("   "),
1190            kind: "dim"
1191          }]);
1192        }
1193        return;
1194      }
1195      // → at the end of the line accepts the dim ghost-text suggestion.
1196      if (e.key === "ArrowRight") {
1197        const el = inputRef.current;
1198        if (el && ghostSuffixRef.current && el.selectionStart === el.value.length) {
1199          e.preventDefault();
1200          setInputValue(el.value + ghostSuffixRef.current);
1201          return;
1202        }
1203      }
1204      // Arrow recall on the normal command line and inside the forest game —
1205      // but not during a menu/prompt, the ASCII prompt, the snake overlay,
1206      // or while a typewriter reveal runs.
1207      if (asciiMode || gameOpen || typewriterCtrl.current.busy) return;
1208      if (gameMode && gameMode !== "forest") return;
1209      const hist = cmdHistoryRef.current;
1210      if (e.key === "ArrowUp") {
1211        if (!hist.length) return;
1212        e.preventDefault();
1213        if (histCursorRef.current === -1) {
1214          histDraftRef.current = inputRef.current ? inputRef.current.value : "";
1215          histCursorRef.current = hist.length - 1;
1216        } else if (histCursorRef.current > 0) {
1217          histCursorRef.current -= 1;
1218        }
1219        setInputValue(hist[histCursorRef.current]);
1220      } else if (e.key === "ArrowDown") {
1221        if (histCursorRef.current === -1) return;
1222        e.preventDefault();
1223        if (histCursorRef.current < hist.length - 1) {
1224          histCursorRef.current += 1;
1225          setInputValue(hist[histCursorRef.current]);
1226        } else {
1227          histCursorRef.current = -1; // past the newest -> back to the draft
1228          setInputValue(histDraftRef.current);
1229        }
1230      }
1231    }
1232    function onSubmit(e) {
1233      e.preventDefault();
1234
1235      // While the typewriter is mid-reveal, Enter skips the current paragraph
1236      // instead of submitting. The input value is left alone so the user can
1237      // keep typing through the reveal.
1238      if (typewriterCtrl.current.busy && typewriterCtrl.current.skipFn) {
1239        typewriterCtrl.current.skipFn();
1240        return;
1241      }
1242      const raw = inputRef.current ? inputRef.current.value : "";
1243      clearInput();
1244
1245      // Forest adventure — title menu (numbered options).
1246      if (gameMode === "forest-menu") {
1247        print(">> " + raw, "cmd");
1248        const F = window.ForestAdventure;
1249        const ans = raw.trim().toLowerCase();
1250        const choice = resolveForestMenuChoice(F, ans);
1251        if (!choice) {
1252          // Wrong answer — re-show the menu so the options never scroll out of
1253          // reach, no matter how many times the player misses.
1254          enqueueGameLines([{
1255            text: "that's not one of the options.",
1256            kind: "dim"
1257          }].concat(forestMenuLines(F)));
1258        } else if (choice.id === "new") {
1259          startForestGame(F);
1260        } else if (choice.id === "resume") {
1261          resumeForestGame(F);
1262        } else if (choice.id === "endings") {
1263          // Show the list, then re-print the menu so the choices are back in
1264          // view after the gallery has scrolled the screen.
1265          enqueueGameLines(F.galleryLines().concat(forestMenuLines(F)));
1266        } else if (choice.id === "help") {
1267          enqueueGameLines(F.helpLines().concat(forestMenuLines(F)));
1268        } else if (choice.id === "seed") {
1269          print("enter a number for the seed, leave blank for a random one, or type `cancel` to go back:", "dim");
1270          setGameMode("forest-seed");
1271        }
1272        return;
1273      }
1274
1275      // Forest adventure — seed entry for "new game with a seed".
1276      if (gameMode === "forest-seed") {
1277        print(">> " + raw, "cmd");
1278        const F = window.ForestAdventure;
1279        const trimmed = raw.trim();
1280        const lower = trimmed.toLowerCase();
1281        if (lower === "cancel" || lower === "back") {
1282          // Bail out without starting a game (and without wiping any save).
1283          enqueueGameLines(forestMenuLines(F));
1284          setGameMode("forest-menu");
1285        } else if (trimmed === "") {
1286          startForestGame(F); // blank → a random seed
1287        } else if (/^\d+$/.test(trimmed)) {
1288          startForestGame(F, {
1289            seed: parseInt(trimmed, 10)
1290          });
1291        } else {
1292          // Don't silently start (and clear the save) on a typo — re-ask.
1293          print("that's not a number. type a seed number, leave blank for random, or `cancel`.", "dim");
1294        }
1295        return;
1296      }
1297
1298      // Forest adventure — main game loop.
1299      if (gameMode === "forest") {
1300        const F = window.ForestAdventure;
1301        print(">> " + raw, "cmd");
1302        pushHistory(raw);
1303        const result = F.parse(raw, gameStateRef.current);
1304        gameStateRef.current = result.state;
1305        enqueueGameLines(result.lines);
1306
1307        // Auto-save after each substantive turn (skip a pure quit, and skip a
1308        // game-over so a finished run never overwrites a still-playable save).
1309        if (!result.done && !result.pendingGameOver) F.save(gameStateRef.current);
1310
1311        // Handle game-end flows.
1312        if (result.ended) {
1313          // Story ending reached — clear save so the next `forest` starts fresh.
1314          F.clearSave();
1315          setGameMode(null);
1316          gameStateRef.current = null;
1317        } else if (result.done) {
1318          // Player quit cleanly.
1319          setGameMode(null);
1320          gameStateRef.current = null;
1321        }
1322        return;
1323      }
1324
1325      // Interactive ASCII flow — collect text, then render.
1326      if (asciiMode === "text") {
1327        print("> " + raw, "cmd");
1328        const value = raw.trim();
1329        if (!value || value.toLowerCase() === "cancel") {
1330          print("cancelled.", "dim");
1331          setAsciiMode(null);
1332          return;
1333        }
1334        print("");
1335        renderBigText(value).forEach(line => print(line, "art"));
1336        print("");
1337        setAsciiMode(null);
1338        return;
1339      }
1340      pushHistory(raw);
1341      run(raw);
1342    }
1343    if (!open) return null;
1344
1345    // ###### MINIMIZED PILL ######
1346    // Yellow button collapses the terminal to a small clickable pill at the bottom-right.
1347    if (minimized) {
1348      return /*#__PURE__*/React.createElement("button", {
1349        className: "terminal-minimized-pill",
1350        onClick: () => setMinimized(false),
1351        title: "Restore terminal"
1352      }, /*#__PURE__*/React.createElement("span", {
1353        className: "mp-dots"
1354      }, /*#__PURE__*/React.createElement("span", {
1355        className: "mp-dot"
1356      }), /*#__PURE__*/React.createElement("span", {
1357        className: "mp-dot"
1358      }), /*#__PURE__*/React.createElement("span", {
1359        className: "mp-dot"
1360      })), /*#__PURE__*/React.createElement("span", null, "cg.term \u2191"));
1361    }
1362    return /*#__PURE__*/React.createElement("div", {
1363      className: "terminal-backdrop" + (maximized ? " maximized" : ""),
1364      onMouseDown: e => {
1365        if (e.target === e.currentTarget) onClose();
1366      }
1367    }, /*#__PURE__*/React.createElement("div", {
1368      className: "terminal" + (maximized ? " maximized" : ""),
1369      onMouseDown: e => e.stopPropagation()
1370    }, /*#__PURE__*/React.createElement("div", {
1371      className: "terminal-bar"
1372    }, /*#__PURE__*/React.createElement("div", {
1373      className: "dots"
1374    }, /*#__PURE__*/React.createElement("button", {
1375      className: "dot r",
1376      title: "Close",
1377      "aria-label": "Close",
1378      onClick: onClose
1379    }), /*#__PURE__*/React.createElement("button", {
1380      className: "dot y",
1381      title: "Minimize",
1382      "aria-label": "Minimize",
1383      onClick: () => setMinimized(true)
1384    }), /*#__PURE__*/React.createElement("button", {
1385      className: "dot g",
1386      title: "Zoom",
1387      "aria-label": "Zoom",
1388      onClick: () => setMaximized(m => !m)
1389    })), /*#__PURE__*/React.createElement("div", {
1390      className: "title"
1391    }, "cg@portfolio ~ %"), /*#__PURE__*/React.createElement("div", {
1392      style: {
1393        width: 56
1394      }
1395    })), gameOpen ? /*#__PURE__*/React.createElement(MiniGame, {
1396      onExit: () => {
1397        // If the game was launched via Konami, exiting closes the whole terminal.
1398        // If launched via the `game` command, exiting drops back to the prompt.
1399        if (wasKonamiRef.current) {
1400          onClose();
1401        } else {
1402          setGameOpen(false);
1403          setTimeout(() => inputRef.current?.focus(), 30);
1404        }
1405      }
1406    }) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
1407      ref: bodyRef,
1408      className: "terminal-body"
1409    }, history.map((line, i) => /*#__PURE__*/React.createElement("p", {
1410      key: i,
1411      className: line.kind + (line.isTyping ? " typing" : ""),
1412      ref: line.isTyping ? el => {
1413        if (el && !revealStartedRef.current.has(i)) {
1414          revealStartedRef.current.add(i);
1415          startReveal(el, line.fullText, i);
1416        }
1417      } : null
1418    }, renderLineText(line.text)))), /*#__PURE__*/React.createElement("form", {
1419      className: "terminal-prompt",
1420      onSubmit: onSubmit
1421    }, /*#__PURE__*/React.createElement("span", {
1422      className: "ps"
1423    }, gameMode ? window.ForestAdventure ? window.ForestAdventure.PROMPT.trim() : ">>" : asciiMode ? ">" : "~ $"), /*#__PURE__*/React.createElement("span", {
1424      className: "term-inputwrap"
1425    }, /*#__PURE__*/React.createElement("span", {
1426      className: "term-ghost",
1427      ref: ghostRef,
1428      "aria-hidden": "true"
1429    }), /*#__PURE__*/React.createElement("input", {
1430      ref: inputRef,
1431      onKeyDown: onInputKeyDown,
1432      onInput: updateGhost,
1433      defaultValue: "",
1434      autoFocus: true,
1435      spellCheck: false,
1436      autoComplete: "off",
1437      autoCorrect: "off",
1438      autoCapitalize: "off"
1439    }))))));
1440  }
1441
1442  // ###### SNAKE ######
1443  // Canvas-rendered snake on a 32×20 board. Walls wrap (toroidal), so the snake
1444  // re-enters from the opposite edge. Game state lives in refs; only HUD/score
1445  // goes through React, so the tick loop never triggers a 600-cell re-render.
1446  //
1447  // Rendering: requestAnimationFrame drives the paint continuously and lerps each
1448  // segment between its previous cell and current cell over the tick interval, so
1449  // motion looks smooth at 60fps regardless of the (slower) logical tick rate.
1450  // Wrap-aware lerp: when a segment straddles the board edge, we split-draw it on
1451  // both sides so the wrap reads as a continuous slide rather than a teleport.
1452  function MiniGame({
1453    onExit
1454  }) {
1455    const COLS = 32,
1456      ROWS = 20;
1457    const PX = 16; // logical pixel size per cell — large enough to fit textured sprites (eyes, shading, apple stem)
1458    const INIT_SNAKE = [{
1459      x: 16,
1460      y: 10
1461    }, {
1462      x: 15,
1463      y: 10
1464    }, {
1465      x: 14,
1466      y: 10
1467    }];
1468    const TICK_BASE = 110; // ms per cell at score 0
1469    const TICK_MIN = 60; // ms per cell at high score
1470
1471    const canvasRef = useRef(null);
1472    const snakeRef = useRef(INIT_SNAKE);
1473    const prevSnakeRef = useRef(INIT_SNAKE); // snake positions *before* the current tick — used as the lerp "from"
1474    const foodRef = useRef({
1475      x: 22,
1476      y: 10
1477    });
1478    const dirRef = useRef({
1479      dx: 1,
1480      dy: 0
1481    });
1482    const lastAppliedDirRef = useRef({
1483      dx: 1,
1484      dy: 0
1485    });
1486    const scoreRef = useRef(0);
1487    const runningRef = useRef(false);
1488    const tickIntervalRef = useRef(TICK_BASE);
1489
1490    // ###### INPUT RESPONSIVENESS ######
1491    // dirQueue: pending direction changes (size ≤ 2) so a quick right→down isn't
1492    //   dropped when both keys land between ticks — each tick consumes one entry.
1493    // lastTickAt + tickFnRef + MIN_INPUT_GAP: lets a valid turn fire a tick
1494    //   immediately instead of waiting up to tick-time, capped so input spam can't
1495    //   make the snake move faster than ~22 cells/sec.
1496    const dirQueueRef = useRef([]);
1497    const lastTickAtRef = useRef(0);
1498    const tickFnRef = useRef(null);
1499    const MIN_INPUT_GAP = 45;
1500    const [score, setScore] = useState(0);
1501    const [length, setLength] = useState(INIT_SNAKE.length);
1502    const [running, setRunning] = useState(false);
1503    const [over, setOver] = useState(false);
1504    function spawnFood(currentSnake) {
1505      for (let i = 0; i < 200; i++) {
1506        const f = {
1507          x: Math.floor(Math.random() * COLS),
1508          y: Math.floor(Math.random() * ROWS)
1509        };
1510        if (!currentSnake.some(s => s.x === f.x && s.y === f.y)) return f;
1511      }
1512      return {
1513        x: 0,
1514        y: 0
1515      };
1516    }
1517
1518    // ###### SPRITES ######
1519    // Each cell is PX×PX. Sprites use 1–2 pixel features (rim shade, highlight, eyes)
1520    // for a textured pixel-art read once the canvas is upscaled. Drawn in absolute
1521    // pixel coords so we can position at fractional cell offsets (for the lerp).
1522    function drawBackground(ctx) {
1523      ctx.fillStyle = "#0F1A07";
1524      ctx.fillRect(0, 0, COLS * PX, ROWS * PX);
1525      // Faint cross-hatch dots — gives the board a board feel without competing for attention.
1526      ctx.fillStyle = "rgba(122, 163, 85, 0.08)";
1527      for (let y = 0; y < ROWS; y++) {
1528        for (let x = 0; x < COLS; x++) {
1529          ctx.fillRect(x * PX + PX - 2, y * PX + PX - 2, 1, 1);
1530        }
1531      }
1532    }
1533    function drawBody(ctx, px, py) {
1534      // Rim (dark), main (mid green), highlight (light dot), shadow (bottom-right).
1535      ctx.fillStyle = "#2D5016";
1536      ctx.fillRect(px + 1, py + 1, PX - 2, PX - 2);
1537      ctx.fillStyle = "#5A8A3C";
1538      ctx.fillRect(px + 2, py + 2, PX - 4, PX - 4);
1539      ctx.fillStyle = "#7AA355";
1540      ctx.fillRect(px + 3, py + 3, 2, 2);
1541      ctx.fillStyle = "#2D5016";
1542      ctx.fillRect(px + PX - 5, py + PX - 5, 2, 2);
1543    }
1544    function drawHead(ctx, px, py, dir) {
1545      // Rim + brighter face so the head reads as distinct from the body.
1546      ctx.fillStyle = "#2D5016";
1547      ctx.fillRect(px + 1, py + 1, PX - 2, PX - 2);
1548      ctx.fillStyle = "#A8CC7A";
1549      ctx.fillRect(px + 2, py + 2, PX - 4, PX - 4);
1550      ctx.fillStyle = "#DFEFD2";
1551      ctx.fillRect(px + 3, py + 3, 3, 2);
1552      // Eyes — placed on the leading edge of the head based on direction.
1553      ctx.fillStyle = "#0F1A07";
1554      let ax, ay, bx, by;
1555      if (dir.dx === 1) {
1556        ax = px + PX - 5;
1557        ay = py + 4;
1558        bx = px + PX - 5;
1559        by = py + PX - 6;
1560      } else if (dir.dx === -1) {
1561        ax = px + 3;
1562        ay = py + 4;
1563        bx = px + 3;
1564        by = py + PX - 6;
1565      } else if (dir.dy === 1) {
1566        ax = px + 4;
1567        ay = py + PX - 5;
1568        bx = px + PX - 6;
1569        by = py + PX - 5;
1570      } else {
1571        ax = px + 4;
1572        ay = py + 3;
1573        bx = px + PX - 6;
1574        by = py + 3;
1575      }
1576      ctx.fillRect(ax, ay, 2, 2);
1577      ctx.fillRect(bx, by, 2, 2);
1578    }
1579    function drawFood(ctx, px, py) {
1580      // Apple silhouette: dark rim, red body, highlight dot, brown stem, leaf.
1581      ctx.fillStyle = "#5A1A0A";
1582      ctx.fillRect(px + 3, py + 5, PX - 6, PX - 7);
1583      ctx.fillStyle = "#C24A2A";
1584      ctx.fillRect(px + 4, py + 6, PX - 8, PX - 9);
1585      ctx.fillStyle = "#E8B19A";
1586      ctx.fillRect(px + 5, py + 7, 2, 2);
1587      ctx.fillStyle = "#3A2410";
1588      ctx.fillRect(px + (PX >> 1) - 1, py + 3, 2, 3);
1589      ctx.fillStyle = "#7AA355";
1590      ctx.fillRect(px + (PX >> 1) + 1, py + 2, 3, 2);
1591    }
1592
1593    // ###### RAF DRAW ######
1594    // Lerp each segment from prev cell to current cell using (time-since-tick / tickInterval).
1595    // Wrap handling: if the segment travelled more than 1 cell in a tick (e.g. 31→0), we
1596    // know it wrapped. Adjust the delta so the lerp goes the short way (off the board), then
1597    // split-draw on the opposite edge so the wrap looks continuous.
1598    function draw() {
1599      const canvas = canvasRef.current;
1600      if (!canvas) return;
1601      const ctx = canvas.getContext("2d");
1602      ctx.imageSmoothingEnabled = false;
1603      drawBackground(ctx);
1604      drawFood(ctx, foodRef.current.x * PX, foodRef.current.y * PX);
1605      const snake = snakeRef.current;
1606      const prev = prevSnakeRef.current;
1607      const interval = tickIntervalRef.current;
1608      let t = (performance.now() - lastTickAtRef.current) / interval;
1609      if (t < 0) t = 0;else if (t > 1) t = 1;
1610
1611      // Back-to-front so the head paints on top of the neck.
1612      for (let i = snake.length - 1; i >= 0; i--) {
1613        const to = snake[i];
1614        // On growth, the new tail-tip has no prev — anchor it at its current cell (no motion).
1615        const from = i < prev.length ? prev[i] : to;
1616        let dx = to.x - from.x;
1617        let dy = to.y - from.y;
1618        // Wrap-aware delta: any |d| > 1 means the segment crossed the board edge.
1619        if (dx > 1) dx -= COLS;else if (dx < -1) dx += COLS;
1620        if (dy > 1) dy -= ROWS;else if (dy < -1) dy += ROWS;
1621        const cx = (from.x + dx * t) * PX;
1622        const cy = (from.y + dy * t) * PX;
1623        const paint = (px, py) => {
1624          if (i === 0) drawHead(ctx, px, py, lastAppliedDirRef.current);else drawBody(ctx, px, py);
1625        };
1626        paint(cx, cy);
1627        // Split-draw across wrapped edges so the segment is visible on both sides during the slide.
1628        if (cx < 0) paint(cx + COLS * PX, cy);else if (cx > (COLS - 1) * PX) paint(cx - COLS * PX, cy);
1629        if (cy < 0) paint(cx, cy + ROWS * PX);else if (cy > (ROWS - 1) * PX) paint(cx, cy - ROWS * PX);
1630      }
1631    }
1632    function begin() {
1633      snakeRef.current = INIT_SNAKE;
1634      prevSnakeRef.current = INIT_SNAKE;
1635      foodRef.current = spawnFood(INIT_SNAKE);
1636      dirRef.current = {
1637        dx: 1,
1638        dy: 0
1639      };
1640      lastAppliedDirRef.current = {
1641        dx: 1,
1642        dy: 0
1643      };
1644      dirQueueRef.current = [];
1645      lastTickAtRef.current = performance.now();
1646      tickIntervalRef.current = TICK_BASE;
1647      scoreRef.current = 0;
1648      runningRef.current = true;
1649      setScore(0);
1650      setLength(INIT_SNAKE.length);
1651      setOver(false);
1652      setRunning(true);
1653    }
1654
1655    // ###### TICK LOOP ######
1656    // Logical step only: snapshot prev positions, compute next snake, update interval.
1657    // Drawing happens in the RAF loop below, so a tick never touches the canvas directly.
1658    useEffect(() => {
1659      if (!running) return;
1660      let timer;
1661      let cancelled = false;
1662      function tick() {
1663        if (cancelled || !runningRef.current) return;
1664        // Cancel any scheduled tick — we might be called early from the key handler.
1665        clearTimeout(timer);
1666
1667        // Consume one queued direction. Validated at enqueue, so always safe.
1668        if (dirQueueRef.current.length > 0) {
1669          dirRef.current = dirQueueRef.current.shift();
1670        }
1671        const d = dirRef.current;
1672        const snake = snakeRef.current;
1673        const head = snake[0];
1674        // Wall wrap — modular arithmetic, no death at the edge.
1675        const newHead = {
1676          x: (head.x + d.dx + COLS) % COLS,
1677          y: (head.y + d.dy + ROWS) % ROWS
1678        };
1679        const food = foodRef.current;
1680        const ate = newHead.x === food.x && newHead.y === food.y;
1681        // Self collision: skip tail tip if not eating (tail vacates the cell this tick).
1682        const checkAgainst = ate ? snake : snake.slice(0, -1);
1683        for (let i = 0; i < checkAgainst.length; i++) {
1684          if (checkAgainst[i].x === newHead.x && checkAgainst[i].y === newHead.y) {
1685            runningRef.current = false;
1686            setRunning(false);
1687            setOver(true);
1688            return;
1689          }
1690        }
1691        lastAppliedDirRef.current = d;
1692
1693        // Snapshot pre-tick positions so the RAF lerp has a "from" anchor.
1694        prevSnakeRef.current = snake;
1695        if (ate) {
1696          snakeRef.current = [newHead, ...snake];
1697          scoreRef.current += 1;
1698          foodRef.current = spawnFood(snakeRef.current);
1699          setScore(scoreRef.current);
1700          setLength(snakeRef.current.length);
1701        } else {
1702          snakeRef.current = [newHead, ...snake.slice(0, -1)];
1703        }
1704        lastTickAtRef.current = performance.now();
1705        tickIntervalRef.current = Math.max(TICK_MIN, TICK_BASE - scoreRef.current * 3);
1706        timer = setTimeout(tick, tickIntervalRef.current);
1707      }
1708      tickFnRef.current = tick;
1709      timer = setTimeout(tick, TICK_BASE);
1710      return () => {
1711        cancelled = true;
1712        clearTimeout(timer);
1713        tickFnRef.current = null;
1714      };
1715    }, [running]);
1716
1717    // ###### RAF RENDER LOOP ######
1718    // Runs from mount until unmount. Drawing every frame is cheap (a few dozen fillRects)
1719    // and keeps motion buttery at the display refresh rate while ticks fire at 9–17Hz.
1720    useEffect(() => {
1721      let raf;
1722      function frame() {
1723        draw();
1724        raf = requestAnimationFrame(frame);
1725      }
1726      raf = requestAnimationFrame(frame);
1727      return () => cancelAnimationFrame(raf);
1728    }, []);
1729
1730    // ###### KEYBOARD ######
1731    useEffect(() => {
1732      function onKey(e) {
1733        if (e.key === "Escape") {
1734          onExit();
1735          return;
1736        }
1737        if (!runningRef.current) {
1738          if (e.key === " " || e.key === "Enter") {
1739            begin();
1740            e.preventDefault();
1741          }
1742          return;
1743        }
1744        let next = null;
1745        const k = e.key.toLowerCase();
1746        if (e.key === "ArrowUp" || k === "w") next = {
1747          dx: 0,
1748          dy: -1
1749        };else if (e.key === "ArrowDown" || k === "s") next = {
1750          dx: 0,
1751          dy: 1
1752        };else if (e.key === "ArrowLeft" || k === "a") next = {
1753          dx: -1,
1754          dy: 0
1755        };else if (e.key === "ArrowRight" || k === "d") next = {
1756          dx: 1,
1757          dy: 0
1758        };
1759        if (!next) return;
1760        e.preventDefault();
1761
1762        // Validate against the last direction in the pipeline (queue tail, or
1763        // the snake's current heading if the queue is empty). This way a queued
1764        // turn can't be cancelled by an illegal 180° that came right after it.
1765        const q = dirQueueRef.current;
1766        const ref = q.length ? q[q.length - 1] : lastAppliedDirRef.current;
1767        if (next.dx === -ref.dx && next.dy === -ref.dy) return; // 180° reversal
1768        if (next.dx === ref.dx && next.dy === ref.dy) return; // duplicate
1769
1770        q.push(next);
1771        if (q.length > 2) q.shift();
1772
1773        // Fire a tick immediately if enough time has passed — this is what makes
1774        // turns feel instant. The MIN_INPUT_GAP cap stops input spam from
1775        // accelerating the snake beyond ~22 cells/sec.
1776        const now = performance.now();
1777        if (now - lastTickAtRef.current >= MIN_INPUT_GAP && tickFnRef.current) {
1778          tickFnRef.current();
1779        }
1780      }
1781      window.addEventListener("keydown", onKey);
1782      return () => window.removeEventListener("keydown", onKey);
1783    }, [onExit]);
1784    return /*#__PURE__*/React.createElement("div", {
1785      className: "mini-game snake"
1786    }, /*#__PURE__*/React.createElement("div", {
1787      className: "mg-hud"
1788    }, /*#__PURE__*/React.createElement("span", null, "SCORE: ", score), /*#__PURE__*/React.createElement("span", null, "LEN: ", length), /*#__PURE__*/React.createElement("span", {
1789      style: {
1790        cursor: "pointer"
1791      },
1792      onClick: onExit
1793    }, "[esc] exit")), /*#__PURE__*/React.createElement("div", {
1794      className: "snake-stage"
1795    }, /*#__PURE__*/React.createElement("canvas", {
1796      ref: canvasRef,
1797      className: "snake-canvas",
1798      width: COLS * PX,
1799      height: ROWS * PX
1800    })), !running && /*#__PURE__*/React.createElement("div", {
1801      className: "mg-overlay"
1802    }, over ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("h4", null, "GAME OVER"), /*#__PURE__*/React.createElement("p", null, `Score: ${score}. Length: ${length}.`), /*#__PURE__*/React.createElement("button", {
1803      className: "mg-btn",
1804      onClick: begin
1805    }, "play again"), /*#__PURE__*/React.createElement("button", {
1806      className: "mg-btn",
1807      onClick: onExit,
1808      style: {
1809        marginTop: 6
1810      }
1811    }, "back to terminal")) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("h4", null, "SNAKE"), /*#__PURE__*/React.createElement("p", null, "Arrows or WASD to steer. Walls wrap. Eat the fruit. Don't bite yourself."), /*#__PURE__*/React.createElement("button", {
1812      className: "mg-btn",
1813      onClick: begin
1814    }, "start"))));
1815  }
1816
1817  // ─────────── Hook: keystroke listener for "cg" ───────────
1818  function useSecretShortcut(sequence, onMatch) {
1819    useEffect(() => {
1820      let buf = "";
1821      let resetTimer;
1822      function onKey(e) {
1823        // Ignore when typing inside inputs/textareas
1824        const t = e.target;
1825        if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1826        if (!/^[a-zA-Z0-9]$/.test(e.key)) return;
1827        buf = (buf + e.key.toLowerCase()).slice(-sequence.length);
1828        clearTimeout(resetTimer);
1829        resetTimer = setTimeout(() => {
1830          buf = "";
1831        }, 1500);
1832        if (buf === sequence) {
1833          buf = "";
1834          // Stop the matching keydown from also producing an input/keypress event,
1835          // which would otherwise land in the terminal input that's about to mount.
1836          e.preventDefault();
1837          onMatch();
1838        }
1839      }
1840      window.addEventListener("keydown", onKey);
1841      return () => {
1842        window.removeEventListener("keydown", onKey);
1843        clearTimeout(resetTimer);
1844      };
1845    }, [sequence, onMatch]);
1846  }
1847
1848  // ###### HOOK: KONAMI CODE LISTENER ######
1849  // Sequence: ↑ ↑ ↓ ↓ ← → ← → B A. Matches Event.key values exactly.
1850  const KONAMI_SEQUENCE = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"];
1851  function useKonamiCode(onMatch) {
1852    useEffect(() => {
1853      let idx = 0;
1854      function onKey(e) {
1855        // Ignore while typing into form fields.
1856        const t = e.target;
1857        if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1858        const expected = KONAMI_SEQUENCE[idx];
1859        const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
1860        if (key === expected) {
1861          idx++;
1862          if (idx === KONAMI_SEQUENCE.length) {
1863            idx = 0;
1864            // Same focus-race fix as useSecretShortcut: stop the final 'a' from
1865            // being typed into the terminal input that's about to mount.
1866            e.preventDefault();
1867            onMatch();
1868          }
1869        } else {
1870          // Allow restart-from-start if the wrong key happened to be the first key of the sequence.
1871          idx = key === KONAMI_SEQUENCE[0] ? 1 : 0;
1872        }
1873      }
1874      window.addEventListener("keydown", onKey);
1875      return () => window.removeEventListener("keydown", onKey);
1876    }, [onMatch]);
1877  }
1878  Object.assign(window, {
1879    LeafParticles,
1880    PixelCharacter,
1881    IdleGarden,
1882    Terminal,
1883    useSecretShortcut,
1884    useKonamiCode
1885  });
1886})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.