Files
apes-sidebar/tests/ui-bundle.spec.ts
T
Menghuan1918 f6cf73c4cf 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.
2026-09-27 22:18:19 +08:00

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)`)
})
})