revert(tasks-ui): drop the shadcn/Tailwind layer and return to the plugin's own UI system
shadcn's defaults are built for a standalone app: 14px body, 36px controls, solid primary fills and elevation read as a foreign surface next to this plugin's own tabs (files, changes, side chat, settings), which are 12/11/10px type, 28px controls, hairline borders and flat panels. Retuning the layer did not converge, so the layer goes. - Removed: src/client/ui/** (21 vendored components), components.json, scripts/ui-css.mjs, the Tailwind/PostCSS compile step in tsdown, and the deps tailwindcss / @tailwindcss/postcss / postcss / radix-ui / class-variance-authority / tailwind-merge. (diff and simple-icons stay: they patch undeclared bare imports of the host primitives, unrelated to shadcn.) - The seven page surfaces are back on the plugin's own base: *.module.css with --dsw-* tokens plus the host primitives (Button/Menu/Modal/Pill/Tag/Switch/ Input/Tooltip/StateDot/MarkdownText). Behaviour, data-* hooks, aria labels, i18n keys and the AnchoredPopover geometry/drag/dismiss contract are unchanged; the non-styling fixes landed during the shadcn era (model id dedupe, task-window CAS, drawer arming, popover dragging, the chunk packaging glob) were re-applied and are covered by the existing specs. - The tasks page returns to the core bundle: `tasks` left CHUNK_NAMES / CHUNKS / ChunkName, src/client/chunks/tasks.tsx is gone and tabs.tsx imports SubagentView directly. - The reverted stylesheet was retuned to the plugin's measured vocabulary (12/11/10px, 28px controls, border-l2 with border-l1 hairlines, hover fills, no static shadows, --dsw-shadow-lv2/3 on floating layers only) and verified against a 70-assertion real-browser contract; the harness's own two measurement defects were fixed with a falsification test proving the assertions still catch injected regressions (56/70, 14 FAIL). - Tests: ui-foundation / ui-bundle / ui-shadows (the shadcn guards) deleted, theme.spec scoped back to the plugin's own stylesheets, bundle-route and manifest-consistency chunk lists back to four. Docs: AGENTS.md and the plugin guide drop the Tailwind/shadcn guidance; the design doc marks those sections superseded and records why the layer was removed. Verified: typecheck/lint clean; 133 files / 1410 tests; core bundle 987.3 KiB with no radix-ui/--tw-/tailwind strings and no client-tasks.js; mount e2e 7/7; 20 real-browser screenshots at 360/720 for graph, tree, board, task window, drawer (including armed), both popovers and the 77-node fixture.
This commit is contained in:
@@ -83,10 +83,9 @@ function bundleId(file: string): string {
|
||||
|
||||
/**
|
||||
* The lazy chunk bundles, DERIVED from the host route's registry (never
|
||||
* hand-mirrored: the hand-written list drifted twice — `locale` was missing
|
||||
* from both this list and a later `package.json#files` cleanup, and `tasks`
|
||||
* shipped without being listed at all, which would have made the Tasks page
|
||||
* fail to load from a published tarball).
|
||||
* hand-mirrored: the hand-written list drifted once — `locale` was missing
|
||||
* from both this list and a later `package.json#files` cleanup, so a
|
||||
* published tarball could not serve it).
|
||||
*/
|
||||
const CHUNK_FILES = CHUNK_NAMES.map(name => `lib/client-${name}.js`)
|
||||
|
||||
|
||||
+43
-3
@@ -14,7 +14,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -91,12 +91,31 @@ describe('effectiveTokenValue', () => {
|
||||
* `FileTypeIcon` artwork from a platform module (the host owns those pixels
|
||||
* and its own palette), and everything the plugin renders around them —
|
||||
* including the colored tab glyphs — rides theme tokens. So the guard is
|
||||
* simply that no plugin module carries a color literal, and that no icon
|
||||
* dataset sneaked back in as a chunk.
|
||||
* simply that no plugin module carries a color literal, that every module
|
||||
* stylesheet paints `color` from a token, and that no icon dataset sneaked
|
||||
* back in as a chunk.
|
||||
*/
|
||||
// jsdom has no file:// import.meta.url; vitest runs from the repo root.
|
||||
const ROOT = process.cwd()
|
||||
|
||||
/** `color` as a property — never `background-color` / `scrollbar-color` / `-webkit-text-fill-color`. */
|
||||
const COLOR_PROPERTY = /(?:^|[;{\s])color\s*:\s*([^;}]+)/g
|
||||
|
||||
/** Values that name no paint of their own and are therefore exempt. */
|
||||
const INERT_COLOR = /^(?:inherit|currentcolor|transparent)$/i
|
||||
|
||||
/** A token allowed by the contract: the plugin's own `--dsw-*` or the host's `--ds-*`. */
|
||||
function isThemeToken(name: string): boolean {
|
||||
return name.startsWith('dsw-') || name.startsWith('ds-')
|
||||
}
|
||||
|
||||
/** Every `*.module.css` sheet under `src/client/`, as repo-relative POSIX paths. */
|
||||
function moduleStylesheets(): string[] {
|
||||
return readdirSync(resolve(ROOT, 'src/client'), { recursive: true, encoding: 'utf8' })
|
||||
.filter(name => name.endsWith('.module.css'))
|
||||
.map(name => `src/client/${name.split(sep).join('/')}`)
|
||||
}
|
||||
|
||||
describe('skin contract: the plugin owns no color of its own', () => {
|
||||
it('the icon modules carry no color literals', () => {
|
||||
for (const file of ['src/client/file-icons.tsx', 'src/client/icons.tsx']) {
|
||||
@@ -121,6 +140,27 @@ describe('skin contract: the plugin owns no color of its own', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('every module stylesheet paints `color` from a theme token', () => {
|
||||
const sheets = moduleStylesheets()
|
||||
// Guard the scan itself: a glob that silently matches nothing would make
|
||||
// this contract vacuous.
|
||||
expect(sheets.length).toBeGreaterThan(5)
|
||||
for (const file of sheets) {
|
||||
const styles = readFileSync(resolve(ROOT, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
for (const declaration of styles.matchAll(COLOR_PROPERTY)) {
|
||||
const value = declaration[1]!.trim()
|
||||
// `color: inherit` / `currentcolor` / `transparent` is the plugin
|
||||
// deliberately taking the surrounding color, not painting one.
|
||||
if (INERT_COLOR.test(value)) continue
|
||||
// Dereference every var() — `color-mix()` chains are fine as long as
|
||||
// the whole expression bottoms out in theme tokens.
|
||||
const tokens = [...value.matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1]!)
|
||||
expect(tokens.length, `${file}: color: ${value}`).toBeGreaterThan(0)
|
||||
for (const name of tokens) expect(isThemeToken(name), `${file}: color: ${value}`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('the empty-pane welcome capsule follows the host guide recipe', () => {
|
||||
// The card is the same surface as DSH's own guide capsule ("pick what
|
||||
// opens here"), so this pins the shared geometry and the token-only
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 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)`)
|
||||
})
|
||||
})
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* 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'],
|
||||
// The plugin's own vocabulary: 1px borders are border-l2 (10%), hairlines
|
||||
// border-l1 (4%). l4 is noticeably darker than every other tab's chrome.
|
||||
['--border', '--dsw-alias-border-l2'],
|
||||
['--input', '--dsw-alias-border-l2'],
|
||||
['--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('restores the scoped preflight subset that form controls need', () => {
|
||||
// Regression pin for a real-device report: "the Tasks page looks like it
|
||||
// has no CSS". The plugin cannot import preflight (it would repaint the
|
||||
// host page), but preflight is what normalizes form controls — without it
|
||||
// every plugin `<button>` kept the UA chrome measured in the real shell:
|
||||
// `padding: 1px 6px`, `border: 2px outset`, `background: ButtonFace`,
|
||||
// `font: 400 13.33px system-ui`. The scoped subset below removes exactly
|
||||
// that, at (0,1,0) so utilities in the later `utilities` layer still win.
|
||||
const reset = /\.dsw-tasks\s+:where\(button, input, select, textarea\)\s*\{([^}]*)\}/
|
||||
.exec(theme)?.[1] ?? ''
|
||||
for (const declaration of ['margin: 0', 'padding: 0', 'border: 0 solid', 'appearance: none']) {
|
||||
expect(reset, declaration).toContain(declaration)
|
||||
}
|
||||
})
|
||||
|
||||
it('declares the layer order before the imports that register the layers', () => {
|
||||
// The compiler orders layers by first appearance. Without the explicit
|
||||
// statement the `@import ... layer(utilities)` lines would register
|
||||
// `utilities` BEFORE the `base` block, making the scoped reset beat the
|
||||
// utilities (measured: a `rounded-md` button rendering square).
|
||||
expect(theme).toMatch(/@layer theme, base, components, utilities;/)
|
||||
expect(theme.indexOf('@layer theme, base, components, utilities;'))
|
||||
.toBeLessThan(theme.indexOf('@import "tailwindcss/theme.css"'))
|
||||
})
|
||||
|
||||
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)),
|
||||
// Non-bridged locals the bridge forwards to (e.g. `--border-subtle`),
|
||||
// read straight out of the file so they cannot drift out of the set.
|
||||
...[...withoutComments.matchAll(/^\s*--([a-z0-9-]+)\s*:/gm)].map(m => m[1]),
|
||||
'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)
|
||||
// `none` is how the plugin says "flat": static panels take no elevation
|
||||
// (the floating layers map to the host's --dsw-shadow-lv* tokens).
|
||||
|| value === 'none'
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Elevation guard of the task page, shadcn/ui stock edition.
|
||||
*
|
||||
* Static panels (cards, rows, the drawer) may carry the stock LIGHT shadows
|
||||
* only — `shadow-xs` (button-like compact surfaces such as graph node cards)
|
||||
* and `shadow-sm` (cards / panels); `shadow-none` (a reset) is fine. Anything heavier (`shadow-md|lg|xl|2xl`,
|
||||
* bare `shadow`, `shadow-inner`, arbitrary `shadow-[…]`) on a static panel is
|
||||
* the regression: elevation beyond the stock pair reads as a floating layer
|
||||
* that nothing anchors.
|
||||
*
|
||||
* Real floating layers keep their deeper shadows: 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: light stock shadows only. */
|
||||
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 a deeper 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`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Every shadow utility a file uses (Tailwind spellings, 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)|\[|\()?/g
|
||||
|
||||
/** The stock pair a static panel may paint. */
|
||||
// `shadow-none` is not an elevation at all (it clears the ring plumbing), so it
|
||||
// is allowed anywhere a light shadow is; upstream's input-group uses it to keep
|
||||
// nested controls flat inside the group's own surface.
|
||||
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm', 'shadow-none'])
|
||||
|
||||
/** Every non-allowed shadow utility in the source, for a readable failure. */
|
||||
function offendingShadows(source: string): string[] {
|
||||
return (source.match(SHADOW_UTILITY) ?? [])
|
||||
.filter(match => !STATIC_ALLOWED.has(match))
|
||||
}
|
||||
|
||||
/** 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 keep stock light shadows only', () => {
|
||||
it.each(STATIC_PANEL_FILES)('%s carries no shadow beyond shadow-xs/shadow-sm', (file) => {
|
||||
expect(offendingShadows(read(file)), file).toEqual([])
|
||||
})
|
||||
|
||||
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 deep shadows 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(offendingShadows(read(file)), file).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('is not a dead allowlist: the floating layers really carry deep shadows', () => {
|
||||
// If a floating file stops carrying its shadow, the allowlist is hiding a
|
||||
// regression elsewhere (or the elevation policy drifted back to flat).
|
||||
expect(read('src/client/AnchoredPopover.tsx')).toMatch(/(?<![-\w])shadow-lg/)
|
||||
expect(read(`${VENDORED_UI_DIR}/popover.tsx`)).toMatch(/(?<![-\w])shadow-md/)
|
||||
expect(read(`${VENDORED_UI_DIR}/dropdown-menu.tsx`)).toMatch(/(?<![-\w])shadow-lg/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user