feat(tasks-ui): migrate the Tasks page to shadcn/ui (Tailwind v4 + radix, lazy chunk)

Visual base replacement for the Tasks page after the 'hard shadows make a
dense graph unreadable' review. Behaviour, hooks, aria labels and i18n are
unchanged; the base layer is not.

- FOUNDATION: Tailwind v4 wired into tsdown's css-inline plugin (theme +
  utilities ONLY — preflight is excluded because the plugin's stylesheet is a
  global <style data-plugin> tag and would repaint the host page), shadcn
  tokens bridged to --dsw-* so the DSH theme keeps flipping light/dark with no
  dark: overrides, cn() util, components.json for the CLI, src/client/ui/.
- COMPONENTS: shadcn's component source vendored into src/client/ui (button,
  card, badge, separator, input, textarea, tooltip, popover, dropdown-menu,
  scroll-area, collapsible, toggle-group, skeleton, spinner, empty) with a
  documented local diff: lucide-react replaced by host IconXxx (no new icon
  dep), shadow-* dropped from non-floating variants, unused variants pruned,
  and React.forwardRef restored on Button — the host runs React 18, where
  radix's Slot cannot hand a ref to a plain function component (dev warns,
  production silently mis-anchors the floating layer).
- SHADOWS: static panels are now hairline + surface tiers with hover:bg-muted;
  shadows exist only on floating layers. Settled nodes recede by ink
  (text-muted-foreground/foreground-3) instead of opacity.
- DENSITY: node cards widened 132 -> 150px with a two-line clamped title
  (13px text in a 132px card left ~7 CJK characters), reserved height 46 -> 60.
- CHUNK: the whole page (graph, tree, task window, board, drawer, shadcn/radix
  layer and its stylesheet) moved into the lazy 'tasks' chunk. Core bundle
  988.5 -> 835.6 KiB (-153 KiB); the 709.8 KiB UI payload loads only when the
  Tasks tab opens.
- MODEL: workflow members whose childId is already a real node elsewhere are
  no longer synthesized into a duplicate id (React keys/edges), with a
  last-resort per-id dedupe.
