feat(desktop): fold Rubrics/Growth/Runtimes into one Evals nav entry (3 tabs + shared rubric selector)

This commit is contained in:
ZiyaZhang
2026-07-19 23:31:47 -07:00
parent 211ffb4b7c
commit 0207b5edd2
14 changed files with 781 additions and 105 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -0,0 +1,274 @@
// Verification script for lane-evals-merge (2026-07-19):
// launches an isolated Electron on CDP :9455 with its own --user-data-dir
// and DSH_DESKTOP_HOME, opens the shell, clicks the new Evals nav item,
// cycles the three inner tabs [ Rubrics | Growth | Runtime ], flips the
// shared rubric selector, and shoots five screenshots into
// docs/qa-evals-merge/:
// 00-sidebar-full.png — left nav shows Evals (not three rows)
// 01-evals-rubrics.png — Rubrics tab active
// 02-evals-growth.png — Growth tab active
// 03-evals-runtime.png — Runtime tab active (all rubrics)
// 04-evals-selector-runtime.png — shared selector picks one rubric,
// Runtime grid filters to that card
//
// Two-root isolation (per 2026-07-18 postmortem in
// scripts/qa-cdp-shoot-affordance.mjs header):
// 1. --user-data-dir=<tmp> isolates Chromium userdata
// 2. DSH_DESKTOP_HOME=<tmp> isolates our shell's config root
// Neither the user's running Electron on CDP 9333 nor their ~/.dsh-desktop
// is touched.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { tmpdir } from 'node:os'
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
const CDP_PORT = Number(process.env.DSH_EVALS_MERGE_PORT || 9455)
const USER_DATA = join(tmpdir(), 'dsh-evals-merge-userdata')
const DSH_HOME = join(tmpdir(), 'dsh-evals-merge-home')
const OUTDIR = join(WORKTREE, 'docs/qa-evals-merge')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
for (const dir of [USER_DATA, DSH_HOME]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
// Minimal onboarding seed so we don't hit the first-run modal. Same shape
// as scripts/qa-cdp-shoot-affordance.mjs — never touches the real ~/.dsh-desktop.
const seedOverlay = [
'# QA evals-merge seed overlay (tmp, per-run).',
'plugins:',
' - "@cordisjs/plugin-include":',
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(DSH_HOME, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({
role: 'coding', approvalMode: 'never',
}))
writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString())
async function bootElectron() {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${USER_DATA}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: {
...process.env,
DSH_DESKTOP_HOME: DSH_HOME,
DSH_MAXIMIZE: '1',
// DSH_QA is intentionally NOT set — it injects the `#qa` URL hash
// that turns on qa-harness.js's click-sweep, which fires switchTo
// in the middle of our own driver's shot sequence. We only need
// isolation + a stable renderer, not the auto-walker.
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const logs = []
child.stdout.on('data', d => logs.push(String(d)))
child.stderr.on('data', d => logs.push(String(d)))
for (let i = 0; i < 40; i++) {
await sleep(500)
try {
const r = await fetch(`http://localhost:${CDP_PORT}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp() {
const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json()
const target = targets.find(t => t.type === 'page')
if (!target) throw new Error('no page target on port ' + CDP_PORT)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
let id = 1
const pending = new Map()
ws.onmessage = ev => {
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
if (msg.id != null && pending.has(msg.id)) {
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
}
}
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evj = async expr => {
const r = await call('Runtime.evaluate', {
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
returnByValue: true, awaitPromise: true,
})
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
return { ws, call, evj }
}
async function main() {
const { child, logs } = await bootElectron()
const kill = () => { try { child.kill('SIGKILL') } catch {} }
try {
const cdp = await newCdp()
await cdp.call('Page.enable')
await cdp.call('Runtime.enable')
// Wait until the sidebar Evals button exists — proves index.html
// wired the new nav and the renderer has painted.
let ready = false
for (let i = 0; i < 60; i++) {
await sleep(200)
ready = await cdp.evj(`!!document.querySelector('.tab-btn[data-tab="evals"]')`)
if (ready) break
}
if (!ready) throw new Error('Evals nav button never mounted — check index.html + renderer.js')
const shoot = async (name) => {
// Retry once — the Runtime tab paint kicks off async listProfiles/
// runtimeStatus calls; on slow machines the first capture attempt
// occasionally times out while the compositor is busy. A single
// retry with a longer settle is enough in practice.
await sleep(500)
let attempts = 0
let lastErr
while (attempts < 2) {
attempts++
try {
const r = await cdp.call('Page.captureScreenshot', { format: 'png' }, 90000)
writeFileSync(join(OUTDIR, name + '.png'), Buffer.from(r.data, 'base64'))
console.error('wrote', name + '.png')
return
} catch (e) {
lastErr = e
console.error(`shoot ${name} attempt ${attempts} failed: ${e.message}; retrying after 800ms`)
await sleep(800)
}
}
throw lastErr
}
// 00 — sidebar full, before entering Evals. Prove: exactly ONE
// Evals button; the three legacy ids (rubrics/growth/runtimes)
// are gone from the nav.
const sidebar = await cdp.evj(`(() => {
const items = [...document.querySelectorAll('.sidebar-nav .tab-btn')]
return items.map(b => ({ tab: b.dataset.tab, label: (b.textContent || '').trim() }))
})()`)
console.error('sidebar snapshot:', JSON.stringify(sidebar))
const tabs = sidebar.map(s => s.tab)
if (tabs.filter(t => t === 'evals').length !== 1) throw new Error('expected exactly one evals button, got ' + tabs.filter(t => t === 'evals').length)
for (const legacy of ['rubrics', 'growth', 'runtimes']) {
if (tabs.includes(legacy)) throw new Error(`legacy nav id still present: ${legacy}`)
}
await shoot('00-sidebar-full')
// 01 — click Evals, expect Rubrics tab active + rubrics-catalog painted.
await cdp.evj(`document.querySelector('.tab-btn[data-tab="evals"]').click()`)
await sleep(500)
const rubricsActive = await cdp.evj(`(() => {
const pane = document.querySelector('.pane[data-pane="evals"]')
if (!pane) return { ok: false, reason: 'no evals pane' }
if (pane.hidden) return { ok: false, reason: 'pane hidden after switchTo' }
const active = pane.dataset.evalsActive
const rubricsPane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="rubrics"]')
const growthPane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="growth"]')
const runtimePane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="runtime"]')
return {
ok: active === 'rubrics' && !rubricsPane.hidden && growthPane.hidden && runtimePane.hidden,
active,
rubricsHidden: rubricsPane.hidden,
growthHidden: growthPane.hidden,
runtimeHidden: runtimePane.hidden,
catalogChildren: pane.querySelectorAll('#rubrics-catalog *').length,
}
})()`)
console.error('rubrics-active state:', JSON.stringify(rubricsActive))
if (!rubricsActive.ok) throw new Error('rubrics tab did not activate as expected: ' + JSON.stringify(rubricsActive))
await shoot('01-evals-rubrics')
// 02 — click Growth tab, expect only Growth pane visible.
await cdp.evj(`document.querySelector('.evals-tab[data-evals-tab="growth"]').click()`)
await sleep(500)
const growthActive = await cdp.evj(`(() => {
const pane = document.querySelector('.pane[data-pane="evals"]')
const active = pane.dataset.evalsActive
const growthPane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="growth"]')
const rubricsPane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="rubrics"]')
const runtimePane = pane.querySelector('.evals-tab-pane[data-evals-tab-pane="runtime"]')
return { ok: active === 'growth' && !growthPane.hidden && rubricsPane.hidden && runtimePane.hidden, active }
})()`)
console.error('growth-active state:', JSON.stringify(growthActive))
if (!growthActive.ok) throw new Error('growth tab did not activate as expected: ' + JSON.stringify(growthActive))
await shoot('02-evals-growth')
// 03 — click Runtime tab. Confirm the rollout-grid card container
// for the fusion-seeded rubrics is populated.
await cdp.evj(`document.querySelector('.evals-tab[data-evals-tab="runtime"]').click()`)
await sleep(700)
const runtimeState = await cdp.evj(`(() => {
const evals = document.querySelector('.pane[data-pane="evals"]')
const active = evals ? evals.dataset.evalsActive : null
const runtimePane = evals ? evals.querySelector('.evals-tab-pane[data-evals-tab-pane="runtime"]') : null
const gridCards = evals ? evals.querySelectorAll('[data-testid^="rubric-grid-card-"]').length : 0
return {
ok: !!evals && !evals.hidden && active === 'runtime' && runtimePane && !runtimePane.hidden,
active, gridCards,
}
})()`)
console.error('runtime-active state:', JSON.stringify(runtimeState))
if (!runtimeState.ok) throw new Error('runtime tab did not activate as expected: ' + JSON.stringify(runtimeState))
await shoot('03-evals-runtime')
// 04 — pick a rubric in the shared selector and confirm the Runtime
// grid filters down. The fusion seed loads a handful of rubrics; we
// take the first available id.
const filterProof = await cdp.evj(`(async () => {
const sel = document.getElementById('evals-shared-rubric')
if (!sel) return { ok: false, reason: 'no shared selector' }
// Skip the "All rubrics" sentinel option; pick the first real one.
const opts = [...sel.querySelectorAll('option')].filter(o => o.value)
if (!opts.length) return { ok: false, reason: 'no rubric options' }
const pick = opts[0].value
sel.value = pick
sel.dispatchEvent(new Event('change', { bubbles: true }))
// Give the change listener + Runtime repaint a moment.
await new Promise(r => setTimeout(r, 400))
const gridCards = [...document.querySelectorAll('[data-testid^="rubric-grid-card-"]')]
return {
ok: gridCards.length === 1,
picked: pick,
gridCardIds: gridCards.map(el => el.getAttribute('data-testid')),
}
})()`)
console.error('shared-selector filter proof:', JSON.stringify(filterProof))
if (!filterProof.ok) throw new Error('shared-selector filter did not narrow the Runtime grid: ' + JSON.stringify(filterProof))
await shoot('04-evals-selector-runtime')
console.error('all evals-merge checkpoints passed')
kill()
} catch (e) {
console.error('shoot failed:', e.message)
console.error('captured logs:\n' + logs.slice(-30).join(''))
kill()
process.exit(1)
}
}
main().catch(e => { console.error(e); process.exit(1) })

View File

@@ -104,35 +104,31 @@
</button>
</div>
<div class="nav-group" data-nav-group="runtime" data-item-count="6">
<div class="nav-group" data-nav-group="runtime" data-item-count="4">
<div class="nav-group-header">runtime</div>
<!-- lane-rubrics slot — live as of NAV delta merge (feat/lane-rubrics-annotation
@ 70293ba already merged as a13e020; this merge is the flip point per team-
lead rule "hub+rubrics flip in NAV delta since neither NAV nor RUB touched
index.html on the merge path"). data-fixture-tier stays because the 28-task
rubric catalog is still fixture-backed until the annotation-store producer
wires up; the "· demo" chip decorateNav() paints from it stays truthful. -->
<button class="tab-btn nav-item" data-tab="rubrics" data-fixture-tier="true"
title="Rubrics — catalog-first scoring rubric library, 28-task groups (fixture-tier)">
<!-- Evals entry (lane-evals-merge, 2026-07-19). Three former nav
items — Rubrics, Growth, Runtimes — are collapsed into a
single Evals door because they already share one rubric
scoring event log (rubric-fusion-model.js) and mean much
less on their own than they do together. Inner surfaces are
unchanged; the pane just grows a top tab strip that mounts
each existing controller under [ Rubrics | Growth | Runtime ].
data-fixture-tier stays: the fusion event log is still
fixture-backed (rubric-fusion-seed.js) until the annotation
store and rubric library seams wire up. -->
<button class="tab-btn nav-item" data-tab="evals" data-fixture-tier="true"
title="Evals — three views over the shared rubric scoring event log (Rubrics catalog · Growth timeline · Runtime rollout grid).">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M4 5h12M4 10h12M4 15h8M14 14l2 2 3-3"/></svg>
<span>Rubrics</span>
<span>Evals</span>
</button>
<button class="tab-btn nav-item" data-tab="plugins" title="Plugins">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M7 3v3H4v4a2 2 0 0 0 2 2h1v4h3v-4h2v4h3v-4a2 2 0 0 0 2-2V6h-3V3h-3v3h-2V3z"/></svg>
<span>Plugins</span>
</button>
<button class="tab-btn nav-item" data-tab="runtimes" title="Local runtime profiles + isolated daemons">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M3 5h14v4H3zM3 11h14v4H3zM6 7h.01M6 13h.01M9 7h.01M9 13h.01"/></svg>
<span>Runtimes</span>
</button>
<button class="tab-btn nav-item" data-tab="mission" title="Missions">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="10" cy="10" r="2" fill="currentColor"/><path stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M10 1.5V4M10 16v2.5M1.5 10H4M16 10h2.5"/></svg>
<span>Missions</span>
</button>
<button class="tab-btn nav-item" data-tab="growth" title="Growth (self-evolution log)">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M3 16.5h14M4.5 16V12M8 16V8.5M12 16V5M15.5 16V10M4.5 12l3.5-3.5L12 5l3.5 5"/></svg>
<span>Growth</span>
</button>
<button class="tab-btn nav-item" data-tab="prs" title="Pull Requests">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="5" cy="4" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="16" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="15" cy="16" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><path fill="none" stroke="currentColor" stroke-width="1.5" d="M5 5.8v8.4M5 6.5c0 4 5 4 5 6.5V6M15 14.2V9l-2 2M15 9l2 2"/></svg>
<span>PRs</span>
@@ -237,35 +233,34 @@
</div>
</div>
<!-- Runtimes sidebar — page owns its own body; the sidebar just
orients + provides a refresh button. See runtimes-page.js. -->
<div class="tab-panel" data-tab-panel="runtimes" hidden>
<!-- Evals sidebar (lane-evals-merge, 2026-07-19). Rubrics / Growth /
Runtimes were three siblings until 2026-07-19; each had its own
sidebar panel. They're now one door because the three views
project the same rubric scoring event log. This single panel
orients the researcher to the three tabs (top of the pane) and
preserves both the rubrics-catalog Refresh button and the
Export annotations action from the old Rubrics sidebar so no
deeplink from Chat header / Recent rows loses its target. The
Runtimes refresh button is preserved under the same id so
runtimes-page.js click handlers keep working. -->
<div class="tab-panel" data-tab-panel="evals" hidden>
<div class="sidebar-section-head">
<span class="section-label">Runtimes</span>
<button id="runtimes-refresh" class="icon-btn ghost" title="Reread profile list + status" aria-label="Refresh">
<span class="section-label">Evals</span>
<button id="rubrics-refresh" class="icon-btn ghost" title="Reload the rubric catalog + fusion store" aria-label="Refresh">
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M15.5 4.5v4h-4M4.5 15.5v-4h4M5.2 7.6a6 6 0 0 1 10 .4M14.8 12.4a6 6 0 0 1-10-.4"/></svg>
</button>
</div>
<div class="runtimes-sidebar-note muted">
Local profiles, adapters, and any isolated daemon currently up. Composed
locally until <code>runtime/list</code> lands on the wire (gap G8).
</div>
</div>
<!-- Rubrics sidebar (task #188) — orients the user to the catalog. -->
<div class="tab-panel" data-tab-panel="rubrics" hidden>
<div class="sidebar-section-head">
<span class="section-label">Rubrics</span>
<button id="rubrics-refresh" class="icon-btn ghost" title="Reload the catalog" aria-label="Refresh">
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M15.5 4.5v4h-4M4.5 15.5v-4h4M5.2 7.6a6 6 0 0 1 10 .4M14.8 12.4a6 6 0 0 1-10-.4"/></svg>
</button>
</div>
<div class="rubrics-sidebar-note muted">
Evaluator library — one SKILL.md file per rubric, grouped by task category.
Every rubric attaches to Bench for scoring runs; "Create from scratch"
lives at the bottom of the page as a fallback CTA.
<div class="evals-sidebar-note muted">
One rubric scoring event log, three views. Rubrics = catalog, Growth =
timeline over sessions, Runtime = per-rubric rollout grid. Pick a rubric
from the top selector to keep them in sync.
</div>
<div class="sidebar-foot">
<!-- Runtimes refresh handler still bound by id; keeps the sidebar
button contract stable for runtimes-page.js's click handler
and any deeplink that targeted it. -->
<button id="runtimes-refresh" class="ghost small" title="Reread profile list + status (Runtime tab).">Reload runtimes</button>
<button id="growth-refresh" class="ghost small" title="Reread the growth log (Growth tab).">Reload growth</button>
<button id="rubrics-annotate-open" class="ghost small" title="Open the export drawer for annotated sessions">Export annotations</button>
</div>
</div>
@@ -281,20 +276,11 @@
</div>
</div>
<!-- Growth sidebar (kept minimal — this page is a self-contained page,
the sidebar just orients the user to what it's for). -->
<div class="tab-panel" data-tab-panel="growth" hidden>
<div class="sidebar-section-head">
<span class="section-label">Growth</span>
<button id="growth-refresh" class="icon-btn ghost" title="Reread the growth log" aria-label="Refresh">
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M15.5 4.5v4h-4M4.5 15.5v-4h4M5.2 7.6a6 6 0 0 1 10 .4M14.8 12.4a6 6 0 0 1-10-.4"/></svg>
</button>
</div>
<div class="growth-sidebar-note muted">
Every entry links to a real event — plugin install, overlay apply, vibe
session. This is an audit trail, not a persona.
</div>
</div>
<!-- Growth sidebar folded into the Evals sidebar above (2026-07-19,
lane-evals-merge). The growth-refresh button lives in the new
Evals sidebar-foot so growth-v2.js listeners keep binding by id.
If a future lane splits Growth back out, restore this block
with the original heading + refresh icon. -->
</aside>
<main class="main">
<!-- Chat pane -->
@@ -937,13 +923,67 @@
</div>
</section>
<!-- Rubrics pane (task #188) — catalog-first evaluator library.
<!-- Evals pane (lane-evals-merge, 2026-07-19). Wraps three sub-panes —
Rubrics catalog, Growth timeline, Runtime rollout grid — that
share the same rubric scoring event log (rubric-fusion-model.js).
The outer .pane is what switchTo('evals') toggles; only one of
the three inner sub-panes shows at a time based on the
data-evals-tab strip below. The inner sub-panes keep their
original data-pane values because rubrics-page.js / growth-v2.js
/ runtimes-page.js all query them by that selector — the
renderer.js switchTo() loop is updated to iterate direct main
children only so nested data-pane doesn't fight the outer
hidden flag. Shared selector (rubric picker) sits above the
tab strip so choosing a rubric syncs all three views. -->
<section class="pane" data-pane="evals" hidden>
<header class="header evals-header">
<div class="header-lead">
<div class="page-title">
Evals
<span class="demo-tier-chip" title="The rubric scoring event log is still fixture-backed (rubric-fusion-seed.js). User-authored rubrics and real annotation results land as the G1 library seam and the annotation-store producer wire up.">demo · G1 pending</span>
</div>
<div class="page-sub muted">Rubric library, evolution over time, and per-rubric rollout grid — three views over one scoring event log.</div>
</div>
<!-- Shared rubric selector. Populated on show() by
renderer.js/evals-shared-selector from window.__dshRubricFusion.
Empty by default so first paint is stable and the "all rubrics"
scope is honest. Emits `dsh:evals-rubric-change` when the user
picks — each inner page listens and re-scopes its view. -->
<div class="evals-shared-selector" role="group" aria-label="Shared rubric selector">
<label class="evals-shared-label muted small" for="evals-shared-rubric">Rubric focus</label>
<select id="evals-shared-rubric" class="evals-shared-select"
title="Filter Rubrics catalog · Growth curves · Runtime grid to one rubric. 'All rubrics' keeps the default per-page view.">
<option value="">All rubrics</option>
</select>
</div>
</header>
<!-- Inner tab strip. Only one .evals-tab-pane is visible at a time;
visibility is driven by [data-evals-active] on this .pane
(CSS in style.css matches the attribute so no JS querySelector
per pane is needed). Rubrics is the default because it's the
catalog entry point new researchers see first. -->
<div class="evals-tab-strip" role="tablist" aria-label="Evals view">
<button type="button" class="evals-tab active" data-evals-tab="rubrics"
role="tab" aria-selected="true"
title="Catalog of scoring rubrics grouped by task class.">Rubrics</button>
<button type="button" class="evals-tab" data-evals-tab="growth"
role="tab" aria-selected="false"
title="Score curves over harness versions / models / data mixes.">Growth</button>
<button type="button" class="evals-tab" data-evals-tab="runtime"
role="tab" aria-selected="false"
title="Rollout × rubric-dim red/green grid for the current runtime.">Runtime</button>
</div>
<div class="evals-tab-body">
<!-- Rubrics sub-pane (task #188) — catalog-first evaluator library.
Rendered by src/renderer/rubrics-page.js; the pure model lives at
rubrics-model.js and the fixture blobs are inlined via
rubrics-seed.js. The detail drawer sits fixed to the right; the
annotation flow (task #191) lives in annotation-panel.js and is
reachable from Chat header + Recent list rows. -->
<section class="pane" data-pane="rubrics" hidden>
reachable from Chat header + Recent list rows. Kept as
data-pane="rubrics" so page controllers that query it by that
selector still bind; the class no longer includes 'pane' because
only the outer wrapper is the main-child pane. -->
<section class="evals-tab-pane" data-pane="rubrics" data-evals-tab-pane="rubrics">
<header class="header">
<div class="header-lead">
<div class="page-title">
@@ -976,10 +1016,15 @@
</aside>
</section>
<!-- Growth pane. Rendered by src/renderer/growth-v2.js — the
compact-window "harness evolution log" for researchers. See the
header of growth-v2.js for pane structure + data source. -->
<section class="pane" data-pane="growth" hidden>
<!-- Growth sub-pane (2026-07-19, lane-evals-merge). Rendered by
src/renderer/growth-v2.js — the compact-window "harness evolution
log" for researchers. Section keeps data-pane="growth" so
growth-v2.js's `document.querySelector('.pane[data-pane="growth"]')`
still binds; the .pane class is preserved for the same reason
(growth-v2.js scopes CSS on `.pane[data-pane="growth"]`). The
outer .pane[data-pane="evals"] toggles overall visibility; this
section shows only when data-evals-active="growth" via CSS. -->
<section class="pane evals-tab-pane" data-pane="growth" data-evals-tab-pane="growth" hidden>
<header class="header">
<div class="header-lead">
<div class="page-title">Growth</div>
@@ -1020,14 +1065,17 @@
</section>
</section>
<!-- Runtimes pane (task #189 / IA §3 Runtimes one-pager). Rendered by
src/renderer/runtimes-page.js on switchTo('runtimes'). Body has
two sections: the profile list (one row per registered profile,
expandable into a capability matrix) and the isolated-daemon
section (any Playground scratch daemon currently up). The
legend chip up top names the wire seam this composes locally
until `runtime/list` (G8) lands. -->
<section class="pane" data-pane="runtimes" hidden id="runtimes-pane">
<!-- Runtimes sub-pane (task #189 / IA §3 Runtimes one-pager), now
the Runtime tab inside the Evals pane. Rendered by
src/renderer/runtimes-page.js on switchTo('evals') + Runtime tab
active. Body has two sections: the profile list (one row per
registered profile, expandable into a capability matrix) and
the isolated-daemon section (any Playground scratch daemon
currently up). Section keeps data-pane="runtimes" + id so
runtimes-page.js's `document.getElementById('runtimes-pane')`
still finds it. .pane class preserved so the CSS geometry
(flex column + min-height) still applies. -->
<section class="pane evals-tab-pane" data-pane="runtimes" data-evals-tab-pane="runtime" hidden id="runtimes-pane">
<header class="header">
<div class="header-lead">
<div class="page-title">Runtimes</div>
@@ -1044,6 +1092,8 @@
<div class="runtimes-isolated" data-runtimes-isolated></div>
</section>
</section>
</div><!-- /.evals-tab-body -->
</section><!-- /.pane[data-pane="evals"] -->
<!-- Settings pane (task #193). Two sections: Model pricing (editable
table seeded from price-table.js DEFAULT_PRICE_TABLE, edits

View File

@@ -22,6 +22,19 @@
const DEFAULT_HIDDEN = Object.freeze(['playground-shim', 'mission'])
// Legacy ids that were merged into the single 'evals' door on 2026-07-19
// (lane-evals-merge). Any of these appearing in a user's persisted
// hiddenPages array is remapped so their old config keeps hiding the
// Evals door — otherwise a user who previously chose "hide Rubrics"
// would see Evals reappear on the next launch, effectively rolling
// their preference back. The keys must match the ORIGINAL data-tab
// values as they existed pre-merge; the value is the new door id.
const LEGACY_ID_ALIAS = Object.freeze({
rubrics: 'evals',
growth: 'evals',
runtimes: 'evals',
})
// Page ids that a user can opt into from the Settings > Optional pages
// section. Kept as a small explicit list rather than "everything in the
// default hidden set" because the Settings section is meant to be a
@@ -44,7 +57,19 @@ function resolveHiddenPages(cfg) {
// every optional page in. Filter out non-strings/blanks defensively so
// a malformed entry can't crash the renderer filter.
const cleaned = raw.filter((x) => typeof x === 'string' && x.length > 0)
return cleaned
// Remap legacy ids merged into 'evals' (lane-evals-merge, 2026-07-19).
// Deduplicates so a config that hides all three of rubrics/growth/
// runtimes still yields a single 'evals' entry in the resolved set.
const out = []
const seen = new Set()
for (const id of cleaned) {
const mapped = LEGACY_ID_ALIAS[id] || id
if (!seen.has(mapped)) {
seen.add(mapped)
out.push(mapped)
}
}
return out
}
// Small helper the Settings page uses to compute the next hiddenPages
@@ -57,7 +82,7 @@ function toggleOptionalPage(current, pageId, enable) {
return Array.from(set)
}
const navConfigApi = { DEFAULT_HIDDEN, OPTIONAL_PAGES, resolveHiddenPages, toggleOptionalPage }
const navConfigApi = { DEFAULT_HIDDEN, OPTIONAL_PAGES, LEGACY_ID_ALIAS, resolveHiddenPages, toggleOptionalPage }
if (typeof module !== 'undefined' && module.exports) module.exports = navConfigApi
if (typeof window !== 'undefined') window.__dshNavConfigModel = navConfigApi

View File

@@ -7436,6 +7436,27 @@ if (runtimesRefreshBtn) {
})
}
// Evals sidebar rubric-refresh + growth-refresh (lane-evals-merge,
// 2026-07-19). These ids used to belong to per-page sidebars; after
// the merge they live in the shared Evals sidebar-foot. Forwarding
// from here keeps the page controllers unchanged.
const evalsRubricsRefreshBtn = document.getElementById('rubrics-refresh')
if (evalsRubricsRefreshBtn) {
evalsRubricsRefreshBtn.addEventListener('click', () => {
if (window.__dshRubrics && typeof window.__dshRubrics.refresh === 'function') {
void window.__dshRubrics.refresh()
}
})
}
const evalsGrowthRefreshBtn = document.getElementById('growth-refresh')
if (evalsGrowthRefreshBtn) {
evalsGrowthRefreshBtn.addEventListener('click', () => {
if (window.__dshGrowthV2 && typeof window.__dshGrowthV2.show === 'function') {
void window.__dshGrowthV2.show()
}
})
}
// Populate profile selector and reflect current status.
async function bootUi() {
// instantiate the subagent lineage store once
@@ -7626,8 +7647,34 @@ async function bootUi() {
// sibling modules (plugins-ui.js calls `switchTo` after vibe).
const tabButtons = document.querySelectorAll('.tab-btn')
const tabPanels = document.querySelectorAll('[data-tab-panel]')
const mainPanes = document.querySelectorAll('.main .pane')
// Direct-child pane list, not descendants: the Evals pane
// (lane-evals-merge) nests three sub-panes that still carry .pane +
// data-pane, and iterating descendants would let their `hidden`
// toggle fight the outer wrapper's. Direct child scope means the
// outer Evals pane is the only main-level match; inner sub-panes are
// managed by the tab strip below.
const mainEl = document.querySelector('.main')
const mainPanes = mainEl ? mainEl.querySelectorAll(':scope > .pane') : document.querySelectorAll('.main > .pane')
// Legacy tab id → new evals tab mapping. Callers that still pass
// 'rubrics' / 'growth' / 'runtimes' land on the Evals pane with the
// correct inner tab active — no dead nav routes after the merge.
const EVALS_TAB_ALIAS = Object.freeze({
rubrics: 'rubrics',
growth: 'growth',
runtimes: 'runtime',
})
function switchTo(name) {
// Alias legacy ids to the new evals door + inner tab. Keeps every
// preexisting switchTo('rubrics'|'growth'|'runtimes') call site
// wired without hunting them all down.
if (EVALS_TAB_ALIAS[name]) {
const innerTab = EVALS_TAB_ALIAS[name]
const originalName = name
name = 'evals'
// Pre-set the inner tab so the show() branch below activates it.
state._pendingEvalsTab = innerTab
state._evalsLegacyAlias = originalName
}
// Bug D layer 5 (2026-07-18, team-lead directive from layout-audit
// 187824e): three drawers historically stayed open across tab
// switches — `.fork-compare-drawer`, `.playground-compare-drawer`,
@@ -7676,17 +7723,38 @@ async function bootUi() {
// one tab is reflected the next time the researcher visits Hub.
void window.__dshHub.show()
}
if (name === 'growth' && window.__dshGrowthV2) {
// Growth v2: reads the compact-window history fixture +
// any user-written rubrics/errors under ~/.dsh/growth/. No session-
// list dependency — the page owns its own data source now.
void window.__dshGrowthV2.show()
}
if (name === 'runtimes' && window.__dshRuntimes) {
// local composition — reads listProfiles + runtimeStatus
// + serverCapabilities + Playground list. Async so the profile
// fetch can complete before the row list paints.
void window.__dshRuntimes.show()
if (name === 'evals') {
// Evals door (lane-evals-merge, 2026-07-19). Mounts the three inner
// pages that share the rubric scoring event log:
// - Rubrics catalog (rubrics-page.js)
// - Growth timeline (growth-v2.js)
// - Runtime rollout grid (runtimes-page.js)
// Each sub-page's show() paints into its own [data-pane] section;
// the tab strip decides which one the researcher sees. All three
// are called on entry so switching tabs is a visibility toggle
// (no re-mount cost). Pending-tab from the legacy-alias branch or
// the shared-selector's last pick overrides the default 'rubrics'
// when set. mountEvalsTabStrip() is idempotent and installs the
// tab click + shared selector listeners on first switch.
const evalsPane = document.querySelector('.pane[data-pane="evals"]')
mountEvalsTabStrip(evalsPane)
populateEvalsSharedSelector()
const pending = state._pendingEvalsTab
state._pendingEvalsTab = null
const initialTab = pending || (evalsPane && evalsPane.dataset.evalsActive) || 'rubrics'
setEvalsActiveTab(evalsPane, initialTab)
// Mount all three so the first tab flip after this doesn't have to
// wait on a fetch/paint. Rubrics is synchronous; Growth+Runtimes
// fire-and-forget.
if (window.__dshRubrics && typeof window.__dshRubrics.show === 'function') {
try { window.__dshRubrics.show() } catch (_) { /* defensive: never let one page crash the door */ }
}
if (window.__dshGrowthV2 && typeof window.__dshGrowthV2.show === 'function') {
try { void window.__dshGrowthV2.show() } catch (_) {}
}
if (window.__dshRuntimes && typeof window.__dshRuntimes.show === 'function') {
try { void window.__dshRuntimes.show() } catch (_) {}
}
}
if (name === 'settings' && window.__dshSettings) {
// pricing table + key-presence chart. Reads the
@@ -7694,12 +7762,10 @@ async function bootUi() {
// and merges any localStorage overrides via settings-model.
void window.__dshSettings.show()
}
if (name === 'rubrics' && window.__dshRubrics) {
// Rubrics catalog. Reads fixture SKILL.md blobs via
// window.__dshRubricsSeed; user overlay rubrics from .dsh/rubrics/
// wait on the G1 library/put seam.
window.__dshRubrics.show()
}
// The legacy 'rubrics' / 'growth' / 'runtimes' branches were folded
// into the 'evals' door above (2026-07-19, lane-evals-merge). The
// EVALS_TAB_ALIAS lookup at the top of switchTo() remaps any lingering
// caller to 'evals' with the correct inner tab pre-set.
if (name === 'bench' && window.__dshBench) {
// Bench (#187): local-lite researcher experiment platform. The page
// owns its own data source (inlined fixture batch); no session-list
@@ -7758,7 +7824,88 @@ async function bootUi() {
}
switchTo(tab)
})
window.__dshTabs = { switchTo }
window.__dshTabs = { switchTo, EVALS_TAB_ALIAS }
// --- Evals pane machinery (lane-evals-merge, 2026-07-19) --------------
// Three helpers install the inner tab strip, the shared rubric picker,
// and the active-tab visibility toggle. Kept in renderer.js because
// they need to reach into window.__dshRubricFusion + the sub-page show()
// functions without introducing a new script file (keeps the load-
// order graph in index.html unchanged, gates cleaner).
let _evalsTabStripMounted = false
function mountEvalsTabStrip(paneEl) {
if (_evalsTabStripMounted || !paneEl) return
const strip = paneEl.querySelector('.evals-tab-strip')
if (!strip) return
strip.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-evals-tab]')
if (!btn) return
const tab = btn.dataset.evalsTab
setEvalsActiveTab(paneEl, tab)
})
// Shared selector emits a custom event so each sub-page can react
// without a direct dependency on renderer.js internals. Fires once
// on mount so first paint sees the current pick.
const sel = paneEl.querySelector('#evals-shared-rubric')
if (sel) {
sel.addEventListener('change', () => {
const rubricId = sel.value || null
try {
window.dispatchEvent(new CustomEvent('dsh:evals-rubric-change', { detail: { rubricId } }))
} catch (_) { /* CustomEvent absent (older Electron) — swallow */ }
})
}
_evalsTabStripMounted = true
}
function setEvalsActiveTab(paneEl, tab) {
if (!paneEl || !tab) return
paneEl.dataset.evalsActive = tab
for (const b of paneEl.querySelectorAll('.evals-tab')) {
const on = b.dataset.evalsTab === tab
b.classList.toggle('active', on)
b.setAttribute('aria-selected', on ? 'true' : 'false')
}
for (const p of paneEl.querySelectorAll('.evals-tab-body > .evals-tab-pane')) {
p.hidden = p.dataset.evalsTabPane !== tab
}
// When the Runtime tab activates, kick a repaint — the page may
// have painted while hidden and its measured metrics could be zero.
if (tab === 'runtime' && window.__dshRuntimes && typeof window.__dshRuntimes.refresh === 'function') {
try { void window.__dshRuntimes.refresh() } catch (_) {}
}
}
function populateEvalsSharedSelector() {
const sel = document.getElementById('evals-shared-rubric')
if (!sel) return
const fusion = window.__dshRubricFusion
if (!fusion || typeof fusion.listRubrics !== 'function') return
// Seed the fusion store once if the sub-pages haven't gotten to it
// yet (opening Evals as the first surface). Uses the same fixture
// ref they read from — the store's WeakSet dedupe means repeated
// seeds are idempotent.
if (typeof fusion.loadFixture === 'function' && window.__dshRubricFusionSeed) {
try { fusion.loadFixture(window.__dshRubricFusionSeed) } catch (_) {}
}
const rubrics = fusion.listRubrics()
const currentValue = sel.value
// Rebuild the option list. Preserves "All rubrics" as the sentinel
// first option; the sub-pages treat null/"" as "no shared filter".
sel.innerHTML = ''
const allOpt = document.createElement('option')
allOpt.value = ''
allOpt.textContent = 'All rubrics'
sel.appendChild(allOpt)
for (const r of rubrics) {
const opt = document.createElement('option')
opt.value = r.id
opt.textContent = r.group ? `${r.group} · ${r.name}` : r.name
sel.appendChild(opt)
}
// Restore the previous pick if the id still exists.
if (currentValue && rubrics.some(r => r.id === currentValue)) {
sel.value = currentValue
}
}
// Left-nav hidden-pages filter (lane-nav-optional). Reads the shell
// config's `hiddenPages` array through the nav IPC and toggles a

View File

@@ -690,6 +690,25 @@
}
}
// Shared Evals-pane rubric selector (lane-evals-merge, 2026-07-19).
// When the researcher picks a rubric from the top selector, open that
// rubric's detail drawer so the catalog view reflects the shared pick.
// "All rubrics" (empty value) closes any open drawer to reset scope.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('dsh:evals-rubric-change', (ev) => {
const rid = ev && ev.detail && ev.detail.rubricId
if (!rid) {
try { closeDetail() } catch (_) {}
return
}
// Only act when the Rubrics tab is the active Evals tab, otherwise
// opening the drawer would flash behind another tab's body.
const evalsPane = document.querySelector('.pane[data-pane="evals"]')
if (evalsPane && evalsPane.dataset.evalsActive !== 'rubrics') return
try { openDetail(rid) } catch (_) {}
})
}
if (typeof window !== 'undefined') {
window.__dshRubrics = { mount, show, refresh, renderCatalog, openDetail, closeDetail, openCreateForm, closeCreateForm, _state: state }
if (document.readyState === 'loading') {

View File

@@ -29,6 +29,12 @@
const rootId = 'runtimes-pane'
let currentTab = 'rubric-grid'
let fusionSeeded = false
// Shared Evals-pane rubric selector filter (lane-evals-merge). null =
// "All rubrics"; a rubric id scopes the grid to a single card so a
// researcher can compare that rubric's rollout matrix in isolation
// when the Rubrics tab is showing its detail and Runtime tab is
// still hydrating the same choice.
let sharedRubricFilter = null
function fusion() {
return typeof window !== 'undefined' ? window.__dshRubricFusion : null
@@ -378,11 +384,17 @@
host.appendChild(muted('Rubric fusion store not loaded.'))
return
}
const rubrics = f.listRubrics()
let rubrics = f.listRubrics()
if (!rubrics.length) {
host.appendChild(muted('No rubrics registered. Author one under Rubrics → Create from scratch, or load the fusion fixture.'))
return
}
// If the shared Evals selector picked a rubric, scope the grid to
// that one. Otherwise show a card per rubric (default behaviour).
if (sharedRubricFilter) {
const scoped = rubrics.filter(r => r.id === sharedRubricFilter)
if (scoped.length) rubrics = scoped
}
// Header row: one card per rubric.
for (const rubric of rubrics) {
const grid = f.rolloutGridFor(rubric.id, null)
@@ -491,6 +503,24 @@
return s
}
// Shared Evals-pane rubric selector (lane-evals-merge, 2026-07-19).
// When the researcher picks a rubric from the top selector, scope
// the rollout grid to that rubric only; "All rubrics" restores the
// per-rubric card view.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('dsh:evals-rubric-change', (ev) => {
const rid = ev && ev.detail && ev.detail.rubricId
sharedRubricFilter = rid || null
// Only repaint when the Runtime tab is what the user is looking
// at; otherwise the next setEvalsActiveTab('runtime') call in
// renderer.js triggers refresh() and the filter takes effect.
const evalsPane = document.querySelector('.pane[data-pane="evals"]')
if (evalsPane && evalsPane.dataset.evalsActive === 'runtime') {
try { void show() } catch (_) {}
}
})
}
if (typeof window !== 'undefined') {
window.__dshRuntimes = { show, refresh: () => show() }
}

View File

@@ -12971,3 +12971,85 @@ button.artifact-version:hover {
.tool-block:not([open]) > summary .tool-edit-rerun-trigger {
display: none;
}
/* Evals pane (lane-evals-merge, 2026-07-19)
* Three former panes — Rubrics, Growth, Runtimes — collapse into one
* .pane[data-pane="evals"]. The outer .pane[data-pane="evals"] flex column
* layout is inherited from `.main .pane`. Inside, header + tab strip sit
* at the top; only one .evals-tab-pane is rendered at a time based on
* [data-evals-active] on the outer .pane.
*
* The inner .evals-tab-pane sections keep their `.pane` class so
* page-scoped CSS in this file (.pane[data-pane="growth"] {…} etc.)
* still applies without a rewrite. The `hidden` attribute is what
* hides inactive tabs; JS in renderer.js flips it on tab click. */
.pane[data-pane="evals"] {
display: flex;
flex-direction: column;
min-height: 0;
}
.pane[data-pane="evals"] > .evals-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.evals-shared-selector {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.evals-shared-label {
white-space: nowrap;
}
.evals-shared-select {
min-width: 180px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.evals-tab-strip {
display: flex;
gap: 4px;
padding: 0 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.evals-tab {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
padding: 8px 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.evals-tab:hover {
color: var(--fg);
}
.evals-tab.active {
color: var(--fg);
border-bottom-color: var(--accent);
}
.evals-tab-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.evals-tab-body > .evals-tab-pane {
flex: 1;
min-height: 0;
}
/* When the pane is hidden the browser handles it via [hidden]; when a
* tab is inactive its inner section is hidden the same way. */
.evals-tab-body > .evals-tab-pane[hidden] {
display: none;
}

View File

@@ -37,8 +37,37 @@ test('resolveHiddenPages: empty array → show everything', () => {
test('resolveHiddenPages: custom list → honored as-is', () => {
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['prs', 'growth'] }),
['prs', 'growth']
M.resolveHiddenPages({ hiddenPages: ['prs', 'bench'] }),
['prs', 'bench']
)
})
test('resolveHiddenPages: legacy rubrics/growth/runtimes ids remap to evals (lane-evals-merge)', () => {
// 2026-07-19: three separate nav items were merged into one Evals
// door. Old config values must keep hiding what the user asked for —
// if they'd hidden Rubrics before, they should still see no Rubrics
// surface after upgrade (which means Evals stays hidden).
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['rubrics'] }),
['evals']
)
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['growth'] }),
['evals']
)
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['runtimes'] }),
['evals']
)
// Multiple legacy ids collapse to a single 'evals' entry.
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['rubrics', 'growth', 'runtimes'] }),
['evals']
)
// Legacy ids mixed with unrelated ids preserve the unrelated ids.
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['prs', 'growth', 'bench'] }),
['prs', 'evals', 'bench']
)
})
@@ -50,8 +79,8 @@ test('resolveHiddenPages: non-array garbage → falls back to defaults (safe)',
test('resolveHiddenPages: filters out non-string / blank entries', () => {
assert.deepStrictEqual(
M.resolveHiddenPages({ hiddenPages: ['prs', '', null, 42, 'growth'] }),
['prs', 'growth']
M.resolveHiddenPages({ hiddenPages: ['prs', '', null, 42, 'bench'] }),
['prs', 'bench']
)
})
@@ -219,13 +248,13 @@ function makeSidebar() {
makeBtn('hub'),
makeBtn('bench'),
])
// "runtime" group — mission (default hidden) sits here.
// "runtime" group — mission (default hidden) sits here. Post
// lane-evals-merge (2026-07-19) rubrics/growth/runtimes fold into
// a single 'evals' button; the sidebar shim mirrors that new shape.
makeGroup([
makeBtn('rubrics'),
makeBtn('evals'),
makeBtn('plugins'),
makeBtn('runtimes'),
makeBtn('mission'),
makeBtn('growth'),
makeBtn('prs'),
])
return { buttons, groups }
@@ -263,11 +292,13 @@ test('DOM filter (case 2): empty array → nothing hidden (all pages show)', ()
test('DOM filter (case 3): custom list → matching buttons hidden, others untouched', () => {
const sb = makeSidebar()
// 'growth' remaps to 'evals' via LEGACY_ID_ALIAS (lane-evals-merge);
// 'prs' passes through unchanged. Both target actual sidebar buttons.
applyFilter(sb, { hiddenPages: ['prs', 'growth'] })
const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden'))
assert.deepStrictEqual(
hidden.map((b) => b.dataset.tab).sort(),
['growth', 'prs']
['evals', 'prs']
)
// Playground + mission are NOT hidden because the researcher opted them in
// via an explicit list; only the ids in the list are hidden.

View File

@@ -41,10 +41,28 @@ test('no lane still carries pending — all four slots flipped', () => {
`all four coordinated slots should be flipped (found ${buttonPending.length} still-pending)`)
})
test('Runtimes tab and pane exist', () => {
assert.match(HTML, /data-tab="runtimes"/)
assert.match(HTML, /data-pane="runtimes"/)
assert.match(HTML, /id="runtimes-pane"/)
test('Runtimes surface preserved inside the Evals pane', () => {
// Post lane-evals-merge (2026-07-19), Runtimes is a tab inside the
// Evals door, not its own top-level nav. The nav button is 'evals';
// the runtimes-pane id + data-pane="runtimes" stay because
// runtimes-page.js queries them by that selector.
assert.match(HTML, /data-tab="evals"/, 'Evals nav button missing')
assert.match(HTML, /data-pane="runtimes"/, 'runtimes sub-pane still keeps its data-pane hook')
assert.match(HTML, /id="runtimes-pane"/, 'runtimes-pane id preserved for runtimes-page.js binding')
assert.match(HTML, /data-evals-tab-pane="runtime"/, 'Runtime tab pane marker present')
})
test('Evals door hosts Rubrics/Growth/Runtime as tabs', () => {
// Sanity: the three sub-pane markers are present and reachable via
// their evals-tab id. Guards against a future edit that silently
// orphans a tab pane.
assert.match(HTML, /data-evals-tab="rubrics"/, 'Rubrics tab button present')
assert.match(HTML, /data-evals-tab="growth"/, 'Growth tab button present')
assert.match(HTML, /data-evals-tab="runtime"/, 'Runtime tab button present')
assert.match(HTML, /data-evals-tab-pane="rubrics"/, 'Rubrics tab pane present')
assert.match(HTML, /data-evals-tab-pane="growth"/, 'Growth tab pane present')
assert.match(HTML, /data-evals-tab-pane="runtime"/, 'Runtime tab pane present')
assert.match(HTML, /id="evals-shared-rubric"/, 'Shared rubric selector present')
})
test('Settings tab and pane exist', () => {