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:
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user