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.
135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
/**
|
|
* Unit tests for the layered graph layout: parent centering, depth rows, the
|
|
* sibling-band WRAP that keeps a narrow sidebar readable (and the row
|
|
* reservation that stops a wrapped band from landing on a sibling's
|
|
* children), the live-line height, and the width-aware solver.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
GRAPH_GAP_Y,
|
|
GRAPH_LIVE_H,
|
|
GRAPH_NODE_H,
|
|
GRAPH_NODE_W,
|
|
GRAPH_PAD,
|
|
GRAPH_ROW_STRIDE,
|
|
bandColsFor,
|
|
layoutTasksGraph,
|
|
layoutTasksGraphForWidth,
|
|
} from '../src/client/tasks-graph-layout.ts'
|
|
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
|
|
|
|
/** An agent node. */
|
|
function agent(id: string, parentId?: string, live = false): TasksAgentNode {
|
|
return {
|
|
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
|
|
label: id, state: 'running', activity: 'running', current: false,
|
|
...(live ? { live: { text: 'x' } } : {}),
|
|
}
|
|
}
|
|
|
|
/** The row index of one laid-out node. */
|
|
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
|
|
const box = layout.boxes.get(id)
|
|
if (box === undefined) throw new Error(`no box for ${id}`)
|
|
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
|
|
}
|
|
|
|
describe('layoutTasksGraph', () => {
|
|
it('places the root on row 0 and children one row below', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
// A running agent reserves the live-line row (its "thinking" fallback).
|
|
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H + GRAPH_LIVE_H })
|
|
expect(layout.boxes.get('a')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
|
|
expect(layout.boxes.get('b')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
|
|
})
|
|
|
|
it('centers a parent over its children band', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
const a = layout.boxes.get('a')
|
|
const b = layout.boxes.get('b')
|
|
const root = layout.boxes.get('root')
|
|
expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined()
|
|
const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2
|
|
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
|
|
})
|
|
|
|
it('grows live rows and widens the canvas for deep/wide forests', () => {
|
|
const nodes: TasksNode[] = [
|
|
agent('root'), agent('a', 'root', true),
|
|
agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'),
|
|
]
|
|
const layout = layoutTasksGraph(nodes)
|
|
expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H)
|
|
expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W)
|
|
expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y))
|
|
})
|
|
|
|
it('gives every node a box even with a defensive orphan', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
expect(layout.boxes.get('root')).toBeDefined()
|
|
expect(layout.boxes.get('ghost')).toBeDefined()
|
|
})
|
|
|
|
it('wraps a wide sibling band into rows of maxBandCols', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
|
|
// Three wrapped rows of 2/2/1, never five on one line.
|
|
expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
|
|
expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
|
|
expect(layoutRow(layout, 'c4')).toBe(3)
|
|
// The canvas stays as narrow as one wrapped band.
|
|
expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
|
|
})
|
|
|
|
it('never lets a wrapped band land on a sibling subtree row', () => {
|
|
// root → [a (with a child), b, c, d] wrapped at 2 columns: the second
|
|
// band must start BELOW a's child, not on the same row as it.
|
|
const nodes: TasksNode[] = [
|
|
agent('root'), agent('a', 'root'), agent('b', 'root'),
|
|
agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
|
|
]
|
|
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
|
|
expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
|
|
expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
|
|
})
|
|
})
|
|
|
|
describe('bandColsFor / layoutTasksGraphForWidth', () => {
|
|
it('derives the column count from the container width', () => {
|
|
// The budget is measured at the READABILITY FLOOR, not at 1:1: the fit may
|
|
// scale the canvas down to 0.78 and stay readable, so a 360px panel keeps
|
|
// two 176px cards per row (scaled ~0.86) instead of collapsing into a
|
|
// one-card-per-row ladder. 720px fits four at the floor (the cap), and a
|
|
// panel too narrow for even two columns falls back to one.
|
|
expect(bandColsFor(360)).toBe(2)
|
|
expect(bandColsFor(720)).toBe(4)
|
|
expect(bandColsFor(120)).toBe(1)
|
|
})
|
|
|
|
it('prefers a flat band when the container is wide', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
const wide = layoutTasksGraphForWidth(nodes, 720)
|
|
// Four 176px cards fit one band inside the floor budget at 720px, so the
|
|
// whole sibling set stays on one row.
|
|
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
|
|
expect(wide.width).toBeLessThanOrEqual(720 / 0.78)
|
|
})
|
|
|
|
it('wraps instead of overflowing wildly when the container is narrow', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
// 360px (the narrowest native-sidebar panel) is where the anti-ladder
|
|
// rule matters most: two cards per band, scaled to fit.
|
|
const narrow = layoutTasksGraphForWidth(nodes, 360)
|
|
// Never wider than the tolerant budget (a small pan is allowed, a 3x
|
|
// overflow is not), and never a one-child-per-row ladder.
|
|
expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
|
|
expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
|
|
})
|
|
})
|