@import 'tailwindcss'; @import '@xyflow/react/dist/style.css'; /* ========================================================================== 主题 token。深色是默认(长时间盯画布,深色更省眼),浅色由 data-theme 切换; 没有 data-theme 就是「跟随系统」,由下面的 prefers-color-scheme 分支接管。 index.html 在首帧前从 localStorage 读偏好写 data-theme(见 lib/theme.ts), 所以不会先闪一帧深色再跳浅色。 对比度都是按 WCAG 公式实算过的: - 文字三级 text / text-dim / text-faint 在 bg、panel、hover 三种底色上都 ≥ 4.5:1(faint 以前只有 2.7–3.6,中文小字在投影和强光下读不清); - --accent-solid / --warn-solid / --err-solid 是给「实心底 + 字」用的, 上面的字用配对的 --on-*,同样 ≥ 4.5:1。--accent 本身留给描边、文字和图形, 深色下白字压在 #4f8cff 上只有 3.2:1; - 状态色、节点类型色作为图形都 ≥ 3:1。 颜色的分工(监控界面的通行做法:常态用中性色,颜色留给状态): - ok / warn / err / accent 只表达状态(accent 兼管「选中 / 焦点 / 主操作」), 状态一律走下面的 --st-* 语义令牌; - 节点类型色 --nt-* 只在青—蓝灰—紫—品红这一段取色,暖色和强调蓝都让给 状态,免得「人工审批」平时就是红的、像失败了。 浅色这一套写了两遍(data-theme='light' 和「跟随系统 + 系统是浅色」), CSS 没法把选择器和媒体查询并成一条规则。改一处必须同步改另一处。 ========================================================================== */ :root { color-scheme: dark; --bg: #0b0e14; --bg-elev: #11151f; --bg-panel: #141926; --bg-hover: #1b2232; --border: #232a3a; --border-strong: #333c52; --text: #e4e8f1; --text-dim: #a0abc1; --text-faint: #7f8ba4; --accent: #4f8cff; --accent-soft: #15284d; --accent-solid: #336cf0; --on-accent: #ffffff; --ok: #3fb950; --warn: #d29922; --warn-solid: #d29922; --on-warn: #1a1300; --err: #f85149; --err-solid: #da3633; --on-err: #ffffff; --copilot: #bc8cff; /* 画布底纹两级:细点每格一个,粗点每 5 格一个,像工程图纸的坐标纸 */ --grid-dot: #2a3346; --grid-dot-major: #3a445b; --hairline: #1c2230; --elev-1: 0 1px 2px rgb(0 0 0 / 0.35); --elev-2: 0 6px 16px -4px rgb(0 0 0 / 0.55), 0 2px 4px -2px rgb(0 0 0 / 0.45); --elev-3: 0 20px 48px -12px rgb(0 0 0 / 0.7), 0 8px 16px -8px rgb(0 0 0 / 0.5); /* 推导出来的状态:比基础状态色压一档,只在图形 + 剪影里出现 */ --st-queued: #678cd2; --st-suspended: #b4a481; --st-blocked: #bc6e77; --st-unreached: #68738b; /* 节点类型色。按类别分色族:起止石板灰、模型紫、执行青绿、控制品红、 上下文天青、把关兰紫;族内靠明度区分,一眼先认出「哪一类」,图标认具体类型 */ --nt-input: #b1ceee; --nt-output: #93afd4; --nt-llm: #c7c2ff; --nt-agent: #b49af8; --nt-supervisor: #bcadff; --nt-tool: #10c4c9; --nt-code: #57e3df; --nt-merge: #96efe6; --nt-branch: #e389ca; --nt-loop: #ffaddd; --nt-subgraph: #dba9c8; --nt-memory: #1ebeeb; --nt-retrieve: #84d7fe; --nt-transform: #93c3d8; --nt-human: #ce91e6; --nt-validate: #ebb1fd; --nt-metrics: #cbaddb; --nt-report: #a9b0ec; --nt-dual_check: #a98ee0; --nt-challenge: #dcb7ef; /* 检索通路的分类色:只说「这一分是哪一路检索贡献的」,不表示状态,所以 不借 accent / ok / warn。两色按 dataviz 校验过:色觉异常下也分得开, 在 panel 底上作为图形 ≥ 3:1 */ --via-semantic: #2b95c0; --via-keyword: #d55181; } :root[data-theme='light'] { color-scheme: light; --bg: #f6f7f9; --bg-elev: #ffffff; --bg-panel: #ffffff; --bg-hover: #eef1f6; --border: #dfe3ea; --border-strong: #c3cad6; --text: #1a1f2b; --text-dim: #4a5467; --text-faint: #636d80; --accent: #2563eb; --accent-soft: #e8f0fe; --accent-solid: #2563eb; --on-accent: #ffffff; --ok: #187a34; --warn: #946300; --warn-solid: #946300; --on-warn: #ffffff; --err: #cf222e; --err-solid: #cf222e; --on-err: #ffffff; --copilot: #8250df; --grid-dot: #c9cfda; --grid-dot-major: #b4bcc9; --hairline: #e8ebf0; --elev-1: 0 1px 2px rgb(16 24 40 / 0.06); --elev-2: 0 6px 16px -4px rgb(16 24 40 / 0.12), 0 2px 4px -2px rgb(16 24 40 / 0.06); --elev-3: 0 20px 48px -12px rgb(16 24 40 / 0.22), 0 8px 16px -8px rgb(16 24 40 / 0.1); --st-queued: #4468b5; --st-suspended: #6a5c3e; --st-blocked: #994857; --st-unreached: #80899a; --nt-input: #546e8b; --nt-output: #415774; --nt-llm: #695ead; --nt-agent: #5c448f; --nt-supervisor: #63519e; --nt-tool: #026264; --nt-code: #057c79; --nt-merge: #0a4d5a; --nt-branch: #7e376c; --nt-loop: #9b4c7d; --nt-subgraph: #7a516c; --nt-memory: #025f78; --nt-retrieve: #00769d; --nt-transform: #3e6778; --nt-human: #6f3d81; --nt-validate: #885398; --nt-metrics: #6e557b; --nt-report: #4f5694; --nt-dual_check: #5a4a91; --nt-challenge: #7d4c8f; --via-semantic: #0a7ea4; --via-keyword: #c2447a; } /* 「跟随系统」且系统是浅色:和上面那块逐字相同 */ @media (prefers-color-scheme: light) { :root:not([data-theme]) { color-scheme: light; --bg: #f6f7f9; --bg-elev: #ffffff; --bg-panel: #ffffff; --bg-hover: #eef1f6; --border: #dfe3ea; --border-strong: #c3cad6; --text: #1a1f2b; --text-dim: #4a5467; --text-faint: #636d80; --accent: #2563eb; --accent-soft: #e8f0fe; --accent-solid: #2563eb; --on-accent: #ffffff; --ok: #187a34; --warn: #946300; --warn-solid: #946300; --on-warn: #ffffff; --err: #cf222e; --err-solid: #cf222e; --on-err: #ffffff; --copilot: #8250df; --grid-dot: #c9cfda; --grid-dot-major: #b4bcc9; --hairline: #e8ebf0; --elev-1: 0 1px 2px rgb(16 24 40 / 0.06); --elev-2: 0 6px 16px -4px rgb(16 24 40 / 0.12), 0 2px 4px -2px rgb(16 24 40 / 0.06); --elev-3: 0 20px 48px -12px rgb(16 24 40 / 0.22), 0 8px 16px -8px rgb(16 24 40 / 0.1); --st-queued: #4468b5; --st-suspended: #6a5c3e; --st-blocked: #994857; --st-unreached: #80899a; --nt-input: #546e8b; --nt-output: #415774; --nt-llm: #695ead; --nt-agent: #5c448f; --nt-supervisor: #63519e; --nt-tool: #026264; --nt-code: #057c79; --nt-merge: #0a4d5a; --nt-branch: #7e376c; --nt-loop: #9b4c7d; --nt-subgraph: #7a516c; --nt-memory: #025f78; --nt-retrieve: #00769d; --nt-transform: #3e6778; --nt-human: #6f3d81; --nt-validate: #885398; --nt-metrics: #6e557b; --nt-report: #4f5694; --nt-dual_check: #5a4a91; --nt-challenge: #7d4c8f; --via-semantic: #0a7ea4; --via-keyword: #c2447a; } } /* 由上面推出来的令牌,两套主题共用,只写一次 */ :root { /* 状态语义。组件里只写 --st-*,不直接写 ok / warn / err / accent—— 哪天「运行中」要换颜色,改这一处就够了。-soft 是同色的浅底 */ --st-idle: var(--text-faint); --st-running: var(--accent); --st-done: var(--ok); --st-waiting: var(--warn); --st-failed: var(--err); --st-skipped: var(--text-faint); --st-cancelled: var(--text-dim); --st-idle-soft: color-mix(in srgb, var(--st-idle) 10%, transparent); --st-queued-soft: color-mix(in srgb, var(--st-queued) 14%, transparent); --st-running-soft: color-mix(in srgb, var(--st-running) 14%, transparent); --st-waiting-soft: color-mix(in srgb, var(--st-waiting) 14%, transparent); --st-done-soft: color-mix(in srgb, var(--st-done) 14%, transparent); --st-failed-soft: color-mix(in srgb, var(--st-failed) 14%, transparent); --st-skipped-soft: color-mix(in srgb, var(--st-skipped) 14%, transparent); --st-cancelled-soft: color-mix(in srgb, var(--st-cancelled) 14%, transparent); --st-suspended-soft: color-mix(in srgb, var(--st-suspended) 14%, transparent); --st-blocked-soft: color-mix(in srgb, var(--st-blocked) 14%, transparent); --st-unreached-soft: color-mix(in srgb, var(--st-unreached) 10%, transparent); /* 「有东西在跑」的颜色。和 --st-running 是一回事,给外壳、品牌标这类 不属于某个节点的地方用 */ --live: var(--st-running); /* 当前焦点的光晕。只给一个元素用(正在看的那个节点、聚焦的输入坞), 满屏都在发光就等于没有焦点 */ --glow-accent: 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent), 0 0 14px -2px color-mix(in srgb, var(--accent) 45%, transparent); /* 动效。时长三档:fast 给 hover / 按下,base 给面板开合,slow 给一次性的 入场和「时刻」。缓动 out 是「起步快、尾部长」——被推出来的手感 */ --dur-fast: 120ms; --dur-base: 200ms; --dur-slow: 360ms; } /* 字号阶梯。中文正文不低于 11px(2xs);画布卡片内部和 360px 的助手栏暂时 不受这条约束,它们的可读性靠按缩放分级渲染解决。 static:这些变量不管 Tailwind 扫没扫到都输出,单独的 .css 文件里也能用 var() */ @theme static { --text-2xs: 11px; --text-2xs--line-height: calc(16 / 11); --text-xs: 12px; --text-xs--line-height: calc(16 / 12); --text-sm: 13px; --text-sm--line-height: calc(20 / 13); --text-base: 14px; --text-base--line-height: calc(22 / 14); --text-lg: 16px; --text-lg--line-height: calc(24 / 16); --text-xl: 20px; --text-xl--line-height: calc(28 / 20); --text-2xl: 24px; --text-2xl--line-height: calc(32 / 24); --radius-xs: 2px; --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; --ease-out: cubic-bezier(0.32, 0.72, 0, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; --font-mono: 'SF Mono', ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace; } @theme inline { --color-bg: var(--bg); --color-elev: var(--bg-elev); --color-panel: var(--bg-panel); --color-hover: var(--bg-hover); --color-line: var(--border); --color-line-strong: var(--border-strong); --color-hairline: var(--hairline); --color-fg: var(--text); --color-dim: var(--text-dim); --color-faint: var(--text-faint); --color-accent: var(--accent); --color-accent-soft: var(--accent-soft); --color-accent-solid: var(--accent-solid); --color-on-accent: var(--on-accent); --color-ok: var(--ok); --color-warn: var(--warn); --color-warn-solid: var(--warn-solid); --color-on-warn: var(--on-warn); --color-err: var(--err); --color-err-solid: var(--err-solid); --color-on-err: var(--on-err); --color-copilot: var(--copilot); --color-st-idle: var(--st-idle); --color-st-queued: var(--st-queued); --color-st-running: var(--st-running); --color-st-waiting: var(--st-waiting); --color-st-done: var(--st-done); --color-st-failed: var(--st-failed); --color-st-skipped: var(--st-skipped); --color-st-cancelled: var(--st-cancelled); --color-st-suspended: var(--st-suspended); --color-st-blocked: var(--st-blocked); --color-st-unreached: var(--st-unreached); --shadow-elev-1: var(--elev-1); --shadow-elev-2: var(--elev-2); --shadow-elev-3: var(--elev-3); } /* 等宽数字:计时器、耗时、token 数刷新时宽度不跳,列与列对得齐 */ @utility tnum { font-variant-numeric: tabular-nums; } /* 全局规则放进 base 层。以前它们不在任何层里,而不分层的样式无论优先级 多低都压过分层的——Tailwind 的工具类全在 utilities 层,于是写在元素上的 border-[var(--accent)]、border-transparent 统统失效,发布弹窗选了「受管」 也看不出来 */ @layer base { *, ::before, ::after, ::backdrop, ::file-selector-button { border-color: var(--border); } :root { /* 原生复选框、单选、滑块跟着强调色走;深浅由上面的 color-scheme 管 */ accent-color: var(--accent); } /* 切主题那一帧关掉过渡,见 lib/theme.ts */ :root.theme-switching *, :root.theme-switching *::before, :root.theme-switching *::after { transition: none !important; } html, body, #root { height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 13px; -webkit-font-smoothing: antialiased; } code, pre, .mono { font-family: var(--font-mono); } /* 滚动条:细一点,不抢视觉。别加 scrollbar-color——写了它 Chrome 就不认 ::-webkit-scrollbar 了 */ ::-webkit-scrollbar { width: 9px; height: 9px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 5px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } } /* -------------------------------------------------------------------------- 节点类型配色。色值在上面的 --nt-* 里(两套主题各一份);这里只把它接到 --nt 上,同一个色相贯穿图标底色、标题栏、协作矩阵。小地图等别处要类型色, 直接读 var(--nt-),不要再抄一份 hex。 -------------------------------------------------------------------------- */ .nt-input { --nt: var(--nt-input); } .nt-output { --nt: var(--nt-output); } .nt-llm { --nt: var(--nt-llm); } .nt-agent { --nt: var(--nt-agent); } .nt-supervisor { --nt: var(--nt-supervisor); } .nt-tool { --nt: var(--nt-tool); } .nt-code { --nt: var(--nt-code); } .nt-merge { --nt: var(--nt-merge); } .nt-branch { --nt: var(--nt-branch); } .nt-loop { --nt: var(--nt-loop); } .nt-human { --nt: var(--nt-human); } .nt-validate { --nt: var(--nt-validate); } .nt-memory { --nt: var(--nt-memory); } .nt-retrieve { --nt: var(--nt-retrieve); } .nt-transform { --nt: var(--nt-transform); } .nt-subgraph { --nt: var(--nt-subgraph); } .nt-metrics { --nt: var(--nt-metrics); } .nt-report { --nt: var(--nt-report); } .nt-dual_check { --nt: var(--nt-dual_check); } .nt-challenge { --nt: var(--nt-challenge); } /* -------------------------------------------------------------------------- React Flow 覆盖 -------------------------------------------------------------------------- */ .react-flow { background: var(--bg); } .react-flow__attribution { display: none; } .react-flow__handle { width: 9px; height: 9px; background: var(--bg-elev); border: 2px solid var(--border-strong); transition-property: border-color, background-color, box-shadow, transform; transition-duration: var(--dur-fast); } .react-flow__handle:hover { border-color: var(--accent); transform: scale(1.25); } .react-flow__handle-connecting { border-color: var(--accent); } .react-flow__edge-path { stroke: var(--border-strong); stroke-width: 1.6; } .react-flow__edge.selected .react-flow__edge-path { stroke: var(--accent); stroke-width: 2.4; } .react-flow__edge-text { fill: var(--text-dim); font-size: 10px; } .react-flow__controls-button { background: var(--bg-panel); border-color: var(--border); fill: var(--text-dim); } .react-flow__controls-button:hover { background: var(--bg-hover); } .react-flow__minimap { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px; } /* -------------------------------------------------------------------------- 节点卡。 状态四重编码:剪影(StatusBadge)+ 线型 / 左侧状态槽的纹理 + 状态色 + 文字。 去掉任何一条都还认得出来——灰度投影、色弱、关了动效、缩到 0.3 倍都一样。 颜色只给状态用:边框、光弧、状态槽一律 --st-*;类型色 --nt 只留在图标块和 标题栏极淡的底色里(以前运行中的分支节点是琥珀框,和「等人审批」分不开)。 正常态安静、异常态醒目:只有等待、失败、挂起才铺底色、加粗边(用 0.5px 的 box-shadow 加粗,不改 border-width——边框一变宽盒子就变,走线跟着重算)。 box-shadow 分四层拼起来,各层只写自己的变量,谁也不盖谁:选中圈 --nc-ring、 悬停联动 --nc-focus、Copilot 新节点 --nc-copilot、状态的分量 --nc-weight。 以前各写各的 box-shadow,悬停一张等待中的卡,琥珀的加粗和 elev-2 就没了; Copilot 刚放上来的节点点选中,选中圈也被紫色盖掉。 卡片高度在整个运行期间不变:标题 32 + 正文两行 36 + 遥测槽 22。流式输出原位 替换摘要那两行,计时、token、工具、循环进度都收进遥测槽。以前输出框把卡片 撑高 80px,正好压住下面那个节点。 -------------------------------------------------------------------------- */ .nc { position: relative; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-elev); box-shadow: var(--nc-ring, 0 0 #0000), var(--nc-focus, 0 0 #0000), var(--nc-copilot, 0 0 #0000), var(--nc-weight, var(--elev-1)); /* 状态的压暗(未到达、阻断)和悬停联动的压暗各管各的,乘起来 */ opacity: calc(var(--nc-op, 1) * var(--nc-dim, 1)); transition: opacity var(--dur-base) var(--ease-out); } .nc-head { display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px 0 10px; border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: color-mix(in srgb, var(--nt) 6%, transparent); } .nc-icon { display: flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--nt) 18%, transparent); color: var(--nt); } .nc-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; line-height: 1.2; } .nc-chip { flex: none; height: 15px; padding: 0 4px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: 9.5px; line-height: 13px; white-space: nowrap; } .nc-chip-warn { border-color: color-mix(in srgb, var(--st-waiting) 45%, transparent); color: var(--st-waiting); } .nc-badge { flex: none; } /* 正文:固定两行。摘要和流式输出轮流占这个位置,谁也不把卡片撑高 */ .nc-body { height: 36px; padding: 4px 10px 0; overflow: hidden; color: var(--text-dim); font-size: 11px; line-height: 14px; } .nc-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } /* 流式尾巴:底对齐,新出的字总在最下面那行,旧的从顶上裁掉 */ .nc-stream { display: flex; flex-direction: column; justify-content: flex-end; height: 28px; overflow: hidden; font-size: 10px; line-height: 14px; white-space: pre-wrap; overflow-wrap: anywhere; } .nc-stream-tag { color: var(--text-faint); font-family: var(--font-sans); } /* 遥测槽 */ .nc-tele { display: flex; align-items: center; gap: 6px; height: 22px; padding: 0 10px; overflow: hidden; border-top: 1px solid var(--hairline); border-radius: 0 0 var(--radius-lg) var(--radius-lg); color: var(--text-faint); font-size: 10px; white-space: nowrap; } .nc-tele-state { flex: none; font-weight: 600; } .nc-tele-main { display: inline-flex; flex: none; align-items: center; gap: 5px; color: var(--text-dim); font-family: var(--font-mono); } .nc-tele-note { min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* 跑完了但有要留意的事:降档交付、收尾时还想调工具 */ .nc-tele-note.is-warn { color: var(--st-waiting); } /* 工具调用超出了后端声明的时限:只有这时才上色,平常是安静的「⋯」 */ .nc-over { color: var(--st-waiting); font-family: var(--font-sans); font-weight: 600; } .nc-tele-side { display: flex; flex: 0 1 auto; min-width: 0; align-items: center; gap: 7px; font-family: var(--font-mono); } .nc-tele-side > * { flex: none; } /* 在跑的工具名可以很长(db_query__warehouse):只有它让位,截成省略号 */ .nc-tele-side > .nc-tools { flex: 0 1 auto; min-width: 0; } .nc-tools { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; } .nc-tools > svg { flex: none; } .nc-tool-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .nc-tools.is-live { color: var(--st-running); } .nc-tools.is-bad { color: var(--st-failed); } .nc-tele-dash { color: var(--text-faint); } .nc-tele-issue { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .nc-tele-issue.is-error { color: var(--st-failed); } .nc-tele-issue.is-warning { color: var(--st-waiting); } /* 「去审批」:画出来的药丸只有 16px 高(0.65 倍时屏幕上 10px),按钮本身撑满 遥测槽的高度、再往右吃进槽的内边距,点击区比看到的大一圈。药丸画在 ::before 上。 不用往外扩的伪元素:遥测槽是 overflow:hidden,扩出去的部分点不到 */ .nc-approve { position: relative; z-index: 0; flex: none; align-self: stretch; margin-right: -6px; padding: 0 13px 0 7px; color: var(--on-warn); font-size: 10px; font-weight: 600; line-height: 16px; cursor: pointer; } .nc-approve::before { content: ''; position: absolute; inset: 2.5px 6px 2.5px 0; z-index: -1; border-radius: var(--radius-sm); background: var(--warn-solid); } .nc-approve:hover::before { background: color-mix(in srgb, var(--warn-solid) 86%, #000); } .nc-approve:focus-visible { outline: none; } .nc-approve:focus-visible::before { outline: 2px solid var(--accent); } /* 循环的分段进度:一格一项(或一轮),做完的绿、在做的蓝 */ .nc-seg { display: inline-flex; flex: none; gap: 2px; } .nc-seg i { width: 9px; height: 5px; border-radius: 1px; background: var(--bg-hover); box-shadow: inset 0 0 0 0.5px var(--border-strong); } .nc-seg i.is-done { background: color-mix(in srgb, var(--st-done) 70%, transparent); box-shadow: none; } .nc-seg i.is-cur { background: var(--st-running); box-shadow: none; } .nc-seg-bar { position: relative; width: 64px; height: 5px; overflow: hidden; border-radius: 1px; background: var(--bg-hover); } .nc-seg-bar i { position: absolute; inset: 0 auto 0 0; height: auto; border-radius: 0; background: color-mix(in srgb, var(--st-done) 70%, transparent); box-shadow: none; } /* 左侧状态槽:纹理本身就是编码——实心 = 在跑,断续 = 排队,斜纹 = 等人, 粗实心 = 失败,两道竖条 = 挂起 */ .nc-slot { position: absolute; top: -1px; bottom: -1px; left: -1px; z-index: 2; width: 3px; border-radius: var(--radius-lg) 0 0 var(--radius-lg); pointer-events: none; } /* ---- 各状态 ---- */ .nc.node-queued { --st-c: var(--st-queued); border-style: dashed; border-color: color-mix(in srgb, var(--st-queued) 65%, var(--border)); } .node-queued .nc-slot { background: repeating-linear-gradient(to bottom, var(--st-queued) 0 4px, transparent 4px 7px); } .nc.node-running { --st-c: var(--st-running); border-color: var(--st-running); } .node-running .nc-slot { background: var(--st-running); } .nc.node-waiting { --st-c: var(--st-waiting); border-color: var(--st-waiting); --nc-weight: 0 0 0 0.5px var(--st-waiting), var(--elev-2); } .node-waiting .nc-slot { width: 5px; background: repeating-linear-gradient(135deg, var(--st-waiting) 0 3px, transparent 3px 6px); } .node-waiting .nc-tele { background: var(--st-waiting-soft); } .nc.node-done { --st-c: var(--st-done); border-color: color-mix(in srgb, var(--st-done) 45%, var(--border)); } .node-done .nc-slot { width: 2px; background: color-mix(in srgb, var(--st-done) 75%, transparent); } .nc.node-failed { --st-c: var(--st-failed); border-color: var(--st-failed); --nc-weight: 0 0 0 0.5px var(--st-failed), var(--elev-2); } .node-failed .nc-slot { width: 5px; background: var(--st-failed); } .node-failed .nc-tele { background: var(--st-failed-soft); } .node-failed .nc-tele-note { color: var(--st-failed); } .nc.node-cancelled { --st-c: var(--st-cancelled); --nc-op: 0.8; } .node-cancelled .nc-slot { background: var(--st-cancelled); } .nc.node-suspended { --st-c: var(--st-suspended); border-color: color-mix(in srgb, var(--st-suspended) 70%, var(--border)); } .node-suspended .nc-slot { width: 5px; background: repeating-linear-gradient(to right, var(--st-suspended) 0 1.5px, transparent 1.5px 3.5px); } .node-suspended .nc-tele { background: var(--st-suspended-soft); } .nc.node-skipped { --st-c: var(--st-skipped); border-style: dashed; border-color: var(--border-strong); } .nc.node-blocked { --st-c: var(--st-blocked); --nc-op: 0.62; border-style: dotted; border-color: color-mix(in srgb, var(--st-blocked) 75%, transparent); } .node-blocked .nc-tele-note { color: var(--st-blocked); } .nc.node-unreached { --st-c: var(--st-unreached); --nc-op: 0.45; border-style: dashed; } /* 选中:外面隔一圈底色再描一圈强调色。运行中也是强调色的边框,靠这圈空隙分开, 不然「选中了」和「在跑」长得一样 */ .nc.nc-selected { --nc-ring: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--accent), 0 0 16px -2px color-mix(in srgb, var(--accent) 45%, transparent); } /* 右栏悬停某一步:对应的节点亮一圈,其余的退下去 */ .nc.nc-hover { --nc-focus: 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent), var(--glow-accent); } .nc.nc-dim { --nc-dim: 0.55; } /* Copilot 刚放上来的节点:紫色描边,落位时从上方轻轻落下一次。 只在节点还没有运行态时画——运行中、失败、等待的边框永远压过编辑态的紫色, 否则停掉一次生成接着试跑,挂掉的那个节点是紫框,一眼看不出谁挂了 */ @keyframes copilot-land { from { opacity: 0; transform: translateY(-8px) scale(0.96); } } .nc.node-copilot-new { animation: copilot-land var(--dur-slow) var(--ease-out) 1 both; } .nc.node-copilot-new[data-state='idle'] { border-color: var(--copilot); --nc-copilot: 0 0 0 3px color-mix(in srgb, var(--copilot) 24%, transparent); } /* 端口不可连时收起来,不给「还能连」的暗示。可不可连只看 React Flow 给端口的 connectable 类(运行进行中画布关掉 nodesConnectable,正式、探索都一样), 不在这里按运行类别再推一遍。跑中已经亮起的出口照样亮着 */ .nc .react-flow__handle:not(.connectable) { cursor: default; pointer-events: none; } .nc .react-flow__handle:not(.connectable):not(.handle-taken) { opacity: 0.3; } /* 正式运行进行中:跑的是发布快照,画布只读,拖也拖不动 */ .react-flow:is([data-readonly], [data-run-class='formal']:is([data-run-phase='queued'], [data-run-phase='running'], [data-run-phase='waiting'])) .nc { cursor: default; } /* 出口标签:画在出线第一段的上方(标签底边离线 2px),不压线。底色和画布同色, 别的线从后面穿过时字照样读得出。运行前一律中性色,命中的那条亮起来 */ .nc-exit { position: absolute; left: calc(100% + 8px); z-index: 1; padding: 0 3px; transform: translateY(calc(-100% - 2px)); border-radius: var(--radius-xs); background: var(--bg); color: var(--text-faint); font-size: 9.5px; line-height: 13px; white-space: nowrap; pointer-events: none; } .nc-exit.is-hit { color: var(--st-running); font-weight: 600; } .nc-exit.is-miss { opacity: 0.45; } /* 出口密(5 个起间距不到 16px):画在线上方的标签会压住上一条出口的线, 改成骑在自己那条线上——标签只截断自己的线,谁是谁的不会看错 */ .nc-exits-dense .nc-exit { transform: translateY(-50%); font-size: 9px; line-height: 11px; } /* 出口被选中/落空:分支节点跑完要能一眼看出走了哪条 */ .react-flow__handle.handle-taken { background: var(--st-running); border-color: var(--st-running); box-shadow: 0 0 0 3px color-mix(in srgb, var(--st-running) 30%, transparent); } .react-flow__handle.handle-idle { opacity: 0.35; } /* 校验问题的角标。error 和 warning 一样醒目——warning 恰恰是"运行时静默取到 空值"那一类,以前有 error 就看不见它,卡片上也根本不画 */ .nc-issue { position: absolute; top: -8px; right: -8px; z-index: 3; display: flex; gap: 2px; transform-origin: top right; } .nc-issue span { height: 16px; padding: 0 5px; border-radius: 8px; box-shadow: 0 0 0 2px var(--bg); font-size: 10px; font-weight: 600; line-height: 16px; } .nc-issue .is-error { background: var(--err-solid); color: var(--on-err); } .nc-issue .is-warning { background: var(--warn-solid); color: var(--on-warn); } /* 出具印章:双线框,成果节点右上角。档位用状态色:完整出具 = 完成的绿, 降档 = 琥珀,不予出具 = 红 */ .nc-stamp { position: absolute; top: -12px; right: 22px; z-index: 3; display: flex; align-items: center; gap: 4px; height: 21px; padding: 0 7px; border: 3px double currentColor; border-radius: var(--radius-sm); background: var(--bg-elev); box-shadow: 0 0 0 2px var(--bg-elev), var(--elev-1); font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; white-space: nowrap; } .nc-stamp.is-link { cursor: pointer; } .nc-stamp small { color: var(--text-faint); font-size: 9px; font-weight: 500; letter-spacing: 0; } /* 报告节点的章(引用 N · 无证据 M)放在卡片下沿:上沿左边是血缘标签(产出 / 引用),右边是校验角标 */ .nc-stamp.is-evidence { top: auto; /* 只压住卡片下沿 4px:卡片最后一行(校验提示、耗时)不被章挡住 */ bottom: -17px; right: 14px; letter-spacing: 0; } .nc-stamp.is-formal { color: var(--st-done); } .nc-stamp.is-degraded { color: var(--st-waiting); } .nc-stamp.is-withheld { color: var(--st-failed); } @keyframes nc-stamp { from { opacity: 0; transform: scale(1.4) rotate(-5deg); } 60% { opacity: 1; } to { opacity: 1; transform: none; } } /* 落章只在实时的出具判定到来时播一次,300ms 以内 */ .nc-stamp-in { animation: nc-stamp 280ms var(--ease-out) 1 both; } /* 「去审批」浮层:挂在 body 上,屏幕空间,不随画布缩放 */ .nc-pop { position: fixed; z-index: 60; width: 340px; max-height: min(70vh, 520px); overflow: auto; border: 1px solid var(--st-waiting); border-radius: var(--radius-lg); background: var(--bg-panel); box-shadow: var(--elev-3); } /* 精简档和信号档的替身层。平时不可见,但一直在:按档切换只改 visibility, 不改盒子——display:none 会让卡片高度变,React Flow 重新量尺寸、走线全部重算 */ .nc-lod, .nc-sig { position: absolute; inset: 0; z-index: 2; visibility: hidden; pointer-events: none; } .nc-lod { display: flex; flex-direction: column; justify-content: center; gap: 4px; padding: 0 14px 0 16px; } /* 字号按缩放反算:精简档一路到 0.34 倍,屏幕上都是标题 11.5px、读数 9.5px; 回滞让这一档最低到 0.33,那时封顶生效,仍有 11.2 / 9.2px。封顶是为了不撑出 卡片:34 × 1.15 + 28 × 1.2 + 4 ≈ 77px,装得进 92px 的盒子 */ .nc-lod-title { display: flex; align-items: center; gap: 0.35em; min-width: 0; font-size: min(34px, calc(11.5px / var(--zoom, 1))); font-weight: 600; line-height: 1.15; } .nc-lod-title svg { width: 0.9em; height: 0.9em; } .nc-lod-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nc-lod-read { overflow: hidden; color: var(--text-dim); font-family: var(--font-mono); font-size: min(28px, calc(9.5px / var(--zoom, 1))); line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; } .nc-lod-read .nc-tenth, .nc-lod-read .nc-seg { display: none; } .nc-sig { display: flex; align-items: center; justify-content: center; } .nc-sig svg { /* 反向缩放:0.3 倍时屏幕上约 16px,封顶免得比卡片还高 */ width: min(56px, calc(16px / var(--zoom, 1))); height: min(56px, calc(16px / var(--zoom, 1))); } .react-flow[data-lod='compact'] .nc-content, .react-flow[data-lod='signal'] .nc-content, .react-flow[data-lod='compact'] .nc-exit, .react-flow[data-lod='signal'] .nc-exit { visibility: hidden; } .react-flow[data-lod='compact'] .nc-lod, .react-flow[data-lod='signal'] .nc-sig { visibility: visible; } /* 远了扫描线只剩一片模糊的亮斑,而且一张大图里每张都在扫,关掉 */ .react-flow[data-lod='compact'] .fx-scan, .react-flow[data-lod='signal'] .fx-scan { display: none; } .react-flow[data-lod='compact'] .nc-slot { width: 6px; } .react-flow[data-lod='compact'] :is(.node-waiting, .node-failed, .node-suspended) .nc-slot { width: 10px; } .react-flow[data-lod='compact'] .nc-issue, .react-flow[data-lod='signal'] .nc-issue { transform: scale(calc(0.9 / var(--zoom, 1))); } /* 印章同样反向缩放,远景里也读得出档位。用独立的 scale 属性:落章动画占着 transform,写在 transform 上会被动画的末帧盖掉 */ .react-flow[data-lod='compact'] .nc-stamp, .react-flow[data-lod='signal'] .nc-stamp { transform-origin: bottom right; scale: calc(0.9 / var(--zoom, 1)); } /* 信号档:整张卡就是一块状态色。颜色之外还有纹理(等人斜纹、挂起竖条)、 线型(排队 / 未到达虚线、阻断点线)和居中的剪影 */ .react-flow[data-lod='signal'] .nc { background: color-mix(in srgb, var(--st-c, transparent) 16%, var(--bg-elev)); --nc-weight: inset 0 0 0 calc(1px / var(--zoom, 1)) color-mix(in srgb, var(--st-c, var(--border-strong)) 70%, transparent); } .react-flow[data-lod='signal'] .nc .nc-slot { display: none; } .react-flow[data-lod='signal'] .nc.node-running { background: color-mix(in srgb, var(--st-running) 34%, var(--bg-elev)); } .react-flow[data-lod='signal'] .nc.node-failed { background: color-mix(in srgb, var(--st-failed) 78%, var(--bg-elev)); --nc-weight: 0 0 0 calc(1.5px / var(--zoom, 1)) var(--st-failed); } .react-flow[data-lod='signal'] .nc.node-waiting { background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--st-waiting) 55%, var(--bg-elev)) 0 14px, var(--bg-elev) 14px 28px); --nc-weight: 0 0 0 calc(1.5px / var(--zoom, 1)) var(--st-waiting); } .react-flow[data-lod='signal'] .nc.node-suspended { background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--st-suspended) 45%, var(--bg-elev)) 0 12px, var(--bg-elev) 12px 30px); } /* 失败那块是实心红,剪影反白才看得见(徽标自带行内颜色,这里要压过它) */ .react-flow[data-lod='signal'] .nc.node-failed .nc-sig svg { color: var(--on-err) !important; } /* 牌子挂在卡片外面,会被后画的邻居节点盖住:异常节点整体抬到最上层 */ .react-flow[data-lod='signal'] .react-flow__node:has(> .nc:is(.node-failed, .node-waiting)) { z-index: 1000 !important; } /* 信号档的牌子:失败和等人的节点头上挂一枚,字号反向缩放,任何缩放下都是 11px */ .nc-pin { position: absolute; bottom: 100%; left: 0; z-index: 5; display: flex; align-items: center; gap: 0.4em; margin-bottom: 0.5em; padding: 0.3em 0.65em; border: 0.09em solid var(--st-c); border-radius: 0.45em; background: var(--bg-panel); box-shadow: var(--elev-2); color: var(--text-dim); font-size: calc(11px / var(--zoom, 1)); line-height: 1.3; white-space: nowrap; visibility: hidden; pointer-events: none; } .nc-pin svg { width: 1.1em; height: 1.1em; } .nc-pin b { color: var(--st-c); font-weight: 600; } .nc-pin em { color: var(--st-c); font-family: var(--font-mono); font-style: normal; } .react-flow[data-lod='signal'] .nc-pin { visibility: visible; } /* -------------------------------------------------------------------------- 运行时的光效语汇。 一条原则:**每个动效都要对应一件真实发生的事**。光弧转 = 这个节点正在跑, 扫描线过 = 正在产出,绿色扫过 = 刚跑完,抖动 = 刚失败,琥珀一圈 = 刚停下来 等人。纯装饰的动效只会让画布变吵——跑一张十几个节点的图,一眼能看出 "谁在动"才是这套东西的用处。 常驻的只有运行中那一圈光弧和扫描线(外加徽标的转弧)。等待、完成和各种终态 都是静态的:以前等人时琥珀色一直在呼吸,停在审批上半小时,画布就半小时在动。 进入某个状态的那一下(抖、扫、琥珀一圈)只在实时事件里播,回放和打开历史 运行时直接落到终态(NodeCard 给这一下挂 .nc-enter)。 颜色一律走 --st-* 令牌,浅色深色都能看。前庭敏感的人由文件末尾那段 prefers-reduced-motion 统一停掉。 -------------------------------------------------------------------------- */ /* 光环的角度要能插值,得先声明成一个 属性 */ @property --fx-angle { syntax: ''; inherits: false; initial-value: 0deg; } /* 光效层。做成独立一层而不是伪元素:卡片自己的背景会盖住伪元素, 而 overflow:hidden 又不能加在卡片上——出口标签、印章、牌子都挂在卡片外面, 一裁就没了 */ .node-fx { position: absolute; inset: 0; z-index: 1; border-radius: inherit; pointer-events: none; } /* 只负责裁剪的那一层:正好是卡片框,扫描线不会漏到邻居卡片上 */ .fx-clip { position: absolute; inset: 0; overflow: hidden; border-radius: inherit; } /* 正在跑:一圈绕着卡片转的光弧。状态色,不是类型色 */ @keyframes fx-halo { to { --fx-angle: 360deg; } } .fx-halo { position: absolute; inset: -1.5px; padding: 1.5px; border-radius: inherit; background: conic-gradient( from var(--fx-angle), transparent 0deg, transparent 300deg, color-mix(in srgb, var(--st-running) 65%, transparent) 340deg, var(--st-running) 358deg, transparent 360deg ); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; animation: fx-halo 2.2s linear infinite; } /* 正在跑:一条光带从上往下扫过卡片,像在读取/产出 */ @keyframes fx-scan { 0% { transform: translateY(-70px); opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } 100% { transform: translateY(var(--scan-h, 120px)); opacity: 0; } } .fx-scan { position: absolute; top: 0; right: 0; left: 0; height: 48px; background: linear-gradient( to bottom, transparent, color-mix(in srgb, var(--st-running) 7%, transparent), transparent ); animation: fx-scan 2.8s var(--ease-in-out) infinite; } .nt-supervisor .fx-scan { --scan-h: 220px; } /* 刚跑完:绿色扫过一遍。只跑一次,跑完停在画面外—— 常驻的完成动画会让整张画布一直在动,而"完成了"这件事只需要说一次 */ @keyframes fx-done { from { transform: translateX(-110%); } to { transform: translateX(110%); } } .fx-done, .fx-sweep { position: absolute; inset: 0; background: linear-gradient( 90deg, transparent, color-mix(in srgb, var(--st-done) 28%, transparent), transparent ); transform: translateX(-110%); } .fx-done { animation: fx-done 0.85s var(--ease-out) 1 forwards; } /* 刚失败:短促地抖一下。幅度压在 3px 以内——再大就不像"出错"像"地震" */ @keyframes fx-shake { 0%, 100% { transform: translateX(0); } 15% { transform: translateX(-3px); } 30% { transform: translateX(3px); } 45% { transform: translateX(-2px); } 60% { transform: translateX(2px); } 80% { transform: translateX(-1px); } } .nc.node-failed.nc-enter { animation: fx-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) 1; } /* 一次性时刻共用的那层描边。平时透明,由下面几种时刻各自点亮一次 */ .fx-moment { position: absolute; inset: -1px; border-radius: inherit; opacity: 0; } @keyframes nc-light { 0% { opacity: 0; } 30% { opacity: 1; } 100% { opacity: 0; } } @keyframes nc-ring-out { from { opacity: 0.9; transform: scale(1); } to { opacity: 0; transform: scale(1.06, 1.14); } } /* 刚停下来等人:琥珀色往外荡一圈,然后静止。停多久都不再动 */ .nc.node-waiting.nc-enter .fx-moment { box-shadow: 0 0 0 2px var(--st-waiting); animation: nc-ring-out 900ms var(--ease-out) 1 both; } /* 开场点亮:run.started 时沿拓扑层级依次亮一下(--rank 由画布写到节点上) */ .react-flow[data-moment='start'] .fx-moment { box-shadow: inset 0 0 0 1px var(--st-running), 0 0 14px -2px color-mix(in srgb, var(--st-running) 55%, transparent); animation: nc-light 520ms var(--ease-out) 1 both; animation-delay: calc(var(--rank, 0) * 55ms); } /* 成功回扫:执行路径上的节点从入口到成果依次扫一遍 */ .react-flow[data-moment='success'] .nc[data-state='done'] .fx-sweep { animation: fx-done 700ms var(--ease-out) 1 both; animation-delay: calc(var(--rank, 0) * 70ms); } .react-flow[data-moment='success'] .nc[data-state='done'] .fx-moment { box-shadow: inset 0 0 0 1px var(--st-done); animation: nc-light 600ms var(--ease-out) 1 both; animation-delay: calc(var(--rank, 0) * 70ms); } /* 失败阻断级联:从失败节点往下游逐层标「阻断」,一层 60ms。 --moment-origin 是失败节点的层级(画布写在根元素上),没有就从 0 算 */ @keyframes nc-block { from { opacity: 1; } } .react-flow[data-moment='failed'] .nc[data-state='blocked'] { animation: nc-block 420ms var(--ease-out) 1 both; animation-delay: calc(max(0, var(--rank, 0) - var(--moment-origin, 0)) * 60ms); } .react-flow[data-moment='failed'] .nc[data-state='blocked'] .fx-moment { box-shadow: inset 0 0 0 1px var(--st-blocked); animation: nc-light 420ms var(--ease-out) 1 both; animation-delay: calc(max(0, var(--rank, 0) - var(--moment-origin, 0)) * 60ms); } /* 右栏点了某一步、画布取景过来:落点的节点荡一圈,告诉眼睛"是这个" */ .fx-focus { position: absolute; inset: -1px; border-radius: inherit; box-shadow: 0 0 0 2px var(--accent); animation: nc-ring-out 700ms var(--ease-out) 1 both; } /* -------------------------------------------------------------------------- 协作矩阵:supervisor 节点展开出来的内部编排。 它要回答三个问题:这一轮派了几个人、谁还在跑、并行到底省了多少。 条形长度按"这一轮里最长的那个人"归一化——三个人并排三条长短不一的条, 一眼就能看出墙钟为什么等于最长那条而不是三条之和。 行高固定,行数从编辑态起就固定,跑起来卡片不长个子。 -------------------------------------------------------------------------- */ .team-matrix { padding: 3px 10px 0; } .team-head { display: flex; align-items: center; gap: 6px; height: 17px; color: var(--text-faint); font-size: 9.5px; letter-spacing: 0.02em; white-space: nowrap; } .team-head-rule { flex: 1; min-width: 6px; height: 1px; background: linear-gradient(to right, var(--hairline), transparent); } .team-head-right { min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* 用完轮数仍未完成:失败是红、降档交付是琥珀。平常的表头是安静的灰 */ .team-head-right.is-err { color: var(--st-failed); font-weight: 600; } .team-head-right.is-warn { color: var(--st-waiting); font-weight: 600; } .team-row { position: relative; display: flex; align-items: center; gap: 6px; height: 18px; padding-left: 5px; border-left: 2px solid transparent; font-size: 10.5px; } /* 被派出去的成员:左边一条竖线把它们串成一组。在跑时是运行色,收尾之后 退成中性线——蓝色只说「正在跑」 */ .team-row-active { border-left-color: var(--border-strong); } .team-matrix.is-live .team-row-active { border-left-color: color-mix(in srgb, var(--st-running) 55%, transparent); } .team-row-running .team-name { color: var(--text); } .team-row-idle { opacity: 0.5; } /* 前几轮干过、这一轮没它:压暗但留着成绩,不是"待命" */ .team-row-past { opacity: 0.6; } /* 轮数用完、一次都没被派到:不压暗,左边一道琥珀虚线——它正是这次没做完的线索 */ .team-row-never { border-left: 2px dashed color-mix(in srgb, var(--st-waiting) 70%, transparent); } .team-row-never .team-name, .team-row-never .team-ms { color: var(--st-waiting); } /* 调度者那一行:和成员分开,底下一道细线 */ .team-row-dispatch { margin-bottom: 2px; border-bottom: 1px dashed var(--hairline); } .team-row-dispatch .team-name { color: var(--text-faint); } .team-row-dispatch.team-row-running .team-name { color: var(--text); } .team-name { flex: none; width: 58px; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; } .team-bar { position: relative; flex: 1; min-width: 18px; height: 3px; overflow: hidden; border-radius: 999px; background: var(--bg-hover); } /* 长度用 scaleX 而不是 width:动效只走 transform */ .team-bar-fill { position: absolute; inset: 0; border-radius: 999px; background: color-mix(in srgb, var(--st-done) 80%, transparent); transform-origin: left center; transition: transform var(--dur-slow) var(--ease-out); } .team-row-failed .team-bar-fill { background: var(--st-failed); } /* 还在跑:不知道要多久,所以是不定长的流光,不是百分比 */ .team-bar-running { position: absolute; top: 0; bottom: 0; width: 40%; border-radius: 999px; background: linear-gradient(90deg, transparent, var(--st-running), transparent); animation: team-shimmer 1.15s var(--ease-in-out) infinite; } @keyframes team-shimmer { from { transform: translateX(-110%); } to { transform: translateX(260%); } } .team-ms { flex: none; min-width: 38px; color: var(--text-faint); font-size: 9.5px; text-align: right; white-space: nowrap; } .team-row-running .team-ms { color: var(--st-running); } .team-row-failed .team-ms { color: var(--st-failed); } .team-foot { display: flex; align-items: center; gap: 5px; height: 18px; margin-top: 2px; border-top: 1px dashed var(--hairline); color: var(--text-faint); font-size: 9.5px; } .team-saved { color: var(--st-done); font-weight: 500; } /* 活跃边上跑的光点:方向感靠它,虚线只会让人觉得"在动",看不出往哪走。 边的运行态(走过、正在流、停在审批、阻断)在 canvas/surface.css,这里只留 光点的底样式和它的关键帧,surface.css 在画布根上覆盖颜色 */ .edge-packet { fill: var(--accent); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 80%, transparent)); offset-rotate: 0deg; animation-name: packet-run; animation-timing-function: linear; animation-iteration-count: infinite; } @keyframes packet-run { from { offset-distance: 0%; } to { offset-distance: 100%; } } @keyframes fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } .fade-up { animation: fade-up 0.16s ease-out; } /* -------------------------------------------------------------------------- 助手栏的动效。 缓动统一用 --ease-out:起步快、尾部长,是"被推出来"而不是"匀速平移"的 手感。面板滑动用 --dur-base——超过 240ms 就开始像卡顿,而不是像流畅。 -------------------------------------------------------------------------- */ /* 属性面板:从右侧盖到助手栏上。它是临时造访者,助手才是常住的,所以用 "盖上去再拿走"而不是切 tab——切 tab 会把下面那层卸载掉,滚动位置、 输入草稿、展开状态全丢。 */ @keyframes sheet-in { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: none; } } .sheet-in { animation: sheet-in var(--dur-base) var(--ease-out); } /* -------------------------------------------------------------------------- 可点击证据(run/EvidenceDoc)。报告里有出处、没出处的片段是原生 button,外观 和正文一样;状态靠下划线的线型和颜色,外观数据在 lib/evidence.ts(颜色、淡底 由组件写成 --ev-line / --ev-soft),这里只管线型和交互态。 不画整句下划线:一份报告满屏都是线就什么都看不出来了。 -------------------------------------------------------------------------- */ .ev-seg { display: inline; margin: 0; padding: 0; border: 0; border-radius: 2px; background: none; font: inherit; color: inherit; letter-spacing: inherit; line-height: inherit; text-align: inherit; white-space: inherit; cursor: pointer; text-decoration-line: underline; text-decoration-color: var(--ev-line, currentColor); text-decoration-thickness: 1px; text-underline-offset: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; } .ev-seg[data-ev-line="solid"] { text-decoration-style: solid; } /* 点状线比实线细碎,同样 1px 在小字号下几乎看不见 */ .ev-seg[data-ev-line="dotted"] { text-decoration-style: dotted; text-decoration-thickness: 1.5px; } /* 引文:前面挂一个引号(字形那一条通道),颜色随状态——有出处的绿、原话对不上的琥珀。 引号是装饰,读屏从 aria-label 里听到「引文」,这里的替代文字给空串 */ .ev-seg[data-ev-kind="quote"]::before { content: "“"; content: "“" / ""; margin-right: 1px; color: var(--ev-glyph, currentColor); font-weight: 600; } /* 旧运行的候选:比无证据的点状线更淡、更细,绝不像确定性的实线。正文里的候选是 Markdown 的标记(.ev-mark), 不是按钮:旧答案保留原来的版式,候选的明细在下面的清单里逐条列出 */ .ev-seg[data-ev-state="candidate"] { text-decoration-thickness: 1px; } .ev-mark { text-decoration-line: underline; text-decoration-style: dotted; text-decoration-thickness: 1px; text-decoration-color: var(--ev-line, currentColor); text-underline-offset: 3px; } .ev-seg:hover, .ev-seg[aria-expanded="true"] { background: var(--ev-soft, transparent); } .ev-seg:focus-visible, .ev-seg[data-flash] { outline: 2px solid var(--accent); outline-offset: 1px; } /* 句末的「无证据」小标签:字形加文字,去掉颜色也读得出 */ .ev-tag { margin: 0 3px; font-size: 10px; line-height: 1; white-space: nowrap; vertical-align: 1px; color: var(--st-waiting); } /* 结论句的判定(四期):句末上标的小字形,是按钮但不像按钮。颜色随判定(组件写成 --ev-glyph), 绝不用确定性的绿;整句的字不画线——判定是模型给的,不能长得像系统核对过的数字 */ .ev-badge { display: inline-block; margin: 0 1px 0 2px; padding: 0 3px; border: 0; border-radius: 3px; background: none; font: inherit; font-size: 0.72em; font-weight: 700; line-height: 1; vertical-align: super; color: var(--ev-glyph, currentColor); text-decoration: none; cursor: pointer; } .ev-badge:hover, .ev-badge[aria-expanded="true"] { background: var(--ev-soft, transparent); } .ev-badge:focus-visible, .ev-badge[data-flash] { outline: 2px solid var(--accent); outline-offset: 1px; } /* 证据不支持:整句铺一层浅底,折行时每一行各自带圆角;不画线、不改字色 */ .ev-claim { background: var(--ev-soft, transparent); border-radius: 2px; text-decoration: none; -webkit-box-decoration-break: clone; box-decoration-break: clone; } .ev-panel-side { animation: sheet-in var(--dur-base) var(--ease-out); } .ev-panel-rise { animation: fade-up var(--dur-base) var(--ease-out); } /* 例句卡片依次入场。--i 是序号,错开 40ms 就够读出"一条条出现", 再多就开始等 */ @keyframes rise-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } .rise-in { opacity: 0; animation: rise-in var(--dur-slow) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 40ms); } /* 正在流的那一步:一道光扫过文字。比转圈多说一件事——它在出字,不只是在等 */ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } .shimmer { background: linear-gradient( 90deg, var(--text-faint) 0%, var(--text-faint) 35%, var(--text) 50%, var(--text-faint) 65%, var(--text-faint) 100% ); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 2.4s linear infinite; } /* 空态那枚标记出现时"吸一口气":暗示可以开始了。只播一次就停——空闲时 一直在动的东西会一直分走注意力,而这里什么都没发生 */ @keyframes breathe { 0% { opacity: 0.55; transform: scale(1); } 45% { opacity: 1; transform: scale(1.06); } 100% { opacity: 1; transform: none; } } .breathe { animation: breathe 2.4s var(--ease-in-out) 1 both; } /* 输入坞聚焦时的光晕。焦点是这一栏最重要的状态,值得比 1px 描边更明确 */ .dock-focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); border-color: var(--accent); } /* -------------------------------------------------------------------------- 前庭敏感的人看这些动效会难受,而这套界面里动的东西不少(边在流动、节点在 脉冲、面板在滑)。系统里关掉动效的,这里就全部停掉——不是调慢,是停掉。 -------------------------------------------------------------------------- */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } .rise-in { opacity: 1; } .shimmer { background: none; -webkit-background-clip: border-box; background-clip: border-box; color: var(--text-dim); } /* 边框上的光弧、扫描线、扫光、一次性时刻的描边、流光条、边上的光点—— 这些"停了就等于没有"的直接不画。只把时长压到 0.01ms 的话它们会停在动画 的最后一帧:光弧卡在角落、光点全堆在起点,看着像渲染坏了。 状态不会因此看不出来:卡片还有剪影、左侧状态槽、边框线型和状态文字, 在跑的节点和成员照样走着计时 */ .fx-halo, .fx-scan, .fx-done, .fx-sweep, .fx-moment, .fx-focus, .edge-packet, .team-bar-running { display: none; } /* 时钟这时 1 秒才走一拍,十分位只会一顿一顿地跳 */ .nc-tenth { display: none; } /* 证据面板直接出现,不滑入:滑入停在 0.01ms 的最后一帧虽然看不出来,但它仍然是一段动画 */ .ev-panel-side, .ev-panel-rise { animation: none !important; } } /* -------------------------------------------------------------------------- 组件类放进 components 层:排在 utilities 前面,元素上再写的工具类 (w-16、text-[12px]、border-[var(--accent)]…)才压得过它们。以前它们不在 任何层里,这些工具类全被静默吞掉——`field w-44` 实际占满整行。 -------------------------------------------------------------------------- */ @layer components { /* 表单控件统一外观 */ .field { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 6px 9px; color: var(--text); font-size: 12px; outline: none; transition: border-color var(--dur-fast) var(--ease-out); } .field:focus { border-color: var(--accent); } .field::placeholder { color: var(--text-faint); } textarea.field { resize: vertical; min-height: 62px; line-height: 1.55; } select.field { cursor: pointer; } .btn { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--radius-md); padding: 6px 11px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); background: var(--bg-elev); color: var(--text); cursor: pointer; /* 按属性列出来:transition: all 会把布局属性也带上,主题切换、尺寸变化 时一起慢慢挪 */ transition-property: background-color, border-color, color, box-shadow, opacity; transition-duration: var(--dur-fast); transition-timing-function: var(--ease-out); white-space: nowrap; } .btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-strong); } .btn:disabled { opacity: 0.45; cursor: not-allowed; } /* 实心底用 --accent-solid 配 --on-accent:深色下白字压在 --accent 上只有 3.2:1。悬停压暗而不是提亮——提亮会把对比度拉回 4.5 以下 */ .btn-primary { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--on-accent); } .btn-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-solid) 88%, #000); border-color: color-mix(in srgb, var(--accent-solid) 88%, #000); } .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 40%, transparent); } .btn-ghost { background: transparent; border-color: transparent; } .btn-ghost:hover:not(:disabled) { background: var(--bg-hover); } .btn-sm { padding: 3px 7px; font-size: 11px; } /* 行内的小图标按钮(会话列表的重命名 / 删除、气泡下的小操作)。以前用了 七处却没定义,实际按普通按钮的尺寸渲染 */ .btn-xs { padding: 2px 6px; font-size: 11px; gap: 4px; } .chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; border: 1px solid var(--border); color: var(--text-dim); white-space: nowrap; } .label { display: block; font-size: 11px; color: var(--text-dim); margin-bottom: 4px; font-weight: 500; } }