Real-device report: the Tasks page "looks like it has no CSS at all, with blurry borders". Two hypotheses were falsified before the real cause surfaced: the sheet IS delivered (the chunk carries both the CSS and its injector, and the bundle route serves it) and the cascade is NOT the problem (with the real host stylesheet plus the real compiled sheet, a controlled sample keeps our padding, radius, border, background and font; host CSS-module selectors only match host nodes, and its global element rules are element-level specificity). The cause is what we deliberately excluded: PREFLIGHT is also what normalizes form controls. Measured on a plugin icon button that sets only an icon size, the UA chrome survived as padding 1px 6px, border 2px outset, background ButtonFace (#efefef) and a 13.33px system font - gray beveled buttons with a soft double border, i.e. "no CSS". - ui/theme.css: a .dsw-tasks-scoped preflight subset (zero margins/padding, border width+style, background, appearance none, inherit color/spacing, cursor rules) inside @layer base, written with :where() so it stays at (0,1,0) and utilities in the later layer still win; plus an explicit "@layer theme, base, components, utilities;" statement - layer order follows first appearance, and without it the utilities import registered utilities BEFORE base, so the reset beat rounded-md (measured: square buttons). The built chunk's order was re-checked: properties, theme, base, components, utilities. - Stock shadcn restyle: static cards carry stock shadow-xs/shadow-sm again (floating layers keep shadow-lg), body/meta text is text-sm/text-xs instead of 11-13px, status uses stock Badge variants, controls use stock Button variants and sizes, row actions appear on hover/focus while staying keyboard reachable, node cards are 176x68 with shadow-xs. - bandColsFor budgets columns at the readability floor (width / 0.78) instead of 1:1, so a 360px panel keeps two columns; FIT_MIN_SCALE moved into the layout module as GRAPH_FIT_MIN_SCALE so the fit and the column budget share one constant. - Guards: tests/ui-foundation.spec.ts pins the scoped reset declarations and the layer-order statement; tests/ui-shadows.spec.ts now allows shadow-xs and shadow-sm on static panels and bans md and above; the theme guard's inert paint rule forced the reset to spell border-width/border-style instead of the "border: 0 solid" shorthand. Verified: typecheck and lint clean; 136 files / 1505 tests; build with the core/chunk bundle guards; mount e2e 7/7 (mount 444ms, longtasks 2 of 8); the visual mirror page (real host stylesheet + real components) re-shot at 360 and 720 for graph, tree and the 77-node stress fixture.
215 lines
8.8 KiB
TypeScript
215 lines
8.8 KiB
TypeScript
/**
|
|
* 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). 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 = 176
|
|
/** Two title lines plus the mono meta line: the card clamps long agent names
|
|
* to two lines instead of ellipsizing them to a couple of characters, so the
|
|
* reserved height covers that (see NODE_TITLE in TasksGraph.tsx). */
|
|
export const GRAPH_NODE_H = 68
|
|
/** Extra height of an agent node carrying a live line. */
|
|
export const GRAPH_LIVE_H = 14
|
|
|
|
/**
|
|
* The readability floor the fit honours: the canvas may shrink to this scale
|
|
* before the layout starts wrapping (TasksGraph's fit and {@link bandColsFor}'s
|
|
* column budget must agree, so the constant lives here, next to the geometry).
|
|
*/
|
|
export const GRAPH_FIT_MIN_SCALE = 0.78
|
|
export const GRAPH_GAP_X = 28
|
|
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 {
|
|
id: string
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}
|
|
|
|
/** The layout result: one box per node plus the canvas extent. */
|
|
export interface GraphLayout {
|
|
boxes: ReadonlyMap<string, GraphBox>
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
/** 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.state === 'running') return GRAPH_NODE_H + GRAPH_LIVE_H
|
|
return GRAPH_NODE_H
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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)
|
|
if (child === undefined) continue
|
|
const list = childrenOf.get(edge.from)
|
|
if (list === undefined) childrenOf.set(edge.from, [child])
|
|
else list.push(child)
|
|
}
|
|
|
|
/** Post-order: the width of the subtree rooted at `node`. */
|
|
const subtreeWidth = (node: TasksNode): number => {
|
|
const children = childrenOf.get(node.id) ?? []
|
|
if (children.length === 0) return GRAPH_NODE_W
|
|
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>()
|
|
/**
|
|
* 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 + row * GRAPH_ROW_STRIDE,
|
|
w: GRAPH_NODE_W,
|
|
h: nodeHeight(node),
|
|
})
|
|
const children = childrenOf.get(node.id) ?? []
|
|
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
|
|
// defensive orphan still lands inside the canvas extent.
|
|
let offset = GRAPH_PAD
|
|
for (const root of roots) {
|
|
const width = subtreeWidth(root)
|
|
place(root, offset + width / 2, 0)
|
|
offset += width + GRAPH_GAP_X
|
|
}
|
|
|
|
const allBoxes = [...boxes.values()]
|
|
const width = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_W, ...allBoxes.map(box => box.x + box.w + GRAPH_PAD))
|
|
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, minScale = GRAPH_FIT_MIN_SCALE): number {
|
|
// Columns are budgeted at the readability floor, not at 1:1: the fit may
|
|
// scale the canvas DOWN to `minScale` and still be readable, so a narrow
|
|
// panel keeps two columns (scaled ~0.86 at 360px) instead of degenerating
|
|
// into a one-card-per-row ladder. `layoutTasksGraphForWidth` re-checks the
|
|
// real width against the same budget, so a too-wide pick is still narrowed.
|
|
const usable = (containerWidth <= 0 ? 360 : containerWidth) / minScale - 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 })
|
|
}
|