fix(tasks-ui): postmodern chrome, working clicks, adaptive layout, lean cards

Review feedback from the live 3384 install, fixed at the root:

- COLORS: the new stylesheet used var(--dsw-alias-accent) in 17 places — a
  token the DSH theme does NOT define, so every declaration was dropped
  (older files only survived because they carry fallbacks); container edges
  used border-l1 (4% black, invisible). Accent now rides
  --dsw-alias-state-business-primary, container edges --dsw-alias-border-l4.
- CLICKS: the canvas called setPointerCapture on pointerdown, which
  retargets the derived click to the container and killed every node (and
  ⓘ) interaction; jsdom cannot see this, a real browser can. Capture is
  gone and panning only starts from the background.
- LAYOUT: fit gave up silently at zero container size and never retried,
  centered only horizontally, and capped at 100% while a five-child level
  scaled to 35%. Now: ResizeObserver + first non-zero fit, both axes
  centered, a 0.78 readability floor, and layoutTasksGraphForWidth picks
  the flattest sibling arrangement that still meets that floor.
- CARDS: title (one line) + mono meta + live line only; display title,
  team role, full model id and latest text moved into the ⓘ popover.
- PANEL: metrics follow the approved 360x660 mockup (132x46 cards, 112 row
  stride), explicit line-heights, user-select:none, horizontal control
  cluster with a labelled mode button, always-visible team board strip.
- Tokens for two new popover headings across all 20 dictionaries.

