fix(tasks-ui): restore the scoped preflight subset and return the page to stock shadcn

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.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent e94da2d4e4
commit d1a9d520ae
14 changed files with 318 additions and 193 deletions
+10
View File
@@ -100,6 +100,11 @@ describe('layoutTasksGraph', () => {
describe('bandColsFor / layoutTasksGraphForWidth', () => {
it('derives the column count from the container width', () => {
// The budget is measured at the READABILITY FLOOR, not at 1:1: the fit may
// scale the canvas down to 0.78 and stay readable, so a 360px panel keeps
// two 176px cards per row (scaled ~0.86) instead of collapsing into a
// one-card-per-row ladder. 720px fits four at the floor (the cap), and a
// panel too narrow for even two columns falls back to one.
expect(bandColsFor(360)).toBe(2)
expect(bandColsFor(720)).toBe(4)
expect(bandColsFor(120)).toBe(1)
@@ -109,12 +114,17 @@ describe('bandColsFor / layoutTasksGraphForWidth', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
const wide = layoutTasksGraphForWidth(nodes, 720)
// Four 176px cards fit one band inside the floor budget at 720px, so the
// whole sibling set stays on one row.
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
expect(wide.width).toBeLessThanOrEqual(720 / 0.78)
})
it('wraps instead of overflowing wildly when the container is narrow', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
// 360px (the narrowest native-sidebar panel) is where the anti-ladder
// rule matters most: two cards per band, scaled to fit.
const narrow = layoutTasksGraphForWidth(nodes, 360)
// Never wider than the tolerant budget (a small pan is allowed, a 3x
// overflow is not), and never a one-child-per-row ladder.
+25
View File
@@ -119,6 +119,31 @@ describe('tailwind entry: theme + utilities only', () => {
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, '')
+37 -26
View File
@@ -1,15 +1,16 @@
/**
* "Static panels cast no shadow" — the elevation half of the task page's
* design language.
* Elevation guard of the task page, shadcn/ui stock edition.
*
* The pre-migration page gave every card, node and row its own tinted,
* shadowed box, which is what made a graph with many nodes look garish; the
* rework replaced elevation with 1px hairlines and the surface ladder
* (background / card / popover). Shadows survive only on layers that really
* float above the page and are positioned by their own anchor: the
* `AnchoredPopover` shell (the task window / node popover chassis), and the
* vendored `popover` / `dropdown-menu` / `tooltip` primitives, which render
* into a portal.
* 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
@@ -23,7 +24,7 @@ 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: they may never paint a shadow. */
/** 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',
@@ -37,7 +38,7 @@ const STATIC_PANEL_FILES = [
'src/client/tasks-canvas.module.css',
]
/** The only files allowed to paint an elevation shadow (floating layers). */
/** The only files allowed to paint a deeper elevation shadow (floating layers). */
const FLOATING_FILES = [
'src/client/AnchoredPopover.tsx',
`${VENDORED_UI_DIR}/popover.tsx`,
@@ -46,12 +47,20 @@ const FLOATING_FILES = [
]
/**
* Tailwind's elevation utilities (`shadow`, `shadow-md`, `shadow-[…]`,
* `shadow-inner`, …) — but NOT `box-shadow`, which is how the components spell
* the focus-ring transition (`transition-[color,box-shadow]`) and the ring
* plumbing (`--tw-shadow`).
* 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)|\[|\()/
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*:/
@@ -63,9 +72,9 @@ function stripComments(source: string): string {
const read = (file: string) => stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
describe('task page: static panels cast no shadow', () => {
it.each(STATIC_PANEL_FILES)('%s carries no shadow utility', (file) => {
expect(read(file), file).not.toMatch(SHADOW_UTILITY)
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', () => {
@@ -74,20 +83,22 @@ describe('task page: static panels cast no shadow', () => {
}
})
it('keeps the shadow in the floating allowlist only', () => {
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(read(file), file).not.toMatch(SHADOW_UTILITY)
expect(offendingShadows(read(file)), file).toEqual([])
}
})
it('is not a dead allowlist: the popover shell really floats', () => {
// AnchoredPopover is the migration's own floating surface; if it stops
// carrying the shadow, this allowlist is hiding a regression elsewhere.
expect(read('src/client/AnchoredPopover.tsx')).toMatch(SHADOW_UTILITY)
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/)
})
})