Real-device report: the Tasks page "looks like it has no CSS at all, with blurry borders". Two hypotheses were falsified before the real cause surfaced: the sheet IS delivered (the chunk carries both the CSS and its injector, and the bundle route serves it) and the cascade is NOT the problem (with the real host stylesheet plus the real compiled sheet, a controlled sample keeps our padding, radius, border, background and font; host CSS-module selectors only match host nodes, and its global element rules are element-level specificity). The cause is what we deliberately excluded: PREFLIGHT is also what normalizes form controls. Measured on a plugin icon button that sets only an icon size, the UA chrome survived as padding 1px 6px, border 2px outset, background ButtonFace (#efefef) and a 13.33px system font - gray beveled buttons with a soft double border, i.e. "no CSS". - ui/theme.css: a .dsw-tasks-scoped preflight subset (zero margins/padding, border width+style, background, appearance none, inherit color/spacing, cursor rules) inside @layer base, written with :where() so it stays at (0,1,0) and utilities in the later layer still win; plus an explicit "@layer theme, base, components, utilities;" statement - layer order follows first appearance, and without it the utilities import registered utilities BEFORE base, so the reset beat rounded-md (measured: square buttons). The built chunk's order was re-checked: properties, theme, base, components, utilities. - Stock shadcn restyle: static cards carry stock shadow-xs/shadow-sm again (floating layers keep shadow-lg), body/meta text is text-sm/text-xs instead of 11-13px, status uses stock Badge variants, controls use stock Button variants and sizes, row actions appear on hover/focus while staying keyboard reachable, node cards are 176x68 with shadow-xs. - bandColsFor budgets columns at the readability floor (width / 0.78) instead of 1:1, so a 360px panel keeps two columns; FIT_MIN_SCALE moved into the layout module as GRAPH_FIT_MIN_SCALE so the fit and the column budget share one constant. - Guards: tests/ui-foundation.spec.ts pins the scoped reset declarations and the layer-order statement; tests/ui-shadows.spec.ts now allows shadow-xs and shadow-sm on static panels and bans md and above; the theme guard's inert paint rule forced the reset to spell border-width/border-style instead of the "border: 0 solid" shorthand. Verified: typecheck and lint clean; 136 files / 1505 tests; build with the core/chunk bundle guards; mount e2e 7/7 (mount 444ms, longtasks 2 of 8); the visual mirror page (real host stylesheet + real components) re-shot at 360 and 720 for graph, tree and the 77-node stress fixture.
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
/**
|
|
* 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). 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. */
|
|
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm'])
|
|
|
|
/** 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/)
|
|
})
|
|
})
|