Tests: 3 new page-interaction specs (click after background pointerdown,
node gestures never pan, team board visible without a click) and a
rewritten 9-case layout suite (band wrap, row reservation, width solver,
reserved live row). Full suite 1400 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 9b6628602e
commit 490e96080a
33 changed files with 1744 additions and 1046 deletions
+129 -38
View File
@@ -1,22 +1,29 @@
/**
* The layered top-down layout of the Tasks graph canvas: a tidy-tree
* simplification — every node sits one row below its parent, siblings share
* a row in model order, and each parent is horizontally centered over its
* children's band. Pure and dependency-free (the bundle-purity rule forbids
* pulling a graph library into the core bundle; <100-node trees need no
* virtualisation), unit-testable in the node environment.
* The layered top-down layout of the Tasks graph canvas: a tidy-tree pass
* with a NARROW-PANEL rule — a parent's children wrap into bands of at most
* {@link LayoutOptions.maxBandCols} columns, stacking extra rows below it.
* Without wrapping a five-child level is ~770px wide and the whole graph
* shrinks to ~40% inside the 360px native sidebar, which is unreadable; with
* wrapping the canvas keeps a ~1:1 scale and the reader pans far less.
*
* Pure and dependency-free (the bundle-purity rule forbids a graph library in
* the core bundle; <100-node trees need no virtualisation).
*/
import type { TasksNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts'
/** Geometry constants of the canvas (px, pre-scale). */
export const GRAPH_NODE_W = 140
export const GRAPH_NODE_H = 48
/** Geometry constants of the canvas (px, pre-scale). Metrics follow the
* approved Variant-D mockup, whose design target is the NARROW native right
* sidebar (~360px): a 132px card fits two per row plus the gutter. */
export const GRAPH_NODE_W = 132
export const GRAPH_NODE_H = 46
/** Extra height of an agent node carrying a live line. */
export const GRAPH_LIVE_H = 16
export const GRAPH_LIVE_H = 13
export const GRAPH_GAP_X = 28
export const GRAPH_GAP_Y = 72
export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16
/** The row stride (one row of cards plus the vertical gutter). */
export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
/** One laid-out node. */
export interface GraphBox {
@@ -34,22 +41,46 @@ export interface GraphLayout {
height: number
}
/** The display height of one node (live lines grow agent rows). */
/** Layout knobs. */
export interface LayoutOptions {
/** Columns per sibling band before it wraps to the next row (default 4). */
maxBandCols?: number
}
/**
* The display height of one node. Every RUNNING agent reserves the live-line
* row: a running node without activity yet still renders the "thinking" line
* (LiveLine's fallback), so reserving only when data arrived would clip it.
*/
function nodeHeight(node: TasksNode): number {
if (node.kind === 'agent' && node.live !== undefined) return GRAPH_NODE_H + GRAPH_LIVE_H
if (node.kind === 'agent' && node.state === 'running') return GRAPH_NODE_H + GRAPH_LIVE_H
return GRAPH_NODE_H
}
/**
* Lay out the model top-down. Nodes whose parent is missing (a fold node
* whose parent scrolled out of the model — defensive) attach to the first
* root. The algorithm is the classic two-walk tidy pass: a post-order walk
* accumulates each subtree's width, a pre-order walk assigns x so every
* parent centers over its children.
* @param nodes - the model's pre-order node list (roots = parentless rows).
* Chunk one sibling band into rows of at most `cols` children. Counting (not
* width-packing) keeps the graph SHAPE: a level stays a row of siblings, and
* a nested subtree never forces its aunts into a ladder of one-child rows.
* Wide subtrees are handled by the fit scale instead.
*/
export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const roots = nodes.filter(node => node.parentId === undefined || !nodes.some(candidate => candidate.id === node.parentId))
function bands(children: readonly TasksNode[], cols: number): TasksNode[][] {
if (children.length <= cols) return [children.slice()]
const out: TasksNode[][] = []
for (let index = 0; index < children.length; index += cols) {
out.push(children.slice(index, index + cols))
}
return out
}
/**
* Lay out the model top-down, wrapping wide sibling bands.
* @param nodes - the model's pre-order node list (roots = parentless rows).
* @param options - see {@link LayoutOptions}.
*/
export function layoutTasksGraph(nodes: readonly TasksNode[], options: LayoutOptions = {}): GraphLayout {
const cols = Math.max(1, options.maxBandCols ?? 4)
const ids = new Set(nodes.map(node => node.id))
const roots = nodes.filter(node => node.parentId === undefined || !ids.has(node.parentId))
const childrenOf = new Map<string, TasksNode[]>()
for (const edge of tasksEdges(nodes)) {
const child = nodes.find(node => node.id === edge.to)
@@ -63,33 +94,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const subtreeWidth = (node: TasksNode): number => {
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return GRAPH_NODE_W
const band = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
return Math.max(GRAPH_NODE_W, band)
let widest = GRAPH_NODE_W
for (const band of bands(children, cols)) {
const width = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (band.length - 1)
widest = Math.max(widest, width)
}
return widest
}
const boxes = new Map<string, GraphBox>()
/** Pre-order: place `node` so its band centers on `centerX`; `depth` is
* the row (the tallest ancestor row so far offsets uniformly). */
const place = (node: TasksNode, centerX: number, depth: number): void => {
const h = nodeHeight(node)
/**
* Place `node` centered on `centerX` at row `row`; returns the LAST row its
* subtree occupies. Wrapped bands advance to the row after the deepest row
* of the previous band, so a sibling's children can never land on top of
* the next band (the overlap a naive +1 counter produced).
*/
const place = (node: TasksNode, centerX: number, row: number): number => {
boxes.set(node.id, {
id: node.id,
x: Math.round(centerX - GRAPH_NODE_W / 2),
y: GRAPH_PAD + depth * (GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y),
y: GRAPH_PAD + row * GRAPH_ROW_STRIDE,
w: GRAPH_NODE_W,
h,
h: nodeHeight(node),
})
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return
const total = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
let cursor = centerX - total / 2
for (const child of children) {
const width = subtreeWidth(child)
place(child, cursor + width / 2, depth + 1)
cursor += width + GRAPH_GAP_X
if (children.length === 0) return row
let deepest = row
let bandRow = row + 1
for (const band of bands(children, cols)) {
const bandWidth = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (band.length - 1)
let cursor = centerX - bandWidth / 2
let bandDeepest = bandRow
for (const child of band) {
const width = subtreeWidth(child)
bandDeepest = Math.max(bandDeepest, place(child, cursor + width / 2, bandRow))
cursor += width + GRAPH_GAP_X
}
deepest = Math.max(deepest, bandDeepest)
bandRow = bandDeepest + 1
}
return deepest
}
// Single-root forest in practice (the topology root); the loop stays so a
@@ -106,3 +152,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const height = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_H, ...allBoxes.map(box => box.y + box.h + GRAPH_PAD))
return { boxes, width, height }
}
/**
* How many sibling columns fit the given container width at ~1:1 scale. The
* canvas keeps its readability floor instead of shrinking: at 360px two cards
* per row, at 720px four.
*/
export function bandColsFor(containerWidth: number): number {
const usable = containerWidth - GRAPH_PAD * 2
const cols = Math.floor((usable + GRAPH_GAP_X) / (GRAPH_NODE_W + GRAPH_GAP_X))
return Math.min(4, Math.max(1, cols))
}
/**
* The layout for one container width: the WIDEST sibling arrangement whose
* canvas still fits `containerWidth / minScale` — i.e. the least-tall layout
* the reader can see without zooming below the readability floor. Wrapping
* more than needed turns the graph into a ladder; wrapping too little forces
* an unreadable scale, so the loop tries the widest first and narrows.
*
* @param nodes - the model's node list.
* @param containerWidth - the canvas viewport width in px.
* @param minScale - the readability floor the fit honours (default 0.78).
*/
export function layoutTasksGraphForWidth(
nodes: readonly TasksNode[],
containerWidth: number,
minScale = 0.78,
): GraphLayout {
const budget = containerWidth <= 0 ? Number.POSITIVE_INFINITY : (containerWidth - 24) / minScale
const widest = bandColsFor(containerWidth <= 0 ? 360 : containerWidth)
// Pass 1: the least-tall arrangement that FITS the readability budget at
// two or more columns (below two the graph degenerates into a ladder).
for (let cols = widest; cols >= 2; cols -= 1) {
const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
if (layout.width <= budget) return layout
}
// Pass 2: nothing flat fits — accept a slight horizontal overflow (the
// reader pans a little) rather than stacking every child vertically.
const tolerant = budget * 1.2
for (let cols = widest; cols >= 2; cols -= 1) {
const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
if (layout.width <= tolerant) return layout
}
return layoutTasksGraph(nodes, { maxBandCols: 1 })
}