For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire the realtime hub, topic/proposal data layers, and resolution flow specified by sub-projects #1–#7 into the wiki-browser chrome and content iframe. Deliver: sidebar state machine, Topic affordances inside prose, an iframe-slot Resolve mode, SSE-driven realtime consumer with presence + toasts, and mobile reflows. No new Go endpoints.
Architecture: Mostly client-side work in internal/server/templates/shell.html, internal/server/static/{chrome.css,chrome.js,content.js,prose.css}, plus one small Go template-data plumbing change for SelfUserID (internal/server/embed.go, internal/server/handler_doc.go). The shell-side chrome.js IIFE grows to host a sidebar state machine, a Resolve-mode controller, an EventSource lifecycle, a focus-POST debouncer, a toast queue, a dead-doc state, and a presence renderer. The in-iframe content.js gains a margin-glyph layer, anchor-state styling, an overlap-menu, and a new anchor:scroll message kind. The existing csrfHeaders() helper plumbs every new POST; the existing postMessage bridge gains one new kind. No SQL migration, no new Go endpoints.
Tech Stack: Vanilla ES modules (the shell already loads chrome.js as type="module" defer), fetch(), EventSource, ResizeObserver, localStorage, native CSS Grid + CSS custom properties (already wired in chrome.css / prose.css). Go template tests (html/template) cover the new shell slots. playwright-cli (skill in .claude/skills/playwright-cli/SKILL.md) verifies visual + interactive behavior — there is no JS unit-test runner in this repo, so verification is browser-based per CLAUDE.md.
Spec: ../specs/2026-05-15-wiki-browser-ui-integration-design.html. Cross-references: ../specs/2026-05-10-collaborative-annotations-domain-model.html, ../specs/2026-05-10-collaborative-annotations-decisions.md, and the server-side plans for #2 topic-core, #4 topic-resolution-incorporation, #6 realtime-collab, #7 identity-permissions.
Prerequisites this plan does not install (all merged into master — the
event/payload catalog below is pinned to the merged code, not to the #6/#4
spec drafts; where the spec and the merged implementation diverged, the merged
implementation wins):
/api/stream, /api/stream/focus endpoints from #6 (merged: internal/realtime/hub.go, internal/server/handler_stream.go). Confirmed contract:
GET /api/stream?source_path=… — SSE, no id: frames, :keepalive comments. 404 unknown_source when the backing file is missing (the browser cannot read this status; Task 15's separate /content/ probe is the workaround). No CSRF (GET).POST /api/stream/focus {subscriber_id, topic_id} — CSRF-protected. 204 on success; 404 unknown_subscriber (also covers session mismatch); 404 unknown_topic; a terminal topic is coerced to empty focus with 204 (not 404); 429 too_many_focus_calls (1 call/sec per session+subscriber). Error bodies are {"code": "..."}.subscribed → {"subscriber_id"} — first frame, re-sent on every reconnect.presence.updated → {"subscriptions":[{"subscriber_id","user_id","display_name","focused_topic_id"?}]} — one entry per tab/subscription; delivered to all subscribers including self. This snapshot is the only source of human-readable display names in the realtime layer.topic.created → {"topic_id","source_path","anchor_kind","first_message_preview","created_by","created_at"}.topic.message_appended → {"topic_id","message_id","sequence","kind","body_preview","created_at"} plus "author_user_id" when kind="human" or "proposal_id" when kind="agent-proposal".topic.discarded → {"topic_id","discarded_by","discarded_at"}.topic.incorporated → {"topic_id","source_path","commit_sha","incorporated_by","incorporated_at"}.proposal.created → {"proposal_id","topic_id","source_path","revision_number","agent_job_id"}.job.updated → {"job_id","kind","status","topic_id"?,"persona_name"?}. State field is status (values queued/running/succeeded/failed/timed_out); there is no state field.actor_display_name or topic_title. The actor is an opaque user id (created_by / discarded_by / incorporated_by); the UI resolves the human name from the latest presence.updated snapshot, falling back to "Someone" (see displayNameForUser, Task 18).POST /api/topics/{id}/proposals, GET /api/topics/{id}/proposals, POST /api/topics/{id}/discard, GET /api/proposals/{id}/diff, GET /content/preview/proposals/{id}, POST /api/proposals/{id}/incorporate, and kind='agent-proposal' message rows with proposal_id. Confirmed: GET /api/topics/{id}/proposals rows expose {id, revision_number, base_source_sha, agent_job_id, job_status, fresh, stale_reasons, missing_topic_ids, created_at}; stale_reasons values are exactly "source_sha" and "missing_topic_markers"; no commit_subject_default (Task 14's client-side fallback stands). GET /api/proposals/{id}/diff → {unified, base_sha, proposed_sha, fresh}. Stale incorporate → 409 {code:"stale_proposal", stale_reasons?, missing_topic_ids?}.GET /api/agent/jobs?source_path=… exists (internal/server/agent_jobs.go). Confirmed shape: array of {id, kind, source_path, topic_id?, persona_name?, status, started_at?, completed_at?, exit_code?, error_tail?, created_at}. GET /api/agent/jobs/{id} returns the same shape (single object) with error_tail.Modified (all paths under internal/server/):
embed.go — ShellData gains SelfUserID for authenticated shell renders.handler_doc.go — stamps SelfUserID from the authenticated principal into ShellData.templates/shell.html — topbar gains a <div id="wb-presence"> slot and a <button id="wb-right-toggle" hidden>; .wb-main gains <div id="wb-resolve-mount" hidden> for the Resolve-mode swap target; <div id="wb-toast-stack"> added at the body end; the wb-topic-sidebar__header button restyles via class change.static/chrome.css — sidebar rail + overlay states (both sides), Resolve-mode containers (toolbar, diff area Tier 1 + Tier 2 grid, banner, approve editor, discard panel, rewrite confirm, mobile tab strip), Topic-card visual polish (kind dot, reader stack, agent-proposal variants, status pills), presence chips (warm palette generator), toast stack, new-messages pill, dead-doc state, mobile breakpoints.static/chrome.js — eight cohesive subsystems added inside the existing IIFE: sidebar state machine, Resolve-mode controller, EventSource lifecycle + event handlers, focus-POST debouncer, toast queue, presence renderer, dead-doc renderer, anchor↔topic bidirectional nav. Selected helpers are exported via window.wbInternal only for the dev console — not relied on by tests.static/content.js — margin-glyph layer (ResizeObserver-driven), anchor-state classes (.wb-anchor, .wb-anchor--hover, .wb-anchor--selected), anchor:scroll listener from parent, overlap-menu replacing the current "first data-topic-ids wins" handler.static/prose.css — anchor resting/hover/selected styles, glyph element + gutter layer, mobile fallback (glyphs hidden).internal/server/templates_test.go — asserts the new shell slots render (or don't, for anonymous loads).Not modified:
SelfUserID template-data plumbing above.internal/server/templates/content_md.html — unchanged. data-authenticated already plumbed; meta[name="wb-source-sha"] already plumbed.internal/server/static/htmx.min.js, internal/server/static/mermaid.esm.min.mjs — vendor, untouched.go test, go vet, and make build from the wiki-browser repo root.embed.FS (internal/server/embed.go). There is no hot reload — every iteration is make build → pkill -f 'dist/wiki-browser' → restart → playwright-cli reload. See CLAUDE.md for the exact recipe.CLAUDE.md, any task that touches chrome layout, sidebar overlay, anchors, glyphs, Resolve mode, presence, or mobile breakpoints requires a playwright-cli round-trip — diff-reading is not sufficient. Each task lists the explicit playwright-cli checks.go test ./internal/server/... runs the relevant slice (template tests). The wider go test ./... should stay green at every commit.git log --oneline -20): wiki-browser: <area> — <thing> where area is one of shell, chrome, content, resolve, realtime, presence, mobile, forward-compat. No Co-Authored-By trailer (project convention — none of the recent wiki-browser commits carry one).Co-Authored-By in this repo. The commit examples below omit it deliberately.POST uses the existing csrfHeaders({...}) helper. New POST sites: POST /api/topics/{id}/proposals, POST /api/topics/{id}/discard, POST /api/proposals/{id}/incorporate, POST /api/stream/focus.#wb-topic-sidebar, #wb-logout, #wb-resolve-mount, or #wb-toast-stack when Authenticated=false. Every new JS subsystem short-circuits when authenticated is false (the existing guard in chrome.js).topicList click handler), edit the existing site rather than adding parallel code paths.Files:
internal/server/embed.gointernal/server/handler_doc.gointernal/server/templates/shell.htmlinternal/server/templates_test.goThis task adds every authenticated-only DOM mount point referenced by later tasks. No CSS, no behavior — just structure. All subsequent JS reads from these IDs.
In internal/server/templates_test.go, in TestRenderShell_authenticatedEmitsTopicSidebar, set the authenticated fixture's SelfUserID to test-user@example.com, then replace the for _, want := range []string{...} block with:
for _, want := range []string{
`id="wb-topic-sidebar"`,
`id="wb-topic-list"`,
`id="wb-new-global-topic"`,
`id="wb-presence"`,
`id="wb-right-toggle"`,
`id="wb-resolve-mount"`,
`id="wb-toast-stack"`,
`id="wb-thread-header"`,
`id="wb-topic-reply-form"`,
`data-self-user-id`,
`data-self-user-id="test-user@example.com"`,
} {
if !strings.Contains(out, want) {
t.Errorf("signed-in shell missing %q", want)
}
}
In TestRenderShell_emitsIframe (the anonymous case), extend the unwanted list:
for _, unwanted := range []string{
`id="wb-topic-sidebar"`,
`id="wb-topic-list"`,
`id="wb-new-global-topic"`,
`id="wb-presence"`,
`id="wb-right-toggle"`,
`id="wb-resolve-mount"`,
`id="wb-toast-stack"`,
`data-self-user-id`,
} {
if strings.Contains(out, unwanted) {
t.Errorf("anonymous shell should not render %q", unwanted)
}
}
Run: go test ./internal/server/ -run 'TestRenderShell' -v
Expected: FAIL — both tests, because the new IDs are not yet emitted.
ShellData to carry SelfUserIDShellData lives in internal/server/embed.go. Add the field, and update the authenticated template-test fixture to set SelfUserID: "test-user@example.com" so the test proves the value is populated, not only that the attribute exists:
type ShellData struct {
// ... existing fields ...
SelfUserID string // empty for anonymous; user_id for authenticated
}
The handler that populates ShellData for /doc/ is handleDoc in internal/server/handler_doc.go (line 45 today). Set SelfUserID from the session principal when authenticated — the existing principal carries UserID. The partials handler in internal/server/handler_partials.go does not need updating (it renders only nav.html).
Spec deviation note. The spec says "Cache self_user_id from the initial /auth/me response." Stamping the value into the body attribute is functionally equivalent (same authoritative source — the session principal — read at the same point in the request lifecycle) and avoids an extra roundtrip on every Document load. The originator-suppression logic in Task 16 reads selfUserID exactly once at IIFE init, which is consistent with the spec's intent ("Stable for the session lifetime").
Privacy note. Principal.UserID today is the user's email (e.g. daniel@getorcha.com). Stamping it into a body attribute makes the email observable to any browser extension and to any iframe under allow-same-origin (today: the content iframe rendering Markdown — same-origin, trusted; and the Tier-2 preview iframes — also same-origin, trusted). This is the same exposure surface the /auth/me approach would have once the value is in JS, but the body attribute makes it visible via DOM inspection. If this is a concern, replace UserID with an opaque per-session ID (server hashes UserID with a per-session salt). For v1 we accept the exposure — the wiki-browser is internal-only and authenticated-only. Document this in the commit message so a future security pass catches it.
shell.html to add the new authenticated-only slotsReplace the entire internal/server/templates/shell.html with:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/chrome.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/chrome.js" type="module" defer></script>
</head>
<body class="wb-shell"
{{ if .Authenticated }}data-self-user-id="{{ .SelfUserID }}"{{ end }}>
<header class="wb-topbar">
<button id="wb-menu" class="wb-menu" aria-label="Toggle navigation" aria-controls="wb-sidebar" aria-expanded="false">☰</button>
<span class="wb-title">{{ .Title }}</span>
<input
id="wb-search"
class="wb-search"
type="search"
placeholder="Search ( / )"
hx-get="/search"
hx-trigger="keyup changed delay:200ms, search"
hx-target="#wb-search-results"
name="q"
autocomplete="off">
{{ if .Authenticated }}
<div id="wb-presence" class="wb-presence" aria-label="Collaborators on this Document"></div>
<button id="wb-right-toggle" class="wb-right-toggle" type="button" aria-controls="wb-topic-sidebar" aria-expanded="false" hidden>‹</button>
{{ end }}
<div class="wb-auth">
{{ if .Authenticated }}
<span class="wb-auth-user">{{ .UserDisplayName }}</span>
<button id="wb-logout" class="wb-auth-button" type="button">Sign out</button>
{{ else }}
<a class="wb-auth-button" href="{{ .LoginPath }}">Sign in</a>
{{ end }}
</div>
</header>
<aside id="wb-sidebar" class="wb-sidebar">
{{ template "nav.html" . }}
</aside>
<div id="wb-backdrop" class="wb-backdrop" aria-hidden="true"></div>
<main class="wb-main">
<div id="wb-search-results" class="wb-search-results"></div>
<iframe
id="wb-content"
name="content"
title="content"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
src="{{ .ContentPath }}"></iframe>
{{ if .Authenticated }}
<div id="wb-resolve-mount" class="wb-resolve" hidden aria-label="Resolve mode"></div>
<section
id="wb-topic-sidebar"
class="wb-topic-sidebar"
data-current-path="{{ .CurrentPath }}"
aria-label="Topics">
<header class="wb-topic-sidebar__header">
<h2>Topics</h2>
<button id="wb-new-global-topic" class="wb-btn wb-btn--primary" type="button">New global Topic</button>
</header>
<div id="wb-topic-list" class="wb-topic-list"></div>
<section id="wb-topic-thread" class="wb-topic-thread" hidden>
<header id="wb-thread-header" class="wb-thread-header" hidden>
<span class="wb-thread-header__title"></span>
<div class="wb-thread-header__actions">
<button id="wb-rewrite-btn" class="wb-btn wb-btn--accent" type="button">Rewrite</button>
<button id="wb-discard-btn" class="wb-btn wb-btn--danger" type="button">Discard</button>
</div>
</header>
<div id="wb-topic-messages" class="wb-topic-messages"></div>
<div id="wb-new-messages-pill" class="wb-pill-bar" hidden>
<button type="button" class="wb-pill-bar__pill">New messages ↓</button>
</div>
<form id="wb-topic-reply-form" class="wb-topic-reply-form">
<textarea id="wb-topic-reply" name="body" rows="3" maxlength="65536"></textarea>
<label class="wb-reply-checkbox">
<input id="wb-ask-rewrite" type="checkbox">
<span>Ask Agent to rewrite after sending</span>
</label>
<button type="submit">Reply</button>
</form>
</section>
</section>
<div id="wb-toast-stack" class="wb-toast-stack" aria-live="polite" aria-atomic="false"></div>
{{ end }}
</main>
</body>
</html>
Run: go test ./internal/server/ -run 'TestRenderShell' -v
Expected: PASS — both tests.
Run: go test ./internal/server/... -count=1
Expected: PASS — no regressions.
Run: make build && pkill -f 'dist/wiki-browser' || true; nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 & disown; sleep 1; curl -sf http://localhost:8080/healthz
Expected: ok (or whatever /healthz returns). No panic in /tmp/wb.log.
git add internal/server/embed.go internal/server/handler_doc.go internal/server/templates/shell.html internal/server/templates_test.go
git commit -m "wiki-browser: shell — add topic-sidebar header, presence slot, resolve mount, toast stack"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThe left (nav) sidebar moves through expanded → rail → overlay. The user toggles via the topbar hamburger; the choice persists in localStorage["wb.sidebar.left"]. The rail is a 16px strip with a chevron; clicking it expands to overlay (transient). Click-outside, Escape, or rail re-click dismisses the overlay.
The existing setSidebarOpen() function is mobile-only (is-sidebar-open class). The new state machine runs on desktop; the mobile overlay behavior stays as today (no wb.sidebar.left reads on mobile).
chrome.jsInsert after the mobileMQ declaration (around line 37):
// Left-sidebar 3-state machine. Persists via localStorage["wb.sidebar.left"].
// States: "expanded" (in-grid), "rail" (16px strip), "overlay" (transient).
// Overlay is never persisted — it always derives from a "rail" base.
const SIDEBAR_KEY = 'wb.sidebar.left';
function readSidebarPref() {
try {
const v = localStorage.getItem(SIDEBAR_KEY);
return v === 'rail' ? 'rail' : 'expanded';
} catch (_) { return 'expanded'; }
}
function writeSidebarPref(v) {
try { localStorage.setItem(SIDEBAR_KEY, v === 'rail' ? 'rail' : 'expanded'); } catch (_) {}
}
let leftSidebarBase = readSidebarPref(); // "expanded" | "rail"
let leftSidebarOverlay = false; // transient over rail
Insert immediately after the constants:
function applyLeftSidebar() {
if (mobileMQ.matches) {
// Mobile keeps the existing is-sidebar-open behavior. Bail out — desktop
// state classes do not apply.
shell.classList.remove('wb-shell--left-rail', 'wb-shell--left-overlay');
return;
}
shell.classList.toggle('wb-shell--left-rail', leftSidebarBase === 'rail' && !leftSidebarOverlay);
shell.classList.toggle('wb-shell--left-overlay', leftSidebarOverlay);
if (menuBtn) {
const open = leftSidebarBase === 'expanded' || leftSidebarOverlay;
menuBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
menuBtn.textContent = open ? '☰' : '›';
}
}
function toggleLeftSidebar() {
if (mobileMQ.matches) {
setSidebarOpen(!shell.classList.contains('is-sidebar-open'));
return;
}
// Toggling on desktop flips the persistent base.
leftSidebarBase = leftSidebarBase === 'expanded' ? 'rail' : 'expanded';
leftSidebarOverlay = false;
writeSidebarPref(leftSidebarBase);
applyLeftSidebar();
}
function peekLeftSidebar() {
if (mobileMQ.matches || leftSidebarBase !== 'rail') return;
leftSidebarOverlay = true;
applyLeftSidebar();
}
function dismissLeftOverlay() {
if (!leftSidebarOverlay) return;
leftSidebarOverlay = false;
applyLeftSidebar();
}
applyLeftSidebar();
menuBtn click handlerFind (around line 295):
if (menuBtn) {
menuBtn.addEventListener('click', () => {
setSidebarOpen(!shell.classList.contains('is-sidebar-open'));
});
}
Replace with:
if (menuBtn) {
menuBtn.addEventListener('click', toggleLeftSidebar);
}
In the same region, replace the backdrop click handler:
if (backdrop) {
backdrop.addEventListener('click', () => {
setSidebarOpen(false);
dismissLeftOverlay();
});
}
Add a sidebar-edge click handler so clicking the rail strip peeks the overlay. Since the rail visual is the leftmost 16px of the existing aside#wb-sidebar, attach a click listener that triggers only when the sidebar is in rail state:
if (sidebar) {
sidebar.addEventListener('click', (ev) => {
// The link delegate below already handles nav clicks. The rail-peek
// fires only when the user clicks the empty rail area, not a link.
if (leftSidebarBase !== 'rail' || leftSidebarOverlay) return;
if (ev.target.closest('a[data-path]')) return;
peekLeftSidebar();
});
}
Update the existing Escape handler (the handleKey function around line 308) so Escape dismisses the overlay before falling through to other behaviors. Insert at the top of the else if (e.key === 'Escape') block:
} else if (e.key === 'Escape') {
if (leftSidebarOverlay) {
dismissLeftOverlay();
return;
}
if (results.innerHTML) {
Add a document-level click handler to dismiss the overlay on outside-click. Insert after the existing document.addEventListener('keydown', handleKey) line:
document.addEventListener('click', (ev) => {
if (!leftSidebarOverlay) return;
if (sidebar.contains(ev.target) || (menuBtn && menuBtn.contains(ev.target))) return;
dismissLeftOverlay();
});
Update the mobileMQ.addEventListener('change', ...) handler to re-apply state on breakpoint flip:
mobileMQ.addEventListener('change', (e) => {
if (!e.matches) setSidebarOpen(false);
applyLeftSidebar();
});
Before appending the state styles, update the existing desktop hamburger rule near the top of chrome.css so the control is visible on desktop too:
/* Hamburger — visible on desktop for left-sidebar rail state and on mobile for overlay nav. */
.wb-menu {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 0;
width: 36px;
height: 36px;
font-size: 18px;
line-height: 1;
cursor: pointer;
color: var(--wb-text);
}
Then append to internal/server/static/chrome.css (after the existing sidebar block, before the mobile breakpoint):
/* ─── left sidebar state machine ──────────────────────────────────────── */
.wb-shell--left-rail { grid-template-columns: 16px 1fr; }
.wb-shell--left-rail .wb-sidebar {
padding: 0;
overflow: hidden;
cursor: pointer;
}
.wb-shell--left-rail .wb-sidebar > * { display: none; }
.wb-shell--left-rail .wb-sidebar::before {
content: "›";
display: block;
text-align: center;
font-size: 13px;
color: var(--wb-muted);
padding-top: 10px;
}
.wb-shell--left-overlay .wb-sidebar {
position: fixed;
top: var(--wb-topbar-h);
left: 0;
bottom: 0;
width: var(--wb-sidebar-w);
z-index: 30;
box-shadow: var(--wb-shadow-2);
background: var(--wb-bg);
padding: 14px 10px 24px;
overflow: auto;
}
.wb-shell--left-overlay .wb-sidebar > * { display: revert; }
.wb-shell--left-overlay .wb-sidebar::before { display: none; }
.wb-shell--left-overlay .wb-sidebar { cursor: default; }
Run:
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli list
playwright-cli close-all || true
playwright-cli open --browser=chromium http://localhost:8080/doc/README
playwright-cli resize 1440 900
playwright-cli screenshot --filename=/tmp/wb-left-expanded.png
playwright-cli eval "() => document.querySelector('#wb-menu').click()"
playwright-cli screenshot --filename=/tmp/wb-left-rail.png
playwright-cli eval "() => document.querySelector('.wb-sidebar').click()"
playwright-cli screenshot --filename=/tmp/wb-left-overlay.png
playwright-cli eval "() => document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}))"
playwright-cli screenshot --filename=/tmp/wb-left-overlay-dismissed.png
Read each screenshot. Expected:
wb-left-expanded.png: sidebar visible at 280px wide.wb-left-rail.png: sidebar collapsed to 16px strip with a › chevron; hamburger flipped to ›.wb-left-overlay.png: sidebar floats over main (drop shadow), same width as expanded.wb-left-overlay-dismissed.png: back to rail.Verify localStorage persistence:
playwright-cli eval "() => localStorage.getItem('wb.sidebar.left')"
Expected: "rail".
Then reload and confirm rail state restores:
playwright-cli reload
playwright-cli screenshot --filename=/tmp/wb-left-rail-restored.png
Read the screenshot — should still be at the 16px rail.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — left-sidebar 3-state machine (expanded/rail/overlay) with localStorage"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThe right (Topics) sidebar has no user toggle. It auto-collapses only in Resolve mode + Tier 2 when viewport math demands it; otherwise it stays expanded. Auto-collapse threshold: viewport − left-sidebar-w − 2 × 360 < 320. When auto-collapsed, the #wb-right-toggle button in the topbar becomes visible and acts as a peek.
The Resolve-mode controller (Task 12) sets a data-resolve-mode attribute on .wb-shell and a data-diff-tier attribute. Both Task 12 and Task 13 read those. This task gates the auto-collapse on those attributes; until Task 12 sets them, the right sidebar stays expanded.
chrome.jsInsert after the left-sidebar state block:
// Right-sidebar auto-collapse state. Driven by viewport math + Resolve mode.
// No localStorage — derived only.
let rightSidebarRail = false;
let rightSidebarOverlay = false;
// Declared up front (before the function defs that reference it) to avoid
// a TDZ trap if any earlier init code calls applyRightSidebar.
const rightToggleBtn = document.getElementById('wb-right-toggle');
function leftWidthForCalc() {
if (mobileMQ.matches) return 0;
if (leftSidebarBase === 'rail') return 16;
return 280; // matches --wb-sidebar-w
}
function shouldAutoCollapseRight() {
const inResolve = shell.dataset.resolveMode === '1';
const tier = shell.dataset.diffTier || 'side-by-side';
if (!inResolve || tier !== 'side-by-side') return false;
const remaining = window.innerWidth - leftWidthForCalc() - 2 * 360;
return remaining < 320;
}
function applyRightSidebar() {
if (mobileMQ.matches) {
shell.classList.remove('wb-shell--right-rail', 'wb-shell--right-overlay');
if (rightToggleBtn) rightToggleBtn.hidden = true;
return;
}
const want = shouldAutoCollapseRight();
if (!want) {
rightSidebarRail = false;
rightSidebarOverlay = false;
} else {
rightSidebarRail = true;
}
shell.classList.toggle('wb-shell--right-rail', rightSidebarRail && !rightSidebarOverlay);
shell.classList.toggle('wb-shell--right-overlay', rightSidebarOverlay);
if (rightToggleBtn) {
rightToggleBtn.hidden = !rightSidebarRail;
rightToggleBtn.setAttribute('aria-expanded', rightSidebarOverlay ? 'true' : 'false');
}
}
function peekRightSidebar() {
if (!rightSidebarRail) return;
rightSidebarOverlay = true;
applyRightSidebar();
}
function dismissRightOverlay() {
if (!rightSidebarOverlay) return;
rightSidebarOverlay = false;
applyRightSidebar();
}
if (rightToggleBtn) {
rightToggleBtn.addEventListener('click', () => {
if (mobileMQ.matches) {
rightSidebarOverlay = !rightSidebarOverlay;
applyRightSidebar();
return;
}
if (rightSidebarOverlay) dismissRightOverlay(); else peekRightSidebar();
});
}
window.addEventListener('resize', applyRightSidebar);
// Re-evaluate when Resolve mode flips. The Resolve controller calls this
// explicitly via window.wbInternal.applyRightSidebar() — exposed below.
applyRightSidebar();
Append at the bottom of the IIFE, just before the closing })();:
window.wbInternal = Object.assign(window.wbInternal || {}, {
applyRightSidebar,
dismissRightOverlay,
});
The Resolve controller (Task 12) will call window.wbInternal.applyRightSidebar() after flipping data-resolve-mode / data-diff-tier.
Extend the document-level click handler from Task 2 step 4 to also dismiss the right overlay:
document.addEventListener('click', (ev) => {
if (leftSidebarOverlay) {
if (!sidebar.contains(ev.target) && !(menuBtn && menuBtn.contains(ev.target))) {
dismissLeftOverlay();
}
}
if (rightSidebarOverlay && topicSidebar) {
if (!topicSidebar.contains(ev.target) && !(rightToggleBtn && rightToggleBtn.contains(ev.target))) {
dismissRightOverlay();
}
}
});
(Replace the prior single-handler version from Task 2.)
Extend Escape handling so it dismisses right overlay too. Right after the left-overlay dismissal in handleKey:
if (leftSidebarOverlay) {
dismissLeftOverlay();
return;
}
if (rightSidebarOverlay) {
dismissRightOverlay();
return;
}
Append to chrome.css, after the left-sidebar state block:
/* ─── right sidebar state machine ─────────────────────────────────────── */
.wb-topic-sidebar {
width: 320px;
}
.wb-shell--right-rail .wb-main {
grid-template-columns: minmax(0, 1fr) 16px;
}
.wb-shell--right-rail .wb-topic-sidebar {
width: 16px;
padding: 0;
overflow: hidden;
cursor: pointer;
}
.wb-shell--right-rail .wb-topic-sidebar > * { display: none; }
.wb-shell--right-rail .wb-topic-sidebar::before {
content: "‹";
display: block;
text-align: center;
font-size: 13px;
color: var(--wb-muted);
padding-top: 10px;
}
.wb-shell--right-overlay .wb-topic-sidebar {
position: fixed;
top: var(--wb-topbar-h);
right: 0;
bottom: 0;
width: 320px;
z-index: 30;
box-shadow: var(--wb-shadow-2);
overflow: auto;
padding: 12px;
}
.wb-shell--right-overlay .wb-topic-sidebar > * { display: revert; }
.wb-shell--right-overlay .wb-topic-sidebar::before { display: none; }
.wb-shell--right-overlay .wb-topic-sidebar { cursor: default; }
/* Right-sidebar toggle button (topbar). Hidden by default; visible only when
the right sidebar is in rail. */
.wb-right-toggle {
width: 28px; height: 28px;
border: 1px solid var(--wb-rule);
border-radius: 4px;
background: var(--wb-surface);
color: var(--wb-muted);
cursor: pointer;
padding: 0;
font: inherit;
}
.wb-right-toggle:hover { background: var(--wb-hover); }
Run:
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli resize 1024 800
playwright-cli screenshot --filename=/tmp/wb-right-default.png
playwright-cli eval "() => ({hidden: document.getElementById('wb-right-toggle').hidden, classes: document.querySelector('.wb-shell').className})"
Read the screenshot. Expected: right sidebar at 320px, expanded. Eval expected: {hidden: true, classes: "wb-shell"} (or whatever the existing class list is, but no wb-shell--right-*).
Now simulate Resolve mode by setting the data attribute manually:
playwright-cli eval "() => { const s = document.querySelector('.wb-shell'); s.dataset.resolveMode = '1'; s.dataset.diffTier = 'side-by-side'; window.wbInternal.applyRightSidebar(); return s.className; }"
playwright-cli screenshot --filename=/tmp/wb-right-rail.png
At 1024×800 (1024 − 280 − 720 = 24 < 320), expected: right sidebar collapsed to 16px rail; #wb-right-toggle visible. Read the screenshot.
Clean up:
playwright-cli eval "() => { const s = document.querySelector('.wb-shell'); delete s.dataset.resolveMode; delete s.dataset.diffTier; window.wbInternal.applyRightSidebar(); }"
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — right-sidebar auto-collapse + topbar toggle (resolve+tier2 gated)"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThe current loadTopics() renders a flat list with <strong>Anchored|Global</strong> plus a quote and preview. This task replaces that with the spec's two-zone card: header row (kind dot + label + reader stack), then optional quote, then preview. Global Topics list first under a GLOBAL subheader; anchored Topics under an ANCHORED subheader. Reserved hidden <span class="wb-topic-card__select" hidden> slot in the top-left for the future #9 selection control.
Reader-stack rendering (per-card initials updated from presence.updated) lands in Task 18 — this task leaves an empty <div class="wb-topic-card__readers"> mount point.
loadTopics() bodyIn chrome.js, find the existing async function loadTopics() and replace its body with:
async function loadTopics() {
const sourcePath = currentSourcePath();
if (!topicList || !sourcePath || sourcePath.startsWith('_')) return;
topicSidebar.dataset.currentPath = sourcePath;
const resp = await fetch('/api/topics?source_path=' + encodeURIComponent(sourcePath), {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return;
const topics = await resp.json();
renderTopicList(topics);
}
function renderTopicList(topics) {
topicList.innerHTML = '';
const globals = [];
const anchored = [];
for (const t of topics) {
const anchor = anchorObject(t.anchor);
if (anchor.kind === 'global') globals.push(t); else anchored.push(t);
}
if (globals.length) {
appendSubheader('Global');
for (const t of globals) topicList.appendChild(renderTopicCard(t));
}
if (anchored.length) {
appendSubheader('Anchored');
for (const t of anchored) topicList.appendChild(renderTopicCard(t));
}
}
function appendSubheader(label) {
const h = document.createElement('h3');
h.className = 'wb-topic-list__subheader';
h.textContent = label;
topicList.appendChild(h);
}
function renderTopicCard(t) {
const anchor = anchorObject(t.anchor);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wb-topic-card';
btn.dataset.topicId = t.id;
btn.dataset.kind = anchor.kind === 'global' ? 'global' : 'anchored';
btn.setAttribute('aria-selected', t.id === selectedTopicID ? 'true' : 'false');
const quoteHTML = anchor.kind === 'global'
? ''
: `<p class="wb-topic-card__quote">${escapeHTML(truncate(anchor.quote || '', 80))}</p>`;
btn.innerHTML =
'<span class="wb-topic-card__select" hidden></span>' +
'<div class="wb-topic-card__head">' +
'<span class="wb-topic-card__dot"></span>' +
'<span class="wb-topic-card__label">' + escapeHTML(anchor.kind === 'global' ? 'Global' : 'Anchored') + '</span>' +
'<div class="wb-topic-card__readers" aria-label="Readers"></div>' +
'</div>' +
quoteHTML +
'<p class="wb-topic-card__preview">' + escapeHTML(truncate(t.first_message_preview || '', 120)) + '</p>';
return btn;
}
chrome.cssBefore appending the new .wb-btn* rules, narrow the existing broad sidebar-button selector so classed buttons (.wb-btn--primary, .wb-btn--accent, .wb-btn--danger) are not overridden by .wb-topic-sidebar button's higher specificity. Replace the existing rule:
.wb-topic-sidebar button {
border: 1px solid var(--wb-rule);
background: var(--wb-bg);
color: var(--wb-text);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
cursor: pointer;
}
with:
.wb-topic-sidebar button:not(.wb-btn) {
border: 1px solid var(--wb-rule);
background: var(--wb-bg);
color: var(--wb-text);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
cursor: pointer;
}
This keeps the unclassed reply-form button styled while allowing the classed CTA variants to win.
Append after the existing .wb-topic-card block:
.wb-topic-list__subheader {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wb-muted);
margin: 8px 0 4px;
}
.wb-topic-list__subheader:first-child { margin-top: 0; }
.wb-topic-card {
position: relative;
cursor: pointer;
}
.wb-topic-card__select {
position: absolute;
top: 8px;
left: 8px;
width: 14px;
height: 14px;
}
.wb-topic-card__head {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12.5px;
margin-bottom: 2px;
}
.wb-topic-card__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wb-accent-bg);
border: 1px solid var(--wb-accent);
flex-shrink: 0;
}
.wb-topic-card[data-kind="global"] .wb-topic-card__dot {
background: var(--wb-rule);
border-color: var(--wb-muted);
}
.wb-topic-card__label { flex: 0 0 auto; }
.wb-topic-card__readers {
margin-left: auto;
display: flex;
align-items: center;
}
.wb-topic-card__quote {
font-style: italic;
}
.wb-btn {
border: 1px solid var(--wb-rule);
background: var(--wb-surface);
color: var(--wb-text);
border-radius: 4px;
padding: 5px 10px;
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.wb-btn:hover { background: var(--wb-hover); }
.wb-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.wb-btn--primary { background: var(--wb-text); color: var(--wb-bg); border-color: var(--wb-text); }
.wb-btn--primary:hover { background: var(--wb-text); }
.wb-btn--accent { background: var(--wb-accent); color: var(--wb-bg); border-color: var(--wb-accent); }
.wb-btn--accent:hover { background: var(--wb-accent-dark, #7c2d12); }
.wb-btn--danger { background: var(--wb-accent-tint); color: #7c2d12; border-color: #7c2d12; }
.wb-btn--muted { color: var(--wb-muted); }
Add the missing token to :root in the same file (after --wb-accent-tint):
--wb-accent-dark: #7c2d12;
You need authenticated state — run with a dev session if wiki-browser.dev.yaml enables dev login. Check internal/auth/handlers.go for the dev-login route (/auth/dev/login).
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Authenticate via dev-login if enabled. Otherwise sign in manually via UI.
playwright-cli open --browser=chromium http://localhost:8080/auth/dev/login
playwright-cli open --browser=chromium http://localhost:8080/doc/README
playwright-cli screenshot --filename=/tmp/wb-topic-cards.png
If no Topics exist yet for the loaded path, create one via the New global Topic button:
playwright-cli eval "() => { document.getElementById('wb-new-global-topic').click(); }"
# A window.prompt fires; respond via playwright-cli's prompt acceptor or skip.
For the visual pass, manually create a Topic via the UI or via curl against /api/topics. Then:
playwright-cli screenshot --filename=/tmp/wb-topic-cards-populated.png
playwright-cli eval "() => Array.from(document.querySelectorAll('.wb-topic-card')).map(c => ({id: c.dataset.topicId, kind: c.dataset.kind, hasSelectSlot: !!c.querySelector('.wb-topic-card__select[hidden]'), hasReadersSlot: !!c.querySelector('.wb-topic-card__readers')}))"
Expected from eval: each card has hasSelectSlot: true and hasReadersSlot: true. Read the screenshot — verify the GLOBAL/ANCHORED subheaders show only when their section is non-empty, the kind dot shows accent-tinted for anchored and muted-grey for global, and quotes only show on anchored cards.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — topic-card redesign (kind dot, readers slot, subheaders, forward-compat select slot)"
Files:
internal/server/static/content.jsinternal/server/static/prose.cssThe current content.js click handler picks the first data-topic-ids and fires topic:focus. Replace with:
.wb-anchor — --hover from mouseenter, --selected from chrome-side persistent selection (driven by Task 7's anchor:scroll message).topic:focus.topic:focus.Selection clearing — clicking empty iframe space, or Escape in the chrome with sidebar focused — is wired in Task 7.
content.jsLocate the existing click handler at the bottom of content.js:
document.addEventListener('click', (e) => {
const mark = e.target.closest('.wb-anchor');
if (!mark) return;
const topicID = mark.dataset.topicId || (mark.dataset.topicIds || '').split(/\s+/).filter(Boolean)[0] || '';
parent.postMessage({ kind: 'topic:focus', topic_id: topicID }, location.origin);
});
Replace with:
function topicIDsFromAnchor(mark) {
const ids = (mark.dataset.topicIds || mark.dataset.topicId || '')
.split(/\s+/)
.filter(Boolean);
return ids;
}
function postFocus(topicID) {
if (!topicID) return;
parent.postMessage({ kind: 'topic:focus', topic_id: topicID }, location.origin);
}
let overlapMenu = null;
function removeOverlapMenu() {
if (overlapMenu) overlapMenu.remove();
overlapMenu = null;
}
function showOverlapMenu(mark, ids, ev) {
removeOverlapMenu();
overlapMenu = document.createElement('div');
overlapMenu.className = 'wb-overlap-menu';
overlapMenu.style.left = Math.min(ev.clientX, window.innerWidth - 220) + 'px';
overlapMenu.style.top = (ev.clientY + 8) + 'px';
for (const id of ids) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'wb-overlap-menu__item';
item.textContent = 'Topic ' + id.slice(0, 8);
item.addEventListener('click', (e) => {
e.stopPropagation();
postFocus(id);
removeOverlapMenu();
});
overlapMenu.appendChild(item);
}
document.body.appendChild(overlapMenu);
}
document.addEventListener('click', (e) => {
const mark = e.target.closest('.wb-anchor');
if (!mark) {
if (overlapMenu && !overlapMenu.contains(e.target)) removeOverlapMenu();
return;
}
const ids = topicIDsFromAnchor(mark);
if (ids.length >= 2) {
showOverlapMenu(mark, ids, e);
return;
}
postFocus(ids[0] || '');
});
// Hover state — purely visual; the chrome doesn't need to know.
document.addEventListener('mouseover', (e) => {
const mark = e.target.closest('.wb-anchor');
if (mark) mark.classList.add('wb-anchor--hover');
});
document.addEventListener('mouseout', (e) => {
const mark = e.target.closest('.wb-anchor');
if (mark) mark.classList.remove('wb-anchor--hover');
});
anchor:scroll)Add this section right above the })(); at the bottom of content.js:
// Selected anchor state is set externally by the chrome (parent frame) via
// an anchor:scroll message. The chrome computes which Topic is selected;
// this side just applies the class and scrolls into view.
window.addEventListener('message', (ev) => {
if (ev.origin !== window.location.origin) return;
const msg = ev.data || {};
if (msg.kind !== 'anchor:scroll') return;
document.querySelectorAll('.wb-anchor--selected').forEach(el => el.classList.remove('wb-anchor--selected'));
if (!msg.topic_id) return;
const target = document.querySelector('[data-topic-id="' + cssEscape(msg.topic_id) + '"], [data-topic-ids~="' + cssEscape(msg.topic_id) + '"]');
if (!target) return;
target.classList.add('wb-anchor--selected');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c.charCodeAt(0).toString(16) + ' ');
}
prose.cssReplace the existing .wb-anchor block (currently around line 187):
/* ─── topic anchors & selection composer ──────────────────────────────── */
.wb-anchor {
background: rgba(254, 243, 199, 0.4);
color: inherit;
border-bottom: 1px dashed var(--wb-accent);
padding: 0 2px;
cursor: pointer;
}
.wb-anchor--hover {
background: rgba(254, 243, 199, 0.7);
}
.wb-anchor--selected {
background: var(--wb-accent-bg);
border-bottom-color: var(--wb-accent);
}
.wb-anchor-overlap {
outline: 1px solid var(--wb-accent);
}
.wb-overlap-menu {
position: fixed;
z-index: 1100;
min-width: 180px;
background: var(--wb-surface);
border: 1px solid var(--wb-rule);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(28, 25, 23, .16);
padding: 4px;
font: inherit;
font-size: 12.5px;
}
.wb-overlap-menu__item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
padding: 6px 10px;
border-radius: 4px;
color: var(--wb-text);
font: inherit;
cursor: pointer;
}
.wb-overlap-menu__item:hover { background: var(--wb-hover); }
Authenticate, navigate to a Markdown doc that has Topics with anchors (create some first via the UI/API if none exist), then:
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli screenshot --filename=/tmp/wb-anchor-resting.png
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
const a = f.contentDocument.querySelector('.wb-anchor');
if (!a) return 'no-anchor';
a.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
return a.className;
}"
playwright-cli screenshot --filename=/tmp/wb-anchor-hover.png
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
f.contentWindow.postMessage({kind: 'anchor:scroll', topic_id: f.contentDocument.querySelector('.wb-anchor').dataset.topicId || ''}, location.origin);
}"
playwright-cli screenshot --filename=/tmp/wb-anchor-selected.png
Expected: distinct visual states (tint differences) across the three screenshots.
git add internal/server/static/content.js internal/server/static/prose.css
git commit -m "wiki-browser: content — anchor hover/selected states, overlap-menu, anchor:scroll listener"
Files:
internal/server/static/content.jsinternal/server/static/prose.cssEach open non-global Topic renders a speech-bubble glyph in the right gutter of the prose column. The gutter exists only when iframe-width − prose-padding − 800px ≥ 32px; below that, glyphs are not rendered. Glyphs are position: fixed to the iframe's right edge so they stay visible during horizontal scroll (mermaid SVGs overflow rightward).
The chrome (Task 7) sends an anchor:topics-changed {topics: [{id, topic_ids}]} message whenever the topic list updates. The iframe treats this as the canonical set of currently open anchored Topics and filters glyphs against it, so discarded/incorporated Topics disappear without requiring an iframe reload.
On mobile (≤640px in prose.css), glyphs are dropped — background tint is the sole indicator.
content.jsGlyphs are authenticated-only — anonymous readers don't see the Topic sidebar, so a clickable glyph would have nowhere to focus.
In content.js, locate this existing pair of lines (around line 25–26):
const authenticated = document.body.dataset.authenticated === 'true';
if (!authenticated) return;
Insert the new glyph-layer block after the if (!authenticated) return; line (so it never runs for anonymous loads). All glyph code below this comment belongs in the authenticated section, after the existing selection-composer code or at any subsequent point inside the IIFE — placement within the authenticated section is not load-bearing.
// Margin-glyph layer. Lives in a fixed-position container pinned to the
// iframe's viewport right edge so horizontal scroll (mermaid SVG overflow)
// doesn't move the glyphs.
const glyphLayer = document.createElement('div');
glyphLayer.className = 'wb-glyph-layer';
glyphLayer.hidden = true;
document.body.appendChild(glyphLayer);
let openAnchoredTopicIDs = new Set();
const isNarrow = () => window.matchMedia('(max-width: 640px)').matches;
function gutterAvailable() {
if (isNarrow()) return false;
const padding = parseFloat(getComputedStyle(document.body).paddingRight) || 32;
return (window.innerWidth - padding - 800) >= 32;
}
function renderGlyphs() {
glyphLayer.innerHTML = '';
if (!gutterAvailable()) {
glyphLayer.hidden = true;
return;
}
glyphLayer.hidden = false;
// For every anchor whose data-topic-id (or first id in data-topic-ids) is
// present, drop a glyph aligned with the anchor's bounding-rect top.
const seen = new Set();
document.querySelectorAll('.wb-anchor').forEach(mark => {
const ids = (mark.dataset.topicIds || mark.dataset.topicId || '').split(/\s+/).filter(Boolean);
if (!ids.length) return;
const id = ids.find(candidate => openAnchoredTopicIDs.has(candidate)) || '';
if (!id) return;
if (seen.has(id)) return; // dedup — one glyph per open topic
seen.add(id);
const r = mark.getBoundingClientRect();
const g = document.createElement('button');
g.type = 'button';
g.className = 'wb-glyph';
g.dataset.topicId = id;
if (ids.length >= 2) g.dataset.topicIds = ids.join(' ');
g.setAttribute('aria-label', 'Open Topic');
g.textContent = '💬';
// Fixed-position layer: rect.top is already viewport-relative.
g.style.top = r.top + 'px';
g.addEventListener('click', (ev) => {
ev.preventDefault();
if (ids.length >= 2) {
showOverlapMenu(mark, ids, ev);
return;
}
postFocus(id);
});
glyphLayer.appendChild(g);
});
}
// Recompute on resize and on font/image load (which can shift anchor positions).
let glyphRAF = 0;
function scheduleGlyphRender() {
if (glyphRAF) return;
glyphRAF = requestAnimationFrame(() => {
glyphRAF = 0;
renderGlyphs();
});
}
window.addEventListener('resize', scheduleGlyphRender);
window.addEventListener('scroll', scheduleGlyphRender, { passive: true });
window.addEventListener('load', scheduleGlyphRender);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(scheduleGlyphRender).catch(() => {});
}
const ro = new ResizeObserver(scheduleGlyphRender);
ro.observe(document.body);
// Initial render: defer one tick so anchors are in the DOM and laid out.
scheduleGlyphRender();
// Re-render whenever the chrome tells us the topic list changed (Task 7).
window.addEventListener('message', (ev) => {
if (ev.origin !== window.location.origin) return;
const msg = ev.data || {};
if (msg.kind === 'anchor:topics-changed') {
openAnchoredTopicIDs = new Set((msg.topics || []).flatMap(t => t.topic_ids || [t.id]).filter(Boolean));
scheduleGlyphRender();
}
});
When the glyph layer adds a selected glyph style — it tracks the selected topic from the existing anchor:scroll handler. Update the anchor:scroll handler to also flip the selected-glyph class:
Find the existing anchor:scroll handler (added in Task 5 step 2). Add right after the target.classList.add('wb-anchor--selected') line:
glyphLayer.querySelectorAll('.wb-glyph--selected').forEach(g => g.classList.remove('wb-glyph--selected'));
const sg = glyphLayer.querySelector('.wb-glyph[data-topic-id="' + cssEscape(msg.topic_id) + '"]');
if (sg) sg.classList.add('wb-glyph--selected');
And before the if (!msg.topic_id) return; line, clear the glyph selection there too:
glyphLayer.querySelectorAll('.wb-glyph--selected').forEach(g => g.classList.remove('wb-glyph--selected'));
prose.cssAppend at the bottom:
/* ─── margin glyphs ───────────────────────────────────────────────────── */
.wb-glyph-layer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 900;
}
.wb-glyph {
position: absolute;
right: 12px;
width: 22px;
height: 18px;
border: 1px solid var(--wb-rule);
background: var(--wb-surface);
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--wb-muted);
font-size: 11px;
cursor: pointer;
padding: 0;
pointer-events: auto;
}
.wb-glyph:hover { background: var(--wb-accent-bg); }
.wb-glyph--selected {
color: var(--wb-accent);
border-color: var(--wb-accent);
background: var(--wb-accent-bg);
}
@media (max-width: 640px) {
.wb-glyph-layer { display: none; }
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli resize 1440 900
playwright-cli screenshot --filename=/tmp/wb-glyphs-wide.png
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
return f.contentDocument.querySelectorAll('.wb-glyph').length;
}"
Expected: number > 0 if anchors exist, glyphs visible in the right gutter.
Test narrow-viewport fallback:
playwright-cli resize 900 800
playwright-cli screenshot --filename=/tmp/wb-glyphs-narrow.png
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
const layer = f.contentDocument.querySelector('.wb-glyph-layer');
return {hidden: layer.hidden, count: f.contentDocument.querySelectorAll('.wb-glyph').length};
}"
Expected: {hidden: true, count: 0} when math doesn't allow gutter. Read both screenshots.
Test wide-child overflow (a Markdown doc with a wide mermaid SVG):
playwright-cli open --browser=chromium http://localhost:8080/doc/<a-doc-with-mermaid>
playwright-cli resize 1440 900
playwright-cli eval "() => { const f = document.getElementById('wb-content'); f.contentWindow.scrollTo(800, 0); }"
playwright-cli screenshot --filename=/tmp/wb-glyphs-scrolled.png
Expected: glyphs still at the right edge of the visible viewport (because position: fixed to iframe).
git add internal/server/static/content.js internal/server/static/prose.css
git commit -m "wiki-browser: content — margin-glyph layer (gutter + viewport-pinned + ResizeObserver)"
Files:
internal/server/static/chrome.jsThis task wires the navigation table from the spec. Click sites:
| User action | Effect |
|---|---|
| Click Topic card | Scroll anchor into view; apply persistent selected highlight; thread opens. |
| Click anchor text or glyph (in iframe) | Persistent selected highlight; card selected + scrolled into view; thread opens. |
| Click overlapping anchor (N ≥ 2) | Iframe pops menu; until user picks, no change. |
| Click global Topic card | No scroll target; card selected; thread opens. |
| Click empty iframe area / Escape with sidebar focused | Clear selection; deselect card; thread collapses. |
Communication via the existing postMessage bridge:
Chrome → iframe: {kind: 'anchor:scroll', topic_id} (new) — Task 5 already added the iframe-side handler.
Chrome → iframe: {kind: 'anchor:topics-changed', topics: [{id, topic_ids}]} — Task 6 already added the iframe-side handler and uses this payload to filter out closed Topics.
Iframe → chrome: {kind: 'topic:focus', topic_id} — existing.
Step 1: Hook into the existing card click delegate to push the iframe selection
Find the existing topicList click listener (around line 259 in the original chrome.js):
if (topicList) {
topicList.addEventListener('click', (ev) => {
const card = ev.target.closest('.wb-topic-card');
if (!card) return;
openTopicThread(card.dataset.topicId);
});
}
Replace with:
if (topicList) {
topicList.addEventListener('click', (ev) => {
const card = ev.target.closest('.wb-topic-card');
if (!card) return;
openTopicThread(card.dataset.topicId);
// Persistent selection in the iframe — scrolls into view if anchored.
iframe.contentWindow.postMessage({ kind: 'anchor:scroll', topic_id: card.dataset.topicId }, location.origin);
});
}
topic:focus from the iframe, also re-broadcast anchor:scrollFind the existing topic:focus branch in the message listener (around line 346):
} else if (msg.kind === 'topic:focus' && msg.topic_id) {
if (!authenticated) return;
openTopicThread(msg.topic_id);
}
Replace with:
} else if (msg.kind === 'topic:focus' && msg.topic_id) {
if (!authenticated) return;
selectTopic(msg.topic_id);
}
Add a new selectTopic() helper near openTopicThread:
function selectTopic(topicID) {
openTopicThread(topicID);
// Scroll the corresponding card into view in the sidebar.
const card = topicList && topicList.querySelector('.wb-topic-card[data-topic-id="' + cssEscape(topicID) + '"]');
if (card) {
card.scrollIntoView({ block: 'nearest' });
}
// Apply selection in the iframe too — the iframe-side handler is idempotent.
iframe.contentWindow.postMessage({ kind: 'anchor:scroll', topic_id: topicID }, location.origin);
}
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c.charCodeAt(0).toString(16) + ' ');
}
function clearTopicSelection() {
selectedTopicID = '';
if (topicList) {
topicList.querySelectorAll('.wb-topic-card[aria-selected="true"]').forEach(c => c.setAttribute('aria-selected', 'false'));
}
if (topicThread) topicThread.hidden = true;
iframe.contentWindow.postMessage({ kind: 'anchor:scroll', topic_id: '' }, location.origin);
}
// Forward-declared as `let` so Task 17 can reassign without "already
// declared" errors under module strict mode. Stub no-op until then;
// prevents ReferenceError between Tasks 7–16 when cards are clicked.
let scheduleFocus = (_topicID) => {};
Extend the existing handleKey Escape branch. Add a final fallthrough so when no other Escape-eligible state is open and the right-sidebar has focus, clear selection:
} else if (search && document.activeElement === search) {
search.blur();
} else if (topicSidebar && topicSidebar.contains(document.activeElement)) {
clearTopicSelection();
}
anchor:topics-changed after every loadTopics()In the loadTopics() function, add at the very end (after renderTopicList(topics)). Only send non-global anchored Topics; each payload row lists the topic IDs the iframe should consider open for glyph rendering:
iframe.contentWindow.postMessage({
kind: 'anchor:topics-changed',
topics: topics
.filter(t => anchorObject(t.anchor).kind !== 'global')
.map(t => ({ id: t.id, topic_ids: [t.id] })),
}, location.origin);
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Click a topic card from the sidebar.
playwright-cli eval "() => { const c = document.querySelector('.wb-topic-card'); if (c) c.click(); }"
playwright-cli screenshot --filename=/tmp/wb-nav-card-clicked.png
playwright-cli eval "() => ({selectedCard: document.querySelector('.wb-topic-card[aria-selected=\"true\"]')?.dataset.topicId, threadVisible: !document.getElementById('wb-topic-thread').hidden})"
# Click an anchor from the iframe.
playwright-cli eval "() => { const f = document.getElementById('wb-content'); const a = f.contentDocument.querySelector('.wb-anchor'); if (a) a.click(); }"
playwright-cli screenshot --filename=/tmp/wb-nav-anchor-clicked.png
# Press Escape with sidebar focused.
playwright-cli eval "() => { document.getElementById('wb-topic-list').focus(); document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); }"
playwright-cli screenshot --filename=/tmp/wb-nav-escape.png
playwright-cli eval "() => ({selectedCard: document.querySelector('.wb-topic-card[aria-selected=\"true\"]')?.dataset.topicId || null, threadVisible: !document.getElementById('wb-topic-thread').hidden})"
Expected after Escape: {selectedCard: null, threadVisible: false}.
git add internal/server/static/chrome.js
git commit -m "wiki-browser: chrome — bidirectional anchor↔topic navigation + escape clears selection"
Files:
internal/server/static/prose.cssVisual-only. The existing composer in content.js already snapshots selection and posts via topic:create-from-selection. This task adjusts CSS so the composer matches the design-system primary button styling. No structural change.
prose.cssFind the existing .wb-selection-composer block and the .wb-selection-trigger block. Replace with:
.wb-selection-trigger {
position: fixed;
z-index: 1000;
height: 28px;
padding: 0 10px;
background: var(--wb-surface);
color: var(--wb-text);
border: 1px solid var(--wb-rule);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(28, 25, 23, .14);
font: inherit;
font-size: 12px;
line-height: 26px;
cursor: pointer;
}
.wb-selection-trigger:hover {
background: var(--wb-accent-bg);
border-color: var(--wb-accent);
}
.wb-selection-composer {
position: fixed;
z-index: 1000;
width: min(280px, calc(100vw - 24px));
background: var(--wb-surface);
border: 1px solid var(--wb-rule);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(28, 25, 23, .16);
padding: 8px;
}
.wb-selection-composer textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 6px;
font: inherit;
resize: none;
min-height: 60px;
color: var(--wb-text);
}
.wb-selection-composer__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
}
.wb-selection-composer button {
border: 1px solid var(--wb-rule);
border-radius: 4px;
background: var(--wb-surface);
color: var(--wb-text);
padding: 4px 10px;
font: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
}
.wb-selection-composer button[type="submit"] {
background: var(--wb-text);
color: var(--wb-bg);
border-color: var(--wb-text);
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Simulate a selection in the iframe.
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
const p = f.contentDocument.querySelector('p');
if (!p) return 'no-p';
const range = f.contentDocument.createRange();
range.selectNodeContents(p);
const sel = f.contentWindow.getSelection();
sel.removeAllRanges();
sel.addRange(range);
f.contentDocument.dispatchEvent(new MouseEvent('mouseup', {bubbles: true}));
return 'ok';
}"
playwright-cli screenshot --filename=/tmp/wb-composer-trigger.png
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
const t = f.contentDocument.querySelector('.wb-selection-trigger');
if (t) t.click();
}"
playwright-cli screenshot --filename=/tmp/wb-composer-open.png
Read both — composer should match the design-system tone (white surface, soft shadow, dark primary "Save" button).
git add internal/server/static/prose.css
git commit -m "wiki-browser: content — selection composer visual polish"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThe thread header was added (hidden) to shell.html in Task 1 with #wb-thread-header, #wb-rewrite-btn, #wb-discard-btn. This task shows it when a Topic is selected, sets the title from anchor kind ("Anchored Topic" / "Global Topic"), and wires the Rewrite-disabled state to job state.
The disabled-state source of truth is a Map<topicID, jobState> populated from the /api/agent/jobs?source_path=… bootstrap (Task 15) and updated by job.updated SSE events (Task 16). This task only declares the map and the render function; population is wired in those later tasks.
The Rewrite click handler posts to /api/topics/{id}/proposals; the Discard click handler opens the inline confirmation panel from Task 10. This task wires Rewrite; Task 10 wires Discard.
In chrome.js, near the top of the IIFE (after the existing let selectedTopicID = '';):
// Map<topicID, "queued"|"running"|"succeeded"|"failed"|"timed_out">.
// Populated by the bootstrap fetch + job.updated events.
const jobStateByTopic = new Map();
let selectedTopicAnchor = null; // remembered for the thread header
const threadHeader = document.getElementById('wb-thread-header');
const threadTitle = threadHeader && threadHeader.querySelector('.wb-thread-header__title');
const rewriteBtn = document.getElementById('wb-rewrite-btn');
const discardBtn = document.getElementById('wb-discard-btn');
function isRewriteInFlight(topicID) {
const s = jobStateByTopic.get(topicID);
return s === 'queued' || s === 'running';
}
function renderThreadHeader() {
if (!threadHeader) return;
if (!selectedTopicID) {
threadHeader.hidden = true;
return;
}
threadHeader.hidden = false;
const kind = (selectedTopicAnchor && selectedTopicAnchor.kind) || 'global';
if (threadTitle) threadTitle.textContent = kind === 'global' ? 'Global Topic' : 'Anchored Topic';
if (rewriteBtn) {
const busy = isRewriteInFlight(selectedTopicID);
rewriteBtn.disabled = busy;
rewriteBtn.classList.toggle('is-busy', busy);
rewriteBtn.textContent = busy ? 'Rewriting…' : 'Rewrite';
}
}
openTopicThread so it shows the header and remembers anchor kindModify openTopicThread — replace the existing function:
async function openTopicThread(topicID) {
selectedTopicID = topicID;
// Remember anchor kind for header title — look up the card in the current list.
const card = topicList && topicList.querySelector('.wb-topic-card[data-topic-id="' + cssEscape(topicID) + '"]');
selectedTopicAnchor = card ? { kind: card.dataset.kind || 'anchored' } : null;
if (!topicThread || !topicMessages) return;
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/messages', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return;
const msgs = await resp.json();
topicMessages.innerHTML = '';
for (const m of msgs) {
topicMessages.appendChild(renderMessage(m));
}
topicThread.hidden = false;
topicList.querySelectorAll('.wb-topic-card').forEach(card => {
card.setAttribute('aria-selected', card.dataset.topicId === topicID ? 'true' : 'false');
});
renderThreadHeader();
}
function renderMessage(m) {
const div = document.createElement('div');
div.className = 'wb-topic-message';
div.dataset.messageId = m.id;
div.textContent = m.body;
return div;
}
renderMessage is replaced in Task 11 to handle kind='agent-proposal'. For now it just renders the body as today.
Update clearTopicSelection from Task 7 to clear the anchor too:
function clearTopicSelection() {
selectedTopicID = '';
selectedTopicAnchor = null;
if (topicList) {
topicList.querySelectorAll('.wb-topic-card[aria-selected="true"]').forEach(c => c.setAttribute('aria-selected', 'false'));
}
if (topicThread) topicThread.hidden = true;
if (threadHeader) threadHeader.hidden = true;
iframe.contentWindow.postMessage({ kind: 'anchor:scroll', topic_id: '' }, location.origin);
}
The confirmation is the small .mk-confirm modal from the spec — a centered card with "Ask the Agent to rewrite?" and Cancel / Rewrite buttons. Render it inline inside .wb-main so it floats over the iframe.
Add this helper near the other rendering helpers:
function showConfirm({title, body, confirmLabel, confirmClass, onConfirm}) {
const backdrop = document.createElement('div');
backdrop.className = 'wb-modal-backdrop';
const card = document.createElement('div');
card.className = 'wb-confirm';
card.innerHTML =
'<h4>' + escapeHTML(title) + '</h4>' +
'<p>' + escapeHTML(body) + '</p>' +
'<div class="wb-confirm__actions"></div>';
const actions = card.querySelector('.wb-confirm__actions');
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'wb-btn';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => backdrop.remove());
const ok = document.createElement('button');
ok.type = 'button';
ok.className = 'wb-btn ' + (confirmClass || 'wb-btn--accent');
ok.textContent = confirmLabel || 'Confirm';
ok.addEventListener('click', async () => {
ok.disabled = true;
try { await onConfirm(); } finally { backdrop.remove(); }
});
actions.appendChild(cancel);
actions.appendChild(ok);
backdrop.appendChild(card);
backdrop.addEventListener('click', (ev) => { if (ev.target === backdrop) backdrop.remove(); });
document.body.appendChild(backdrop);
}
async function postRewrite(topicID) {
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/proposals', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({}),
});
if (!resp.ok) return;
// Optimistically mark queued; the SSE job.updated event reconciles.
jobStateByTopic.set(topicID, 'queued');
renderThreadHeader();
}
if (rewriteBtn) {
rewriteBtn.addEventListener('click', () => {
if (!selectedTopicID || isRewriteInFlight(selectedTopicID)) return;
showConfirm({
title: 'Ask the Agent to rewrite?',
body: 'Queues a wb-incorporate job using the discussion so far. The proposal lands in the thread when the Agent finishes.',
confirmLabel: 'Rewrite',
confirmClass: 'wb-btn--accent',
onConfirm: () => postRewrite(selectedTopicID),
});
});
}
Append to chrome.css:
/* ─── thread header ───────────────────────────────────────────────────── */
.wb-thread-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: var(--wb-bg);
border-bottom: 1px solid var(--wb-rule);
font-size: 12px;
margin-bottom: 12px;
}
.wb-thread-header__title { font-weight: 600; }
.wb-thread-header__actions { display: flex; gap: 6px; }
.wb-btn.is-busy::after {
content: " ⟳";
display: inline-block;
animation: wb-spin 900ms linear infinite;
}
@keyframes wb-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* ─── modal backdrop + confirm card ───────────────────────────────────── */
.wb-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(28, 25, 23, 0.32);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.wb-confirm {
background: var(--wb-surface);
border: 1px solid var(--wb-rule);
border-radius: 6px;
padding: 14px 16px;
width: 360px;
max-width: calc(100vw - 32px);
box-shadow: 0 8px 24px rgba(28, 25, 23, .16);
}
.wb-confirm h4 {
margin: 0 0 8px;
font-size: 15px;
}
.wb-confirm p {
margin: 0 0 12px;
color: var(--wb-muted);
font-size: 12.5px;
line-height: 1.5;
}
.wb-confirm__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Open a topic to verify the header shows up.
playwright-cli eval "() => { const c = document.querySelector('.wb-topic-card'); if (c) c.click(); }"
playwright-cli screenshot --filename=/tmp/wb-thread-header.png
playwright-cli eval "() => ({hidden: document.getElementById('wb-thread-header').hidden, title: document.querySelector('.wb-thread-header__title')?.textContent})"
# Click Rewrite, verify the confirm modal.
playwright-cli eval "() => document.getElementById('wb-rewrite-btn').click()"
playwright-cli screenshot --filename=/tmp/wb-rewrite-confirm.png
playwright-cli eval "() => document.querySelector('.wb-modal-backdrop button.wb-btn').click()" # cancel
playwright-cli screenshot --filename=/tmp/wb-rewrite-cancelled.png
Read screenshots — confirm modal centered on a dark backdrop; Cancel dismisses it.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — thread header CTAs (rewrite confirm + disabled-spinner state)"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThe Discard button opens an inline panel (not a modal) inside the thread above the messages, with an optional reason textarea. Submit posts /api/topics/{id}/discard; the reason becomes the final kind='human' message body. The compose-and-propose checkbox in the reply form, when checked, fires POST /api/topics/{id}/messages then POST /api/topics/{id}/proposals in sequence.
In chrome.js, add near the other render helpers:
function showDiscardPanel(topicID) {
if (!topicThread) return;
let panel = document.getElementById('wb-discard-panel');
if (panel) return; // already open
panel = document.createElement('div');
panel.id = 'wb-discard-panel';
panel.className = 'wb-discard';
panel.innerHTML =
'<label for="wb-discard-reason">Why discard? Leave blank to skip.</label>' +
'<textarea id="wb-discard-reason" rows="3" maxlength="65536"></textarea>' +
'<div class="wb-discard__actions">' +
'<button type="button" class="wb-btn" data-cancel>Cancel</button>' +
'<button type="button" class="wb-btn wb-btn--danger" data-discard>Discard Topic</button>' +
'</div>';
topicThread.insertBefore(panel, topicThread.firstChild);
panel.querySelector('[data-cancel]').addEventListener('click', () => panel.remove());
panel.querySelector('[data-discard]').addEventListener('click', async () => {
const reason = panel.querySelector('#wb-discard-reason').value.trim();
panel.querySelector('[data-discard]').disabled = true;
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/discard', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(reason ? { reason } : {}),
});
if (resp.ok) {
panel.remove();
// The Topic drops out — SSE topic.discarded will refresh the list.
// Optimistic local cleanup so the UI feels instant:
clearTopicSelection();
await loadTopics();
// Exit Resolve mode if this Topic was being resolved.
if (window.wbInternal && window.wbInternal.exitResolveIfTopic) {
window.wbInternal.exitResolveIfTopic(topicID);
}
} else {
panel.querySelector('[data-discard]').disabled = false;
}
});
}
if (discardBtn) {
discardBtn.addEventListener('click', () => {
if (!selectedTopicID) return;
showDiscardPanel(selectedTopicID);
});
}
window.wbInternal.exitResolveIfTopic is provided by Task 12.
Find the existing topicReplyForm submit handler (around line 274). Replace with:
if (topicReplyForm) {
topicReplyForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!selectedTopicID || !topicReply.value.trim()) return;
const body = topicReply.value.trim();
const askRewrite = document.getElementById('wb-ask-rewrite');
const wantsRewrite = !!(askRewrite && askRewrite.checked);
const resp = await fetch('/api/topics/' + encodeURIComponent(selectedTopicID) + '/messages', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ body })
});
if (!resp.ok) return;
topicReply.value = '';
if (askRewrite) askRewrite.checked = false;
// Refresh thread + list eagerly; SSE will re-confirm.
await openTopicThread(selectedTopicID);
await loadTopics();
if (wantsRewrite) {
await postRewrite(selectedTopicID);
}
});
}
Append to chrome.css:
/* ─── discard inline panel ────────────────────────────────────────────── */
.wb-discard {
margin-bottom: 12px;
padding: 10px 12px;
background: var(--wb-accent-tint);
border: 1px solid var(--wb-accent-dark);
border-radius: 4px;
font-size: 12px;
}
.wb-discard label {
display: block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--wb-accent-dark);
margin-bottom: 4px;
}
.wb-discard textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
background: var(--wb-surface);
color: var(--wb-text);
margin-bottom: 8px;
min-height: 48px;
resize: vertical;
}
.wb-discard__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
/* ─── compose-and-propose checkbox ────────────────────────────────────── */
.wb-reply-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 6px 0 8px;
color: var(--wb-muted);
font-size: 12px;
cursor: pointer;
}
.wb-reply-checkbox input { accent-color: var(--wb-accent); }
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli eval "() => document.querySelector('.wb-topic-card')?.click()"
playwright-cli eval "() => document.getElementById('wb-discard-btn').click()"
playwright-cli screenshot --filename=/tmp/wb-discard-panel.png
playwright-cli eval "() => document.querySelector('.wb-discard [data-cancel]').click()"
playwright-cli eval "() => ({hasPanel: !!document.getElementById('wb-discard-panel'), checkbox: !!document.getElementById('wb-ask-rewrite')})"
Expected: panel appears with peach background + textarea + Cancel/Discard buttons; Cancel removes it. checkbox: true.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — discard inline panel + compose-and-propose checkbox"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssMessages with kind='agent-proposal' render with a left accent stripe, light tint, "Agent" header, status pill (pending/superseded/stale), and a "Review changes" footer button. Click → enter Resolve mode (Task 12).
Status derivation (from spec):
| Status | Source |
|---|---|
| Pending review | Latest proposal for this Topic; no stale_reasons |
| Superseded | A higher revision_number exists for the same topic_id |
| Stale — source changed | stale_reasons includes "source_sha" |
| Stale — new Topic opened | stale_reasons includes "missing_topic_markers" |
The thread fetch (GET /api/topics/{id}/messages) returns messages with kind and proposal_id. The proposals fetch (GET /api/topics/{id}/proposals) returns proposals with revision_number and stale_reasons. The renderer joins them.
In chrome.js, add:
// Map<proposalID, {revision_number, stale_reasons, source_path, ...}>
// populated by loadProposalsForTopic, used by renderMessage.
let proposalsByID = new Map();
let proposalsByTopicID = new Map();
async function loadProposalsForTopic(topicID) {
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/proposals', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return [];
const list = await resp.json();
proposalsByTopicID.set(topicID, list);
for (const p of list) proposalsByID.set(p.id, p);
return list;
}
function statusForProposal(p, allForTopic) {
if (!p) return null;
const reasons = Array.isArray(p.stale_reasons) ? p.stale_reasons : [];
if (reasons.includes('source_sha')) return 'stale-source';
if (reasons.includes('missing_topic_markers')) return 'stale-topic';
const maxRev = allForTopic.reduce((m, q) => Math.max(m, q.revision_number || 0), 0);
if ((p.revision_number || 0) < maxRev) return 'superseded';
return 'pending';
}
renderMessage to handle agent-proposal rowsReplace the function:
function renderMessage(m) {
if (m.kind === 'agent-proposal' && m.proposal_id) {
return renderAgentProposalMessage(m);
}
const div = document.createElement('div');
div.className = 'wb-topic-message';
div.dataset.messageId = m.id;
div.textContent = m.body;
return div;
}
function renderAgentProposalMessage(m) {
const wrap = document.createElement('div');
wrap.className = 'wb-agent-msg';
wrap.dataset.messageId = m.id;
wrap.dataset.proposalId = m.proposal_id;
const p = proposalsByID.get(m.proposal_id);
const all = proposalsByTopicID.get(selectedTopicID) || [];
const status = statusForProposal(p, all);
if (status === 'superseded') wrap.classList.add('wb-agent-msg--superseded');
else if (status === 'stale-source' || status === 'stale-topic') wrap.classList.add('wb-agent-msg--stale');
const head = document.createElement('div');
head.className = 'wb-agent-msg__head';
const headLabel = status === 'superseded' && p
? 'Agent · revision ' + (p.revision_number || '?')
: 'Agent';
head.innerHTML = '<span>' + escapeHTML(headLabel) + '</span>';
const pill = document.createElement('span');
pill.className = 'wb-pill wb-pill--' + (status || 'pending');
pill.textContent = pillLabelFor(status);
head.appendChild(pill);
const body = document.createElement('p');
body.className = 'wb-agent-msg__body';
body.textContent = m.body || '';
const footer = document.createElement('div');
footer.className = 'wb-agent-msg__footer';
const review = document.createElement('button');
review.type = 'button';
review.className = 'wb-btn ' + (status === 'pending' ? 'wb-btn--primary' : 'wb-btn--muted');
review.textContent = 'Review changes';
review.addEventListener('click', () => {
if (window.wbInternal && window.wbInternal.enterResolveMode) {
window.wbInternal.enterResolveMode(m.proposal_id, status);
}
});
footer.appendChild(review);
wrap.appendChild(head);
wrap.appendChild(body);
wrap.appendChild(footer);
return wrap;
}
function pillLabelFor(status) {
switch (status) {
case 'superseded': return 'Superseded';
case 'stale-source': return 'Stale — source changed';
case 'stale-topic': return 'Stale — new Topic opened';
default: return 'Pending review';
}
}
window.wbInternal.enterResolveMode is implemented in Task 12.
openTopicThread to load proposals firstModify the function so proposals are fetched before messages render (so the join in renderMessage works):
async function openTopicThread(topicID) {
selectedTopicID = topicID;
const card = topicList && topicList.querySelector('.wb-topic-card[data-topic-id="' + cssEscape(topicID) + '"]');
selectedTopicAnchor = card ? { kind: card.dataset.kind || 'anchored' } : null;
if (!topicThread || !topicMessages) return;
// Proposals first so the message renderer can join them.
await loadProposalsForTopic(topicID);
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/messages', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return;
const msgs = await resp.json();
topicMessages.innerHTML = '';
for (const m of msgs) topicMessages.appendChild(renderMessage(m));
topicThread.hidden = false;
topicList.querySelectorAll('.wb-topic-card').forEach(card => {
card.setAttribute('aria-selected', card.dataset.topicId === topicID ? 'true' : 'false');
});
renderThreadHeader();
}
chrome.css/* ─── agent-proposal message rows ─────────────────────────────────────── */
.wb-agent-msg {
border-left: 3px solid var(--wb-accent);
background: var(--wb-accent-bg);
padding: 8px 10px;
border-radius: 0 4px 4px 0;
margin: 8px 0;
font-size: 12.5px;
}
.wb-agent-msg--superseded,
.wb-agent-msg--stale {
background: var(--wb-bg);
border-left-color: var(--wb-muted);
color: var(--wb-muted);
}
.wb-agent-msg__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 4px;
color: var(--wb-accent);
}
.wb-agent-msg--superseded .wb-agent-msg__head,
.wb-agent-msg--stale .wb-agent-msg__head { color: var(--wb-muted); }
.wb-agent-msg__body { margin: 0; }
.wb-agent-msg__footer {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 8px;
}
.wb-pill {
display: inline-block;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
padding: 2px 7px;
border-radius: 99px;
}
.wb-pill--pending { background: var(--wb-accent); color: var(--wb-bg); }
.wb-pill--superseded { background: var(--wb-rule); color: var(--wb-muted); }
.wb-pill--stale-source,
.wb-pill--stale-topic { background: var(--wb-accent-tint); color: var(--wb-accent-dark); }
You'll need an Agent-proposal message to render. Create one via the Rewrite flow if #4 and the runner are available, or seed one through the real dev API/DB fixture used by the #4 plan. Do not verify by injecting unrelated static HTML; the screenshot must come from the actual renderMessage() path.
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli eval "() => document.querySelector('.wb-topic-card')?.click()"
playwright-cli eval "() => Array.from(document.querySelectorAll('.wb-agent-msg')).map(n => ({proposalID: n.dataset.proposalId, hasPill: !!n.querySelector('.wb-pill'), hasReview: !!n.querySelector('button')}))"
playwright-cli screenshot --filename=/tmp/wb-agent-proposal.png
Expected: at least one rendered .wb-agent-msg with a proposal ID, status pill, and Review button. If #4 is not present in the branch, stop this task and land #4 first; do not mark this task complete with a synthetic-only screenshot.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: chrome — agent-proposal message rendering (status pill + review button)"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssClick Review changes on an agent-proposal message → enter Resolve mode:
<iframe id="wb-content">.<div id="wb-resolve-mount"> with the toolbar + diff area.shell.dataset.resolveMode = '1' and shell.dataset.diffTier = readTierPref().applyRightSidebar() to trigger viewport-aware auto-collapse.Close button (the toolbar ×), navigation away, topic.incorporated for the matched Topic, or topic.discarded for the matched Topic → exit.
This task installs the controller, the enter/exit functions, and the toolbar skeleton with Close + tier toggle + Approve placeholder. Tier 1 and Tier 2 diff rendering land in Task 13.
In chrome.js, declare near the top of the IIFE:
const TIER_KEY = 'wb.diff.tier';
function readTierPref() {
try {
const v = localStorage.getItem(TIER_KEY);
return v === 'unified' ? 'unified' : 'side-by-side';
} catch (_) { return 'side-by-side'; }
}
function writeTierPref(v) {
try { localStorage.setItem(TIER_KEY, v === 'unified' ? 'unified' : 'side-by-side'); } catch (_) {}
}
const resolveMount = document.getElementById('wb-resolve-mount');
let resolveState = null; // { proposalID, topicID, sourcePath, status, ... } | null
function enterResolveMode(proposalID, status) {
const p = proposalsByID.get(proposalID);
if (!p) return;
resolveState = {
proposalID,
topicID: p.topic_id || selectedTopicID,
sourcePath: p.source_path || currentSourcePath(),
status: status || 'pending',
};
shell.dataset.resolveMode = '1';
shell.dataset.diffTier = readTierPref();
iframe.style.display = 'none';
resolveMount.hidden = false;
renderResolveShell();
applyRightSidebar();
}
function exitResolveMode() {
resolveState = null;
delete shell.dataset.resolveMode;
delete shell.dataset.diffTier;
iframe.style.display = '';
resolveMount.hidden = true;
resolveMount.innerHTML = '';
applyRightSidebar();
}
function exitResolveIfTopic(topicID) {
if (resolveState && resolveState.topicID === topicID) exitResolveMode();
}
function renderResolveShell() {
if (!resolveState) return;
const tier = shell.dataset.diffTier || 'side-by-side';
const approvable = resolveState.status === 'pending';
resolveMount.innerHTML = `
<div class="wb-resolve__toolbar">
<button type="button" class="wb-btn" data-resolve-close>× Close</button>
<span class="wb-resolve__count">1 Topic</span>
<div class="wb-seg" role="tablist">
<button type="button" class="wb-seg__opt${tier === 'unified' ? ' is-on' : ''}" data-tier="unified">Unified</button>
<button type="button" class="wb-seg__opt${tier === 'side-by-side' ? ' is-on' : ''}" data-tier="side-by-side">Side-by-side</button>
</div>
<span class="wb-resolve__spacer"></span>
${approvable ? '<button type="button" class="wb-btn wb-btn--accent" data-resolve-approve>Approve</button>' : ''}
</div>
<div class="wb-resolve__banner" hidden></div>
<div class="wb-resolve__body" data-tier="${tier}"></div>
`;
resolveMount.querySelector('[data-resolve-close]').addEventListener('click', exitResolveMode);
resolveMount.querySelectorAll('[data-tier]').forEach(btn => {
btn.addEventListener('click', () => {
const next = btn.dataset.tier;
writeTierPref(next);
shell.dataset.diffTier = next;
applyRightSidebar();
renderResolveShell();
renderResolveBody();
});
});
const approveBtn = resolveMount.querySelector('[data-resolve-approve]');
if (approveBtn) approveBtn.addEventListener('click', startApproveEditor);
renderResolveBanner();
renderResolveBody();
}
function renderResolveBanner() {
// Filled in by Task 14.
}
function renderResolveBody() {
// Filled in by Task 13.
const body = resolveMount.querySelector('.wb-resolve__body');
if (body) body.innerHTML = '<p style="padding:12px;color:var(--wb-muted);">Diff loading…</p>';
}
function startApproveEditor() {
// Filled in by Task 14.
}
// Exit on navigation — wire into the existing iframe.load.
In the existing iframe.addEventListener('load', ...) handler, add at the top:
iframe.addEventListener('load', () => {
if (resolveState) exitResolveMode();
// ... rest unchanged ...
Expose via window.wbInternal:
window.wbInternal = Object.assign(window.wbInternal || {}, {
applyRightSidebar,
dismissRightOverlay,
enterResolveMode,
exitResolveMode,
exitResolveIfTopic,
});
/* ─── Resolve mode ────────────────────────────────────────────────────── */
.wb-resolve {
position: absolute;
inset: 0;
background: var(--wb-bg);
display: flex;
flex-direction: column;
z-index: 10;
}
.wb-resolve__toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: var(--wb-bg);
border-bottom: 1px solid var(--wb-rule);
font-size: 12px;
}
.wb-resolve__count { color: var(--wb-muted); }
.wb-resolve__spacer { flex: 1; }
.wb-seg {
display: inline-flex;
border: 1px solid var(--wb-rule);
border-radius: 4px;
overflow: hidden;
background: var(--wb-surface);
}
.wb-seg__opt {
padding: 3px 10px;
font-size: 11px;
color: var(--wb-muted);
border-right: 1px solid var(--wb-rule);
background: transparent;
cursor: pointer;
border: 0;
border-right: 1px solid var(--wb-rule);
}
.wb-seg__opt:last-child { border-right: none; }
.wb-seg__opt.is-on { background: var(--wb-text); color: var(--wb-bg); }
.wb-resolve__banner {
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid var(--wb-rule);
}
.wb-resolve__banner--stale { background: var(--wb-accent-tint); color: var(--wb-accent-dark); }
.wb-resolve__banner--superseded { background: var(--wb-rule); color: var(--wb-muted); }
.wb-resolve__body { flex: 1; overflow: auto; }
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Trigger Resolve through the actual Review button path.
playwright-cli eval "() => document.querySelector('.wb-agent-msg button')?.click()"
playwright-cli screenshot --filename=/tmp/wb-resolve-mode-shell.png
Expected: iframe gone, toolbar visible at top of main area with Close, "1 Topic" label, toggle, Approve. Read the screenshot.
If there is no live proposal, expose a temporary window.wbInternal.enterResolveModeWithStateForDev() hook that populates the same maps and then calls the real enterResolveMode() / render functions. Do not verify by assigning innerHTML directly.
Clean up:
playwright-cli eval "() => { const s = document.querySelector('.wb-shell'); delete s.dataset.resolveMode; delete s.dataset.diffTier; const m = document.getElementById('wb-resolve-mount'); m.hidden = true; m.innerHTML = ''; document.getElementById('wb-content').style.display = ''; }"
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: resolve — controller scaffold (enter/exit, toolbar, tier toggle, mount swap)"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssTier 1: fetch GET /api/proposals/{id}/diff, read the JSON response's unified field, and render it as <pre> with line-prefix CSS classes (.add for +, .del for -, .hunk for @@).
Tier 2: two iframes — left /content/{path}, right /content/preview/proposals/{id}. The user's choice persists in localStorage["wb.diff.tier"] (already wired in Task 12).
renderResolveBody with the real implementationIn chrome.js, replace the placeholder:
async function renderResolveBody() {
if (!resolveState) return;
const body = resolveMount.querySelector('.wb-resolve__body');
if (!body) return;
const tier = shell.dataset.diffTier || 'side-by-side';
body.dataset.tier = tier;
body.innerHTML = '<p style="padding:12px;color:var(--wb-muted);">Diff loading…</p>';
if (tier === 'unified') {
const resp = await fetch('/api/proposals/' + encodeURIComponent(resolveState.proposalID) + '/diff', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) {
body.innerHTML = '<p style="padding:12px;color:var(--wb-muted);">Could not load diff.</p>';
return;
}
const data = await resp.json();
body.innerHTML = renderUnifiedDiffHTML(data.unified || '');
} else {
const path = resolveState.sourcePath;
const previewSrc = '/content/preview/proposals/' + encodeURIComponent(resolveState.proposalID);
const currentSrc = '/content/' + path;
body.innerHTML = `
<div class="wb-tier2">
<div class="wb-tier2__pane">
<h4>Current</h4>
<iframe class="wb-tier2__iframe" data-side="current" sandbox="allow-same-origin allow-scripts allow-popups allow-forms" src="${escapeHTMLAttr(currentSrc)}"></iframe>
</div>
<div class="wb-tier2__pane">
<h4>Proposed</h4>
<iframe class="wb-tier2__iframe" data-side="proposed" sandbox="allow-same-origin allow-scripts allow-popups allow-forms" src="${escapeHTMLAttr(previewSrc)}"></iframe>
</div>
</div>
`;
}
}
function escapeHTMLAttr(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function renderUnifiedDiffHTML(text) {
const lines = String(text).split('\n');
const out = lines.map(line => {
const esc = escapeHTML(line);
if (line.startsWith('@@')) return '<span class="wb-diff__line wb-diff__line--hunk">' + esc + '</span>';
if (line.startsWith('+') && !line.startsWith('+++')) return '<span class="wb-diff__line wb-diff__line--add">' + esc + '</span>';
if (line.startsWith('-') && !line.startsWith('---')) return '<span class="wb-diff__line wb-diff__line--del">' + esc + '</span>';
return '<span class="wb-diff__line">' + esc + '</span>';
}).join('\n');
return '<pre class="wb-diff">' + out + '</pre>';
}
/* ─── Tier 1 unified diff ─────────────────────────────────────────────── */
.wb-diff {
margin: 0;
padding: 10px 14px;
background: #292524;
color: #e7e5e4;
font-family: 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 11.5px;
line-height: 1.55;
overflow: auto;
white-space: pre;
/* Required on mobile — the parent .wb-resolve__body is also overflow:auto.
Without max-width here, the inner <pre> would expand to fit its content
and the parent would scroll instead of the pre, defeating the spec's
"horizontally-scrollable <pre>" requirement. */
max-width: 100%;
box-sizing: border-box;
}
.wb-diff__line { display: block; }
.wb-diff__line--add { color: #86efac; }
.wb-diff__line--del { color: #fca5a5; }
.wb-diff__line--hunk { color: #fbbf24; }
/* ─── Tier 2 side-by-side ─────────────────────────────────────────────── */
.wb-tier2 {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
height: 100%;
min-height: 0;
}
.wb-tier2__pane {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--wb-surface);
}
.wb-tier2__pane + .wb-tier2__pane { border-left: 1px solid var(--wb-rule); }
.wb-tier2__pane h4 {
margin: 0;
padding: 6px 10px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--wb-muted);
background: var(--wb-bg);
border-bottom: 1px solid var(--wb-rule);
}
.wb-tier2__iframe {
flex: 1;
width: 100%;
border: 0;
background: var(--wb-bg);
}
The verification needs a real proposal. If #4 is wired in the running server:
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Visually inspect by triggering Resolve from an agent-proposal Review button.
playwright-cli eval "() => document.querySelector('.wb-agent-msg button')?.click()"
playwright-cli screenshot --filename=/tmp/wb-resolve-tier2.png
# Switch to Tier 1 visual:
playwright-cli eval "() => { const opt = Array.from(document.querySelectorAll('.wb-seg__opt')).find(n => /Unified/.test(n.textContent)); opt?.click(); }"
playwright-cli screenshot --filename=/tmp/wb-resolve-tier1.png
Read both. Verify tier toggle persists by clicking the actual tier toggle once Resolve mode is entered from a real proposal:
playwright-cli eval "() => localStorage.getItem('wb.diff.tier')"
If there is no live proposal, use the same temporary renderer-backed wbInternal dev hook described in Task 12. Do not set resolveMount.innerHTML directly for verification.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: resolve — tier 1 unified diff + tier 2 side-by-side iframes"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThree banner variants drive whether Approve is shown:
The Approve button click swaps the Approve toolbar button for an inline .wb-approve editor (subject input prefilled, optional body textarea) inside the Resolve mount, above the diff body. Submit posts /api/proposals/{id}/incorporate {subject, body}. On 409 stale_proposal, collapse the editor, refresh proposals from GET /api/topics/{id}/proposals, then re-render — banner now shows with fresh stale_reasons, Approve disappears.
The subject default is the #4-derived "Incorporate Topic: GET /api/topics/{id}/proposals response does not expose commit_subject_default or a topic summary, so v1 derives the fallback client-side by fetching the Topic messages and using the first human message body (first 60 characters). If a future server response adds commit_subject_default, the helper below will prefer it without changing the UI flow.
renderResolveBanner and the status→banner mapIn chrome.js, replace the placeholder:
function renderResolveBanner() {
if (!resolveState) return;
const banner = resolveMount.querySelector('.wb-resolve__banner');
if (!banner) return;
const status = resolveState.status;
let text = '';
let cls = '';
if (status === 'superseded') {
text = 'A newer proposal exists below in this thread. This one can no longer be approved.';
cls = 'wb-resolve__banner--superseded';
} else if (status === 'stale-source') {
text = 'Stale — the source changed. Another Incorporation landed before this one was approved. Click Rewrite to ask for a fresh proposal.';
cls = 'wb-resolve__banner--stale';
} else if (status === 'stale-topic') {
text = 'Stale — a new Topic was opened during this proposal. Click Rewrite to refresh.';
cls = 'wb-resolve__banner--stale';
}
if (!text) {
banner.hidden = true;
banner.className = 'wb-resolve__banner';
banner.textContent = '';
return;
}
banner.hidden = false;
banner.className = 'wb-resolve__banner ' + cls;
banner.textContent = text;
}
Replace the placeholder startApproveEditor:
async function startApproveEditor() {
if (!resolveState) return;
const toolbar = resolveMount.querySelector('.wb-resolve__toolbar');
if (!toolbar) return;
const approveBtn = toolbar.querySelector('[data-resolve-approve]');
if (approveBtn) approveBtn.remove();
let editor = resolveMount.querySelector('.wb-approve');
if (editor) return; // already open
const p = proposalsByID.get(resolveState.proposalID) || {};
const defaultSubject = await defaultCommitSubject(resolveState.topicID, p);
editor = document.createElement('div');
editor.className = 'wb-approve';
editor.innerHTML = `
<label>Commit subject</label>
<input type="text" data-subject value="${escapeHTMLAttr(defaultSubject)}">
<label>Commit body (optional)</label>
<textarea data-body rows="3"></textarea>
<div class="wb-approve__actions">
<button type="button" class="wb-btn" data-approve-cancel>Cancel</button>
<button type="button" class="wb-btn wb-btn--accent" data-approve-submit>Commit & approve</button>
</div>
`;
const banner = resolveMount.querySelector('.wb-resolve__banner');
resolveMount.insertBefore(editor, banner);
editor.querySelector('[data-approve-cancel]').addEventListener('click', () => {
editor.remove();
// Restore Approve button.
renderResolveShell();
});
editor.querySelector('[data-approve-submit]').addEventListener('click', async () => {
const subject = editor.querySelector('[data-subject]').value.trim();
const body = editor.querySelector('[data-body]').value.trim();
const btn = editor.querySelector('[data-approve-submit]');
btn.disabled = true;
const resp = await fetch('/api/proposals/' + encodeURIComponent(resolveState.proposalID) + '/incorporate', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(subject ? (body ? { subject, body } : { subject }) : {}),
});
if (resp.ok) {
// SSE topic.incorporated will fire exitResolveMode + iframe reload.
// Don't pre-empt; close the editor only.
editor.remove();
return;
}
if (resp.status === 409) {
// Stale — refresh proposals, re-derive status, re-render.
const fresh = await loadProposalsForTopic(resolveState.topicID);
const updated = fresh.find(q => q.id === resolveState.proposalID);
if (updated) {
resolveState.status = statusForProposal(updated, fresh) || 'pending';
}
editor.remove();
renderResolveShell();
renderResolveBody();
} else {
btn.disabled = false;
}
});
}
async function defaultCommitSubject(topicID, proposal) {
if (proposal && proposal.commit_subject_default) return proposal.commit_subject_default;
try {
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/messages', {
credentials: 'same-origin',
cache: 'no-store',
});
if (resp.ok) {
const msgs = await resp.json();
const first = msgs.find(m => m.kind === 'human' && m.body);
if (first) return 'Incorporate Topic: ' + truncate(first.body, 60);
}
} catch (_) {}
return 'Incorporate Topic';
}
/* ─── Approve inline editor ───────────────────────────────────────────── */
.wb-approve {
padding: 10px 12px;
background: var(--wb-accent-bg);
border-bottom: 1px solid var(--wb-rule);
font-size: 12px;
}
.wb-approve label {
display: block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--wb-accent);
margin-bottom: 4px;
}
.wb-approve input,
.wb-approve textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
background: var(--wb-surface);
color: var(--wb-text);
margin-bottom: 8px;
}
.wb-approve textarea {
min-height: 50px;
resize: vertical;
}
.wb-approve__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Drive each banner variant through the real Resolve renderer. Prefer seeded
# proposal fixtures that return pending/superseded/stale rows from
# GET /api/topics/{id}/proposals. If fixture data is unavailable, expose a
# temporary wbInternal dev hook that sets resolveState/proposalsByID and calls
# renderResolveShell(), then remove or keep it explicitly under wbInternal.
Capture three screenshots: /tmp/wb-resolve-pending.png, /tmp/wb-resolve-stale.png, /tmp/wb-resolve-approve.png. Read each and confirm the actual renderer produced toolbar+banner+approve states.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: resolve — banner variants + approve inline editor with 409 stale handling"
Files:
internal/server/static/chrome.jsThe realtime consumer:
/api/agent/jobs?source_path=… to bootstrap jobStateByTopic.self_user_id from the body's data-self-user-id (set by shell.html in Task 1).EventSource('/api/stream?source_path=…').subscribed frame: capture subscriber_id. Re-set on every reconnect./auth/me. Anonymous/401 → location.reload(). Authenticated → noop (EventSource auto-retries).404 unknown_source directly from EventSource.onerror; browser JS cannot read the SSE HTTP status. Only render the dead-doc state after a separate source-existence confirmation returns 404. Otherwise keep the page intact and let EventSource retry.This task installs the lifecycle. Per-event handlers land in Task 16.
In chrome.js, add near the top of the IIFE (after csrfHeaders):
const selfUserID = document.body.dataset.selfUserId || '';
let eventSource = null;
let subscriberID = '';
let pendingFocusTopicID = '';
let deadDocActive = false;
window.__wbReconnects = 0; // dev-console diagnostic for EventSource retries
// Forward-declared (Task 17 supplies the real implementation). Until then,
// the `subscribed` handler's re-post call below must not throw.
let postFocusEager = async (_topicID) => {};
async function bootstrapJobs(sourcePath) {
if (!sourcePath) return;
try {
const resp = await fetch('/api/agent/jobs?source_path=' + encodeURIComponent(sourcePath), {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return;
const jobs = await resp.json();
jobStateByTopic.clear();
for (const j of jobs) {
// AgentJob (internal/collab/agent_jobs.go) exposes `status`, not `state`.
if (j.topic_id) jobStateByTopic.set(j.topic_id, j.status || 'queued');
}
renderThreadHeader();
} catch (_) {}
}
function closeEventSource() {
if (eventSource) {
try { eventSource.close(); } catch (_) {}
eventSource = null;
}
subscriberID = '';
}
function openEventSource(sourcePath) {
closeEventSource();
if (!authenticated || !sourcePath) return;
const url = '/api/stream?source_path=' + encodeURIComponent(sourcePath);
const es = new EventSource(url, { withCredentials: true });
eventSource = es;
es.addEventListener('subscribed', (ev) => {
window.__wbReconnects += 1;
try {
const data = JSON.parse(ev.data);
subscriberID = data.subscriber_id || '';
} catch (_) {}
// Re-post any pending focus after reconnect.
if (pendingFocusTopicID !== '' && subscriberID) {
postFocusEager(pendingFocusTopicID);
}
});
// Event handlers wired in Task 16.
es.onerror = handleSSEError;
}
async function sourceStillExists(sourcePath) {
if (!sourcePath) return true;
const contentPath = '/content/' + sourcePath.split('/').map(encodeURIComponent).join('/');
const resp = await fetch(contentPath, {
method: 'HEAD',
credentials: 'same-origin',
cache: 'no-store',
});
if (resp.status === 404) return false;
return true;
}
async function handleSSEError() {
// EventSource does not expose HTTP status or response body. Treat errors as
// non-terminal unless a normal fetch can confirm auth loss or missing source.
try {
const resp = await fetch('/auth/me', { credentials: 'same-origin', cache: 'no-store' });
if (!resp.ok) {
location.reload();
return;
}
} catch (_) { /* network — let EventSource auto-retry */ return; }
let exists = true;
try { exists = await sourceStillExists(currentSourcePath()); } catch (_) { return; }
if (!exists && typeof renderDeadDoc === 'function') {
deadDocActive = true;
closeEventSource();
renderDeadDoc();
}
}
function startRealtimeForCurrentDocument() {
if (!authenticated) return;
const sourcePath = currentSourcePath();
if (!sourcePath || sourcePath.startsWith('_')) {
// Welcome, _404, _search-offline, etc. — these are baked pages without
// a backing Source. SSE deliberately stays closed; toasts queued in
// another tab are not delivered during the visit to a baked page.
// The user will get a fresh subscription when they navigate back to a
// real Document. Acceptable: visits to baked pages are typically brief.
closeEventSource();
return;
}
deadDocActive = false;
bootstrapJobs(sourcePath);
openEventSource(sourcePath);
}
In the existing iframe.addEventListener('load', () => { ... }) handler, after the existing if (authenticated) { resetTopicUI(); loadTopics(); } block, append:
startRealtimeForCurrentDocument();
So the full block becomes:
if (authenticated) {
resetTopicUI();
loadTopics();
startRealtimeForCurrentDocument();
}
Modify the existing logout click handler to call closeEventSource() before the await fetch('/auth/logout', ...) line.
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli eval "() => ({selfUserID: document.body.dataset.selfUserId, subscriberID: 'inspect-via-network'})"
# Network tab is not directly accessible via playwright-cli; rely on tail of server logs.
tail -n 30 /tmp/wb.log
Expected (in /tmp/wb.log): one GET to /api/stream?source_path=… per Document load. Eval should show a non-empty selfUserID for the authenticated user.
If the realtime hub is not yet implemented server-side, the EventSource may receive an immediate close. Because handleSSEError() now confirms source existence separately, this must gracefully no-op: no dead-doc UI and no console flood. Confirm:
playwright-cli eval "() => console.warn('boundary-marker')"
# Read console logs:
playwright-cli console-logs --since="2s" # or whatever the playwright-cli command is for console; check SKILL.md
If playwright-cli does not surface console, just inspect via:
playwright-cli eval "() => { return window.__wbReconnects || 0; }"
(window.__wbReconnects is a dev-console diagnostic incremented by the subscribed handler above.)
git add internal/server/static/chrome.js
git commit -m "wiki-browser: realtime — EventSource lifecycle + jobs bootstrap + subscribed handshake"
Files:
internal/server/static/chrome.jsPer spec's "REST authoritative, events advise" rule, each handler refetches REST state. Payloads are used only as previews (≤160 chars) and for routing. Record-ID dedup: every handler reads the canonical record and renders only if new or its monotonic key advanced.
Events handled:
topic.created — refetch /api/topics. If Resolve mode is open, also refetch /api/topics/{resolve_topic_id}/proposals (a sibling Topic stales the in-flight proposal with missing_topic_markers).topic.message_appended — if thread open: refetch /api/topics/{id}/messages. Auto-append if at bottom; pill if scrolled up. Closed thread → unread dot on card.topic.incorporated — refetch /api/topics. If Resolve mode open for same Topic, exits (handled by post-incorporate). If for a sibling, refetch proposals + reload both Tier-2 iframes. Reload Browse-mode iframe via source_path.topic.discarded — refetch /api/topics. Exit Resolve mode if matched.proposal.created — refetch /api/topics/{id}/proposals. If thread open, update existing agent-proposal message's status pill.job.updated — update jobStateByTopic. On failed/timed_out, fetch /api/agent/jobs/{id} for error_tail. Update Rewrite button state.presence.updated — full snapshot. Re-render topbar chips + per-card readers. (Task 18 supplies the renderer; this task only routes.)Toast emission lands in Task 18.
openEventSource after the subscribed listenerIn chrome.js, extend openEventSource:
es.addEventListener('topic.created', (ev) => onTopicCreated(parseEvent(ev)));
es.addEventListener('topic.message_appended', (ev) => onMessageAppended(parseEvent(ev)));
es.addEventListener('topic.incorporated', (ev) => onTopicIncorporated(parseEvent(ev)));
es.addEventListener('topic.discarded', (ev) => onTopicDiscarded(parseEvent(ev)));
es.addEventListener('proposal.created', (ev) => onProposalCreated(parseEvent(ev)));
es.addEventListener('job.updated', (ev) => onJobUpdated(parseEvent(ev)));
es.addEventListener('presence.updated', (ev) => onPresenceUpdated(parseEvent(ev)));
function parseEvent(ev) {
try { return JSON.parse(ev.data); } catch (_) { return {}; }
}
Add these functions:
// SSE can replay frames after reconnect. These sets/maps are UI-side
// idempotency guards for side effects (toasts, status transitions). REST
// refetch remains authoritative; repeated refetches are harmless, but
// duplicated toasts and regressed job states are not.
const realtimeSeen = {
topicCreated: new Set(),
topicIncorporated: new Set(),
topicDiscarded: new Set(),
proposalCreated: new Set(),
jobStatusByID: new Map(),
};
const JOB_STATUS_RANK = { queued: 1, running: 2, succeeded: 3, failed: 3, timed_out: 3 };
function seenOnce(set, key) {
if (!key) return false; // no stable record id; allow the handler to refetch
if (set.has(key)) return true;
set.add(key);
return false;
}
function jobStatusAdvanced(jobID, status) {
if (!jobID) return true;
const prev = realtimeSeen.jobStatusByID.get(jobID);
if (prev === status) return false;
if (prev && (JOB_STATUS_RANK[status] || 0) <= (JOB_STATUS_RANK[prev] || 0)) return false;
realtimeSeen.jobStatusByID.set(jobID, status);
return true;
}
async function onTopicCreated(payload) {
if (seenOnce(realtimeSeen.topicCreated, payload.topic_id)) return;
await loadTopics();
if (resolveState) {
const fresh = await loadProposalsForTopic(resolveState.topicID);
const updated = fresh.find(q => q.id === resolveState.proposalID);
if (updated) {
const status = statusForProposal(updated, fresh) || 'pending';
if (status !== resolveState.status) {
resolveState.status = status;
renderResolveShell();
renderResolveBody();
}
}
}
if (payload.created_by && payload.created_by !== selfUserID) {
pushToast({
// #6's topic.created carries only an opaque created_by user id —
// no display name. Resolve via the presence snapshot (Task 18).
actorName: displayNameForUser(payload.created_by),
actorRest: 'opened a Topic on this Document.',
userID: payload.created_by,
topicID: payload.topic_id,
});
}
}
async function onMessageAppended(payload) {
// No toast for message_appended (spec §Toast: "Everything else … is
// silent"). This includes agent-proposal rows — they land into the
// open thread without a notification; the user discovers them by
// scrolling. The Rewrite button's disabled-spinner state is the only
// active cue while the job is running; the message lands when it
// succeeds. Intentional per spec.
if (!selectedTopicID || selectedTopicID !== payload.topic_id) {
// Closed thread — mark the card with an unread dot.
const card = topicList && topicList.querySelector('.wb-topic-card[data-topic-id="' + cssEscape(payload.topic_id) + '"]');
if (card) card.classList.add('wb-topic-card--unread');
return;
}
// Open thread — refetch and decide auto-append vs pill.
const wasAtBottom = isMessagesAtBottom();
// proposal_id routing happens via the standard renderMessage path; load proposals first.
if (payload.proposal_id) await loadProposalsForTopic(selectedTopicID);
const resp = await fetch('/api/topics/' + encodeURIComponent(selectedTopicID) + '/messages', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!resp.ok) return;
const msgs = await resp.json();
const seen = new Set(Array.from(topicMessages.children).map(n => n.dataset.messageId));
let newCount = 0;
for (const m of msgs) {
if (!seen.has(m.id)) {
topicMessages.appendChild(renderMessage(m));
newCount++;
}
}
if (newCount === 0) return;
if (wasAtBottom) scrollMessagesToBottom();
else showNewMessagesPill(newCount);
}
async function onTopicIncorporated(payload) {
if (seenOnce(realtimeSeen.topicIncorporated, payload.topic_id)) return;
await loadTopics();
const matchedTopic = payload.topic_id;
if (resolveState && resolveState.topicID === matchedTopic) {
exitResolveMode();
} else if (resolveState) {
const fresh = await loadProposalsForTopic(resolveState.topicID);
const updated = fresh.find(q => q.id === resolveState.proposalID);
if (updated) {
const status = statusForProposal(updated, fresh) || 'pending';
resolveState.status = status;
renderResolveShell();
renderResolveBody(); // reloads both Tier-2 iframes
}
}
// Browse-mode iframe reload to pick up new source.
if (payload.source_path === currentSourcePath()) {
iframe.contentWindow.location.reload();
}
if (payload.incorporated_by && payload.incorporated_by !== selfUserID) {
pushToast({
// No display name on topic.incorporated — resolve via presence.
actorName: displayNameForUser(payload.incorporated_by),
actorRest: 'incorporated a Topic.',
userID: payload.incorporated_by,
topicID: payload.topic_id,
});
}
}
async function onTopicDiscarded(payload) {
if (seenOnce(realtimeSeen.topicDiscarded, payload.topic_id)) return;
await loadTopics();
exitResolveIfTopic(payload.topic_id);
if (selectedTopicID === payload.topic_id) {
clearTopicSelection();
}
if (payload.discarded_by && payload.discarded_by !== selfUserID) {
pushToast({
// No display name on topic.discarded — resolve via presence.
actorName: displayNameForUser(payload.discarded_by),
actorRest: 'discarded a Topic.',
userID: payload.discarded_by,
topicID: payload.topic_id,
});
}
}
async function onProposalCreated(payload) {
if (seenOnce(realtimeSeen.proposalCreated, payload.proposal_id)) return;
if (selectedTopicID === payload.topic_id) {
await loadProposalsForTopic(payload.topic_id);
// Update pill on any rendered agent-proposal message for this proposal.
const node = topicMessages.querySelector('.wb-agent-msg[data-proposal-id="' + cssEscape(payload.proposal_id) + '"]');
if (node) {
const fresh = proposalsByID.get(payload.proposal_id);
const all = proposalsByTopicID.get(selectedTopicID) || [];
const status = statusForProposal(fresh, all);
const pill = node.querySelector('.wb-pill');
if (pill) {
pill.className = 'wb-pill wb-pill--' + (status || 'pending');
pill.textContent = pillLabelFor(status);
}
}
}
}
async function onJobUpdated(payload) {
const status = payload.status || 'queued'; // #6's job.updated field is `status` (no `state`).
if (!jobStatusAdvanced(payload.job_id, status)) return;
const prevState = jobStateByTopic.get(payload.topic_id);
jobStateByTopic.set(payload.topic_id, status);
renderThreadHeader();
if ((status === 'failed' || status === 'timed_out') && prevState !== status) {
let errorTail = '';
try {
const resp = await fetch('/api/agent/jobs/' + encodeURIComponent(payload.job_id), {
credentials: 'same-origin', cache: 'no-store',
});
if (resp.ok) {
const data = await resp.json();
errorTail = data.error_tail || '';
}
} catch (_) {}
pushToast({
// No actor field on job.updated — both collaborators care, so no
// originator suppression and no bold actor name. #6's job.updated
// carries no topic title (Topics have no title field — they are
// identified by their first-message preview), so the toast names no
// Topic; the Focus link (topicID) is how the user navigates to it.
text: 'Rewrite failed.',
secondary: errorTail ? truncate(errorTail, 160) : '',
topicID: payload.topic_id,
});
}
}
function onPresenceUpdated(payload) {
if (typeof renderPresence === 'function') renderPresence(payload);
}
function isMessagesAtBottom() {
if (!topicMessages) return true;
const slack = 40; // px from bottom counts as "at bottom"
return (topicMessages.scrollHeight - topicMessages.scrollTop - topicMessages.clientHeight) <= slack;
}
function scrollMessagesToBottom() {
if (!topicMessages) return;
topicMessages.scrollTop = topicMessages.scrollHeight;
hideNewMessagesPill();
}
function showNewMessagesPill(_n) {
const pill = document.getElementById('wb-new-messages-pill');
if (pill) pill.hidden = false;
}
function hideNewMessagesPill() {
const pill = document.getElementById('wb-new-messages-pill');
if (pill) pill.hidden = true;
}
function pushToast(_opts) {
// Wired in Task 18.
}
function renderPresence(_payload) {
// Wired in Task 18.
}
// #6 events carry only opaque user ids, never a display name. The real
// implementation (Task 18) resolves the name from the latest
// presence.updated snapshot; until then this stub keeps toasts working.
function displayNameForUser(_userID) {
return 'Someone';
}
Wire the new-messages pill click:
const pillBar = document.getElementById('wb-new-messages-pill');
if (pillBar) {
pillBar.addEventListener('click', () => {
scrollMessagesToBottom();
});
}
Mark messages as read when the user re-opens a card with the unread dot:
// In the existing topicList click handler (Task 7), after openTopicThread(card.dataset.topicId):
// Add immediately after that line:
// card.classList.remove('wb-topic-card--unread');
Find the topicList click delegate and add card.classList.remove('wb-topic-card--unread'); after the openTopicThread(...) call.
chrome.css/* ─── unread + new-messages pill ──────────────────────────────────────── */
.wb-topic-card--unread::after {
content: "";
position: absolute;
top: 8px;
right: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--wb-accent);
}
.wb-pill-bar {
text-align: center;
padding: 6px 0;
}
.wb-pill-bar__pill {
display: inline-block;
background: var(--wb-text);
color: var(--wb-bg);
padding: 4px 12px;
border-radius: 99px;
font-size: 11.5px;
font: inherit;
border: 0;
cursor: pointer;
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Dispatch fake events to verify routing:
playwright-cli eval "() => {
// Simulate handlers receiving payloads. Use an internal hook:
// (Add a window.wbInternal.onJobUpdated etc. for tests.)
return 'add internal hooks for dispatch';
}"
For better testability, expose the handlers via window.wbInternal:
window.wbInternal = Object.assign(window.wbInternal || {}, {
applyRightSidebar,
dismissRightOverlay,
enterResolveMode,
exitResolveMode,
exitResolveIfTopic,
onTopicCreated, onMessageAppended, onTopicIncorporated, onTopicDiscarded,
onProposalCreated, onJobUpdated, onPresenceUpdated,
});
Now verify routing:
playwright-cli eval "() => window.wbInternal.onJobUpdated({topic_id: 'x', status: 'queued'}); document.getElementById('wb-rewrite-btn')?.disabled"
Expected: true after the simulated queued if selectedTopicID === 'x'.
Open a Topic, run again with its real ID, then dispatch succeeded:
playwright-cli eval "() => { window.wbInternal.onJobUpdated({topic_id: window.__wbSelectedTopic || 'x', status: 'succeeded'}); }"
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: realtime — 7 event handlers (REST refetch + record-ID dedup)"
Files:
internal/server/static/chrome.jsTopic focus changes POST /api/stream/focus {subscriber_id, topic_id}; closing the thread posts {topic_id: ""}. Trailing-edge 1-second debouncer to stay under the server's per-subscriber rate limit. Error handling per spec:
404 unknown_subscriber → drop cached subscriberID; re-POST after next subscribed.
404 unknown_topic → treat as silent coerce.
429 too_many_focus_calls → silently dropped.
Step 1: Add the debouncer
In chrome.js, replace the let scheduleFocus = (_topicID) => {}; stub introduced in Task 7 with the real implementation below, and add the post helper alongside. Keep scheduleFocus declared with let in this replacement; chrome.js is a strict-mode module and assigning to an undeclared binding would break the whole script.
let focusDebounceTimer = 0;
let focusLatestTopicID = '';
let scheduleFocus = (topicID) => {
focusLatestTopicID = topicID || '';
if (focusDebounceTimer) return;
focusDebounceTimer = window.setTimeout(() => {
focusDebounceTimer = 0;
const target = focusLatestTopicID;
if (!subscriberID) {
pendingFocusTopicID = target;
return;
}
postFocusEager(target);
}, 1000);
};
postFocusEager = async (topicID) => {
if (!subscriberID) {
pendingFocusTopicID = topicID;
return;
}
const resp = await fetch('/api/stream/focus', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ subscriber_id: subscriberID, topic_id: topicID || '' }),
});
if (resp.ok) {
pendingFocusTopicID = '';
return;
}
if (resp.status === 404) {
// Could be unknown_subscriber or unknown_topic. Inspect body if available.
let code = '';
try {
const data = await resp.json();
code = data.code || data.error || '';
} catch (_) {}
if (code === 'unknown_subscriber') {
subscriberID = '';
pendingFocusTopicID = topicID;
// Will be re-posted by the subscribed handler.
return;
}
// unknown_topic — treat as silent coerce to "".
pendingFocusTopicID = '';
return;
}
if (resp.status === 429) {
// Defense-in-depth — the debouncer should already keep us under the limit.
return;
}
};
scheduleFocus into selection changesIn selectTopic, add at the end:
scheduleFocus(topicID);
In clearTopicSelection, add at the end:
scheduleFocus('');
In openTopicThread, add at the end:
scheduleFocus(topicID);
(Note: selectTopic calls openTopicThread, so we end up calling scheduleFocus twice on a card click — but the second call updates focusLatestTopicID to the same value and the trailing timer coalesces. Acceptable.)
In the iframe load handler — when navigation happens, the previous focus is no longer relevant. Add to the existing load handler (after startRealtimeForCurrentDocument()):
scheduleFocus('');
This sends a focus-clear to the server for the previous Document. On the new Document, the next user action will re-focus.
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli eval "() => document.querySelector('.wb-topic-card')?.click()"
# Wait 1.2s for debouncer:
playwright-cli eval "() => new Promise(r => setTimeout(r, 1200))"
tail -n 20 /tmp/wb.log
Expected (in /tmp/wb.log): one POST to /api/stream/focus with the topic_id. Verify rapid clicks coalesce:
playwright-cli eval "() => {
const cards = document.querySelectorAll('.wb-topic-card');
for (const c of cards) c.click();
}"
playwright-cli eval "() => new Promise(r => setTimeout(r, 1200))"
tail -n 20 /tmp/wb.log
Expected: only one focus POST after the burst (the last clicked card's ID), not N.
git add internal/server/static/chrome.js
git commit -m "wiki-browser: realtime — focus POST debouncer (1s trailing) with 404/429 handling"
Files:
internal/server/static/chrome.jsinternal/server/static/chrome.cssThree rendering subsystems wired from event payloads:
user_id, no self-chip. Deterministic warm-palette color from user_id. Per-card reader stack (max 3 stacked + +N).×.renderPresenceIn chrome.js, replace the placeholder:
const presenceMount = document.getElementById('wb-presence');
let presenceSnapshot = []; // last payload, retained for re-renders
function colorForUserID(userID) {
// Warm-palette only. Deterministic hash of user_id → hue 18°..38°.
let h = 0;
for (let i = 0; i < userID.length; i++) h = (h * 31 + userID.charCodeAt(i)) >>> 0;
const hue = 18 + (h % 21);
return { bg: `hsl(${hue}, 80%, 88%)`, border: `hsl(${hue}, 70%, 35%)`, text: `hsl(${hue}, 70%, 25%)` };
}
function initialsFor(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
return parts.slice(0, 2).map(p => p[0].toUpperCase()).join('');
}
// Replaces the Task 16 forward stub. #6's topic.created / topic.discarded /
// topic.incorporated events carry only an opaque actor user id, never a
// display name — the presence.updated snapshot is the sole name source.
// Best-effort: an actor who acted then closed their tab (or acted on a
// different Document) won't be in this Source's snapshot; "Someone" is the
// accepted fallback per spec.
function displayNameForUser(userID) {
if (!userID) return 'Someone';
for (const s of presenceSnapshot) {
if (s.user_id === userID && s.display_name) return s.display_name;
}
return 'Someone';
}
// Payload shape, pinned to merged #6 (internal/realtime/hub.go,
// PresencePayload/PresenceEntry):
// { subscriptions: [ { subscriber_id, user_id, display_name, focused_topic_id? }, ... ] }
// Each entry represents one tab/subscription. Multiple entries with the
// same user_id are deduped into a single chip with the highest tab count.
// The field is `subscriptions` (confirmed against the merged hub — there is
// no `subscribers` variant; do not add speculative fallbacks).
function renderPresence(payload) {
if (!presenceMount) return;
presenceSnapshot = Array.isArray(payload?.subscriptions) ? payload.subscriptions : [];
const byUser = new Map();
for (const s of presenceSnapshot) {
if (!s.user_id || s.user_id === selfUserID) continue;
const prev = byUser.get(s.user_id) || { user_id: s.user_id, display_name: s.display_name, tabs: 0, focused_topic_id: '' };
prev.tabs += 1;
if (s.focused_topic_id) prev.focused_topic_id = s.focused_topic_id;
byUser.set(s.user_id, prev);
}
presenceMount.innerHTML = '';
for (const u of byUser.values()) {
const chip = document.createElement('span');
const c = colorForUserID(u.user_id);
chip.className = 'wb-chip';
chip.style.background = c.bg;
chip.style.borderColor = c.border;
chip.style.color = c.text;
chip.title = u.display_name + (u.tabs > 1 ? ' — ' + u.tabs + ' tabs' : '');
chip.textContent = initialsFor(u.display_name);
presenceMount.appendChild(chip);
}
renderPerCardReaders();
}
function renderPerCardReaders() {
if (!topicList) return;
// group subscribers by focused_topic_id
const byTopic = new Map();
for (const s of presenceSnapshot) {
if (!s.user_id || !s.focused_topic_id || s.user_id === selfUserID) continue;
const arr = byTopic.get(s.focused_topic_id) || [];
if (!arr.find(x => x.user_id === s.user_id)) arr.push({ user_id: s.user_id, display_name: s.display_name });
byTopic.set(s.focused_topic_id, arr);
}
topicList.querySelectorAll('.wb-topic-card').forEach(card => {
const slot = card.querySelector('.wb-topic-card__readers');
if (!slot) return;
slot.innerHTML = '';
const arr = byTopic.get(card.dataset.topicId) || [];
const shown = arr.slice(0, 3);
for (const u of shown) {
const chip = document.createElement('span');
const c = colorForUserID(u.user_id);
chip.className = 'wb-chip wb-chip--small';
chip.style.background = c.bg;
chip.style.borderColor = c.border;
chip.style.color = c.text;
chip.title = u.display_name;
chip.textContent = initialsFor(u.display_name);
slot.appendChild(chip);
}
if (arr.length > 3) {
const more = document.createElement('span');
more.className = 'wb-chip wb-chip--small wb-chip--more';
more.textContent = '+' + (arr.length - 3);
slot.appendChild(more);
}
});
}
Hook renderPerCardReaders to fire after every loadTopics. Modify loadTopics:
iframe.contentWindow.postMessage({
kind: 'anchor:topics-changed',
topics: topics
.filter(t => anchorObject(t.anchor).kind !== 'global')
.map(t => ({ id: t.id, topic_ids: [t.id] })),
}, location.origin);
renderPerCardReaders();
const toastStack = document.getElementById('wb-toast-stack');
const TOAST_AUTODISMISS_MS = 6000;
const TOAST_PAUSED_MS = 12000;
const TOAST_MAX_VISIBLE = 3;
function pushToast({ actorName, actorRest, text, secondary, userID, topicID }) {
if (!toastStack) return;
const node = document.createElement('div');
node.className = 'wb-toast';
if (userID) {
const chip = document.createElement('span');
const c = colorForUserID(userID);
chip.className = 'wb-chip wb-chip--small';
chip.style.background = c.bg;
chip.style.borderColor = c.border;
chip.style.color = c.text;
chip.textContent = userID ? initialsFor(actorName || userID) : '·';
node.appendChild(chip);
}
const body = document.createElement('div');
body.className = 'wb-toast__body';
if (actorName) {
const strong = document.createElement('strong');
strong.textContent = actorName;
body.appendChild(strong);
const rest = document.createTextNode(' ' + (actorRest || ''));
body.appendChild(rest);
} else {
body.textContent = text || '';
}
if (topicID) {
const a = document.createElement('a');
a.href = '#';
a.className = 'wb-toast__focus';
a.textContent = ' Focus';
a.addEventListener('click', (ev) => {
ev.preventDefault();
selectTopic(topicID);
});
body.appendChild(a);
}
if (secondary) {
const sec = document.createElement('p');
sec.className = 'wb-toast__secondary';
sec.textContent = secondary;
body.appendChild(sec);
}
node.appendChild(body);
const close = document.createElement('button');
close.type = 'button';
close.className = 'wb-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', () => dismissToast(node));
node.appendChild(close);
let timer = window.setTimeout(() => dismissToast(node), TOAST_AUTODISMISS_MS);
node.addEventListener('mouseenter', () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => dismissToast(node), TOAST_PAUSED_MS);
});
toastStack.appendChild(node);
// FIFO drop above max:
while (toastStack.children.length > TOAST_MAX_VISIBLE) {
toastStack.removeChild(toastStack.firstChild);
}
}
function dismissToast(node) {
if (node && node.parentNode) node.parentNode.removeChild(node);
}
function renderDeadDoc() {
if (!authenticated) return;
iframe.style.display = 'none';
if (topicSidebar) topicSidebar.style.display = 'none';
if (presenceMount) presenceMount.innerHTML = '';
let mount = document.getElementById('wb-dead-doc');
if (mount) return;
mount = document.createElement('div');
mount.id = 'wb-dead-doc';
mount.className = 'wb-dead-doc';
mount.innerHTML = `
<h3>This document no longer exists.</h3>
<p>It was removed from the repository. The Topics that existed for it are no longer reachable through this URL.</p>
<p><a class="wb-btn" href="/">Back to index</a></p>
`;
document.querySelector('.wb-main').appendChild(mount);
}
function clearDeadDoc() {
const mount = document.getElementById('wb-dead-doc');
if (mount) mount.remove();
iframe.style.display = '';
if (topicSidebar) topicSidebar.style.display = '';
}
Modify the iframe load handler to clear dead-doc state on navigation:
iframe.addEventListener('load', () => {
clearDeadDoc();
if (resolveState) exitResolveMode();
// ... rest unchanged ...
/* ─── presence chips ──────────────────────────────────────────────────── */
.wb-presence {
display: flex;
gap: 4px;
align-items: center;
margin-left: 8px;
}
.wb-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--wb-rule);
background: var(--wb-accent-bg);
color: var(--wb-accent);
font-size: 11px;
font-weight: 700;
}
.wb-chip--small { width: 18px; height: 18px; font-size: 9px; }
.wb-chip--small + .wb-chip--small { margin-left: -6px; }
.wb-chip--more {
background: var(--wb-rule);
color: var(--wb-muted);
border-color: var(--wb-muted);
}
/* ─── toasts ──────────────────────────────────────────────────────────── */
.wb-toast-stack {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
width: 320px;
pointer-events: none;
}
.wb-toast {
background: var(--wb-surface);
border: 1px solid var(--wb-rule);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(28, 25, 23, .16);
padding: 10px 12px;
font-size: 12.5px;
display: flex;
align-items: flex-start;
gap: 10px;
pointer-events: auto;
}
.wb-toast__body { flex: 1; }
.wb-toast__focus { color: var(--wb-accent); text-decoration: underline; cursor: pointer; }
.wb-toast__secondary { margin: 4px 0 0; color: var(--wb-muted); font-size: 11.5px; }
.wb-toast__close {
background: transparent;
border: 0;
color: var(--wb-muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
/* ─── dead-doc ────────────────────────────────────────────────────────── */
.wb-dead-doc {
position: absolute;
inset: 0;
background: var(--wb-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--wb-muted);
}
.wb-dead-doc h3 {
margin: 0 0 8px;
color: var(--wb-text);
font-family: 'Source Serif 4', Georgia, serif;
}
.wb-dead-doc a.wb-btn {
text-decoration: none;
display: inline-block;
margin-top: 12px;
}
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
# Fake presence:
playwright-cli eval "() => window.wbInternal.onPresenceUpdated({subscriptions: [{subscriber_id: 'a', user_id: 'u1', display_name: 'Max', focused_topic_id: ''}, {subscriber_id: 'b', user_id: 'u1', display_name: 'Max', focused_topic_id: ''}, {subscriber_id: 'c', user_id: 'u2', display_name: 'Daniel', focused_topic_id: ''}]})"
playwright-cli screenshot --filename=/tmp/wb-presence.png
# Fake a toast. created_by 'u2' resolves to 'Daniel' via displayNameForUser
# because the presence snapshot seeded above contains u2 → Daniel. #6 events
# do NOT carry a display name, so this is the real resolution path.
playwright-cli eval "() => window.wbInternal.onTopicCreated({topic_id: 't1', created_by: 'u2'})"
playwright-cli screenshot --filename=/tmp/wb-toast.png
# Fake dead-doc:
playwright-cli eval "() => window.wbInternal.renderDeadDoc ? window.wbInternal.renderDeadDoc() : null"
Since renderDeadDoc isn't exposed on wbInternal yet, add it. Extend the existing assignment:
window.wbInternal = Object.assign(window.wbInternal || {}, {
renderDeadDoc, clearDeadDoc, pushToast, renderPresence,
});
Re-run the dead-doc verification:
playwright-cli eval "() => window.wbInternal.renderDeadDoc()"
playwright-cli screenshot --filename=/tmp/wb-dead-doc.png
playwright-cli eval "() => window.wbInternal.clearDeadDoc()"
Read all four screenshots.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: realtime — presence chips, toast queue, dead-doc state"
Files:
internal/server/static/chrome.cssinternal/server/static/chrome.jsMobile (≤768px) constraints from the spec:
Browse mode unchanged. Authenticated users get a second hamburger on the right side of the topbar opening the Topic sidebar as overlay.
Margin glyphs dropped (Task 6 already drops them at ≤640px). Confirm the breakpoint matches.
Resolve mode (≤700px): toolbar reflows to one row of icons; no Approve button; subject/body editor not shown. Rewrite + Discard remain in the thread.
Tier 2 on mobile collapses to a tab strip; one iframe at a time.
Tier 1 mobile renders unified diff in a horizontally-scrollable <pre> (already scrollable; verify).
Toasts stack from the bottom, max 2.
Step 1: Re-purpose wb-right-toggle as the mobile right-side hamburger
The existing #wb-right-toggle is used by Task 3 only when the right sidebar auto-collapses (Resolve + Tier 2). On mobile, the right sidebar is already overlay-only (CSS display: none in the existing chrome.css at @media (max-width: 900px)). The mobile right-hamburger needs to be unconditionally visible (for authenticated users) at mobile widths, regardless of auto-collapse state.
Update applyRightSidebar to handle mobile differently:
function applyRightSidebar() {
if (mobileMQ.matches) {
shell.classList.remove('wb-shell--right-rail');
// On mobile, the toggle button is always visible for authenticated users.
if (rightToggleBtn) rightToggleBtn.hidden = !authenticated;
shell.classList.toggle('wb-shell--right-overlay', rightSidebarOverlay);
return;
}
// ... desktop code unchanged ...
Append to chrome.css:
/* ─── mobile: right-side hamburger + topic-sidebar overlay ────────────── */
@media (max-width: 768px) {
/* Show the right toggle for authenticated users; the existing display:none
on .wb-topic-sidebar covers the default; the overlay state pops it. */
.wb-shell--right-overlay .wb-topic-sidebar {
display: block;
position: fixed;
top: var(--wb-topbar-h);
right: 0;
bottom: 0;
width: min(85vw, 360px);
z-index: 25;
overflow: auto;
box-shadow: var(--wb-shadow-2);
padding: 12px;
}
/* Toast stack: max 2 visible, slightly smaller. */
.wb-toast-stack { width: calc(100vw - 24px); right: 12px; bottom: 12px; }
}
/* ─── mobile: Resolve mode reflows (≤700px) ───────────────────────────── */
@media (max-width: 700px) {
.wb-resolve__toolbar {
flex-wrap: wrap;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
}
.wb-resolve__toolbar [data-resolve-approve],
.wb-resolve__toolbar .wb-resolve__count {
display: none;
}
.wb-approve { display: none; } /* approve editor hidden on mobile */
/* Tier 2 → tabs */
.wb-tier2 {
display: flex;
flex-direction: column;
}
.wb-tier2__pane { display: none; }
.wb-tier2__pane.is-active { display: flex; }
.wb-tier2__tabs {
display: flex;
border-bottom: 1px solid var(--wb-rule);
background: var(--wb-bg);
}
.wb-tier2__tab {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 11.5px;
color: var(--wb-muted);
border-bottom: 2px solid transparent;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.wb-tier2__tab.is-on {
color: var(--wb-accent);
border-bottom-color: var(--wb-accent);
font-weight: 600;
}
}
In renderResolveBody, modify the side-by-side branch to include a tab strip and add the is-active class to the active pane:
const tabStrip = `
<div class="wb-tier2__tabs">
<button type="button" class="wb-tier2__tab is-on" data-pane="current">Current</button>
<button type="button" class="wb-tier2__tab" data-pane="proposed">Proposed</button>
</div>
`;
body.innerHTML = `
${tabStrip}
<div class="wb-tier2">
<div class="wb-tier2__pane is-active" data-pane="current">
<h4>Current</h4>
<iframe class="wb-tier2__iframe" data-side="current" sandbox="allow-same-origin allow-scripts allow-popups allow-forms" src="${escapeHTMLAttr(currentSrc)}"></iframe>
</div>
<div class="wb-tier2__pane" data-pane="proposed">
<h4>Proposed</h4>
<iframe class="wb-tier2__iframe" data-side="proposed" sandbox="allow-same-origin allow-scripts allow-popups allow-forms" src="${escapeHTMLAttr(previewSrc)}"></iframe>
</div>
</div>
`;
body.querySelectorAll('.wb-tier2__tab').forEach(tab => {
tab.addEventListener('click', () => {
body.querySelectorAll('.wb-tier2__tab').forEach(t => t.classList.toggle('is-on', t === tab));
const which = tab.dataset.pane;
body.querySelectorAll('.wb-tier2__pane').forEach(p => p.classList.toggle('is-active', p.dataset.pane === which));
});
});
Hide the tab strip on desktop:
.wb-tier2__tabs { display: none; }
@media (max-width: 700px) {
.wb-tier2__tabs { display: flex; }
}
(Add this to the section above, or merge into the existing mobile block.)
In pushToast, after the existing while (toastStack.children.length > TOAST_MAX_VISIBLE) block, replace the constant lookup with a viewport-aware one:
const max = mobileMQ.matches ? 2 : TOAST_MAX_VISIBLE;
while (toastStack.children.length > max) {
toastStack.removeChild(toastStack.firstChild);
}
(Remove the prior TOAST_MAX_VISIBLE hardcoded loop and use this single block.)
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli reload
playwright-cli resize 375 812
playwright-cli screenshot --filename=/tmp/wb-mobile-browse.png
playwright-cli eval "() => document.getElementById('wb-right-toggle')?.hidden"
# Expected: false (visible for authenticated users on mobile).
playwright-cli eval "() => document.getElementById('wb-right-toggle')?.click()"
playwright-cli screenshot --filename=/tmp/wb-mobile-topic-overlay.png
# Resolve mobile through actual renderer (Review button or wbInternal dev hook
# that calls enterResolveMode/renderResolveBody; no direct innerHTML injection):
playwright-cli eval "() => document.querySelector('.wb-agent-msg button')?.click()"
# Tier 2 tabs:
playwright-cli screenshot --filename=/tmp/wb-mobile-tier2-tabs.png
Read each screenshot. Verify: glyph layer hidden on mobile (confirmed by inspecting .wb-glyph-layer hidden attr).
Also verify Tier-1 mobile horizontal scroll. Inject a wide synthetic diff:
playwright-cli eval "() => {
const m = document.getElementById('wb-resolve-mount');
m.hidden = false;
document.getElementById('wb-content').style.display = 'none';
document.querySelector('.wb-shell').dataset.resolveMode = '1';
document.querySelector('.wb-shell').dataset.diffTier = 'unified';
const longLine = '+' + 'lorem '.repeat(40);
m.innerHTML = '<div class=\"wb-resolve__toolbar\"><button class=\"wb-btn\">×</button></div><div class=\"wb-resolve__body\" data-tier=\"unified\"><pre class=\"wb-diff\"><span class=\"wb-diff__line wb-diff__line--hunk\">@@ -1 +1 @@</span>\\n<span class=\"wb-diff__line wb-diff__line--add\">' + longLine + '</span></pre></div>';
}"
playwright-cli screenshot --filename=/tmp/wb-mobile-tier1-scroll.png
playwright-cli eval "() => {
const pre = document.querySelector('.wb-diff');
return { scrollWidth: pre.scrollWidth, clientWidth: pre.clientWidth, hasHScroll: pre.scrollWidth > pre.clientWidth };
}"
Expected: hasHScroll: true. The screenshot should show the diff content clipped at the viewport right edge with no body-level horizontal scrollbar — the <pre> itself scrolls.
git add internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "wiki-browser: mobile — right hamburger, resolve-mode reflow, tier-2 tabs, toast cap"
Files:
Final pass — confirm the binary builds clean, no console errors at first authenticated load, every flow exercises end-to-end, and Go tests stay green.
go vet ./...
go test ./internal/server/... -count=1
if [ -d internal/realtime ]; then go test ./internal/realtime/... -count=1; else echo "internal/realtime missing — land #6 before final UI verification" >&2; exit 1; fi
go test ./... -count=1
Expected: all pass. If internal/realtime is missing or failing, this UI integration is blocked until #6 lands; do not mask the failure.
make build
ls -lh dist/wiki-browser
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
sleep 1
curl -sf http://localhost:8080/healthz
Expected: binary present, server starts, /healthz returns 2xx.
playwright-cli close-all || true
playwright-cli open --browser=chromium http://localhost:8080/
playwright-cli resize 1440 900
playwright-cli screenshot --filename=/tmp/wb-final-anonymous.png
playwright-cli eval "() => { return Array.from(document.body.attributes).map(a => a.name + '=' + a.value); }"
# Sign in (dev login):
playwright-cli open --browser=chromium http://localhost:8080/auth/dev/login
playwright-cli open --browser=chromium http://localhost:8080/doc/README
playwright-cli screenshot --filename=/tmp/wb-final-authenticated.png
Read screenshots; verify no visual regression on either side. Tail server log:
tail -n 50 /tmp/wb.log | grep -iE 'error|panic|fail' || echo "log clean"
Expected: log clean.
Walk through the spec's table-of-contents in order:
For any flow that requires server-side state not yet wired in the local dev server (e.g., live SSE events without #6 fully landed), stop and land the prerequisite first. Do not mark the UI integration complete or hide the gap in a commit message.
iframe.load handler matches this canonical orderingTasks 12, 15, 17, and 18 each amend the existing iframe.load handler. Subagent-driven execution can produce confused ordering. After all preceding tasks land, the handler in chrome.js MUST match this exact shape — verify and fix if it drifted:
iframe.addEventListener('load', () => {
// 1. Clear any leftover dead-doc UI from a previous navigation.
clearDeadDoc();
// 2. Resolve mode is tied to a specific proposal — exit on any nav.
if (resolveState) exitResolveMode();
// 3. Maintain history alignment (existing behavior — do not move).
const docPath = pathFromIframeURL(iframe.contentWindow.location.href);
if (suppressNextLoad) {
suppressNextLoad = false;
} else if (location.pathname !== docPath) {
history.pushState({}, '', docPath);
}
try {
document.title = iframe.contentDocument.title || document.title;
} catch (_) { /* cross-doc race during very fast nav */ }
updateAriaCurrent(docPath.replace(/^\/doc\//, ''));
// 4. Authenticated-only init for the new document.
if (authenticated) {
resetTopicUI();
loadTopics();
// 5. Open the new SSE stream BEFORE clearing focus, so the focus
// POST in step 6 targets the new subscriber. (closeEventSource
// inside openEventSource closes the prior stream cleanly; the
// server-side hub clears the prior focus when the prior
// EventSource closes — no race.)
startRealtimeForCurrentDocument();
// 6. Clear any pending focus from the prior document. The new
// subscriber starts with no focus until the user picks a Topic.
scheduleFocus('');
}
});
Run a quick diff against the actual chrome.js:
grep -A 30 "iframe.addEventListener('load'" internal/server/static/chrome.js
Manually verify the ordering matches steps 1–6 above. If anything is out of order (especially the relative order of startRealtimeForCurrentDocument() vs scheduleFocus('')), fix it.
git add -u
git status
# If anything was tweaked during the walkthrough:
git commit -m "wiki-browser: ui-integration — verify end-to-end, console-clean at first load"
# Otherwise:
echo "no changes — walkthrough clean"
A self-test for the plan author. Each spec section must map to at least one task above.
| Spec section | Task(s) |
|---|---|
| Purpose | Plan header (deferred Perspectives) |
| Vocabulary delta — Resolve mode, Rail, Margin glyph | Tasks 12, 2/3, 6 |
| Shell — Topbar (presence chips, right-sidebar toggle) | Tasks 1, 3, 18 |
| Shell — Sidebar state model (3 states) | Tasks 2, 3 |
| Shell — Sidebar toggle/auto-collapse table | Tasks 2, 3 |
| Shell — localStorage tolerance | Tasks 2, 12 |
| Shell — Iframe slot modes | Task 12 |
| Topic affordances — Topic card (subheaders, dot, readers, button restyle) | Task 4 |
| Topic affordances — Anchors (tint, glyph) | Tasks 5, 6 |
| Topic affordances — Effective-width fallback | Task 6 |
| Topic affordances — Wide-child overflow | Task 6 |
| Topic affordances — Bidirectional nav table | Task 7 |
Topic affordances — anchor:scroll kind |
Tasks 5, 7 |
| Topic affordances — Selection composer | Task 8 |
| Resolve — Thread header CTAs | Task 9 |
| Resolve — Rewrite confirm | Task 9 |
| Resolve — Discard confirm | Task 10 |
| Resolve — Compose-and-propose | Task 10 |
| Resolve — Agent-proposal messages (3 variants) | Task 11 |
| Resolve — Resolve mode shell | Task 12 |
| Resolve — Tier 1 / Tier 2 | Task 13 |
| Resolve — Stale/superseded banner | Task 14 |
| Resolve — Approve inline editor + 409 | Task 14 |
| Resolve — Lifecycle summary table | Tasks 12, 16 |
| Realtime — Lifecycle (7 steps) | Tasks 15, 17 |
| Realtime — Event handlers table | Task 16 |
| Realtime — Focus protocol | Task 17 |
| Realtime — Presence chips (topbar + per-card) | Task 18 |
| Realtime — Toast policy | Tasks 16, 18 |
| Realtime — New-messages pill | Task 16 |
| Realtime — Dead-doc state | Tasks 15, 18 |
| Realtime — Polling-loop removal | Tasks 15, 16 (the bootstrap + job.updated handler is the replacement; no separate removal — there is no existing JS polling code in chrome.js today, so nothing to remove) |
| Mobile | Task 19 |
| HTTP and SSE surface (recap) | All tasks (no new endpoints; anchor:scroll covered in Tasks 5, 7) |
| Implementation surface | All tasks (client assets/templates plus SelfUserID template-data plumbing in embed.go / handler_doc.go) |
| Cross-cutting open questions — Tier-1 rendering | Task 13 |
| Cross-cutting open questions — Glyph reposition on resize | Task 6 |
| Cross-cutting open questions — localStorage namespace | Tasks 2, 12 |
| Forward-compatibility — Topic-card select slot | Task 4 |
| Forward-compatibility — "1 Topic" label | Task 12 |
| Out of scope — Perspectives | (deliberately deferred) |
Plan complete and saved to docs/superpowers/plans/2026-05-15-wiki-browser-ui-integration-implementation.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints for review.
Which approach?