From 6451f9b7f9d430ca973b2fb44869b9f5cd420ec3 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 22:10:06 +0900 Subject: [PATCH 1/2] feat(space): add Board view and avoid unchanged Team geometry writes --- CHANGELOG.md | 4 + README.md | 8 +- scripts/ui-check-space-board.mjs | 137 +++++++++++++++++++++++++++++++ scripts/ui-check-space-team.mjs | 22 +++-- scripts/ui-check.mjs | 2 + shell/shell.css | 21 +++++ shell/space-board.js | 95 +++++++++++++++++++++ shell/space.js | 20 +++-- src/server.ts | 2 +- src/static.ts | 2 +- test/space-board.test.ts | 95 +++++++++++++++++++++ test/space-server.test.ts | 1 + test/space-team.test.ts | 19 ++++- test/static.test.ts | 2 +- 14 files changed, 405 insertions(+), 25 deletions(-) create mode 100644 scripts/ui-check-space-board.mjs create mode 100644 shell/space-board.js create mode 100644 test/space-board.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3580a0..478a3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Added +- Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101). + - Coalesce identical snapshots across viewers in a bounded in-memory LRU cache with per-panel TTL overrides, stale-while-revalidate, action/reload invalidation and separate principal keys. Declared readOnly principal tools preserve the cache; other principal actions and all console actions invalidate it. Keep producer bodies and error snapshots unchanged, expose freshness only through cache/age/sample-time headers and add System cache/provider timing diagnostics, and add a private-output snapshot timing probe (#105). - Add the Space Flow view with per-project actors, directed 15-minute message counts, distinct relationship strokes and recent activity. Keep at most 200 public link events for 30 minutes in page memory, and show per-peer counts and the last 10 events in agent detail. Suspend the minute refresh while Flow is hidden (#101). @@ -23,6 +25,8 @@ ### Fixed +- Restrict geometry-forced Space updates to Room: Team, Flow and Board use CSS layout and no longer rewrite unchanged root controls on ResizeObserver notifications. Wait for both fixture producers before measuring Team polls; remove its CSP-incompatible predicate waits (#101). + - Honor `changed: false` panel replies without notifying listeners or rerendering, while updating the conditional cursor and retaining legacy full-snapshot deduplication (#63). - Safely release queued diagram visibility callbacks after frame handover or a visible theme redraw, without dereferencing a cleared observer (#90). diff --git a/README.md b/README.md index 0841661..cf0967d 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,15 @@ Space is an optional area in the rail, shown when a ready plugin declares `capab The area uses `#/local/space`; if a plugin already owns the tool id `space`, the area's link uses `#/local/~space` so that plugin's existing URLs keep working. -The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=`; Team is the default, while a saved view choice in `acon.space.section` wins when the route omits `view`. The header scrolls with the page. Actor detail uses `&actor=`. Board remains a placeholder for its later delivery. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters. +The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=`; Team is the default, while a saved view choice in `acon.space.section` wins when the route omits `view`. The header scrolls with the page. Actor detail uses `&actor=`. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters. Team puts blocked, waiting and approval-holding actors first under Needs attention, then shows each group with agent and active counts and shortcuts to that group's Board and Flow. Working, waiting and blocked actors appear as tiles with their avatar, roles, state, current task, inbox and approval counts. Idle and away actors collapse into one expandable line per group. Team is the text alternative; the separate actor/zone/link roster remains available in Room. Flow shows one compact actor panel per project with directed message counts over 15 minutes and labeled overlap, conflict and cohort connections. Recent activity sits beside the panels at 1024 px and wider and below them on narrow screens. History starts when the page opens: the shell retains up to 200 observed public link events for 30 minutes, without requesting or storing message bodies. New activity highlights once; reduced motion disables the animation. -Selecting an actor in Team, Flow or Room opens its detail using the namespaced actor route. At 1024 px and wider, detail is a side sheet; below 1024 px, it is a full page. Escape or Back returns to the prior view and restores its scroll position and focus. Detail includes the actor's current task and producer link, marks, and current overlap/conflict/cohort partners. Its Interactions section shows messages sent and received per peer over the retained history and the actor's last 10 observed events. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step. +Board shows each group's work as columns from the producer's zone kinds and titles, with the reported card titles, tones and owner avatars/names. Cards open their existing producer links; owner buttons open agent detail. Done shows its count and the last five cards in supplied order, with earlier cards behind a disclosure. Each row scrolls within its own keyboard-focusable region on narrow screens, keeping the page width fixed. Project and state filters apply to Board, including ungrouped work. + +Selecting an actor in Team, Flow, Board or Room opens its detail using the namespaced actor route. At 1024 px and wider, detail is a side sheet; below 1024 px, it is a full page. Escape or Back returns to the prior view and restores its scroll position and focus. Detail includes the actor's current task and producer link, marks, and current overlap/conflict/cohort partners. Its Interactions section shows messages sent and received per peer over the retained history and the actor's last 10 observed events. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step. Room contains the existing Flat, Room and, when WebGL2 is available, 3D scene controls; their sub-toggle persists separately as `acon.space.roomView`. Flat keeps the normal layout. Room uses CSS 3D floor and wall planes with upright actor/card buttons, without downloading Three.js. Rotate left/right changes yaw by 90 degrees; zoom has three steps. Focus the scene and use Left/Right or +/- for the same controls. Reduced motion removes camera transitions. @@ -297,7 +299,7 @@ Versions and upgrades (#26): Snapshot reuse (#105): the server shares an in-process cache between viewers, keyed by tool, provider and canonical snapshot input. Identical concurrent requests share one provider call. The default TTL is `min(pollMs / 2, 2000)` ms (`pollMs` defaults to 5000). A plugin can set `panels[].maxAgeMs`, `capabilities.inboxMaxAgeMs` or `capabilities.spaceMaxAgeMs` to a finite value from 0 to 60000 ms; 0 disables reuse while preserving concurrent-call coalescing. An explicitly declared panel owns its TTL even when its id is `space`; capability TTLs apply only to undeclared reserved panels with the corresponding capability enabled. The fields are additive API 1 options, and conformance checks their bounds. -During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. Producer snapshot bodies, including their own `stale` and `sampledAt` fields, stay unchanged. A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node. +During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. The cache does not overwrite producer fields: ordinary panel bodies retain producer-owned `stale` and `sampledAt`; principal ports retain their existing public-value projection, and successful Space replies retain their existing allowlisted scene projection (which omits producer top-level `stale` and `sampledAt`). A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node. The cache holds at most 256 entries and 4 MiB, evicts least recently used entries, and writes nothing to disk. System > Snapshot cache shows entry/byte totals and hit, miss and stale counts by tool, panel and namespace. Its p50/p95 provider durations use a rolling window of the last 128 calls; cache hits do not add provider timing samples. Older nodes without these diagnostics remain usable. diff --git a/scripts/ui-check-space-board.mjs b/scripts/ui-check-space-board.mjs new file mode 100644 index 0000000..9f5371b --- /dev/null +++ b/scripts/ui-check-space-board.mjs @@ -0,0 +1,137 @@ +// #101 Delivery 4: per-project Board, Done disclosure and producer-card navigation. +import assert from "node:assert/strict"; +import { join } from "node:path"; + +function isSpaceResponse(response, tool) { + if (new URL(response.url()).pathname !== `/p/${tool}/snapshot` || response.request().method() !== "POST") return false; + try { return response.request().postDataJSON()?.panel === "space"; } catch { return false; } +} + +async function settle(page) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); +} + +async function installDoneFixture(page) { + // This context adds public synthetic work items only; each producer link targets an existing fixture note. + await page.route("**/p/demo/snapshot", async (route) => { + let input; + try { input = route.request().postDataJSON(); } catch { await route.continue(); return; } + if (input?.panel !== "space") { await route.continue(); return; } + const response = await route.fetch(); + const body = await response.json(); + if (body.scene) { + const zone = body.scene.zones.find((item) => item.kind === "done" && item.group === "delivery"); + assert(zone, "Board fixture has no delivery Done zone"); + body.scene.cards = body.scene.cards.filter((card) => card.zone !== zone.id); + const tones = ["ok", "warn", "err"]; + for (let i = 0; i < 7; i++) body.scene.cards.push({ + id: `board-done-${i}`, title: `Board completed task ${i + 1}`, zone: zone.id, group: "delivery", owner: "claude", tone: tones[i % tones.length], + link: { panel: "notes", params: { id: "n1" } }, + }); + zone.count = 7; + } + await route.fulfill({ response, json: body }); + }); +} + +async function enterBoard(page, ticket, textPercent) { + await page.goto((await ticket()).url); + await page.evaluate((size) => { + document.documentElement.style.fontSize = `${size}%`; + location.hash = "#/local/space?view=board"; + }, textPercent); + await page.locator(".space-board:not([hidden])").waitFor(); + // Both producers must have reached the merged model before an unchanged-poll measurement starts. + await page.locator('.space-board [data-board-card="demo:board-done-6"]').waitFor(); + await page.locator('.space-board [data-board-card="echo:n1"]').waitFor({ state: "attached" }); + await settle(page); +} + +async function unchangedWrites(page) { + await page.evaluate(() => { + globalThis.boardProbeWrites = 0; + globalThis.boardProbeObserver = new MutationObserver((records) => { globalThis.boardProbeWrites += records.length; }); + globalThis.boardProbeObserver.observe(document.querySelector("#view-space"), { subtree: true, attributes: true, childList: true, characterData: true }); + }); + const responses = await Promise.all(["demo", "echo"].map((tool) => page.waitForResponse((response) => isSpaceResponse(response, tool), { timeout: 20000 }))); + await Promise.all(responses.map((response) => response.finished())); + await settle(page); + const writes = await page.evaluate(() => { + globalThis.boardProbeObserver.disconnect(); + return globalThis.boardProbeWrites; + }); + assert.equal(writes, 0, `Unchanged Board poll wrote ${writes} DOM mutations`); + return writes; +} + +async function fitsPage(page, label) { + assert(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), `${label} caused horizontal page scroll`); +} + +async function checkDone(page) { + const done = page.locator('.space-board-group[data-board-group="demo:delivery"] .space-board-column[data-board-kind="done"]'); + const details = done.locator("details"); + const summary = details.locator("summary"); + await summary.waitFor(); + assert.equal(await details.evaluate((node) => node.open), false, "Done was not collapsed initially"); + assert.match(await done.innerText(), /\b7\b/, "Done does not show the total count"); + assert.equal(await done.locator("[data-board-card]:visible").count(), 5, "Collapsed Done must show its last five cards"); + const lastFive = await done.locator("[data-board-card]:visible").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-board-card"))); + assert.deepEqual(lastFive.sort(), Array.from({ length: 5 }, (_, i) => `demo:board-done-${i + 2}`), "Collapsed Done does not show the last five cards"); + assert(await summary.getAttribute("data-board-focus"), "Done summary has no stable focus key"); + await summary.focus(); + await page.keyboard.press("Enter"); + await done.locator('[data-board-card="demo:board-done-0"]').waitFor(); + assert.equal(await details.evaluate((node) => node.open), true, "Keyboard did not expand Done"); + assert.equal(await done.locator("[data-board-card]:visible").count(), 7, "Expanded Done does not show all cards"); + await fitsPage(page, "Expanded Board Done"); + await summary.click(); + await done.locator('[data-board-card="demo:board-done-0"]').waitFor({ state: "hidden" }); + return { doneCount: 7, collapsedCards: 5, expandedCards: 7 }; +} + +async function keyboardProducer(page) { + const card = page.locator('.space-board [data-board-card="demo:board-done-6"]'); + assert(await card.getAttribute("data-board-focus"), "Board card has no stable focus key"); + assert((await card.evaluate((node) => node.closest(".space-board-item")?.textContent ?? "")).includes("claude"), "Board card item does not show its owner name"); + await card.focus(); + await page.keyboard.press("Enter"); + await page.waitForURL((url) => { + const [route, query] = url.hash.split("?"); + return route === "#/local/demo/notes" && new URLSearchParams(query).get("id") === "n1"; + }); + await page.locator(".space-board").waitFor({ state: "hidden" }); + await page.goBack(); + await page.locator(".space-board:not([hidden])").waitFor(); + return { keyboardProducer: true, producerLink: "demo/notes?id=n1" }; +} + +export async function runSpaceBoard({ browser, ticket, report, output }) { + report.spaceBoard = []; + for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) { + // Locator waits and evaluations run under the shell CSP; no bypass or waitForFunction is needed. + const context = await browser.newContext({ viewport: { width, height: 1000 }, colorScheme: theme, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(12000); + try { + await installDoneFixture(page); + await enterBoard(page, ticket, textPercent); + assert(await page.locator(".space-board-group").count() >= 2, "Board has no per-project groups"); + const writes = await unchangedWrites(page); + await fitsPage(page, "Board"); + const done = await checkDone(page); + const scrollers = await page.locator(".space-board-columns").evaluateAll((nodes) => nodes.map((node) => ({ + width: node.clientWidth, content: node.scrollWidth, overflow: getComputedStyle(node).overflowX, + }))); + assert(scrollers.length > 0, "Board has no column scroll regions"); + assert(scrollers.every((scroller) => scroller.content <= scroller.width + 1 || ["auto", "scroll"].includes(scroller.overflow)), "Overflowing Board columns are not locally scrollable"); + const screenshot = `space-board-${width}-${theme}-${textPercent}.png`; + await page.screenshot({ path: join(output, screenshot), fullPage: true }); + report.captures.push({ key: `space-board-${width}-${theme}-${textPercent}`, screenshot, width, theme, textPercent, view: "space-board" }); + const navigation = await keyboardProducer(page); + report.spaceBoard.push({ width, theme, textPercent, unchangedWrites: writes, ...done, ...navigation }); + } catch (error) { + report.findings.push({ kind: "space-board", width, theme, textPercent, detail: error.message }); + } finally { await context.close(); } + } +} diff --git a/scripts/ui-check-space-team.mjs b/scripts/ui-check-space-team.mjs index 6274a78..b9b5994 100644 --- a/scripts/ui-check-space-team.mjs +++ b/scripts/ui-check-space-team.mjs @@ -8,7 +8,9 @@ async function enterSpace(page, ticket, width, textPercent) { await page.evaluate((size) => { document.documentElement.style.fontSize = `${size}%`; localStorage.removeItem("acon.space.section"); location.hash = "#/local/space"; }, textPercent); await page.locator(".space-team").waitFor(); if (await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").has("view"))) throw new Error("Default Team route unexpectedly contains an explicit view"); - await page.locator("[data-space-actor]").first().waitFor(); + // Do not begin the unchanged-poll measurement until both fixture producers have rendered. + await page.locator('.space-team [data-space-actor="demo:claude"]').waitFor(); + await page.locator('.space-team [data-space-actor="echo:claude"]').waitFor(); } async function unchangedWrites(page, mode) { @@ -17,11 +19,13 @@ async function unchangedWrites(page, mode) { globalThis.teamProbeObserver = new MutationObserver((records) => { globalThis.teamProbeWrites += records.length; }); globalThis.teamProbeObserver.observe(document.querySelector("#view-space"), { subtree: true, attributes: true, childList: true, characterData: true }); }); - const response = await page.waitForResponse((item) => { - if (new URL(item.url()).pathname !== "/p/demo/snapshot" || item.request().method() !== "POST") return false; + // A merged Space update waits on both tools; waiting only for demo can stop the + // observer before echo's reply is rendered or count its initial scene instead. + const responses = await Promise.all(["demo", "echo"].map((tool) => page.waitForResponse((item) => { + if (new URL(item.url()).pathname !== `/p/${tool}/snapshot` || item.request().method() !== "POST") return false; try { return item.request().postDataJSON()?.panel === "space"; } catch { return false; } - }, { timeout: 20000 }); - await response.finished(); + }, { timeout: 20000 }))); + await Promise.all(responses.map((response) => response.finished())); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); const writes = await page.evaluate(() => { globalThis.teamProbeObserver.disconnect(); return globalThis.teamProbeWrites; }); assert.equal(writes, 0, `Unchanged ${mode} poll wrote ${writes} DOM mutations`); @@ -57,7 +61,7 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { await actor.focus(); await page.keyboard.press("Enter"); await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); - await page.waitForFunction((key) => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor") === key, actorKey); + assert.equal(await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor")), actorKey, "Detail route selected the wrong actor"); const detailWrites = await unchangedWrites(page, "detail"); assert(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), "Detail caused horizontal page scroll"); await page.keyboard.press("Escape"); @@ -88,7 +92,7 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { await page.locator(`.space-team [data-space-actor="${actorKey}"]`).first().click(); await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); await page.locator(".space-agent-detail [data-detail-focus='open']").click(); - await page.waitForFunction(() => !location.hash.includes("/space")); + await page.waitForURL((url) => !url.hash.includes("/space")); await page.goBack(); await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); await page.keyboard.press("Escape"); @@ -106,10 +110,10 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { assert.equal(await page.evaluate(() => Boolean(document.querySelector(".space-view-bar")?.closest("[inert]"))), true, "Room controls stay interactive behind the detail"); // Leaving for the producer panel and coming back puts focus inside the detail again. await page.locator(".space-agent-detail [data-detail-focus='open']").click(); - await page.waitForFunction(() => !location.hash.includes("/space")); + await page.waitForURL((url) => !url.hash.includes("/space")); await page.goBack(); await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); - await page.waitForFunction(() => document.querySelector(".space-agent-detail")?.contains(document.activeElement)); + await page.locator(".space-agent-detail :focus").waitFor(); await page.keyboard.press("Escape"); await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); } diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index a9b8973..59dd8d2 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -12,6 +12,7 @@ import { runLoginCheck } from "./ui-check-login.mjs"; import { runSpaceFoundation } from "./ui-check-space-foundation.mjs"; import { runSpaceTeam } from "./ui-check-space-team.mjs"; import { runSpaceFlow } from "./ui-check-space-flow.mjs"; +import { runSpaceBoard } from "./ui-check-space-board.mjs"; import { runStaticCheck } from "./ui-check-static.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -1256,6 +1257,7 @@ try { await runSpaceFoundation({ browser, ticket, report, output: options.output }); await runSpaceTeam({ browser, ticket, report, output: options.output }); await runSpaceFlow({ browser, ticket, report, output: options.output }); + await runSpaceBoard({ browser, ticket, report, output: options.output }); } if (!focused || options.visualOnly) await runVisualCheck({ browser, root, report, output: options.output, env, scratch }); if (!focused || options.loginOnly) await runLoginCheck({ browser, root, report, output: options.output, forceOverflow: options.forceOverflow, env, scratch }); diff --git a/shell/shell.css b/shell/shell.css index 7d04c2c..8bacf74 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -684,3 +684,24 @@ body.pip { margin: 0; overflow-y: auto; } @keyframes space-flow-arrival { from { background: var(--acon-accent-soft); } to { background: transparent; } } @media (prefers-reduced-motion: reduce) { .space-flow-events [data-new="true"] { animation: none; } } @media (min-width: 1024px) { .space-flow-layout { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } } + +/* Each Board row owns its horizontal scroll; the page remains fluid. */ +.space-board, .space-board-group { min-width: 0; max-width: 100%; } +.space-board-group { margin-bottom: var(--acon-space-5); } +.space-board-group > h2 { font-size: var(--acon-text-lg); overflow-wrap: anywhere; } +.space-board-columns { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(220px, 1fr); gap: var(--acon-space-3); overflow-x: auto; max-width: 100%; padding: var(--acon-space-1); align-items: start; } +.space-board-column { min-width: 0; padding: var(--acon-space-3); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-3); background: var(--acon-surface); } +.space-board-column > h3 { margin: 0 0 var(--acon-space-3); font-size: var(--acon-text-sm); overflow-wrap: anywhere; } +.space-board-item { min-width: 0; margin-bottom: var(--acon-space-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); background: var(--acon-surface-2); overflow-wrap: anywhere; } +.space-board-card, .space-board-owner { display: flex; width: 100%; min-height: 44px; min-width: 0; padding: var(--acon-space-2); border: 0; border-radius: var(--acon-radius-2); background: transparent; color: var(--acon-text); font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; } +.space-board-card { flex-direction: column; align-items: start; gap: var(--acon-space-2); } +.space-board-card strong { font-weight: 600; max-width: 100%; } +.space-board-tone { padding: var(--acon-space-1) var(--acon-space-2); border-radius: var(--acon-radius-1); font-size: var(--acon-text-xs); color: var(--acon-text-2); } +.space-board-item[data-tone="ok"] .space-board-tone { color: var(--acon-ok); background: var(--acon-ok-soft); } +.space-board-item[data-tone="warn"] .space-board-tone { color: var(--acon-warn); background: var(--acon-warn-soft); } +.space-board-item[data-tone="err"] .space-board-tone { color: var(--acon-err); background: var(--acon-err-soft); } +.space-board-owner { align-items: center; gap: var(--acon-space-2); font-size: var(--acon-text-sm); } +.space-board-owner > :last-child { min-width: 0; } +.space-board-unassigned { padding: 0 var(--acon-space-2); } +.space-board-done > summary { min-height: 44px; padding: var(--acon-space-2); cursor: pointer; overflow-wrap: anywhere; } +.space-board :is(button, summary, [tabindex]):focus-visible { outline: 2px solid var(--acon-focus); outline-offset: 2px; } diff --git a/shell/space-board.js b/shell/space-board.js new file mode 100644 index 0000000..3def296 --- /dev/null +++ b/shell/space-board.js @@ -0,0 +1,95 @@ +import { focusSpaceControl } from "./space-team.js"; + +function columnKind(zone) { + if ((!zone.kind || zone.kind === "queue") && /backlog/i.test(zone.title)) return "backlog"; + const title = zone.title.toLowerCase().trim(); + return zone.kind ?? ({ work: "work", review: "review", done: "done" }[title] || "queue"); +} +const columnOrder = { backlog: 0, queue: 1, work: 2, review: 3, done: 4 }; + +/** Partition cards by their public group without merging distinct producer zones. */ +export function boardGroups(model, group = "") { + const groups = model.groups.filter((item) => !group || item.id === group); + if (model.zones.some((zone) => !zone.group) || model.cards.some((card) => !card.group)) groups.push({ id: "", title: "Ungrouped" }); + const zones = new Map(model.zones.map((zone) => [zone.id, zone])); + return groups.map((item) => { + const cards = model.cards.filter((card) => (card.group ?? "") === item.id); + const columns = new Map(model.zones.filter((zone) => (zone.group ?? "") === item.id).map((zone) => [zone.id, { ...zone, cards: [] }])); + for (const card of cards) { + if (!columns.has(card.zone)) { + const zone = zones.get(card.zone); + columns.set(card.zone, { ...(zone ?? { id: card.zone, title: "Unreported column" }), cards: [] }); + } + columns.get(card.zone).cards.push(card); + } + return { ...item, columns: [...columns.values()].sort((a, b) => columnOrder[columnKind(a)] - columnOrder[columnKind(b)]) }; + }); +} + +/** CSS lays out Board; it has no timer, observer, polling or work while hidden. */ +export function createSpaceBoard(root, host) { + const { h } = host; + let signature = "", visible = false, destroyed = false; + + function cardElement(card, owners) { + const owner = owners.get(card.owner); + const tone = ["neutral", "ok", "warn", "err"].includes(card.tone) ? card.tone : "neutral"; + const article = h("article", { class: "space-board-item", "data-tone": tone }); + article.append(h("button", { type: "button", class: "space-board-card", "data-board-card": card.id, + "data-board-focus": `card:${card.id}`, onclick: () => host.navigate(card.link.tool, card.link.panel, card.link.params) }, + h("strong", { text: card.title }), h("span", { class: "space-board-tone", text: tone }))); + if (owner) article.append(h("button", { type: "button", class: "space-board-owner", "data-space-actor": owner.id, + "data-board-focus": `owner:${card.id}`, onclick: (event) => host.openActor(owner.id, event.currentTarget), + "aria-label": `Agent details: ${owner.name}` }, + h("span", { class: "space-team-avatar", "aria-hidden": "true" }, host.sprite(owner)), + h("span", { text: `${owner.name}${owner.stale ? " (stale)" : ""}` }))); + else article.append(h("p", { class: "sub space-board-unassigned", text: "Unassigned" })); + return article; + } + + function update(model, group = "", state = "") { + if (!visible || destroyed) return; + const next = JSON.stringify([group, state, model.groups, model.zones, model.cards, model.actors]); + if (next === signature) return; + signature = next; + const active = root.ownerDocument.activeElement; + const focusKey = root.contains(active) ? active?.getAttribute("data-board-focus") : null; + const opened = new Map([...root.querySelectorAll("details[data-board-done]")].map((node) => [node.getAttribute("data-board-done"), node.open])); + const scrolls = new Map([...root.querySelectorAll("[data-board-scroll]")].map((node) => [node.getAttribute("data-board-scroll"), node.scrollLeft])); + const owners = new Map(model.actors.map((actor) => [actor.id, actor])); + const fragment = root.ownerDocument.createDocumentFragment(); + for (const item of boardGroups(model, group)) { + const section = h("section", { class: "space-board-group", "data-board-group": item.id }, h("h2", { text: item.title })); + const columns = h("div", { class: "space-board-columns", role: "region", tabindex: "0", "aria-label": `${item.title} work columns, scroll to explore`, + "data-board-scroll": item.id, "data-board-focus": `columns:${item.id}` }); + for (const zone of item.columns) { + const key = JSON.stringify([item.id, zone.id]); + const kind = columnKind(zone); + const column = h("section", { class: "space-board-column", "data-board-kind": kind, "data-board-zone": zone.id }, + h("h3", { text: `${zone.title} (${zone.cards.length})` })); + if (kind === "done") { + for (const card of zone.cards.slice(-5)) column.append(cardElement(card, owners)); + if (zone.cards.length > 5) { + const details = h("details", { class: "space-board-done", "data-board-done": key }); + details.open = opened.get(key) ?? false; + details.append(h("summary", { "data-board-focus": `done:${key}`, text: `Show ${zone.cards.length - 5} earlier cards` })); + for (const card of zone.cards.slice(0, -5)) details.append(cardElement(card, owners)); + column.append(details); + } + } else for (const card of zone.cards) column.append(cardElement(card, owners)); + if (!zone.cards.length) column.append(h("p", { class: "sub", text: "No matching cards." })); + columns.append(column); + } + if (!item.columns.length) section.append(h("p", { class: "sub", text: "No work columns reported." })); + else section.append(columns); + fragment.append(section); + } + root.replaceChildren(fragment); + for (const node of root.querySelectorAll("[data-board-scroll]")) { + const left = scrolls.get(node.getAttribute("data-board-scroll")) ?? 0; + if (node.scrollLeft !== left) node.scrollLeft = left; + } + if (focusKey) focusSpaceControl(root.querySelector(`[data-board-focus="${CSS.escape(focusKey)}"]`), host.sectionTab()); + } + return { update, setVisible(on) { visible = on && !destroyed; }, destroy() { destroyed = true; visible = false; signature = ""; root.replaceChildren(); } }; +} diff --git a/shell/space.js b/shell/space.js index a4129d9..f359aa9 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,3 +1,4 @@ +import { createSpaceBoard } from "./space-board.js"; import { createActivityBuffer, createSpaceFlow, flowRelativeTime } from "./space-flow.js"; import { createSpaceTeam, focusSpaceControl } from "./space-team.js"; import { createSpaceViewController } from "./space-view.js"; @@ -124,12 +125,12 @@ export function createSpaceRenderer(root, host) { const project = h("select", { id: "space-project", "aria-label": "Project", onchange: () => choose({ group: project.value }) }); const header = h("header", { class: "space-header" }, h("h1", { class: "lead", id: "space-lead", text: "Space" }), summary, h("div", { class: "space-header-controls" }, tabs, h("label", { class: "space-project-label", for: "space-project" }, "Project", project))); + const boardRoot = h("section", { class: "space-board", hidden: true }); const flowRoot = h("section", { class: "space-flow", hidden: true }); const teamRoot = h("section", { class: "space-team", tabindex: "-1" }); const detailRoot = h("aside", { class: "space-agent-detail", role: "dialog", "aria-modal": "true", "aria-labelledby": "space-agent-title", tabindex: "-1", hidden: true }); - const placeholder = h("p", { class: "notice", hidden: true }); const content = h("div", { class: "space-content" }, scroll, roster); - root.replaceChildren(header, placeholder, notice, teamRoot, flowRoot, content, empty, detailRoot); + root.replaceChildren(header, notice, teamRoot, flowRoot, boardRoot, content, empty, detailRoot); let selection = { view: "team", group: "", state: "", actor: "" }; let savedView = "team"; let returnPoint = null, restorePoint = null, detailSignature = ""; @@ -177,9 +178,10 @@ export function createSpaceRenderer(root, host) { }; const activity = createActivityBuffer(); const flow = createSpaceFlow(flowRoot, { h, sprite, openActor, activity, sectionTab: () => sectionButtons.get(selection.view), onTime: () => { if (lastModel && !root.hidden) renderDetail(lastModel); } }); + const board = createSpaceBoard(boardRoot, { h, sprite, openActor, navigate: host.navigate, sectionTab: () => sectionButtons.get(selection.view) }); const team = createSpaceTeam(teamRoot, { h, sprite, openActor, choose, sectionTab: () => sectionButtons.get(selection.view) }); function openActor(id, element) { - if (!selection.actor) returnPoint = { element, focusKey: element?.getAttribute("data-team-focus") ?? element?.getAttribute("data-flow-focus"), actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, columnTop: root.closest(".column")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; + if (!selection.actor) returnPoint = { element, focusKey: element?.getAttribute("data-team-focus") ?? element?.getAttribute("data-flow-focus") ?? element?.getAttribute("data-board-focus"), actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, columnTop: root.closest(".column")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; choose({ actor: id }); } function closeActor() { if (returnPoint) history.back(); else choose({ actor: "" }); } @@ -242,7 +244,7 @@ export function createSpaceRenderer(root, host) { function restore() { if (!restorePoint) return; const point = restorePoint; restorePoint = null; - const keyed = point.focusKey ? root.querySelector(`[data-team-focus="${CSS.escape(point.focusKey)}"],[data-flow-focus="${CSS.escape(point.focusKey)}"]`) : null; + const keyed = point.focusKey ? root.querySelector(`[data-team-focus="${CSS.escape(point.focusKey)}"],[data-flow-focus="${CSS.escape(point.focusKey)}"],[data-board-focus="${CSS.escape(point.focusKey)}"]`) : null; const element = point.element?.isConnected ? point.element : keyed ?? (point.actor ? [...root.querySelectorAll("[data-space-actor],.space-actor,.space-roster-actor")].find((el) => el.dataset.spaceActor === point.actor || el.dataset.key === point.actor) : null); focusSpaceControl(element, sectionButtons.get(selection.view)); const main = root.closest("main"); if (main) main.scrollTop = point.mainTop; @@ -255,6 +257,7 @@ export function createSpaceRenderer(root, host) { lastModel = model; activity.observe(model); flow.setVisible(selection.view === "flow" && !root.hidden); + board.setVisible(selection.view === "board" && !root.hidden); const pack = host.packs()[0]; const { sampledAt, samples: receivedSamples, ...facts } = model; let sourceGap = false, firstSource = false; @@ -272,11 +275,10 @@ export function createSpaceRenderer(root, host) { root.dataset.spaceSection = selection.view; const room = selection.view === "room"; setHidden(content, !room); setHidden(teamRoot, selection.view !== "team"); - setHidden(flowRoot, selection.view !== "flow"); setHidden(placeholder, selection.view !== "board"); + setHidden(flowRoot, selection.view !== "flow"); setHidden(boardRoot, selection.view !== "board"); // A section switch (e.g. a Team Board/Flow shortcut) must not leave focus on a now-hidden control. const active = document.activeElement; if (active && active !== root && root.contains(active) && !detailRoot.contains(active) && active.offsetParent === null) sectionButtons.get(selection.view)?.focus({ preventScroll: true }); - setText(placeholder, `${selection.view[0].toUpperCase() + selection.view.slice(1)} view is not available yet. Room shows the current scene.`); for (const [id, button] of sectionButtons) button.setAttribute("aria-pressed", String(id === selection.view)); const grouped = filterSpaceModel(model, selection.group, ""); for (const [state, button] of summaryButtons) { @@ -294,6 +296,7 @@ export function createSpaceRenderer(root, host) { const unknownGroup = !model.pending && selection.group && !model.groups.some((g) => g.id === selection.group); renderDetail(model); model = filterSpaceModel(model, selection.group, selection.state); + if (selection.view === "board") board.update(model, selection.group, selection.state); if (selection.view === "flow") flow.update(model, selection.group, selection.state); if (selection.view === "team") team.update({ ...model, groups: selection.group ? model.groups.filter((g) => g.id === selection.group) : model.groups }); const hidden = Object.entries(model.dropped).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k} omitted`); @@ -405,8 +408,9 @@ export function createSpaceRenderer(root, host) { const next = `${root.clientWidth}:${getComputedStyle(root).fontSize}`; if (next === geometry) return; geometry = next; - if (lastModel && !root.hidden) update(lastModel, true); + // Team, Flow and Board use responsive CSS; only Room has measured scene geometry. + if (lastModel && !root.hidden && selection.view === "room") update(lastModel, true); }); observer.observe(root); - return { update, setRoute, enter, hide() { flow.setVisible(false); views?.suspend(); }, get viewStats() { return views?.stats ?? { view: "flat", renderCount: 0, pendingFrames: 0 }; }, destroy() { observer.disconnect(); document.removeEventListener("keydown", onDetailKey, true); team.destroy(); flow.destroy(); views?.destroy(); root.replaceChildren(); } }; + return { update, setRoute, enter, hide() { board.setVisible(false); flow.setVisible(false); views?.suspend(); }, get viewStats() { return views?.stats ?? { view: "flat", renderCount: 0, pendingFrames: 0 }; }, destroy() { observer.disconnect(); document.removeEventListener("keydown", onDetailKey, true); team.destroy(); flow.destroy(); board.destroy(); views?.destroy(); root.replaceChildren(); } }; } diff --git a/src/server.ts b/src/server.ts index 4088017..b4e1191 100644 --- a/src/server.ts +++ b/src/server.ts @@ -102,7 +102,7 @@ export function startServer(options: ServerOptions) { const log = options.log ?? ((line: string) => console.error(`acon: ${line}`)); const startedAt = new Date(now()).toISOString(); const serve = options.serve; - const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-flow.js": ["space-flow.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; + const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-board.js": ["space-board.js", "text/javascript; charset=utf-8"], "/space-flow.js": ["space-flow.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; const files: Record = Object.fromEntries(Object.entries(shell).map(([path, [name, type]]) => [path, { body: asset(name), type }])); // Sandboxed visual frame: fixed files only, served with FRAME_CSP and framable by the console itself. files["/visual/frame"] = { body: asset("visual-frame.html"), type: "text/html; charset=utf-8", frame: true }; diff --git a/src/static.ts b/src/static.ts index 0af0b43..692674a 100644 --- a/src/static.ts +++ b/src/static.ts @@ -39,7 +39,7 @@ export function staticCsp(config: StaticConfig): string { } const FILES: [string, string][] = [ - ...["shell.js", "static.js", "shell.css", "space.js", "space-team.js", "space-flow.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), + ...["shell.js", "static.js", "shell.css", "space.js", "space-team.js", "space-board.js", "space-flow.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), ...["three.module.js", "three.core.js", "GLTFLoader.js", "BufferGeometryUtils.js", "SkeletonUtils.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/${file}`, `vendor/${file}`]), ...["icons.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/icons/${file}`, `vendor/icons/${file}`]), ["visual-frame.html", "visual/frame.html"], ["visual-frame.css", "visual/frame.css"], ["visual-frame.js", "visual/frame.js"], diff --git a/test/space-board.test.ts b/test/space-board.test.ts new file mode 100644 index 0000000..80c3e32 --- /dev/null +++ b/test/space-board.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; +import { harness } from "./space-team.test.ts"; + +function boardFixture() { + const h = harness(); + const model = { ...h.model, + groups: [...h.model.groups, { id: "demo:beta", title: "Beta" }], + zones: [ + { id: "demo:backlog", title: "Backlog", kind: "queue", count: 0, group: "demo:alpha" }, + { id: "demo:queue", title: "To do", kind: "queue", count: 0, group: "demo:alpha" }, + ...h.model.zones, + { id: "demo:review", title: "Review", kind: "review", count: 0, group: "demo:alpha" }, + { id: "demo:done", title: "Done", kind: "done", count: 7, group: "demo:alpha" }, + ], + cards: [...h.model.cards.map((card: any) => ({ ...card, owner: "demo:one", tone: "warn" })), + ...Array.from({ length: 7 }, (_, i) => ({ id: `demo:done-${i}`, title: `Completed ${i}`, zone: "demo:done", group: "demo:alpha", owner: "demo:two", tone: "ok", + link: { tool: "demo", panel: "notes", params: { id: String(i) } } })), + ], + }; + h.renderer.setRoute({ view: "board" }); h.renderer.update(model); + return { ...h, model, root: h.root, renderer: h.renderer, get writes() { return h.writes; } }; +} + +test("Board uses per-group zone columns and keeps exact producer card links and owner controls", () => { + const h = boardFixture(); + const board = h.root.querySelector(".space-board")!; + expect(board.hidden).toBe(false); + expect(board.querySelectorAll(".space-board-group")).toHaveLength(2); + expect(board.querySelectorAll(".space-board-column").map((node) => node.getAttribute("data-board-kind"))).toEqual(["backlog", "queue", "work", "review", "done"]); + const card = board.querySelector('[data-board-card="demo:task"]')!; + expect(card.textContent).toContain("Task title"); expect(card.textContent).toContain("warn"); + card.onclick?.({}); expect(h.nav.at(-1)).toEqual(["demo", "notes", { id: "task" }]); + const owner = board.querySelector('[data-board-focus="owner:demo:task"]')!; + expect(owner.textContent).toContain("Exact / name"); + owner.focus(); owner.onclick?.({ currentTarget: owner }); + expect(h.nav.at(-1)).toMatchObject({ view: "board", actor: "demo:one" }); + h.renderer.setRoute({ view: "board", actor: "demo:one" }); h.renderer.update(h.model); + expect(h.root.querySelector(".space-agent-detail")!.hidden).toBe(false); + h.renderer.setRoute({ view: "board" }); h.renderer.update(h.model); + expect(h.doc.activeElement).toBe(owner); +}); + +test("Done defaults to its last five supplied cards and preserves its disclosure on updates", () => { + const h = boardFixture(), board = h.root.querySelector(".space-board")!; + const done = board.querySelector('[data-board-kind="done"]')!; + const details = done.querySelector("details")!; + expect(details.open).toBe(false); expect(done.textContent).toContain("Done (7)"); + expect(done.children.filter((node) => node.tag === "article").map((node) => node.textContent)).toHaveLength(5); + expect(details.querySelectorAll(".space-board-card").map((node) => node.getAttribute("data-board-card"))).toEqual(["demo:done-0", "demo:done-1"]); + details.open = true; + h.renderer.update({ ...h.model, cards: h.model.cards.map((card: any) => ({ ...card, title: `${card.title}!` })) }); + expect(board.querySelector("details")!.open).toBe(true); +}); + +test("unchanged Board snapshots and hidden Board changes write nothing to the Board", () => { + const h = boardFixture(); + const before = h.writes; h.renderer.update({ ...h.model, sampledAt: 9 }); expect(h.writes).toBe(before); + h.renderer.setRoute({ view: "team" }); h.renderer.update(h.model); + const board = h.root.querySelector(".space-board")!; + const children = [...board.children], hiddenWrites = board.hiddenWrites; + h.renderer.update({ ...h.model, cards: h.model.cards.slice(0, 2) }); + expect(board.hidden).toBe(true); expect(board.hiddenWrites).toBe(hiddenWrites); expect(board.children).toEqual(children); +}); + +test("Board honors group/state filters and keeps ungrouped cards discoverable", () => { + const h = boardFixture(); + const model = { ...h.model, cards: [...h.model.cards, { ...h.model.cards[0], id: "demo:ungrouped", group: undefined }] }; + h.renderer.setRoute({ view: "board", group: "demo:alpha", state: "working" }); h.renderer.update(model); + expect(h.root.querySelectorAll(".space-board-group").map((node) => node.getAttribute("data-board-group"))).toEqual(["demo:alpha", ""]); + expect(h.root.querySelectorAll(".space-board-card").map((node) => node.getAttribute("data-board-card"))).toEqual(["demo:task", "demo:ungrouped"]); + h.renderer.setRoute({ view: "board", group: "demo:beta" }); h.renderer.update(model); + expect(h.root.querySelectorAll(".space-board-card").map((node) => node.getAttribute("data-board-card"))).toEqual(["demo:ungrouped"]); +}); + +test("Board restores focus to closed Done summary and falls back to its tab for removed controls", () => { + const h = boardFixture(); + const board = h.root.querySelector(".space-board")!; + const details = board.querySelector("details")!; details.open = true; + details.querySelector(".space-board-card")!.focus(); details.open = false; + h.renderer.update({ ...h.model, cards: h.model.cards.map((card: any) => ({ ...card, title: `${card.title}!` })) }); + expect(h.doc.activeElement.tag).toBe("summary"); + board.querySelector('[data-board-card="demo:task"]')!.focus(); + h.renderer.update({ ...h.model, cards: h.model.cards.filter((card: any) => card.id !== "demo:task") }); + expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("board"); +}); + +test("Board does not merge zones with identical kind/title across groups or lose cross-group zone references", () => { + const h = boardFixture(); + const model = { ...h.model, zones: [...h.model.zones, { id: "demo:beta-work", title: "Work", kind: "work", count: 1, group: "demo:beta" }], + cards: [...h.model.cards, { ...h.model.cards[0], id: "demo:beta-card", group: "demo:beta", zone: "demo:work" }] }; + const groups = h.api.boardGroups(model); + const beta = groups.find((group: any) => group.id === "demo:beta"); + expect(beta.columns).toHaveLength(2); + expect(beta.columns.flatMap((column: any) => column.cards).map((card: any) => card.id)).toEqual(["demo:beta-card"]); +}); diff --git a/test/space-server.test.ts b/test/space-server.test.ts index 0e28ae8..db7a4c7 100644 --- a/test/space-server.test.ts +++ b/test/space-server.test.ts @@ -51,6 +51,7 @@ test("space capability authorizes its snapshot and transport drops private field plugin.manifest.capabilities!.space = false; expect((await post("/p/demo/snapshot", { panel: "space" })).status).toBe(400); expect(await (await get("/space-flow.js")).text()).toContain("createSpaceFlow"); + expect((await get("/space-board.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space-team.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space.js")).headers.get("content-type")).toContain("javascript"); }); diff --git a/test/space-team.test.ts b/test/space-team.test.ts index 8418239..4090d0b 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -4,6 +4,7 @@ import { runInNewContext } from "node:vm"; export function harness({ now = Date.now }: { now?: () => number } = {}) { let writes = 0; + let resize: () => void = () => {}; const listeners = new Mapvoid>(); const doc: any = { activeElement: null, querySelector(s:string){return s === "dialog[open]" && doc.nativeDialog?.open ? doc.nativeDialog : null;}, addEventListener(name:string, callback:(e:any)=>void) {listeners.set(name,callback);}, removeEventListener() {}, createDocumentFragment: () => new Element("fragment") }; class Element { @@ -49,14 +50,15 @@ export function harness({ now = Date.now }: { now?: () => number } = {}) { doc.body = new Element("body"); main.parent=doc.body; root.parent=main; const h = (tag:string,props:Record={},...children:any[]): Element => { const n=new Element(tag); for(const[k,v]of Object.entries(props)){if(k==="text")n.textContent=v;else if(k==="hidden")n.hidden=v;else if(k.startsWith("on"))(n as any)[k]=v;else n.setAttribute(k==="class"?"class":k,String(v));} n.append(...children.flat().filter(Boolean).map(c=>typeof c==="string"?h("text",{text:c}):c));return n; }; const teamSource=readFileSync(new URL("../shell/space-team.js",import.meta.url),"utf8"); + const boardSource=readFileSync(new URL("../shell/space-board.js",import.meta.url),"utf8"); const flowSource=readFileSync(new URL("../shell/space-flow.js",import.meta.url),"utf8"); const source=readFileSync(new URL("../shell/space.js",import.meta.url),"utf8"); const storage=new Map(); const nav: any[]=[]; let back=0; - const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${flowSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date: class extends Date { static now = now; },setInterval(){return 1;},clearInterval(){}}); + const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${flowSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${boardSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel,boardGroups})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{constructor(callback:()=>void){resize=callback;}observe(){}disconnect(){}},getComputedStyle:()=>({fontSize:"16px"}),CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date: class extends Date { static now = now; },setInterval(){return 1;},clearInterval(){}}); const renderer=api.createSpaceRenderer(root,{h,pets:()=>[],packs:()=>[],navigateSpace:(params:any)=>nav.push(params),navigate:(...args:any[])=>nav.push(args)}); const scene={groups:[{id:"alpha",title:"Alpha"}],actors:[{id:"one",name:"Exact / name",state:"working",roles:["implementer"],since:"2026-01-01T00:00:00Z",group:"alpha",card:"task",marks:[{kind:"inbox",text:"3 inbox"},{kind:"approval",text:"1 approval"}],link:{panel:"notes"}},{id:"two",name:"Resting",state:"idle",group:"alpha",marks:[],link:{panel:"notes"}}],zones:[{id:"work",title:"Work",count:1,group:"alpha"}],cards:[{id:"task",title:"Task title",zone:"work",group:"alpha",link:{panel:"notes",params:{id:"task"}}}],links:[{kind:"cohort",from:"one",to:"two"}]}; const model=api.mergeSpaceScenes(new Map([["demo",{scene,sampledAt:1}]])); - return {api,renderer,root,model,doc,nav,listeners,storage,get writes(){return writes;},get back(){return back;}}; + return {api,renderer,root,model,doc,nav,listeners,storage,resize:()=>resize(),get writes(){return writes;},get back(){return back;}}; } test("Team is default, shows attention/roles/task/badges and collapsed resting actors without Room initialization",()=>{ const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); @@ -230,3 +232,16 @@ test("a Team Board/Flow shortcut moves focus to the selected section tab",()=>{ h.renderer.setRoute({view:"board",group:"demo:alpha"});h.renderer.update(h.model); expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("board"); }); + + +test("Team geometry notifications do not force unchanged root writes", () => { + const h = harness(); + h.renderer.setRoute({ view: "team" }); h.renderer.update(h.model); + const before = h.writes; + h.resize(); + expect(h.writes).toBe(before); + (h.root as any).clientWidth = 1024; h.resize(); + expect(h.writes).toBe(before); + h.renderer.update({ ...h.model, sampledAt: 2 }); + expect(h.writes).toBe(before); +}); diff --git a/test/static.test.ts b/test/static.test.ts index 696960e..f2f7ea9 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -48,7 +48,7 @@ describe("H2 static build (#46)", () => { expect(html).toContain(`content="${staticCsp(result.config).replace("; frame-ancestors 'none'", "")}"`); expect(html).not.toMatch(/]*>\s*[^\s<]/i); expect(html).not.toMatch(/ Date: Tue, 10 Nov 2026 22:20:46 +0900 Subject: [PATCH 2/2] fix(space): preserve producer totals in Board columns --- README.md | 2 +- scripts/ui-check-space-board.mjs | 6 +++--- shell/space-board.js | 4 +++- test/space-board.test.ts | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cf0967d..f3e9797 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Team puts blocked, waiting and approval-holding actors first under Needs attenti Flow shows one compact actor panel per project with directed message counts over 15 minutes and labeled overlap, conflict and cohort connections. Recent activity sits beside the panels at 1024 px and wider and below them on narrow screens. History starts when the page opens: the shell retains up to 200 observed public link events for 30 minutes, without requesting or storing message bodies. New activity highlights once; reduced motion disables the animation. -Board shows each group's work as columns from the producer's zone kinds and titles, with the reported card titles, tones and owner avatars/names. Cards open their existing producer links; owner buttons open agent detail. Done shows its count and the last five cards in supplied order, with earlier cards behind a disclosure. Each row scrolls within its own keyboard-focusable region on narrow screens, keeping the page width fixed. Project and state filters apply to Board, including ungrouped work. +Board shows each group's work as columns from the producer's zone kinds and titles, with the reported card titles, tones and owner avatars/names. Cards open their existing producer links; owner buttons open agent detail. Column headings preserve the producer-reported total and show the supplied/filtered card count when it differs. Done shows the last five cards in supplied order, with earlier cards behind a disclosure. Each row scrolls within its own keyboard-focusable region on narrow screens, keeping the page width fixed. Project and state filters apply to Board, including ungrouped work. Selecting an actor in Team, Flow, Board or Room opens its detail using the namespaced actor route. At 1024 px and wider, detail is a side sheet; below 1024 px, it is a full page. Escape or Back returns to the prior view and restores its scroll position and focus. Detail includes the actor's current task and producer link, marks, and current overlap/conflict/cohort partners. Its Interactions section shows messages sent and received per peer over the retained history and the actor's last 10 observed events. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step. diff --git a/scripts/ui-check-space-board.mjs b/scripts/ui-check-space-board.mjs index 9f5371b..c63c0b9 100644 --- a/scripts/ui-check-space-board.mjs +++ b/scripts/ui-check-space-board.mjs @@ -28,7 +28,7 @@ async function installDoneFixture(page) { id: `board-done-${i}`, title: `Board completed task ${i + 1}`, zone: zone.id, group: "delivery", owner: "claude", tone: tones[i % tones.length], link: { panel: "notes", params: { id: "n1" } }, }); - zone.count = 7; + zone.count = 20; // The producer total deliberately exceeds the seven supplied cards. } await route.fulfill({ response, json: body }); }); @@ -74,7 +74,7 @@ async function checkDone(page) { const summary = details.locator("summary"); await summary.waitFor(); assert.equal(await details.evaluate((node) => node.open), false, "Done was not collapsed initially"); - assert.match(await done.innerText(), /\b7\b/, "Done does not show the total count"); + assert.equal(await done.locator("h3").innerText(), "Done (20) · 7 shown", "Done must distinguish producer total from supplied cards"); assert.equal(await done.locator("[data-board-card]:visible").count(), 5, "Collapsed Done must show its last five cards"); const lastFive = await done.locator("[data-board-card]:visible").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-board-card"))); assert.deepEqual(lastFive.sort(), Array.from({ length: 5 }, (_, i) => `demo:board-done-${i + 2}`), "Collapsed Done does not show the last five cards"); @@ -87,7 +87,7 @@ async function checkDone(page) { await fitsPage(page, "Expanded Board Done"); await summary.click(); await done.locator('[data-board-card="demo:board-done-0"]').waitFor({ state: "hidden" }); - return { doneCount: 7, collapsedCards: 5, expandedCards: 7 }; + return { doneCount: 20, suppliedCards: 7, collapsedCards: 5, expandedCards: 7 }; } async function keyboardProducer(page) { diff --git a/shell/space-board.js b/shell/space-board.js index 3def296..a2a8ef7 100644 --- a/shell/space-board.js +++ b/shell/space-board.js @@ -65,8 +65,10 @@ export function createSpaceBoard(root, host) { for (const zone of item.columns) { const key = JSON.stringify([item.id, zone.id]); const kind = columnKind(zone); + const total = zone.count ?? "total not reported"; + const shown = zone.count === zone.cards.length ? "" : ` · ${zone.cards.length} shown`; const column = h("section", { class: "space-board-column", "data-board-kind": kind, "data-board-zone": zone.id }, - h("h3", { text: `${zone.title} (${zone.cards.length})` })); + h("h3", { text: `${zone.title} (${total})${shown}` })); if (kind === "done") { for (const card of zone.cards.slice(-5)) column.append(cardElement(card, owners)); if (zone.cards.length > 5) { diff --git a/test/space-board.test.ts b/test/space-board.test.ts index 80c3e32..43a8e93 100644 --- a/test/space-board.test.ts +++ b/test/space-board.test.ts @@ -93,3 +93,17 @@ test("Board does not merge zones with identical kind/title across groups or lose expect(beta.columns).toHaveLength(2); expect(beta.columns.flatMap((column: any) => column.cards).map((card: any) => card.id)).toEqual(["demo:beta-card"]); }); + +test("every Board column preserves producer totals when supplied or filtered cards differ", () => { + const h = boardFixture(); + const model = { ...h.model, zones: h.model.zones.map((zone: any) => ({ ...zone, count: zone.id === "demo:done" ? 20 : zone.id === "demo:work" ? 12 : zone.count })) }; + h.renderer.update(model); + const board = h.root.querySelector(".space-board")!; + const heading = (kind: string) => board.querySelector(`[data-board-kind="${kind}"]`)!.children.find((node) => node.tag === "h3")!.textContent; + expect(heading("done")).toBe("Done (20) · 7 shown"); + expect(heading("work")).toBe("Work (12) · 1 shown"); + expect(board.querySelector('[data-board-kind="done"]')!.querySelector("summary")!.textContent).toBe("Show 2 earlier cards"); + h.renderer.setRoute({ view: "board", state: "working" }); h.renderer.update(model); + expect(heading("done")).toBe("Done (20) · 0 shown"); + expect(heading("work")).toBe("Work (12) · 1 shown"); +});