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:
@@ -331,6 +331,29 @@ npx -y shadcn@latest add button card badge separator input textarea tooltip popo
|
||||
|
||||
新增的守护文件:`tests/ui-foundation.spec.ts`(入口 import 清单 / 令牌桥全表 / 级联层 / 作用域根类 / `@source`)、`tests/ui-bundle.spec.ts`(核心包与 chunk 的双向产物断言 + 体积预算)、`tests/ui-shadows.spec.ts`(静态面板零阴影)。`tests/theme.spec.ts` 追加「迁移后皮肤契约」一节:`src/client/ui/**` 与迁移文件的**颜色字面量**、**Tailwind 默认调色板类**与**未解析 `var()`** 三类回归。
|
||||
|
||||
### 真机"完全没有 CSS"的根因:缺 preflight 导致表单控件保留 UA 样式(2026-09-20)
|
||||
|
||||
真机反馈:任务页"看起来完全没有 CSS,边框非常模糊"。第一反应是 Tailwind 没下发或级联被宿主压过,**两个假设都被实验否掉**:
|
||||
|
||||
- **样式确实下发**:chunk 里含注入代码与完整 CSS 文本,真机资源清单里 `/sidebar/bundle/tasks.js` 200;页面能看出我们的两行标题截断、Badge、ToggleGroup 等布局。
|
||||
- **级联不是根因**:用真实宿主样式表(`dsh-web-frontend` 的 index+vendor CSS)+真实编译产物做受控样本,`padding/radius/border/background/font` 全部是我们的值胜出;宿主 CSS 模块是哈希类名(`button._cfgyt_4`)只命中宿主自己的元素,全局 `button{}` 只有元素级特异性(0,0,1)压不过工具类(0,1,0)。
|
||||
|
||||
**真根因**:我们**刻意排除 preflight**(它会重绘整个宿主页面),但 preflight 同时负责**归零表单控件**。于是插件里的 `<button>` 全部保留浏览器 UA 外观 —— 在真实浏览器里对一个只写了 `size-7` 的图标按钮实测:`padding: 1px 6px`、`border: 2px outset`、`background: ButtonFace`(#efefef)、`font: 400 13.33px system-ui`。这正是"灰扑扑的立体小方块 + 发虚的双线边框 + 字号不搭"的来源;`<input>`/`<textarea>`/`<select>` 同理。
|
||||
|
||||
**修法**(`src/client/ui/theme.css`):在 `@layer base` 内补一份**限定 `.dsw-tasks` 作用域**的 preflight 子集(`margin/padding` 归零、`border-width/style` 归零、`background-color/-image` 归零、`color/letter-spacing: inherit`、`appearance: none`、`cursor: pointer`、`:disabled` 默认光标),全部写在 `:where()` 里保持 (0,1,0),与工具类同特异性但在**更晚的 `utilities` 层**,所以只清掉"没人要的" UA 外观、不抢我们自己的类。同时**显式声明 `@layer theme, base, components, utilities;`**:层序按首次出现决定,否则 `@import ... layer(utilities)` 会把 utilities 注册在 `base` 之前,让 reset 反过来压住 `rounded-md`(实测按钮渲染成直角)。产物里已核对层序为 `properties → theme → base → components → utilities`。
|
||||
|
||||
**为什么两层验证都漏了**:本地可视化 harness 的页面没有宿主样式、也没人量过 `getComputedStyle`,UA 外观被当成"我的极简风格";挂载冒烟断言的是"不崩 + 无 console 错误",样式正确性不在其内。**因此把"真实宿主样式表 + 真实编译产物 + 真实组件"的镜像页面加入视觉验证流程**(`index-host.html`),并补两条源码级守卫(`tests/ui-foundation.spec.ts`:作用域 reset 的声明必须存在;层序声明必须出现在 import 之前)。
|
||||
|
||||
### shadcn/ui stock 风格回调(2026-09-20)
|
||||
|
||||
用户的明确诉求是"**shadcn/ui 默认风格就行**"——此前那套"零阴影 + 1px 细线 + 11–13px 字"的极简取向被判定为"像没上样式"。回调原则:**stock 组件默认值优先,只保留画布必需的紧凑化**。改动(11 个文件):
|
||||
|
||||
- **阴影**:静态卡片/面板恢复 stock 轻阴影(按钮 `shadow-xs`、卡片/面板 `shadow-sm`);浮层继续 `shadow-lg`。`tests/ui-shadows.spec.ts` 的规则从"静态面板零阴影"改为"静态面板只允许 `shadow-xs`/`shadow-sm`,禁用 `shadow-md` 及以上与裸 `box-shadow`",浮层白名单不变且断言其非空转。
|
||||
- **字号**:正文 `text-sm`(14px)、meta `text-xs`(12px) 全面替换 `text-[13px]`/`text-[11px]`/`text-[10px]`;等宽只留 id/时长/工具参数。页面基准字号由 `.dsw-tasks` 的 `font-size: .875rem` 提供。
|
||||
- **组件**:状态一律用 vendored `Badge` 的 stock 变体(进行中 `default`、待办/完成 `secondary`、阻塞 `outline`+warning 墨色、错误 `destructive`);控制条与非图标按钮回到 `Button variant="outline" size="sm"`;行内操作(任务行菜单、抽屉终止)改为 hover/focus 显现但仍可键盘触达;任务窗口/面板内边距回到 stock(`p-4`/`p-6`)。
|
||||
- **画布**:节点卡 `rounded-lg border bg-card p-2.5 shadow-xs`,`GRAPH_NODE_W` 150→176、`GRAPH_NODE_H` 60→68(字号变大后需要宽度);相位框加 `bg-muted/40`。
|
||||
- **窄面板不再退化成单列**:`bandColsFor` 改为**按可读性下限预算列数**(`宽度/0.78` 而不是 1:1),360px 面板恢复两列(缩放 ~0.86);同时把 `FIT_MIN_SCALE` 提升为布局模块导出的 `GRAPH_FIT_MIN_SCALE`,让 fit 与列预算共用同一个下限常量。
|
||||
|
||||
### 迁移返工记录(门禁与审查发现)
|
||||
|
||||
**1. 体积 → 任务页整体下沉为懒加载 chunk。** 见上一节的返工说明:核心包从 988.5 KiB 降到 835.6 KiB(净减 153.0 KiB),shadcn/radix/Tailwind 产物(706.7 KiB)随 `lib/client-tasks.js` 按需加载。这次返工同时把 Tailwind 样式表挪进 chunk——它只服务任务页,没有理由占启动路径。
|
||||
|
||||
+30
-33
@@ -14,14 +14,14 @@
|
||||
* Visual language: Tailwind utilities over the shadcn tokens
|
||||
* (src/client/ui/theme.css) with the vendored Collapsible / ScrollArea /
|
||||
* Button for the interactive shells. Hierarchy comes from a 1px `border-border`
|
||||
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
|
||||
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
|
||||
* the static drawer carries no shadow; the only float is the popover card,
|
||||
* whose surface/shadow belong to AnchoredPopover. Body copy is 13px, meta
|
||||
* 11–12px, identifiers/durations `font-mono tabular-nums`.
|
||||
* whose surface/shadow belong to AnchoredPopover. Body copy is text-sm, meta
|
||||
* text-xs, identifiers/durations `font-mono tabular-nums`.
|
||||
*
|
||||
* Every non-shadcn control is a host primitive (Switch / StateDot), and every
|
||||
* icon is a host `IconXxx` glyph — the badges are the vendored shadcn `Badge`
|
||||
* on the page's shared outline + semantic-tone classes.
|
||||
* on its stock variants.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
@@ -51,15 +51,13 @@ const JOB_KILL_ARM_MS = 3000
|
||||
export const JOBS_DRAWER_COLLAPSE_AT = 8
|
||||
|
||||
/**
|
||||
* The status badge's semantic ink (the page's shared `outline` + tone class
|
||||
* pattern): a running job wears the accent, success/failure their own family,
|
||||
* and an ending/killed job stays neutral.
|
||||
* The status badge's stock shadcn variant: running = `default` (the accent),
|
||||
* failed = `destructive`, settled/ending = `secondary`.
|
||||
*/
|
||||
function statusTone(job: SidebarJobView): string {
|
||||
if (job.status === 'running') return 'border-primary/40 text-primary'
|
||||
if (job.status === 'completed') return 'border-success/40 text-success'
|
||||
if (job.status === 'failed') return 'border-destructive/40 text-destructive'
|
||||
return 'text-muted-foreground'
|
||||
function statusVariant(job: SidebarJobView): 'default' | 'secondary' | 'destructive' {
|
||||
if (job.status === 'running') return 'default'
|
||||
if (job.status === 'failed') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export interface JobsDrawerProps {
|
||||
@@ -136,10 +134,10 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
{/* The bar keeps the drawer's whole toggle affordance (title, running
|
||||
tally, count line) and Radix owns aria-expanded / aria-controls. */}
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-none border-0 bg-transparent px-3 py-[7px] text-left font-mono text-[11px] tracking-[0.12em] text-muted-foreground uppercase transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-none border-0 bg-transparent px-3 py-[7px] text-left font-mono text-xs tracking-[0.12em] text-muted-foreground uppercase transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<span>{t('jobs')}</span>
|
||||
<span className="text-[13px] font-semibold text-primary tabular-nums">
|
||||
<span className="text-sm font-semibold text-primary tabular-nums">
|
||||
{liveCount > 0 ? liveCount : rows.length}
|
||||
</span>
|
||||
<span className="tracking-[0.02em] text-foreground-3 normal-case">{countLabel}</span>
|
||||
@@ -154,7 +152,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
{!autoOpen && (
|
||||
<div className="px-3 pb-2 font-mono text-[11px] text-foreground-3">{t('jobsAutoCollapsed')}</div>
|
||||
<div className="px-3 pb-2 font-mono text-xs text-foreground-3">{t('jobsAutoCollapsed')}</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{/* Bounded log surface: one row per job, hairline-separated by the
|
||||
@@ -186,7 +184,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
<div
|
||||
key={job.id}
|
||||
className={cn(
|
||||
'flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
|
||||
'group flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
|
||||
'hover:bg-muted',
|
||||
openJobId === job.id && 'bg-muted',
|
||||
// A settled job recedes by ink (the label line drops to the
|
||||
@@ -199,7 +197,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-[7px] px-0 py-0 text-left text-[13px] font-normal tracking-normal normal-case"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-[7px] px-0 py-0 text-left text-sm font-normal tracking-normal normal-case"
|
||||
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
|
||||
title={t('jobViewOutput')}
|
||||
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
|
||||
@@ -209,19 +207,19 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
running state draws an svg, the others a span), so the
|
||||
indicator keeps its 6px. */}
|
||||
<StateDot state={jobDotState(job.status)} size={6} className="size-1.5" />
|
||||
<Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
|
||||
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate font-mono text-[13px]',
|
||||
'min-w-0 flex-1 truncate font-mono text-sm',
|
||||
live ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
title={job.label}
|
||||
>
|
||||
{job.label}
|
||||
</span>
|
||||
<Badge variant="outline" className={cn('h-4 flex-none px-1.5 py-0 text-[11px] font-normal', statusTone(job))}>
|
||||
<Badge variant={statusVariant(job)} className="flex-none">
|
||||
{jobStatusLabel(job.status, t)}
|
||||
</Badge>
|
||||
{context !== '' && (
|
||||
@@ -237,11 +235,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// `border-border` is explicit: preflight is never loaded
|
||||
// (see ui/theme.css), so a bare `border` here would take
|
||||
// `currentColor` instead of the hairline token.
|
||||
// Inline row action: quiet until the row is hovered, but
|
||||
// keyboard focus brings it back (focus-visible below).
|
||||
className={cn(
|
||||
'h-auto shrink-0 border-border px-1.5 py-0.5 font-mono text-[11px] font-normal text-muted-foreground',
|
||||
'h-auto shrink-0 font-mono text-xs font-normal text-muted-foreground',
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
|
||||
armed && 'border-destructive text-destructive',
|
||||
)}
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
@@ -258,7 +256,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
</Button>
|
||||
)}
|
||||
{killFailed && (
|
||||
<span className="shrink-0 font-mono text-[11px] text-destructive">{t('jobKillError')}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-destructive">{t('jobKillError')}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -363,14 +361,13 @@ export function JobOutputPopoverContent(props: {
|
||||
// `dsw-tasks`: this card renders inside AnchoredPopover's portal at
|
||||
// document.body — OUTSIDE the page root — so it re-declares the page root
|
||||
// class for the scoped base reset (see TaskWindow.tsx).
|
||||
<div className="dsw-tasks box-border flex flex-col gap-1 p-2.5 text-[13px] text-popover-foreground" data-popover-handle>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-[11px] tracking-[0.14em] text-foreground-3 uppercase">
|
||||
<div className="dsw-tasks box-border flex flex-col gap-1 p-2.5 text-sm text-popover-foreground" data-popover-handle>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-xs tracking-[0.14em] text-foreground-3 uppercase">
|
||||
<span>{t('jobs')}</span>
|
||||
<span className="inline-flex items-center gap-0.5 tracking-normal normal-case" data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0"
|
||||
size="icon"
|
||||
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
title={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
disabled={text === ''}
|
||||
@@ -387,15 +384,15 @@ export function JobOutputPopoverContent(props: {
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground" title={job.label}>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground" title={job.label}>
|
||||
{job.label}
|
||||
</span>
|
||||
<Badge variant="outline" className={cn('h-5 flex-none px-2 text-[11px]', statusTone(job))}>
|
||||
<Badge variant={statusVariant(job)} className="flex-none">
|
||||
{jobStatusLabel(job.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
|
||||
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
|
||||
@@ -435,7 +432,7 @@ export function JobOutputPopoverContent(props: {
|
||||
<Button
|
||||
variant={armed ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="border-border font-mono text-xs"
|
||||
className="font-mono text-xs"
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill()
|
||||
|
||||
@@ -428,7 +428,7 @@ export function SubagentView(props: {
|
||||
: ''}
|
||||
</span>
|
||||
{countLabel !== undefined && (
|
||||
<span className="flex-none font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{countLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -439,7 +439,7 @@ export function SubagentView(props: {
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 flex-none"
|
||||
className="flex-none"
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
disabled={rootId === undefined}
|
||||
@@ -451,7 +451,7 @@ export function SubagentView(props: {
|
||||
<IconRefreshOutline14 size={13} />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-[11px]">{t('refresh')}</TooltipContent>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('refresh')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
+24
-21
@@ -39,6 +39,7 @@ import { Button } from './ui/button.tsx'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
|
||||
import { Input } from './ui/input.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
import { Textarea } from './ui/textarea.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
|
||||
|
||||
@@ -56,13 +57,19 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
|
||||
}
|
||||
|
||||
/**
|
||||
* The status badge's ink — hue carries meaning only: done = success, running =
|
||||
* accent, blocked = warning, everything else stays on quiet meta ink.
|
||||
* The status badge's stock variant: in progress takes `default` (the accent),
|
||||
* everything else settles on `secondary`; a BLOCKED task wears `outline` with
|
||||
* warning ink — hue carries meaning only.
|
||||
*/
|
||||
function statusVariant(task: SidebarTeamTaskView): 'default' | 'secondary' | 'outline' {
|
||||
if (task.status === 'in_progress') return 'default'
|
||||
if (task.ready) return 'secondary'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
/** The blocked badge's warning ink (the `outline` variant is otherwise neutral). */
|
||||
function statusTone(task: SidebarTeamTaskView): string {
|
||||
if (task.status === 'completed') return 'border-success/40 text-success'
|
||||
if (task.status === 'in_progress') return 'border-primary/40 text-primary'
|
||||
return task.ready ? 'text-muted-foreground' : 'border-warning/40 text-warning'
|
||||
return task.ready ? '' : 'text-warning'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +167,10 @@ function TaskViewBody(props: {
|
||||
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={task.subject}>{task.subject}</span>
|
||||
<Badge variant="outline" className={`h-5 px-2 text-[11px] ${statusTone(task)}`}>
|
||||
<Badge
|
||||
variant={statusVariant(task)}
|
||||
className={cn('flex-none', statusTone(task))}
|
||||
>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -184,7 +194,7 @@ function TaskViewBody(props: {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskOwner')}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskOwner')}</span>
|
||||
<OwnerPicker
|
||||
members={props.teammates}
|
||||
owner={task.ownerName}
|
||||
@@ -206,7 +216,7 @@ function TaskEditBody(props: {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5" data-popover-no-drag>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskSubject')}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskSubject')}</span>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={props.subject}
|
||||
@@ -216,7 +226,7 @@ function TaskEditBody(props: {
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskDescription')}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskDescription')}</span>
|
||||
<MultilineField
|
||||
value={props.description}
|
||||
label={t('teamTaskDescription')}
|
||||
@@ -328,21 +338,17 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-[13px]"
|
||||
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-sm"
|
||||
data-popover-handle
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 px-3 py-2">
|
||||
<CardTitle className="min-w-0 flex-1 truncate font-mono text-[11px] font-medium text-foreground-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
|
||||
<CardTitle className="min-w-0 flex-1 truncate text-sm font-medium text-muted-foreground">
|
||||
{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
|
||||
</CardTitle>
|
||||
<div className="flex flex-none items-center gap-1" data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
// Load-bearing reset: the plugin ships no preflight, so a control
|
||||
// whose variant declares no paint keeps the UA's `buttonface` fill
|
||||
// and 2px `outset` border. A ghost icon button must be chrome-free.
|
||||
className="size-7 border-0 bg-transparent"
|
||||
aria-label={t('teamTaskCancel')}
|
||||
title={t('teamTaskCancel')}
|
||||
onClick={onClose}
|
||||
@@ -353,7 +359,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
<CardContent className="px-3 py-2.5">
|
||||
<CardContent className="px-4 py-3">
|
||||
{editing || task === undefined
|
||||
? (
|
||||
<TaskEditBody
|
||||
@@ -375,15 +381,12 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
<CardFooter className="flex-wrap justify-end gap-1.5 px-3 py-2" data-popover-no-drag>
|
||||
<CardFooter className="flex-wrap justify-end gap-1.5 px-4 py-3" data-popover-no-drag>
|
||||
{editing
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
// Same preflight reset as the ghost button: `default` declares no
|
||||
// border, so the UA's 2px `outset` edge would frame the fill.
|
||||
className="border-0"
|
||||
disabled={busy || subject.trim() === ''}
|
||||
onClick={save}
|
||||
>
|
||||
|
||||
+40
-43
@@ -13,15 +13,16 @@
|
||||
* on both axes and scaling UP to {@link FIT_MAX_SCALE} so a small tree fills
|
||||
* the narrow panel instead of hugging the top-left corner.
|
||||
*
|
||||
* Visual language: every node is a shadcn `Card` — a single 1px `border-border`
|
||||
* hairline over the surface ladder, `hover:bg-muted` as the only hover change,
|
||||
* no shadow (nothing here floats) and no card tint (the old double/dashed
|
||||
* borders and the filled workflow cards are gone; "current" is a 2px accent
|
||||
* Visual language: every node is a shadcn `Card` — the compact stock recipe
|
||||
* (`rounded-lg border bg-card p-2.5 shadow-xs`) with `hover:bg-muted` as the
|
||||
* only hover change and no card tint (the old double/dashed borders and the
|
||||
* filled workflow cards are gone; "current" is a 2px accent
|
||||
* bar, running is the host `StateDot` plus the live line). Statuses ride the
|
||||
* mono meta line, team membership is a `Badge`, a truncated line carries its
|
||||
* full text in a shadcn `Tooltip`, and the control cluster is the vendored
|
||||
* `Button` recipe (`ghost` / `sm`) with a tooltip per control. The phase frames
|
||||
* are 1px dashed `border-border` boxes and the relations are hairline strokes
|
||||
* `Button` recipe (`outline` / `sm`) with a tooltip per control. The phase
|
||||
* frames are dashed `border-border` boxes on `bg-muted/40` and the relations
|
||||
* are hairline strokes
|
||||
* in the border ink (see tasks-canvas.module.css for the canvas-only pieces:
|
||||
* the dotted grid, the transformed layer and the edge strokes).
|
||||
*
|
||||
@@ -44,14 +45,19 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { tasksEdges } from './tasks-model.ts'
|
||||
import { GRAPH_NODE_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts'
|
||||
import {
|
||||
GRAPH_FIT_MIN_SCALE,
|
||||
GRAPH_NODE_W,
|
||||
layoutTasksGraphForWidth,
|
||||
type GraphBox,
|
||||
} from './tasks-graph-layout.ts'
|
||||
import {
|
||||
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
|
||||
WorkflowGlyph, workflowMeta,
|
||||
} from './tasks-shared.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { buttonVariants } from './ui/button.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Card } from './ui/card.tsx'
|
||||
import { Spinner } from './ui/spinner.tsx'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx'
|
||||
@@ -65,28 +71,27 @@ const ZOOM_MAX = 1.5
|
||||
const FIT_MAX_SCALE = 1.15
|
||||
/** Lower bound of the auto-fit scale: below this the cards stop being
|
||||
* readable, so the canvas overflows and pans instead of shrinking further. */
|
||||
const FIT_MIN_SCALE = 0.78
|
||||
/** Drag-vs-click separation: pointer travel below this stays a click. */
|
||||
const CLICK_TOLERANCE_PX = 4
|
||||
|
||||
/**
|
||||
* The shared shell of every node card: absolute (the layout owns left/top),
|
||||
* one 1px hairline, the card surface, and `hover:bg-muted` as the only hover
|
||||
* change. No shadow — a node never floats — and no tint.
|
||||
* the compact stock node recipe — `rounded-lg border bg-card p-2.5 shadow-xs`
|
||||
* — with `hover:bg-muted` as the only hover change. No tint.
|
||||
*/
|
||||
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border-border bg-card px-2 py-1.5 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
/** The on-screen session: a 2px accent bar, never a tinted or shadowed card. */
|
||||
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border border-border bg-card p-2.5 shadow-xs transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
/** The on-screen session: a 2px accent bar, never a tinted card. */
|
||||
const NODE_ACCENT = 'border-l-2 border-l-primary'
|
||||
/** Settled nodes recede by INK, not by opacity: the whole card drops to the
|
||||
* secondary ink (the title inherits it) and the meta line goes one level
|
||||
* further down (`text-foreground-3`, appended over NODE_META). */
|
||||
const NODE_SETTLED = 'text-muted-foreground'
|
||||
/** Card line 1: 12px medium, clamped to TWO lines — at 150px a single
|
||||
* ellipsized line left ~7 CJK characters, which is what made a dense graph
|
||||
/** Card line 1: `text-sm` medium, clamped to TWO lines — a single ellipsized
|
||||
* line left too few CJK characters, which is what made a dense graph
|
||||
* unreadable. The tooltip still carries the full name. */
|
||||
const NODE_TITLE = 'min-w-0 flex-1 line-clamp-2 text-xs leading-[1.3] font-medium [overflow-wrap:anywhere]'
|
||||
/** The mono meta line: 11px, tabular figures, secondary ink. */
|
||||
const NODE_META = 'mt-0.5 truncate font-mono text-[11px] leading-[1.3] tabular-nums text-muted-foreground'
|
||||
const NODE_TITLE = 'min-w-0 flex-1 line-clamp-2 text-sm leading-snug font-medium [overflow-wrap:anywhere]'
|
||||
/** The mono meta line: `text-xs`, tabular figures, secondary ink. */
|
||||
const NODE_META = 'mt-0.5 truncate font-mono text-xs leading-[1.3] tabular-nums text-muted-foreground'
|
||||
|
||||
/** One phase frame of a run node (the dashed box behind its members). */
|
||||
interface PhaseFrame {
|
||||
@@ -116,16 +121,10 @@ export interface TasksGraphProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* One button of the control cluster: the vendored `Button`'s own `ghost` / `sm`
|
||||
* recipe on a real `<button>`, with a shadcn tooltip carrying the same copy as
|
||||
* the aria-label (`title` used to carry it; the bubble replaces it).
|
||||
*
|
||||
* The recipe comes from `buttonVariants` rather than the `Button` component for
|
||||
* one concrete reason: Radix's `TooltipTrigger` has to attach its ref to the
|
||||
* anchor, and the vendored `Button` is a plain function component (not a
|
||||
* `forwardRef` one), so on React 18 the cloned ref is dropped with a warning
|
||||
* and the tooltip would measure nothing. The rendered element is exactly what
|
||||
* `<Button variant="ghost" size="sm">` renders.
|
||||
* One button of the control cluster: the vendored `Button`'s stock
|
||||
* `outline` / `sm` recipe, with a shadcn tooltip carrying the same copy as
|
||||
* the aria-label (`title` used to carry it; the bubble replaces it). The
|
||||
* pressed state rides the outline variant's own accent surface.
|
||||
*/
|
||||
function ControlButton(props: {
|
||||
label: string
|
||||
@@ -137,21 +136,19 @@ function ControlButton(props: {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
||||
'flex-none px-2 text-[13px]',
|
||||
props.pressed === true && 'bg-muted text-foreground',
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('flex-none', props.pressed === true && 'bg-accent text-accent-foreground')}
|
||||
aria-label={props.label}
|
||||
aria-pressed={props.pressed}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-[11px]">{props.label}</TooltipContent>
|
||||
<TooltipContent side="top" className="text-xs">{props.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -168,7 +165,7 @@ function CardLine(props: { className: string; label: string; children?: ReactNod
|
||||
<TooltipTrigger asChild>
|
||||
<span className={props.className}>{props.children ?? props.label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px] text-[11px]">{props.label}</TooltipContent>
|
||||
<TooltipContent side="bottom" className="max-w-[260px] text-xs">{props.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -221,7 +218,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
/** Container width drives the sibling-band wrap (see bandColsFor). */
|
||||
const [width, setWidth] = useState(0)
|
||||
const layout = useMemo(
|
||||
() => layoutTasksGraphForWidth(nodes, width, FIT_MIN_SCALE),
|
||||
() => layoutTasksGraphForWidth(nodes, width, GRAPH_FIT_MIN_SCALE),
|
||||
[nodes, width],
|
||||
)
|
||||
const edges = useMemo(() => tasksEdges(nodes), [nodes])
|
||||
@@ -235,7 +232,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
const ch = container.clientHeight
|
||||
if (cw === 0 || ch === 0) return
|
||||
const k = Math.max(
|
||||
FIT_MIN_SCALE,
|
||||
GRAPH_FIT_MIN_SCALE,
|
||||
Math.min((cw - 24) / layout.width, (ch - 24) / layout.height, FIT_MAX_SCALE),
|
||||
)
|
||||
setTf({
|
||||
@@ -431,10 +428,10 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
{phaseFrames.map(frame => (
|
||||
<div
|
||||
key={frame.key}
|
||||
className="pointer-events-none absolute rounded-md border border-dashed border-border"
|
||||
className="pointer-events-none absolute rounded-md bg-muted/40 border border-dashed border-border"
|
||||
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
|
||||
>
|
||||
<span className="absolute -top-[7px] left-2 bg-background px-1 font-mono text-[11px] leading-[1.3] tracking-wide text-foreground-3 uppercase">
|
||||
<span className="absolute -top-[7px] left-2 bg-background px-1 font-mono text-xs leading-[1.3] tracking-wide text-foreground-3 uppercase">
|
||||
{frame.title ?? t('workflowPhaseUnnamed')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -466,7 +463,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
<ControlButton label={t('tasksZoomOut')} onClick={() => { zoomBy(1 / 1.2) }}>−</ControlButton>
|
||||
<span
|
||||
className="flex-none px-1.5 font-mono text-[11px] tabular-nums text-muted-foreground"
|
||||
className="flex-none px-1.5 font-mono text-xs tabular-nums text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Math.round(tf.k * 100)}%
|
||||
@@ -517,7 +514,7 @@ function renderAgentNode(
|
||||
{node.team?.role === 'teammate' && node.team.name !== '' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 max-w-[48px] flex-none truncate px-1.5 py-0 text-[11px] font-normal text-muted-foreground"
|
||||
className="h-5 max-w-[56px] flex-none truncate font-normal text-muted-foreground"
|
||||
>
|
||||
{node.team.name}
|
||||
</Badge>
|
||||
|
||||
@@ -44,16 +44,14 @@ function stateKey(state: TasksAgentNode['state']): CopyKey {
|
||||
}
|
||||
|
||||
/**
|
||||
* The semantic ink of one status word: settled = success, in progress = the
|
||||
* accent, blocked = warning, failed = destructive. The hue rides the text
|
||||
* only — the badge keeps a neutral outline, so a popover never turns into a
|
||||
* block of color.
|
||||
* The stock Badge variant of one status word: running / in progress =
|
||||
* `default` (the accent), failed / cancelled = `destructive`, everything
|
||||
* settled = `secondary` (pending included — a to-do list is not an alert).
|
||||
*/
|
||||
function statusTone(status: WorkflowStatus | 'pending' | 'in_progress' | 'completed'): string {
|
||||
if (status === 'completed') return 'text-success'
|
||||
if (status === 'running' || status === 'in_progress') return 'text-primary'
|
||||
if (status === 'pending') return 'text-muted-foreground'
|
||||
return 'text-destructive'
|
||||
function statusVariant(status: WorkflowStatus | 'pending' | 'in_progress' | 'completed'): 'default' | 'secondary' | 'destructive' {
|
||||
if (status === 'running' || status === 'in_progress') return 'default'
|
||||
if (status === 'failed' || status === 'cancelled') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** The popover's key/value grid (label left, value right, mono on request).
|
||||
@@ -84,15 +82,15 @@ function PopRow(props: { label: string; mono?: boolean; children: ReactNode }):
|
||||
/** The mono kicker above a popover list ("任务" / a phase title). */
|
||||
function GroupLabel(props: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="px-1 pt-0.5 font-mono text-[11px] tracking-wide text-foreground-3 uppercase">
|
||||
<div className="px-1 pt-0.5 font-mono text-xs tracking-wide text-foreground-3 uppercase">
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One outlined status badge (neutral chrome, semantic ink only). */
|
||||
function StatusBadge(props: { tone: string; children: ReactNode }): ReactNode {
|
||||
return <Badge variant="outline" className={`h-4 px-1.5 text-[11px] ${props.tone}`}>{props.children}</Badge>
|
||||
/** One status badge on its stock variant (neutral chrome). */
|
||||
function StatusBadge(props: { variant: 'default' | 'secondary' | 'destructive'; children: ReactNode }): ReactNode {
|
||||
return <Badge variant={props.variant}>{props.children}</Badge>
|
||||
}
|
||||
|
||||
/** The agent node detail popover. */
|
||||
@@ -114,7 +112,7 @@ export function AgentNodePopover(props: {
|
||||
// root class to pick up the scoped base reset the plugin ships instead of
|
||||
// preflight (see TaskWindow.tsx for the same pattern).
|
||||
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
|
||||
<div className="px-3 font-mono text-[11px] text-foreground-3">{t('tasksNodeDetail')}</div>
|
||||
<div className="px-3 font-mono text-xs text-foreground-3">{t('tasksNodeDetail')}</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1.5 px-3">
|
||||
<span className="truncate text-sm font-medium" title={node.label}>{node.label}</span>
|
||||
@@ -161,7 +159,7 @@ export function AgentNodePopover(props: {
|
||||
>
|
||||
<StateDot state={taskDotState(task)} size={6} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{task.subject}</span>
|
||||
<StatusBadge tone={statusTone(task.status)}>{t(taskStatusKey(task.status))}</StatusBadge>
|
||||
<StatusBadge variant={statusVariant(task.status)}>{t(taskStatusKey(task.status))}</StatusBadge>
|
||||
</UiButton>
|
||||
))}
|
||||
</div>
|
||||
@@ -198,7 +196,7 @@ export function WorkflowNodePopover(props: {
|
||||
return (
|
||||
// `dsw-tasks`: same portal-scope reset as AgentNodePopover above.
|
||||
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
|
||||
<div className="px-3 font-mono text-[11px] text-foreground-3">{t('workflowRun')}</div>
|
||||
<div className="px-3 font-mono text-xs text-foreground-3">{t('workflowRun')}</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1.5 px-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5" title={run.name}>
|
||||
@@ -209,7 +207,7 @@ export function WorkflowNodePopover(props: {
|
||||
</div>
|
||||
<PopRows>
|
||||
<PopRow label={t('tasksNodeState')}>
|
||||
<StatusBadge tone={statusTone(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
|
||||
<StatusBadge variant={statusVariant(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
|
||||
</PopRow>
|
||||
</PopRows>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* The tree mode of the Tasks page: the SAME unified model rendered as the
|
||||
* classic indentation tree — nested children with a hairline connector, rows on
|
||||
* the shadcn base (a 13px/500 title, an 11px mono meta line, a 2px accent bar
|
||||
* on the current session, dashed fold cards), and the shared bottom-right
|
||||
* the shadcn base (a text-sm/500 title, a text-xs mono meta line, a 2px accent
|
||||
* bar on the current session, dashed fold cards), and the shared bottom-right
|
||||
* control cluster (view toggle + fold toggle) so switching back is always
|
||||
* possible.
|
||||
*
|
||||
* Visual language: Tailwind utilities over the shadcn tokens
|
||||
* (src/client/ui/theme.css). Hierarchy is one 1px `border-border` hairline plus
|
||||
* the surface ladder and `hover:bg-muted` — the row carries no shadow (shadows
|
||||
* belong to floating layers only). Every color is a semantic token: no palette
|
||||
* the surface ladder and `hover:bg-muted` — the row is a list line, not a
|
||||
* card, so it paints no shadow. Every color is a semantic token: no palette
|
||||
* class and no literal. Controls come from the vendored shadcn set
|
||||
* (`ScrollArea`, `Collapsible`, `Spinner`) and glyphs from the host
|
||||
* `IconXxx` primitives, so the page owns no artwork of its own.
|
||||
@@ -38,20 +38,20 @@ import { Spinner } from './ui/spinner.tsx'
|
||||
import { t } from './locales.ts'
|
||||
|
||||
/** The row slab: one shared box, so every row kind lines up on the same grid. */
|
||||
const ROW = 'flex w-full cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-left text-[13px] leading-[1.35] text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
const ROW = 'flex w-full cursor-pointer items-start gap-2 rounded-md px-2 py-2 text-left text-sm leading-[1.45] text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
/** Hover is a background change only, per the design language. */
|
||||
const ROW_HOVER = 'hover:bg-muted'
|
||||
/** The current session: the same muted surface plus the 2px accent bar. */
|
||||
const ROW_CURRENT = 'bg-muted'
|
||||
/** Settled (done / error) rows recede by INK — the row's 13px title drops
|
||||
/** Settled (done / error) rows recede by INK — the row's title drops
|
||||
* from the foreground to the secondary ink — never by opacity or hue. */
|
||||
const ROW_SETTLED = 'text-muted-foreground'
|
||||
/** The fold aggregate is a dashed inline card, not a filled row. */
|
||||
const ROW_FOLD = 'border border-dashed border-border text-muted-foreground'
|
||||
/** One text line of a row body. */
|
||||
const LINE = 'truncate'
|
||||
/** The mono meta line: 11px, tabular figures, secondary ink. */
|
||||
const META = 'truncate font-mono text-[11px] tabular-nums text-muted-foreground'
|
||||
/** The mono meta line: `text-xs`, tabular figures, secondary ink. */
|
||||
const META = 'truncate font-mono text-xs tabular-nums text-muted-foreground'
|
||||
/** The glyph/state-dot gutter (row level 0). */
|
||||
const GLYPH = 'mt-0.5 flex-none text-foreground-3'
|
||||
/** The current-session accent bar; the slot is reserved on EVERY agent row so
|
||||
|
||||
+34
-36
@@ -5,11 +5,11 @@
|
||||
* task list scrolls inside a bounded height, so the canvas keeps its room.
|
||||
*
|
||||
* Visual language: shadcn/ui over the task page's Tailwind tokens
|
||||
* (src/client/ui/theme.css) — the strip is a flat Card (1px hairline, no
|
||||
* shadow), members are a ToggleGroup whose selected chip carries the accent,
|
||||
* and every task row ends in one DropdownMenu. Ink stays on the three token
|
||||
* levels (13px body / 11px mono meta, `font-mono tabular-nums`), and color is
|
||||
* state only: in progress = primary, done = success, blocked = warning.
|
||||
* (src/client/ui/theme.css) — the strip is a stock Card (`rounded-xl border
|
||||
* bg-card shadow-sm`), members are a ToggleGroup whose selected chip carries
|
||||
* the accent, and every task row ends in one DropdownMenu. Ink stays on the
|
||||
* three token levels (text-sm body / text-xs mono meta, `font-mono
|
||||
* tabular-nums`), and status color is the stock Badge variant set only.
|
||||
*
|
||||
* Behaviour is unchanged, and deliberately so: the row and EVERY menu entry
|
||||
* open the shared task window, which is the ONE surface owning view / edit /
|
||||
@@ -51,14 +51,14 @@ function taskStatusLabel(task: SidebarTeamTaskView): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The status Badge's semantic tint. In progress takes the accent, done takes
|
||||
* success, blocked takes warning; pending stays neutral (secondary) because a
|
||||
* to-do list is not an alert.
|
||||
* The status Badge: stock shadcn variants only — in progress takes `default`
|
||||
* (the accent), a blocked task wears `outline` with warning ink, everything
|
||||
* else settles on `secondary` (a to-do list is not an alert).
|
||||
*/
|
||||
function taskBadgeClass(task: SidebarTeamTaskView): string | undefined {
|
||||
if (task.status === 'completed') return 'text-success'
|
||||
if (!task.ready) return 'text-warning'
|
||||
return task.status === 'in_progress' ? 'text-primary' : undefined
|
||||
function taskBadge(task: SidebarTeamTaskView): { variant: 'default' | 'secondary' | 'outline'; className?: string } {
|
||||
if (task.status === 'in_progress' && task.ready) return { variant: 'default' }
|
||||
if (!task.ready) return { variant: 'outline', className: 'text-warning' }
|
||||
return { variant: 'secondary' }
|
||||
}
|
||||
|
||||
/** The StateDot semantic of one task row. */
|
||||
@@ -83,12 +83,12 @@ const FILTER_ALL = '__all__'
|
||||
|
||||
/** The shared slab: a filled rounded row whose only hover change is its background. */
|
||||
const ROW_CLASS =
|
||||
'flex min-w-0 cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-1 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
||||
/** The shared filter chip: sm toggle geometry on the 4px rhythm, accent when picked. */
|
||||
'flex min-w-0 cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-2 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
||||
/** The shared filter chip: sm toggle geometry, accent when picked (stock). */
|
||||
const CHIP_CLASS =
|
||||
'h-6 w-auto min-w-0 gap-1 rounded-md px-2 text-[11px] font-normal text-muted-foreground data-[state=on]:bg-primary/10 data-[state=on]:text-primary'
|
||||
'h-6 w-auto min-w-0 gap-1 px-2 text-xs font-normal text-muted-foreground data-[state=on]:text-foreground'
|
||||
/** The row menu's rows: body size, no icon column (the label is the affordance). */
|
||||
const ITEM_CLASS = 'text-[13px]'
|
||||
const ITEM_CLASS = 'text-sm'
|
||||
|
||||
/**
|
||||
* One board row: state dot, subject, owner, status Badge, and the row menu.
|
||||
@@ -114,7 +114,7 @@ function TeamTaskRow(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rowRef} className="grid grid-cols-[1fr_auto] items-center gap-1">
|
||||
<div ref={rowRef} className="group grid grid-cols-[1fr_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={ROW_CLASS}
|
||||
@@ -123,24 +123,26 @@ function TeamTaskRow(props: {
|
||||
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
|
||||
>
|
||||
<StateDot size={6} state={taskDotState(task)} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{task.subject}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{task.subject}</span>
|
||||
{task.ownerName !== undefined && (
|
||||
<span className="max-w-[72px] flex-none truncate font-mono text-[11px] tabular-nums text-foreground-3">
|
||||
<span className="max-w-[72px] flex-none truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{task.ownerName}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={taskBadgeClass(task) === undefined ? 'secondary' : 'outline'}
|
||||
className={cn('flex-none px-1.5 py-0 text-[11px] font-normal', taskBadgeClass(task))}
|
||||
>
|
||||
{taskStatusLabel(task)}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const badge = taskBadge(task)
|
||||
return (
|
||||
<Badge variant={badge.variant} className={cn('flex-none', badge.className)}>
|
||||
{taskStatusLabel(task)}
|
||||
</Badge>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('teamTaskActions')}
|
||||
title={t('teamTaskActions')}
|
||||
className="flex size-6 flex-none cursor-pointer items-center justify-center rounded-md bg-transparent text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
className="flex size-6 flex-none cursor-pointer items-center justify-center rounded-md bg-transparent text-muted-foreground opacity-0 outline-none transition-colors group-hover:opacity-100 hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<IconChevronUpOutline14 size={12} className="rotate-90" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -203,11 +205,10 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
return (
|
||||
<Card
|
||||
// `gap-0` because the bands are separated by hairlines, not by the stock
|
||||
// card rhythm; `py-0` because each band owns its own padding;
|
||||
// `border-border` because the hairline takes the border token, never the
|
||||
// inherited ink (this plugin ships no preflight to default it); the
|
||||
// card rhythm; `py-0` because each band owns its own padding. The rest is
|
||||
// the stock card surface (`rounded-xl border bg-card shadow-sm`); the
|
||||
// landmark role keeps the labelled region the old `<section>` provided.
|
||||
className="mb-2 shrink-0 gap-0 overflow-hidden rounded-lg border-border py-0"
|
||||
className="mb-2 shrink-0 gap-0 overflow-hidden rounded-xl py-0 shadow-sm"
|
||||
role="region"
|
||||
aria-label={t('teamBoard')}
|
||||
>
|
||||
@@ -223,8 +224,8 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
<span className="flex-none text-muted-foreground" aria-hidden="true">
|
||||
<IconChecklistOutline14 size={12} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">{t('teamBoard')}</span>
|
||||
<span className="min-w-0 truncate font-mono text-[11px] tabular-nums text-foreground-3">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">{t('teamBoard')}</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{t('teamChip', { members: members.length, tasks: live.length })}
|
||||
</span>
|
||||
<span
|
||||
@@ -271,7 +272,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
{shown.length === 0
|
||||
// `Empty` is the design language's empty state; the stock card
|
||||
// rhythm is overridden so an empty board stays a thin band.
|
||||
? <Empty className="gap-0 rounded-md p-2 text-[12px]">{t('teamTasksEmpty')}</Empty>
|
||||
? <Empty className="gap-0 rounded-md p-2 text-xs">{t('teamTasksEmpty')}</Empty>
|
||||
: shown.map(task => (
|
||||
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
|
||||
))}
|
||||
@@ -281,9 +282,6 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// `bg-background`: the host's preflight is the only thing that
|
||||
// would otherwise clear the UA's button face paint.
|
||||
className="bg-background"
|
||||
icon={<IconPlusOutline16 size={13} />}
|
||||
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
|
||||
>
|
||||
|
||||
@@ -15,13 +15,20 @@ import { tasksEdges } from './tasks-model.ts'
|
||||
/** Geometry constants of the canvas (px, pre-scale). Metrics follow the
|
||||
* approved Variant-D mockup, whose design target is the NARROW native right
|
||||
* sidebar (~360px): a 132px card fits two per row plus the gutter. */
|
||||
export const GRAPH_NODE_W = 150
|
||||
export const GRAPH_NODE_W = 176
|
||||
/** Two title lines plus the mono meta line: the card clamps long agent names
|
||||
* to two lines instead of ellipsizing them to a couple of characters, so the
|
||||
* reserved height covers that (see NODE_TITLE in TasksGraph.tsx). */
|
||||
export const GRAPH_NODE_H = 60
|
||||
export const GRAPH_NODE_H = 68
|
||||
/** Extra height of an agent node carrying a live line. */
|
||||
export const GRAPH_LIVE_H = 14
|
||||
|
||||
/**
|
||||
* The readability floor the fit honours: the canvas may shrink to this scale
|
||||
* before the layout starts wrapping (TasksGraph's fit and {@link bandColsFor}'s
|
||||
* column budget must agree, so the constant lives here, next to the geometry).
|
||||
*/
|
||||
export const GRAPH_FIT_MIN_SCALE = 0.78
|
||||
export const GRAPH_GAP_X = 28
|
||||
export const GRAPH_GAP_Y = 53
|
||||
export const GRAPH_PAD = 16
|
||||
@@ -161,8 +168,13 @@ export function layoutTasksGraph(nodes: readonly TasksNode[], options: LayoutOpt
|
||||
* canvas keeps its readability floor instead of shrinking: at 360px two cards
|
||||
* per row, at 720px four.
|
||||
*/
|
||||
export function bandColsFor(containerWidth: number): number {
|
||||
const usable = containerWidth - GRAPH_PAD * 2
|
||||
export function bandColsFor(containerWidth: number, minScale = GRAPH_FIT_MIN_SCALE): number {
|
||||
// Columns are budgeted at the readability floor, not at 1:1: the fit may
|
||||
// scale the canvas DOWN to `minScale` and still be readable, so a narrow
|
||||
// panel keeps two columns (scaled ~0.86 at 360px) instead of degenerating
|
||||
// into a one-card-per-row ladder. `layoutTasksGraphForWidth` re-checks the
|
||||
// real width against the same budget, so a too-wide pick is still narrowed.
|
||||
const usable = (containerWidth <= 0 ? 360 : containerWidth) / minScale - GRAPH_PAD * 2
|
||||
const cols = Math.floor((usable + GRAPH_GAP_X) / (GRAPH_NODE_W + GRAPH_GAP_X))
|
||||
return Math.min(4, Math.max(1, cols))
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* line, and the iconified live activity row ("tool icon + tool + args").
|
||||
*
|
||||
* Card content contract (the page's answer to "everything is ellipsized"):
|
||||
* line 1 state dot + agent icon + name (11px semibold, one line)
|
||||
* line 2 mode/model · activity (9px mono, one line)
|
||||
* line 1 state dot + agent icon + name (text-sm semibold, one line)
|
||||
* line 2 mode/model · activity (text-xs mono, one line)
|
||||
* line 3 live tool line (running nodes only)
|
||||
* line 4 owned shared task (team members only)
|
||||
* Everything else lives in the popovers.
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
* without it Tailwind would glob the project root and pick class-like strings
|
||||
* out of `lib/`, `tests/`, `node_modules/` and the docs.
|
||||
*/
|
||||
/*
|
||||
* Layer order is declared FIRST and explicitly: the compiler orders layers by
|
||||
* first appearance, and the `@import`s below would otherwise register
|
||||
* `utilities` before the `base` block at the bottom of this file — which would
|
||||
* make the scoped reset beat the utilities (a `rounded-md` button rendering
|
||||
* square, exactly the kind of "no CSS" artifact this file exists to prevent).
|
||||
*/
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
@@ -124,12 +133,54 @@
|
||||
@layer base {
|
||||
.dsw-tasks {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* The page's own density: stock shadcn body text, not the host's 16px. */
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dsw-tasks :where(button, input, textarea, select) {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* THE SCOPED PREFLIGHT SUBSET — without it the page wears the browser's
|
||||
* defaults. Preflight itself cannot be imported (it would repaint the host
|
||||
* page), but preflight is also what normalizes FORM CONTROLS: measured in a
|
||||
* real browser on a plugin icon button that sets only `size-7`, the UA chrome
|
||||
* survived as `padding: 1px 6px`, `border: 2px outset`, `background:
|
||||
* ButtonFace` (#efefef) and `font: 400 13.33px system-ui` — gray beveled
|
||||
* buttons and a soft double border, i.e. "the page has no CSS". Text inputs
|
||||
* and selects are affected the same way.
|
||||
*
|
||||
* `:where()` keeps these at the ancestor's (0,1,0); utilities sit in the
|
||||
* later `utilities` layer at the same specificity, so they still win — this
|
||||
* only removes what nobody asked for.
|
||||
*/
|
||||
.dsw-tasks :where(button, input, select, textarea) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Split rather than the `border: 0 solid` shorthand: the theme guard
|
||||
* (tests/theme.spec.ts) requires every `border` declaration to name a
|
||||
* colour token or be inert, and `0 solid` is neither. */
|
||||
border-width: 0;
|
||||
border-style: none;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
color: inherit;
|
||||
letter-spacing: inherit;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.dsw-tasks :where(button, [role='button']) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dsw-tasks :where(button:disabled, input:disabled, select:disabled, textarea:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dsw-tasks :where(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user