- GUARDS: tests/ui-foundation.spec.ts, tests/ui-bundle.spec.ts (core/chunk
  split + size budget + no preflight/oklch/lucide), tests/ui-shadows.spec.ts,
  extended tests/theme.spec.ts (no colour literal / palette class in
  src/client/ui/** and the migrated files).
- REVIEW FIXES: portal popovers re-scoped under .dsw-tasks, graph loading
  state, 11px floor, error nodes no longer tint a whole border, TeamBoard's
  delete item is neutral (the window owns the two-step confirm).

Verified: typecheck / lint / 136 files 1502 tests / build bundle assertions /
mount e2e 7-7; browser harness 65-65 checks with 0 console errors.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 7cbfc766a1
commit f6cf73c4cf
43 changed files with 3394 additions and 2048 deletions
+41
View File
@@ -191,3 +191,44 @@ describe('tasksEdges', () => {
expect(kindOf('m1')).toBe('workflow')
})
})
describe('buildTasksModel: duplicate workflow members', () => {
it('never synthesizes a second node for a member another parent already lists', () => {
// `m1` is a real child of `a`, but a run started by `root` names it as a
// member — the node must stay single (React keys / edge map depend on it)
// and the run simply does not duplicate it on the canvas.
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'm1', childId: 'm1', outcome: 'completed' }] }],
})
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a')]),
a: catalog([child('m1', { activity: 'inactive' })]),
},
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
runs: [audit],
folded: false,
}))
const ids = model.map(node => node.id)
expect(ids.filter(id => id === 'm1')).toHaveLength(1)
expect(new Set(ids).size).toBe(ids.length)
// The real node keeps its catalog parent.
expect(model.find(node => node.id === 'm1')?.parentId).toBe('a')
})
it('still synthesizes a member no catalog knows', () => {
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'ghost member', childId: 'ghost' }] }],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([]) },
byId: { root: summary('root') },
runs: [audit],
folded: false,
}))
const ghost = model.find(node => node.id === 'ghost')
expect(ghost).toMatchObject({ parentId: 'run:run-1', synthesized: true })
})
})
+101
View File
@@ -157,3 +157,104 @@ describe('skin contract: the plugin owns no color of its own', () => {
}
})
})
/**
* Task-page migration guard (the shadcn/ui rework).
*
* The vendored components under `src/client/ui/**` and every migrated
* task-page module must take their colors from a DSH token — either directly
* (`var(--dsw-…)`) or through one of the shadcn aliases bridged in
* `src/client/ui/theme.css` (`--background`, `--border`, …). Three ways that
* can rot silently:
*
* 1. A color literal (`#hex`, `rgb()`, `oklch()`, …) — the host skin can no
* longer repaint it, and the value stops flipping with dark mode.
* 2. A Tailwind default-palette class (`bg-blue-500`, `text-white`) — its
* value is defined by Tailwind's own theme, not by DSH.
* 3. A `var()` in a migrated stylesheet pointing at neither a `--dsw-*` token
* nor a bridged alias — a typo falls back to the initial color.
*
* Comments are stripped first: prose legitimately quotes the old values (the
* Badge docstring records that upstream's `text-white` became the
* `--destructive-foreground` token), and upstream issue numbers look like
* three-digit hex colors.
*/
const VENDORED_UI_DIR = 'src/client/ui'
const MIGRATED_TASK_FILES = [
'src/client/SubagentView.tsx',
'src/client/SubagentView.module.css',
'src/client/TasksGraph.tsx',
'src/client/TasksTree.tsx',
'src/client/TaskWindow.tsx',
'src/client/TeamBoard.tsx',
'src/client/JobsDrawer.tsx',
'src/client/TasksPopovers.tsx',
'src/client/AnchoredPopover.tsx',
'src/client/tasks-shared.tsx',
'src/client/tasks-canvas.module.css',
]
/** Drop block comments and whole-line `//` comments (URLs/strings stay intact). */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
}
/** Any spelled-out color value (the skin contract's forbidden set). */
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color-mix|light-dark)\(/
/** Tailwind's default palette families, which are not DSH tokens. */
const PALETTE_CLASS
= /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|shadow|accent|caret|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?\b/
/** Paint declarations whose value must resolve to a token. */
const PAINT_DECLARATION
= /(?:^|;)\s*(background(?:-color|-image)?|color|border(?:-(?:top|right|bottom|left))?-color|border|outline(?:-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color|scrollbar-color)\s*:\s*([^;}]+)/g
/** A paint declaration naming no token at all may only be inert. */
const INERT_PAINT = /^(?:none|transparent|currentcolor|inherit|initial|unset|revert)$/i
/** Every variable the theme bridge defines (or forwards) — the alias allowlist. */
const THEME_ALIASES = new Set(
[...readFileSync(resolve(ROOT, `${VENDORED_UI_DIR}/theme.css`), 'utf8').matchAll(/--([a-z0-9-]+)\s*:/g)]
.map(match => match[1]),
)
/** `--dsw-*` is the contract; the bridged aliases and runtime vars are the only indirection. */
function tokenAllows(name: string): boolean {
return name.startsWith('dsw-') || name.startsWith('tw-') || name.startsWith('radix-') || THEME_ALIASES.has(name)
}
describe('skin contract: the migrated task page and the vendored ui/ own no color', () => {
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
.map(name => `${VENDORED_UI_DIR}/${name}`)
const files = [...uiFiles, ...MIGRATED_TASK_FILES]
it('covers the vendored components and the migrated task-page modules', () => {
expect(uiFiles.length).toBeGreaterThan(10)
for (const file of files) expect(readFileSync(resolve(ROOT, file), 'utf8').length, file).toBeGreaterThan(0)
})
it.each(files)('%s carries no color literal', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(COLOR_LITERAL)
})
it.each(files)('%s uses no Tailwind default-palette class', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(PALETTE_CLASS)
})
it('binds every paint declaration in the migrated stylesheets to a token', () => {
for (const file of files.filter(name => name.endsWith('.css'))) {
const styles = stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
for (const declaration of styles.matchAll(PAINT_DECLARATION)) {
const [, property, value] = declaration
const vars = [...(value ?? '').matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1] ?? '')
if (vars.length === 0) {
expect(INERT_PAINT.test((value ?? '').trim()), `${file}: ${property}: ${value}`).toBe(true)
continue
}
for (const name of vars) expect(tokenAllows(name), `${file}: ${property}: var(--${name})`).toBe(true)
}
}
})
})
+125
View File
@@ -0,0 +1,125 @@
/**
* Built-artifact gate for the Tailwind v4 + shadcn/ui migration.
*
* The Tasks page — the workflow graph, the tree, the task window, the team
* board, the jobs drawer AND the vendored shadcn/radix component layer — lives
* in the lazy `tasks` chunk (`lib/client-tasks.js`), so this gate checks BOTH
* artifacts:
*
* - **core** (`lib/client.js`): must stay free of the UI layer. No Tailwind
* utilities, no radix, no tailwind-merge — those are the chunk's payload.
* It also must never carry preflight (its CSS is injected into the host page
* globally, with no shadow root) nor a palette literal.
* - **chunk** (`lib/client-tasks.js`): carries the compiled Tailwind output
* (`--tw-*` plumbing plus the generated `.flex{}`), the bridged tokens and
* the radix components; preflight and oklch stay out of it for the same
* global-injection reason.
*
* Preflight is detected by its DECLARATION payload, not the selector spelling:
* the minifier rewrites `::before` to `:before`, Tailwind's own `@property`
* fallback shares the `*,:before,:after,::backdrop` selector list, and
* `::file-selector-button` is emitted legitimately by the `file:` variant
* (that false positive is why this check lives here rather than in a
* substring match).
*
* `lib/` is a build output, not a test prerequisite, so every spec SKIPS
* (loudly, via skipIf) when the artifact is absent instead of failing an
* unbuilt checkout or building behind the suite's back.
*/
// @vitest-environment node
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const CORE_BUNDLE = resolve(process.cwd(), 'lib/client.js')
const TASKS_CHUNK = resolve(process.cwd(), 'lib/client-tasks.js')
/** Tailwind's generated output: the utility class and its `--tw-*` plumbing. */
const TAILWIND_UTILITY = '.flex{'
const TAILWIND_CUSTOM_PROPERTY = '--tw-'
/** Preflight's universal reset, in the spellings Tailwind/minification emit. */
const PREFLIGHT_RESET_DECLARATIONS = /box-sizing:\s*border-box;\s*margin:\s*0/
const PREFLIGHT_SELECTORS = [/\*,::before,::after/, /\*, ::before, ::after/]
const PREFLIGHT_HTML_BLOCK = '-webkit-text-size-adjust'
/** The measured pre-migration core bundle (HEAD 12b4716, same toolchain). */
const PRE_MIGRATION_CORE_BYTES = 1_012_271
/** The migration's reviewed core allowance (chunk payload excluded). */
const CORE_BUDGET_BYTES = PRE_MIGRATION_CORE_BYTES + 250 * 1024
/** A bundle smaller than this lost the plugin entirely. */
const SANITY_FLOOR_BYTES = 500 * 1024
/** Every preflight signature, asserted absent from one artifact. */
function expectNoPreflight(source: string): void {
for (const selector of PREFLIGHT_SELECTORS) expect(source).not.toMatch(selector)
expect(source).not.toMatch(PREFLIGHT_RESET_DECLARATIONS)
expect(source).not.toContain(PREFLIGHT_HTML_BLOCK)
}
describe('lib/client.js: the core bundle stays free of the UI layer', () => {
const built = existsSync(CORE_BUNDLE)
const bundle = built ? readFileSync(CORE_BUNDLE, 'utf8') : ''
it.skipIf(!built)('carries no Tailwind utilities (they belong to the tasks chunk)', () => {
expect(bundle).not.toContain(TAILWIND_CUSTOM_PROPERTY)
expect(bundle).not.toContain(TAILWIND_UTILITY)
})
it.skipIf(!built)('carries neither radix nor tailwind-merge nor lucide', () => {
expect(bundle).not.toContain('radix-ui')
expect(bundle).not.toContain('tailwind-merge')
expect(bundle).not.toContain('lucide-react')
})
it.skipIf(!built)('carries no preflight reset and no palette literal', () => {
expectNoPreflight(bundle)
// Tailwind's own palette is oklch-based; any hit is a leaked value.
expect(bundle).not.toContain('oklch(')
})
it.skipIf(!built)('fits the reviewed core budget and reports its size', () => {
const bytes = Buffer.byteLength(bundle)
expect(bytes).toBeGreaterThan(SANITY_FLOOR_BYTES)
expect(bytes).toBeLessThanOrEqual(CORE_BUDGET_BYTES)
console.log(
`[ui-bundle] lib/client.js = ${bytes} bytes (${(bytes / 1024).toFixed(1)} KiB);`
+ ` pre-migration ${PRE_MIGRATION_CORE_BYTES} bytes; delta ${bytes - PRE_MIGRATION_CORE_BYTES}`,
)
})
})
describe('lib/client-tasks.js: the lazy chunk carries the shadcn layer', () => {
const built = existsSync(TASKS_CHUNK)
const chunk = built ? readFileSync(TASKS_CHUNK, 'utf8') : ''
it.skipIf(!built)('carries the compiled Tailwind output and the bridged tokens', () => {
expect(chunk).toContain(TAILWIND_CUSTOM_PROPERTY)
expect(chunk).toContain(TAILWIND_UTILITY)
// The bridge is what keeps the skin contract: shadcn variables point at
// DSH tokens, so no palette value is ever painted.
expect(chunk).toContain('var(--dsw-alias-state-business-primary)')
})
it.skipIf(!built)('carries the radix component layer', () => {
expect(chunk).toContain('radix-ui')
})
it.skipIf(!built)('carries no preflight reset and no palette literal', () => {
// The chunk's stylesheet is injected globally too, so the same rule holds.
expectNoPreflight(chunk)
expect(chunk).not.toContain('oklch(')
expect(chunk).not.toContain('lucide-react')
})
it.skipIf(!built)('assigns its global chunk registry slot', () => {
// chunk-loader.ts materializes the factory from this slot.
expect(chunk).toContain('__dshChunks__["tasks"]')
})
it.skipIf(!built)('reports its size for the gate', () => {
const bytes = Buffer.byteLength(chunk)
expect(bytes).toBeGreaterThan(SANITY_FLOOR_BYTES / 4)
console.log(`[ui-bundle] lib/client-tasks.js = ${bytes} bytes (${(bytes / 1024).toFixed(1)} KiB)`)
})
})
+205
View File
@@ -0,0 +1,205 @@
/**
* Tailwind v4 + shadcn/ui foundation guards.
*
* The plugin ships ONE global stylesheet (src/client/ui/theme.css, compiled by
* tsdown's css-inline plugin into `<style data-plugin>`), and the two ways that
* can go wrong are silent:
*
* 1. Someone adds `@import "tailwindcss"` (or preflight) — the reset then
* repaints the entire DSH host page, because plugin CSS is not scoped by a
* shadow root. The first test locks the import list and the layer names; the
* bundle test looks for preflight's own selectors in the built artifact.
* 2. Someone bridge a shadcn token to a literal instead of a `--dsw-*` token —
* a dark/light theme switch then leaves the plugin's controls unreadable.
* That is the skin contract (docs/external-plugin-guide.md §12).
*
* `@source` is also guarded: if it disappears, Tailwind falls back to scanning
* the project root (lib/, tests/, node_modules/), which both bloats the bundle
* and lets `lib/*.js` class-like strings feed the scanner.
*/
// @vitest-environment node
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { compileTailwind } from '../tsdown.config.ts'
const ROOT = process.cwd()
const THEME_CSS = resolve(ROOT, 'src/client/ui/theme.css')
const CLIENT_BUNDLE = resolve(ROOT, 'lib/client.js')
/** The lazy chunk that carries the Tasks page and the shadcn layer. */
const TASKS_CHUNK = resolve(ROOT, 'lib/client-tasks.js')
/** The shadcn token -> DSH token bridge, as specified by the design language. */
const TOKEN_BRIDGE: Array<[shadcn: string, dsw: string]> = [
['--background', '--dsw-alias-bg-base'],
['--foreground', '--dsw-alias-label-primary'],
['--card', '--dsw-alias-bg-layer-1'],
['--card-foreground', '--dsw-alias-label-primary'],
['--popover', '--dsw-alias-bg-layer-2'],
['--popover-foreground', '--dsw-alias-label-primary'],
['--primary', '--dsw-alias-state-business-primary'],
['--primary-foreground', '--dsw-alias-label-primary-foreground'],
['--secondary', '--dsw-alias-interactive-bg-hover'],
['--secondary-foreground', '--dsw-alias-label-primary'],
['--muted', '--dsw-alias-interactive-bg-hover'],
['--muted-foreground', '--dsw-alias-label-secondary'],
['--accent', '--dsw-alias-interactive-bg-hover'],
['--accent-foreground', '--dsw-alias-label-primary'],
['--destructive', '--dsw-alias-state-error-primary'],
['--destructive-foreground', '--dsw-alias-label-primary-foreground'],
['--border', '--dsw-alias-border-l4'],
['--input', '--dsw-alias-border-l4'],
['--ring', '--dsw-alias-state-business-primary'],
['--success', '--dsw-alias-state-success-primary'],
['--warning', '--dsw-alias-state-warn-primary'],
['--foreground-3', '--dsw-alias-label-tertiary'],
['--border-strong', '--dsw-alias-label-tertiary'],
]
describe('tailwind entry: theme + utilities only', () => {
const theme = readFileSync(THEME_CSS, 'utf8')
it('imports exactly the theme and utilities layers, and never preflight', () => {
const imports = [...theme.matchAll(/@import\s+"([^"]+)"\s+layer\(([^)]+)\);/g)]
.map(match => [match[1], match[2]])
expect(imports).toEqual([
['tailwindcss/theme.css', 'theme'],
['tailwindcss/utilities.css', 'utilities'],
])
// No bare `@import "tailwindcss"` and no stylesheet path that could be the
// reset (both forms pull preflight in; the file's own comment is prose).
expect(theme).not.toMatch(/@import\s+"tailwindcss"/)
expect(theme).not.toMatch(/@import\s+"[^"]*preflight/)
})
it('scans only the client TSX tree', () => {
expect(theme).toMatch(/@source\s+"\.\.\/\*\*\/\*\.tsx";/)
})
it('defines the dark variant from the host attribute, not a .dark class', () => {
expect(theme).toMatch(/@custom-variant\s+dark\s+\(&:where\(\[data-ds-dark-theme\],\s*\[data-ds-dark-theme\]\s*\*\)\);/)
expect(theme).not.toMatch(/@custom-variant\s+dark\s+\(&:where\(\.dark/)
})
it('bridges every shadcn token to a DSH token', () => {
for (const [name, dsw] of TOKEN_BRIDGE) {
expect(theme, name).toContain(`${name}: var(${dsw})`)
}
})
it('carries the radius scale and the scoped base reset', () => {
for (const [name, value] of [['--radius', '6px'], ['--radius-sm', '4px'], ['--radius-md', '6px'], ['--radius-lg', '8px'], ['--radius-xl', '12px']]) {
expect(theme, name).toContain(`${name}: ${value}`)
}
expect(theme).toMatch(/\.dsw-tasks\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/)
expect(theme).toMatch(/\.dsw-tasks\s+:where\(button, input, textarea, select\)\s*\{[^}]*font:\s*inherit/)
expect(theme).toMatch(/\.dsw-tasks\s+:where\(\*, \*::before, \*::after\)\s*\{[^}]*box-sizing:\s*border-box/)
// Motion is opt-out-able.
expect(theme).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
})
it('scopes every portal-rendered popover root back under .dsw-tasks', () => {
// The popovers are portaled to document.body, i.e. OUTSIDE the page root
// that carries `.dsw-tasks` — without the class on their own root the
// scoped base reset (font inheritance, box-sizing, focus-visible ring on
// bare controls) silently stops applying there. Regression: the first
// migration shipped TaskWindow with the class but not the two node
// popovers nor the job-output popover.
const portalContents = [
'src/client/TaskWindow.tsx',
'src/client/TasksPopovers.tsx',
'src/client/JobsDrawer.tsx',
]
for (const file of portalContents) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).toContain('dsw-tasks')
}
// The shell itself portals — that is the reason the class is needed.
const shell = readFileSync(resolve(ROOT, 'src/client/AnchoredPopover.tsx'), 'utf8')
expect(shell).toContain('createPortal')
})
it('holds the skin contract: no color literal anywhere in the entry', () => {
// Comments carry prose, not values — strip them before looking.
const withoutComments = theme.replace(/\/\*[\s\S]*?\*\//g, '')
expect(withoutComments).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(withoutComments).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|color-mix)\(/)
// Every custom-property value is either a DSH token, another token defined
// in this file (the @theme inline block), or a length from the radius
// scale — never a paint value of its own.
const definedElsewhere = new Set([
...TOKEN_BRIDGE.map(([name]) => name.slice(2)),
'radius', 'radius-sm', 'radius-md', 'radius-lg', 'radius-xl',
])
for (const declaration of withoutComments.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
const [, name, raw] = declaration
const value = (raw ?? '').trim()
const reference = value.match(/^var\(--([a-z0-9-]+)/)
const refName = reference?.[1]
const ok = (refName !== undefined && (refName.startsWith('dsw-') || definedElsewhere.has(refName)))
|| /^[0-9.]+(?:px|rem|em|%)$/.test(value)
expect(ok, `${name}: ${value}`).toBe(true)
}
})
})
describe('tailwind compile: the shared pipeline produces utilities bound to DSH tokens', () => {
it('emits utilities and the token indirection, and no preflight', async () => {
const css = await compileTailwind(readFileSync(THEME_CSS, 'utf8'), THEME_CSS, { minify: true })
expect(css).toMatch(/--tw-/)
expect(css).toMatch(/\.flex\{/)
expect(css).toContain('var(--dsw-alias-state-business-primary)')
expect(css).toContain('.dsw-tasks')
// Preflight's actual signature is its universal reset block. The selector
// spelling alone is not enough (minification rewrites `::before` to
// `:before`, and the utilities layer's own `*,:before,:after,::backdrop`
// @property fallback shares the selector list), and
// `::file-selector-button` is not a signature at all: Tailwind's `file:`
// variant emits it legitimately for the vendored Input's file button.
expect(css).not.toMatch(/box-sizing:\s*border-box;\s*margin:\s*0/)
expect(css).not.toContain('-webkit-text-size-adjust')
})
})
describe('built artifacts: tailwind inlined, no preflight, no new deps', () => {
const built = existsSync(CLIENT_BUNDLE)
// `pnpm build` is a gate command, not a test prerequisite; skip loudly rather
// than fail a checkout that has not been built yet.
//
// The Tasks page (and therefore the shadcn layer's stylesheet) lives in the
// lazy `tasks` chunk, so the compiled utilities are asserted against THAT
// artifact; tests/ui-bundle.spec.ts owns the core/chunk split contract.
const chunkBuilt = existsSync(TASKS_CHUNK)
it.skipIf(!chunkBuilt)('inlines the compiled utilities into the tasks chunk', () => {
const bundle = readFileSync(TASKS_CHUNK, 'utf8')
expect(bundle).toMatch(/--tw-/)
expect(bundle).toMatch(/\.flex\{/)
// The scoped base reset travels with the utilities.
expect(bundle).toContain('.dsw-tasks')
expect(bundle).toContain('--dsw-alias-state-business-primary')
})
it.skipIf(!built)('keeps the core bundle free of the tailwind layer', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
expect(bundle).not.toMatch(/--tw-/)
expect(bundle).not.toContain('radix-ui')
})
it.skipIf(!built)('ships no preflight reset', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
expect(bundle).not.toMatch(/\*,::before,::after/)
expect(bundle).not.toMatch(/\*, ::before, ::after/)
// The minifier rewrites `::before` to `:before`, so the selector spelling
// is not a complete signature — the reset's declaration payload is.
expect(bundle).not.toMatch(/box-sizing:\s*border-box;\s*margin:\s*0/)
expect(bundle).not.toContain('-webkit-text-size-adjust')
})
it.skipIf(!built)('ships no color literal from the tailwind palette', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
// Tailwind's default palette is defined in oklch; any hit means a palette
// value leaked into the sheet instead of a --dsw-* token.
expect(bundle).not.toContain('oklch(')
expect(bundle).not.toContain('lucide-react')
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* "Static panels cast no shadow" — the elevation half of the task page's
* design language.
*
* The pre-migration page gave every card, node and row its own tinted,
* shadowed box, which is what made a graph with many nodes look garish; the
* rework replaced elevation with 1px hairlines and the surface ladder
* (background / card / popover). Shadows survive only on layers that really
* float above the page and are positioned by their own anchor: the
* `AnchoredPopover` shell (the task window / node popover chassis), and the
* vendored `popover` / `dropdown-menu` / `tooltip` primitives, which render
* into a portal.
*
* The guard is deliberately source-level: a `shadow-*` utility dropped into a
* static panel is exactly the regression, and the class name is the only place
* it is visible (Tailwind generates the CSS at build time).
*/
// @vitest-environment node
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const ROOT = process.cwd()
const VENDORED_UI_DIR = 'src/client/ui'
/** Panels that are laid out IN the page: they may never paint a shadow. */
const STATIC_PANEL_FILES = [
'src/client/SubagentView.tsx',
'src/client/SubagentView.module.css',
'src/client/TasksGraph.tsx',
'src/client/TasksTree.tsx',
'src/client/TaskWindow.tsx',
'src/client/TeamBoard.tsx',
'src/client/JobsDrawer.tsx',
'src/client/TasksPopovers.tsx',
'src/client/tasks-shared.tsx',
'src/client/tasks-canvas.module.css',
]
/** The only files allowed to paint an elevation shadow (floating layers). */
const FLOATING_FILES = [
'src/client/AnchoredPopover.tsx',
`${VENDORED_UI_DIR}/popover.tsx`,
`${VENDORED_UI_DIR}/dropdown-menu.tsx`,
`${VENDORED_UI_DIR}/tooltip.tsx`,
]
/**
* Tailwind's elevation utilities (`shadow`, `shadow-md`, `shadow-[…]`,
* `shadow-inner`, …) — but NOT `box-shadow`, which is how the components spell
* the focus-ring transition (`transition-[color,box-shadow]`) and the ring
* plumbing (`--tw-shadow`).
*/
const SHADOW_UTILITY = /(?<![-\w])shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)|\[|\()/
/** A literal `box-shadow` declaration is the same defect in stylesheet form. */
const BOX_SHADOW_DECLARATION = /(?:^|;)\s*box-shadow\s*:/
/** Comments are prose about shadows ("no shadow — a node never floats"). */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
}
const read = (file: string) => stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
describe('task page: static panels cast no shadow', () => {
it.each(STATIC_PANEL_FILES)('%s carries no shadow utility', (file) => {
expect(read(file), file).not.toMatch(SHADOW_UTILITY)
})
it('no migrated stylesheet declares box-shadow', () => {
for (const file of [...STATIC_PANEL_FILES, ...FLOATING_FILES].filter(name => name.endsWith('.css'))) {
expect(read(file), file).not.toMatch(BOX_SHADOW_DECLARATION)
}
})
it('keeps the shadow in the floating allowlist only', () => {
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
.map(name => `${VENDORED_UI_DIR}/${name}`)
const surface = [...STATIC_PANEL_FILES, ...FLOATING_FILES, ...uiFiles]
for (const file of new Set(surface)) {
if (FLOATING_FILES.includes(file)) continue
expect(read(file), file).not.toMatch(SHADOW_UTILITY)
}
})
it('is not a dead allowlist: the popover shell really floats', () => {
// AnchoredPopover is the migration's own floating surface; if it stops
// carrying the shadow, this allowlist is hiding a regression elsewhere.
expect(read('src/client/AnchoredPopover.tsx')).toMatch(SHADOW_UTILITY)
})
})