`;
}
function refreshTreeRow(id){
const s = state[id];
const row = $(`.test[data-id="${id}"]`);
if (!row) return;
row.classList.toggle('is-running', s.status==='running');
row.classList.toggle('is-passed', s.status==='passed');
row.classList.toggle('is-failed', s.status==='failed');
row.classList.toggle('is-skipped', s.status==='skipped');
const iconEl = row.querySelector('.test__icon');
iconEl.className = `test__icon ${statusClass(s.status)}`;
iconEl.innerHTML = statusIcon(s.status);
row.querySelector('.test__dur').textContent = s.runtime?formatMs(s.runtime):'';
}
// ============ TAG BAR ============
// Show the first VISIBLE_TAGS by default; the rest hide behind a "+N more"
// chip that expands them inline on click. Keeps the sidebar visual mass
// small until a visitor needs the full registry.
const VISIBLE_TAGS = 6;
const activeTags = new Set();
function renderTagBar(){
const bar = $('#tag-bar');
const all = Array.isArray(data.tags) ? data.tags : [];
const hidden = Math.max(0, all.length - VISIBLE_TAGS);
const tagHTML = all.map((t, i) =>
`${t}`
).join('');
const moreHTML = hidden > 0
? ``
: '';
const clearHTML = ``;
bar.innerHTML = tagHTML + moreHTML + clearHTML;
bar.addEventListener('click', e=>{
const clr = e.target.closest('[data-clear]');
if (clr) { clearTagFilter(); return; }
const more = e.target.closest('[data-more]');
if (more) {
bar.classList.add('is-expanded');
more.setAttribute('aria-expanded', 'true');
return;
}
const el = e.target.closest('.tag'); if (!el) return;
const tag = el.dataset.tag;
if (activeTags.has(tag)) activeTags.delete(tag);
else activeTags.add(tag);
syncTagBarUI();
});
}
function syncTagBarUI(){
const bar = $('#tag-bar');
bar.querySelectorAll('.tag').forEach(t=>{
t.classList.toggle('is-active', activeTags.has(t.dataset.tag));
});
bar.classList.toggle('has-active', activeTags.size > 0);
$('#grep').value = Array.from(activeTags).join(' ');
applyFilter();
}
function clearTagFilter(){
activeTags.clear();
syncTagBarUI();
}
function applyFilter(){
const grep = $('#grep').value.trim().toLowerCase();
const wantedTags = grep.split(/\s+/).filter(s=>s.startsWith('@'));
const text = grep.replace(/@\S+/g,'').trim();
const hasFilter = wantedTags.length > 0 || text.length > 0;
// Sidebar tree — filter per-suite so we can hide entire describe blocks
// when none of their tests match (prevents the awkward "open caret but no
// rows" look that reads as a broken collapsed state).
$$('[data-suite]').forEach(suite => {
let anyVisible = false;
suite.querySelectorAll('.test').forEach(row => {
const id = row.dataset.id;
const t = tests.find(x => x.id === id);
if (!t) { row.style.display = 'none'; return; }
const tagsOK = wantedTags.length === 0 || wantedTags.every(tg => t.tags.includes(tg));
const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text);
const visible = tagsOK && textOK;
row.style.display = visible ? '' : 'none';
if (visible) anyVisible = true;
});
// No filter → always show all suites. With a filter → hide suites that
// have zero matching tests; auto-expand suites that do match.
suite.style.display = (!hasFilter || anyVisible) ? '' : 'none';
if (hasFilter && anyVisible) suite.classList.remove('collapsed');
});
// Right-pane results — only active spec is visible. The editor tab is
// the "open file"; we render its body, not the rest of the project.
$$('.result').forEach(art => {
const id = art.dataset.id;
const t = tests.find(x => x.id === id);
art.style.display = (t && t.spec === activeSpec) ? '' : 'none';
});
}
// ============ MAIN PANE RESULTS ============
// Returns the id of the first idle test for the active spec — the one we
// render as a "pending preview" so the page never looks empty before a run.
function firstIdleId(){
const t = tests.find(x => x.spec === activeSpec && state[x.id].status === 'idle' && !x.skip && !x.fail);
return t ? t.id : null;
}
// Pre-render the body for the pending-preview test: steps + the section's
// own render(). Wrapped with an overlay so it reads as "preview, not result".
function renderPendingBody(t){
const stepsHTML = t.steps.map(st => `
${t.render()}`;
art.dataset.populated = '1';
art.classList.remove('is-pending'); // un-fade — real result is in.
art.classList.add('is-open');
// Animate skill bars if present
$$('.skill__fill', body).forEach(el => requestAnimationFrame(()=> el.style.width = el.dataset.pct + '%'));
// Hook resume buttons
const dl = $('#dl-resume', body); if (dl) dl.addEventListener('click', downloadResume);
const pr = $('#print-resume', body); if (pr) pr.addEventListener('click', ()=> window.open(RESUME_PDF, '_blank', 'noopener,noreferrer'));
}
}
// ============ RUN ENGINE ============
function selectTest(id, scroll){
$$('.test').forEach(r=>r.classList.toggle('is-selected', r.dataset.id===id));
if (scroll) {
const el = $(`#result-${id}`);
if (el) el.scrollIntoView({ behavior:'smooth', block:'start' });
}
}
async function runTest(id){
const t = tests.find(x=>x.id===id);
if (!t) return;
const s = state[id];
if (s.status === 'running') return;
if (t.skip) { consoleLine('warn', `⊘ test('${t.title}') skipped`); return; }
if (t.fail) { consoleLine('err', `✗ test('${t.title}') failed — ${t.failMessage ? t.failMessage.split('\n')[0] : 'assertion error'}`); return; }
// If this row was sitting in the pending-preview state, lift the overlay
// but keep the faded preview body visible through the run — that way the
// report never blanks mid-animation. The "is-pending" class stays on
// (so the body stays greyed) until refreshResultRow swaps in the real
// result on completion.
const art = $(`#result-${id}`);
if (art && art.dataset.pending) {
art.removeAttribute('data-pending');
const overlay = art.querySelector('.result__pending-overlay');
if (overlay) overlay.remove();
art.dataset.populated = '';
}
// reset
s.status = 'running'; s.runtime = 0;
refreshTreeRow(id); refreshResultRow(id); updateStatusbar();
selectTest(id, true);
consoleLine('info', `▶ running test('${t.title}')`);
const dur = headed() ? t.duration * 2.5 : t.duration;
const start = performance.now();
const bar = $(`#bar-${id}`);
bar.style.width = '0%';
await tween(dur, (p)=>{ bar.style.width = (p*100) + '%'; });
const elapsed = Math.round(performance.now() - start);
s.runtime = elapsed; s.status = 'passed';
bar.style.width = '100%';
setTimeout(()=>{ bar.style.transition='opacity .4s'; bar.style.opacity='0'; }, 200);
// Clear populated flag so it re-renders fresh on re-run
if (art) art.dataset.populated = '';
refreshTreeRow(id); refreshResultRow(id); updateStatusbar();
consoleLine('ok', `✓ test('${t.title}') passed in ${formatMs(elapsed)}`);
}
async function runAll(){
if (isRunningAll) return;
isRunningAll = true;
$('#run-all').disabled = true;
$('#stop-all').disabled = false;
// Build the queue from the active spec, in suite order. Skip tests
// explicitly marked skip, and respect grep/tag filters via row display.
const queue = tests.filter(t => {
if (t.spec !== activeSpec) return false;
if (t.skip || t.fail) return false;
const row = $(`.test[data-id="${t.id}"]`);
return !row || row.style.display !== 'none';
});
// Reset every queued test up front so all rows are visibly idle before
// workers fan out — otherwise the first row would briefly look special.
for (const t of queue) {
state[t.id].status = 'idle';
state[t.id].runtime = 0;
const art = $(`#result-${t.id}`);
if (art) {
art.dataset.populated = '';
const b = $(`#bar-${t.id}`);
if (b) { b.style.width = '0%'; b.style.opacity = '1'; b.style.transition = ''; }
}
refreshTreeRow(t.id);
refreshResultRow(t.id);
}
updateStatusbar();
const N = Math.min(getWorkers(), queue.length || 1);
const sf = getSpec(activeSpec).file;
consoleLine('info', `▶ playwright test ${sf} (workers=${N})`);
let cursor = 0;
const startedAt = performance.now();
async function worker(){
while (isRunningAll) {
const next = queue[cursor++];
if (!next) return;
await runTest(next.id);
}
}
await Promise.all(Array.from({ length: N }, worker));
isRunningAll = false;
$('#run-all').disabled = false;
$('#stop-all').disabled = true;
const wall = Math.round(performance.now() - startedAt);
const cpu = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0);
const speedup = cpu > 0 ? (cpu / Math.max(1, wall)).toFixed(2) : '1.00';
const tail = N > 1 ? ` · ${formatMs(wall)} wall, ${formatMs(cpu)} cpu (${speedup}× speedup)`
: ` in ${formatMs(wall)}`;
consoleLine('ok', `Test suite finished — ${countPassed()} passed${tail}`);
}
function resetAll(){
isRunningAll = false;
for (const t of tests) {
state[t.id] = { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 };
const art = $(`#result-${t.id}`);
if (art) {
art.dataset.populated = '';
art.classList.remove('is-open');
art.classList.remove('is-pending');
art.removeAttribute('data-pending');
const body = $(`#body-${t.id}`); if (body) body.innerHTML='';
const b = $(`#bar-${t.id}`); if (b){ b.style.width='0%'; b.style.opacity='1'; b.style.transition=''; }
}
refreshTreeRow(t.id); refreshResultRow(t.id);
}
$('#console').innerHTML = '';
// After reset, the first test in the active spec is "pending" again —
// restore the preview so the report doesn't land empty.
refreshPendingPreview();
updateStatusbar();
consoleLine('info', 'reset · all tests returned to idle');
}
// ============ STATUS / COUNTS ============
function countPassed(){ return Object.values(state).filter(s=>s.status==='passed').length; }
function updateStatusbar(){
// Counts and runtime are scoped to the active spec — matches what the
// user is currently viewing, not the global pool of all specs.
const specTests = tests.filter(t => t.spec === activeSpec);
const passed = specTests.filter(t => state[t.id].status === 'passed').length;
const failed = specTests.filter(t => state[t.id].status === 'failed').length;
const running = specTests.filter(t => state[t.id].status === 'running').length;
const skipped = specTests.filter(t => state[t.id].status === 'skipped').length;
const xfailed = specTests.filter(t => t.fail).length;
const surprise = failed - xfailed;
const runnableCount = specTests.length - skipped - xfailed;
$('#cnt-pass').textContent = passed;
$('#cnt-fail').textContent = surprise;
$('#cnt-skip').textContent = skipped;
const dot = $('#status-dot'); const txt = $('#status-text');
if (running) { dot.className='dot dot--running'; txt.textContent = 'running'; }
else if (surprise > 0) { dot.className='dot dot--fail'; txt.textContent = `${surprise} failed`; }
else if (runnableCount > 0 && passed === runnableCount) { dot.className='dot dot--pass'; txt.textContent='all passed'; }
else if (passed > 0) { dot.className='dot dot--pass'; txt.textContent='partial'; }
else { dot.className='dot dot--idle'; txt.textContent='idle'; }
const total = specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0);
$('#sb-runtime').textContent = `runtime: ${formatMs(total)}`;
updateSummaryStripe(specTests, { passed, failed, running, skipped, total });
}
// Tiny run-history stripe above the results list. We aggregate the current
// (last) wall time + counts for the active spec so it reads naturally as
// either "Running… ✓ 1 passed · 3 pending" or "Last run · 1.4s · 4 passed".
function updateSummaryStripe(specTests, agg){
const el = $('#summary-stripe');
if (!el) return;
specTests = specTests || tests.filter(t => t.spec === activeSpec);
const passed = agg ? agg.passed : specTests.filter(t => state[t.id].status === 'passed').length;
const failed = agg ? (agg.failed || 0) : specTests.filter(t => state[t.id].status === 'failed').length;
const running = agg ? agg.running : specTests.filter(t => state[t.id].status === 'running').length;
const skipped = agg ? (agg.skipped || 0) : specTests.filter(t => state[t.id].status === 'skipped').length;
const total = agg ? agg.total : specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0);
const pending = specTests.length - passed - failed - running - skipped;
let lbl, dotCls;
if (running) { lbl = 'Running'; dotCls = 'ss__dot--running'; }
else if (failed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--fail'; }
else if (passed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--pass'; }
else { lbl = 'No runs yet'; dotCls = 'ss__dot--pending'; }
const parts = [
`${lbl}`,
];
if (total > 0) parts.push(`·${formatMs(total)}`);
parts.push(`·${passed} passed`);
if (failed > 0) parts.push(`·${failed} failed`);
if (running > 0) parts.push(`·${running} running`);
if (pending > 0) parts.push(`·${pending} pending`);
if (skipped > 0) parts.push(`·${skipped} skipped`);
el.innerHTML = parts.join('');
}
// ============ CONSOLE ============
function consoleLine(lvl, msg){
const el = $('#console');
const ts = new Date().toLocaleTimeString('en-CA', { hour12:false });
const line = document.createElement('div');
line.className = 'console__line';
line.innerHTML = `${ts}${lvl.toUpperCase()}${msg}`;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
// ============ TABS ============
function initTabs(){
$$('.tab').forEach(tab=>{
tab.addEventListener('click', ()=>{
const id = tab.dataset.tab;
$$('.tab').forEach(t=>t.classList.toggle('is-active', t===tab));
$$('.pane').forEach(p=>p.classList.toggle('is-active', p.id === `pane-${id}`));
if (id === 'network') {
requestAnimationFrame(()=>renderNetwork());
}
});
});
}
// ============ TRACE PANE ============
function renderTrace(){
// Build a career timeline: each role gets a bar relative to a global timeline
const trace = $('#trace');
const items = data.experience.map(e => {
const m = e.when.match(/(\w+\s+\d{4})\s*[–-]\s*(\w+\s+\d{4})/i);
if (!m) return null;
return { ...e, start: parseMon(m[1]), end: parseMon(m[2]) };
}).filter(Boolean);
const min = Math.min(...items.map(i=>i.start.getTime()));
const max = Math.max(...items.map(i=>i.end.getTime()));
const span = max - min;
trace.innerHTML = items.map(it=>{
const left = ((it.start - min) / span) * 100;
const width = Math.max(2, ((it.end - it.start) / span) * 100);
const months = Math.round((it.end - it.start)/(1000*60*60*24*30));
return `
${_esc(it.company)} · ${_esc(it.role)}
${months}mo
`;
}).join('');
// Axis ticks: years from min to max
const axis = $('#trace-axis');
const yStart = new Date(min).getFullYear();
const yEnd = new Date(max).getFullYear();
const years = [];
for (let y=yStart; y<=yEnd; y+=Math.max(1, Math.ceil((yEnd-yStart)/8))) years.push(y);
if (years[years.length-1] !== yEnd) years.push(yEnd);
axis.innerHTML = `
${years.map(y=>`${y}`).join('')}
`;
}
function parseMon(s){
const [mon,yr] = s.split(/\s+/);
const idx = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']
.indexOf(mon.slice(0,3).toLowerCase());
return new Date(parseInt(yr), idx<0?0:idx, 1);
}
function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// ============ NETWORK PANE (Gitea repos as API rows) ============
function networkLatencyMs(name){
let h = 2166136261;
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return 38 + (Math.abs(h) % 145);
}
function formatNetworkBytes(n){
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function utf8ByteLength(str){
if (typeof TextEncoder !== 'undefined') {
try { return new TextEncoder().encode(str).length; } catch { /* fall through */ }
}
try { return encodeURIComponent(str).replace(/%../g, 'x').length; }
catch { return str.length * 4; }
}
/** Resolve list mount (handles older HTML ids + creates a node under `.network-pane` if needed). */
function networkMount(){
let el = document.getElementById('gitea-network');
if (el) return el;
el = document.querySelector('#pane-network [data-repo-list]');
if (el) return el;
el = document.querySelector('#pane-network #network'); // legacy id
if (el) return el;
el = document.querySelector('#pane-network .network-scroll');
if (el) return el;
const pn = document.getElementById('pane-network');
if (!pn) return null;
const host = pn.querySelector('.network-pane') || pn;
el = document.createElement('div');
el.id = 'gitea-network';
el.className = 'network-scroll';
el.setAttribute('data-network-list', '1');
const hdr = host.querySelector('.network-head');
if (hdr) hdr.insertAdjacentElement('afterend', el);
else host.appendChild(el);
console.warn('portfolio: created #gitea-network — update deployed index.html to include this div.');
return el;
}
/** Expand/collapse (delegated on #pane-network; survives innerHTML swaps). */
function onNetworkPaneClick(e){
const btn = e.target.closest('.network-row');
if (!btn) return;
const art = btn.closest('.network-entry');
if (!art) return;
const panel = art.querySelector('.network-detail');
if (!panel) return;
const open = !art.classList.contains('is-open');
art.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
panel.hidden = !open;
}
function renderNetwork(){
const wrap = networkMount();
if (!wrap) {
console.warn('portfolio: Network tab missing #pane-network · cannot render repo list.');
return;
}
const GF = window.PORTFOLIO;
const repos = GF && Array.isArray(GF.giteaRepos) ? GF.giteaRepos : [];
const apiRoot = 'https://git.levkin.ca/api/v1/repos/';
if (repos.length === 0) {
wrap.innerHTML = `
No giteaRepos in js/data.js. Hard refresh the page (Cmd+Shift+R). If deploying, redeploy after adding the array.
`).join('');
}
// ============ RESUME DOWNLOAD ============
function downloadResume(){
const a = document.createElement('a');
a.href = RESUME_PDF;
a.download = 'Ilia-Dobkin-SDET-resume.pdf';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
consoleLine('ok', '⇩ resume.pdf downloaded');
}
// ============ THEME TOGGLE ============
// Three-step cycle: dark → light → hc (WCAG AAA high-contrast) → dark.
// Persist via cookie because sandboxed iframes block other storage APIs.
const THEME_CYCLE = ['dark', 'light', 'hc'];
function readThemeCookie(){
const m = document.cookie.match(/(?:^|;\s*)theme=(dark|light|hc)/);
return m ? m[1] : null;
}
function writeThemeCookie(v){
try { document.cookie = `theme=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch { /* cookies disabled */ }
}
function initTheme(){
const saved = readThemeCookie() || 'dark';
document.documentElement.setAttribute('data-theme', saved);
$('#theme-toggle').addEventListener('click', ()=>{
const cur = document.documentElement.getAttribute('data-theme');
const idx = THEME_CYCLE.indexOf(cur);
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length] || THEME_CYCLE[0];
document.documentElement.setAttribute('data-theme', next);
writeThemeCookie(next);
});
}
// ============ HELPERS ============
function formatMs(ms){
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms/1000).toFixed(1)}s`;
}
function tween(duration, onUpdate){
return new Promise(resolve=>{
const start = performance.now();
function frame(now){
const p = Math.min(1, (now-start)/duration);
onUpdate(p);
if (p < 1) requestAnimationFrame(frame); else resolve();
}
requestAnimationFrame(frame);
});
}
// ============ KEYBOARD SHORTCUTS ============
// Single source of truth: drives both the help overlay and the keydown handler.
const SHORTCUTS = [
{ html: 'R', desc: 'Run all tests' },
{ html: 'X', desc: 'Reset · clear results' },
{ html: 'Esc', desc: 'Stop running · close overlay' },
{ html: 'T', desc: 'Toggle theme' },
{ html: '/', desc: 'Focus --grep filter' },
{ html: '1 – 9', desc: 'Run visible test by index' },
{ html: '?', desc: 'Toggle this overlay' },
];
function renderShortcuts(){
$('#kshelp-grid').innerHTML = SHORTCUTS.map(s =>
`
${s.html}${s.desc}
`
).join('');
}
function helpOpen(){ return !$('#kshelp').hidden; }
function openHelp(){
const el = $('#kshelp');
if (!el.hidden) return;
el.hidden = false;
// Defer focus so the dialog is in the a11y tree before we focus into it.
requestAnimationFrame(()=> el.querySelector('.kshelp__panel').focus());
}
function closeHelp(){ $('#kshelp').hidden = true; }
function toggleHelp(){ helpOpen() ? closeHelp() : openHelp(); }
function isTypingTarget(el){
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
function initShortcuts(){
renderShortcuts();
// Delegated on document so a click on the SVG glyph (or any nested element)
// still reaches us, and so we don't depend on element lookup timing.
document.addEventListener('click', e => {
if (e.target.closest('#kshelp-open')) { e.preventDefault(); toggleHelp(); return; }
if (e.target.closest('#kshelp-close')) { e.preventDefault(); closeHelp(); return; }
if (e.target.closest('#kshelp-scrim')) { closeHelp(); return; }
});
document.addEventListener('keydown', e => {
// Escape always wins: close overlay > stop run > blur grep.
if (e.key === 'Escape') {
if (helpOpen()) { closeHelp(); e.preventDefault(); return; }
if (isRunningAll) { isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); e.preventDefault(); return; }
if (isTypingTarget(document.activeElement)) { document.activeElement.blur(); e.preventDefault(); }
return;
}
// `?` toggles the overlay everywhere, even from the grep input.
if (e.key === '?') { toggleHelp(); e.preventDefault(); return; }
// Otherwise: don't hijack typing or modifier combos (Cmd+R, Ctrl+T, …).
if (isTypingTarget(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (helpOpen()) return;
const k = e.key;
if (k === 'r' || k === 'R') { e.preventDefault(); runAll(); return; }
if (k === 'x' || k === 'X') { e.preventDefault(); resetAll(); return; }
if (k === 't' || k === 'T') { e.preventDefault(); $('#theme-toggle').click(); return; }
if (k === '/') { e.preventDefault(); const g = $('#grep'); g.focus(); g.select(); return; }
if (/^[1-9]$/.test(k)) {
// Index into the active spec's currently-visible tests (so it pairs
// naturally with the open editor tab + any --grep / tag filter).
const visible = tests.filter(t => {
if (t.spec !== activeSpec) return false;
const row = $(`.test[data-id="${t.id}"]`);
return !row || row.style.display !== 'none';
});
const t = visible[parseInt(k, 10) - 1];
if (t) { e.preventDefault(); runTest(t.id); }
}
});
}
// ============ EDITOR STRIP (open .spec.ts files) ============
function renderEditorStrip(){
const strip = $('#editor-strip');
if (!strip) return;
strip.innerHTML = specs.map(s => {
const cnt = specCountFor(s.id);
const on = s.id === activeSpec;
return `
`;
}).join('');
strip.addEventListener('click', e => {
const btn = e.target.closest('.espec');
if (!btn) return;
activateSpec(btn.dataset.spec);
});
}
function activateSpec(specId, opts){
opts = opts || {};
const spec = getSpec(specId);
if (!spec) return;
if (specId === activeSpec && !opts.force) return;
activeSpec = spec.id;
writeSpecCookie(activeSpec);
// Editor strip — toggle is-active.
$$('.espec').forEach(b => {
const on = b.dataset.spec === activeSpec;
b.classList.toggle('is-active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
// Sidebar tree — highlight the suite block matching the open spec.
$$('.suite[data-spec]').forEach(s => {
s.classList.toggle('is-active', s.dataset.spec === activeSpec);
});
// Breadcrumb + sidebar foot.
const crumb = document.querySelector('.crumb strong');
if (crumb) crumb.textContent = spec.file;
const cnt = specCountFor(activeSpec);
const foot = $('#sidebar-foot-meta');
if (foot) foot.textContent = `v1.0.0 · ${cnt} test${cnt===1?'':'s'}`;
// Reskin the hero (title/sub/hint) for the active spec.
applyHeroForSpec();
// Re-render the source pane for the new spec.
renderSource();
// Re-rank the "pending preview" — different spec means a different first
// idle test. Clear the previous one, mark the new one (only if it's idle).
refreshPendingPreview();
// Apply grep + tag filter; refresh status counts (per active spec).
applyFilter();
updateStatusbar();
// Reset scroll for panes whose content swapped (report, source).
const panes = ['#pane-report', '#pane-source'];
panes.forEach(sel => { const p = document.querySelector(sel); if (p) p.scrollTop = 0; });
if (!opts.silent) consoleLine('info', `▶ opened ${spec.file} (${cnt} test${cnt===1?'':'s'})`);
}
// Recompute which result row should show the pending-preview body. Called
// when the active spec changes (each spec has its own first idle test).
function refreshPendingPreview(){
const targetId = firstIdleId();
$$('.result').forEach(art => {
const id = art.dataset.id;
const want = id === targetId;
const has = !!art.dataset.pending;
if (want === has) return;
const body = $(`#body-${id}`);
if (want) {
const t = tests.find(x => x.id === id);
if (!t) return;
art.classList.add('is-pending');
art.classList.add('is-open');
art.dataset.pending = '1';
if (body) body.innerHTML = renderPendingBody(t);
} else {
art.classList.remove('is-pending');
art.removeAttribute('data-pending');
if (body) body.innerHTML = '';
art.dataset.populated = '';
}
});
}
// Per-spec hero copy. Portfolio gets the full personal landing block;
// other specs get a slimmer header so their (often single) test row
// doesn't get visually buried by a 52px name title.
const HEROES = {
portfolio: {
title: 'Ilia Dobkin',
sub: 'Senior SDET · Remote (ET) · Canadian citizen · test.describe("portfolio")',
hint: 'Click the green ▶ next to any test to run it — or press Run all above.',
},
projects: {
title: 'Projects',
sub: 'Self-hosted infrastructure & code · test.describe("projects")',
hint: 'Run the test below to surface homelab, sdetProfile, Atlas voice agent, AtAnyRate, and LLM Council cards.',
},
skills: {
title: 'Skills & Capabilities',
sub: 'Tag-driven competencies · test.describe("skills")',
hint: 'Each test exposes a different angle — skills, daily stack, leadership, and KPIs.',
},
playground: {
title: 'Playground',
sub: 'Interactive demos & experiments · test.describe("playground")',
hint: 'Reserved for fun stuff — see the test below for what\'s on the runway.',
},
};
function applyHeroForSpec(){
const hero = document.querySelector('.hero');
if (!hero) return;
const conf = HEROES[activeSpec] || HEROES.portfolio;
hero.classList.toggle('hero--slim', activeSpec !== 'portfolio');
const titleEl = hero.querySelector('.hero__title');
const subEl = hero.querySelector('.hero__sub');
const hintEl = hero.querySelector('.hero__hint');
if (titleEl) titleEl.textContent = conf.title;
if (subEl) subEl.innerHTML = conf.sub;
if (hintEl) hintEl.innerHTML = conf.hint;
}
// ============ INIT ============
function init(){
initTheme();
renderTagBar();
renderTree();
renderResults();
renderTrace();
renderNetwork();
$('#pane-network').addEventListener('click', onNetworkPaneClick);
renderEditorStrip();
// activateSpec drives renderSource, applyFilter, updateStatusbar, etc.
activateSpec(activeSpec, { force: true, silent: true });
initTabs();
initShortcuts();
$('#results').addEventListener('click', e=>{
const jump = e.target.closest('.tab-link[data-tab]');
if (!jump) return;
e.preventDefault();
const tabBtn = $(`.tab[data-tab="${jump.dataset.tab}"]`);
if (tabBtn) tabBtn.click();
});
$('#grep').addEventListener('input', applyFilter);
$('#run-all').addEventListener('click', runAll);
$('#stop-all').addEventListener('click', ()=>{ isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); });
$('#reset-all').addEventListener('click', resetAll);
$('#expand-all').addEventListener('click', ()=>{
$$('.suite').forEach(s=>s.classList.remove('collapsed'));
});
// Mobile drawer
const sidebar = $('#sidebar');
const scrim = $('#sidebar-scrim');
function closeDrawer(){ sidebar.classList.remove('is-open'); scrim.classList.remove('is-open'); }
$('#menu-btn').addEventListener('click', ()=>{
sidebar.classList.toggle('is-open');
scrim.classList.toggle('is-open');
});
scrim.addEventListener('click', closeDrawer);
// Close drawer when a test is selected on mobile
sidebar.addEventListener('click', e => {
if (window.innerWidth <= 900 && (e.target.closest('.test') || e.target.closest('.test__run'))) {
setTimeout(closeDrawer, 120);
}
});
const w0 = getWorkers();
const sf = getSpec(activeSpec).file;
const sc = specCountFor(activeSpec);
consoleLine('info', `Loaded ${sc} test${sc===1?'':'s'} from ${sf} · workers=${w0}`);
$('#workers').addEventListener('change', ()=>{
const n = getWorkers();
consoleLine('info', `--workers=${n} · next run will use ${n} parallel worker${n===1?'':'s'}`);
});
// Overflow menu (⋯) on the right of the editor bar. Holds --workers=
// and --headed; opens on click, closes on outside-click + Escape.
initOverflowMenu();
// Swipe left/right on main pane to cycle specs (mobile)
(function initSwipeSpecs(){
const main = $('.main');
if (!main) return;
let startX = 0, startY = 0;
main.addEventListener('touchstart', e => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
main.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - startX;
const dy = e.changedTouches[0].clientY - startY;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.6) return;
const idx = specs.findIndex(s => s.id === activeSpec);
if (dx < 0 && idx < specs.length - 1) activateSpec(specs[idx + 1].id);
else if (dx > 0 && idx > 0) activateSpec(specs[idx - 1].id);
}, { passive: true });
})();
// Friendly nudge: animate the Run All button briefly on first load
setTimeout(()=> $('#run-all').classList.add('pulse'), 600);
// Auto-run the first test of the active spec so the report never lands
// empty — visitors see body content populated, with the runner animation
// still playing once (progress bar fill). Skip if a Reduce Motion user
// is here, or if the visitor has already touched a test in the 850ms
// since load (e.g. clicked ▶ themselves — no surprise auto-runs).
const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReduce) {
setTimeout(() => {
const anyTouched = tests.some(t => state[t.id].status !== 'idle');
if (anyTouched) return;
const firstId = firstIdleId();
if (firstId) runTest(firstId);
}, 850);
}
}
function initOverflowMenu(){
const root = $('#overflow');
const btn = $('#overflow-btn');
const menu = $('#overflow-menu');
if (!root || !btn || !menu) return;
function open(){
menu.hidden = false;
root.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
}
function close(){
menu.hidden = true;
root.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', e => {
e.stopPropagation();
menu.hidden ? open() : close();
});
// Outside-click closes; clicks inside the menu (e.g. toggling --headed
// or changing --workers) shouldn't close it — visitors usually want to
// tweak both in one go.
document.addEventListener('click', e => {
if (menu.hidden) return;
if (root.contains(e.target)) return;
close();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !menu.hidden) { close(); e.stopPropagation(); }
});
}
document.addEventListener('DOMContentLoaded', init);
})();