feat(dashboard): redesign autopilot kanban with React + dark cyberpunk theme
Replace the inline HTML template with a Vite + React + TypeScript app in autopilot-dashboard/. The new design follows the AnyFS dark theme aesthetic with animated SVG hero background (data streams, constellation nodes, binary rain, scanline), pipeline visualization, glass cards with status-colored glow effects, and JetBrains Mono typography throughout. - autopilot-dashboard/: new Vite+React project, builds to docs/autopilot/ - HeroBackground.tsx: multi-layer SMIL SVG animation - PipelineAnimation.tsx: QUEUE→PREP→RUN→CHECK→MERGE traveling glow - CI workflow updated to build React app before deploying to Pages - Python _render_dashboard_html simplified to a minimal fallback page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "docs/autopilot/**"
|
||||
- "autopilot-dashboard/**"
|
||||
- ".github/workflows/autopilot-pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -28,6 +29,21 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: autopilot-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: autopilot-dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: autopilot-dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autopilot Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "autopilot-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"generated_at": 1776338766.0050209,
|
||||
"repo_name": "OpenHarness-new",
|
||||
"repo_path": "/home/tangjiabin/OpenHarness-new",
|
||||
"focus": null,
|
||||
"counts": {
|
||||
"queued": 0,
|
||||
"accepted": 0,
|
||||
"preparing": 0,
|
||||
"running": 0,
|
||||
"verifying": 0,
|
||||
"pr_open": 0,
|
||||
"waiting_ci": 0,
|
||||
"repairing": 0,
|
||||
"completed": 0,
|
||||
"merged": 0,
|
||||
"failed": 0,
|
||||
"rejected": 0,
|
||||
"superseded": 0
|
||||
},
|
||||
"status_order": [
|
||||
"queued",
|
||||
"accepted",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"pr_open",
|
||||
"waiting_ci",
|
||||
"repairing",
|
||||
"completed",
|
||||
"merged",
|
||||
"failed",
|
||||
"rejected",
|
||||
"superseded"
|
||||
],
|
||||
"columns": {
|
||||
"queued": [],
|
||||
"accepted": [],
|
||||
"preparing": [],
|
||||
"running": [],
|
||||
"verifying": [],
|
||||
"pr_open": [],
|
||||
"waiting_ci": [],
|
||||
"repairing": [],
|
||||
"completed": [],
|
||||
"merged": [],
|
||||
"failed": [],
|
||||
"rejected": [],
|
||||
"superseded": []
|
||||
},
|
||||
"cards": [],
|
||||
"journal": [],
|
||||
"policies": {
|
||||
"autopilot": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml",
|
||||
"verification": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml",
|
||||
"release": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
},
|
||||
"active_context": "# Active Repo Context\n\nGenerated at: 2026-04-16 11:05:49 UTC\n\n## Current Task Focus\n- No active repo task focus yet.\n\n## In Progress\n- None.\n\n## Next Up\n- No queued items.\n\n## Recently Completed\n- None yet.\n\n## Recent Failures\n- None.\n\n## Recent Repo Journal\n- Journal is empty.\n\n## Policies\n- Autopilot: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml\n- Verification: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml\n- Release: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HeroBackground } from "./components/HeroBackground";
|
||||
import { PipelineAnimation } from "./components/PipelineAnimation";
|
||||
import type { Snapshot, TaskCard, JournalEntry } from "./types";
|
||||
import { STATUS_LABELS, STATUS_COLORS } from "./types";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────── */
|
||||
|
||||
function fmtAgo(ts?: number): string {
|
||||
if (!ts) return "-";
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (delta < 60) return `${delta}s ago`;
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (["running", "completed", "merged", "preparing"].includes(status)) return "badge-teal";
|
||||
if (["repairing"].includes(status)) return "badge-orange";
|
||||
if (["accepted", "pr_open"].includes(status)) return "badge-violet";
|
||||
if (["failed", "rejected"].includes(status)) return "badge-red";
|
||||
if (["verifying", "waiting_ci"].includes(status)) return "badge-blue";
|
||||
if (["superseded"].includes(status)) return "badge-amber";
|
||||
return "badge-gray";
|
||||
}
|
||||
|
||||
/* ── Card Component ──────────────────────────── */
|
||||
|
||||
function CardView({ card }: { card: TaskCard }) {
|
||||
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
|
||||
const verification = (card.metadata?.verification_steps || [])
|
||||
.map((step) => `${step.status} · ${step.command}`)
|
||||
.slice(0, 2)
|
||||
.join(" | ");
|
||||
const borderColor = STATUS_COLORS[card.status] || "#333";
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card"
|
||||
style={{ "--card-accent": borderColor } as React.CSSProperties}
|
||||
>
|
||||
<div className="card-meta">
|
||||
<span>{card.id}</span>
|
||||
<span className={`badge ${statusBadgeClass(card.status)}`}>
|
||||
{card.score}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{card.title}</h3>
|
||||
{card.body && (
|
||||
<p className="card-body">{card.body.slice(0, 260)}</p>
|
||||
)}
|
||||
{labels.length > 0 && (
|
||||
<div className="card-tags">
|
||||
{labels.map((tag, i) => (
|
||||
<span key={i} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div>
|
||||
updated {fmtAgo(card.updated_at)}
|
||||
{card.source_ref ? ` · ref ${card.source_ref}` : ""}
|
||||
</div>
|
||||
<div>{card.metadata?.last_note || "no status note yet"}</div>
|
||||
{(verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || card.metadata?.human_gate_pending) && (
|
||||
<div>
|
||||
{verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary ||
|
||||
(card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Column Component ────────────────────────── */
|
||||
|
||||
function ColumnView({ status, cards }: { status: string; cards: TaskCard[] }) {
|
||||
const color = STATUS_COLORS[status] || "#666";
|
||||
return (
|
||||
<section className="column">
|
||||
<div className="column-header">
|
||||
<h2 style={{ color }}>{STATUS_LABELS[status] || status}</h2>
|
||||
<span className="column-count">{cards.length}</span>
|
||||
</div>
|
||||
<div className="cards">
|
||||
{cards.length > 0
|
||||
? cards.map((card) => <CardView key={card.id} card={card} />)
|
||||
: <div className="empty">No cards in this column.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Journal Component ───────────────────────── */
|
||||
|
||||
function JournalView({ entries }: { entries: JournalEntry[] }) {
|
||||
return (
|
||||
<section className="journal">
|
||||
<div className="journal-header">
|
||||
<span style={{ color: "var(--accent)", fontSize: 10, letterSpacing: 2, fontWeight: 700 }}>
|
||||
//
|
||||
</span>
|
||||
<h2>RECENT JOURNAL</h2>
|
||||
</div>
|
||||
<div className="journal-list">
|
||||
{entries.length > 0
|
||||
? entries.slice().reverse().map((entry, i) => (
|
||||
<article key={i} className="journal-item">
|
||||
<time>
|
||||
{new Date(entry.timestamp * 1000)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(".000Z", " UTC")}
|
||||
</time>
|
||||
<div>
|
||||
<span className="kind">{entry.kind}</span>
|
||||
{entry.task_id && <span className="task-ref"> [{entry.task_id}]</span>}
|
||||
</div>
|
||||
<div className="summary">{entry.summary}</div>
|
||||
</article>
|
||||
))
|
||||
: <div className="empty">Journal is empty.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main App ────────────────────────────────── */
|
||||
|
||||
export function App() {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("./snapshot.json", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then(setSnapshot)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="shell" style={{ paddingTop: 80 }}>
|
||||
<div className="empty">Failed to load snapshot.json: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="shell" style={{ paddingTop: 80, textAlign: "center" }}>
|
||||
<div style={{ color: "var(--accent)", fontSize: 12, letterSpacing: 2 }}>
|
||||
LOADING SNAPSHOT...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = snapshot.counts || {};
|
||||
const order = snapshot.status_order || [];
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
|
||||
const filteredColumns = order.map((status) => {
|
||||
const cards = (snapshot.columns?.[status] || []).filter((card) => {
|
||||
if (!normalizedFilter) return true;
|
||||
const haystack = [
|
||||
card.id, card.title, card.body, card.source_kind, card.source_ref,
|
||||
...(card.labels || []), ...(card.score_reasons || []),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalizedFilter);
|
||||
});
|
||||
return { status, cards };
|
||||
}).filter(({ cards }) => cards.length > 0 || !normalizedFilter);
|
||||
|
||||
const inProgress = (counts.preparing || 0) + (counts.running || 0) +
|
||||
(counts.verifying || 0) + (counts.waiting_ci || 0) +
|
||||
(counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0);
|
||||
const completed = (counts.completed || 0) + (counts.merged || 0);
|
||||
const failed = (counts.failed || 0) + (counts.rejected || 0);
|
||||
|
||||
const generated = new Date((snapshot.generated_at || 0) * 1000)
|
||||
.toISOString().replace("T", " ").replace(".000Z", " UTC");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Hero ─────────────────────────── */}
|
||||
<section className="hero">
|
||||
<div className="hero-bg">
|
||||
<HeroBackground />
|
||||
</div>
|
||||
<div className="hero-content">
|
||||
<div className="hero-main">
|
||||
<div className="eyebrow">// AUTOPILOT_KANBAN</div>
|
||||
<h1>
|
||||
{snapshot.repo_name || "OpenHarness"}<br />
|
||||
<span className="accent">SELF-EVOLUTION</span>
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
A static GitHub Pages view of the repo-local autopilot queue.
|
||||
Cards come from ohmo requests, GitHub issues and PRs, and
|
||||
claude-code alignment candidates.
|
||||
</p>
|
||||
<div className="focus-box">
|
||||
<div className="focus-label">// CURRENT_FOCUS</div>
|
||||
<div className="focus-text">
|
||||
{snapshot.focus
|
||||
? `[${snapshot.focus.status}] ${snapshot.focus.title} · score=${snapshot.focus.score} · ${snapshot.focus.source_kind}`
|
||||
: "No active task focus yet."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-side">
|
||||
<div className="hero-timestamp">
|
||||
Generated from repo state at {generated}
|
||||
</div>
|
||||
<div className="pipeline-viz">
|
||||
<PipelineAnimation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shell">
|
||||
{/* ── Stats Bar ──────────────────── */}
|
||||
<section className="stats-bar">
|
||||
<div className="stat">
|
||||
<div className="stat-label teal">QUEUED</div>
|
||||
<div className="stat-value">{counts.queued || 0}</div>
|
||||
<div className="stat-sub">waiting</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label orange">IN PROGRESS</div>
|
||||
<div className="stat-value">{inProgress}</div>
|
||||
<div className="stat-sub">active pipeline</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label teal">COMPLETED</div>
|
||||
<div className="stat-value">{completed}</div>
|
||||
<div className="stat-sub">merged + done</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label" style={{ color: "var(--error)" }}>FAILED</div>
|
||||
<div className="stat-value">{failed}</div>
|
||||
<div className="stat-sub">rejected + failed</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Toolbar ────────────────────── */}
|
||||
<section className="toolbar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter by title, body, source, label, or task id..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<div className="hint">
|
||||
Reads <code>snapshot.json</code> — no backend required
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Kanban Board ───────────────── */}
|
||||
<section className="board">
|
||||
{filteredColumns.map(({ status, cards }) => (
|
||||
<ColumnView key={status} status={status} cards={cards} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ── Journal ────────────────────── */}
|
||||
<JournalView entries={snapshot.journal || []} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* CyberHeroBackground — full-bleed animated SVG background for the kanban hero.
|
||||
*
|
||||
* Layers (back → front):
|
||||
* 1. Radial teal glow
|
||||
* 2. Perspective grid floor
|
||||
* 3. Horizontal data-stream lines
|
||||
* 4. Constellation nodes + edges
|
||||
* 5. Data fragment segments
|
||||
* 6. Binary / hex rain
|
||||
* 7. Horizontal scan-line
|
||||
*
|
||||
* All SMIL-based — no JS animation loops needed.
|
||||
*/
|
||||
export function HeroBackground() {
|
||||
const glyphs = [
|
||||
{ x: 45, ch: "0", sz: 11, dur: 14, d: 0 },
|
||||
{ x: 120, ch: "1", sz: 9, dur: 18, d: 3 },
|
||||
{ x: 195, ch: "A", sz: 10, dur: 12, d: 7 },
|
||||
{ x: 290, ch: "F", sz: 8, dur: 16, d: 1 },
|
||||
{ x: 365, ch: "0", sz: 12, dur: 20, d: 5 },
|
||||
{ x: 430, ch: "1", sz: 9, dur: 13, d: 9 },
|
||||
{ x: 510, ch: "D", sz: 10, dur: 17, d: 2 },
|
||||
{ x: 580, ch: "0", sz: 11, dur: 15, d: 6 },
|
||||
{ x: 650, ch: "1", sz: 8, dur: 11, d: 4 },
|
||||
{ x: 720, ch: "B", sz: 10, dur: 19, d: 8 },
|
||||
{ x: 790, ch: "0", sz: 9, dur: 14, d: 1 },
|
||||
{ x: 860, ch: "E", sz: 11, dur: 16, d: 10 },
|
||||
{ x: 930, ch: "1", sz: 10, dur: 12, d: 3 },
|
||||
{ x: 1000, ch: "C", sz: 8, dur: 18, d: 7 },
|
||||
{ x: 1070, ch: "0", sz: 12, dur: 15, d: 0 },
|
||||
{ x: 1140, ch: "7", sz: 9, dur: 13, d: 5 },
|
||||
{ x: 260, ch: "3", sz: 8, dur: 22, d: 11 },
|
||||
{ x: 475, ch: "F", sz: 10, dur: 16, d: 4 },
|
||||
{ x: 690, ch: "8", sz: 9, dur: 20, d: 6 },
|
||||
{ x: 1020, ch: "1", sz: 11, dur: 14, d: 8 },
|
||||
];
|
||||
|
||||
const cn = [
|
||||
{ x: 80, y: 55 },
|
||||
{ x: 210, y: 30 },
|
||||
{ x: 355, y: 80 },
|
||||
{ x: 500, y: 45 },
|
||||
{ x: 700, y: 68 },
|
||||
{ x: 850, y: 28 },
|
||||
{ x: 980, y: 60 },
|
||||
{ x: 1120, y: 42 },
|
||||
{ x: 145, y: 140 },
|
||||
{ x: 420, y: 155 },
|
||||
{ x: 780, y: 145 },
|
||||
{ x: 1050, y: 130 },
|
||||
];
|
||||
|
||||
const ce: [number, number][] = [
|
||||
[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7],
|
||||
[8, 9], [9, 10], [10, 11],
|
||||
[0, 8], [3, 9], [4, 10], [7, 11],
|
||||
];
|
||||
|
||||
const streams = [
|
||||
{ y: 90, dash: "4 18", tot: 22, spd: 2.0, op: 0.07 },
|
||||
{ y: 160, dash: "2 22", tot: 24, spd: 2.8, op: 0.05 },
|
||||
{ y: 230, dash: "6 14", tot: 20, spd: 1.5, op: 0.08 },
|
||||
{ y: 300, dash: "3 20", tot: 23, spd: 2.2, op: 0.06 },
|
||||
{ y: 370, dash: "5 15", tot: 20, spd: 1.8, op: 0.07 },
|
||||
];
|
||||
|
||||
const frags = [
|
||||
{ x: 95, y: 120, w: 40 },
|
||||
{ x: 310, y: 200, w: 30 },
|
||||
{ x: 540, y: 280, w: 50 },
|
||||
{ x: 760, y: 130, w: 35 },
|
||||
{ x: 990, y: 250, w: 45 },
|
||||
{ x: 180, y: 350, w: 30 },
|
||||
{ x: 640, y: 380, w: 40 },
|
||||
{ x: 1080, y: 330, w: 35 },
|
||||
];
|
||||
|
||||
const gridY = [300, 325, 355, 390, 430];
|
||||
const vx = 600;
|
||||
const vy = 240;
|
||||
const radials = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1200 460"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="45%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.12" />
|
||||
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.03" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="bg-vfade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="8%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="88%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<mask id="bg-mask">
|
||||
<rect width="1200" height="460" fill="url(#bg-vfade)" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
{/* Central glow */}
|
||||
<rect width="1200" height="460" fill="url(#bg-glow)" />
|
||||
|
||||
<g mask="url(#bg-mask)">
|
||||
{/* 1. Perspective grid */}
|
||||
{gridY.map((y, i) => (
|
||||
<line key={`hg${i}`} x1="50" y1={y} x2="1150" y2={y}
|
||||
stroke="#00d4aa" strokeOpacity={0.025 + i * 0.012} strokeWidth="1" />
|
||||
))}
|
||||
{radials.map((n) => (
|
||||
<line key={`vg${n}`} x1={vx} y1={vy} x2={vx + n * 130} y2="460"
|
||||
stroke="#00d4aa" strokeOpacity="0.025" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* 2. Data streams */}
|
||||
{streams.map((s, i) => (
|
||||
<line key={`ds${i}`} x1="0" y1={s.y} x2="1200" y2={s.y}
|
||||
stroke="#00d4aa" strokeOpacity={s.op} strokeWidth="1" strokeDasharray={s.dash}>
|
||||
<animate attributeName="stroke-dashoffset" from="0" to={`-${s.tot}`}
|
||||
dur={`${s.spd}s`} repeatCount="indefinite" />
|
||||
</line>
|
||||
))}
|
||||
|
||||
{/* 3. Constellation edges */}
|
||||
{ce.map(([a, b], i) => (
|
||||
<line key={`ce${i}`} x1={cn[a].x} y1={cn[a].y} x2={cn[b].x} y2={cn[b].y}
|
||||
stroke="#00d4aa" strokeOpacity="0.06" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* 4. Constellation nodes */}
|
||||
{cn.map((n, i) => (
|
||||
<circle key={`cn${i}`} cx={n.x} cy={n.y} r="2" fill="#00d4aa">
|
||||
<animate attributeName="fill-opacity" values="0.1;0.35;0.1"
|
||||
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="r" values="1.5;3.5;1.5"
|
||||
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* 5. Data fragments */}
|
||||
{frags.map((f, i) => (
|
||||
<line key={`fr${i}`} x1={f.x} y1={f.y} x2={f.x + f.w} y2={f.y}
|
||||
stroke={i % 3 === 0 ? "#8b5cf6" : "#00d4aa"} strokeOpacity="0.08"
|
||||
strokeWidth="1" strokeLinecap="round">
|
||||
<animate attributeName="stroke-opacity" values="0.04;0.16;0.04"
|
||||
dur={`${2.5 + (i % 3) * 0.7}s`} begin={`${i * 0.4}s`} repeatCount="indefinite" />
|
||||
</line>
|
||||
))}
|
||||
|
||||
{/* 6. Binary / hex rain */}
|
||||
{glyphs.map((g, i) => (
|
||||
<text key={`gl${i}`} x={g.x} y={-10} fontSize={g.sz}
|
||||
fill={i % 7 === 0 ? "#8b5cf6" : i % 11 === 0 ? "#ff6b35" : "#00d4aa"}
|
||||
fillOpacity="0" fontFamily="JetBrains Mono, monospace" fontWeight="600" letterSpacing="0.6">
|
||||
{g.ch}
|
||||
<animateTransform attributeName="transform" type="translate"
|
||||
from="0 0" to="0 480" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" values="0;0.2;0.2;0"
|
||||
keyTimes="0;0.08;0.85;1" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* 7. Scan-line */}
|
||||
<line x1="0" y1="0" x2="1200" y2="0" stroke="#00d4aa" strokeOpacity="0" strokeWidth="1">
|
||||
<animateTransform attributeName="transform" type="translate"
|
||||
from="0 0" to="0 460" dur="6s" repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity" values="0;0.16;0.16;0"
|
||||
keyTimes="0;0.04;0.96;1" dur="6s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* PipelineAnimation — visualizes the autopilot pipeline stages.
|
||||
* A data packet travels through: QUEUE → PREPARE → RUN → VERIFY → MERGE
|
||||
* Communicates the "self-evolution pipeline" idea.
|
||||
* Uses SMIL animations — no JS loops needed.
|
||||
*/
|
||||
export function PipelineAnimation() {
|
||||
const stages = [
|
||||
{ x: 30, label: "QUEUE" },
|
||||
{ x: 95, label: "PREP" },
|
||||
{ x: 160, label: "RUN" },
|
||||
{ x: 225, label: "CHECK" },
|
||||
{ x: 290, label: "MERGE" },
|
||||
];
|
||||
|
||||
const motionPath = "M 30 50 L 95 50 L 160 50 L 225 50 L 290 50";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 320 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Autopilot pipeline"
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="pipe-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Rail */}
|
||||
<line x1="30" y1="50" x2="290" y2="50"
|
||||
stroke="#00d4aa" strokeOpacity="0.15" strokeWidth="1" strokeDasharray="3 5">
|
||||
<animate attributeName="stroke-dashoffset" values="0;-16" dur="1.5s" repeatCount="indefinite" />
|
||||
</line>
|
||||
|
||||
{/* Stage nodes */}
|
||||
{stages.map((stage, i) => (
|
||||
<g key={stage.label} transform={`translate(${stage.x}, 50)`}>
|
||||
{/* Node circle */}
|
||||
<circle r="14" fill="#0a0a0a" stroke="#00d4aa" strokeOpacity="0.4" strokeWidth="1" />
|
||||
{/* Corner ticks */}
|
||||
<line x1="-10" y1="-10" x2="-7" y2="-10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="-10" y1="-10" x2="-10" y2="-7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="10" y1="-10" x2="7" y2="-10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="10" y1="-10" x2="10" y2="-7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="-10" y1="10" x2="-7" y2="10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="-10" y1="10" x2="-10" y2="7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="7" y2="10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="10" y2="7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
|
||||
{/* Inner dot */}
|
||||
<circle r="3" fill="#00d4aa">
|
||||
<animate attributeName="r" values="3;4.5;3" dur="2.4s"
|
||||
begin={`${i * 0.5}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s"
|
||||
begin={`${i * 0.5}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
{/* Arrival pulse */}
|
||||
<circle r="14" fill="none" stroke="#00d4aa" strokeWidth="1">
|
||||
<animate attributeName="r" values="14;24" dur="3s"
|
||||
begin={`${i * 0.8}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0" dur="3s"
|
||||
begin={`${i * 0.8}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
{/* Label */}
|
||||
<text x="0" y="30" fontSize="7" fill="#00d4aa" fillOpacity="0.6"
|
||||
textAnchor="middle" fontFamily="JetBrains Mono, monospace"
|
||||
letterSpacing="1.5" fontWeight="600">
|
||||
{stage.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Traveling glow */}
|
||||
<circle r="14" fill="url(#pipe-glow)" opacity="0.7">
|
||||
<animateMotion dur="4s" repeatCount="indefinite" path={motionPath}
|
||||
keyPoints="0;0.24;0.25;0.49;0.5;0.74;0.75;0.99;1"
|
||||
keyTimes="0;0.18;0.22;0.38;0.42;0.58;0.62;0.78;1"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1" />
|
||||
</circle>
|
||||
<circle r="4" fill="#00d4aa">
|
||||
<animateMotion dur="4s" repeatCount="indefinite" path={motionPath}
|
||||
keyPoints="0;0.24;0.25;0.49;0.5;0.74;0.75;0.99;1"
|
||||
keyTimes="0;0.18;0.22;0.38;0.42;0.58;0.62;0.78;1"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1" />
|
||||
</circle>
|
||||
|
||||
{/* Caption */}
|
||||
<text x="160" y="93" fontSize="7" fill="#00d4aa" fillOpacity="0.45"
|
||||
textAnchor="middle" fontFamily="JetBrains Mono, monospace" letterSpacing="2.5">
|
||||
AUTOPILOT · PIPELINE
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/* ─── AnyFS-inspired dark cyberpunk theme ─────────── */
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-surface: #111111;
|
||||
--bg-elevated: #1a1a1a;
|
||||
--ink: #ffffff;
|
||||
--ink-secondary: #888888;
|
||||
--accent: #00d4aa;
|
||||
--accent-light: #00f0c0;
|
||||
--accent-orange: #ff6b35;
|
||||
--accent-violet: #8b5cf6;
|
||||
--muted: #666666;
|
||||
--line: #222222;
|
||||
--line-bright: #333333;
|
||||
--success: #00d4aa;
|
||||
--error: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(0, 212, 170, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ──────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
|
||||
/* ─── Shell ──────────────────────────────────── */
|
||||
.shell {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 56px;
|
||||
}
|
||||
|
||||
/* ─── Hero Section ───────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 -20px;
|
||||
padding: 60px 20px 40px;
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-main { padding: 8px; }
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(32px, 4.5vw, 56px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero h1 .accent { color: var(--accent); }
|
||||
|
||||
.hero-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
max-width: 52ch;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.focus-box {
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 212, 170, 0.06);
|
||||
border: 1px solid rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.focus-box .focus-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.focus-box .focus-text {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.hero-timestamp {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ─── Stats Bar ──────────────────────────────── */
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--bg-surface);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label.teal { color: var(--accent); }
|
||||
.stat-label.orange { color: var(--accent-orange); }
|
||||
.stat-label.violet { color: var(--accent-violet); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ─── Toolbar ────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
flex: 1;
|
||||
min-width: min(400px, 100%);
|
||||
height: 2.5rem;
|
||||
padding: 0 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-bright);
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbar input::placeholder { color: #444; }
|
||||
|
||||
.toolbar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 170, 0.1);
|
||||
}
|
||||
|
||||
.toolbar .hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar .hint code {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ─── Board ──────────────────────────────────── */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ─── Column ─────────────────────────────────── */
|
||||
.column {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.cards { display: grid; gap: 10px; }
|
||||
|
||||
/* ─── Card ───────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: var(--card-accent, var(--line-bright));
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--line-bright);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--card-accent, var(--accent)) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ink-secondary);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ─── Status badges for column headers ────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badge-teal {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(0, 212, 170, 0.2);
|
||||
}
|
||||
|
||||
.badge-orange {
|
||||
background: rgba(255, 107, 53, 0.1);
|
||||
color: var(--accent-orange);
|
||||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||||
}
|
||||
|
||||
.badge-violet {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: var(--accent-violet);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
color: #ff6666;
|
||||
border: 1px solid rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.badge-amber {
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
color: #ffaa00;
|
||||
border: 1px solid rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #888;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ─── Journal ────────────────────────────────── */
|
||||
.journal {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.journal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.journal-header h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.journal-list { display: grid; gap: 8px; }
|
||||
|
||||
.journal-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-surface);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.journal-item:hover { background: var(--bg-elevated); }
|
||||
|
||||
.journal-item time {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.5px;
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.journal-item .kind {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.journal-item .task-ref {
|
||||
color: var(--accent-violet);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.journal-item .summary {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 16px 4px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ─── Pipeline Animation (hero side) ─────────── */
|
||||
.pipeline-viz {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Animations ─────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 12px rgba(0, 212, 170, 0.08); }
|
||||
50% { box-shadow: 0 0 24px rgba(0, 212, 170, 0.16); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 1100px) {
|
||||
.hero-content { grid-template-columns: 1fr; }
|
||||
.stats-bar { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell { padding: 0 12px 36px; }
|
||||
.hero { padding: 40px 12px 28px; margin: 0 -12px; }
|
||||
.board { grid-template-columns: 1fr; }
|
||||
.stats-bar { grid-template-columns: 1fr 1fr; }
|
||||
.toolbar input { min-width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface TaskCard {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
status: string;
|
||||
score: number;
|
||||
score_reasons?: string[];
|
||||
source_kind?: string;
|
||||
source_ref?: string;
|
||||
labels?: string[];
|
||||
updated_at?: number;
|
||||
metadata?: {
|
||||
last_note?: string;
|
||||
last_ci_summary?: string;
|
||||
last_failure_summary?: string;
|
||||
human_gate_pending?: boolean;
|
||||
verification_steps?: { status: string; command: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
timestamp: number;
|
||||
kind: string;
|
||||
task_id?: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
generated_at: number;
|
||||
repo_name: string;
|
||||
focus?: TaskCard;
|
||||
counts: Record<string, number>;
|
||||
status_order: string[];
|
||||
columns: Record<string, TaskCard[]>;
|
||||
cards: TaskCard[];
|
||||
journal: JournalEntry[];
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
queued: "Queued",
|
||||
accepted: "Accepted",
|
||||
preparing: "Preparing",
|
||||
running: "Running",
|
||||
verifying: "Verifying",
|
||||
pr_open: "PR Open",
|
||||
waiting_ci: "Waiting CI",
|
||||
repairing: "Repairing",
|
||||
completed: "Completed",
|
||||
merged: "Merged",
|
||||
failed: "Failed",
|
||||
rejected: "Rejected",
|
||||
superseded: "Superseded",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "#64748b",
|
||||
accepted: "#8b5cf6",
|
||||
preparing: "#0f766e",
|
||||
running: "#00d4aa",
|
||||
verifying: "#3b82f6",
|
||||
pr_open: "#8b5cf6",
|
||||
waiting_ci: "#3b82f6",
|
||||
repairing: "#ff6b35",
|
||||
completed: "#00d4aa",
|
||||
merged: "#00d4aa",
|
||||
failed: "#ff4444",
|
||||
rejected: "#ff4444",
|
||||
superseded: "#ffaa00",
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "../docs/autopilot",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
-262
@@ -1,264 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenHarness-new Autopilot Kanban</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe7;
|
||||
--panel: rgba(255,255,255,0.82);
|
||||
--ink: #1b2430;
|
||||
--muted: #5b6875;
|
||||
--line: rgba(27,36,48,0.12);
|
||||
--accent: #0f7b6c;
|
||||
--accent-soft: rgba(15,123,108,0.12);
|
||||
--danger: #bb3e28;
|
||||
--warn: #b7791f;
|
||||
--shadow: 0 20px 40px rgba(27,36,48,0.08);
|
||||
--radius: 18px;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--sans: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,123,108,0.14), transparent 30%),
|
||||
radial-gradient(circle at right, rgba(187,62,40,0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f9f6ef 0%, var(--bg) 100%);
|
||||
}
|
||||
.shell { max-width: 1600px; margin: 0 auto; padding: 32px 20px 56px; }
|
||||
.hero { display: grid; gap: 18px; grid-template-columns: 1.5fr 1fr; margin-bottom: 20px; }
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.hero-main { padding: 28px; min-height: 220px; }
|
||||
.eyebrow {
|
||||
display: inline-flex; gap: 8px; align-items: center; font-size: 12px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--accent); font-weight: 700; margin-bottom: 18px;
|
||||
}
|
||||
h1 { margin: 0 0 12px; font-size: clamp(32px, 4vw, 52px); line-height: 0.96; }
|
||||
.sub { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 58ch; }
|
||||
.focus {
|
||||
margin-top: 20px; padding: 16px; border-radius: 14px; background: var(--accent-soft);
|
||||
border: 1px solid rgba(15,123,108,0.14);
|
||||
}
|
||||
.focus strong { display: block; margin-bottom: 6px; }
|
||||
.hero-side { padding: 22px; display: grid; gap: 12px; align-content: start; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.stat { padding: 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
|
||||
.stat .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.stat .value { margin-top: 6px; font-size: 26px; font-weight: 700; }
|
||||
.toolbar {
|
||||
display: flex; gap: 12px; align-items: center; justify-content: space-between;
|
||||
padding: 18px 20px; margin-bottom: 18px; flex-wrap: wrap;
|
||||
}
|
||||
.toolbar input {
|
||||
min-width: min(420px, 100%); padding: 12px 14px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: rgba(255,255,255,0.84); font: inherit;
|
||||
}
|
||||
.toolbar .hint { color: var(--muted); font-size: 14px; }
|
||||
.board {
|
||||
display: grid; grid-template-columns: repeat(6, minmax(260px, 1fr)); gap: 14px;
|
||||
overflow-x: auto; padding-bottom: 4px;
|
||||
}
|
||||
.column { min-height: 280px; padding: 14px; }
|
||||
.column-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; gap: 8px; }
|
||||
.column-header h2 { margin: 0; font-size: 16px; }
|
||||
.count { color: var(--muted); font-size: 12px; font-family: var(--mono); }
|
||||
.cards { display: grid; gap: 12px; }
|
||||
.card {
|
||||
border-radius: 16px; border: 1px solid var(--line); background: rgba(255,255,255,0.9);
|
||||
padding: 14px; transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.card:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(27,36,48,0.10); }
|
||||
.meta {
|
||||
display: flex; justify-content: space-between; gap: 8px; font-size: 12px;
|
||||
color: var(--muted); margin-bottom: 8px; font-family: var(--mono);
|
||||
}
|
||||
.card h3 { margin: 0 0 8px; font-size: 15px; line-height: 1.35; }
|
||||
.body {
|
||||
margin: 0 0 10px; color: var(--muted); font-size: 14px;
|
||||
line-height: 1.5; white-space: pre-wrap;
|
||||
}
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.tag { font-size: 11px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line); background: #fff; }
|
||||
.footer { border-top: 1px dashed var(--line); padding-top: 10px; display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.journal { margin-top: 20px; padding: 22px; }
|
||||
.journal h2 { margin: 0 0 14px; font-size: 18px; }
|
||||
.journal-list { display: grid; gap: 10px; }
|
||||
.journal-item { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
|
||||
.journal-item time { font-family: var(--mono); color: var(--muted); font-size: 12px; display: inline-block; margin-bottom: 4px; }
|
||||
.empty { color: var(--muted); font-style: italic; padding: 12px 4px; }
|
||||
.status-repairing { border-left: 4px solid #c05621; }
|
||||
.status-waiting_ci { border-left: 4px solid #2563eb; }
|
||||
.status-running { border-left: 4px solid var(--accent); }
|
||||
.status-verifying { border-left: 4px solid #1d4ed8; }
|
||||
.status-preparing { border-left: 4px solid #0f766e; }
|
||||
.status-accepted, .status-pr_open { border-left: 4px solid #7c3aed; }
|
||||
.status-queued { border-left: 4px solid #64748b; }
|
||||
.status-completed, .status-merged { border-left: 4px solid #15803d; }
|
||||
.status-failed, .status-rejected { border-left: 4px solid var(--danger); }
|
||||
.status-superseded { border-left: 4px solid var(--warn); }
|
||||
@media (max-width: 1100px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.board { grid-template-columns: repeat(3, minmax(250px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shell { padding: 18px 12px 36px; }
|
||||
.board { grid-template-columns: 1fr; }
|
||||
.toolbar input { min-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<section class="hero">
|
||||
<div class="hero-main panel">
|
||||
<div class="eyebrow">OpenHarness Autopilot</div>
|
||||
<h1>OpenHarness-new self-evolution board</h1>
|
||||
<p class="sub">
|
||||
A static GitHub Pages view of the repo-local autopilot queue. Cards come from ohmo requests,
|
||||
GitHub issues and PRs, and claude-code alignment candidates. Service-level verification remains the source of truth.
|
||||
</p>
|
||||
<div class="focus" id="focus-box">
|
||||
<strong>Current focus</strong>
|
||||
<div id="focus-text">Loading latest snapshot…</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-side panel">
|
||||
<div class="hint">Generated from repo state at 2026-04-16 11:26:06 UTC</div>
|
||||
<div class="stat-grid" id="stats"></div>
|
||||
</aside>
|
||||
</section>
|
||||
<section class="toolbar panel">
|
||||
<input id="search" type="search" placeholder="Filter by title, body, source, label, or task id" />
|
||||
<div class="hint">This page reads <code>snapshot.json</code> and renders a kanban without any backend.</div>
|
||||
</section>
|
||||
<section class="board" id="board"></section>
|
||||
<section class="journal panel">
|
||||
<h2>Recent repo journal</h2>
|
||||
<div class="journal-list" id="journal"></div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const STATUS_LABELS = {
|
||||
queued: "Queued",
|
||||
accepted: "Accepted",
|
||||
preparing: "Preparing",
|
||||
running: "Running",
|
||||
verifying: "Verifying",
|
||||
pr_open: "PR Open",
|
||||
waiting_ci: "Waiting CI",
|
||||
repairing: "Repairing",
|
||||
completed: "Completed",
|
||||
merged: "Merged",
|
||||
failed: "Failed",
|
||||
rejected: "Rejected",
|
||||
superseded: "Superseded",
|
||||
};
|
||||
const fmtAgo = (ts) => {
|
||||
if (!ts) return "-";
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (delta < 60) return `${delta}s ago`;
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
};
|
||||
const renderCard = (card) => {
|
||||
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
|
||||
const verification = (card.metadata?.verification_steps || [])
|
||||
.map((step) => `${step.status} · ${step.command}`)
|
||||
.slice(0, 2)
|
||||
.join(" | ");
|
||||
return `
|
||||
<article class="card status-${card.status}">
|
||||
<div class="meta"><span>${card.id}</span><span>score ${card.score}</span></div>
|
||||
<h3>${card.title}</h3>
|
||||
<p class="body">${(card.body || "").slice(0, 260) || "No extra detail."}</p>
|
||||
<div class="tags">${labels.map((tag) => `<span class="tag">${tag}</span>`).join("")}</div>
|
||||
<div class="footer">
|
||||
<div>updated ${fmtAgo(card.updated_at)}${card.source_ref ? ` · ref ${card.source_ref}` : ""}</div>
|
||||
<div>${card.metadata?.last_note || "no status note yet"}</div>
|
||||
<div>${verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || (card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
const render = (snapshot, filterText = "") => {
|
||||
const counts = snapshot.counts || {};
|
||||
const order = snapshot.status_order || [];
|
||||
const board = document.getElementById("board");
|
||||
const stats = document.getElementById("stats");
|
||||
const journal = document.getElementById("journal");
|
||||
const focusText = document.getElementById("focus-text");
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
stats.innerHTML = [
|
||||
["Queued", counts.queued || 0],
|
||||
["In progress", (counts.preparing || 0) + (counts.running || 0) + (counts.verifying || 0) + (counts.waiting_ci || 0) + (counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0)],
|
||||
["Completed", (counts.completed || 0) + (counts.merged || 0)],
|
||||
["Failed", (counts.failed || 0) + (counts.rejected || 0)]
|
||||
].map(([label, value]) => `
|
||||
<div class="stat">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value">${value}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
focusText.textContent = snapshot.focus
|
||||
? `[${snapshot.focus.status}] ${snapshot.focus.title} · score=${snapshot.focus.score} · ${snapshot.focus.source_kind}`
|
||||
: "No active task focus yet.";
|
||||
board.innerHTML = order.map((status) => {
|
||||
const cards = (snapshot.columns?.[status] || []).filter((card) => {
|
||||
if (!normalizedFilter) return true;
|
||||
const haystack = [
|
||||
card.id,
|
||||
card.title,
|
||||
card.body,
|
||||
card.source_kind,
|
||||
card.source_ref,
|
||||
...(card.labels || []),
|
||||
...(card.score_reasons || []),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalizedFilter);
|
||||
});
|
||||
return `
|
||||
<section class="column panel">
|
||||
<div class="column-header">
|
||||
<h2>${STATUS_LABELS[status] || status}</h2>
|
||||
<span class="count">${cards.length}</span>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${cards.length ? cards.map(renderCard).join("") : '<div class="empty">No cards in this column.</div>'}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
const entries = snapshot.journal || [];
|
||||
journal.innerHTML = entries.length ? entries.slice().reverse().map((entry) => `
|
||||
<article class="journal-item">
|
||||
<time>${new Date(entry.timestamp * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC")}</time>
|
||||
<div><strong>${entry.kind}</strong>${entry.task_id ? ` [${entry.task_id}]` : ""}</div>
|
||||
<div>${entry.summary}</div>
|
||||
</article>
|
||||
`).join("") : '<div class="empty">Journal is empty.</div>';
|
||||
};
|
||||
fetch("./snapshot.json", { cache: "no-store" })
|
||||
.then((response) => response.json())
|
||||
.then((snapshot) => {
|
||||
render(snapshot);
|
||||
const search = document.getElementById("search");
|
||||
search.addEventListener("input", () => render(snapshot, search.value));
|
||||
})
|
||||
.catch((error) => {
|
||||
document.getElementById("board").innerHTML = `<div class="empty">Failed to load snapshot.json: ${error}</div>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autopilot Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="./assets/index-DbmnWBut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CUSUttvC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1758,6 +1758,14 @@ class RepoAutopilotStore:
|
||||
return order.get(status, 99)
|
||||
|
||||
def _render_dashboard_html(self, snapshot: dict[str, Any]) -> str:
|
||||
"""Return a minimal fallback HTML page.
|
||||
|
||||
The primary dashboard is now a React + Vite app built from
|
||||
``autopilot-dashboard/``. This fallback is only written when
|
||||
no pre-built ``index.html`` already exists in the output
|
||||
directory, so local ``snapshot.json`` generation still works
|
||||
without a Node.js toolchain.
|
||||
"""
|
||||
repo_name = escape(_safe_text(snapshot.get("repo_name")) or "OpenHarness")
|
||||
generated = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC",
|
||||
@@ -1769,262 +1777,42 @@ class RepoAutopilotStore:
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{repo_name} Autopilot Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f4efe7;
|
||||
--panel: rgba(255,255,255,0.82);
|
||||
--ink: #1b2430;
|
||||
--muted: #5b6875;
|
||||
--line: rgba(27,36,48,0.12);
|
||||
--accent: #0f7b6c;
|
||||
--accent-soft: rgba(15,123,108,0.12);
|
||||
--danger: #bb3e28;
|
||||
--warn: #b7791f;
|
||||
--shadow: 0 20px 40px rgba(27,36,48,0.08);
|
||||
--radius: 18px;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--sans: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,123,108,0.14), transparent 30%),
|
||||
radial-gradient(circle at right, rgba(187,62,40,0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f9f6ef 0%, var(--bg) 100%);
|
||||
}}
|
||||
.shell {{ max-width: 1600px; margin: 0 auto; padding: 32px 20px 56px; }}
|
||||
.hero {{ display: grid; gap: 18px; grid-template-columns: 1.5fr 1fr; margin-bottom: 20px; }}
|
||||
.panel {{
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}}
|
||||
.hero-main {{ padding: 28px; min-height: 220px; }}
|
||||
.eyebrow {{
|
||||
display: inline-flex; gap: 8px; align-items: center; font-size: 12px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--accent); font-weight: 700; margin-bottom: 18px;
|
||||
}}
|
||||
h1 {{ margin: 0 0 12px; font-size: clamp(32px, 4vw, 52px); line-height: 0.96; }}
|
||||
.sub {{ margin: 0; color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 58ch; }}
|
||||
.focus {{
|
||||
margin-top: 20px; padding: 16px; border-radius: 14px; background: var(--accent-soft);
|
||||
border: 1px solid rgba(15,123,108,0.14);
|
||||
}}
|
||||
.focus strong {{ display: block; margin-bottom: 6px; }}
|
||||
.hero-side {{ padding: 22px; display: grid; gap: 12px; align-content: start; }}
|
||||
.stat-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }}
|
||||
.stat {{ padding: 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }}
|
||||
.stat .label {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }}
|
||||
.stat .value {{ margin-top: 6px; font-size: 26px; font-weight: 700; }}
|
||||
.toolbar {{
|
||||
display: flex; gap: 12px; align-items: center; justify-content: space-between;
|
||||
padding: 18px 20px; margin-bottom: 18px; flex-wrap: wrap;
|
||||
}}
|
||||
.toolbar input {{
|
||||
min-width: min(420px, 100%); padding: 12px 14px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: rgba(255,255,255,0.84); font: inherit;
|
||||
}}
|
||||
.toolbar .hint {{ color: var(--muted); font-size: 14px; }}
|
||||
.board {{
|
||||
display: grid; grid-template-columns: repeat(6, minmax(260px, 1fr)); gap: 14px;
|
||||
overflow-x: auto; padding-bottom: 4px;
|
||||
}}
|
||||
.column {{ min-height: 280px; padding: 14px; }}
|
||||
.column-header {{ display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; gap: 8px; }}
|
||||
.column-header h2 {{ margin: 0; font-size: 16px; }}
|
||||
.count {{ color: var(--muted); font-size: 12px; font-family: var(--mono); }}
|
||||
.cards {{ display: grid; gap: 12px; }}
|
||||
.card {{
|
||||
border-radius: 16px; border: 1px solid var(--line); background: rgba(255,255,255,0.9);
|
||||
padding: 14px; transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}}
|
||||
.card:hover {{ transform: translateY(-2px); box-shadow: 0 14px 30px rgba(27,36,48,0.10); }}
|
||||
.meta {{
|
||||
display: flex; justify-content: space-between; gap: 8px; font-size: 12px;
|
||||
color: var(--muted); margin-bottom: 8px; font-family: var(--mono);
|
||||
}}
|
||||
.card h3 {{ margin: 0 0 8px; font-size: 15px; line-height: 1.35; }}
|
||||
.body {{
|
||||
margin: 0 0 10px; color: var(--muted); font-size: 14px;
|
||||
line-height: 1.5; white-space: pre-wrap;
|
||||
}}
|
||||
.tags {{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }}
|
||||
.tag {{ font-size: 11px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line); background: #fff; }}
|
||||
.footer {{ border-top: 1px dashed var(--line); padding-top: 10px; display: grid; gap: 6px; font-size: 12px; color: var(--muted); }}
|
||||
.journal {{ margin-top: 20px; padding: 22px; }}
|
||||
.journal h2 {{ margin: 0 0 14px; font-size: 18px; }}
|
||||
.journal-list {{ display: grid; gap: 10px; }}
|
||||
.journal-item {{ padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }}
|
||||
.journal-item time {{ font-family: var(--mono); color: var(--muted); font-size: 12px; display: inline-block; margin-bottom: 4px; }}
|
||||
.empty {{ color: var(--muted); font-style: italic; padding: 12px 4px; }}
|
||||
.status-repairing {{ border-left: 4px solid #c05621; }}
|
||||
.status-waiting_ci {{ border-left: 4px solid #2563eb; }}
|
||||
.status-running {{ border-left: 4px solid var(--accent); }}
|
||||
.status-verifying {{ border-left: 4px solid #1d4ed8; }}
|
||||
.status-preparing {{ border-left: 4px solid #0f766e; }}
|
||||
.status-accepted, .status-pr_open {{ border-left: 4px solid #7c3aed; }}
|
||||
.status-queued {{ border-left: 4px solid #64748b; }}
|
||||
.status-completed, .status-merged {{ border-left: 4px solid #15803d; }}
|
||||
.status-failed, .status-rejected {{ border-left: 4px solid var(--danger); }}
|
||||
.status-superseded {{ border-left: 4px solid var(--warn); }}
|
||||
@media (max-width: 1100px) {{
|
||||
.hero {{ grid-template-columns: 1fr; }}
|
||||
.board {{ grid-template-columns: repeat(3, minmax(250px, 1fr)); }}
|
||||
}}
|
||||
@media (max-width: 760px) {{
|
||||
.shell {{ padding: 18px 12px 36px; }}
|
||||
.board {{ grid-template-columns: 1fr; }}
|
||||
.toolbar input {{ min-width: 100%; }}
|
||||
--bg: #0a0a0a; --bg-elevated: #1a1a1a; --ink: #fff;
|
||||
--accent: #00d4aa; --muted: #666; --line: #222;
|
||||
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ background: var(--bg); color: var(--ink); font-family: var(--mono); font-size: 13px; }}
|
||||
.shell {{ max-width: 960px; margin: 80px auto; padding: 0 20px; text-align: center; }}
|
||||
h1 {{ font-size: 32px; letter-spacing: 2px; margin-bottom: 16px; }}
|
||||
h1 span {{ color: var(--accent); }}
|
||||
.sub {{ color: var(--muted); font-size: 12px; line-height: 1.8; margin-bottom: 32px; }}
|
||||
.info {{ background: var(--bg-elevated); border: 1px solid var(--line); border-radius: 6px; padding: 24px; text-align: left; }}
|
||||
.info p {{ color: #888; font-size: 12px; line-height: 1.7; margin-bottom: 12px; }}
|
||||
.info code {{ color: var(--accent); }}
|
||||
.ts {{ color: var(--muted); font-size: 10px; letter-spacing: 1px; margin-top: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<section class="hero">
|
||||
<div class="hero-main panel">
|
||||
<div class="eyebrow">OpenHarness Autopilot</div>
|
||||
<h1>{repo_name} self-evolution board</h1>
|
||||
<p class="sub">
|
||||
A static GitHub Pages view of the repo-local autopilot queue. Cards come from ohmo requests,
|
||||
GitHub issues and PRs, and claude-code alignment candidates. Service-level verification remains the source of truth.
|
||||
</p>
|
||||
<div class="focus" id="focus-box">
|
||||
<strong>Current focus</strong>
|
||||
<div id="focus-text">Loading latest snapshot…</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-side panel">
|
||||
<div class="hint">Generated from repo state at {escape(generated)}</div>
|
||||
<div class="stat-grid" id="stats"></div>
|
||||
</aside>
|
||||
</section>
|
||||
<section class="toolbar panel">
|
||||
<input id="search" type="search" placeholder="Filter by title, body, source, label, or task id" />
|
||||
<div class="hint">This page reads <code>snapshot.json</code> and renders a kanban without any backend.</div>
|
||||
</section>
|
||||
<section class="board" id="board"></section>
|
||||
<section class="journal panel">
|
||||
<h2>Recent repo journal</h2>
|
||||
<div class="journal-list" id="journal"></div>
|
||||
</section>
|
||||
<h1>{repo_name} <span>AUTOPILOT</span></h1>
|
||||
<p class="sub">
|
||||
This is a fallback page. The full React dashboard is built via CI
|
||||
from <code>autopilot-dashboard/</code>.
|
||||
</p>
|
||||
<div class="info">
|
||||
<p>To view the full dashboard locally, build the React app:</p>
|
||||
<p><code>cd autopilot-dashboard && npm install && npm run build</code></p>
|
||||
<p>Then open <code>docs/autopilot/index.html</code> in a browser.</p>
|
||||
<p>Snapshot data: <code>snapshot.json</code> (generated {escape(generated)})</p>
|
||||
</div>
|
||||
<div class="ts">Generated at {escape(generated)}</div>
|
||||
</div>
|
||||
<script>
|
||||
const STATUS_LABELS = {{
|
||||
queued: "Queued",
|
||||
accepted: "Accepted",
|
||||
preparing: "Preparing",
|
||||
running: "Running",
|
||||
verifying: "Verifying",
|
||||
pr_open: "PR Open",
|
||||
waiting_ci: "Waiting CI",
|
||||
repairing: "Repairing",
|
||||
completed: "Completed",
|
||||
merged: "Merged",
|
||||
failed: "Failed",
|
||||
rejected: "Rejected",
|
||||
superseded: "Superseded",
|
||||
}};
|
||||
const fmtAgo = (ts) => {{
|
||||
if (!ts) return "-";
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (delta < 60) return `${{delta}}s ago`;
|
||||
if (delta < 3600) return `${{Math.floor(delta / 60)}}m ago`;
|
||||
if (delta < 86400) return `${{Math.floor(delta / 3600)}}h ago`;
|
||||
return `${{Math.floor(delta / 86400)}}d ago`;
|
||||
}};
|
||||
const renderCard = (card) => {{
|
||||
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
|
||||
const verification = (card.metadata?.verification_steps || [])
|
||||
.map((step) => `${{step.status}} · ${{step.command}}`)
|
||||
.slice(0, 2)
|
||||
.join(" | ");
|
||||
return `
|
||||
<article class="card status-${{card.status}}">
|
||||
<div class="meta"><span>${{card.id}}</span><span>score ${{card.score}}</span></div>
|
||||
<h3>${{card.title}}</h3>
|
||||
<p class="body">${{(card.body || "").slice(0, 260) || "No extra detail."}}</p>
|
||||
<div class="tags">${{labels.map((tag) => `<span class="tag">${{tag}}</span>`).join("")}}</div>
|
||||
<div class="footer">
|
||||
<div>updated ${{fmtAgo(card.updated_at)}}${{card.source_ref ? ` · ref ${{card.source_ref}}` : ""}}</div>
|
||||
<div>${{card.metadata?.last_note || "no status note yet"}}</div>
|
||||
<div>${{verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || (card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}}</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}};
|
||||
const render = (snapshot, filterText = "") => {{
|
||||
const counts = snapshot.counts || {{}};
|
||||
const order = snapshot.status_order || [];
|
||||
const board = document.getElementById("board");
|
||||
const stats = document.getElementById("stats");
|
||||
const journal = document.getElementById("journal");
|
||||
const focusText = document.getElementById("focus-text");
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
stats.innerHTML = [
|
||||
["Queued", counts.queued || 0],
|
||||
["In progress", (counts.preparing || 0) + (counts.running || 0) + (counts.verifying || 0) + (counts.waiting_ci || 0) + (counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0)],
|
||||
["Completed", (counts.completed || 0) + (counts.merged || 0)],
|
||||
["Failed", (counts.failed || 0) + (counts.rejected || 0)]
|
||||
].map(([label, value]) => `
|
||||
<div class="stat">
|
||||
<div class="label">${{label}}</div>
|
||||
<div class="value">${{value}}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
focusText.textContent = snapshot.focus
|
||||
? `[${{snapshot.focus.status}}] ${{snapshot.focus.title}} · score=${{snapshot.focus.score}} · ${{snapshot.focus.source_kind}}`
|
||||
: "No active task focus yet.";
|
||||
board.innerHTML = order.map((status) => {{
|
||||
const cards = (snapshot.columns?.[status] || []).filter((card) => {{
|
||||
if (!normalizedFilter) return true;
|
||||
const haystack = [
|
||||
card.id,
|
||||
card.title,
|
||||
card.body,
|
||||
card.source_kind,
|
||||
card.source_ref,
|
||||
...(card.labels || []),
|
||||
...(card.score_reasons || []),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalizedFilter);
|
||||
}});
|
||||
return `
|
||||
<section class="column panel">
|
||||
<div class="column-header">
|
||||
<h2>${{STATUS_LABELS[status] || status}}</h2>
|
||||
<span class="count">${{cards.length}}</span>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${{cards.length ? cards.map(renderCard).join("") : '<div class="empty">No cards in this column.</div>'}}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}}).join("");
|
||||
const entries = snapshot.journal || [];
|
||||
journal.innerHTML = entries.length ? entries.slice().reverse().map((entry) => `
|
||||
<article class="journal-item">
|
||||
<time>${{new Date(entry.timestamp * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC")}}</time>
|
||||
<div><strong>${{entry.kind}}</strong>${{entry.task_id ? ` [${{entry.task_id}}]` : ""}}</div>
|
||||
<div>${{entry.summary}}</div>
|
||||
</article>
|
||||
`).join("") : '<div class="empty">Journal is empty.</div>';
|
||||
}};
|
||||
fetch("./snapshot.json", {{ cache: "no-store" }})
|
||||
.then((response) => response.json())
|
||||
.then((snapshot) => {{
|
||||
render(snapshot);
|
||||
const search = document.getElementById("search");
|
||||
search.addEventListener("input", () => render(snapshot, search.value));
|
||||
}})
|
||||
.catch((error) => {{
|
||||
document.getElementById("board").innerHTML = `<div class="empty">Failed to load snapshot.json: ${{error}}</div>`;
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user