File size: 6,185 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { playCatSound } from '../cat-sound.mjs';

export function createEmptyMascot(element, onSelect) {
  const measure = document.createElement('canvas').getContext('2d');
  const baselineMarker = document.createElement('span');
  baselineMarker.style.cssText = 'display:inline-block;width:0;height:0;padding:0;border:0;vertical-align:baseline';
  const mascot = document.createElement('div');
  mascot.className = 'empty-mascot'; mascot.hidden = true;
  // Measure its final position while keeping the bubble and cat hidden together.
  mascot.style.visibility = 'hidden';
  const suggestion = document.createElement('button');
  suggestion.type = 'button'; suggestion.className = 'mascot-suggestion';
  const figure = document.createElement('div');
  figure.className = 'mascot-cat'; figure.setAttribute('aria-hidden', 'true');
  const cat = document.createElement('img');
  cat.width = cat.height = 104; cat.alt = ''; cat.draggable = false;
  const eyelids = document.createElement('span');
  eyelids.className = 'mascot-eyelids'; eyelids.setAttribute('aria-hidden', 'true');
  figure.append(cat, eyelids);
  mascot.append(suggestion, figure); element.append(mascot);
  let prompt = '', imageReady = false;
  let bumped = false, bumpTimer = 0, blinkTimer = 0;
  const animations = new Set();
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
  function animate(target, frames, options) {
    for (const active of animations) if (active.id === options.id) { active.cancel(); animations.delete(active); }
    const animation = target.animate(frames, options);
    animations.add(animation);
    animation.onfinish = () => animations.delete(animation);
  }
  function hop() {
    clearTimeout(bumpTimer); bumpTimer = 0;
    if (mascot.hidden || document.hidden || reducedMotion.matches || !imageReady) return;
    bumped = true;
    animate(figure, [{ transform: 'translate3d(0, 0, 0)', easing: 'ease-out' }, { transform: 'translate3d(0, -4px, 0)', offset: .4, easing: 'ease-in' }, { transform: 'translate3d(0, 0, 0)' }],
      { id: 'mascot-bump', duration: 420 });
  }
  figure.onclick = () => { playCatSound(); hop(); };
  figure.onpointerdown = event => event.preventDefault();
  figure.onmousedown = event => event.preventDefault();
  function stopMotion() {
    clearTimeout(bumpTimer); clearTimeout(blinkTimer);
    bumpTimer = blinkTimer = 0;
    for (const animation of animations) animation.cancel();
    animations.clear();
  }
  function syncMotion() {
    if (mascot.hidden || document.hidden || reducedMotion.matches || !imageReady) { stopMotion(); return; }
    if (!bumped && !bumpTimer) bumpTimer = setTimeout(hop, 1800);
    if (!blinkTimer) blinkTimer = setTimeout(() => {
      blinkTimer = 0;
      animate(eyelids, [{ opacity: 0, easing: 'steps(1, end)' }, { opacity: 1, offset: .15 }, { opacity: 1, offset: .8, easing: 'steps(1, end)' }, { opacity: 0 }],
        { id: 'mascot-blink', duration: 180 });
      syncMotion();
    }, 5000 + Math.random() * 4000);
  }
  function hide() { mascot.hidden = true; stopMotion(); }
  async function revealImage() {
    try { await cat.decode(); } catch { return; }
    if (!mascot.isConnected) return;
    imageReady = true;
    mascot.style.visibility = '';
    syncMotion();
  }
  cat.addEventListener('load', revealImage);
  document.addEventListener('visibilitychange', syncMotion);
  reducedMotion.addEventListener('change', syncMotion);
  suggestion.onclick = () => onSelect(prompt);
  return {
    hide,
    get statusInset() { return mascot.hidden ? 0 : figure.offsetWidth + 20; },
    update({ screen, rows, viewportRows, contentRows, draft, hasSales, hasProducts }) {
      if (!screen || !rows) { hide(); return; }
      const area = screen.getBoundingClientRect(), root = element.getBoundingClientRect();
      const rowHeight = area.height / rows;
      const room = (viewportRows - contentRows) * rowHeight - 16;
      if (room < 48) { hide(); return; }
      if (!cat.hasAttribute('src')) cat.src = new URL('../../public/empty-cat-v1.png', import.meta.url).href;
      const withSuggestion = !draft.trim() && room >= 180;
      suggestion.hidden = !withSuggestion;
      suggestion.textContent = hasSales && hasProducts
        ? 'Ask me to calculate our total sales.'
        : 'Ask me to explore your files.';
      prompt = hasSales && hasProducts
        ? "What's our total sales revenue?"
        : 'Explore the workspace files and summarize what is here.';
      suggestion.setAttribute('aria-label', `Use suggested prompt: ${prompt}`);
      const size = Math.min(104, Math.floor(withSuggestion ? room - 76 : room));
      figure.style.width = figure.style.height = `${size}px`;
      mascot.hidden = false;
      // One status row precedes the editor's top border. Measure the actual
      // box-drawing glyph so its baseline follows the browser's font and scale.
      let ground = area.top + (viewportRows + 1.5) * rowHeight;
      const border = screen.querySelector('.xterm-rows')?.children[viewportRows + 1]?.firstElementChild;
      const scale = devicePixelRatio || 1;
      if (border && measure) {
        const style = getComputedStyle(border);
        border.parentElement.append(baselineMarker);
        const baseline = baselineMarker.getBoundingClientRect().top;
        baselineMarker.remove();
        measure.font = `${style.fontWeight} ${parseFloat(style.fontSize) * scale}px ${style.fontFamily}`;
        const ink = measure.measureText('─');
        ground = baseline - (ink.actualBoundingBoxAscent - ink.actualBoundingBoxDescent) / 2 / scale;
      }
      ground = Math.floor(ground * scale + .0001) / scale;
      // The paws end at row 1156 in the 1250px sprite; exclude its black margin.
      const pawHeight = Math.round(size * 1156 / 1250 * scale) / scale;
      mascot.style.top = `${ground - root.top + size - pawHeight - mascot.getBoundingClientRect().height}px`;
      syncMotion();
    },
    dispose() {
      hide();
      cat.removeEventListener('load', revealImage);
      document.removeEventListener('visibilitychange', syncMotion);
      reducedMotion.removeEventListener('change', syncMotion);
      mascot.remove();
    },
  };
}