slyfox / index.html
ArthurZ's picture
ArthurZ HF Staff
Harness tab: inline overview + slyfox-flow.svg (dark-themed, no iframe)
832566c verified
Raw
History Blame Contribute Delete
54 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Slyfox</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/linear-theme.css" />
<link rel="stylesheet" href="/slyfox-theme.css" />
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-inter-variable);
font-size: var(--text-body);
line-height: var(--leading-body);
letter-spacing: var(--tracking-body);
}
/* Sidebar */
aside {
width: 260px;
flex-shrink: 0;
background: var(--sidebar-bg);
color: var(--sidebar-fg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar-header {
padding: 12px;
}
.new-chat {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: transparent;
border: 0;
color: inherit;
font: inherit;
font-weight: 500;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
text-align: left;
}
.new-chat:hover { background: var(--sidebar-hover); }
.new-chat svg { width: 16px; height: 16px; }
.chat-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 16px;
}
.chat-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
color: inherit;
font: inherit;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-item:hover { background: var(--sidebar-hover); }
.chat-item.active { background: var(--sidebar-active); }
/* Main column */
.main-wrap {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
}
main {
width: 100%;
max-width: 1100px;
display: flex;
flex-direction: column;
height: 100vh;
}
.msg {
max-width: 80%;
padding: 10px 14px;
border-radius: 14px;
white-space: pre-wrap;
word-wrap: break-word;
}
.msg.user {
align-self: flex-end;
background: var(--bubble-user);
color: var(--bubble-user-fg);
border-bottom-right-radius: 4px;
}
.msg.bot {
align-self: flex-start;
background: var(--bubble-bot);
color: var(--bubble-bot-fg);
border-bottom-left-radius: 4px;
}
/* Composer */
form {
padding: 12px 16px 18px;
background: var(--bg);
}
.composer {
--composer-pad-x: 2px;
border: 1px solid var(--border);
border-radius: 22px;
background: var(--composer-bg);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.composer:focus-within { border-color: #c7c7cc; }
#input {
flex: 1;
min-width: 0;
resize: none;
font: inherit;
color: inherit;
background: transparent;
border: 0;
outline: none;
padding: 4px var(--composer-pad-x);
max-height: 240px;
min-height: calc(1.45em + 8px);
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
#input.multiline {
min-height: calc(2 * 1.45em + 8px);
}
#input::placeholder { color: var(--muted); }
#send {
flex-shrink: 0;
background: var(--send-bg);
color: var(--send-fg);
width: 32px;
height: 32px;
border-radius: 50%;
border: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
#send:disabled { opacity: 0.45; cursor: not-allowed; }
#send svg { width: 18px; height: 18px; }
/* ---- Tabs ---- */
.tabs {
display: flex;
gap: var(--spacing-4);
padding: var(--spacing-12) var(--spacing-16) 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { height: 0; }
.tab-btn {
appearance: none;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
color: var(--muted);
font: inherit;
font-size: var(--text-body);
font-weight: var(--font-weight-regular);
letter-spacing: var(--tracking-body);
padding: var(--spacing-8) var(--spacing-12) var(--spacing-8);
cursor: pointer;
margin-bottom: -1px;
border-radius: var(--radius-tags);
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: var(--font-weight-w510);
}
/* ---- Analysis panel ---- */
#panel-analysis {
flex: 1;
overflow-y: auto;
padding: 28px 20px;
display: none;
flex-direction: column;
gap: 32px;
}
#panel-analysis.visible { display: flex; }
#panel-jobs {
flex: 1;
overflow-y: auto;
padding: var(--spacing-28) var(--spacing-20);
display: none;
flex-direction: column;
gap: var(--spacing-32);
}
#panel-jobs.visible { display: flex; }
.panel-embed {
flex: 1;
min-height: 0;
display: none;
flex-direction: column;
}
.panel-embed.visible { display: flex; }
.panel-embed iframe {
flex: 1;
width: 100%;
min-height: 0;
border: 0;
background: var(--bg);
}
.analysis-section h2 .hint {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
font-size: 12px;
color: var(--muted);
}
.jobs-lead {
font-size: 13px;
color: var(--muted);
margin: 0 0 16px;
line-height: 1.5;
}
.jobs-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.jobs-field label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
.jobs-field input,
.jobs-field select {
width: 100%;
font: inherit;
font-size: var(--text-body);
color: var(--fg);
background: var(--color-gunmetal);
border: 1px solid var(--border);
border-radius: var(--radius-inputs);
padding: var(--spacing-12) 14px;
}
.jobs-field input:focus,
.jobs-field select:focus {
outline: none;
border-color: var(--color-muted-ash);
box-shadow: var(--shadow-subtle-2);
}
.jobs-actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.jobs-btn {
background: var(--pill);
color: var(--pill-fg);
border: 0;
border-radius: var(--radius-buttons);
padding: var(--spacing-12) var(--spacing-24);
font: inherit;
font-size: 15px;
font-weight: var(--font-weight-w590);
letter-spacing: -0.11px;
cursor: pointer;
}
.jobs-btn:hover { filter: brightness(1.04); }
.jobs-btn.secondary {
background: transparent;
color: var(--color-light-steel);
border: 1px solid var(--border);
box-shadow: none;
}
.jobs-btn.danger { background: var(--err); color: var(--color-porcelain); box-shadow: none; }
.jobs-btn[disabled] { opacity: 0.45; cursor: not-allowed; }
.jobs-toast { font-size: 13px; color: var(--muted); }
.jobs-toast.ok { color: var(--ok); }
.jobs-toast.err { color: var(--err); }
.jobs-list {
border: 0;
border-radius: 0;
overflow: visible;
background: transparent;
box-shadow: none;
}
.job-row {
display: grid;
grid-template-columns: 28px 24px 1fr auto auto;
gap: var(--spacing-12);
align-items: center;
padding: var(--spacing-12) 0;
background: transparent;
border-bottom: 1px solid var(--color-muted-ash);
cursor: pointer;
}
.job-row:last-child { border-bottom: 0; }
.job-row:hover { background: transparent; }
.job-row.expanded { background: transparent; }
.job-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px var(--bg);
}
.job-dot.queued, .job-dot.pending { background: var(--muted); }
.job-dot.running { background: var(--running); animation: job-pulse 1.4s ease-in-out infinite; }
.job-dot.completed, .job-dot.success { background: var(--ok); }
.job-dot.failed, .job-dot.error { background: var(--err); }
.job-dot.cancelled, .job-dot.canceled { background: var(--warn); }
.job-dot.unknown { background: var(--muted); opacity: 0.5; }
@keyframes job-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.job-kind { font-size: 16px; text-align: center; }
.job-title { font-size: 14px; font-weight: 500; }
.job-title .id {
color: var(--meta);
font-family: var(--mono);
font-size: 11px;
margin-left: 6px;
font-weight: 400;
}
.job-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.job-meta {
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
text-align: right;
}
.job-meta .status {
display: block;
color: var(--fg);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.job-chevron { color: var(--muted); font-size: 14px; transition: transform 0.15s; }
.job-row.expanded .job-chevron { transform: rotate(90deg); }
.job-detail {
display: none;
padding: 14px 0 18px;
background: transparent;
border-bottom: 1px solid var(--color-muted-ash);
font-size: 13px;
}
.job-row.expanded + .job-detail { display: block; }
.job-detail .params { color: var(--muted); margin-bottom: 10px; line-height: 1.6; }
.job-detail .params b { color: var(--fg); font-weight: 500; }
.job-detail .logs {
background: var(--color-gunmetal);
border: 1px solid var(--border);
border-radius: var(--radius-inputs);
padding: var(--spacing-12);
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
color: var(--fg);
}
.job-detail .job-detail-actions { display: flex; gap: 8px; margin-top: 10px; }
.jobs-empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
.analysis-section h2 {
font-size: 15px;
font-weight: var(--font-weight-w510);
color: var(--fg);
letter-spacing: -0.11px;
margin: 0 0 var(--spacing-16);
}
.analysis-section {
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
box-shadow: none;
}
.analysis-section + .analysis-section {
padding-top: var(--section-gap);
border-top: 1px solid var(--color-muted-ash);
}
/* ---- Emotion heatmap ---- */
.emotion-heatmap-wrap { overflow-x: auto; }
.emotion-heatmap {
display: flex;
flex-direction: column;
gap: 3px;
}
.emotion-row {
display: flex;
align-items: center;
gap: 6px;
}
.emotion-row-label {
width: 120px;
flex-shrink: 0;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
padding-right: 4px;
}
.emotion-cells {
display: flex;
gap: 2px;
}
.emotion-cell {
width: 10px;
height: 20px;
border-radius: 2px;
flex-shrink: 0;
position: relative;
cursor: default;
}
.emotion-cell:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 5px);
left: 50%;
transform: translateX(-50%);
background: var(--fg);
color: var(--bg);
font-size: 11px;
white-space: nowrap;
padding: 3px 7px;
border-radius: 5px;
pointer-events: none;
z-index: 20;
}
.emotion-legend {
display: flex;
align-items: center;
gap: 0;
margin-top: 12px;
margin-left: 126px;
}
.emotion-legend-bar {
width: 160px;
height: 10px;
border-radius: 3px;
background: linear-gradient(to right, #67001f, #d6604d, #f7f7f7, #4393c3, #053061);
}
.emotion-legend-labels {
display: flex;
justify-content: space-between;
width: 160px;
margin-left: 126px;
margin-top: 3px;
font-size: 11px;
color: var(--muted);
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.stat-card {
background: var(--bubble-bot);
border-radius: 12px;
padding: 14px 16px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.stat-card .label {
font-size: 13px;
color: var(--muted);
}
/* Topic breakdown */
.topic-bars { display: flex; flex-direction: column; gap: 8px; }
.topic-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.topic-row .name { width: 160px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topic-row .bar-wrap { flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
.topic-row .bar { height: 100%; background: var(--pill); border-radius: 4px; }
.topic-row .count { width: 32px; text-align: right; color: var(--muted); }
/* ---- Communication style tab ---- */
#panel-style {
flex: 1;
overflow-y: auto;
padding: 28px 20px;
display: none;
flex-direction: column;
gap: 40px;
}
#panel-style.visible { display: flex; }
/* Radar */
.radar-wrap {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.radar-svg-wrap { flex-shrink: 0; }
.radar-legend {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 16px;
}
.radar-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.radar-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
/* Diff chart */
.diff-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.diff-controls label { font-size: 13px; color: var(--muted); }
.diff-controls select {
font: inherit;
font-size: 13px;
background: var(--composer-bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 10px;
cursor: pointer;
outline: none;
}
.diff-controls select:focus { border-color: var(--pill); }
.diff-vs { font-size: 13px; color: var(--muted); font-weight: 500; }
.diff-chart { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 560px; }
.diff-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.diff-dim-label { width: 90px; flex-shrink: 0; text-align: right; color: var(--muted); }
.diff-bar-track {
flex: 1;
position: relative;
height: 20px;
display: flex;
align-items: center;
}
.diff-center-line {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
}
.diff-bar {
position: absolute;
height: 14px;
border-radius: 3px;
top: 3px;
}
.diff-bar.pos { background: #4393c3; left: 50%; }
.diff-bar.neg { background: #d6604d; right: 50%; }
.diff-val { width: 48px; font-size: 12px; color: var(--muted); }
.diff-note { font-size: 12px; color: var(--muted); margin-top: 8px; }
</style>
</head>
<body>
<div class="main-wrap">
<main>
<!-- Tab bar -->
<div class="tabs">
<button class="tab-btn active" data-tab="buddy">Buddy</button>
<button class="tab-btn" data-tab="mirror">Mirror</button>
<button class="tab-btn" data-tab="cards">Cards</button>
<button class="tab-btn" data-tab="quests">Quests</button>
<button class="tab-btn" data-tab="marketplace">Marketplace</button>
<button class="tab-btn" data-tab="arena">Arena</button>
<button class="tab-btn" data-tab="jobs">Jobs</button>
<button class="tab-btn" data-tab="mood-ci">Mood CI</button>
<button class="tab-btn" data-tab="harness">Harness</button>
</div>
<div id="panel-jobs">
<div class="analysis-section">
<h2>Request a new model <span class="hint">runs emotion-vector extraction on hf jobs</span></h2>
<div class="jobs-grid">
<div class="jobs-field">
<label for="ex-model">Model id</label>
<input id="ex-model" type="text" placeholder="Qwen/Qwen2.5-7B-Instruct" />
</div>
<div class="jobs-field">
<label for="ex-flavor">Hardware</label>
<select id="ex-flavor">
<option value="a10g-large">a10g-large (default)</option>
<option value="a10g-small">a10g-small</option>
<option value="a100-large">a100-large</option>
<option value="cpu-upgrade">cpu-upgrade</option>
</select>
</div>
</div>
<div class="jobs-actions">
<button type="button" class="jobs-btn" id="ex-submit">Submit extraction</button>
<span class="jobs-toast" id="ex-toast"></span>
</div>
</div>
<div class="analysis-section">
<h2>Analyze traces <span class="hint">replays sessions through a model, emits per-turn emotion trajectories</span></h2>
<div class="jobs-grid">
<div class="jobs-field">
<label for="an-source">Traces bucket</label>
<input id="an-source" type="text" placeholder="HF-slyfox/traces" value="HF-slyfox/traces" />
</div>
<div class="jobs-field">
<label for="an-prefix">Prefix (optional)</label>
<input id="an-prefix" type="text" placeholder="Arthurs-beast/-Users-...-transformers" />
</div>
<div class="jobs-field">
<label for="an-n">Num traces</label>
<input id="an-n" type="number" min="1" max="100" value="5" />
</div>
<div class="jobs-field">
<label for="an-model">Model</label>
<input id="an-model" type="text" placeholder="google/gemma-2-2b-it" />
</div>
<div class="jobs-field">
<label for="an-flavor">Hardware</label>
<select id="an-flavor">
<option value="a10g-large">a10g-large</option>
<option value="a10g-small">a10g-small</option>
<option value="a100-large">a100-large</option>
</select>
</div>
</div>
<div class="jobs-actions">
<button type="button" class="jobs-btn" id="an-submit">Submit analysis</button>
<span class="jobs-toast" id="an-toast"></span>
</div>
</div>
<div class="analysis-section">
<h2>Recent runs</h2>
<div class="jobs-list" id="jobs"></div>
</div>
</div>
<div id="panel-buddy" class="panel-embed" data-embed-tab="buddy">
<iframe title="Buddy" data-src="/buddy?embed=1"></iframe>
</div>
<div id="panel-mirror" class="panel-embed" data-embed-tab="mirror">
<iframe title="Mirror" data-src="/mirror?embed=1"></iframe>
</div>
<div id="panel-cards" class="panel-embed" data-embed-tab="cards">
<iframe title="Cards" data-src="/cards?embed=1"></iframe>
</div>
<div id="panel-quests" class="panel-embed" data-embed-tab="quests">
<iframe title="Quests" data-src="/quests?embed=1"></iframe>
</div>
<div id="panel-marketplace" class="panel-embed" data-embed-tab="marketplace">
<iframe title="Marketplace" data-src="/marketplace?embed=1"></iframe>
</div>
<div id="panel-arena" class="panel-embed" data-embed-tab="arena">
<iframe title="Arena" data-src="/arena?embed=1"></iframe>
</div>
<div id="panel-mood-ci" class="panel-embed" data-embed-tab="mood-ci">
<iframe title="Mood CI" data-src="/mood-ci?embed=1"></iframe>
</div>
<div id="panel-harness" class="panel-embed" data-embed-tab="harness">
<div class="harness-overview" style="padding:28px 32px;overflow-y:auto;height:100%;box-sizing:border-box;line-height:1.55;color:var(--fg,#e6edf3)">
<h2 style="font-size:22px;font-weight:700;margin:0 0 16px;letter-spacing:-0.3px">
🦊 slyfox — give Claude Code your team's prior context, when it matters
</h2>
<p style="margin:0 0 12px;color:var(--fg,#e6edf3);font-size:14px">
<b>The pitch:</b> maintainers re-explain the same things to every new contributor (and to every fresh AI session).
Their Claude Code sessions are a treasure trove — encoding <i>how</i> this team thinks about specific files,
which optimisation patterns they reach for, where the dragons live. slyfox surfaces that prior context inside
any Claude Code session in the same repo.
</p>
<p style="margin:0 0 12px;color:var(--fg,#e6edf3);font-size:14px">
<b>What's in this Space:</b> the harness for measuring whether that injected context actually helps an AI fix
real bugs. 100 perf-flavoured tasks from <code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">huggingface/transformers</code>,
gold patches included, runnable both with and without slyfox.
</p>
<p style="margin:0 0 8px;color:var(--fg,#e6edf3);font-size:14px">
<b>Three companion pieces</b> (one repo:
<a href="https://github.com/huggingface/slyfox" target="_blank" rel="noopener" style="color:#79c0ff">github.com/huggingface/slyfox</a>):
</p>
<ul style="margin:0 0 12px;padding-left:22px;color:var(--fg,#e6edf3);font-size:14px">
<li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">plugin/</code> — the Claude Code plugin that does the injection (<code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">UserPromptSubmit</code> hook + retrieval).</li>
<li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">tools/hf-upload-traces/</code> — uploads filtered local Claude Code traces to a HF dataset (privacy scrubbed).</li>
<li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">jobs_scripts/</code> — HF Jobs for extracting emotion-direction vectors per model and projecting traces through them (the "persona" layer, owned by <a href="https://github.com/AidanZach" target="_blank" rel="noopener" style="color:#79c0ff">@AidanZach</a>).</li>
<li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">harness/</code> — what you're looking at: 100-task SWE bench + LLM-judge.</li>
</ul>
<p style="margin:0 0 18px;color:var(--fg,#e6edf3);font-size:14px">
The diagram below walks the full flow end-to-end and highlights the <b>buddy</b> and <b>mirror</b> ideas.
Both pipelines require open weights; closed-API tokens-only models can't be substituted at any of the boxes
tagged <code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">open</code>.
</p>
<div class="flow-diagram" style="margin:8px 0 24px;background:#08090a;border:1px solid #383b3f;border-radius:8px;padding:10px;overflow-x:auto">
<svg style="width:100%;max-width:1400px;height:auto;display:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 940" font-family="Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="13">
<defs>
<!-- Arrow markers, themed -->
<marker id="arr-grey" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8a8f98"/>
</marker>
<marker id="arr-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#5e6ad2"/>
</marker>
<marker id="arr-violet" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8b5cf6"/>
</marker>
<marker id="arr-lime" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#e4f222"/>
</marker>
<marker id="arr-emerald" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#27a644"/>
</marker>
<style>
.bg-fill { fill: #08090a; }
.panel { fill: #161718; stroke: #383b3f; stroke-width: 1; }
.panel-strong { fill: #161718; stroke: #f7f8f8; stroke-width: 1.5; }
.panel-data { fill: #0f1011; stroke: #5e6ad2; stroke-width: 1.5; }
.panel-persona { fill: #0f1011; stroke: #8b5cf6; stroke-width: 1.5; }
.panel-plugin { fill: #1a1c1d; stroke: #e4f222; stroke-width: 2; }
.panel-buddy { fill: #0f1011; stroke: #02b8cc; stroke-width: 1.5; }
.panel-mirror { fill: #0f1011; stroke: #ec4899; stroke-width: 1.5; }
.panel-quests { fill: #0f1011; stroke: #f59e0b; stroke-width: 1.5; }
.panel-augmented { fill: #0f1011; stroke: #27a644; stroke-width: 1.5; }
.panel-callout-red { fill: #1a0e10; stroke: #eb5757; stroke-width: 1; }
.title-main { font-size: 22px; font-weight: 600; fill: #f7f8f8; letter-spacing: -0.22px; }
.subtitle-main { font-size: 13px; fill: #8a8f98; }
.box-step { font-size: 11px; font-weight: 600; fill: #e4f222; letter-spacing: 0.08em; }
.box-step-blue { font-size: 11px; font-weight: 600; fill: #5e6ad2; letter-spacing: 0.08em; }
.box-step-violet { font-size: 11px; font-weight: 600; fill: #8b5cf6; letter-spacing: 0.08em; }
.box-title { font-size: 14px; font-weight: 600; fill: #f7f8f8; }
.box-title-lg { font-size: 16px; font-weight: 600; fill: #f7f8f8; }
.box-body { font-size: 12px; fill: #d0d6e0; }
.box-meta { font-size: 11px; fill: #8a8f98; font-style: italic; }
.arrow-label { font-size: 10.5px; fill: #8a8f98; }
.pill-bg { fill: #27a644; }
.pill-bg-red { fill: #eb5757; }
.pill-fg { fill: #08090a; font-size: 10px; font-weight: 700; }
.concept-title { font-size: 15px; font-weight: 700; }
.concept-buddy { fill: #02b8cc; }
.concept-mirror { fill: #ec4899; }
.concept-quests { fill: #f59e0b; }
.concept-plugin { fill: #e4f222; }
</style>
</defs>
<!-- background -->
<rect width="1400" height="940" class="bg-fill"/>
<!-- Title -->
<text x="40" y="50" class="title-main">🦊 slyfox — buddy, mirror, quests</text>
<text x="40" y="74" class="subtitle-main">Three modes of one plugin. All three need open weights, and all three are reachable from the plugin AND from the Space tab bar.</text>
<!-- ====================================================== -->
<!-- COLUMN 1 (x=40-280): data sources -->
<!-- ====================================================== -->
<g>
<!-- Sources -->
<rect x="40" y="110" width="240" height="100" rx="8" class="panel"/>
<text x="56" y="134" class="box-step">1 · SOURCE</text>
<text x="56" y="156" class="box-title">Claude Code sessions</text>
<text x="56" y="178" class="box-body">~/.claude/projects/</text>
<text x="56" y="194" class="box-body">turns · tools · files · timestamps</text>
<!-- Upload -->
<rect x="40" y="240" width="240" height="100" rx="8" class="panel"/>
<text x="56" y="264" class="box-step">2 · PUBLISH</text>
<text x="56" y="286" class="box-title">hf upload-traces</text>
<text x="56" y="308" class="box-body">Filter secrets + PII + paths.</text>
<text x="56" y="324" class="box-body">Push to HF dataset.</text>
<!-- Hub -->
<rect x="40" y="370" width="240" height="100" rx="8" class="panel-strong"/>
<text x="56" y="394" class="box-step">3 · HUB</text>
<text x="56" y="416" class="box-title">HF-slyfox/example-traces</text>
<text x="56" y="438" class="box-body">52 sessions · 40k turns</text>
<text x="56" y="454" class="box-body">arthurs-beast/transformers/*</text>
</g>
<!-- vertical arrows in col 1 -->
<line x1="160" y1="212" x2="160" y2="237" stroke="#8a8f98" stroke-width="1.5" marker-end="url(#arr-grey)"/>
<line x1="160" y1="342" x2="160" y2="367" stroke="#8a8f98" stroke-width="1.5" marker-end="url(#arr-grey)"/>
<!-- ====================================================== -->
<!-- COLUMN 2 (x=320-650): two HF Jobs side by side, stacked -->
<!-- ====================================================== -->
<!-- Embed Job -->
<g>
<rect x="320" y="120" width="330" height="150" rx="8" class="panel-data"/>
<text x="336" y="144" class="box-step-blue">3a · EMBED JOB (open weights)</text>
<rect x="556" y="128" width="78" height="18" rx="9" class="pill-bg"/>
<text x="595" y="142" class="pill-fg" text-anchor="middle">OPEN ✓</text>
<text x="336" y="170" class="box-title">Qwen3-Embedding-0.6B → FAISS</text>
<text x="336" y="192" class="box-body">Chunk each session → embed locally on the</text>
<text x="336" y="208" class="box-body">job runner → write chunks.parquet + faiss.index</text>
<text x="336" y="230" class="box-meta">→ HF-slyfox/example-traces-embeddings</text>
<text x="336" y="246" class="box-meta"> 8,908 chunks · 1024-dim · 3,848 are yours</text>
</g>
<!-- Persona Job -->
<g>
<rect x="320" y="290" width="330" height="200" rx="8" class="panel-persona"/>
<text x="336" y="314" class="box-step-violet">3b · PERSONA JOB (open weights)</text>
<rect x="556" y="298" width="78" height="18" rx="9" class="pill-bg"/>
<text x="595" y="312" class="pill-fg" text-anchor="middle">OPEN ✓</text>
<text x="336" y="340" class="box-title">Aidan's emotion-scope pipeline</text>
<text x="336" y="362" class="box-body"><tspan font-weight="600">extract_vectors.py:</tspan> load open model, layer-</text>
<text x="336" y="378" class="box-body">sweep residual stream, save 20 emotion vectors.</text>
<text x="336" y="402" class="box-body"><tspan font-weight="600">analyze_traces.py:</tspan> replay each session through</text>
<text x="336" y="418" class="box-body">the same model → per-turn projection → parquet.</text>
<text x="336" y="442" class="box-meta">Needs residual-stream access at chosen layers.</text>
<text x="336" y="458" class="box-meta">Closed APIs return tokens only — impossible.</text>
<rect x="336" y="468" width="120" height="18" rx="9" class="pill-bg-red"/>
<text x="396" y="482" class="pill-fg" text-anchor="middle">CLOSED API: ✗</text>
</g>
<!-- arrows hub → embed, hub → persona -->
<path d="M 280 410 C 300 410, 300 195, 320 195" fill="none" stroke="#5e6ad2" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<path d="M 280 430 C 300 430, 300 380, 320 380" fill="none" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#arr-violet)"/>
<!-- ====================================================== -->
<!-- COLUMN 3 (x=720-1020): THE PLUGIN — central -->
<!-- ====================================================== -->
<g>
<rect x="720" y="180" width="300" height="380" rx="12" class="panel-plugin"/>
<text x="740" y="208" class="box-step">4 · RUNTIME</text>
<text x="740" y="232" class="box-title-lg">slyfox plugin</text>
<text x="740" y="252" class="box-body">UserPromptSubmit hook · install.py</text>
<text x="740" y="268" class="box-body">runs inside Claude Code on your machine</text>
<line x1="740" y1="284" x2="1000" y2="284" stroke="#383b3f" stroke-width="1"/>
<text x="740" y="306" class="box-body" font-weight="600" fill="#e4f222">on every user prompt:</text>
<text x="740" y="326" class="box-body">a · embed the prompt (same model as index)</text>
<text x="740" y="342" class="box-body">b · FAISS top-K → candidate prior turns</text>
<text x="740" y="358" class="box-body">c · persona rerank (project prompt + match</text>
<text x="740" y="374" class="box-body"> trajectory shape against candidates)</text>
<text x="740" y="390" class="box-body">d · inject excerpts via additionalContext</text>
<line x1="740" y1="408" x2="1000" y2="408" stroke="#383b3f" stroke-width="1"/>
<text x="740" y="430" class="box-body" font-weight="600" fill="#e4f222">all three modes accessible from:</text>
<text x="740" y="450" class="box-body">· /slyfox-expert /-status /-refresh</text>
<text x="740" y="466" class="box-body"> (slash commands inside Claude Code)</text>
<text x="740" y="488" class="box-body">· tab bar at HF-slyfox/slyfox</text>
<text x="740" y="504" class="box-body"> (Buddy · Mirror · Quests tabs)</text>
<text x="740" y="540" class="box-meta">≤1500 token budget · 1400ms timeout · fail-open</text>
</g>
<!-- arrows from jobs → plugin (clean horizontal/diagonal lines, no overlap) -->
<path d="M 650 195 L 720 240" fill="none" stroke="#5e6ad2" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<path d="M 650 380 L 720 390" fill="none" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#arr-violet)"/>
<text x="680" y="200" class="arrow-label">chunks.parquet + faiss.index</text>
<text x="680" y="406" class="arrow-label">trajectory.parquet</text>
<!-- ====================================================== -->
<!-- COLUMN 4 (x=1070-1360): three modes -->
<!-- ====================================================== -->
<!-- Buddy -->
<g>
<rect x="1070" y="120" width="290" height="120" rx="8" class="panel-buddy"/>
<text x="1088" y="146" class="concept-title concept-buddy">🫂 Buddy</text>
<text x="1088" y="170" class="box-body">"Retrieve from someone who has</text>
<text x="1088" y="186" class="box-body">already worked here." You / colleague /</text>
<text x="1088" y="202" class="box-body">the whole org's pooled sessions.</text>
<text x="1088" y="224" class="box-meta">Uses 3a (semantic) + 3b (persona rerank).</text>
</g>
<!-- Mirror -->
<g>
<rect x="1070" y="260" width="290" height="120" rx="8" class="panel-mirror"/>
<text x="1088" y="286" class="concept-title concept-mirror">🪞 Mirror</text>
<text x="1088" y="310" class="box-body">"Project everything through the same</text>
<text x="1088" y="326" class="box-body">open model — index, query, persona.</text>
<text x="1088" y="342" class="box-body">Two different X's = two activation spaces."</text>
<text x="1088" y="364" class="box-meta">Same-model invariant — enforced by 3b.</text>
</g>
<!-- Quests -->
<g>
<rect x="1070" y="400" width="290" height="160" rx="8" class="panel-quests"/>
<text x="1088" y="426" class="concept-title concept-quests">🎯 Quests</text>
<text x="1088" y="450" class="box-body">Persona trajectories recommend a next</text>
<text x="1088" y="466" class="box-body">task that fits your current emotional arc.</text>
<text x="1088" y="482" class="box-body">A Quest = {issue, base_commit, target_files,</text>
<text x="1088" y="498" class="box-body">judge_rubric} — same schema as the harness.</text>
<text x="1088" y="520" class="box-meta">Driven by 3b (emotion trajectories).</text>
<text x="1088" y="540" class="box-meta">Open inside the plugin OR from the tab bar.</text>
</g>
<!-- arrows plugin → buddy/mirror/quests (clean, separate vertical positions on plugin's right edge) -->
<path d="M 1020 250 C 1045 250, 1045 180, 1070 180" fill="none" stroke="#02b8cc" stroke-width="1.5" marker-end="url(#arr-grey)"/>
<path d="M 1020 330 C 1045 330, 1045 320, 1070 320" fill="none" stroke="#ec4899" stroke-width="1.5" marker-end="url(#arr-grey)"/>
<path d="M 1020 430 C 1045 430, 1045 470, 1070 470" fill="none" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arr-grey)"/>
<!-- feedback arrows (B/M/Q ARE the plugin's behavior — small dashed loop-back) -->
<path d="M 1070 220 C 1040 220, 1040 280, 1020 280" fill="none" stroke="#02b8cc" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/>
<path d="M 1070 360 C 1040 360, 1040 360, 1020 360" fill="none" stroke="#ec4899" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/>
<path d="M 1070 540 C 1040 540, 1040 460, 1020 460" fill="none" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/>
<!-- ====================================================== -->
<!-- BOTTOM ROW: augmented Claude (left) + why-open (right) -->
<!-- ====================================================== -->
<g>
<rect x="40" y="610" width="640" height="180" rx="10" class="panel-augmented"/>
<text x="60" y="638" class="box-step" fill="#27a644">5 · OUTPUT</text>
<text x="60" y="664" class="box-title-lg">Claude Code session, augmented</text>
<text x="60" y="688" class="box-body">Your prompt + fenced trace excerpts ("from buddy: ArthurZ · file-expert: …")</text>
<text x="60" y="706" class="box-body">+ persona-aware Quest suggestion ("decisive → ready to land")</text>
<text x="60" y="730" class="box-body">+ Mirror guarantee (every projection through the same open model).</text>
<text x="60" y="760" class="box-meta">harness/run_eval.py measures the LLM-judge lift on 50 perf bugs in transformers.</text>
<text x="60" y="778" class="box-meta">Result so far: signal exists per task (PR #44231 +3, #35184 +5, …) but sits inside generation noise at n=50.</text>
</g>
<!-- Plugin → Augmented Claude arrow -->
<path d="M 870 560 L 870 600" fill="none" stroke="#e4f222" stroke-width="2" marker-end="url(#arr-lime)"/>
<text x="880" y="585" class="arrow-label" fill="#e4f222">runtime output</text>
<!-- Why-open callout -->
<g>
<rect x="720" y="610" width="640" height="180" rx="10" class="panel-callout-red"/>
<text x="740" y="638" class="box-title-lg" fill="#eb5757">⚠ Why open weights — non-negotiable</text>
<circle cx="748" cy="664" r="3" fill="#27a644"/>
<text x="760" y="668" class="box-body"><tspan font-weight="600">Residual-stream access.</tspan> Persona extraction reads layer-L activations; tokens-only APIs can't do this.</text>
<circle cx="748" cy="690" r="3" fill="#27a644"/>
<text x="760" y="694" class="box-body"><tspan font-weight="600">Mirror (same model both sides).</tspan> Closed APIs silently swap model versions across calls — reproducibility = open.</text>
<circle cx="748" cy="716" r="3" fill="#27a644"/>
<text x="760" y="720" class="box-body"><tspan font-weight="600">Live per-turn projection.</tspan> Persona scoring runs as you type — must be a model you can hot-load.</text>
<circle cx="748" cy="744" r="3" fill="#eb5757"/>
<text x="760" y="748" class="box-body"><tspan font-weight="600">Buddy works *better* with open weights</tspan> too: index, query, AND rerank in the same activation space.</text>
<text x="740" y="778" class="box-meta">Closed APIs degrade Buddy to text-only retrieval and disable Mirror + Quests entirely.</text>
</g>
<!-- Footer -->
<text x="700" y="910" class="subtitle-main" text-anchor="middle">huggingface/slyfox · github.com/huggingface/slyfox · all three modes reachable from the plugin + the Space tab bar</text>
</svg>
</div>
<p style="margin:14px 0 0;color:var(--muted,#8b949e);font-size:12px">
<a href="https://hf-slyfox-harness.hf.space/?__theme=dark" target="_blank" rel="noopener" style="color:#79c0ff">Open the full harness Space →</a>
for the plugin / persona pipeline / results tabs.
</p>
</div>
</div>
</main>
</div>
<script>
const _params = new URLSearchParams(location.search);
// Anonymous per-browser user id for /api/jobs and related endpoints.
function getUserId() {
let id = localStorage.getItem("slyfox.user_id");
if (!id) {
id = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(36).slice(2));
id = id.replace(/-/g, "");
localStorage.setItem("slyfox.user_id", id);
}
return id;
}
const USER_ID = getUserId();
const persistHeaders = () => ({ "X-User-Id": USER_ID });
// ---- Jobs panel ----
let jobsPanelReady = false;
const jobsRoot = document.getElementById("jobs");
const jobsExpanded = new Set();
const jobsLogCache = new Map();
const JOBS_KIND_ICON = { extract: "🧠", analyze: "🧪" };
function jobsStatusClass(s) {
if (!s) return "unknown";
s = String(s).toLowerCase();
if (s.includes("run")) return "running";
if (s.includes("queue") || s.includes("pend") || s.includes("start")) return "queued";
if (s.includes("complete") || s.includes("succ") || s.includes("done")) return "completed";
if (s.includes("fail") || s.includes("err")) return "failed";
if (s.includes("cancel")) return "cancelled";
return "unknown";
}
function jobsFmtElapsed(s) {
if (!s && s !== 0) return "—";
const m = Math.floor(s / 60), sec = s % 60;
if (m === 0) return sec + "s";
if (m < 60) return m + "m " + sec + "s";
return Math.floor(m / 60) + "h " + (m % 60) + "m";
}
function jobsTitle(j) {
if (j.kind === "extract") return "Extract " + (j.params?.model_id || "?");
if (j.kind === "analyze") {
return "Analyze " + (j.params?.source || "?") + " · " + (j.params?.model_id || "?");
}
return j.job_id;
}
function jobsSubtitle(j) {
if (j.kind === "extract") return "→ " + (j.params?.output || "");
if (j.kind === "analyze") {
const n = j.params?.n_traces ?? "?";
return n + " traces → " + (j.params?.output || "");
}
return "";
}
function jobsParamRow(label, value) {
if (!value) return "";
return `<div><b>${label}</b>: ${jobsEsc(String(value))}</div>`;
}
function jobsEsc(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function renderJobsList(jobs) {
if (!jobs.length) {
jobsRoot.innerHTML = `<div class="jobs-empty">No jobs yet — submit one above to get started.</div>`;
return;
}
jobsRoot.innerHTML = jobs.map((j) => {
const sCls = jobsStatusClass(j.live?.status);
const elapsed = jobsFmtElapsed(j.live?.elapsed_s);
const isOpen = jobsExpanded.has(j.job_id);
const logs = (jobsLogCache.get(j.job_id) || []).join("\n");
const canCancel = sCls === "running" || sCls === "queued";
return `
<div class="job-row ${isOpen ? "expanded" : ""}" data-id="${j.job_id}">
<span class="job-dot ${sCls}" title="${j.live?.status ?? "unknown"}"></span>
<span class="job-kind">${JOBS_KIND_ICON[j.kind] || "▶"}</span>
<div>
<div class="job-title">${jobsEsc(jobsTitle(j))}<span class="id">${j.job_id.slice(0, 12)}</span></div>
<div class="job-sub">${jobsEsc(jobsSubtitle(j))}</div>
</div>
<div class="job-meta"><span class="status">${j.live?.status || "unknown"}</span>${elapsed}</div>
<span class="job-chevron">▶</span>
</div>
<div class="job-detail" data-id="${j.job_id}">
<div class="params">
${jobsParamRow("submitted", j.submitted_at)}
${jobsParamRow("requester", j.requester)}
${jobsParamRow("flavor", j.params?.flavor || j.live?.flavor)}
${jobsParamRow("started", j.live?.started_at)}
${jobsParamRow("ended", j.live?.ended_at)}
${j.kind === "analyze" ? jobsParamRow("source", j.params?.source) : ""}
${j.kind === "analyze" ? jobsParamRow("prefix", j.params?.prefix) : ""}
${j.kind === "analyze" ? jobsParamRow("n_traces", j.params?.n_traces) : ""}
${jobsParamRow("output", j.params?.output)}
</div>
<div class="logs" id="logs-${j.job_id}">${logs ? jobsEsc(logs) : '<span style="color:var(--muted)">no logs yet</span>'}</div>
<div class="job-detail-actions">
<button type="button" class="jobs-btn secondary" data-act="refresh-logs" data-id="${j.job_id}">Refresh logs</button>
${canCancel ? `<button type="button" class="jobs-btn danger" data-act="cancel" data-id="${j.job_id}">Cancel</button>` : ""}
</div>
</div>`;
}).join("");
}
async function jobsApi(path, opts = {}) {
const headers = { "X-User-Id": USER_ID, ...(opts.headers || {}) };
if (opts.body && typeof opts.body !== "string") {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(path, { ...opts, headers });
if (!res.ok) {
let err;
try { err = (await res.json()).detail || res.statusText; } catch { err = res.statusText; }
throw new Error(err);
}
return res.json();
}
let jobsRefreshInFlight = false;
async function refreshJobsList() {
if (jobsRefreshInFlight) return;
jobsRefreshInFlight = true;
try {
renderJobsList(await jobsApi("/api/jobs"));
} catch (e) {
jobsRoot.innerHTML = `<div class="jobs-empty" style="color:#dc2626">${jobsEsc(e.message)}</div>`;
} finally {
jobsRefreshInFlight = false;
}
}
async function refreshJobLogs(id) {
try {
const data = await jobsApi(`/api/jobs/${id}/logs?tail=200`);
jobsLogCache.set(id, data.lines || []);
const el = document.getElementById("logs-" + id);
if (el) {
el.textContent = (data.lines || []).join("\n");
el.scrollTop = el.scrollHeight;
}
} catch (e) {
const el = document.getElementById("logs-" + id);
if (el) el.textContent = "(logs: " + e.message + ")";
}
}
function bindJobsSubmit(buttonId, toastId, build, endpoint) {
const btn = document.getElementById(buttonId);
const toast = document.getElementById(toastId);
btn.addEventListener("click", async () => {
btn.disabled = true;
toast.textContent = "submitting…";
toast.className = "jobs-toast";
try {
const job = await jobsApi(endpoint, { method: "POST", body: build() });
toast.textContent = "queued: " + job.job_id;
toast.className = "jobs-toast ok";
refreshJobsList();
} catch (e) {
toast.textContent = "✗ " + e.message;
toast.className = "jobs-toast err";
} finally {
btn.disabled = false;
}
});
}
function initJobsPanel() {
if (jobsPanelReady) return;
jobsPanelReady = true;
bindJobsSubmit("ex-submit", "ex-toast", () => ({
model_id: document.getElementById("ex-model").value.trim(),
flavor: document.getElementById("ex-flavor").value,
}), "/api/models/extract");
bindJobsSubmit("an-submit", "an-toast", () => ({
source: document.getElementById("an-source").value.trim(),
prefix: document.getElementById("an-prefix").value.trim(),
n_traces: parseInt(document.getElementById("an-n").value, 10),
model_id: document.getElementById("an-model").value.trim(),
flavor: document.getElementById("an-flavor").value,
}), "/api/analyses");
jobsRoot.addEventListener("click", async (ev) => {
const actBtn = ev.target.closest("button[data-act]");
if (actBtn) {
const id = actBtn.dataset.id;
if (actBtn.dataset.act === "refresh-logs") {
await refreshJobLogs(id);
return;
}
if (actBtn.dataset.act === "cancel") {
if (!confirm("Cancel job " + id + "?")) return;
try {
await jobsApi(`/api/jobs/${id}/cancel`, { method: "POST" });
refreshJobsList();
} catch (e) { alert("cancel failed: " + e.message); }
}
return;
}
const row = ev.target.closest(".job-row");
if (!row) return;
const id = row.dataset.id;
if (jobsExpanded.has(id)) jobsExpanded.delete(id);
else {
jobsExpanded.add(id);
await refreshJobLogs(id);
}
refreshJobsList();
});
refreshJobsList();
setInterval(refreshJobsList, 5000);
setInterval(() => jobsExpanded.forEach((id) => refreshJobLogs(id)), 3000);
}
// ---- Tabs ----
const panelJobs = document.getElementById("panel-jobs");
const embedPanels = document.querySelectorAll(".panel-embed[data-embed-tab]");
function embedSrcFor(panel) {
let src = panel.querySelector("iframe")?.dataset.src || "";
if (panel.dataset.embedTab !== "cards") return src;
const cardId = location.hash.slice(1) || _params.get("card") || "";
if (!cardId) return src;
return src + "#" + encodeURIComponent(cardId);
}
function loadEmbedPanel(panel) {
const iframe = panel.querySelector("iframe");
if (!iframe?.dataset.src || iframe.dataset.loaded) return;
iframe.src = embedSrcFor(panel);
iframe.dataset.loaded = "1";
}
function syncCardsDeepLink() {
const panel = document.querySelector('.panel-embed[data-embed-tab="cards"]');
const iframe = panel?.querySelector("iframe");
if (!iframe?.dataset.loaded) return;
const nextSrc = embedSrcFor(panel);
if (iframe.getAttribute("src") !== nextSrc) iframe.src = nextSrc;
}
function activateTab(tab) {
document.querySelectorAll(".tab-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
panelJobs.classList.toggle("visible", tab === "jobs");
embedPanels.forEach((panel) => {
const show = panel.dataset.embedTab === tab;
panel.classList.toggle("visible", show);
if (show) loadEmbedPanel(panel);
});
if (tab === "jobs") initJobsPanel();
}
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => activateTab(btn.dataset.tab));
});
window.addEventListener("hashchange", syncCardsDeepLink);
const tabParam = _params.get("tab");
const defaultTab = "buddy";
activateTab(
tabParam && document.querySelector(`.tab-btn[data-tab="${tabParam}"]`)
? tabParam
: defaultTab
);
</script>
</body>
</html>