niceeval 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.zh.md +11 -0
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +4 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +39 -5
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +4 -3
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +3 -4
- package/src/i18n/zh-CN.ts +2 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/run.ts +30 -19
- package/src/runner/types.ts +17 -0
- package/src/sandbox/types.ts +3 -3
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/i18n.ts +10 -1
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +13 -19
- package/src/view/loader.test.ts +61 -0
- package/src/view/loader.ts +35 -12
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
package/src/view/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
|
-
import {
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
8
|
import { loadSummaries, type ScanResult } from "./loader.ts";
|
|
9
9
|
import { renderHtml, type ViewOptions } from "./server.ts";
|
|
10
10
|
|
|
@@ -13,34 +13,28 @@ export {
|
|
|
13
13
|
IncompatibleResultsError,
|
|
14
14
|
incompatibleHint,
|
|
15
15
|
incompatibleViewCommand,
|
|
16
|
-
|
|
16
|
+
loadLatestResultsPerEval,
|
|
17
17
|
loadSummaries,
|
|
18
18
|
type IncompatibleRun,
|
|
19
19
|
type ScanResult,
|
|
20
20
|
type SkippedRun,
|
|
21
21
|
} from "./loader.ts";
|
|
22
22
|
|
|
23
|
-
/** --out 目标是单文件(*.html)还是目录式静态导出;CLI 的提示文案也按这个口径分。 */
|
|
24
|
-
export function isSingleFileOut(out: string): boolean {
|
|
25
|
-
return /\.html?$/i.test(out);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
/**
|
|
29
|
-
* 导出静态报告(--out)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* server 的 /artifact/<rel> 路由同一布局,整个目录扔给任何静态托管即是完整体验。
|
|
24
|
+
* 导出静态报告(--out):只有目录式一种形态。写 <dir>/index.html,并把前端会 fetch 的工件
|
|
25
|
+
* (sources.json / events.json / trace.json)复制到 <dir>/artifact/<base>/——与本地
|
|
26
|
+
* server 的 /artifact/<rel> 路由同一布局,整个目录扔给任何静态托管即是完整体验。
|
|
27
|
+
* 单文件(*.html)导出已移除:代码/transcript/trace 视图依赖工件文件,单文件注定残缺,
|
|
28
|
+
* 存在本身就在诱导用户导出一份看不了证据的报告(docs/view.md「静态导出」)。
|
|
35
29
|
*/
|
|
36
30
|
export async function buildView(opts: ViewOptions = {}): Promise<string> {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return out;
|
|
31
|
+
const out = resolve(opts.out ?? ".niceeval/site");
|
|
32
|
+
if (/\.html?$/i.test(out)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`--out expects a directory, got "${opts.out}". Single-file HTML export was removed: code, transcript and trace views need artifact files next to the page. Export a directory instead (e.g. --out site) and serve it with any static host.`,
|
|
35
|
+
);
|
|
43
36
|
}
|
|
37
|
+
const scan = await loadSummaries(opts.input);
|
|
44
38
|
await mkdir(out, { recursive: true });
|
|
45
39
|
await writeFile(join(out, "index.html"), await renderHtml(scan), "utf-8");
|
|
46
40
|
await copyFetchedArtifacts(scan, join(out, "artifact"));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// loadLatestResultsPerEval:续跑携带基线必须是跨历史「每 (experimentId, evalId) 最新一份」,
|
|
2
|
+
// 而不是最近一个 run —— 否则部分补跑 run 会把基线清空,`exp <组>` 补齐失效。
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { loadLatestResultsPerEval } from "./loader.ts";
|
|
8
|
+
import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
|
|
9
|
+
|
|
10
|
+
let root: string;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (root) await rm(root, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function writeRun(dir: string, startedAt: string, results: object[]): Promise<void> {
|
|
17
|
+
await mkdir(join(root, dir), { recursive: true });
|
|
18
|
+
await writeFile(
|
|
19
|
+
join(root, dir, "summary.json"),
|
|
20
|
+
JSON.stringify({ format: RESULTS_FORMAT, schemaVersion: RESULTS_SCHEMA_VERSION, startedAt, results }),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function result(experimentId: string, id: string, outcome: string, extra: object = {}): object {
|
|
25
|
+
return { experimentId, id, agent: "a", outcome, ...extra };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("loadLatestResultsPerEval", () => {
|
|
29
|
+
it("部分补跑 run 只遮蔽它跑过的 eval,其它 eval 仍取自更早的全量 run", async () => {
|
|
30
|
+
root = await mkdtemp(join(tmpdir(), "niceeval-loader-"));
|
|
31
|
+
await writeRun("2026-01-01T00-00-00", "2026-01-01T00:00:00.000Z", [
|
|
32
|
+
result("exp/a", "e1", "passed"),
|
|
33
|
+
result("exp/a", "e2", "errored"),
|
|
34
|
+
result("exp/b", "e1", "passed"),
|
|
35
|
+
]);
|
|
36
|
+
// 部分补跑:只重跑了 exp/a 的 e2
|
|
37
|
+
await writeRun("2026-01-02T00-00-00", "2026-01-02T00:00:00.000Z", [result("exp/a", "e2", "passed")]);
|
|
38
|
+
|
|
39
|
+
const results = (await loadLatestResultsPerEval(root)) as Array<{ experimentId: string; id: string; outcome: string }>;
|
|
40
|
+
const byKey = new Map(results.map((r) => [`${r.experimentId}|${r.id}`, r.outcome]));
|
|
41
|
+
expect(byKey.get("exp/a|e1")).toBe("passed"); // 来自旧全量 run,没被部分 run 冲掉
|
|
42
|
+
expect(byKey.get("exp/a|e2")).toBe("passed"); // 来自补跑 run(最新)
|
|
43
|
+
expect(byKey.get("exp/b|e1")).toBe("passed");
|
|
44
|
+
expect(results).toHaveLength(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("同 (experiment, eval) 多 attempt 整批取自含它的最新 run,不跨 run 混装", async () => {
|
|
48
|
+
root = await mkdtemp(join(tmpdir(), "niceeval-loader-"));
|
|
49
|
+
await writeRun("2026-01-01T00-00-00", "2026-01-01T00:00:00.000Z", [
|
|
50
|
+
result("exp/a", "e1", "passed", { fingerprint: "old" }),
|
|
51
|
+
]);
|
|
52
|
+
await writeRun("2026-01-02T00-00-00", "2026-01-02T00:00:00.000Z", [
|
|
53
|
+
result("exp/a", "e1", "failed", { fingerprint: "new" }),
|
|
54
|
+
result("exp/a", "e1", "passed", { fingerprint: "new" }),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const results = (await loadLatestResultsPerEval(root)) as Array<{ fingerprint: string }>;
|
|
58
|
+
expect(results).toHaveLength(2);
|
|
59
|
+
expect(results.every((r) => r.fingerprint === "new")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/view/loader.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync, statSync } from "node:fs";
|
|
|
6
6
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
7
7
|
import { dirname, join, relative, resolve } from "node:path";
|
|
8
8
|
import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION, type EvalResult, type RunSummary } from "../types.ts";
|
|
9
|
+
import type { ViewEvalResult } from "./shared/types.ts";
|
|
9
10
|
import { t } from "../i18n/index.ts";
|
|
10
11
|
|
|
11
12
|
export interface LoadedSummary {
|
|
@@ -86,11 +87,27 @@ export class NotAReportError extends Error {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 读跨历史「每 (experimentId, evalId) 最新一份」的 EvalResult,供续跑携带已通过结果。
|
|
92
|
+
* 只看最近一个 run 不行:部分补跑(位置参数只跑几道题)会把携带基线换成那个部分 run,
|
|
93
|
+
* 之后重跑任何实验都携带不到东西,`exp <组>` 的「补齐缺失」语义随之失效。
|
|
94
|
+
* 同一 (experimentId, evalId) 的多个 attempt 整批取自含它的最新 run,不跨 run 混装。
|
|
95
|
+
*/
|
|
96
|
+
export async function loadLatestResultsPerEval(root = ".niceeval"): Promise<EvalResult[]> {
|
|
97
|
+
const { loaded } = await loadSummaries(root); // 已按 startedAt 降序
|
|
98
|
+
const claimed = new Set<string>();
|
|
99
|
+
const results: EvalResult[] = [];
|
|
100
|
+
for (const { summary } of loaded) {
|
|
101
|
+
const takenThisRun = new Set<string>();
|
|
102
|
+
for (const r of summary.results ?? []) {
|
|
103
|
+
const key = `${r.experimentId ?? `${r.agent}|${r.model ?? ""}`}|${r.id}`;
|
|
104
|
+
if (claimed.has(key)) continue;
|
|
105
|
+
takenThisRun.add(key);
|
|
106
|
+
results.push(r);
|
|
107
|
+
}
|
|
108
|
+
for (const key of takenThisRun) claimed.add(key);
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
/** 服务/解析工件的根目录:输入是目录就用它,是文件就用其所在目录。 */
|
|
@@ -112,7 +129,7 @@ export async function loadSummaries(input?: string): Promise<ScanResult> {
|
|
|
112
129
|
if (s.isFile()) {
|
|
113
130
|
// 单文件模式:这是用户明确指定的目标,任何读不了(版本/损坏/无关)都直接抛,由 CLI 打印提示退出。
|
|
114
131
|
const summary = normalizeSummary(await parseSummary(target), target);
|
|
115
|
-
return { loaded: [{ path: target, summary:
|
|
132
|
+
return { loaded: [{ path: target, summary: withViewRefs(summary, target, root, artifactDirs) }], skipped: [], artifactDirs };
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
const candidates = await findSummaryFiles(target);
|
|
@@ -121,7 +138,7 @@ export async function loadSummaries(input?: string): Promise<ScanResult> {
|
|
|
121
138
|
for (const path of candidates) {
|
|
122
139
|
try {
|
|
123
140
|
const summary = normalizeSummary(await parseSummary(path), path);
|
|
124
|
-
loaded.push({ path, summary:
|
|
141
|
+
loaded.push({ path, summary: withViewRefs(summary, path, root, artifactDirs) });
|
|
125
142
|
} catch (e) {
|
|
126
143
|
const entry = classifySkip(e, path);
|
|
127
144
|
// 无关 JSON(NotAReportError)可以静默忽略;像报告却读不了的必须记下来。
|
|
@@ -203,25 +220,31 @@ function classifySkip(e: unknown, path: string): SkippedRun | undefined {
|
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
/**
|
|
206
|
-
* 给每条 result
|
|
223
|
+
* 给每条 result 注入 view 侧标注:
|
|
224
|
+
* - attemptRef:run 目录(相对 view 根)+ summary.results 下标,`#/attempt/<run>/<result>`
|
|
225
|
+
* 深链的身份。必须在这里(读盘顺序)捕获下标——聚合层会把 results 重排。
|
|
226
|
+
* - artifactBase:相对 view 根的工件目录(前端据此 fetch trace.json 等)。
|
|
207
227
|
* 返回新对象,不 mutate 读入的 summary;宿主机绝对路径只写进 artifactDirs(server 端内存),
|
|
208
228
|
* 不挂到 result 上,避免随 viewData 进静态 HTML。
|
|
209
229
|
*/
|
|
210
|
-
function
|
|
230
|
+
function withViewRefs(
|
|
211
231
|
summary: RunSummary,
|
|
212
232
|
summaryPath: string,
|
|
213
233
|
root: string,
|
|
214
234
|
artifactDirs: Map<string, string>,
|
|
215
235
|
): RunSummary {
|
|
216
236
|
const runDir = dirname(summaryPath);
|
|
237
|
+
// 单文件入口时 run 目录就是 view 根,relative 为空串;统一占位 "."(与 classifySkip 的目录口径一致)。
|
|
238
|
+
const runRef = relative(root, runDir).split(/[\\/]/).join("/") || ".";
|
|
217
239
|
return {
|
|
218
240
|
...summary,
|
|
219
|
-
results: summary.results.map((r) => {
|
|
220
|
-
|
|
241
|
+
results: summary.results.map((r, index): ViewEvalResult => {
|
|
242
|
+
const annotated: ViewEvalResult = { ...r, attemptRef: { run: runRef, result: index } };
|
|
243
|
+
if (!r.artifactsDir) return annotated;
|
|
221
244
|
const abs = join(runDir, r.artifactsDir);
|
|
222
245
|
const base = relative(root, abs).split(/[\\/]/).join("/");
|
|
223
246
|
artifactDirs.set(base, abs);
|
|
224
|
-
return { ...
|
|
247
|
+
return { ...annotated, artifactBase: base };
|
|
225
248
|
}),
|
|
226
249
|
};
|
|
227
250
|
}
|
package/src/view/shared/types.ts
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
import type { EvalResult, LocalizedText, Usage } from "../../types.ts";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* attempt 的深链身份:run 目录(相对 view 输入根,通常就是 `.niceeval/` 下的时间戳目录名)
|
|
8
|
+
* + 该 attempt 在 summary.results 里的下标。`#/attempt/<run>/<result>` 路由的参数——
|
|
9
|
+
* 报告页(前门)与 view(证据室)靠这份契约指向同一个 attempt,见 docs/view.md「用 Reports 积木重建 view」。
|
|
10
|
+
*/
|
|
11
|
+
export interface AttemptRef {
|
|
12
|
+
run: string;
|
|
13
|
+
result: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** view 侧的 attempt 结果 = 瘦身后的 EvalResult + loadSummaries 注入的深链身份。 */
|
|
17
|
+
export type ViewEvalResult = EvalResult & { attemptRef?: AttemptRef };
|
|
18
|
+
|
|
6
19
|
/** 榜单一行 = 一个实验(或 legacy 的 agent×model 组合),跨全部历史 run 聚合。 */
|
|
7
20
|
export interface ViewRow {
|
|
8
21
|
key: string;
|
|
@@ -27,7 +40,7 @@ export interface ViewRow {
|
|
|
27
40
|
estimatedCostUSD?: number;
|
|
28
41
|
/** 该实验组里最新一次 run 的 startedAt(ISO);详情展示「运行时间」。 */
|
|
29
42
|
lastRunAt?: string;
|
|
30
|
-
results:
|
|
43
|
+
results: ViewEvalResult[];
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
/** 目录扫描里被跳过的 run 在页面顶部的提示条目。 */
|
package/src/view/styles.css
CHANGED
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
/* send/回复行的强调色,对齐 landing page setup-card 的 --blue(#6ea8fe)。 */
|
|
35
35
|
--blue: light-dark(#1d63d8, #6ea8fe);
|
|
36
36
|
--focus: light-dark(#111113, #ededed);
|
|
37
|
+
/* 分类色板(cost/score 散点图按实验区分系列),六色顺序按 CVD 校验过,不要重排。 */
|
|
38
|
+
--series-1: light-dark(#2a78d6, #3987e5);
|
|
39
|
+
--series-2: light-dark(#1baf7a, #199e70);
|
|
40
|
+
--series-3: light-dark(#eda100, #c98500);
|
|
41
|
+
--series-4: light-dark(#008300, #008300);
|
|
42
|
+
--series-5: light-dark(#e34948, #e66767);
|
|
43
|
+
--series-6: light-dark(#eb6834, #d95926);
|
|
37
44
|
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -308,6 +315,42 @@ tbody tr:last-child td { border-bottom: 0; }
|
|
|
308
315
|
.group-card-rate { font-size: 30px; font-weight: 760; font-variant-numeric: tabular-nums; line-height: 1; margin-bottom: 6px; }
|
|
309
316
|
.group-card-meta { font-size: 12px; color: var(--muted); }
|
|
310
317
|
.group-card-time { font-size: 11px; color: var(--soft); margin-top: 2px; }
|
|
318
|
+
.cost-score-chart {
|
|
319
|
+
border: 1px solid var(--line);
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
padding: 16px 20px 10px;
|
|
322
|
+
margin-bottom: 20px;
|
|
323
|
+
background: var(--panel);
|
|
324
|
+
}
|
|
325
|
+
.csc-head { font-size: 13px; font-weight: 680; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
326
|
+
.csc-svg-wrap { position: relative; width: 100%; }
|
|
327
|
+
.csc-svg { width: 100%; height: auto; display: block; overflow: visible; }
|
|
328
|
+
.csc-grid line { stroke: var(--line); stroke-width: 1; }
|
|
329
|
+
.csc-axis text { fill: var(--muted); font-size: 10px; }
|
|
330
|
+
.csc-point { cursor: pointer; }
|
|
331
|
+
.csc-point .csc-hit { fill: transparent; }
|
|
332
|
+
.csc-point .csc-dot { stroke: var(--panel); stroke-width: 2; }
|
|
333
|
+
.csc-point text { fill: var(--text); font-size: 11px; }
|
|
334
|
+
.csc-leader { stroke: var(--line-strong); stroke-width: 1; }
|
|
335
|
+
.csc-point:hover .csc-dot,
|
|
336
|
+
.csc-point:focus-visible .csc-dot { r: 6.5; }
|
|
337
|
+
.csc-point:focus-visible { outline: none; }
|
|
338
|
+
.csc-tooltip {
|
|
339
|
+
position: absolute;
|
|
340
|
+
transform: translate(-50%, -100%);
|
|
341
|
+
margin-top: -12px;
|
|
342
|
+
background: var(--panel);
|
|
343
|
+
border: 1px solid var(--line-strong);
|
|
344
|
+
border-radius: 6px;
|
|
345
|
+
padding: 8px 10px;
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
white-space: nowrap;
|
|
348
|
+
pointer-events: none;
|
|
349
|
+
box-shadow: 0 4px 16px color-mix(in oklch, var(--text), transparent 88%);
|
|
350
|
+
z-index: 5;
|
|
351
|
+
}
|
|
352
|
+
.csc-tooltip b { font-size: 13px; }
|
|
353
|
+
.csc-tooltip .csc-tooltip-meta { color: var(--muted); margin-top: 2px; }
|
|
311
354
|
.section-sub-head {
|
|
312
355
|
display: flex;
|
|
313
356
|
align-items: center;
|