feat(desktop): artifact evolution chain + Board/Timeline views
Adds a version-evolution capability to the Artifacts pane:
- Evolution chain: LCS-based diff across successive versions of the
same logical artifact; when a blob is missing, the chain honestly
surfaces a placeholder rather than fabricating diff content.
- Board / Timeline tab switcher on the Artifacts page.
- Auto-grouping into 5 kinds so the Board reads at a glance.
Files:
src/renderer/artifacts-board.js — new Board / Timeline / Evolution views
src/renderer/artifacts.js — wire Board tab + evolution rail
src/renderer/index.html — script tag + tab markup
src/renderer/style.css — tail append (~200 lines)
test/artifact-evolution-board.test.js — 23 new tests
docs/artifact-board-fixture.json — fixture seed
docs/qa-artifact-evolution/ — 3 QA screenshots + fixture.html
scripts/qa-cdp-shoot-artifact-v2.mjs — QA capture harness
Merged as source-repo test-real @ 7279870 (with lane-side conflict
resolution against test-real @ c1d93aa: style.css tail-append union;
independent of Lanes C/A/D CSS sections).
Test suite: 1727/1727 pass (+23 over Lane C+A+D baseline of 1704).
Follow-up L-3 (in upstream ledger): runtime ArtifactServer to grow
a snapshot store so the "missing blob" fallback becomes rare.
This commit is contained in:
86
examples/desktop/docs/artifact-board-fixture.json
Normal file
86
examples/desktop/docs/artifact-board-fixture.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"_note": "Fixture for lane-artifact-v2 (Artifacts evolution chain + Board view). Each entry is one artifact:event payload the renderer would receive. Multi-version entries share an artifactId; blob content is included so the evolution diff pane can render real per-hop line-diffs. The real ArtifactServer does not include content in its broadcast — that's the constraint the strip's fallback 'content not preserved' note is calibrated for.",
|
||||||
|
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactId": "session.md",
|
||||||
|
"kind": "md",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000000000,
|
||||||
|
"path": "/w/.artifacts/session.md",
|
||||||
|
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "session.md",
|
||||||
|
"kind": "md",
|
||||||
|
"version": 2,
|
||||||
|
"seenAt": 1747000060000,
|
||||||
|
"path": "/w/.artifacts/session.md",
|
||||||
|
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\nTurn 2: agent scaffolded landing.html and hero.svg.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "session.md",
|
||||||
|
"kind": "md",
|
||||||
|
"version": 3,
|
||||||
|
"seenAt": 1747000180000,
|
||||||
|
"path": "/w/.artifacts/session.md",
|
||||||
|
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\nTurn 2: agent scaffolded landing.html and hero.svg.\nTurn 3: agent added responsive styles and OG tags.\nTurn 4: agent added README with build steps.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "landing.html",
|
||||||
|
"kind": "html",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000030000,
|
||||||
|
"path": "/w/.artifacts/landing.html",
|
||||||
|
"blob": "<!doctype html>\n<html>\n<head><title>Landing</title></head>\n<body>\n <h1>Hello</h1>\n</body>\n</html>\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "landing.html",
|
||||||
|
"kind": "html",
|
||||||
|
"version": 2,
|
||||||
|
"seenAt": 1747000090000,
|
||||||
|
"path": "/w/.artifacts/landing.html",
|
||||||
|
"blob": "<!doctype html>\n<html>\n<head>\n <title>Landing</title>\n <meta name=\"description\" content=\"A demo landing page\">\n</head>\n<body>\n <h1>Hello</h1>\n <p>Welcome to our product.</p>\n</body>\n</html>\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "hero.svg",
|
||||||
|
"kind": "svg",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000045000,
|
||||||
|
"path": "/w/.artifacts/hero.svg",
|
||||||
|
"blob": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 100\">\n <rect width=\"200\" height=\"100\" fill=\"#eef\"/>\n</svg>\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "config.json",
|
||||||
|
"kind": "json",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000120000,
|
||||||
|
"path": "/w/.artifacts/config.json",
|
||||||
|
"blob": "{\n \"name\": \"landing\",\n \"version\": \"0.1.0\"\n}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "config.json",
|
||||||
|
"kind": "json",
|
||||||
|
"version": 2,
|
||||||
|
"seenAt": 1747000200000,
|
||||||
|
"path": "/w/.artifacts/config.json",
|
||||||
|
"blob": "{\n \"name\": \"landing\",\n \"version\": \"0.2.0\",\n \"port\": 3000\n}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "build.sh",
|
||||||
|
"kind": "sh",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000150000,
|
||||||
|
"path": "/w/.artifacts/build.sh",
|
||||||
|
"blob": "#!/bin/sh\nset -e\nnpm install\nnpm run build\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactId": "README.md",
|
||||||
|
"kind": "md",
|
||||||
|
"version": 1,
|
||||||
|
"seenAt": 1747000210000,
|
||||||
|
"path": "/w/.artifacts/README.md",
|
||||||
|
"blob": "# Landing\n\nStatic landing page for the demo product.\n\n## Build\n\n```sh\nsh build.sh\n```\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
examples/desktop/docs/qa-artifact-evolution/01-list.png
Normal file
BIN
examples/desktop/docs/qa-artifact-evolution/01-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
examples/desktop/docs/qa-artifact-evolution/02-board.png
Normal file
BIN
examples/desktop/docs/qa-artifact-evolution/02-board.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
examples/desktop/docs/qa-artifact-evolution/03-evolution.png
Normal file
BIN
examples/desktop/docs/qa-artifact-evolution/03-evolution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
99
examples/desktop/docs/qa-artifact-evolution/fixture.html
Normal file
99
examples/desktop/docs/qa-artifact-evolution/fixture.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Reproducible fixture for the lane-artifact-v2 change (version
|
||||||
|
evolution strip + Board / Timeline views). Loads the fixture data
|
||||||
|
from docs/artifact-board-fixture.json and replays every entry
|
||||||
|
through window.__cb (the onArtifact bridge) in order, so:
|
||||||
|
* Auto-group + L0 rows populate the List view.
|
||||||
|
* Multi-version entries (session.md v1→v3, landing.html v1→v2,
|
||||||
|
config.json v1→v2) exercise the evolution strip diffs.
|
||||||
|
* Every kind bucket (md/html/svg/json/code/other) surfaces in the
|
||||||
|
Board view.
|
||||||
|
Screenshots for docs/qa-artifact-evolution/ are taken with the
|
||||||
|
panel in each of the three views + the evolution strip opened. -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>lane-artifact-v2 fixture — Board + evolution</title>
|
||||||
|
<link rel="stylesheet" href="../../src/renderer/style.css">
|
||||||
|
<style>
|
||||||
|
/* Bare-minimum host chrome. */
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||||
|
.app { display: flex; height: 100vh; }
|
||||||
|
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
|
.header {
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
.header .fixture-controls button {
|
||||||
|
background: var(--bg-elev-2); color: var(--text);
|
||||||
|
border: 1px solid var(--border); padding: 3px 10px;
|
||||||
|
border-radius: 4px; font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.header .fixture-controls button:hover { border-color: var(--accent); }
|
||||||
|
#stream {
|
||||||
|
flex: 1; padding: 12px; overflow-y: auto;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
/* Silence the pulse + arrival flash so screenshots capture the
|
||||||
|
resting state, matching the qa-artifact-compact convention. */
|
||||||
|
.artifact-live-dot { animation: none !important; }
|
||||||
|
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<div class="main">
|
||||||
|
<div class="header">
|
||||||
|
<span>Fixture: 3 multi-version + 4 single-version artifacts across 5 kinds</span>
|
||||||
|
<span class="fixture-controls" style="margin-left: auto; display: flex; gap: 6px;">
|
||||||
|
<button data-view="list">List</button>
|
||||||
|
<button data-view="board">Board</button>
|
||||||
|
<button data-view="timeline">Timeline</button>
|
||||||
|
<button id="open-evolution">Open evolution: session.md</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="stream" class="stream"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fake bridge — artifacts.js only touches window.dsh.onArtifact +
|
||||||
|
window.dsh.openArtifact + window.dsh.mockArtifact. -->
|
||||||
|
<script>
|
||||||
|
window.dsh = {
|
||||||
|
onArtifact: (cb) => { window.__cb = cb },
|
||||||
|
openArtifact: async () => ({ ok: true }),
|
||||||
|
mockArtifact: async () => {},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="../../src/renderer/artifacts-board.js"></script>
|
||||||
|
<script src="../../src/renderer/artifacts.js"></script>
|
||||||
|
<script>
|
||||||
|
// Replay the fixture through the same onArtifact seam the real IPC
|
||||||
|
// event uses. Because the artifacts.js history tracker de-dups on
|
||||||
|
// version, feeding multi-version entries in order produces exactly
|
||||||
|
// the same chain a real session would.
|
||||||
|
async function main() {
|
||||||
|
const res = await fetch('../artifact-board-fixture.json')
|
||||||
|
const data = await res.json()
|
||||||
|
for (const entry of data.artifacts) {
|
||||||
|
window.__cb(entry)
|
||||||
|
}
|
||||||
|
// Wire the header buttons to the exposed switchView.
|
||||||
|
const api = window.__dshArtifacts
|
||||||
|
for (const btn of document.querySelectorAll('.fixture-controls button[data-view]')) {
|
||||||
|
btn.addEventListener('click', () => api.switchView(btn.dataset.view))
|
||||||
|
}
|
||||||
|
// Convenience: pop the version chip on session.md so QA can
|
||||||
|
// snap the evolution strip without a manual click search.
|
||||||
|
document.getElementById('open-evolution').addEventListener('click', () => {
|
||||||
|
const card = document.querySelector('.artifact-card[data-artifact-id="session.md"]')
|
||||||
|
if (!card) return
|
||||||
|
const chip = card.querySelector('.artifact-version')
|
||||||
|
if (chip) chip.click()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
179
examples/desktop/scripts/qa-cdp-shoot-artifact-v2.mjs
Normal file
179
examples/desktop/scripts/qa-cdp-shoot-artifact-v2.mjs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// QA shoot for lane-artifact-v2 (Artifacts evolution chain + Board view).
|
||||||
|
//
|
||||||
|
// Cheap variant vs. the full electron-shoot pattern (qa-cdp-shoot-*.mjs):
|
||||||
|
// this loads docs/qa-artifact-evolution/fixture.html directly in a bare
|
||||||
|
// Electron BrowserWindow — no user-data-dir, no seed overlay, no
|
||||||
|
// $DSH_DESKTOP_HOME plumbing, because the fixture doesn't touch profiles
|
||||||
|
// / plugins / main.js. It just needs a Chromium to render style.css +
|
||||||
|
// artifacts.js + artifacts-board.js against the fixture JSON.
|
||||||
|
//
|
||||||
|
// Shoots three screenshots into docs/qa-artifact-evolution/:
|
||||||
|
// 01-list.png — default List view (compact rows + auto-group)
|
||||||
|
// 02-board.png — Board view grouped by kind
|
||||||
|
// 03-evolution.png — session.md's evolution strip expanded, showing
|
||||||
|
// the v1→v2 diff pane opened
|
||||||
|
|
||||||
|
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_ARTIFACT_V2_PORT || 9276)
|
||||||
|
|
||||||
|
const OUTDIR = join(WORKTREE, 'docs/qa-artifact-evolution')
|
||||||
|
if (!existsSync(OUTDIR)) mkdirSync(OUTDIR, { recursive: true })
|
||||||
|
|
||||||
|
// Tiny electron main.js: loads a single file via `file://`. Written to
|
||||||
|
// /tmp/dsh-artifact-v2-<pid>/main.js so we don't pollute the worktree.
|
||||||
|
const APPDIR = join(tmpdir(), `dsh-artifact-v2-${process.pid}`)
|
||||||
|
mkdirSync(APPDIR, { recursive: true })
|
||||||
|
const fixturePath = join(WORKTREE, 'docs/qa-artifact-evolution/fixture.html')
|
||||||
|
writeFileSync(join(APPDIR, 'package.json'), JSON.stringify({
|
||||||
|
name: 'dsh-artifact-v2-shoot', main: 'main.js',
|
||||||
|
}))
|
||||||
|
writeFileSync(join(APPDIR, 'main.js'), `
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
app.commandLine.appendSwitch('remote-debugging-port', '${CDP_PORT}')
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200, height: 900,
|
||||||
|
webPreferences: { nodeIntegration: false, contextIsolation: true },
|
||||||
|
})
|
||||||
|
win.loadFile(${JSON.stringify(fixturePath)})
|
||||||
|
})
|
||||||
|
app.on('window-all-closed', () => app.quit())
|
||||||
|
`)
|
||||||
|
|
||||||
|
async function bootElectron() {
|
||||||
|
const child = spawn(ELECTRON, ['.'], {
|
||||||
|
cwd: APPDIR,
|
||||||
|
env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: '1' },
|
||||||
|
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 {} }
|
||||||
|
process.on('exit', kill)
|
||||||
|
process.on('SIGINT', () => { kill(); process.exit(1) })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500)
|
||||||
|
const cdp = await newCdp()
|
||||||
|
await cdp.call('Page.enable')
|
||||||
|
await cdp.call('Emulation.setDeviceMetricsOverride', {
|
||||||
|
width: 1200, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for fixture data to load — the fetch() inside fixture.html
|
||||||
|
// is async so give it a beat.
|
||||||
|
await sleep(1500)
|
||||||
|
const state = await cdp.evj(`(() => {
|
||||||
|
const api = window.__dshArtifacts
|
||||||
|
const board = window.__dshArtifactsBoard
|
||||||
|
return {
|
||||||
|
cards: api ? api.cards.size : -1,
|
||||||
|
historyKeys: api ? [...api.history.keys()] : [],
|
||||||
|
boardOk: !!board,
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.error('fixture state:', JSON.stringify(state))
|
||||||
|
if (!state.cards || state.cards < 5) throw new Error('fixture did not populate: ' + JSON.stringify(state))
|
||||||
|
|
||||||
|
const shoot = async (name) => {
|
||||||
|
await sleep(300)
|
||||||
|
const r = await cdp.call('Page.captureScreenshot', { format: 'png' }, 30000)
|
||||||
|
writeFileSync(join(OUTDIR, name + '.png'), Buffer.from(r.data, 'base64'))
|
||||||
|
console.error('wrote', name + '.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 01. List view — default state, auto-grouped rows.
|
||||||
|
await shoot('01-list')
|
||||||
|
|
||||||
|
// 02. Board view.
|
||||||
|
await cdp.evj(`window.__dshArtifacts.switchView('board')`)
|
||||||
|
await sleep(400)
|
||||||
|
await shoot('02-board')
|
||||||
|
|
||||||
|
// 03. Evolution strip — return to List, click session.md's version
|
||||||
|
// chip, and open the first diff pane so the diff is captured in the
|
||||||
|
// resting state.
|
||||||
|
await cdp.evj(`window.__dshArtifacts.switchView('list')`)
|
||||||
|
await sleep(300)
|
||||||
|
await cdp.evj(`(() => {
|
||||||
|
const card = document.querySelector('.artifact-card[data-artifact-id="session.md"]')
|
||||||
|
const chip = card && card.querySelector('.artifact-version')
|
||||||
|
if (chip) chip.click()
|
||||||
|
// Open the first diff pane so the v1→v2 line-diff is visible in the shot.
|
||||||
|
const firstDiff = document.querySelector('.artifact-evolution-diff')
|
||||||
|
if (firstDiff) firstDiff.open = true
|
||||||
|
return true
|
||||||
|
})()`)
|
||||||
|
await sleep(400)
|
||||||
|
// Scroll the strip into view so the shot's top has it.
|
||||||
|
await cdp.evj(`document.querySelector('.artifact-evolution').scrollIntoView({ block: 'start' })`)
|
||||||
|
await sleep(200)
|
||||||
|
await shoot('03-evolution')
|
||||||
|
|
||||||
|
console.error('DONE — shots at', OUTDIR)
|
||||||
|
} finally {
|
||||||
|
kill()
|
||||||
|
try { rmSync(APPDIR, { recursive: true, force: true }) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('shoot failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
426
examples/desktop/src/renderer/artifacts-board.js
Normal file
426
examples/desktop/src/renderer/artifacts-board.js
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
// Artifact panel views: Board grid + Timeline list + Evolution chain.
|
||||||
|
//
|
||||||
|
// The compact L0 row (density-spec §2, artifacts.js) is the default List
|
||||||
|
// view. This module adds two more projections of the same event stream:
|
||||||
|
//
|
||||||
|
// * Board — group by kind (md / html / svg / json / code / other),
|
||||||
|
// each group a tile grid with thumbnails. Good when the
|
||||||
|
// front-end task is producing several files and you want to
|
||||||
|
// see "what's ready" at a glance rather than scroll rows.
|
||||||
|
// * Timeline — chronological, one row per artifact-version tuple, so
|
||||||
|
// you can watch "which file changed when" over the session.
|
||||||
|
//
|
||||||
|
// Plus the version-evolution chain: clicking a `.artifact-version` chip
|
||||||
|
// on any List row expands an inline strip listing every version this
|
||||||
|
// artifact has seen, with a per-hop diff pane. The chain is fed from
|
||||||
|
// state.history[artifactId] which artifacts.js maintains as events arrive.
|
||||||
|
//
|
||||||
|
// Runtime constraint: the artifact server only serves the CURRENT file
|
||||||
|
// contents (see src/main/artifact-server.js — no per-version blob store).
|
||||||
|
// This module accepts an optional `blob` field on history entries so the
|
||||||
|
// fixture can demo real diffs; when absent, the pre-latest hops render a
|
||||||
|
// "content not preserved for older versions" placeholder — an honest
|
||||||
|
// signal that the diff is fixture-tier until the runtime seam grows a
|
||||||
|
// snapshot store (RFC follow-up).
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
;(function () {
|
||||||
|
const KINDS = ['md', 'html', 'svg', 'json', 'code', 'other']
|
||||||
|
const KIND_LABEL = {
|
||||||
|
md: 'Markdown', html: 'HTML', svg: 'SVG', json: 'JSON',
|
||||||
|
code: 'Code', other: 'Other',
|
||||||
|
}
|
||||||
|
// Mirrors artifacts.js's ICON_SVG shape — inline stroke icons keep the
|
||||||
|
// Board tiles consistent with the row icons on the List view.
|
||||||
|
const KIND_ICON = {
|
||||||
|
md:
|
||||||
|
'<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="M11 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V8zM11 3v4a1 1 0 0 0 1 1h4M7 11h6M7 13h4"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
html:
|
||||||
|
'<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="M11 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V8zM11 3v4a1 1 0 0 0 1 1h4"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
svg:
|
||||||
|
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
|
||||||
|
+ '<rect x="3" y="4" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>'
|
||||||
|
+ '<circle cx="7" cy="8" r="1.4" fill="none" stroke="currentColor" stroke-width="1.4"/>'
|
||||||
|
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M4 14l4-4 3 3 3-3 3 3"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
json:
|
||||||
|
'<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="M8 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17H8M12 3h2.5A1.5 1.5 0 0 1 16 4.5v11a1.5 1.5 0 0 1-1.5 1.5H12"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
code:
|
||||||
|
'<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="M7 6l-4 4 4 4M13 6l4 4-4 4M11 4l-2 12"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
other:
|
||||||
|
'<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="M14.5 8.5 8 15a3 3 0 0 1-4.2-4.2l7-7a2 2 0 0 1 2.8 2.8L7.5 12.5a1 1 0 0 1-1.4-1.4L12 5.5"/>'
|
||||||
|
+ '</svg>',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the free-form `kind` value each event carries into one of
|
||||||
|
// the six Board buckets — HTML from real artifacts sometimes arrives as
|
||||||
|
// 'html', 'htm', 'text/html'; keep the mapping generous.
|
||||||
|
function bucketOf(kind) {
|
||||||
|
if (!kind) return 'other'
|
||||||
|
const k = String(kind).toLowerCase()
|
||||||
|
if (k.includes('md') || k.includes('markdown')) return 'md'
|
||||||
|
if (k.includes('svg')) return 'svg'
|
||||||
|
if (k.includes('html') || k.includes('htm')) return 'html'
|
||||||
|
if (k.includes('json')) return 'json'
|
||||||
|
if (['js', 'ts', 'py', 'go', 'rs', 'sh', 'c', 'cpp', 'rb', 'java'].some((x) => k === x || k.includes(x))) return 'code'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByKind(entries) {
|
||||||
|
const groups = new Map()
|
||||||
|
for (const k of KINDS) groups.set(k, [])
|
||||||
|
for (const e of entries) {
|
||||||
|
const b = bucketOf(e.kind)
|
||||||
|
groups.get(b).push(e)
|
||||||
|
}
|
||||||
|
// Drop empty buckets so the Board doesn't render empty group headers
|
||||||
|
// when a session has only produced markdown, etc.
|
||||||
|
for (const k of KINDS) {
|
||||||
|
if (groups.get(k).length === 0) groups.delete(k)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
const nl = text.indexOf('\n')
|
||||||
|
return (nl < 0 ? text : text.slice(0, nl)).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a small text preview for the tile: MD → first non-blank line;
|
||||||
|
// JSON → first two keys; code → first two non-blank lines; SVG/HTML
|
||||||
|
// fall back to the filename since a code preview isn't legible.
|
||||||
|
function thumbnailPreview(entry) {
|
||||||
|
const b = bucketOf(entry.kind)
|
||||||
|
const blob = entry.blob || ''
|
||||||
|
if (b === 'md') return firstLine(blob) || entry.artifactId
|
||||||
|
if (b === 'json') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(blob)
|
||||||
|
const keys = Object.keys(parsed).slice(0, 3)
|
||||||
|
return keys.length ? '{ ' + keys.join(', ') + ' }' : entry.artifactId
|
||||||
|
} catch {
|
||||||
|
return firstLine(blob) || entry.artifactId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b === 'code') {
|
||||||
|
const lines = blob.split('\n').filter((l) => l.trim()).slice(0, 3)
|
||||||
|
return lines.join('\n') || entry.artifactId
|
||||||
|
}
|
||||||
|
if (b === 'html') return firstLine(blob.replace(/<[^>]+>/g, ' ')) || entry.artifactId
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board renderer: one <section class="artifact-board-group"> per kind
|
||||||
|
// bucket, containing a grid of `.artifact-tile` cards. Clicking a tile
|
||||||
|
// opens the artifact in the system browser (same seam as the L0
|
||||||
|
// `open ↗` link on the List view).
|
||||||
|
function renderBoard(entries, opts) {
|
||||||
|
const openArtifact = (opts && opts.openArtifact) || (() => {})
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'artifact-board'
|
||||||
|
const groups = groupByKind(entries)
|
||||||
|
for (const [kind, group] of groups) {
|
||||||
|
const section = document.createElement('section')
|
||||||
|
section.className = 'artifact-board-group'
|
||||||
|
section.dataset.kind = kind
|
||||||
|
const head = document.createElement('header')
|
||||||
|
head.className = 'artifact-board-group-head'
|
||||||
|
const icon = document.createElement('span')
|
||||||
|
icon.className = 'artifact-board-group-icon'
|
||||||
|
icon.innerHTML = KIND_ICON[kind] || KIND_ICON.other
|
||||||
|
const title = document.createElement('span')
|
||||||
|
title.className = 'artifact-board-group-title'
|
||||||
|
title.textContent = KIND_LABEL[kind] || kind
|
||||||
|
const count = document.createElement('span')
|
||||||
|
count.className = 'artifact-board-group-count'
|
||||||
|
count.textContent = String(group.length)
|
||||||
|
head.append(icon, title, count)
|
||||||
|
section.append(head)
|
||||||
|
|
||||||
|
const grid = document.createElement('div')
|
||||||
|
grid.className = 'artifact-board-grid'
|
||||||
|
for (const entry of group) {
|
||||||
|
grid.appendChild(renderTile(entry, openArtifact))
|
||||||
|
}
|
||||||
|
section.append(grid)
|
||||||
|
el.append(section)
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTile(entry, openArtifact) {
|
||||||
|
const tile = document.createElement('button')
|
||||||
|
tile.type = 'button'
|
||||||
|
tile.className = 'artifact-tile'
|
||||||
|
tile.dataset.artifactId = entry.artifactId
|
||||||
|
tile.dataset.kind = bucketOf(entry.kind)
|
||||||
|
tile.setAttribute('aria-label', `Open ${entry.artifactId} v${entry.version || 1} in browser`)
|
||||||
|
tile.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
Promise.resolve().then(() => openArtifact(entry.artifactId))
|
||||||
|
})
|
||||||
|
const thumb = document.createElement('div')
|
||||||
|
thumb.className = 'artifact-thumb'
|
||||||
|
thumb.dataset.kind = bucketOf(entry.kind)
|
||||||
|
const preview = thumbnailPreview(entry)
|
||||||
|
if (preview) {
|
||||||
|
const pre = document.createElement('pre')
|
||||||
|
pre.className = 'artifact-thumb-text'
|
||||||
|
pre.textContent = preview
|
||||||
|
thumb.append(pre)
|
||||||
|
} else {
|
||||||
|
const icon = document.createElement('span')
|
||||||
|
icon.className = 'artifact-thumb-icon'
|
||||||
|
icon.innerHTML = KIND_ICON[bucketOf(entry.kind)] || KIND_ICON.other
|
||||||
|
thumb.append(icon)
|
||||||
|
}
|
||||||
|
const meta = document.createElement('div')
|
||||||
|
meta.className = 'artifact-tile-meta'
|
||||||
|
const name = document.createElement('span')
|
||||||
|
name.className = 'artifact-tile-name'
|
||||||
|
name.textContent = entry.artifactId
|
||||||
|
name.title = entry.path || entry.artifactId
|
||||||
|
const ver = document.createElement('span')
|
||||||
|
ver.className = 'artifact-tile-version'
|
||||||
|
ver.textContent = `v${entry.version || 1}`
|
||||||
|
meta.append(name, ver)
|
||||||
|
tile.append(thumb, meta)
|
||||||
|
return tile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline renderer: chronological (newest first), one row per
|
||||||
|
// artifact-version tuple pulled from `history`. Each row shows kind,
|
||||||
|
// name, version, and a relative timestamp — good for auditing
|
||||||
|
// "when did which file mutate" during a long session.
|
||||||
|
function renderTimeline(entries, opts) {
|
||||||
|
const openArtifact = (opts && opts.openArtifact) || (() => {})
|
||||||
|
const history = (opts && opts.history) || new Map()
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'artifact-timeline'
|
||||||
|
|
||||||
|
// Flatten history into a single sorted stream.
|
||||||
|
const events = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const hist = history.get(entry.artifactId) || [{
|
||||||
|
artifactId: entry.artifactId,
|
||||||
|
version: entry.version || 1,
|
||||||
|
seenAt: entry.seenAt || Date.now(),
|
||||||
|
kind: entry.kind,
|
||||||
|
path: entry.path,
|
||||||
|
}]
|
||||||
|
for (const h of hist) events.push({ ...h, kind: h.kind || entry.kind, path: h.path || entry.path })
|
||||||
|
}
|
||||||
|
events.sort((a, b) => (b.seenAt || 0) - (a.seenAt || 0))
|
||||||
|
for (const ev of events) {
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'artifact-timeline-row'
|
||||||
|
row.dataset.artifactId = ev.artifactId
|
||||||
|
row.dataset.version = String(ev.version)
|
||||||
|
const stamp = document.createElement('span')
|
||||||
|
stamp.className = 'artifact-timeline-time'
|
||||||
|
stamp.textContent = formatRelative(ev.seenAt)
|
||||||
|
stamp.title = new Date(ev.seenAt).toISOString()
|
||||||
|
const icon = document.createElement('span')
|
||||||
|
icon.className = 'artifact-timeline-icon'
|
||||||
|
icon.innerHTML = KIND_ICON[bucketOf(ev.kind)] || KIND_ICON.other
|
||||||
|
const name = document.createElement('span')
|
||||||
|
name.className = 'artifact-timeline-name'
|
||||||
|
name.textContent = ev.artifactId
|
||||||
|
const ver = document.createElement('span')
|
||||||
|
ver.className = 'artifact-timeline-version'
|
||||||
|
ver.textContent = `v${ev.version}`
|
||||||
|
const openLink = document.createElement('a')
|
||||||
|
openLink.className = 'artifact-timeline-open'
|
||||||
|
openLink.href = '#'
|
||||||
|
openLink.textContent = 'open ↗'
|
||||||
|
openLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
Promise.resolve().then(() => openArtifact(ev.artifactId))
|
||||||
|
})
|
||||||
|
row.append(stamp, icon, name, ver, openLink)
|
||||||
|
el.append(row)
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
const delta = Date.now() - ts
|
||||||
|
if (delta < 60_000) return 'just now'
|
||||||
|
if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`
|
||||||
|
if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`
|
||||||
|
return `${Math.round(delta / 86_400_000)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evolution chain: N version records → N-1 inter-version diff panes.
|
||||||
|
// Each hop is a `<details>` collapsed by default (opens on click) so a
|
||||||
|
// long history doesn't dominate vertical space. The pane body renders
|
||||||
|
// a naïve line-diff — pre-latest hops fall back to a "content not
|
||||||
|
// preserved" placeholder when no `blob` field is available (the real
|
||||||
|
// runtime doesn't retain older blobs; the fixture provides them).
|
||||||
|
function renderEvolution(entry, history) {
|
||||||
|
const chain = (history || []).slice().sort((a, b) => (a.version || 0) - (b.version || 0))
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'artifact-evolution'
|
||||||
|
el.dataset.artifactId = entry.artifactId
|
||||||
|
|
||||||
|
const head = document.createElement('div')
|
||||||
|
head.className = 'artifact-evolution-head'
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'artifact-evolution-label'
|
||||||
|
label.textContent = 'evolution'
|
||||||
|
const summary = document.createElement('span')
|
||||||
|
summary.className = 'artifact-evolution-summary'
|
||||||
|
const versions = chain.map((c) => `v${c.version}`).join(' → ')
|
||||||
|
summary.textContent = versions || `v${entry.version || 1}`
|
||||||
|
head.append(label, summary)
|
||||||
|
el.append(head)
|
||||||
|
|
||||||
|
const chainEl = document.createElement('ol')
|
||||||
|
chainEl.className = 'artifact-evolution-chain'
|
||||||
|
for (let i = 0; i < chain.length; i++) {
|
||||||
|
const step = chain[i]
|
||||||
|
const stepEl = document.createElement('li')
|
||||||
|
stepEl.className = 'artifact-evolution-step'
|
||||||
|
stepEl.dataset.version = String(step.version)
|
||||||
|
const stepHead = document.createElement('div')
|
||||||
|
stepHead.className = 'artifact-evolution-step-head'
|
||||||
|
const ver = document.createElement('span')
|
||||||
|
ver.className = 'artifact-evolution-step-ver'
|
||||||
|
ver.textContent = `v${step.version}`
|
||||||
|
const stamp = document.createElement('span')
|
||||||
|
stamp.className = 'artifact-evolution-step-time muted'
|
||||||
|
stamp.textContent = formatRelative(step.seenAt)
|
||||||
|
stepHead.append(ver, stamp)
|
||||||
|
stepEl.append(stepHead)
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
const prev = chain[i - 1]
|
||||||
|
const diffPane = renderDiffPane(prev, step)
|
||||||
|
stepEl.append(diffPane)
|
||||||
|
} else {
|
||||||
|
const seedNote = document.createElement('div')
|
||||||
|
seedNote.className = 'artifact-evolution-seed muted'
|
||||||
|
seedNote.textContent = 'initial version'
|
||||||
|
stepEl.append(seedNote)
|
||||||
|
}
|
||||||
|
chainEl.append(stepEl)
|
||||||
|
}
|
||||||
|
el.append(chainEl)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiffPane(prev, next) {
|
||||||
|
const pane = document.createElement('details')
|
||||||
|
pane.className = 'artifact-evolution-diff'
|
||||||
|
pane.open = false
|
||||||
|
const sum = document.createElement('summary')
|
||||||
|
sum.className = 'artifact-evolution-diff-summary'
|
||||||
|
sum.textContent = `diff v${prev.version} → v${next.version}`
|
||||||
|
pane.append(sum)
|
||||||
|
|
||||||
|
// If either side is missing a blob, we can't compute a diff. This is
|
||||||
|
// the honest state for the real runtime (older blobs aren't cached).
|
||||||
|
if (!prev.blob || !next.blob) {
|
||||||
|
const note = document.createElement('div')
|
||||||
|
note.className = 'artifact-evolution-diff-note muted'
|
||||||
|
note.textContent = 'content for older versions is not preserved by the artifact server — diff unavailable'
|
||||||
|
pane.append(note)
|
||||||
|
return pane
|
||||||
|
}
|
||||||
|
const diffEl = document.createElement('div')
|
||||||
|
diffEl.className = 'artifact-evolution-diff-body'
|
||||||
|
const lines = diffLines(prev.blob, next.blob)
|
||||||
|
for (const ln of lines) {
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = `artifact-evolution-diff-line kind-${ln.kind}`
|
||||||
|
const gutter = document.createElement('span')
|
||||||
|
gutter.className = 'artifact-evolution-diff-gutter'
|
||||||
|
gutter.textContent = ln.kind === 'add' ? '+' : ln.kind === 'del' ? '-' : ' '
|
||||||
|
const body = document.createElement('span')
|
||||||
|
body.className = 'artifact-evolution-diff-text'
|
||||||
|
body.textContent = ln.text
|
||||||
|
row.append(gutter, body)
|
||||||
|
diffEl.append(row)
|
||||||
|
}
|
||||||
|
pane.append(diffEl)
|
||||||
|
return pane
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line-level diff, LCS-style. Kept tiny on purpose — the artifact
|
||||||
|
// demo isn't a git-scale diff engine and blobs here are small
|
||||||
|
// (single-page HTML, short markdown). For big files we'd swap in
|
||||||
|
// tool-cards.js's diffLines(); this is deliberately dependency-free
|
||||||
|
// so the module unit tests don't have to boot the whole renderer.
|
||||||
|
function diffLines(a, b) {
|
||||||
|
const aLines = a.split('\n')
|
||||||
|
const bLines = b.split('\n')
|
||||||
|
const n = aLines.length
|
||||||
|
const m = bLines.length
|
||||||
|
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
if (aLines[i] === bLines[j]) dp[i][j] = dp[i + 1][j + 1] + 1
|
||||||
|
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (aLines[i] === bLines[j]) {
|
||||||
|
out.push({ kind: 'ctx', text: aLines[i] })
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
out.push({ kind: 'del', text: aLines[i] })
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
out.push({ kind: 'add', text: bLines[j] })
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) { out.push({ kind: 'del', text: aLines[i++] }) }
|
||||||
|
while (j < m) { out.push({ kind: 'add', text: bLines[j++] }) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
KINDS,
|
||||||
|
bucketOf,
|
||||||
|
groupByKind,
|
||||||
|
renderBoard,
|
||||||
|
renderTimeline,
|
||||||
|
renderEvolution,
|
||||||
|
thumbnailPreview,
|
||||||
|
diffLines,
|
||||||
|
formatRelative,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual export: CommonJS for node:test (test/artifact-evolution-board.test.js
|
||||||
|
// asserts on the module surface without booting Electron), window for
|
||||||
|
// the renderer to consume via artifacts.js. Mirrors the pattern used by
|
||||||
|
// tool-cards.js and widgets.js.
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__dshArtifactsBoard = api
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
// Renderer-side artifact card: inline entry point in the chat stream that
|
// Renderer-side artifact card: inline entry point in the chat stream that
|
||||||
// opens the artifact in the system browser. Deliberately not a webview — the
|
// opens the artifact in the system browser. Deliberately not a webview —
|
||||||
// demo shell only hosts the entry point per the RFC (2026-07-13 §Deliberate
|
// the demo shell only hosts the entry point per the RFC (2026-07-13
|
||||||
// exclusions, "No embedded GUI pane").
|
// §Deliberate exclusions, "No embedded GUI pane").
|
||||||
//
|
//
|
||||||
// Density-spec §2 L0 shape (user-flagged 2026-07-18): each artifact renders
|
// Density-spec §2 L0 shape (user-flagged 2026-07-18): each artifact
|
||||||
// as a single ~28px row — small icon + filename + kind/version chips + live
|
// renders as a single ~28px row — small icon + filename + kind/version
|
||||||
// dot + tiny right-aligned `open ↗` link. Clicking the row toggles a native
|
// chips + live dot + tiny right-aligned `open ↗` link. Clicking the row
|
||||||
// <details> L1 body that carries the full path and the ghost "Open in
|
// toggles a native <details> L1 body that carries the full path and the
|
||||||
// browser" button. Consecutive .artifact-card siblings render as a visual
|
// ghost "Open in browser" button. Consecutive .artifact-card siblings
|
||||||
// group (shared border, zero gap between rows) via CSS `:has()`.
|
// render as a visual group (shared border, zero gap between rows) via
|
||||||
|
// CSS `:has()`.
|
||||||
|
//
|
||||||
|
// V2 (lane-artifact-v2, 2026-07-19): the group container grew a top
|
||||||
|
// tab-bar (List / Board / Timeline) so the same artifact stream can be
|
||||||
|
// viewed three ways without leaving the chat. Clicking the L0
|
||||||
|
// `.artifact-version` chip on any List row expands an inline evolution
|
||||||
|
// chain (chain rendered by artifacts-board.js). History is kept per
|
||||||
|
// artifactId as versions arrive; blob content is captured when supplied
|
||||||
|
// by the event so the fixture demo can render real per-hop diffs — for
|
||||||
|
// the real runtime the pre-latest blobs aren't preserved, so the diff
|
||||||
|
// panes show an honest "content not preserved" note there.
|
||||||
//
|
//
|
||||||
// Two triggers:
|
// Two triggers:
|
||||||
// 1. tool/result carrying a file write inside the artifact dir (detected
|
// 1. tool/result carrying a file write inside the artifact dir
|
||||||
// by main.js and re-broadcast as `artifact:event`).
|
// (detected by main.js and re-broadcast as `artifact:event`).
|
||||||
// 2. debug menu "mock: artifact" button (window.dsh.mockArtifact).
|
// 2. debug menu "mock: artifact" button (window.dsh.mockArtifact).
|
||||||
//
|
//
|
||||||
// De-dup: one card per artifactId per stream. If a re-declare fires the
|
// De-dup: one card per artifactId per stream. If a re-declare fires the
|
||||||
@@ -25,11 +36,21 @@
|
|||||||
|
|
||||||
// artifactId -> DOM element, so a same-path re-declare updates in place.
|
// artifactId -> DOM element, so a same-path re-declare updates in place.
|
||||||
const cards = new Map()
|
const cards = new Map()
|
||||||
|
// artifactId -> [{ version, seenAt, kind, path, blob? }, …] — populated
|
||||||
|
// as `artifact:event` fires. Fed to artifacts-board.js for Board/
|
||||||
|
// Timeline/Evolution renderers.
|
||||||
|
const history = new Map()
|
||||||
|
// The tab-bar container ("Artifact panel") wraps every artifact-group
|
||||||
|
// so the List / Board / Timeline tabs sit above the same event stream.
|
||||||
|
// Kept as a single, top-of-stream panel — the compact L0 rows still
|
||||||
|
// live inside it under the List view.
|
||||||
|
let panelEl = null
|
||||||
|
let currentView = 'list'
|
||||||
|
|
||||||
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke) so
|
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke)
|
||||||
// artifact rows match the minimalist icon language rather than sitting
|
// so artifact rows match the minimalist icon language rather than
|
||||||
// on emoji glyphs. Fallback is the paperclip glyph used elsewhere for
|
// sitting on emoji glyphs. Fallback is the paperclip glyph used
|
||||||
// context-family cards.
|
// elsewhere for context-family cards.
|
||||||
const ICON_SVG = {
|
const ICON_SVG = {
|
||||||
html:
|
html:
|
||||||
'<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">'
|
'<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">'
|
||||||
@@ -59,16 +80,50 @@
|
|||||||
if (s) s.scrollTop = s.scrollHeight
|
if (s) s.scrollTop = s.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordHistory(entry) {
|
||||||
|
const id = entry.artifactId
|
||||||
|
const version = entry.version || 1
|
||||||
|
const rec = {
|
||||||
|
artifactId: id,
|
||||||
|
version,
|
||||||
|
seenAt: entry.seenAt || Date.now(),
|
||||||
|
kind: entry.kind,
|
||||||
|
path: entry.path,
|
||||||
|
}
|
||||||
|
// Fixture / server-side blob capture. The real ArtifactServer does
|
||||||
|
// not include content in its `artifact:event` payload; the fixture
|
||||||
|
// does. When present we retain it so the evolution diff panes can
|
||||||
|
// render real per-hop line diffs.
|
||||||
|
if (typeof entry.blob === 'string') rec.blob = entry.blob
|
||||||
|
const arr = history.get(id) || []
|
||||||
|
// De-dup on version — a re-broadcast of the same version shouldn't
|
||||||
|
// double-count in the timeline. Latest wins for the seenAt/blob
|
||||||
|
// fields so a corrected blob overwrites the placeholder.
|
||||||
|
const idx = arr.findIndex((r) => r.version === version)
|
||||||
|
if (idx >= 0) arr[idx] = { ...arr[idx], ...rec }
|
||||||
|
else arr.push(rec)
|
||||||
|
history.set(id, arr)
|
||||||
|
}
|
||||||
|
|
||||||
function ensureCard(entry) {
|
function ensureCard(entry) {
|
||||||
|
recordHistory(entry)
|
||||||
const existing = cards.get(entry.artifactId)
|
const existing = cards.get(entry.artifactId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
updateCard(existing, entry)
|
updateCard(existing, entry)
|
||||||
|
// If the evolution strip for this card is expanded, refresh it so
|
||||||
|
// the new version appears in the chain without a manual re-click.
|
||||||
|
refreshEvolutionIfOpen(existing, entry)
|
||||||
|
// View-level projections re-render on demand — the Board / Timeline
|
||||||
|
// views read live state on switch, so a dropped-in event during
|
||||||
|
// those views repaints the panel body.
|
||||||
|
if (currentView !== 'list') refreshView()
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
const el = renderCard(entry)
|
const el = renderCard(entry)
|
||||||
cards.set(entry.artifactId, el)
|
cards.set(entry.artifactId, el)
|
||||||
const s = streamEl()
|
const s = streamEl()
|
||||||
if (s) appendGrouped(s, el)
|
if (s) appendGrouped(s, el)
|
||||||
|
if (currentView !== 'list') refreshView()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
@@ -77,23 +132,114 @@
|
|||||||
// the list reads as one clumped block. The stream itself has a 12px
|
// the list reads as one clumped block. The stream itself has a 12px
|
||||||
// flex `gap` that a plain negative margin can't undo; the wrapper owns
|
// flex `gap` that a plain negative margin can't undo; the wrapper owns
|
||||||
// its own zero-gap layout so grouped rows sit flush.
|
// its own zero-gap layout so grouped rows sit flush.
|
||||||
|
//
|
||||||
|
// V2 note: the group itself lives inside `.artifact-panel` — a single
|
||||||
|
// container above the stream position where the first artifact would
|
||||||
|
// land, hosting the List/Board/Timeline tab bar. All subsequent
|
||||||
|
// artifacts append into the same group so the tab-bar covers one
|
||||||
|
// coherent event stream per session.
|
||||||
function appendGrouped(stream, el) {
|
function appendGrouped(stream, el) {
|
||||||
const last = stream.lastElementChild
|
// First artifact of the session: build the panel + tab bar and
|
||||||
if (last && last.classList && last.classList.contains('artifact-group')) {
|
// append it to the stream. The panel owns a `.artifact-group` in
|
||||||
last.appendChild(el)
|
// its body which the List view uses as-is.
|
||||||
|
if (!panelEl || !panelEl.isConnected) {
|
||||||
|
panelEl = buildPanel()
|
||||||
|
stream.appendChild(panelEl)
|
||||||
|
}
|
||||||
|
const groupHost = panelEl.querySelector('.artifact-group')
|
||||||
|
groupHost.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanel() {
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.className = 'artifact-panel'
|
||||||
|
panel.dataset.view = 'list'
|
||||||
|
|
||||||
|
const tabBar = document.createElement('div')
|
||||||
|
tabBar.className = 'artifact-panel-tabs'
|
||||||
|
tabBar.setAttribute('role', 'tablist')
|
||||||
|
for (const v of ['list', 'board', 'timeline']) {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.className = 'artifact-panel-tab'
|
||||||
|
btn.dataset.view = v
|
||||||
|
btn.setAttribute('role', 'tab')
|
||||||
|
btn.setAttribute('aria-selected', v === 'list' ? 'true' : 'false')
|
||||||
|
btn.textContent = v[0].toUpperCase() + v.slice(1)
|
||||||
|
btn.addEventListener('click', () => switchView(v))
|
||||||
|
tabBar.appendChild(btn)
|
||||||
|
}
|
||||||
|
panel.appendChild(tabBar)
|
||||||
|
|
||||||
|
const body = document.createElement('div')
|
||||||
|
body.className = 'artifact-panel-body'
|
||||||
|
// The List view surface — the auto-grouped rows continue to render
|
||||||
|
// here directly, so downstream QA that inspects `.artifact-group`
|
||||||
|
// keeps working unchanged.
|
||||||
|
const group = document.createElement('div')
|
||||||
|
group.className = 'artifact-group'
|
||||||
|
body.appendChild(group)
|
||||||
|
panel.appendChild(body)
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(v) {
|
||||||
|
if (v === currentView) return
|
||||||
|
currentView = v
|
||||||
|
if (!panelEl) return
|
||||||
|
panelEl.dataset.view = v
|
||||||
|
for (const tab of panelEl.querySelectorAll('.artifact-panel-tab')) {
|
||||||
|
tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
refreshView()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshView() {
|
||||||
|
if (!panelEl) return
|
||||||
|
const body = panelEl.querySelector('.artifact-panel-body')
|
||||||
|
if (!body) return
|
||||||
|
// The List view is stable DOM (the auto-grouped card rows). Board
|
||||||
|
// and Timeline are re-rendered from state on every switch — cheap,
|
||||||
|
// since the entry count for a demo session is small and this keeps
|
||||||
|
// the projection honest as new events arrive.
|
||||||
|
const listGroup = body.querySelector('.artifact-group')
|
||||||
|
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
|
||||||
|
for (const s of stale) s.remove()
|
||||||
|
if (currentView === 'list') {
|
||||||
|
if (listGroup) listGroup.hidden = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (last && last.classList && last.classList.contains('artifact-card')) {
|
if (listGroup) listGroup.hidden = true
|
||||||
// Previous artifact is a lone card — promote it and the new one
|
|
||||||
// into a fresh group.
|
const entries = collectLatestEntries()
|
||||||
const group = document.createElement('div')
|
const board = window.__dshArtifactsBoard
|
||||||
group.className = 'artifact-group'
|
if (!board) return // module hasn't loaded yet; safe no-op
|
||||||
stream.replaceChild(group, last)
|
const view = currentView === 'board'
|
||||||
group.appendChild(last)
|
? board.renderBoard(entries, { openArtifact: (id) => window.dsh && window.dsh.openArtifact(id) })
|
||||||
group.appendChild(el)
|
: board.renderTimeline(entries, {
|
||||||
return
|
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
|
||||||
|
history,
|
||||||
|
})
|
||||||
|
body.appendChild(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the latest-version entry per artifactId — that's what
|
||||||
|
// Board tiles show. Timeline reads full history separately.
|
||||||
|
function collectLatestEntries() {
|
||||||
|
const out = []
|
||||||
|
for (const [id, arr] of history) {
|
||||||
|
if (!arr || arr.length === 0) continue
|
||||||
|
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
|
||||||
|
out.push({
|
||||||
|
artifactId: id,
|
||||||
|
version: latest.version,
|
||||||
|
kind: latest.kind,
|
||||||
|
path: latest.path,
|
||||||
|
seenAt: latest.seenAt,
|
||||||
|
blob: latest.blob,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
stream.appendChild(el)
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function invokeOpen(entry, actionEl, restoreLabel) {
|
function invokeOpen(entry, actionEl, restoreLabel) {
|
||||||
@@ -121,14 +267,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(entry) {
|
function renderCard(entry) {
|
||||||
// <details> is the L0 row shell. `open=false` keeps rows collapsed by
|
// <details> is the L0 row shell. `open=false` keeps rows collapsed
|
||||||
// default; clicking anywhere on the <summary> toggles the L1 body.
|
// by default; clicking anywhere on the <summary> toggles the L1
|
||||||
|
// body.
|
||||||
const el = document.createElement('details')
|
const el = document.createElement('details')
|
||||||
el.className = 'artifact-card'
|
el.className = 'artifact-card'
|
||||||
el.dataset.artifactId = entry.artifactId
|
el.dataset.artifactId = entry.artifactId
|
||||||
el.dataset.version = String(entry.version || 1)
|
el.dataset.version = String(entry.version || 1)
|
||||||
|
|
||||||
// ---- L0 summary row ------------------------------------------------
|
// ---- L0 summary row -----------------------------------------------
|
||||||
const summary = document.createElement('summary')
|
const summary = document.createElement('summary')
|
||||||
summary.className = 'artifact-row'
|
summary.className = 'artifact-row'
|
||||||
|
|
||||||
@@ -145,9 +292,22 @@
|
|||||||
kindEl.className = 'artifact-kind'
|
kindEl.className = 'artifact-kind'
|
||||||
kindEl.textContent = entry.kind || 'file'
|
kindEl.textContent = entry.kind || 'file'
|
||||||
|
|
||||||
const verEl = document.createElement('span')
|
// Version chip: promoted from a static span to a <button> in V2 so
|
||||||
|
// clicking it opens the inline evolution strip. Preserves the same
|
||||||
|
// class name so the row layout / stylesheet locks still hold.
|
||||||
|
const verEl = document.createElement('button')
|
||||||
|
verEl.type = 'button'
|
||||||
verEl.className = 'artifact-version'
|
verEl.className = 'artifact-version'
|
||||||
verEl.textContent = `v${entry.version || 1}`
|
verEl.textContent = `v${entry.version || 1}`
|
||||||
|
verEl.title = 'View version history'
|
||||||
|
verEl.setAttribute('aria-label', `View version history for ${entry.artifactId}`)
|
||||||
|
verEl.addEventListener('click', (e) => {
|
||||||
|
// Prevent both the <details> toggle and the row default so the
|
||||||
|
// chip acts as its own trigger.
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleEvolution(el, entry)
|
||||||
|
})
|
||||||
|
|
||||||
const dotEl = document.createElement('span')
|
const dotEl = document.createElement('span')
|
||||||
dotEl.className = 'artifact-live-dot'
|
dotEl.className = 'artifact-live-dot'
|
||||||
@@ -173,7 +333,7 @@
|
|||||||
|
|
||||||
summary.append(iconEl, nameEl, kindEl, verEl, dotEl, openLink)
|
summary.append(iconEl, nameEl, kindEl, verEl, dotEl, openLink)
|
||||||
|
|
||||||
// ---- L1 inline body (lazy content, structure is there for a11y) ----
|
// ---- L1 inline body (lazy content, structure is there for a11y) ---
|
||||||
const body = document.createElement('div')
|
const body = document.createElement('div')
|
||||||
body.className = 'artifact-body-l1'
|
body.className = 'artifact-body-l1'
|
||||||
const pathRow = document.createElement('div')
|
const pathRow = document.createElement('div')
|
||||||
@@ -207,6 +367,43 @@
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The evolution strip is a sibling under the card root, appended below
|
||||||
|
// the L1 body. Toggle: create on first click, remove on second. Sits
|
||||||
|
// outside the <details> body deliberately so the version chip acts
|
||||||
|
// independently of the row's open/close state — a user can inspect
|
||||||
|
// the version chain without expanding the path/actions body.
|
||||||
|
//
|
||||||
|
// Layout note: the strip is a child of the <details> element in the
|
||||||
|
// DOM, and a closed <details> hides all non-<summary> children per
|
||||||
|
// the HTML spec (no `display:` override wins against that). So the
|
||||||
|
// toggle-on branch also opens the details so the strip actually
|
||||||
|
// renders. The path + Open-in-browser row happen to appear too — an
|
||||||
|
// acceptable side effect since the user just declared interest in
|
||||||
|
// this artifact by clicking its version chip.
|
||||||
|
function toggleEvolution(cardEl, entry) {
|
||||||
|
const existing = cardEl.querySelector(':scope > .artifact-evolution')
|
||||||
|
if (existing) {
|
||||||
|
existing.remove()
|
||||||
|
cardEl.classList.remove('has-evolution')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const board = window.__dshArtifactsBoard
|
||||||
|
if (!board) return
|
||||||
|
const strip = board.renderEvolution(entry, history.get(entry.artifactId) || [])
|
||||||
|
cardEl.append(strip)
|
||||||
|
cardEl.classList.add('has-evolution')
|
||||||
|
cardEl.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEvolutionIfOpen(cardEl, entry) {
|
||||||
|
const existing = cardEl.querySelector(':scope > .artifact-evolution')
|
||||||
|
if (!existing) return
|
||||||
|
const board = window.__dshArtifactsBoard
|
||||||
|
if (!board) return
|
||||||
|
const fresh = board.renderEvolution(entry, history.get(entry.artifactId) || [])
|
||||||
|
existing.replaceWith(fresh)
|
||||||
|
}
|
||||||
|
|
||||||
function updateCard(el, entry) {
|
function updateCard(el, entry) {
|
||||||
el.dataset.version = String(entry.version || 1)
|
el.dataset.version = String(entry.version || 1)
|
||||||
const ver = el.querySelector('.artifact-version')
|
const ver = el.querySelector('.artifact-version')
|
||||||
@@ -218,8 +415,8 @@
|
|||||||
|
|
||||||
function flash(el) {
|
function flash(el) {
|
||||||
el.classList.add('artifact-flash')
|
el.classList.add('artifact-flash')
|
||||||
// Reflow trick so re-adding the class re-triggers the animation for a
|
// Reflow trick so re-adding the class re-triggers the animation for
|
||||||
// rapid second update.
|
// a rapid second update.
|
||||||
void el.offsetWidth
|
void el.offsetWidth
|
||||||
setTimeout(() => el.classList.remove('artifact-flash'), 900)
|
setTimeout(() => el.classList.remove('artifact-flash'), 900)
|
||||||
}
|
}
|
||||||
@@ -245,8 +442,9 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up once the DOM + preload bridge are ready. The renderer script tag
|
// Wire up once the DOM + preload bridge are ready. The renderer script
|
||||||
// is loaded after this one, so we just register the listener eagerly.
|
// tag is loaded after this one, so we just register the listener
|
||||||
|
// eagerly.
|
||||||
if (window.dsh && typeof window.dsh.onArtifact === 'function') {
|
if (window.dsh && typeof window.dsh.onArtifact === 'function') {
|
||||||
window.dsh.onArtifact(onArtifactEvent)
|
window.dsh.onArtifact(onArtifactEvent)
|
||||||
}
|
}
|
||||||
@@ -256,6 +454,14 @@
|
|||||||
bindMockButton()
|
bindMockButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose the small API for the smoke tests + potential renderer-side reuse.
|
// Expose the small API for the smoke tests + potential renderer-side
|
||||||
window.__dshArtifacts = { onArtifactEvent, cards }
|
// reuse. `history` and `switchView` join the surface so fixture
|
||||||
|
// drivers can inspect state and QA can screenshot each view directly.
|
||||||
|
window.__dshArtifacts = {
|
||||||
|
onArtifactEvent,
|
||||||
|
cards,
|
||||||
|
history,
|
||||||
|
switchView,
|
||||||
|
getView: () => currentView,
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -1324,6 +1324,10 @@
|
|||||||
from any tab. -->
|
from any tab. -->
|
||||||
<script src="./fork-compare.js"></script>
|
<script src="./fork-compare.js"></script>
|
||||||
<script src="./tool-cards.js"></script>
|
<script src="./tool-cards.js"></script>
|
||||||
|
<!-- artifacts-board.js defines window.__dshArtifactsBoard (Board/Timeline/
|
||||||
|
Evolution renderers) and MUST load before artifacts.js so the L0 row
|
||||||
|
version-chip click handler + view switcher find the module ready. -->
|
||||||
|
<script src="./artifacts-board.js"></script>
|
||||||
<script src="./artifacts.js"></script>
|
<script src="./artifacts.js"></script>
|
||||||
<script src="./plugin-runtime-fold.js"></script>
|
<script src="./plugin-runtime-fold.js"></script>
|
||||||
<script src="./mcp-tool-name.js"></script>
|
<script src="./mcp-tool-name.js"></script>
|
||||||
|
|||||||
@@ -12101,3 +12101,265 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
|
|||||||
outline: 2px solid rgba(122, 90, 248, 0.8);
|
outline: 2px solid rgba(122, 90, 248, 0.8);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
/* ==========================================================================
|
||||||
|
* Artifact panel — V2 (lane-artifact-v2, 2026-07-19)
|
||||||
|
*
|
||||||
|
* The panel wraps the artifact-group with a top tab bar (List/Board/
|
||||||
|
* Timeline). Keeps the L0 compact-row shape intact for List; Board and
|
||||||
|
* Timeline are computed projections that render inside the same body.
|
||||||
|
* The version-evolution strip is a sibling to `.artifact-body-l1`
|
||||||
|
* inside each card, toggled by clicking the `.artifact-version` chip.
|
||||||
|
*
|
||||||
|
* Design brief: on light/dark themes, keep the panel visually quiet —
|
||||||
|
* the tab bar sits on the same elevation as the card body so the panel
|
||||||
|
* reads as one surface, not a chrome-heavy widget. Board tiles reuse
|
||||||
|
* the shared card family variables so they don't stand out from the
|
||||||
|
* rest of the chat.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
.artifact-panel {
|
||||||
|
align-self: flex-start; max-width: 780px; width: 100%;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.artifact-panel-tabs {
|
||||||
|
display: flex; gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
.artifact-panel-tab {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
color: var(--muted); font-size: 11px; line-height: 1.4;
|
||||||
|
cursor: pointer; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.artifact-panel-tab:hover { background: var(--bg-elev); color: var(--text); }
|
||||||
|
.artifact-panel-tab[aria-selected="true"] {
|
||||||
|
background: var(--bg-elev); color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.artifact-panel-body {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
/* When the panel hosts the group, the inner group loses its own outer
|
||||||
|
* shell — the panel already owns the border+radius. Grouped card rows
|
||||||
|
* still fuse via the existing `.artifact-group > .artifact-card` rules. */
|
||||||
|
.artifact-panel .artifact-group {
|
||||||
|
max-width: none; width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.artifact-panel .artifact-group > .artifact-card {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none; border-right: none;
|
||||||
|
}
|
||||||
|
.artifact-panel .artifact-group > .artifact-card:first-child {
|
||||||
|
border-top: none;
|
||||||
|
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
.artifact-panel .artifact-group > .artifact-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version chip: promoted to a <button> in V2 so it can toggle the
|
||||||
|
* evolution strip. Keep the muted-accent look so it still reads as a
|
||||||
|
* chip, not a full button — a subtle underline on hover is the only
|
||||||
|
* hint that it's interactive. */
|
||||||
|
button.artifact-version {
|
||||||
|
background: transparent; border: none; padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button.artifact-version:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.artifact-card.has-evolution > .artifact-row .artifact-version {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evolution strip: appended below `.artifact-body-l1` inside the card
|
||||||
|
* root. Visible only when the version chip is toggled on. */
|
||||||
|
.artifact-evolution {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 8px 12px 12px 34px; /* match .artifact-body-l1 left inset */
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
.artifact-evolution-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-label {
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
font-size: 10px; color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.artifact-evolution-summary {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.artifact-evolution-chain {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-step {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding: 4px 0 4px 10px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-step-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-step-ver {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.artifact-evolution-step-time { font-size: 10px; }
|
||||||
|
.artifact-evolution-seed {
|
||||||
|
font-size: 11px; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-summary {
|
||||||
|
font-size: 11px; color: var(--muted); cursor: pointer;
|
||||||
|
padding: 2px 6px; border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-summary:hover { background: var(--bg-elev); color: var(--text); }
|
||||||
|
.artifact-evolution-diff-summary::-webkit-details-marker { display: none; }
|
||||||
|
.artifact-evolution-diff-summary::marker { content: ''; }
|
||||||
|
.artifact-evolution-diff[open] .artifact-evolution-diff-summary {
|
||||||
|
background: var(--bg-elev); color: var(--text);
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-note {
|
||||||
|
padding: 6px 8px; font-size: 11px;
|
||||||
|
border: 1px dashed var(--border); border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
font-family: var(--mono); font-size: 11px;
|
||||||
|
overflow-x: auto; max-height: 260px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-line {
|
||||||
|
display: flex; align-items: baseline; gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-line.kind-add { background: color-mix(in oklab, var(--ok, #22a06b) 12%, transparent); }
|
||||||
|
.artifact-evolution-diff-line.kind-del { background: color-mix(in oklab, var(--danger, #e5484d) 12%, transparent); }
|
||||||
|
.artifact-evolution-diff-gutter {
|
||||||
|
color: var(--text-tertiary); width: 10px; flex: 0 0 auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.artifact-evolution-diff-text {
|
||||||
|
color: var(--text); word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Board view: group tiles by kind bucket. */
|
||||||
|
.artifact-board {
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.artifact-board-group {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.artifact-board-group-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.artifact-board-group-icon { display: inline-flex; color: var(--muted); }
|
||||||
|
.artifact-board-group-title {
|
||||||
|
font-size: 12px; letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase; color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.artifact-board-group-count {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||||
|
background: var(--bg-elev-2); border-radius: 3px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.artifact-board-grid {
|
||||||
|
display: grid; gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.artifact-tile {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-elev-2); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 0; overflow: hidden;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
color: var(--text); font: inherit;
|
||||||
|
transition: border-color 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.artifact-tile:hover {
|
||||||
|
border-color: var(--accent); transform: translateY(-1px);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
.artifact-thumb {
|
||||||
|
min-height: 84px; max-height: 84px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex; align-items: flex-start; justify-content: flex-start;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.artifact-thumb-text {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--mono); font-size: 10.5px; line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: pre-wrap; word-break: break-word;
|
||||||
|
max-height: 68px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.artifact-thumb-icon {
|
||||||
|
color: var(--muted); display: inline-flex;
|
||||||
|
}
|
||||||
|
.artifact-tile-meta {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 6px; padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.artifact-tile-name {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
}
|
||||||
|
.artifact-tile-version {
|
||||||
|
color: var(--accent); font-family: var(--mono); font-size: 10.5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline view: chronological rows. */
|
||||||
|
.artifact-timeline {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.artifact-timeline-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 4px 12px; min-height: 26px;
|
||||||
|
font-size: 11px; color: var(--text);
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.artifact-timeline-row:last-child { border-bottom: none; }
|
||||||
|
.artifact-timeline-time {
|
||||||
|
font-family: var(--mono); color: var(--text-tertiary);
|
||||||
|
width: 72px; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.artifact-timeline-icon {
|
||||||
|
display: inline-flex; color: var(--muted); flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.artifact-timeline-name {
|
||||||
|
font-family: var(--mono); font-size: 11px;
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.artifact-timeline-version {
|
||||||
|
color: var(--accent); font-family: var(--mono); font-size: 10.5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.artifact-timeline-open {
|
||||||
|
font-size: 10.5px; color: var(--muted); text-decoration: none;
|
||||||
|
padding: 2px 4px; border-radius: 3px; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.artifact-timeline-open:hover { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
|||||||
403
examples/desktop/test/artifact-evolution-board.test.js
Normal file
403
examples/desktop/test/artifact-evolution-board.test.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
// Lock the lane-artifact-v2 additions (2026-07-19): version-evolution
|
||||||
|
// chain + Board/Timeline views + panel tab bar. Complements the existing
|
||||||
|
// artifact-compact-row.test.js which locks the L0 row shape.
|
||||||
|
//
|
||||||
|
// Three surfaces:
|
||||||
|
// (a) src/renderer/artifacts-board.js — Board/Timeline/Evolution
|
||||||
|
// renderers + kind-bucket + diffLines. Testable directly via
|
||||||
|
// CommonJS export.
|
||||||
|
// (b) src/renderer/artifacts.js — history tracking, panel
|
||||||
|
// building, view switcher. Only source-string assertions here
|
||||||
|
// (the module is an IIFE that touches window at load).
|
||||||
|
// (c) src/renderer/style.css — panel + tab + tile + evolution
|
||||||
|
// + timeline styling.
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..')
|
||||||
|
const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
|
||||||
|
const styleCss = fs.readFileSync(path.join(ROOT, 'src/renderer/style.css'), 'utf8')
|
||||||
|
|
||||||
|
// Load artifacts-board.js as a real module — it emits a CommonJS export
|
||||||
|
// alongside the window.__dshArtifactsBoard shim (mirrors tool-cards.js).
|
||||||
|
const board = require(path.join(ROOT, 'src/renderer/artifacts-board.js'))
|
||||||
|
|
||||||
|
// -- (a) artifacts-board.js module surface --------------------------------
|
||||||
|
|
||||||
|
test('artifacts-board: exports the seven-piece API', () => {
|
||||||
|
for (const key of ['KINDS', 'bucketOf', 'groupByKind', 'renderBoard',
|
||||||
|
'renderTimeline', 'renderEvolution', 'diffLines']) {
|
||||||
|
assert.ok(board[key], `missing export: ${key}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board: bucketOf normalizes freeform kinds to six buckets', () => {
|
||||||
|
assert.equal(board.bucketOf('md'), 'md')
|
||||||
|
assert.equal(board.bucketOf('markdown'), 'md')
|
||||||
|
assert.equal(board.bucketOf('html'), 'html')
|
||||||
|
assert.equal(board.bucketOf('htm'), 'html')
|
||||||
|
assert.equal(board.bucketOf('svg'), 'svg')
|
||||||
|
assert.equal(board.bucketOf('json'), 'json')
|
||||||
|
assert.equal(board.bucketOf('py'), 'code')
|
||||||
|
assert.equal(board.bucketOf('sh'), 'code')
|
||||||
|
assert.equal(board.bucketOf('bin'), 'other')
|
||||||
|
assert.equal(board.bucketOf(''), 'other')
|
||||||
|
assert.equal(board.bucketOf(null), 'other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board: groupByKind drops empty buckets so headers do not render blank', () => {
|
||||||
|
const groups = board.groupByKind([
|
||||||
|
{ artifactId: 'a.md', kind: 'md', version: 1 },
|
||||||
|
{ artifactId: 'b.html', kind: 'html', version: 1 },
|
||||||
|
])
|
||||||
|
assert.ok(groups.has('md'))
|
||||||
|
assert.ok(groups.has('html'))
|
||||||
|
assert.ok(!groups.has('svg'), 'empty buckets must be dropped')
|
||||||
|
assert.ok(!groups.has('json'))
|
||||||
|
assert.ok(!groups.has('code'))
|
||||||
|
assert.ok(!groups.has('other'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board: diffLines emits ordered add / del / ctx lines', () => {
|
||||||
|
const prev = 'a\nb\nc'
|
||||||
|
const next = 'a\nx\nc'
|
||||||
|
const lines = board.diffLines(prev, next)
|
||||||
|
// Must preserve first line as context, then produce a del+add for b→x,
|
||||||
|
// then keep last line as context.
|
||||||
|
assert.deepEqual(
|
||||||
|
lines.map((l) => `${l.kind}:${l.text}`),
|
||||||
|
['ctx:a', 'del:b', 'add:x', 'ctx:c'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board: diffLines handles pure insertion / pure deletion', () => {
|
||||||
|
// Note: '' splits to [''] (one empty line), so an empty→two-line
|
||||||
|
// transition is one del (empty) plus two adds, not two bare adds.
|
||||||
|
// That's the honest LCS answer — the fixture never diffs empty
|
||||||
|
// blobs in real usage, but the shape matters for regressions.
|
||||||
|
const ins = board.diffLines('', 'a\nb')
|
||||||
|
assert.deepEqual(ins.map((l) => l.kind), ['del', 'add', 'add'])
|
||||||
|
const del = board.diffLines('a\nb', '')
|
||||||
|
assert.deepEqual(del.map((l) => l.kind), ['del', 'del', 'add'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- (a2) artifacts-board.js: DOM-shape asserts ---------------------------
|
||||||
|
//
|
||||||
|
// Board / Timeline / Evolution renderers return DOM nodes, so we run
|
||||||
|
// them against a minimal DOM stub (same idea as renderer-harness.js).
|
||||||
|
// Kept inline + tiny so the test file stays hermetic.
|
||||||
|
|
||||||
|
function makeDom() {
|
||||||
|
// Bare-bones stub matching what the renderers touch: createElement,
|
||||||
|
// append(), appendChild, dataset, className, textContent, innerHTML,
|
||||||
|
// setAttribute, addEventListener, querySelector, querySelectorAll,
|
||||||
|
// classList (used only via className strings here), title.
|
||||||
|
const install = (tag, extra = {}) => {
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
children: [],
|
||||||
|
dataset: {},
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
innerHTML: '',
|
||||||
|
title: '',
|
||||||
|
hidden: false,
|
||||||
|
_attrs: {},
|
||||||
|
_listeners: {},
|
||||||
|
style: {},
|
||||||
|
classList: {
|
||||||
|
_s: new Set(),
|
||||||
|
add(...n) { for (const x of n) this._s.add(x) },
|
||||||
|
remove(...n) { for (const x of n) this._s.delete(x) },
|
||||||
|
contains(x) { return this._s.has(x) },
|
||||||
|
},
|
||||||
|
appendChild(child) { child.parentElement = this; this.children.push(child); return child },
|
||||||
|
append(...cs) { for (const c of cs) this.appendChild(c) },
|
||||||
|
setAttribute(k, v) { this._attrs[k] = String(v) },
|
||||||
|
getAttribute(k) { return this._attrs[k] },
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
(this._listeners[name] = this._listeners[name] || []).push(fn)
|
||||||
|
},
|
||||||
|
dispatchEvent(name) {
|
||||||
|
for (const fn of (this._listeners[name] || [])) {
|
||||||
|
fn({ preventDefault() {}, stopPropagation() {} })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
querySelector(sel) { return findFirst(this, sel) },
|
||||||
|
querySelectorAll(sel) { return findAll(this, sel) },
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
function selMatch(el, sel) {
|
||||||
|
if (sel.startsWith('.')) return el.className && el.className.split(/\s+/).includes(sel.slice(1))
|
||||||
|
if (sel.startsWith('[')) {
|
||||||
|
// Only used in this test for `[data-artifact-id="..."]` shape,
|
||||||
|
// which isn't hit by these assertions. Return false safely.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return el.tagName === sel.toUpperCase()
|
||||||
|
}
|
||||||
|
function findFirst(root, sel) {
|
||||||
|
for (const c of (root.children || [])) {
|
||||||
|
if (selMatch(c, sel)) return c
|
||||||
|
const nested = findFirst(c, sel)
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function findAll(root, sel) {
|
||||||
|
const out = []
|
||||||
|
const walk = (n) => {
|
||||||
|
for (const c of (n.children || [])) {
|
||||||
|
if (selMatch(c, sel)) out.push(c)
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
global.document = { createElement: (tag) => install(tag) }
|
||||||
|
return install
|
||||||
|
}
|
||||||
|
|
||||||
|
test('artifacts-board.renderBoard: one group per kind, tile count matches bucket size', () => {
|
||||||
|
makeDom()
|
||||||
|
const entries = [
|
||||||
|
{ artifactId: 'a.md', kind: 'md', version: 3, blob: '# hi' },
|
||||||
|
{ artifactId: 'b.md', kind: 'md', version: 1, blob: 'hi' },
|
||||||
|
{ artifactId: 'c.html', kind: 'html', version: 2, blob: '<p>x</p>' },
|
||||||
|
]
|
||||||
|
const el = board.renderBoard(entries, { openArtifact: () => {} })
|
||||||
|
assert.equal(el.className, 'artifact-board')
|
||||||
|
const groups = el.querySelectorAll('.artifact-board-group')
|
||||||
|
assert.equal(groups.length, 2, 'md + html only — svg/json/code/other are empty and must be dropped')
|
||||||
|
const mdGroup = groups.find ? groups.find((g) => g.dataset.kind === 'md') : groups[0]
|
||||||
|
const mdTiles = mdGroup.querySelectorAll('.artifact-tile')
|
||||||
|
assert.equal(mdTiles.length, 2, 'both md entries live under the md group')
|
||||||
|
// Kind badge on the tile mirrors the bucket so CSS can theme per-kind.
|
||||||
|
for (const tile of mdTiles) assert.equal(tile.dataset.kind, 'md')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board.renderTimeline: newest-first ordering across all versions', () => {
|
||||||
|
makeDom()
|
||||||
|
const entries = [
|
||||||
|
{ artifactId: 'a.md', kind: 'md', version: 2, seenAt: 100 },
|
||||||
|
]
|
||||||
|
const history = new Map([
|
||||||
|
['a.md', [
|
||||||
|
{ artifactId: 'a.md', version: 1, kind: 'md', seenAt: 50 },
|
||||||
|
{ artifactId: 'a.md', version: 2, kind: 'md', seenAt: 100 },
|
||||||
|
]],
|
||||||
|
])
|
||||||
|
const el = board.renderTimeline(entries, { history, openArtifact: () => {} })
|
||||||
|
const rows = el.querySelectorAll('.artifact-timeline-row')
|
||||||
|
assert.equal(rows.length, 2, 'both history versions must appear as rows')
|
||||||
|
assert.equal(rows[0].dataset.version, '2', 'newest version appears first')
|
||||||
|
assert.equal(rows[1].dataset.version, '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board.renderEvolution: N versions → N-1 diff panes + honest fallback', () => {
|
||||||
|
makeDom()
|
||||||
|
const entry = { artifactId: 'a.md', kind: 'md', version: 3 }
|
||||||
|
// Chain with two hops. First hop has both blobs (real diff); second
|
||||||
|
// hop has one missing (fallback note path).
|
||||||
|
const history = [
|
||||||
|
{ version: 1, seenAt: 100, kind: 'md', blob: 'a\nb' },
|
||||||
|
{ version: 2, seenAt: 200, kind: 'md', blob: 'a\nB' },
|
||||||
|
{ version: 3, seenAt: 300, kind: 'md' /* no blob */ },
|
||||||
|
]
|
||||||
|
const el = board.renderEvolution(entry, history)
|
||||||
|
const steps = el.querySelectorAll('.artifact-evolution-step')
|
||||||
|
assert.equal(steps.length, 3, 'one step per version')
|
||||||
|
const diffs = el.querySelectorAll('.artifact-evolution-diff')
|
||||||
|
assert.equal(diffs.length, 2, 'N-1 diff panes for a chain of N versions')
|
||||||
|
// The first diff pane has real content; the second (v2 has blob, v3
|
||||||
|
// does not) must fall back to the not-preserved note.
|
||||||
|
const notes = el.querySelectorAll('.artifact-evolution-diff-note')
|
||||||
|
assert.equal(notes.length, 1, 'exactly one hop falls back to the honest note')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts-board.renderEvolution: chain summary reads v1 → v2 → v3', () => {
|
||||||
|
makeDom()
|
||||||
|
const entry = { artifactId: 'a.md', kind: 'md', version: 3 }
|
||||||
|
const history = [
|
||||||
|
{ version: 1, seenAt: 1 },
|
||||||
|
{ version: 2, seenAt: 2 },
|
||||||
|
{ version: 3, seenAt: 3 },
|
||||||
|
]
|
||||||
|
const el = board.renderEvolution(entry, history)
|
||||||
|
const sum = el.querySelector('.artifact-evolution-summary')
|
||||||
|
assert.ok(sum, 'summary line must render')
|
||||||
|
assert.equal(sum.textContent, 'v1 → v2 → v3',
|
||||||
|
'chain summary must list every version in order')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- (b) artifacts.js source-string locks ---------------------------------
|
||||||
|
|
||||||
|
test('artifacts.js: builds an .artifact-panel with a role="tablist" tab bar', () => {
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/panel\.className\s*=\s*['"]artifact-panel['"]/,
|
||||||
|
'panel container must exist so the tab bar has a scope',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/tabBar\.setAttribute\(['"]role['"],\s*['"]tablist['"]\)/,
|
||||||
|
'tab-bar container must carry role="tablist" for a11y',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/['"]list['"],\s*['"]board['"],\s*['"]timeline['"]/,
|
||||||
|
'exactly three tabs must exist in the exact order List / Board / Timeline',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts.js: version chip is a <button> that toggles the evolution strip', () => {
|
||||||
|
// The chip changed from <span> to <button> so the click hit-target is
|
||||||
|
// an accessible control, not a bare span. Static text lock — if a
|
||||||
|
// future edit drops the button back to a span the a11y regresses.
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/createElement\(['"]button['"]\)[\s\S]{0,300}verEl\.className\s*=\s*['"]artifact-version['"]/,
|
||||||
|
'artifact-version chip must be built as a <button>',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/toggleEvolution\(el,\s*entry\)/,
|
||||||
|
'chip click handler must call toggleEvolution(el, entry)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts.js: history map tracks per-version records', () => {
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/const\s+history\s*=\s*new\s+Map\(\)/,
|
||||||
|
'history map must exist to seed the evolution / timeline views',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/function\s+recordHistory\(entry\)/,
|
||||||
|
'recordHistory must exist and fire per event',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts.js: switchView flips the panel dataset + aria-selected', () => {
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/function\s+switchView\(v\)/,
|
||||||
|
'switchView helper must exist',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/panelEl\.dataset\.view\s*=\s*v/,
|
||||||
|
'view state must be reflected on panel.dataset.view for CSS + QA',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/setAttribute\(['"]aria-selected['"],\s*tab\.dataset\.view\s*===\s*v/,
|
||||||
|
'aria-selected must flip on tab per current view',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artifacts.js: __dshArtifacts surface exposes history + switchView', () => {
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/window\.__dshArtifacts\s*=\s*\{[\s\S]*?history[\s\S]*?switchView[\s\S]*?\}/,
|
||||||
|
'debug seam must include history + switchView so QA / drivers can inspect state',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- (c) style.css static locks -------------------------------------------
|
||||||
|
|
||||||
|
function findRule(css, selector) {
|
||||||
|
const idx = css.indexOf(selector)
|
||||||
|
if (idx < 0) return null
|
||||||
|
const brace = css.indexOf('{', idx)
|
||||||
|
const close = css.indexOf('}', brace)
|
||||||
|
if (brace < 0 || close < 0) return null
|
||||||
|
return css.slice(brace + 1, close)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('style.css: .artifact-panel is a bordered container that wraps the group', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-panel {')
|
||||||
|
assert.ok(body, '.artifact-panel rule must exist')
|
||||||
|
assert.match(body, /border:\s*1px solid var\(--border\)/, 'panel must render one shared border')
|
||||||
|
assert.match(body, /border-radius:\s*8px/, 'panel radius mirrors the card-family radius')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .artifact-panel-tabs sit above the body with their own separator', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-panel-tabs {')
|
||||||
|
assert.ok(body, '.artifact-panel-tabs rule must exist')
|
||||||
|
assert.match(body, /border-bottom:\s*1px solid var\(--border\)/,
|
||||||
|
'tab bar must sit on a separator so it reads as chrome, not part of the body')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: aria-selected tab reads darker than its neighbours', () => {
|
||||||
|
// Presence check for the selected-state rule — the visual weight is
|
||||||
|
// the whole point of a tab bar, so drift here is a UX regression.
|
||||||
|
assert.match(
|
||||||
|
styleCss,
|
||||||
|
/\.artifact-panel-tab\[aria-selected="true"\]/,
|
||||||
|
'selected tab must have a dedicated rule so its visual weight is stable',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .artifact-tile is a click-target with hover promotion', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-tile {')
|
||||||
|
assert.ok(body, '.artifact-tile rule must exist')
|
||||||
|
assert.match(body, /cursor:\s*pointer/, 'tile is a click target')
|
||||||
|
const hover = findRule(styleCss, '.artifact-tile:hover {')
|
||||||
|
assert.ok(hover, '.artifact-tile:hover rule must exist')
|
||||||
|
assert.match(hover, /border-color:\s*var\(--accent\)/,
|
||||||
|
'tile hover must promote the border to accent so the click affordance is visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .artifact-board-grid is a responsive minmax grid', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-board-grid {')
|
||||||
|
assert.ok(body, '.artifact-board-grid rule must exist')
|
||||||
|
assert.match(body, /grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(\d+px/,
|
||||||
|
'board grid must be a responsive minmax layout so tiles reflow on narrow panels')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .artifact-evolution has a subdued background so it reads as an aside', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-evolution {')
|
||||||
|
assert.ok(body, '.artifact-evolution rule must exist')
|
||||||
|
assert.match(body, /background:\s*var\(--bg-elev-2\)/,
|
||||||
|
'evolution strip must sit on the secondary surface so it reads as inline detail, not a peer card')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: diff-add / diff-del rows use ok / danger colour-mix tints', () => {
|
||||||
|
assert.match(
|
||||||
|
styleCss,
|
||||||
|
/\.artifact-evolution-diff-line\.kind-add\s*\{[^}]*color-mix\([^)]*var\(--ok/,
|
||||||
|
'add-lines must tint with the ok token via color-mix so both themes read correctly',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
styleCss,
|
||||||
|
/\.artifact-evolution-diff-line\.kind-del\s*\{[^}]*color-mix\([^)]*var\(--danger/,
|
||||||
|
'del-lines must tint with the danger token via color-mix so both themes read correctly',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .artifact-timeline-row is a slim border-bottom stripe', () => {
|
||||||
|
const body = findRule(styleCss, '.artifact-timeline-row {')
|
||||||
|
assert.ok(body, '.artifact-timeline-row rule must exist')
|
||||||
|
assert.match(body, /border-bottom:\s*1px dashed var\(--border\)/,
|
||||||
|
'timeline rows use a dashed separator so they read as an audit list, not full cards')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: existing compact-row locks (min-height <= 32) still hold', () => {
|
||||||
|
// Belt-and-suspenders — the V2 changes must not regress the L0 row
|
||||||
|
// locks that artifact-compact-row.test.js pins.
|
||||||
|
const body = findRule(styleCss, '.artifact-row {')
|
||||||
|
assert.ok(body, '.artifact-row rule must survive V2')
|
||||||
|
const m = body.match(/min-height:\s*(\d+)px/)
|
||||||
|
assert.ok(m, '.artifact-row must still declare min-height')
|
||||||
|
assert.ok(Number(m[1]) <= 32, 'L0 row compact height must not drift')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user