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.
301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
/**
|
||
* Live theme-token access tests — issue #90's guard.
|
||
*
|
||
* `effectiveTokenValue` must treat visually inert values (CSS reset
|
||
* keywords, `transparent`, and any color below the opacity floor) as UNSET
|
||
* so callers' `|| fallback` chains fire. Skin systems set global tokens like
|
||
* `--dsw-alias-bg-base` to `transparent` or translucent glass values (the
|
||
* dsh-web-ui skins use rgba 0.16–0.7); without this guard a
|
||
* truthy-but-inert value would leave the terminal/editor see-through over
|
||
* the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a
|
||
* skin's scoped 0.96 porcelain) pass through so the skin still controls the
|
||
* surface.
|
||
*/
|
||
// @vitest-environment jsdom
|
||
import { afterEach, describe, expect, it } from 'vitest'
|
||
import { readFileSync, readdirSync } from 'node:fs'
|
||
import { resolve, sep } from 'node:path'
|
||
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
|
||
|
||
afterEach(() => {
|
||
document.body.removeAttribute('style')
|
||
})
|
||
|
||
describe('colorAlpha', () => {
|
||
it('parses the hex family', () => {
|
||
expect(colorAlpha('#fff')).toBe(1)
|
||
expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255)
|
||
expect(colorAlpha('#112233')).toBe(1)
|
||
expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255)
|
||
})
|
||
|
||
it('parses rgb()/rgba() in comma and space syntax', () => {
|
||
expect(colorAlpha('rgb(1, 2, 3)')).toBe(1)
|
||
expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45)
|
||
expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7)
|
||
})
|
||
|
||
it('parses hsl()/hsla()', () => {
|
||
expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1)
|
||
expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3)
|
||
})
|
||
|
||
it('treats unparseable formats as opaque', () => {
|
||
expect(colorAlpha('white')).toBeNull()
|
||
expect(colorAlpha('')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('effectiveTokenValue', () => {
|
||
it('passes through real opaque paint values verbatim', () => {
|
||
document.body.style.setProperty('--probe', '#112233')
|
||
expect(effectiveTokenValue('--probe')).toBe('#112233')
|
||
// Effectively opaque translucency (a skin's scoped 0.96 porcelain glass)
|
||
// is a deliberate surface choice — it passes through.
|
||
document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)')
|
||
expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)')
|
||
})
|
||
|
||
it('treats transparent and CSS reset keywords as unset', () => {
|
||
for (const inert of ['transparent', 'initial', 'inherit', 'unset']) {
|
||
document.body.style.setProperty('--probe', inert)
|
||
expect(effectiveTokenValue('--probe'), inert).toBe('')
|
||
}
|
||
})
|
||
|
||
it('treats translucent glass values below the opacity floor as unset', () => {
|
||
// The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels;
|
||
// the terminal must fall back to an opaque background there.
|
||
for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) {
|
||
document.body.style.setProperty('--probe', glass)
|
||
expect(effectiveTokenValue('--probe'), glass).toBe('')
|
||
}
|
||
})
|
||
|
||
it('treats a missing token as unset', () => {
|
||
expect(effectiveTokenValue('--never-defined')).toBe('')
|
||
})
|
||
|
||
it('tokenValue still returns the raw value', () => {
|
||
document.body.style.setProperty('--probe', 'transparent')
|
||
expect(tokenValue('--probe')).toBe('transparent')
|
||
expect(effectiveTokenValue('--probe')).toBe('')
|
||
})
|
||
})
|
||
|
||
/**
|
||
* Skin contract (guide §12): every visual value rides a `--dsw-alias-*` /
|
||
* `--dsw-font-*` / `--ds-*` token, and the plugin draws no color of its own.
|
||
*
|
||
* File icons are no exception to check any more: the built-in set IS DSH's
|
||
* `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, 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']) {
|
||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||
expect(source, file).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
|
||
expect(source, file).not.toMatch(/\brgba?\(/)
|
||
}
|
||
})
|
||
|
||
it('the colored tab glyphs take their color from theme tokens, never from a literal', () => {
|
||
const module = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.tsx'), 'utf8')
|
||
expect(module).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
|
||
expect(module).not.toMatch(/\brgba?\(/)
|
||
// Each glyph is wrapped in a themed class, which is how the color arrives.
|
||
const styles = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.module.css'), 'utf8')
|
||
const classes = [...module.matchAll(/css\.([a-zA-Z]+)/g)].map(m => m[1])
|
||
expect(classes.length).toBeGreaterThan(0)
|
||
for (const name of new Set(classes)) expect(styles, name).toContain(`.${name}`)
|
||
// Every declaration that paints a color resolves to a token.
|
||
for (const declaration of styles.matchAll(/color:\s*([^;]+);/g)) {
|
||
expect(declaration[1], declaration[0]).toContain('var(--dsw-')
|
||
}
|
||
})
|
||
|
||
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
|
||
// paint. A literal here would drift away from the host under every skin.
|
||
const styles = readFileSync(resolve(ROOT, 'src/client/sidebar.module.css'), 'utf8')
|
||
const block = /\.paneCard \{([\s\S]*?)\n\}/.exec(styles)?.[1]
|
||
if (block === undefined) throw new Error('the .paneCard rule must exist in src/client/sidebar.module.css')
|
||
expect(block).toContain('border: 0.5px solid var(--dsw-alias-border-l4)')
|
||
expect(block).toContain('border-radius: 24px')
|
||
expect(block).toContain('background: var(--dsw-alias-bg-layer-1)')
|
||
expect(block).toContain('min-height: 56px')
|
||
expect(block).toContain('padding: 14px 20px')
|
||
// No paint of its own: every color-ish declaration resolves to a token,
|
||
// and the upstream-only spellings this repo must not copy are absent.
|
||
for (const declaration of block.matchAll(/(?:^|\n)\s*(?:background|border|color|font):\s*([^;]*);/g)) {
|
||
const value = declaration[1] ?? ''
|
||
// A literal here (hex, rgba, a named colour) would drift from the host
|
||
// under every skin, so any keyword whose value is not a token fails.
|
||
expect(
|
||
value === '' || value.includes('var(--dsw-'),
|
||
`${declaration[0].trim()} must paint through a --dsw-* token (got: ${value})`,
|
||
).toBe(true)
|
||
}
|
||
expect(block).not.toContain('--dsw-alias-bg-l1')
|
||
expect(block).not.toContain('--dsw-alias-bg-l2')
|
||
})
|
||
|
||
it('no icon dataset is shipped as a lazy chunk', () => {
|
||
const chunkDir = resolve(ROOT, 'src/client/chunks')
|
||
const chunks = readdirSync(chunkDir).filter(name => /\.tsx?$/.test(name))
|
||
for (const name of chunks) {
|
||
const source = readFileSync(resolve(chunkDir, name), 'utf8')
|
||
expect(source, name).not.toMatch(/#[0-9a-fA-F]{6}\b/)
|
||
}
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 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)
|
||
}
|
||
}
|
||
})
|
||
})
|