PageSourceSearch

https://imiordanov.github.io/dist/terminal.js

js imiordanov.github.io collected 2026-10-03 09:20:18 UTC 26,523 bytes, 670 lines download raw bytes

1import { WORK, PROJECTS, PUBLICATIONS, EXTRAS, parsePeriod, formatDuration } from './data.js';
2
3/* ── DOM helpers — no innerHTML ── */
4
5function el(tag, cls) {
6    const e = document.createElement(tag);
7    if (cls) e.className = cls;
8    return e;
9}
10function text(s)               { return document.createTextNode(s); }
11function span(cls, s)          { const e = el('span', cls); if (s !== undefined) e.textContent = s; return e; }
12function div(cls)              { return el('div', cls); }
13function a(href, s, cls) {
14    const e = el('a', cls);
15    e.href = href;
16    e.target = '_blank';
17    e.rel = 'noopener noreferrer';
18    e.textContent = s;
19    return e;
20}
21
22/* ── Terminal state ── */
23
24const output     = document.getElementById('output');
25const promptText = document.getElementById('prompt-text');
26const realInput  = document.getElementById('realInput');
27
28let cmdHistory = [];
29let histIdx = -1;
30let busy = false;
31
32/* ── Boot sequence ── */
33
34document.querySelectorAll('.boot-line').forEach(line => {
35    const delay = parseInt(line.dataset.delay || '0', 10);
36    setTimeout(() => line.classList.add('show'), delay);
37});
38
39/* ── Output helpers ── */
40
41function echoCmd(cmd) {
42    const block = div('cmd-block');
43    const echo  = div('cmd-echo');
44    echo.appendChild(span('sigil', 'iordan@tokyo:~$'));
45    echo.appendChild(span('typed', cmd));
46    block.appendChild(echo);
47    const result = div('result');
48    block.appendChild(result);
49    output.appendChild(block);
50    return result;
51}
52
53function revealLines(container, children, baseDelay = 80, step = 60) {
54    children.forEach((child, i) => {
55        child.style.opacity = '0';
56        child.style.transform = 'translateY(6px)';
57        child.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
58        container.appendChild(child);
59        setTimeout(() => {
60            child.style.opacity = '1';
61            child.style.transform = 'translateY(0)';
62        }, baseDelay + i * step);
63    });
64    setTimeout(() => { output.scrollTop = output.scrollHeight; }, baseDelay + children.length * step);
65}
66
67function scrollBottom() {
68    setTimeout(() => { output.scrollTop = output.scrollHeight; }, 50);
69}
70
71/* ══════════════════════════════════════════
72   COMMAND HANDLERS
73══════════════════════════════════════════ */
74
75function cmdWhoami(result) {
76    const wrap   = div('r-whoami');
77    const layout = div('r-whoami-layout');
78
79    const portraitCol = div('r-portrait-col');
80    const portrait = document.createElement('img');
81    portrait.src = 'img/me.jpg';
82    portrait.alt = 'Iordan Iordanov';
83    portrait.className = 'r-portrait';
84    portraitCol.appendChild(portrait);
85    portraitCol.appendChild(div('r-portrait-glow'));
86    layout.appendChild(portraitCol);
87
88    const textCol = div('r-whoami-text-col');
89
90    const name = div('r-name');
91    name.appendChild(text('Iordan '));
92    const em = document.createElement('em');
93    em.textContent = 'Iordanov';
94    name.appendChild(em);
95    textCol.appendChild(name);
96
97    const tagline = div('r-tagline');
98    tagline.textContent = 'Making sense of things, one breakdown at a time.';
99    textCol.appendChild(tagline);
100
101    const bio = div('r-bio');
102    bio.appendChild(text('I do whatever needs to be done: research, code, architecture, management, counseling, to name a few. Breaking and rebuilding is the best way for me to learn. I care about impact, not titles. These days I\'m at '));
103    bio.appendChild(a('https://braid.tech', 'Braid Technologies', null));
104    bio.appendChild(text(', working on geometry generation and processing for manufacturing. Before that, I was the CTO at '));
105    bio.appendChild(a('https://corpy.co.jp', 'Corpy&Co.', null));
106    bio.appendChild(text(', where I shipped 50+ projects to clients while building an MLOps platform. Before that, I did a Ph.D. in Computational Geometry, which is now part of '));
107    bio.appendChild(a('https://www.cgal.org/2019/02/25/Hyperbolic_triangulations/', 'CGAL', null));
108    bio.appendChild(text('.'));
109    textCol.appendChild(bio);
110
111    const facts = div('r-facts');
112    const factData = [
113        ['based',     'Tokyo, Japan'],
114        ['origin',    'Bulgaria'],
115        ['languages', [
116            'Native (but rusty) in Greek and Bulgarian',
117            'Fluent in English and Italian',
118            'Confident in French',
119            'Hopeless in Japanese',
120            'Bonus minority language available upon request',
121        ]],
122        ['lived in',  'Bulgaria · Greece · Italy · France · Japan'],
123    ];
124    factData.forEach(([key, val]) => {
125        const row = div('r-fact-row');
126        row.appendChild(span('r-fact-key', key));
127        if (Array.isArray(val)) {
128            const list = el('ul', 'r-fact-list');
129            val.forEach(item => { const li = el('li'); li.textContent = item; list.appendChild(li); });
130            row.appendChild(list);
131        } else {
132            row.appendChild(span('r-fact-val', val));
133        }
134        facts.appendChild(row);
135    });
136    textCol.appendChild(facts);
137
138    const edu = div('r-edu');
139    const eduEntries = [
140        { degree: 'Ph.D. Computer Science',    school: 'Université de Lorraine', year: '2016 ~ 2018', thesis: 'Delaunay triangulations of hyperbolic surfaces',  thesisHref: 'docs/PhD-thesis.pdf' },
141        { degree: 'M.Sc. Applied Mathematics', school: 'University of Crete',    year: '2014 ~ 2015', thesis: 'Shape-Preserving Interpolation on the Sphere',    thesisHref: 'docs/MSc-thesis.pdf' },
142        { degree: 'B.Sc. Applied Mathematics', school: 'University of Crete',    year: '2005 ~ 2013', thesis: 'The Euclidean InSphere Predicate',                 thesisHref: 'docs/BSc-thesis.pdf',
143          note: 'Studies interrupted 2007 ~ 2009 for work in Italy' },
144    ];
145    eduEntries.forEach(e => {
146        const item = div('r-edu-item');
147        const top = div('r-edu-top');
148        top.appendChild(span('r-edu-degree', e.degree));
149        top.appendChild(span('r-edu-year', e.year));
150        item.appendChild(top);
151        const school = div('r-edu-school');
152        school.textContent = e.school;
153        item.appendChild(school);
154        if (e.note) { const note = div('r-edu-note'); note.textContent = e.note; item.appendChild(note); }
155        item.appendChild(a(e.thesisHref, '↗ ' + e.thesis, 'r-edu-thesis'));
156        edu.appendChild(item);
157    });
158    textCol.appendChild(edu);
159
160    layout.appendChild(textCol);
161    wrap.appendChild(layout);
162
163    const hint = div('back-sug');
164    hint.appendChild(text('→ '));
165    [['ls work/', 'ls work/'], ['ls projects/', 'ls projects/']].forEach(([label, cmd]) => {
166        const btn = document.createElement('button');
167        btn.textContent = label;
168        btn.addEventListener('click', () => typeCommand(cmd));
169        hint.appendChild(btn);
170        hint.appendChild(text('  '));
171    });
172    wrap.appendChild(hint);
173
174    result.appendChild(wrap);
175    scrollBottom();
176}
177
178function cmdLsWork(result) {
179    const wrap = div('r-ls');
180    const header = div('ls-header');
181    header.textContent = 'PERIOD          ORG                NAME                  DURATION';
182    wrap.appendChild(header);
183
184    const rows = WORK.map(w => {
185        const months = parsePeriod(w.period);
186        const row = div('ls-entry');
187        row.setAttribute('role', 'button');
188        row.setAttribute('tabindex', '0');
189        row.setAttribute('aria-label', `${w.org}, ${w.name}, ${w.period}`);
190        row.appendChild(span('ls-period', w.period));
191        row.appendChild(span('ls-org', w.org));
192        row.appendChild(span('ls-name', w.name));
193        const badges = div('ls-fields');
194        (w.fields || []).forEach(f => badges.appendChild(span('ls-field-badge', f)));
195        row.appendChild(badges);
196        row.appendChild(span('ls-duration', months ? formatDuration(months) : ''));
197        const activate = () => typeCommand('cat ' + w.id);
198        row.addEventListener('click', activate);
199        row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } });
200        return row;
201    });
202
203    const fieldTotals = {};
204    WORK.forEach(w => {
205        const fs = w.fields?.length ? w.fields : ['Other'];
206        const m  = parsePeriod(w.period) || 0;
207        fs.forEach(f => { fieldTotals[f] = (fieldTotals[f] || 0) + m; });
208    });
209    const fieldOrder = ['Software Dev', 'Research', 'AI / ML', 'Management', 'Other'];
210    const totalsRow = div('ls-field-totals');
211    fieldOrder.forEach(f => {
212        if (!fieldTotals[f]) return;
213        const item = span('ls-field-total-item', '');
214        item.appendChild(span('ls-field-label', f));
215        item.appendChild(span('ls-field-val', formatDuration(fieldTotals[f])));
216        totalsRow.appendChild(item);
217    });
218
219    revealLines(wrap, rows, 60, 50);
220    setTimeout(() => {
221        wrap.appendChild(totalsRow);
222        totalsRow.style.opacity = '0';
223        totalsRow.style.transition = 'opacity 0.4s ease';
224        setTimeout(() => { totalsRow.style.opacity = '1'; }, 60 + rows.length * 50 + 100);
225    }, 0);
226    result.appendChild(wrap);
227    scrollBottom();
228}
229
230function cmdLsProjects(result) {
231    const wrap = div('r-ls');
232    const header = div('ls-header');
233    header.textContent = 'PERIOD          ORG                NAME';
234    wrap.appendChild(header);
235
236    const rows = PROJECTS.map(p => {
237        const row = div('ls-entry');
238        row.setAttribute('role', 'button');
239        row.setAttribute('tabindex', '0');
240        row.setAttribute('aria-label', `${p.org}, ${p.name}, ${p.period}`);
241        row.appendChild(span('ls-period', p.period));
242        row.appendChild(span('ls-org', p.org));
243        row.appendChild(span('ls-name', p.name));
244        const activate = () => typeCommand('cat ' + p.id);
245        row.addEventListener('click', activate);
246        row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } });
247        return row;
248    });
249
250    revealLines(wrap, rows, 60, 50);
251    result.appendChild(wrap);
252    scrollBottom();
253}
254
255function cmdCat(id, result) {
256    const entry = [...WORK, ...PROJECTS].find(e => e.id === id);
257    if (!entry) {
258        const err = div('r-error');
259        err.textContent = 'cat: ' + id + ': no such file';
260        result.appendChild(err);
261        scrollBottom();
262        return;
263    }
264
265    const wrap   = div('r-cat');
266    const header = div('r-cat-header');
267    const role   = div('r-cat-role');
268    role.textContent = entry.role;
269    header.appendChild(role);
270
271    const org = div('r-cat-org');
272    if (entry.orgUrl) org.appendChild(a(entry.orgUrl, entry.org, null));
273    else org.textContent = entry.org;
274    header.appendChild(org);
275
276    const isWork = WORK.some(w => w.id === id);
277    const months = isWork ? parsePeriod(entry.period) : null;
278    const meta   = div('r-cat-meta');
279    meta.textContent = entry.meta + ' · ' + entry.period + (months ? ' · ' + formatDuration(months) : '');
280    header.appendChild(meta);
281    wrap.appendChild(header);
282
283    const body = div('r-cat-body');
284    body.textContent = entry.body;
285    wrap.appendChild(body);
286
287    const tags = div('r-cat-tags');
288    entry.tags.forEach(t => tags.appendChild(span('r-cat-tag', t)));
289    wrap.appendChild(tags);
290
291    let links = null;
292    if (entry.links?.length) {
293        links = div('r-cat-links');
294        entry.links.forEach(l => links.appendChild(a(l.href, l.label, 'out-link')));
295        wrap.appendChild(links);
296    }
297
298    const backCmd = PROJECTS.some(p => p.id === id) ? 'ls projects/' : 'ls work/';
299    const hint = div('back-sug');
300    hint.appendChild(text('← '));
301    const btn = document.createElement('button');
302    btn.textContent = backCmd;
303    btn.addEventListener('click', () => typeCommand(backCmd));
304    hint.appendChild(btn);
305    wrap.appendChild(hint);
306
307    const children = [header, body, tags, ...(links ? [links] : []), hint];
308    children.forEach(c => {
309        c.style.opacity = '0';
310        c.style.transform = 'translateY(6px)';
311        c.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
312    });
313    result.appendChild(wrap);
314    children.forEach((c, i) => setTimeout(() => { c.style.opacity = '1'; c.style.transform = 'translateY(0)'; }, 80 + i * 70));
315    scrollBottom();
316}
317
318function cmdWget(result) {
319    const wrap   = div('r-writing');
320    const status = div('r-wget-status');
321    [
322        'Connecting to imiordanov.substack.com...',
323        'HTTP request sent, awaiting response... 200 OK',
324        'Length: unspecified [text/html]',
325        'Saving to: oni-no-pantsu.html',
326    ].forEach(l => { const line = div('r-wget-line'); line.textContent = l; status.appendChild(line); });
327    wrap.appendChild(status);
328
329    const titleLink = a('https://imiordanov.substack.com', '', null);
330    titleLink.style.textDecoration = 'none';
331    const jp = div('r-jp'); jp.textContent = '鬼のパンツ'; titleLink.appendChild(jp);
332    const romaji = div('r-romaji'); romaji.textContent = 'oni no pantsu · the demon\'s pants'; titleLink.appendChild(romaji);
333    wrap.appendChild(titleLink);
334
335    const quote = div('r-writing-quote');
336    quote.textContent = 'The personal journal of a foreigner in Japan. Fatherhood, career, cultural shocks, mental health (and lack thereof) -- I\'ve got it all covered. Come along and laugh at my struggles.';
337    wrap.appendChild(quote);
338
339    const body = div('r-writing-body');
340    body.textContent = 'I write on Substack about the things that don\'t fit anywhere else.';
341    wrap.appendChild(body);
342
343    wrap.appendChild(a('https://imiordanov.substack.com', '↗ imiordanov.substack.com', 'out-link amber'));
344    result.appendChild(wrap);
345    scrollBottom();
346}
347
348function cmdGrep(query, result) {
349    const wrap = div('r-grep');
350    const matches = query
351        ? PUBLICATIONS.filter(p => (p.title + ' ' + p.authors + ' ' + p.venue).toLowerCase().includes(query.toLowerCase()))
352        : PUBLICATIONS;
353
354    if (!matches.length) {
355        const err = div('r-error');
356        err.textContent = 'grep: no matches found for "' + query + '"';
357        result.appendChild(err);
358        scrollBottom();
359        return;
360    }
361
362    const header = div('r-grep-header');
363    header.textContent = query
364        ? `publications.bib:${matches.length} match${matches.length > 1 ? 'es' : ''} for "${query}"`
365        : `publications.bib:${matches.length} entries`;
366    wrap.appendChild(header);
367
368    const rows = matches.map(p => {
369        const row  = div('r-grep-row');
370        const meta = div('r-grep-meta');
371        meta.appendChild(span('r-grep-ref', p.ref));
372        row.appendChild(meta);
373        const titleEl = a(p.href, p.title, 'r-grep-title');
374        titleEl.target = '_blank'; titleEl.rel = 'noopener noreferrer';
375        row.appendChild(titleEl);
376        const authors = div('r-grep-authors'); authors.textContent = p.authors; row.appendChild(authors);
377        const venue   = div('r-grep-venue');   venue.textContent   = p.venue;   row.appendChild(venue);
378        if (p.note) { const note = div('r-grep-note'); note.textContent = p.note; row.appendChild(note); }
379        return row;
380    });
381
382    revealLines(wrap, rows, 60, 70);
383    result.appendChild(wrap);
384    scrollBottom();
385}
386
387function cmdMan(result) {
388    const wrap = div('r-man');
389    const sections = [
390        { key: 'NAME',        val: 'iordan — researcher, builder, chronic overthinker' },
391        { key: 'SYNOPSIS',    val: 'iordan [--geometry] [--ai] [--software] [--research]' },
392        { key: 'DESCRIPTION', val: 'Researcher at Braid Technologies, currently working on geometry generation and design automation for manufacturing. Former CTO at Corpy&Co., scaling a team from 10 to 70 engineers and delivering over 50 client-facing projects. Ph.D. in Computational Geometry from Université de Lorraine, France. Contributor to CGAL, the largest open-source library for computational geometry.' },
393        { key: 'HISTORY',     val: 'Born in Bulgaria, of Linguri/Rudari heritage. Spent 13 years in Greece, then 3 years in Italy, then 3 in France, and moved to Japan in 2019. Became a husband in the times of COVID. Became a father of a very energetic boy in 2024.' },
394        { key: 'LANGUAGES',   val: 'Bulgarian (native), Greek (native), English (fluent), Italian (fluent), French (advanced), Linguri (native), Japanese (currently faking it, not sure I\'ll ever make it)' },
395        { key: 'BUGS',        val: 'Anxiety. Opinionated idealism. Dad jokes. No music taste.' },
396        { key: 'SEE ALSO',    val: 'whoami(1), ping(8), grep(1), ls(1), wget(1)' },
397    ];
398    const els = sections.map(s => {
399        const item = div('r-man-section');
400        item.appendChild(span('r-man-key', s.key));
401        const val = div('r-man-val'); val.textContent = s.val; item.appendChild(val);
402        return item;
403    });
404    revealLines(wrap, els, 60, 60);
405    result.appendChild(wrap);
406    scrollBottom();
407}
408
409function cmdPing(result) {
410    const wrap   = div('r-ping');
411    const header = div('r-ping-header');
412    header.textContent = 'PING iordan (imiordanov.github.io): current location & contact';
413    wrap.appendChild(header);
414
415    const rows = [
416        { key: 'location',  val: 'Tokyo, Japan' },
417        { key: 'role',      val: 'Researcher @ Braid Technologies' },
418        { key: 'status',    val: 'open to interesting conversations' },
419        { key: 'email',     val: '[email protected]',     href: 'mailto:[email protected]' },
420        { key: 'email alt', val: '[email protected]',          href: 'mailto:[email protected]' },
421        { key: 'linkedin',  val: 'linkedin.com/in/imiordanov', href: 'https://www.linkedin.com/in/imiordanov/' },
422        { key: 'github',    val: 'github.com/imiordanov',      href: 'https://github.com/imiordanov' },
423        { key: 'substack',  val: 'imiordanov.substack.com',    href: 'https://imiordanov.substack.com' },
424    ];
425    const rowEls = rows.map(r => {
426        const row = div('r-ping-row');
427        row.appendChild(span('r-ping-key', r.key));
428        if (r.href) {
429            const lnk = a(r.href, r.val, null);
430            if (!r.href.startsWith('mailto:')) { lnk.target = '_blank'; lnk.rel = 'noopener noreferrer'; }
431            row.appendChild(lnk);
432        } else {
433            row.appendChild(span('r-ping-val', r.val));
434        }
435        return row;
436    });
437    revealLines(wrap, rowEls, 60, 80);
438    result.appendChild(wrap);
439    scrollBottom();
440}
441
442function cmdLsExtras(result) {
443    const wrap = div('r-ls');
444    const header = div('ls-header');
445    header.textContent = 'NAME             DESCRIPTION';
446    wrap.appendChild(header);
447
448    const rows = EXTRAS.map(e => {
449        const row = div('ls-entry');
450        row.style.gridTemplateColumns = '160px 1fr';
451        row.setAttribute('role', 'button');
452        row.setAttribute('tabindex', '0');
453        row.setAttribute('aria-label', `${e.name}, ${e.desc}`);
454        row.appendChild(span('ls-name', e.name));
455        row.appendChild(span('ls-org', e.desc));
456        const activate = () => typeCommand('open ' + e.id);
457        row.addEventListener('click', activate);
458        row.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); activate(); } });
459        return row;
460    });
461    revealLines(wrap, rows, 60, 50);
462    result.appendChild(wrap);
463    scrollBottom();
464}
465
466function cmdOpen(id, result) {
467    const extra = EXTRAS.find(e => e.id === id);
468    if (!extra) {
469        const err = div('r-error');
470        err.textContent = 'open: ' + id + ': no such file — try ls extras/';
471        result.appendChild(err);
472        scrollBottom();
473        return;
474    }
475    const wrap = div('r-contact');
476    const row  = div('r-contact-line');
477    row.appendChild(span('ck', 'opening'));
478    const lnk = a(extra.href, extra.name, null);
479    lnk.target = '_blank'; lnk.rel = 'noopener noreferrer';
480    row.appendChild(lnk);
481    wrap.appendChild(row);
482    result.appendChild(wrap);
483    setTimeout(() => window.open(extra.href, '_blank'), 600);
484    scrollBottom();
485}
486
487function cmdCv(result) {
488    const wrap = div('r-contact');
489    const row  = div('r-contact-line');
490    row.appendChild(span('ck', 'cv'));
491    const lnk = a('docs/cv-iordanov.pdf', 'cv-iordanov.pdf', null);
492    lnk.target = '_blank';
493    row.appendChild(lnk);
494    wrap.appendChild(row);
495    const note = div('back-sug');
496    note.style.marginTop = '14px';
497    note.textContent = 'or try: ls work/ for an interactive version';
498    wrap.appendChild(note);
499    result.appendChild(wrap);
500    scrollBottom();
501}
502
503function cmdHelp(result) {
504    const wrap = div('r-help');
505    const cmds = [
506        ['whoami',       'brief identity'],
507        ['man iordan',   'full manual page'],
508        ['ls work/',     'work history'],
509        ['ls projects/', 'research & side projects'],
510        ['ls extras/',   'other things worth your time'],
511        ['cat <id>',     'read a work or project entry (click any row, or type the id)'],
512        ['open <id>',    'open an extra — try: open game-of-life'],
513        ['wget writing', 'fetch the Substack journal'],
514        ['grep <query>', 'search publications (or grep alone for all)'],
515        ['ping iordan',  'current location, role & contact'],
516        ['cv',           'download curriculum vitae'],
517        ['clear',        'clear the terminal'],
518        ['help',         'show this message'],
519    ];
520    const rows = cmds.map(([cmd, desc]) => {
521        const row = div('r-help-row');
522        row.appendChild(span('r-help-cmd', cmd));
523        row.appendChild(span('r-help-desc', desc));
524        return row;
525    });
526    revealLines(wrap, rows, 40, 40);
527    result.appendChild(wrap);
528    scrollBottom();
529}
530
531function cmdClear() {
532    while (output.firstChild) output.removeChild(output.firstChild);
533}
534
535function cmdUnknown(cmd, result) {
536    const err = div('r-error');
537    err.textContent = 'command not found: ' + cmd + ' — type help for available commands';
538    result.appendChild(err);
539    scrollBottom();
540}
541
542/* ── Router ── */
543
544function route(raw) {
545    const cmd   = raw.trim();
546    const lower = cmd.toLowerCase();
547    const result = echoCmd(cmd);
548
549    if (lower === 'whoami')                                  return cmdWhoami(result);
550    if (lower === 'man iordan' || lower === 'man')           return cmdMan(result);
551    if (lower === 'ping iordan' || lower === 'ping')         return cmdPing(result);
552    if (lower === 'ls work/' || lower === 'ls work')         return cmdLsWork(result);
553    if (lower === 'ls projects/' || lower === 'ls projects') return cmdLsProjects(result);
554    if (lower === 'ls extras/' || lower === 'ls extras')     return cmdLsExtras(result);
555    if (lower === 'ls')                                      return cmdLsWork(result);
556    if (lower === 'wget writing' || lower === 'wget')        return cmdWget(result);
557    if (lower === 'cv')                                      return cmdCv(result);
558    if (lower === 'help')                                    return cmdHelp(result);
559    if (lower === 'clear')                                   return cmdClear();
560    if (lower.startsWith('grep')) {
561        const query = cmd.slice(4).trim();
562        if (!query) {
563            const typedEl = result.previousElementSibling?.querySelector('.typed');
564            if (typedEl) typedEl.textContent = 'grep . publications.bib';
565        }
566        return cmdGrep(query, result);
567    }
568    if (lower.startsWith('open ')) return cmdOpen(lower.slice(5).trim(), result);
569    if (lower.startsWith('cat '))  return cmdCat(lower.slice(4).trim(), result);
570    cmdUnknown(cmd, result);
571}
572
573/* ── Typewriter effect ── */
574
575function typeCommand(cmd) {
576    if (busy) return;
577    busy = true;
578    realInput.blur();
579    promptText.textContent = '';
580    let i = 0;
581    function typeChar() {
582        if (i < cmd.length) {
583            promptText.textContent += cmd[i++];
584            setTimeout(typeChar, 38 + Math.random() * 30);
585        } else {
586            setTimeout(() => {
587                promptText.textContent = '';
588                cmdHistory.unshift(cmd);
589                histIdx = -1;
590                route(cmd);
591                busy = false;
592                realInput.focus();
593            }, 180);
594        }
595    }
596    typeChar();
597}
598
599/* ── Keyboard input ── */
600
601realInput.addEventListener('keydown', e => {
602    if (busy) { e.preventDefault(); return; }
603
604    if (e.key === 'Enter') {
605        const val = realInput.value.trim();
606        realInput.value = '';
607        promptText.textContent = '';
608        if (!val) return;
609        cmdHistory.unshift(val);
610        histIdx = -1;
611        route(val);
612        return;
613    }
614
615    if (e.key === 'ArrowUp') {
616        e.preventDefault();
617        if (histIdx < cmdHistory.length - 1) {
618            histIdx++;
619            realInput.value = promptText.textContent = cmdHistory[histIdx];
620        }
621        return;
622    }
623
624    if (e.key === 'ArrowDown') {
625        e.preventDefault();
626        if (histIdx > 0) {
627            histIdx--;
628            realInput.value = promptText.textContent = cmdHistory[histIdx];
629        } else {
630            histIdx = -1;
631            realInput.value = promptText.textContent = '';
632        }
633        return;
634    }
635
636    if (e.key === 'Tab') {
637        e.preventDefault();
638        const val = realInput.value.toLowerCase();
639        const allCmds = [
640            'whoami', 'man iordan', 'ping iordan',
641            'ls work/', 'ls projects/', 'ls extras/',
642            'wget writing', 'grep', 'cv', 'help', 'clear',
643            ...[...WORK, ...PROJECTS].map(e => 'cat ' + e.id),
644            ...EXTRAS.map(e => 'open ' + e.id),
645        ];
646        const match = allCmds.find(c => c.startsWith(val) && c !== val);
647        if (match) realInput.value = promptText.textContent = match;
648        return;
649    }
650});
651
652realInput.addEventListener('input', () => { promptText.textContent = realInput.value; });
653
654/* ── Suggestion pills ── */
655
656document.querySelectorAll('.sug').forEach(btn => {
657    btn.addEventListener('click', () => { if (btn.dataset.cmd) typeCommand(btn.dataset.cmd); });
658});
659
660/* ── Click anywhere to focus input ── */
661
662document.addEventListener('click', e => {
663    if (!e.target.closest('a') && !e.target.closest('button') && !e.target.closest('.ls-entry')) {
664        realInput.focus();
665    }
666});
667
668/* ── Boot: auto-run whoami ── */
669
670setTimeout(() => { realInput.focus(); typeCommand('whoami'); }, 700);

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.