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