Stock shadcn defaults are designed for a standalone app; next to this plugin's other tabs (files, changes, side chat, settings) they read as foreign: 14px body against 12px, 36px controls against 28px, #00000029 borders against the plugin's 10%/4% hairlines, solid primary badges against flat state tints, and elevation where every other panel is flat. Measured the plugin's own vocabulary from its CSS modules (12px body / 11px meta / 10px micro; 16-20px line heights; 28px controls; 6x8 and 6x10 paddings; 8/6/4 gaps; 6/8px radii; `border-l2` for 1px borders and `border-l1` for hairlines; `--dsw-shadow-lv2/3` only on floating layers) and mapped the vendored set onto it instead of editing every page: - theme.css bridge: `--border`/`--input` -> `border-l2` (l4 was visibly darker than every other tab), new `--border-subtle` -> `border-l1`, the type scale remapped (text-xs 11px, text-sm 12px, text-base 13px, text-lg 14px), elevation mapped to the plugin's tokens (static shadows none, md/lg -> --dsw-shadow-lv2 /lv3), and the state tints bridged. - component defaults retuned to the same scale: button sizes 28/24/20px with text-xs, card padding 12px with gap-3 and no shadow, input 28px, badge 18px with 11px ink, empty at p-4, separators on the hairline token. - badges: the default variant is the plugin's flat state tint (bg-state-business-tertiary + state ink, the same recipe sidebar.module.css uses for revealed rows) rather than a solid primary fill. Guards updated with the reasoning inline: the border bridge expectations, a `none` value being the plugin's way of saying "flat", and the skin-contract check now derives local token names from the file instead of a hand-kept list. Verified: typecheck/lint clean; 136 files / 1515 tests; build with the bundle guards; mount e2e 7/7; mirror-page screenshots (real host stylesheet + real components) re-shot at 360/720 for graph, tree, board and the 77-node fixture.
239 lines
12 KiB
TypeScript
239 lines
12 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|