diff --git a/src/server/board/chrome.ts b/src/server/board/chrome.ts
new file mode 100644
index 0000000..22b2430
--- /dev/null
+++ b/src/server/board/chrome.ts
@@ -0,0 +1,60 @@
+import { escapeHtml } from '../ui-shared.js';
+
+const LOGO = '
';
+
+/**
+ * Fixed app header for board v2. The project cell is visual only — the real
+ * project switcher dropdown arrives with the multi-project v3 spec.
+ * The SSE status span keeps the v1 ids (`sseStatus`, `sseLabel`) so the ported
+ * connection-status JS keeps working unchanged.
+ */
+export function headerHtml(projectName: string): string {
+ const name = escapeHtml(projectName);
+ return `
+`;
+}
+
+/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
+export function splashHtml(): string {
+ return `
+
+
+

+
agenthub
+
+
`;
+}
+
+/**
+ * Inline script: hides the splash after the first successful refresh() or after
+ * a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
+ * the data layer can signal completion.
+ */
+export function splashJs(): string {
+ return `
+(function () {
+ var done = false;
+ window.__b2SplashDone = function () {
+ if (done) return;
+ done = true;
+ var s = document.getElementById('b2-splash');
+ if (!s) return;
+ s.classList.add('b2-splash-hide');
+ setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
+ };
+ setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
+})();`;
+}
diff --git a/src/server/routes.ts b/src/server/routes.ts
index 0efd463..efe0560 100644
--- a/src/server/routes.ts
+++ b/src/server/routes.ts
@@ -59,6 +59,16 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
+ // AgentHub logo (header, splash, favicon). Read-only, cacheable.
+ app.get('/logo.svg', async (_request, reply) => {
+ try {
+ const buf = await readFile(join(CARD_ASSETS_DIR, 'logo.svg'));
+ return reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(buf);
+ } catch {
+ return reply.status(404).send('not found');
+ }
+ });
+
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
app.get('/card-assets/*', async (request, reply) => {
diff --git a/tests/boardV2-chrome.test.ts b/tests/boardV2-chrome.test.ts
new file mode 100644
index 0000000..7d7ad9e
--- /dev/null
+++ b/tests/boardV2-chrome.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
+
+describe('headerHtml', () => {
+ it('renders logo, project cell with chevron, nav and new-task button', () => {
+ const h = headerHtml('my-project');
+ expect(h).toContain('/logo.svg');
+ expect(h).toContain('my-project');
+ expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
+ expect(h).toContain('▾');
+ expect(h).toContain('+ New task');
+ expect(h).toContain('id="newTaskBtn"');
+ // SSE status mount compatible with the ported v1 connection JS
+ expect(h).toContain('id="sseStatus"');
+ expect(h).toContain('id="sseLabel"');
+ });
+ it('escapes the project name', () => {
+ expect(headerHtml('x')).not.toContain('x');
+ });
+});
+
+describe('splash', () => {
+ it('renders overlay with logo and wordmark', () => {
+ const s = splashHtml();
+ expect(s).toContain('id="b2-splash"');
+ expect(s).toContain('/logo.svg');
+ expect(s).toContain('agenthub');
+ });
+ it('fade-out script has a hard timeout and never blocks', () => {
+ const js = splashJs();
+ expect(js).toContain('b2-splash');
+ expect(js).toContain('1500'); // hard cap in ms
+ expect(js).toContain('__b2SplashDone');
+ });
+});