Board UI: rail section, plan gate, expanded overlay, journal cases (OPE-96)

Board+journal endpoints act as the user; sessions get lead verbs behind OPENWORKER_TEAM_BOARD=1 until the team trait lands.
Rail hides all team chrome until the workspace has items; expand opens a full-width state-column board.
This commit is contained in:
Rohit C Prasad
2026-08-16 07:11:52 -07:00
committed by Rohit P
parent 054f807d4e
commit 4c1b542370
11 changed files with 739 additions and 3 deletions
+74
View File
@@ -0,0 +1,74 @@
// Agent teams (OPE-96): the board in the session UI — rail section (grouped by
// state, blocked on top), the plan gate (decomposition approval), and the expanded
// Linear-shaped overlay. The fake agent files items on "plan the work"; approve and
// transition round-trip through the mocked /board endpoints.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function planTheWork(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("plan the work");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/approve the plan and I'll get started/)).toBeVisible();
}
test("plain sessions carry zero board chrome", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Echo: hello")).toBeVisible();
await expect(page.getByTestId("board-rail")).toHaveCount(0);
await expect(page.getByTestId("plangate-card")).toHaveCount(0);
});
test("a decomposition turn raises the plan gate; approving moves items to Approved", async ({
page,
}) => {
await planTheWork(page);
const gate = page.getByTestId("plangate-card");
await expect(gate).toBeVisible();
// 3 visible + expander with the true remainder (mock UX-030: expander, true count in header)
await expect(gate).toContainText("Proposed plan — 4 work items");
await expect(gate).toContainText("Done when:");
await expect(gate.getByText("Code security review — api")).toBeVisible();
await expect(gate.getByText("Rate-limit audit — public endpoints")).toHaveCount(0);
await gate.getByRole("button", { name: /1 more item/ }).click();
await expect(gate.getByText("Rate-limit audit — public endpoints")).toBeVisible();
// Blocked renders on top in the rail; proposed items are listed under Proposed.
const rail = page.getByTestId("board-rail");
await expect(rail).toBeVisible();
const groups = rail.locator(".board-group");
await expect(groups.first()).toHaveText("Blocked");
await page.getByTestId("plangate-approve").click();
await expect(page.getByTestId("plangate-card")).toHaveCount(0);
await expect(rail).toContainText("Approved");
});
test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({
page,
}) => {
await planTheWork(page);
await page.getByTestId("board-expand").click();
const overlay = page.getByTestId("board-overlay");
await expect(overlay).toBeVisible();
// Columns render need-attention first; the review item offers the user verbs.
await expect(page.getByTestId("board-col-blocked")).toBeVisible();
const reviewCol = page.getByTestId("board-col-review");
await expect(reviewCol).toContainText("Report rollup");
await reviewCol.getByRole("button", { name: "Mark done" }).click();
await expect(page.getByTestId("board-col-done")).toContainText("Report rollup");
await page.keyboard.press("Escape");
await expect(page.getByTestId("board-overlay")).toHaveCount(0);
});
test("journal section lists cases once a board exists", async ({ page }) => {
await planTheWork(page);
await page.getByRole("button", { name: /Journal/ }).click();
const journal = page.getByTestId("journal-list");
await expect(journal).toBeVisible();
await expect(journal).toContainText("findings");
await expect(journal).toContainText("12 entries");
});
+56
View File
@@ -561,6 +561,26 @@ export async function mockApi(page: import("@playwright/test").Page) {
];
let stagedSkill: any = null;
// Agent teams (OPE-96): the session's board — empty until a test opts in by sending
// "plan the work" (the fake agent then files items into the proposed gate). Mutable
// so approve/transition round-trip through the real endpoints.
const boardItems: any[] = [];
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
{ id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 6, title: "Rate-limit audit — public endpoints", description: "", criteria: "every unauthenticated route has a limit or a reason", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [] },
{ id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [], links: [] },
);
};
const boardPayload = () =>
boardItems.length
? { space: "/Users/test/OpenWorker/launch-note", name: "launch-note", items: boardItems }
: { space: null, name: "", items: [] };
// Fresh cloud sign-in state per test (module state outlives a page).
Object.assign(CLOUD_STATE, {
signed_in: false,
@@ -610,6 +630,16 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return; // suspended on the approval
}
// Agent teams (OPE-96): a decomposition turn — the agent files work items and
// the board (rail section + plan gate) appears on the next board fetch.
if (/plan the work/i.test(msg.text)) {
seedBoard();
send("assistant_message", {
text: "Split it into 5 work items — approve the plan and I'll get started.",
});
send("turn_done");
return;
}
// A deliverable turn ending in an artifact chip (§34) — for the chip-open flow.
if (/show the report/i.test(msg.text)) {
send("assistant_message", {
@@ -893,6 +923,32 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
// Agent teams (OPE-96): board reads + the user-side mutations.
if (/\/v1\/sessions\/[^/]+\/board\/approve$/.test(p)) {
let approved = 0;
for (const item of boardItems) {
if (item.state === "proposed") {
item.state = "approved";
approved += 1;
}
}
return json({ approved, ...boardPayload() });
}
if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) {
const b = req.postDataJSON() || {};
const item = boardItems.find((i) => i.id === Number(b.item));
if (!item) return json({ error: "no such item" });
item.state = String(b.to);
return json(item);
}
if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload());
if (p.endsWith("/v1/teams/journal")) {
return json({
cases: boardItems.length
? [{ case: "findings", entries: 12, last_ts: new Date().toISOString() }]
: [],
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
return json({
artifacts: [
+43
View File
@@ -3,7 +3,11 @@ import {
announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun,
boardApprove,
boardTransition,
getArtifacts,
getBoard,
type Board,
getHealth,
getRecentWorkspaces,
getSessionMessages,
@@ -72,6 +76,7 @@ import { ApprovalCard } from "./components/ApprovalCard";
import { ToolRequestCard } from "./components/ToolRequestCard";
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard";
import { BoardOverlay, PlanGateCard } from "./components/BoardPanel";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -267,6 +272,10 @@ export function App() {
setSurface("persona");
};
const [browserRefreshKey, setBrowserRefreshKey] = useState(0);
// Agent teams (OPE-96): board for the current session's workspace space.
const [board, setBoard] = useState<Board | null>(null);
const [boardOpen, setBoardOpen] = useState(false);
const [planBusy, setPlanBusy] = useState(false);
const [railHidden, setRailHidden] = useState(false);
// Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the
// width; hovering the left edge peeks it back as a floating overlay. Persisted per-device.
@@ -945,6 +954,32 @@ export function App() {
getArtifacts(sessionId).then((a) => setArtifactCount(a.length)).catch(() => {});
}, [agent, surface, sessionId, browserRefreshKey]);
// Agent teams (OPE-96): the session's board — drives the rail section, the plan
// gate, and the expanded overlay. Refreshes with the same cycle as artifacts
// (session change + turn end) so items the agent just created appear.
useEffect(() => {
if (surface !== "session" || agent === "chat") {
setBoard(null);
return;
}
getBoard(sessionId).then(setBoard).catch(() => setBoard(null));
}, [agent, surface, sessionId, browserRefreshKey, running]);
const refreshBoard = () => getBoard(sessionId).then(setBoard).catch(() => {});
const approvePlan = async () => {
setPlanBusy(true);
try {
await boardApprove(sessionId);
await refreshBoard();
} finally {
setPlanBusy(false);
}
};
const moveBoardItem = async (item: number, to: string) => {
await boardTransition(sessionId, item, to);
await refreshBoard();
};
// Keep the active session's pending Inbox items fresh (answer-in-context card). Loads on session
// change + after each turn, plus a slow poll so an unattended agent's new question surfaces.
useEffect(() => {
@@ -1912,6 +1947,9 @@ export function App() {
) : sessionInbox[0] ? (
// Unattended session blocked on an Inbox item — answer it in context.
<InboxItemCard item={sessionInbox[0]} onResolve={resolveSessionInbox} compact />
) : board && board.items.some((i) => i.state === "proposed") ? (
// Agent teams: the decomposition gate — proposed items awaiting approval.
<PlanGateCard board={board} onApprove={approvePlan} busy={planBusy} />
) : undefined
}
/>
@@ -1932,7 +1970,12 @@ export function App() {
scratchPrimary={agent === "cowork" || tempWorkspace}
openAccessKey={accessKey}
onOpenIntegrations={() => setSurface("integrations")}
board={board}
onExpandBoard={() => setBoardOpen(true)}
/>
{boardOpen && board && board.space && (
<BoardOverlay board={board} onClose={() => setBoardOpen(false)} onTransition={moveBoardItem} />
)}
</div>
</div>
)}
+54
View File
@@ -209,6 +209,60 @@ export async function deleteSession(sessionId: string): Promise<{ ok: boolean; e
return res.json();
}
// Agent teams (OPE-96): the session's board — items on the workspace-keyed space.
export interface BoardItem {
id: number;
title: string;
description: string;
criteria: string;
state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string;
assignee: string;
creator: string;
refs: string[];
links: { kind: string; item: number }[];
}
export interface Board {
space: string | null;
name: string;
items: BoardItem[];
}
export interface JournalCase {
case: string;
entries: number;
last_ts: string;
}
export async function getBoard(sessionId: string): Promise<Board> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board`);
return res.json();
}
export async function boardApprove(sessionId: string): Promise<Board & { approved: number }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/approve`, { method: "POST" });
return res.json();
}
export async function boardTransition(
sessionId: string,
item: number,
to: string,
comment = "",
): Promise<BoardItem | { error: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/transition`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item, to, comment }),
});
return res.json();
}
export async function getJournalCases(): Promise<JournalCase[]> {
const res = await fetch(`${httpBase()}/v1/teams/journal`);
return (await res.json()).cases ?? [];
}
export interface ArtifactInfo {
path: string; // workspace-relative (the display/API identifier)
abs_path?: string; // absolute — what "Copy path" copies
+224
View File
@@ -0,0 +1,224 @@
// Agent teams (OPE-96): the board in three shapes —
// - BoardSection: the right-rail summary (grouped by state, blocked on top)
// - BoardOverlay: the expanded, Linear-shaped view covering the chat column
// - PlanGateCard: the decomposition gate (proposed items awaiting the user)
// All three render the same Board data App owns; mutations go through the
// /board endpoints and act as the USER — the human side of the gates.
import { useEffect, useMemo, useState } from "react";
import type { Board, BoardItem } from "../api";
import { Icon } from "./Icon";
// Display order: needs-attention first (mock UX-030: "grouped by state, blocked on top").
const GROUPS: { state: string; label: string }[] = [
{ state: "blocked", label: "Blocked" },
{ state: "review", label: "Review" },
{ state: "in_progress", label: "In progress" },
{ state: "approved", label: "Approved" },
{ state: "proposed", label: "Proposed" },
{ state: "done", label: "Done" },
{ state: "canceled", label: "Canceled" },
];
function dotClass(state: string): string {
if (state === "blocked") return "board-dot blocked";
if (state === "review") return "board-dot review";
if (state === "in_progress") return "board-dot work";
if (state === "done") return "board-dot done";
return "board-dot idle";
}
export function boardSummary(board: Board): string {
const counts: Record<string, number> = {};
for (const item of board.items) counts[item.state] = (counts[item.state] || 0) + 1;
const parts: string[] = [];
if (counts.blocked) parts.push(`${counts.blocked} blocked`);
if (counts.review) parts.push(`${counts.review} review`);
if (counts.in_progress) parts.push(`${counts.in_progress} in progress`);
if (counts.proposed) parts.push(`${counts.proposed} proposed`);
return parts.join(" · ");
}
export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) {
const groups = GROUPS.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
})).filter((g) => g.items.length > 0);
return (
<div className="board-rail" data-testid="board-rail">
{groups.map((group) => (
<div key={group.state}>
<div className="board-group">{group.label}</div>
{group.items.map((item) => (
<button className="board-row" key={item.id} onClick={onExpand} title="Open the board">
<span className={dotClass(item.state)} />
<span className="board-row-main">
<span className="board-row-title">
<span className="board-row-id">#{item.id}</span> {item.title}
</span>
{item.assignee && <span className="board-row-who">{item.assignee}</span>}
</span>
</button>
))}
</div>
))}
</div>
);
}
// The expanded board: state columns over the whole session area — the "clean and
// large board like Linear" (owner ask 2026-08-16). Esc, backdrop, or ✕ closes.
export function BoardOverlay({
board,
onClose,
onTransition,
}: {
board: Board;
onClose: () => void;
// (item, to) → performed as the user; App refetches on completion.
onTransition?: (item: number, to: string) => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const columns = GROUPS.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
})).filter((g) => g.items.length > 0 || ["in_progress", "approved", "review"].includes(g.state));
return (
<div className="board-overlay" data-testid="board-overlay" onClick={onClose}>
<div className="board-overlay-panel" onClick={(e) => e.stopPropagation()}>
<div className="board-overlay-head">
<div className="board-overlay-title">
<Icon name="table" size={16} />
<span>Board</span>
<span className="board-overlay-space">{board.name}</span>
</div>
<button className="artifact-icon-btn" onClick={onClose} aria-label="Close board" title="Close">
<Icon name="x" size={16} />
</button>
</div>
<div className="board-columns">
{columns.map((column) => (
<div className="board-col" key={column.state} data-testid={`board-col-${column.state}`}>
<div className="board-col-head">
<span className={dotClass(column.state)} />
{column.label}
<span className="board-col-count">{column.items.length}</span>
</div>
<div className="board-col-body">
{column.items.map((item) => (
<BoardCard key={item.id} item={item} onTransition={onTransition} />
))}
{column.items.length === 0 && <div className="board-col-empty"></div>}
</div>
</div>
))}
</div>
</div>
</div>
);
}
function BoardCard({
item,
onTransition,
}: {
item: BoardItem;
onTransition?: (item: number, to: string) => void;
}) {
// The user can always act; offer the obvious next moves for the state.
const moves: { to: string; label: string }[] =
item.state === "proposed"
? [{ to: "approved", label: "Approve" }, { to: "canceled", label: "Cancel" }]
: item.state === "review"
? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }]
: item.state === "done" || item.state === "canceled"
? []
: [{ to: "canceled", label: "Cancel" }];
return (
<div className="board-card" data-testid={`board-item-${item.id}`}>
<div className="board-card-title">
<span className="board-row-id">#{item.id}</span> {item.title}
</div>
{item.criteria && (
<div className="board-card-criteria">
<span className="board-card-label">Done when:</span> {item.criteria}
</div>
)}
<div className="board-card-foot">
{item.assignee ? <span className="board-card-who">{item.assignee}</span> : <span />}
{onTransition && moves.length > 0 && (
<span className="board-card-actions">
{moves.map((m) => (
<button key={m.to} className="board-card-btn" onClick={() => onTransition(item.id, m.to)}>
{m.label}
</button>
))}
</span>
)}
</div>
</div>
);
}
// The decomposition gate: proposed items awaiting the user's approval, rendered in
// the composer head like the other request cards. Visible layer = the decisions
// (items + criteria); editing happens by replying — no in-card reply surface.
export function PlanGateCard({
board,
onApprove,
busy,
}: {
board: Board;
onApprove: () => void;
busy?: boolean;
}) {
const proposed = useMemo(() => board.items.filter((i) => i.state === "proposed"), [board.items]);
const [expanded, setExpanded] = useState(false);
if (proposed.length === 0) return null;
const visible = expanded ? proposed : proposed.slice(0, 3);
const hidden = proposed.length - visible.length;
return (
<div className="dirreq-card plangate-card" data-testid="plangate-card">
<div className="plangate-head">
<Icon name="table" size={15} />
<span className="plangate-title">
Proposed plan {proposed.length} work item{proposed.length === 1 ? "" : "s"}
</span>
<span className="plangate-board">board: {board.name}</span>
</div>
{visible.map((item) => (
<div className="plangate-item" key={item.id}>
<span className="plangate-num">#{item.id}</span>
<span className="plangate-body">
<span className="plangate-item-title">{item.title}</span>
{item.criteria && (
<span className="plangate-ac">
<b>Done when:</b> {item.criteria}
</span>
)}
</span>
</div>
))}
{hidden > 0 && (
<button className="plangate-more" onClick={() => setExpanded(true)}>
{hidden} more item{hidden === 1 ? "" : "s"}
<Icon name="chevronDown" size={12} />
</button>
)}
<div className="dirreq-actions">
<span className="plangate-note">Reply to edit the plan; nothing runs until you approve.</span>
<span className="spacer" />
<button className="btn primary" data-testid="plangate-approve" disabled={busy} onClick={onApprove}>
Approve plan
</button>
</div>
</div>
);
}
+67 -1
View File
@@ -3,17 +3,21 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import {
getArtifacts,
getJournalCases,
readArtifact,
revealArtifact,
type ArtifactContent,
type ArtifactInfo,
type Board,
type JournalCase,
} from "../api";
import type { TodoItem } from "../types";
import { AccessSection } from "./AccessSection";
import { BoardSection, boardSummary } from "./BoardPanel";
import { Icon } from "./Icon";
import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown";
type Panel = "progress" | "artifacts";
type Panel = "progress" | "artifacts" | "board" | "journal";
// Quiet file-type icons for the artifact list (the colored kind pills read as noisy).
function kindIcon(kind: string): "file" | "fileCode" | "image" | "table" {
@@ -57,6 +61,10 @@ interface Props {
scratchPrimary?: boolean;
openAccessKey?: number;
onOpenIntegrations?: () => void;
// Agent teams (OPE-96): App owns board data (the plan gate needs it too);
// the rail renders the summary section and the expand affordance.
board?: Board | null;
onExpandBoard?: () => void;
}
export function RightRail({
@@ -75,12 +83,17 @@ export function RightRail({
scratchPrimary,
openAccessKey = 0,
onOpenIntegrations,
board,
onExpandBoard,
}: Props) {
const [open, setOpen] = useState<Record<Panel, boolean>>({
progress: true,
artifacts: true,
board: true,
journal: false,
});
const [artifacts, setArtifacts] = useState<ArtifactInfo[]>([]);
const [journal, setJournal] = useState<JournalCase[]>([]);
const [selected, setSelected] = useState<ArtifactInfo | null>(null);
const [content, setContent] = useState<ArtifactContent | null>(null);
@@ -91,6 +104,16 @@ export function RightRail({
if (showArtifacts) refreshArtifacts();
}, [active, sessionId, refreshKey, showArtifacts]);
// Journal cases surface only when a board exists — same visibility rule as the
// Board section, so plain sessions carry zero team chrome.
useEffect(() => {
if (!active || !board?.space) {
setJournal([]);
return;
}
getJournalCases().then(setJournal).catch(() => setJournal([]));
}, [active, sessionId, refreshKey, board?.space]);
// Switching conversations closes any open artifact — it belongs to the previous session's
// workspace, which the new session can't (and shouldn't) read.
useEffect(() => {
@@ -178,6 +201,49 @@ export function RightRail({
<ProgressSummary running={running} toolNames={toolNames} todo={todo} />
</RailSection>
{/* Agent teams (OPE-96): board summary grouped by state, blocked on top.
Hidden entirely until the workspace has items (no chrome for plain sessions). */}
{board?.space && (
<RailSection
title={`Board${boardSummary(board) ? ` · ${boardSummary(board)}` : ""}`}
open={open.board}
onToggle={() => setOpen({ ...open, board: !open.board })}
action={
<button
className="rail-mini-btn"
data-testid="board-expand"
onClick={(e) => {
e.stopPropagation();
onExpandBoard?.();
}}
title="Expand the board"
>
<Icon name="panelOpen" size={13} />
</button>
}
>
<BoardSection board={board} onExpand={() => onExpandBoard?.()} />
</RailSection>
)}
{board?.space && journal.length > 0 && (
<RailSection
title={`Journal (${journal.length})`}
open={open.journal}
onToggle={() => setOpen({ ...open, journal: !open.journal })}
>
<div className="journal-list" data-testid="journal-list">
{journal.map((c) => (
<div className="journal-row" key={c.case}>
<Icon name="file" size={13} />
<span className="journal-case">{c.case}</span>
<span className="journal-count">{c.entries} entr{c.entries === 1 ? "y" : "ies"}</span>
</div>
))}
</div>
</RailSection>
)}
{showArtifacts && (
<RailSection
title={`Artifacts${artifacts.length ? ` (${artifacts.length})` : ""}`}
+92
View File
@@ -1637,3 +1637,95 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.artifact-folder-row:hover { background: var(--paper); }
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-folder-size { font-size: 11px; color: var(--faint); }
/* ── Agent teams (OPE-96): board rail, expanded overlay, plan gate ─────────── */
.board-rail { display: flex; flex-direction: column; gap: 2px; }
.board-group {
font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--faint); font-weight: 700; margin: 8px 0 3px;
}
.board-rail > div:first-child .board-group { margin-top: 0; }
.board-row {
display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left;
padding: 5px 6px; border: 0; border-radius: 7px; background: transparent;
cursor: pointer; color: var(--muted); font-size: 12.5px;
}
.board-row:hover { background: var(--paper); }
.board-row-main { display: flex; flex-direction: column; min-width: 0; }
.board-row-title { color: var(--ink); overflow: hidden; text-overflow: ellipsis; }
.board-row-id { color: var(--faint); font-weight: 600; font-size: 11.5px; }
.board-row-who { font-size: 11px; color: var(--faint); }
.board-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; background: var(--faint); }
.board-dot.work { background: var(--ok-dot); }
.board-dot.blocked { background: var(--danger); }
.board-dot.review { background: var(--warn-ink); }
.board-dot.done { background: var(--ok); opacity: 0.55; }
.board-dot.idle { background: var(--faint); }
/* Expanded board: covers the session area — the roomy, Linear-shaped view. */
.board-overlay {
position: fixed; inset: 0; z-index: 60;
background: color-mix(in srgb, var(--paper) 55%, transparent);
display: flex; align-items: stretch; justify-content: center; padding: 26px 28px;
}
.board-overlay-panel {
flex: 1; max-width: 1280px; display: flex; flex-direction: column; min-height: 0;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); overflow: hidden;
}
.board-overlay-head {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.board-overlay-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13.5px; color: var(--ink); flex: 1; }
.board-overlay-space { color: var(--faint); font-weight: 400; font-size: 12px; }
.board-columns {
flex: 1; display: flex; gap: 12px; padding: 14px 16px; overflow: auto; min-height: 0;
}
.board-col { flex: 1 1 0; min-width: 210px; max-width: 320px; display: flex; flex-direction: column; min-height: 0; }
.board-col-head {
display: flex; align-items: center; gap: 7px; font-size: 11.5px; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding: 2px 4px 8px;
}
.board-col-head .board-dot { margin-top: 0; }
.board-col-count { margin-left: auto; color: var(--faint); font-weight: 500; }
.board-col-body { display: flex; flex-direction: column; gap: 8px; overflow: auto; padding-bottom: 8px; }
.board-col-empty { color: var(--faint); font-size: 12px; padding: 6px 4px; }
.board-card {
background: var(--paper); border: 1px solid var(--line); border-radius: 10px;
padding: 9px 11px; display: flex; flex-direction: column; gap: 5px;
}
.board-card-title { font-size: 12.5px; color: var(--ink); }
.board-card-criteria { font-size: 11.5px; color: var(--muted); }
.board-card-label { font-weight: 600; }
.board-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.board-card-who { font-size: 11px; color: var(--faint); }
.board-card-actions { display: flex; gap: 6px; }
.board-card-btn {
border: 1px solid var(--line-strong); background: transparent; color: var(--muted);
border-radius: 6px; padding: 2px 8px; font-size: 11px; cursor: pointer;
}
.board-card-btn:hover { color: var(--ink); border-color: var(--muted); }
/* Journal rail section */
.journal-list { display: flex; flex-direction: column; gap: 3px; }
.journal-row { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); padding: 3px 4px; }
.journal-case { color: var(--ink); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.journal-count { font-size: 11px; color: var(--faint); }
/* Plan gate (decomposition gate) — rides the dirreq-card frame. */
.plangate-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.plangate-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.plangate-title { font-weight: 600; font-size: 13px; color: var(--ink); }
.plangate-board { margin-left: auto; font-size: 11.5px; color: var(--faint); }
.plangate-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); }
.plangate-num { color: var(--faint); font-size: 12px; padding-top: 1px; }
.plangate-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.plangate-item-title { font-size: 12.5px; color: var(--ink); }
.plangate-ac { font-size: 11.5px; color: var(--muted); }
.plangate-ac b { font-weight: 600; }
.plangate-more {
display: flex; align-items: center; gap: 5px; border: 0; background: transparent;
color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0;
}
.plangate-note { font-size: 11.5px; color: var(--faint); }