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.
126 lines
5.5 KiB
TypeScript
126 lines
5.5 KiB
TypeScript
/**
|
|
* 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)`)
|
|
})
|
|
})
|