From 1cf7e13c3f95a100337ac5c0b3beaa2ea269b33d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 18 Jun 2026 23:28:34 +0200 Subject: [PATCH 01/35] feat(dashboard): Rspack build foundation, real Tailwind v4, canonical cn Replace the esbuild + hand-rolled-utility dashboard build with the Rspack engine (the one Rsbuild wraps) and real Tailwind v4, while preserving the prod-embed contract exactly: dist files are still embedded at compile time by src/dashboard/assets.rs and served with no Node/Rspack dependency at launch. Rspack runs only at build time. Build (dashboard/build.mjs, replaces esbuild): - shell + holographic/graph/savings plugins built with @rspack/core; React externalized onto the host SDK via resolve.alias to the in-tree shims, so every plugin still shares the host's single React instance and runs unmodified in both the standalone shell and Hermes. - single-file IIFE per plugin at the exact dist paths assets.rs expects. - hermes-wrapper concatenation + lcm copy preserved. - esbuild builder kept as build-esbuild.mjs (`npm run build:esbuild`) fallback. - holographic CSS compiled with real Tailwind v4 (@tailwindcss/node + @tailwindcss/oxide); @layer theme/base stripped so the plugin never clobbers host :root vars, wrapped in @layer hermes-plugin. Holographic styles (dashboard/holographic/src/styles.css): - @import "tailwindcss" + @theme color tokens replace the ~390-line hand-rolled utility subset (the fragile [class*="xl:grid-cols-[..."] attribute selectors). Arbitrary values now resolve natively. - surviving :root provides only the tokens the host doesn't (text-primary/secondary/tertiary, midground, shadow-*); hv-*/ts-card component polish kept verbatim. cn (dashboard/lib/cn.ts): one canonical class-name joiner (flatten nested arrays, keep non-empty strings) consumed by the shell SDK, lib/sdk.ts, and holographic's self-contained in-tree copy. Pinned by test/shashell-sdk.test.mjs. Verified: 100/100 node + 12/12 vitest; jsdom smokes (shell renders + exposes SDK; all 3 plugins register); cargo embed + dashboard serve (all routes 200, byte sizes match); real-browser Playwright smoke (desktop + narrow) exercises Holographic/Similarity/Curation/Code Graph/LCM. --- dashboard/build-esbuild.mjs | 165 ++++++++ dashboard/build.mjs | 236 +++++++---- dashboard/holographic/src/sdk.ts | 15 +- dashboard/holographic/src/styles.css | 338 +++------------- dashboard/lib/cn.ts | 12 + dashboard/lib/sdk.ts | 7 +- dashboard/package-lock.json | 561 +++++++++++++++++++++++++++ dashboard/package.json | 5 + dashboard/shell/src/sdk.jsx | 9 +- 9 files changed, 974 insertions(+), 374 deletions(-) create mode 100644 dashboard/build-esbuild.mjs create mode 100644 dashboard/lib/cn.ts diff --git a/dashboard/build-esbuild.mjs b/dashboard/build-esbuild.mjs new file mode 100644 index 00000000..544f5cf0 --- /dev/null +++ b/dashboard/build-esbuild.mjs @@ -0,0 +1,165 @@ +/** + * Build every dashboard artifact served by `tracedecay dashboard`. + * + * npm install && npm run build (from dashboard/) + * + * Outputs: + * shell/dist/shell.js + shell.css Standalone host shell (bundles React 19, + * exposes a Hermes-compatible plugin SDK on + * window, loads the plugin bundles below). + * holographic/dist/index.js Holographic-memory plugin bundle, rebuilt + * from holographic/src (esbuild IIFE; React + * externalized onto the host SDK via shims, + * exactly like the original Hermes build). + * holographic/dist/style.css Copied from holographic/src/styles.css + * (hand-rolled token stylesheet). + * lcm/dist/index.js + style.css Copied from lcm/src (hand-written, + * unbundled JS — no build step needed). + * graph/dist/index.js + style.css Code graph explorer plugin bundle + * (esbuild IIFE; React externalized). + * + * The Rust binary embeds these dist files at compile time (src/dashboard/assets.rs), + * so run this before `cargo build` when the UI changed. + */ + +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs/promises"; +import esbuild from "esbuild"; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +async function buildShell() { + await esbuild.build({ + entryPoints: [path.join(root, "shell/src/main.jsx")], + outfile: path.join(root, "shell/dist/shell.js"), + bundle: true, + format: "iife", + platform: "browser", + target: ["es2020"], + jsx: "automatic", + minify: true, + legalComments: "none", + define: { "process.env.NODE_ENV": '"production"' }, + logLevel: "warning", + }); + await fs.copyFile( + path.join(root, "shell/src/styles.css"), + path.join(root, "shell/dist/shell.css"), + ); +} + +/** + * Builds one plugin bundle (`/src/entry.tsx` → `/dist/index.js`), + * externalizing React onto the host SDK (Hermes or the standalone shell) via + * the shims; everything else (@observablehq/plot, d3-force, lucide-react) is + * bundled. + * + * Shims default to the shared `lib/` copies. holographic/ overrides with its + * own in-tree shims: that source mirrors the upstream Hermes plugin + * byte-for-byte (see build.from-hermes.mjs) and must stay self-contained. + */ +async function buildPlugin(dir, bannerLabel, { shimDir = path.join(root, "lib") } = {}) { + const srcDir = path.join(root, dir, "src"); + await esbuild.build({ + entryPoints: [path.join(srcDir, "entry.tsx")], + outfile: path.join(root, dir, "dist/index.js"), + bundle: true, + format: "iife", + platform: "browser", + target: ["es2020"], + jsx: "automatic", + minify: true, + legalComments: "none", + define: { "process.env.NODE_ENV": '"production"' }, + alias: { + react: path.join(shimDir, "react-shim.ts"), + "react/jsx-runtime": path.join(shimDir, "jsx-runtime.ts"), + "react/jsx-dev-runtime": path.join(shimDir, "jsx-runtime.ts"), + }, + banner: { + js: `/* tracedecay ${bannerLabel} dashboard plugin — bundled with esbuild. Do not edit; see src/. */`, + }, + logLevel: "warning", + }); + await fs.copyFile( + path.join(srcDir, "styles.css"), + path.join(root, dir, "dist/style.css"), + ); +} + +async function copyLcm() { + const dist = path.join(root, "lcm/dist"); + await fs.mkdir(dist, { recursive: true }); + await fs.copyFile(path.join(root, "lcm/src/index.js"), path.join(dist, "index.js")); + await fs.copyFile(path.join(root, "lcm/src/style.css"), path.join(dist, "style.css")); +} + +/** + * The Hermes wrapper plugin reuses the exact bundles above: its dist gets the + * wrapper entry (registers the combined "tracedecay" tab), copies of + * the child bundles, and a concatenated stylesheet. Deploy by copying + * hermes-wrapper/{manifest.json,plugin_api.py,dist} into + * hermes-agent/plugins/hermes_intelligence/dashboard/. + */ +async function buildHermesWrapper() { + const dist = path.join(root, "hermes-wrapper/dist"); + await fs.mkdir(dist, { recursive: true }); + await fs.copyFile( + path.join(root, "hermes-wrapper/src/entry.js"), + path.join(dist, "index.js"), + ); + await fs.copyFile( + path.join(root, "holographic/dist/index.js"), + path.join(dist, "holographic.js"), + ); + await fs.copyFile(path.join(root, "lcm/dist/index.js"), path.join(dist, "lcm.js")); + await fs.copyFile(path.join(root, "graph/dist/index.js"), path.join(dist, "graph.js")); + await fs.copyFile(path.join(root, "savings/dist/index.js"), path.join(dist, "savings.js")); + const css = await Promise.all([ + fs.readFile(path.join(root, "hermes-wrapper/src/wrapper.css"), "utf8"), + fs.readFile(path.join(root, "holographic/dist/style.css"), "utf8"), + fs.readFile(path.join(root, "lcm/dist/style.css"), "utf8"), + fs.readFile(path.join(root, "graph/dist/style.css"), "utf8"), + fs.readFile(path.join(root, "savings/dist/style.css"), "utf8"), + ]); + await fs.writeFile(path.join(dist, "style.css"), css.join("\n"), "utf8"); +} + +async function main() { + await fs.mkdir(path.join(root, "shell/dist"), { recursive: true }); + await Promise.all([ + buildShell(), + buildPlugin("holographic", "holographic-memory", { + shimDir: path.join(root, "holographic/src"), + }), + buildPlugin("graph", "code graph"), + buildPlugin("savings", "savings & cost"), + copyLcm(), + ]); + await buildHermesWrapper(); + for (const f of [ + "shell/dist/shell.js", + "shell/dist/shell.css", + "holographic/dist/index.js", + "holographic/dist/style.css", + "lcm/dist/index.js", + "lcm/dist/style.css", + "graph/dist/index.js", + "graph/dist/style.css", + "savings/dist/index.js", + "savings/dist/style.css", + "hermes-wrapper/dist/index.js", + "hermes-wrapper/dist/graph.js", + "hermes-wrapper/dist/savings.js", + "hermes-wrapper/dist/style.css", + ]) { + const st = await fs.stat(path.join(root, f)); + console.log(`✓ ${f} ${(st.size / 1024).toFixed(1)} KB`); + } +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/dashboard/build.mjs b/dashboard/build.mjs index 544f5cf0..658c1726 100644 --- a/dashboard/build.mjs +++ b/dashboard/build.mjs @@ -4,45 +4,82 @@ * npm install && npm run build (from dashboard/) * * Outputs: - * shell/dist/shell.js + shell.css Standalone host shell (bundles React 19, - * exposes a Hermes-compatible plugin SDK on - * window, loads the plugin bundles below). - * holographic/dist/index.js Holographic-memory plugin bundle, rebuilt - * from holographic/src (esbuild IIFE; React - * externalized onto the host SDK via shims, - * exactly like the original Hermes build). - * holographic/dist/style.css Copied from holographic/src/styles.css - * (hand-rolled token stylesheet). - * lcm/dist/index.js + style.css Copied from lcm/src (hand-written, - * unbundled JS — no build step needed). - * graph/dist/index.js + style.css Code graph explorer plugin bundle - * (esbuild IIFE; React externalized). + * shell/dist/shell.js + shell.css Standalone host shell. + * holographic/dist/index.js Holographic-memory plugin bundle. + * graph/dist/index.js Code graph explorer plugin bundle. + * savings/dist/index.js Savings plugin bundle. + * lcm/dist/index.js + style.css Copied from lcm/src. + * hermes-wrapper/dist/* Combined Hermes dashboard plugin. * - * The Rust binary embeds these dist files at compile time (src/dashboard/assets.rs), - * so run this before `cargo build` when the UI changed. + * The Rust binary embeds these dist files at compile time + * (src/dashboard/assets.rs), so run this before `cargo build` when the UI + * changed. */ +import { rspack } from "@rspack/core"; +import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import esbuild from "esbuild"; import path from "node:path"; import fs from "node:fs/promises"; -import esbuild from "esbuild"; const root = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(path.join(root, "package.json")); -async function buildShell() { - await esbuild.build({ - entryPoints: [path.join(root, "shell/src/main.jsx")], - outfile: path.join(root, "shell/dist/shell.js"), - bundle: true, - format: "iife", - platform: "browser", - target: ["es2020"], - jsx: "automatic", - minify: true, - legalComments: "none", - define: { "process.env.NODE_ENV": '"production"' }, - logLevel: "warning", +function swcRule(syntax, test) { + const isTs = syntax === "typescript"; + return { + test, + exclude: /node_modules/, + use: { + loader: "builtin:swc-loader", + options: { + jsc: { + parser: isTs + ? { syntax: "typescript", tsx: true } + : { syntax: "ecmascript", jsx: true }, + transform: { react: { runtime: "automatic" } }, + }, + env: { targets: "defaults" }, + }, + }, + }; +} + +function run(config) { + return new Promise((resolve, reject) => { + rspack(config, (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) { + const info = stats.toJson({ all: false, errors: true }); + return reject(new Error(info.errors.map((e) => e.message).join("\n"))); + } + resolve(stats); + }); }); +} + +const RULES = [swcRule("ecmascript", /\.(jsx|js)$/), swcRule("typescript", /\.(tsx|ts)$/)]; + +function shellConfig() { + return { + mode: "production", + context: root, + entry: { shell: "./shell/src/main.jsx" }, + output: { + path: path.join(root, "shell/dist"), + filename: "shell.js", + clean: true, + }, + resolve: { extensions: [".jsx", ".js", ".json", ".ts", ".tsx"] }, + module: { rules: RULES }, + optimization: { minimize: true, splitChunks: false, runtimeChunk: false }, + performance: { hints: false }, + }; +} + +async function buildShell() { + await run(shellConfig()); await fs.copyFile( path.join(root, "shell/src/styles.css"), path.join(root, "shell/dist/shell.css"), @@ -50,42 +87,102 @@ async function buildShell() { } /** - * Builds one plugin bundle (`/src/entry.tsx` → `/dist/index.js`), - * externalizing React onto the host SDK (Hermes or the standalone shell) via - * the shims; everything else (@observablehq/plot, d3-force, lucide-react) is - * bundled. - * - * Shims default to the shared `lib/` copies. holographic/ overrides with its - * own in-tree shims: that source mirrors the upstream Hermes plugin - * byte-for-byte (see build.from-hermes.mjs) and must stay self-contained. + * Builds one plugin bundle with React externalized onto the host SDK via shims. */ -async function buildPlugin(dir, bannerLabel, { shimDir = path.join(root, "lib") } = {}) { +function pluginConfig(dir, shimDir, bannerLabel) { const srcDir = path.join(root, dir, "src"); - await esbuild.build({ - entryPoints: [path.join(srcDir, "entry.tsx")], - outfile: path.join(root, dir, "dist/index.js"), - bundle: true, - format: "iife", - platform: "browser", - target: ["es2020"], - jsx: "automatic", - minify: true, - legalComments: "none", - define: { "process.env.NODE_ENV": '"production"' }, - alias: { - react: path.join(shimDir, "react-shim.ts"), - "react/jsx-runtime": path.join(shimDir, "jsx-runtime.ts"), - "react/jsx-dev-runtime": path.join(shimDir, "jsx-runtime.ts"), + return { + mode: "production", + context: root, + target: "web", + entry: { index: path.join(srcDir, "entry.tsx") }, + output: { + path: path.join(root, dir, "dist"), + filename: "index.js", + clean: true, }, - banner: { - js: `/* tracedecay ${bannerLabel} dashboard plugin — bundled with esbuild. Do not edit; see src/. */`, + resolve: { + extensions: [".tsx", ".ts", ".jsx", ".js", ".json"], + alias: { + "react$": path.join(shimDir, "react-shim.ts"), + "react/jsx-runtime$": path.join(shimDir, "jsx-runtime.ts"), + "react/jsx-dev-runtime$": path.join(shimDir, "jsx-runtime.ts"), + }, }, - logLevel: "warning", - }); - await fs.copyFile( - path.join(srcDir, "styles.css"), - path.join(root, dir, "dist/style.css"), - ); + module: { rules: RULES }, + optimization: { minimize: true, splitChunks: false, runtimeChunk: false }, + performance: { hints: false }, + plugins: [ + new rspack.BannerPlugin({ + banner: `tracedecay ${bannerLabel} dashboard plugin - bundled with Rspack. Do not edit; see src/.`, + entryOnly: true, + }), + ], + }; +} + +async function buildPlugin( + dir, + bannerLabel, + { shimDir = path.join(root, "lib"), tailwind = false } = {}, +) { + await run(pluginConfig(dir, shimDir, bannerLabel)); + if (tailwind) { + await compileTailwindCss(path.join(root, dir, "src"), path.join(root, dir, "dist/style.css")); + } else { + await fs.copyFile( + path.join(root, dir, "src/styles.css"), + path.join(root, dir, "dist/style.css"), + ); + } +} + +/** + * Compile a plugin stylesheet with real Tailwind v4 (programmatic Oxide scan + + * @tailwindcss/node compile). Mirrors the proven build.from-hermes.mjs path: + * + * - scan the plugin src for class candidates; + * - strip @layer theme + @layer base so the plugin never clobbers the host's + * :root vars or preflight (utilities resolve --color-* against the host); + * - confine the sheet to the host's `hermes-plugin` cascade layer; + * - minify with esbuild (preserves @supports color-mix blocks that + * lightningcss would strip). + */ +async function compileTailwindCss(srcDir, outFile) { + const { compile } = require("@tailwindcss/node"); + const { Scanner } = require("@tailwindcss/oxide"); + const input = await fs.readFile(path.join(srcDir, "styles.css"), "utf8"); + const compiler = await compile(input, { base: root, onDependency: () => {} }); + const scanner = new Scanner({ sources: [{ base: srcDir, pattern: "**/*", negated: false }] }); + const candidates = scanner.scan(); + let css = compiler.build(candidates); + css = stripTopLevelAtLayer(css, "theme"); + css = stripTopLevelAtLayer(css, "base"); + css = `@layer hermes-plugin{\n${css}\n}`; + css = (await esbuild.transform(css, { loader: "css", minify: true })).code; + await fs.writeFile(outFile, css, "utf8"); +} + +/** Remove a top-level `@layer { ... }` block via brace matching. + * Matches `@layer name{` or `@layer name {` (any whitespace). */ +function stripTopLevelAtLayer(css, name) { + const re = new RegExp(`@layer\\s+${name}\\s*\\{`, "g"); + let out = css; + let m; + while ((m = re.exec(out)) !== null) { + const idx = m.index; + let i = idx + m[0].length; + let depth = 1; + while (i < out.length && depth > 0) { + const ch = out[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + out = out.slice(0, idx) + out.slice(i); + re.lastIndex = idx; + } + return out; } async function copyLcm() { @@ -96,23 +193,13 @@ async function copyLcm() { } /** - * The Hermes wrapper plugin reuses the exact bundles above: its dist gets the - * wrapper entry (registers the combined "tracedecay" tab), copies of - * the child bundles, and a concatenated stylesheet. Deploy by copying - * hermes-wrapper/{manifest.json,plugin_api.py,dist} into - * hermes-agent/plugins/hermes_intelligence/dashboard/. + * Builds the combined Hermes plugin from the child dashboard bundles. */ async function buildHermesWrapper() { const dist = path.join(root, "hermes-wrapper/dist"); await fs.mkdir(dist, { recursive: true }); - await fs.copyFile( - path.join(root, "hermes-wrapper/src/entry.js"), - path.join(dist, "index.js"), - ); - await fs.copyFile( - path.join(root, "holographic/dist/index.js"), - path.join(dist, "holographic.js"), - ); + await fs.copyFile(path.join(root, "hermes-wrapper/src/entry.js"), path.join(dist, "index.js")); + await fs.copyFile(path.join(root, "holographic/dist/index.js"), path.join(dist, "holographic.js")); await fs.copyFile(path.join(root, "lcm/dist/index.js"), path.join(dist, "lcm.js")); await fs.copyFile(path.join(root, "graph/dist/index.js"), path.join(dist, "graph.js")); await fs.copyFile(path.join(root, "savings/dist/index.js"), path.join(dist, "savings.js")); @@ -132,6 +219,7 @@ async function main() { buildShell(), buildPlugin("holographic", "holographic-memory", { shimDir: path.join(root, "holographic/src"), + tailwind: true, }), buildPlugin("graph", "code graph"), buildPlugin("savings", "savings & cost"), diff --git a/dashboard/holographic/src/sdk.ts b/dashboard/holographic/src/sdk.ts index 06c816f3..78db69e3 100644 --- a/dashboard/holographic/src/sdk.ts +++ b/dashboard/holographic/src/sdk.ts @@ -27,7 +27,18 @@ export const Badge: any = components.Badge; export const Button: any = components.Button; export const Input: any = components.Input; -export const cn: (...args: any[]) => string = - utils.cn || ((...a: any[]) => a.filter(Boolean).join(" ")); +// Keep this in-tree copy so holographic stays self-contained. +export function cn(...args: unknown[]): string { + const out: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const v of value) visit(v); + } else if (typeof value === "string" && value.length > 0) { + out.push(value); + } + }; + for (const a of args) visit(a); + return out.join(" "); +} export const timeAgo: ((ts: number) => string) | undefined = utils.timeAgo; export const useI18n: any = SDK.useI18n; diff --git a/dashboard/holographic/src/styles.css b/dashboard/holographic/src/styles.css index 783060cd..ef200765 100644 --- a/dashboard/holographic/src/styles.css +++ b/dashboard/holographic/src/styles.css @@ -1,11 +1,41 @@ /* * Holographic memory dashboard styles. * - * Phase 2 replaces the copied Tailwind artifact with a small, source-built - * utility subset plus dashboard-specific polish. The bundle still runs in both - * hosts: all colors resolve through host variables with standalone fallbacks. + * Utilities come from real Tailwind v4 (compiled by dashboard/build.mjs via + * @tailwindcss/node + @tailwindcss/oxide). The build strips @layer theme and + * @layer base so this sheet never clobbers the host's :root theme vars or + * preflight; color utilities resolve --color-* against the HOST (standalone + * shell or Hermes). Plugin-only tokens the host doesn't provide + * (text-primary/secondary/tertiary, midground, shadow-*) are defined in the + * surviving :root below. The hv-* section is dashboard-specific component + * polish layered over the utilities. */ +@import "tailwindcss"; + +@theme { + /* Color tokens. Values are dark defaults that get stripped with @layer theme + * at build time; the live values come from the host's --color-* (or the + * plugin-only :root below for tokens the host doesn't carry). Listing them + * is what makes Tailwind generate bg-/text-/border-/stroke-/fill- utilities, + * including opacity variants like bg-background/30. */ + --color-background: #07100f; + --color-foreground: #e7fff9; + --color-card: #0d1b1a; + --color-border: rgba(139, 255, 218, 0.16); + --color-primary: #75f4d2; + --color-secondary: #7aa7ff; + --color-muted: rgba(117, 244, 210, 0.1); + --color-muted-foreground: #6f9189; + --color-destructive: #ff6b7a; + --color-warning: #f7c76a; + --color-success: #67e8a9; + --color-text-primary: #e7fff9; + --color-text-secondary: #a8c8c0; + --color-text-tertiary: #6f9189; + --color-midground: #75f4d2; +} + :root { --hm-bg: var(--color-background, var(--background, #07100f)); --hm-panel: var(--color-card, rgba(13, 27, 26, 0.84)); @@ -27,10 +57,8 @@ --hm-popover: var(--color-card, #08201d); /* Categorical chart palette shared by every holographic visualization. - * Slots 0–4 ride the shell accent tokens; 5–7 are extra hues with explicit + * Slots 0-4 ride the shell accent tokens; 5-7 are extra hues with explicit * light-theme overrides below so charts stay legible in both themes. */ - /* Slot order keeps adjacent hues maximally distinct (cyan/green stay far - * apart — they were near-identical for deuteranopia at small dot sizes). */ --hm-cat-0: var(--ts-cyan, #75f4d2); --hm-cat-1: var(--ts-blue, #7aa7ff); --hm-cat-2: var(--ts-amber, #f7c76a); @@ -39,6 +67,16 @@ --hm-cat-5: #ff9d6e; --hm-cat-6: var(--ts-green, #67e8a9); --hm-cat-7: #8fd8ff; + + /* Plugin-only tokens the host does not provide. Utilities like + * text-text-tertiary / bg-midground / shadow-lg resolve these. They live + * outside @theme so they survive the build-time @layer-theme strip. */ + --color-text-primary: var(--text-primary, var(--color-foreground, #e7fff9)); + --color-text-secondary: var(--text-secondary, #a8c8c0); + --color-text-tertiary: var(--text-tertiary, #6f9189); + --color-midground: var(--color-primary, #75f4d2); + --shadow-lg: var(--hm-shadow); + --shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.18); } :root[data-theme="light"] { @@ -46,12 +84,14 @@ --hm-cat-5: #bc5413; --hm-cat-7: #0d6f9e; --hm-shadow: 0 18px 70px rgba(0, 0, 0, 0.12); + --shadow-lg: var(--hm-shadow); /* The shell's light tertiary (#538880) measures 3.63:1 on the page bg — * below AA for the small labels this plugin leans on. Darken toward the * foreground token (stays theme-driven) to clear 4.5:1. */ --hm-text-3: color-mix(in srgb, var(--text-tertiary, #538880) 62%, var(--color-foreground, #0c2420)); } +/* Custom font classes (unlayered; override Tailwind's defaults). */ .font-mono-ui, .font-mono, .font-courier { @@ -68,228 +108,10 @@ text-transform: uppercase; } -/* Layout utilities used by the ported TSX. */ -.relative { position: relative; } -.absolute { position: absolute; } -.fixed { position: fixed; } -.sticky { position: sticky; } -.inset-0 { inset: 0; } -.top-4 { top: 1rem; } -.left-2\.5 { left: 0.625rem; } -.right-1\.5 { right: 0.375rem; } -.top-1\/2 { top: 50%; } -.z-50 { z-index: 50; } -.block { display: block; } -.inline-block { display: inline-block; } -.flex { display: flex; } -.inline-flex { display: inline-flex; } -.grid { display: grid; } -.hidden { display: none; } -.contents { display: contents; } -.flex-col { flex-direction: column; } -.flex-wrap { flex-wrap: wrap; } -.items-start { align-items: flex-start; } -.items-center { align-items: center; } -.items-end { align-items: flex-end; } -.justify-center { justify-content: center; } -.justify-between { justify-content: space-between; } -.justify-end { justify-content: flex-end; } -.self-start { align-self: flex-start; } -.self-stretch { align-self: stretch; } -.shrink-0 { flex-shrink: 0; } -.flex-1 { flex: 1 1 0%; } -.min-w-0 { min-width: 0; } -.min-h-0 { min-height: 0; } -.w-full { width: 100%; } -.max-w-full { max-width: 100%; } -.max-w-md { max-width: 28rem; } -.max-w-xl { max-width: 36rem; } -.h-full { height: 100%; } -.h-1 { height: 0.25rem; } -.h-1\.5 { height: 0.375rem; } -.h-2 { height: 0.5rem; } -.h-2\.5 { height: 0.625rem; } -.h-3\.5 { height: 0.875rem; } -.h-4 { height: 1rem; } -.h-8 { height: 2rem; } -.h-16 { height: 4rem; } -.h-24 { height: 6rem; } -.w-1 { width: 0.25rem; } -.w-2 { width: 0.5rem; } -.w-2\.5 { width: 0.625rem; } -.w-3\.5 { width: 0.875rem; } -.w-4 { width: 1rem; } -.w-16 { width: 4rem; } -.w-48 { width: 12rem; } -.max-h-56 { max-height: 14rem; } -.max-h-64 { max-height: 16rem; } -.max-h-72 { max-height: 18rem; } -.overflow-hidden { overflow: hidden; } -.overflow-y-auto { overflow-y: auto; } -.overflow-x-auto { overflow-x: auto; } -.overflow-x-hidden { overflow-x: hidden; } -.touch-none { touch-action: none; } -.select-none { user-select: none; } -.appearance-none { appearance: none; } -.cursor-pointer { cursor: pointer; } -.outline-none { outline: none; } -.pointer-events-none { pointer-events: none; } - -[class~="h-[42rem]"] { height: 42rem; } -[class~="h-[60vh]"] { height: 60vh; } -[class~="max-h-[36rem]"] { max-height: 36rem; } -[class~="max-h-[38rem]"] { max-height: 38rem; } -[class~="max-h-[46rem]"] { max-height: 46rem; } -[class~="max-h-[50vh]"] { max-height: 50vh; } -[class~="max-h-[60vh]"] { max-height: 60vh; } -[class~="max-h-[80vh]"] { max-height: 80vh; } -[class~="min-h-[12rem]"] { min-height: 12rem; } - -.gap-0\.5 { gap: 0.125rem; } -.gap-1 { gap: 0.25rem; } -.gap-1\.5 { gap: 0.375rem; } -.gap-2 { gap: 0.5rem; } -.gap-3 { gap: 0.75rem; } -.gap-4 { gap: 1rem; } -.gap-x-3 { column-gap: 0.75rem; } -.gap-y-1 { row-gap: 0.25rem; } -.gap-y-1\.5 { row-gap: 0.375rem; } -.m-0 { margin: 0; } -.mx-4 { margin-left: 1rem; margin-right: 1rem; } -.mt-0\.5 { margin-top: 0.125rem; } -.mt-1 { margin-top: 0.25rem; } -.mt-2 { margin-top: 0.5rem; } -.mt-3 { margin-top: 0.75rem; } -.mb-1 { margin-bottom: 0.25rem; } -.mb-2 { margin-bottom: 0.5rem; } -.mb-3 { margin-bottom: 0.75rem; } -.mb-4 { margin-bottom: 1rem; } -.ml-auto { margin-left: auto; } -.p-1 { padding: 0.25rem; } -.p-1\.5 { padding: 0.375rem; } -.p-2 { padding: 0.5rem; } -.p-3 { padding: 0.75rem; } -.p-4 { padding: 1rem; } -.px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; } -.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; } -.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } -.py-0 { padding-top: 0; padding-bottom: 0; } -.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; } -.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } -.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; } -.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } -.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; } -.py-4 { padding-top: 1rem; padding-bottom: 1rem; } -.py-16 { padding-top: 4rem; padding-bottom: 4rem; } -.pr-1 { padding-right: 0.25rem; } -.pr-7 { padding-right: 1.75rem; } -.pl-2\.5 { padding-left: 0.625rem; } -.pl-8 { padding-left: 2rem; } -.pt-1\.5 { padding-top: 0.375rem; } -.pt-2 { padding-top: 0.5rem; } - -.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } -[class*="grid-cols-[4.5rem_5.5rem"] { grid-template-columns: 4.5rem 5.5rem minmax(0, 1fr); } - -/* Typography utilities. */ -.text-left { text-align: left; } -.text-right { text-align: right; } -.text-xs { font-size: 11px; line-height: 1.4; } -.text-sm { font-size: 0.875rem; line-height: 1.25rem; } -.text-lg { font-size: 1.125rem; line-height: 1.75rem; } -.text-2xl { font-size: 1.5rem; line-height: 2rem; } -/* Legibility floor (a11y audit): nothing renders below 11px even where the - * markup still says 10px/0.65rem. */ -[class~="text-[10px]"] { font-size: 11px; line-height: 1.35; } -[class~="text-[11px]"] { font-size: 11px; line-height: 1.35; } -[class~="text-[0.65rem]"] { font-size: 11px; line-height: 1.35; } -[class~="text-[0.875rem]"] { font-size: 0.875rem; } -.font-medium { font-weight: 600; } -.font-bold { font-weight: 800; } -.uppercase { text-transform: uppercase; } -.italic { font-style: italic; } -.leading-4 { line-height: 1rem; } -.leading-none { line-height: 1; } -.leading-relaxed { line-height: 1.62; } -.tracking-normal { letter-spacing: 0; } -[class*="tracking-[0.04em]"] { letter-spacing: 0.04em; } -[class*="tracking-[0.08em]"] { letter-spacing: 0.08em; } -[class*="tracking-[0.12em]"] { letter-spacing: 0.12em; } -.tabular-nums { font-variant-numeric: tabular-nums; } -.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.break-all { word-break: break-all; } -.break-words { overflow-wrap: anywhere; } -.whitespace-nowrap { white-space: nowrap; } -.whitespace-pre-wrap { white-space: pre-wrap; } -.line-through { text-decoration: line-through; } -.line-clamp-3 { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; -} - -.text-foreground, -.text-text-primary { color: var(--hm-text); } -.text-text-secondary, -.text-muted-foreground { color: var(--hm-text-2); } -.text-text-tertiary { color: var(--hm-text-3); } -.text-primary { color: var(--hm-primary); } -.text-success { color: var(--hm-success); } -.text-warning { color: var(--hm-warning); } -.text-destructive { color: var(--hm-danger); } -.fill-text-secondary { fill: var(--hm-text-2); } -.stroke-primary { stroke: var(--hm-primary); } -.stroke-border { stroke: var(--hm-line-strong); } -.stroke-muted { stroke: color-mix(in srgb, var(--hm-primary) 12%, transparent); } -.stroke-success { stroke: var(--hm-success); } -.stroke-warning { stroke: var(--hm-warning); } -.stroke-destructive { stroke: var(--hm-danger); } -.stroke-text-tertiary { stroke: var(--hm-text-3); } -[class~="fill-primary/15"] { fill: color-mix(in srgb, var(--hm-primary) 15%, transparent); } - -/* Surface utilities and refined component atoms. */ -.border { border-width: 1px; border-style: solid; } -.border-t { border-top-width: 1px; border-top-style: solid; } -.border-b { border-bottom-width: 1px; border-bottom-style: solid; } -.border-l-2 { border-left-width: 2px; border-left-style: solid; } -.border-border { border-color: var(--hm-line); } -[class~="border-border/50"] { border-color: color-mix(in srgb, var(--hm-line) 75%, transparent); } -[class~="border-border/60"] { border-color: var(--hm-line); } -[class~="border-border/70"] { border-color: color-mix(in srgb, var(--hm-line) 85%, var(--hm-text-3)); } -[class~="border-primary/30"] { border-color: color-mix(in srgb, var(--hm-primary) 30%, transparent); } -[class~="border-primary/60"], -.hover\:border-primary\/60:hover { border-color: color-mix(in srgb, var(--hm-primary) 60%, transparent); } -[class~="border-warning/30"] { border-color: color-mix(in srgb, var(--hm-warning) 32%, transparent); } -[class~="border-success/30"] { border-color: color-mix(in srgb, var(--hm-success) 32%, transparent); } -[class~="border-destructive/30"] { border-color: color-mix(in srgb, var(--hm-danger) 34%, transparent); } -.rounded { border-radius: 0.45rem; } -.rounded-sm { border-radius: 0.3rem; } -.rounded-full { border-radius: 999px; } -.bg-card { background: var(--hm-panel); } -.bg-background { background: var(--hm-bg); } -[class~="bg-background/30"] { background: color-mix(in srgb, var(--hm-bg) 30%, transparent); } -[class~="bg-background/40"] { background: color-mix(in srgb, var(--hm-bg) 40%, transparent); } -[class~="bg-background/50"] { background: color-mix(in srgb, var(--hm-bg) 50%, transparent); } -[class~="bg-background/60"] { background: color-mix(in srgb, var(--hm-bg) 60%, transparent); } -[class~="bg-muted/30"] { background: color-mix(in srgb, var(--hm-primary) 8%, transparent); } -[class~="bg-muted/40"] { background: var(--hm-muted); } -.bg-muted { background: var(--hm-muted); } -.bg-midground, -.bg-primary { background: var(--hm-primary); } -[class~="bg-primary/10"] { background: color-mix(in srgb, var(--hm-primary) 10%, transparent); } -[class~="bg-secondary/30"] { background: color-mix(in srgb, var(--hm-blue) 9%, transparent); } -[class~="bg-secondary/50"] { background: color-mix(in srgb, var(--hm-blue) 13%, transparent); } -[class~="bg-secondary/60"] { background: color-mix(in srgb, var(--hm-blue) 16%, transparent); } -[class~="bg-warning/10"] { background: color-mix(in srgb, var(--hm-warning) 10%, transparent); } -[class~="bg-warning/30"] { background: color-mix(in srgb, var(--hm-warning) 30%, transparent); } -[class~="bg-success/10"] { background: color-mix(in srgb, var(--hm-success) 10%, transparent); } -[class~="bg-destructive/10"] { background: color-mix(in srgb, var(--hm-danger) 10%, transparent); } +/* Shadow / backdrop overrides (Tailwind's defaults differ from these). */ .shadow-lg { box-shadow: var(--hm-shadow); } .shadow-sm { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); } .backdrop-blur-sm { backdrop-filter: blur(8px); } -[class~="bg-black/60"] { background: rgba(0, 0, 0, 0.6); } mark { border-radius: 0.25rem; @@ -345,23 +167,7 @@ input[type="range"] { display: none; } -.group:hover .group-hover\:text-foreground, -.hover\:text-foreground:hover { - color: var(--hm-text); -} - -.hover\:text-text-secondary:hover { - color: var(--hm-text-2); -} - -.last\:border-b-0:last-child { - border-bottom-width: 0; -} - -.-rotate-90 { transform: rotate(-90deg); } -.-translate-y-1\/2 { transform: translateY(-50%); } - -/* Dashboard-specific polish layered over the utility subset. */ +/* Dashboard-specific polish layered over the utilities. */ .ts-card-content svg { max-width: 100%; } @@ -404,51 +210,9 @@ input[type="range"] { filter: drop-shadow(0 12px 30px rgba(0, 0, 0, 0.35)); } -@media (min-width: 640px) { - .sm\:max-w-xl { max-width: 36rem; } - .sm\:col-span-2 { grid-column: span 2 / span 2; } - .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .sm\:flex-row { flex-direction: row; } - .sm\:items-center { align-items: center; } -} - -@media (min-width: 768px) { - [class~="md:h-[42rem]"] { height: 42rem; } - [class~="md:max-h-[32rem]"] { max-height: 32rem; } - [class~="md:max-h-[38rem]"] { max-height: 38rem; } - [class~="md:max-h-[46rem]"] { max-height: 46rem; } -} - -@media (min-width: 1024px) { - [class*="lg:grid-cols-[minmax(0,1fr)_22rem]"] { - grid-template-columns: minmax(0, 1fr) 22rem; - } - .lg\:sticky { position: sticky; } - .lg\:top-4 { top: 1rem; } - [class~="lg:max-h-[calc(100vh-2rem)]"] { max-height: calc(100vh - 2rem); } - .lg\:self-start { align-self: flex-start; } - .lg\:grid-cols-\[repeat\(2\,minmax\(0\,1fr\)\)\] { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (min-width: 1280px) { - [class*="xl:grid-cols-[repeat(2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } - [class*="xl:grid-cols-[repeat(3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); } - [class*="xl:grid-cols-[repeat(7"] { grid-template-columns: repeat(7, minmax(0, 1fr)); } - [class*="xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]"] { - grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); - } - [class*="xl:grid-cols-[minmax(0,1fr)_20rem]"] { - grid-template-columns: minmax(0, 1fr) 20rem; - } - .xl\:col-span-1 { grid-column: span 1 / span 1; } - .xl\:sticky { position: sticky; } - .xl\:top-4 { top: 1rem; } - .xl\:self-start { align-self: flex-start; } -} - +/* Narrow-viewport grid stacking. Tailwind's responsive variants handle the + * standard breakpoints; these two are custom overrides the utility classes + * don't encode, so they stay. */ @media (max-width: 720px) { .grid-cols-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/dashboard/lib/cn.ts b/dashboard/lib/cn.ts new file mode 100644 index 00000000..96720e15 --- /dev/null +++ b/dashboard/lib/cn.ts @@ -0,0 +1,12 @@ +export function cn(...args: unknown[]): string { + const out: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const v of value) visit(v); + } else if (typeof value === "string" && value.length > 0) { + out.push(value); + } + }; + for (const a of args) visit(a); + return out.join(" "); +} diff --git a/dashboard/lib/sdk.ts b/dashboard/lib/sdk.ts index 6625b612..e807c944 100644 --- a/dashboard/lib/sdk.ts +++ b/dashboard/lib/sdk.ts @@ -5,8 +5,8 @@ * * Each plugin bundle externalizes React and the design-system components onto * `window.__HERMES_PLUGIN_SDK__` (provided by the Hermes dashboard or the - * standalone shell — see shell/src/sdk.jsx). esbuild inlines this module into - * every bundle, so the bundles stay independent at runtime. + * standalone shell — see shell/src/sdk.jsx). The dashboard build inlines this + * module into every bundle, so the bundles stay independent at runtime. */ const SDK: any = @@ -26,8 +26,7 @@ export const Badge: any = components.Badge; export const Button: any = components.Button; export const Input: any = components.Input; -export const cn: (...args: any[]) => string = - utils.cn || ((...a: any[]) => a.filter(Boolean).join(" ")); +export { cn } from "./cn"; export const timeAgo: (ts: number) => string = utils.timeAgo || ((ts: number) => new Date(ts * 1000).toLocaleString()); diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a7e14d55..7929fbd5 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -15,10 +15,14 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@rspack/core": "^2.0.8", + "@tailwindcss/node": "^4.3.1", + "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", "esbuild": "^0.25.0", "jsdom": "^29.1.1", "playwright": "^1.60.0", + "tailwindcss": "^4.3.1", "vitest": "^4.1.8" } }, @@ -757,6 +761,38 @@ } } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -764,6 +800,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1071,6 +1118,211 @@ "dev": true, "license": "MIT" }, + "node_modules/@rspack/binding": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", + "integrity": "sha512-3uZ+y8aQxq33ty2srMxg2Nu0XuBI6vVrG50rkDaXqwWqOohfgGUSfFuQK7EnSUNy4aFUQlCG6NHialQHJov0wg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "2.0.8", + "@rspack/binding-darwin-x64": "2.0.8", + "@rspack/binding-linux-arm64-gnu": "2.0.8", + "@rspack/binding-linux-arm64-musl": "2.0.8", + "@rspack/binding-linux-x64-gnu": "2.0.8", + "@rspack/binding-linux-x64-musl": "2.0.8", + "@rspack/binding-wasm32-wasi": "2.0.8", + "@rspack/binding-win32-arm64-msvc": "2.0.8", + "@rspack/binding-win32-ia32-msvc": "2.0.8", + "@rspack/binding-win32-x64-msvc": "2.0.8" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.8.tgz", + "integrity": "sha512-vCgbgH7B7qom+uID+RCZsTCOYFb9wC4/4+1U6rMfytrXGVJ72eNQs2tbdjOl0lb18CT3N/n+VkWynUiLk84GwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.8.tgz", + "integrity": "sha512-satPm2PD4B7jDTVlVAdvMVdUszwLvWUEnUDzLb77mvVkezKNDZmuhb+e8s+FfKs8hJpNbZ9VAejuA2rr8o985w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.8.tgz", + "integrity": "sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.8.tgz", + "integrity": "sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.8.tgz", + "integrity": "sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.8.tgz", + "integrity": "sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.8.tgz", + "integrity": "sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "1.1.4" + } + }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.8.tgz", + "integrity": "sha512-8NCuiQsAhXrwRBy57QZoypqrws/zLBkaQVGiB8hksr6v++8hNigNjqpQARLbd0iyMuHsQQ++8+auGk6xlDXmzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.8.tgz", + "integrity": "sha512-bxiekytbX7V9KFAra+HkwtNWC6pYfHEBBZFpiT0xUs3mCFOmAAFVBsBSQsoCP9AdCEXoMAvNdnrHNw3iov4OZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.8.tgz", + "integrity": "sha512-7zPs8YCe/ZVJTwd+5lpB0CP0tkn2pONf/T1ycmVY76u21Nrwt8mXQGc/2yH2eWP4B7fikYBr3hGr7mpR2fajqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.8.tgz", + "integrity": "sha512-+NLGJf8gZxihDmMFzjlly3toc2SMjeDmuvz0/Cai9AMdV4F+Pqcnt2BA9V4e3SY2jmhJQtPwgyyLtR1RiJO77g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/binding": "2.0.8" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", + "@swc/helpers": "^0.5.23" + }, + "peerDependenciesMeta": { + "@module-federation/runtime-tools": { + "optional": true + }, + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1078,6 +1330,263 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1819,6 +2328,20 @@ "license": "MIT", "peer": true }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -1934,6 +2457,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1990,6 +2520,16 @@ "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2655,6 +3195,27 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 4329f525..feafb195 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,6 +6,7 @@ "description": "TraceDecay dashboard UI: standalone shell + ported Hermes plugin dashboards (holographic memory, LCM).", "scripts": { "build": "node build.mjs", + "build:esbuild": "node build-esbuild.mjs", "test": "npm run test:node && npm run test:dom", "test:node": "node run-unit-tests.mjs", "test:dom": "vitest run", @@ -20,10 +21,14 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@rspack/core": "^2.0.8", + "@tailwindcss/node": "^4.3.1", + "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", "esbuild": "^0.25.0", "jsdom": "^29.1.1", "playwright": "^1.60.0", + "tailwindcss": "^4.3.1", "vitest": "^4.1.8" } } diff --git a/dashboard/shell/src/sdk.jsx b/dashboard/shell/src/sdk.jsx index fcf9b375..dcd93585 100644 --- a/dashboard/shell/src/sdk.jsx +++ b/dashboard/shell/src/sdk.jsx @@ -24,8 +24,10 @@ import React, { createContext, } from "react"; import { makeSequence } from "../../lib/sequence"; +import { cn as cnImpl } from "../../lib/cn"; export { makeSequence }; +export const cn = cnImpl; export async function fetchJSON(url, init) { const res = await fetch(url, init); @@ -42,13 +44,6 @@ export async function fetchJSON(url, init) { return res.json(); } -export function cn(...args) { - return args - .flat(Infinity) - .filter((a) => typeof a === "string" && a.length > 0) - .join(" "); -} - function relativeTime(deltaSeconds) { if (Number.isNaN(deltaSeconds)) return "unknown"; if (deltaSeconds < 60) return "just now"; From 9680b6399ddcd286b90640aa313e807dd2ebfcf9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 00:59:13 +0200 Subject: [PATCH 02/35] feat(dashboard): migrate LCM to TSX, drop esbuild from build, add Rsbuild dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LCM plugin — port the 2020-line hand-written vanilla IIFE (lcm/src/index.js, React.createElement via h()) to modern TSX built as a standard plugin bundle, mirroring graph/savings. Split into navigable modules: entry.tsx (registers "hermes-lcm"), App.tsx, components.tsx, markdown.tsx, helpers.ts. All behavior + hermes-lcm-* class names preserved (style.css unchanged, renamed to styles.css for build uniformity). Built as a TSX plugin (React externalized via lib/ shims) instead of copied verbatim; bundle shrinks 86 KB → 48 KB. Build pipeline — esbuild is gone from the build. build.mjs: - LCM now built with buildPlugin (was copyLcm). - Tailwind CSS minify switched from esbuild.transform to a small CSS compactor (preserves @supports color-mix blocks lightningcss would strip). - esbuild fallback builder (build-esbuild.mjs) and the build:esbuild script removed. esbuild remains a devDependency ONLY for the unit-test bundler helper (test/helpers/module-loader.mjs); not in the shipped build path. Dev server — new dashboard/dev/ Rsbuild dev server (`npm run dev`) with HMR, proxying /api/* to a running `tracedecay dashboard` (TRACEDECAY_DEV_API, default 127.0.0.1:7341; port TRACEDECAY_DEV_PORT, default 7342). The dev entry builds the SDK on window before importing plugin entries, so SDK consumers behave like prod. @rsbuild/plugin-tailwindcss compiles holographic's Tailwind v4 in dev (closes the prior dev/prod styling divergence). Verified: 100/100 node + 12/12 vitest; build emits all 14 artifacts; cargo embed + dashboard serve (all routes 200); real-browser Playwright smoke (desktop) exercises Holographic/Similarity/Curation/Code Graph/LCM incl. the new TSX LCM bundle. --- dashboard/build-esbuild.mjs | 165 -- dashboard/build.mjs | 31 +- dashboard/dev/index.html | 12 + dashboard/dev/main.tsx | 209 ++ dashboard/dev/run.mjs | 92 + dashboard/lcm/src/App.tsx | 1007 +++++++++ dashboard/lcm/src/components.tsx | 991 +++++++++ dashboard/lcm/src/entry.tsx | 20 + dashboard/lcm/src/helpers.ts | 231 +++ dashboard/lcm/src/index.js | 2020 ------------------- dashboard/lcm/src/markdown.tsx | 145 ++ dashboard/lcm/src/{style.css => styles.css} | 0 dashboard/package-lock.json | 142 +- dashboard/package.json | 5 +- dashboard/test/lcm-logic.test.mjs | 92 +- 15 files changed, 2898 insertions(+), 2264 deletions(-) delete mode 100644 dashboard/build-esbuild.mjs create mode 100644 dashboard/dev/index.html create mode 100644 dashboard/dev/main.tsx create mode 100644 dashboard/dev/run.mjs create mode 100644 dashboard/lcm/src/App.tsx create mode 100644 dashboard/lcm/src/components.tsx create mode 100644 dashboard/lcm/src/entry.tsx create mode 100644 dashboard/lcm/src/helpers.ts delete mode 100644 dashboard/lcm/src/index.js create mode 100644 dashboard/lcm/src/markdown.tsx rename dashboard/lcm/src/{style.css => styles.css} (100%) diff --git a/dashboard/build-esbuild.mjs b/dashboard/build-esbuild.mjs deleted file mode 100644 index 544f5cf0..00000000 --- a/dashboard/build-esbuild.mjs +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Build every dashboard artifact served by `tracedecay dashboard`. - * - * npm install && npm run build (from dashboard/) - * - * Outputs: - * shell/dist/shell.js + shell.css Standalone host shell (bundles React 19, - * exposes a Hermes-compatible plugin SDK on - * window, loads the plugin bundles below). - * holographic/dist/index.js Holographic-memory plugin bundle, rebuilt - * from holographic/src (esbuild IIFE; React - * externalized onto the host SDK via shims, - * exactly like the original Hermes build). - * holographic/dist/style.css Copied from holographic/src/styles.css - * (hand-rolled token stylesheet). - * lcm/dist/index.js + style.css Copied from lcm/src (hand-written, - * unbundled JS — no build step needed). - * graph/dist/index.js + style.css Code graph explorer plugin bundle - * (esbuild IIFE; React externalized). - * - * The Rust binary embeds these dist files at compile time (src/dashboard/assets.rs), - * so run this before `cargo build` when the UI changed. - */ - -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import fs from "node:fs/promises"; -import esbuild from "esbuild"; - -const root = path.dirname(fileURLToPath(import.meta.url)); - -async function buildShell() { - await esbuild.build({ - entryPoints: [path.join(root, "shell/src/main.jsx")], - outfile: path.join(root, "shell/dist/shell.js"), - bundle: true, - format: "iife", - platform: "browser", - target: ["es2020"], - jsx: "automatic", - minify: true, - legalComments: "none", - define: { "process.env.NODE_ENV": '"production"' }, - logLevel: "warning", - }); - await fs.copyFile( - path.join(root, "shell/src/styles.css"), - path.join(root, "shell/dist/shell.css"), - ); -} - -/** - * Builds one plugin bundle (`/src/entry.tsx` → `/dist/index.js`), - * externalizing React onto the host SDK (Hermes or the standalone shell) via - * the shims; everything else (@observablehq/plot, d3-force, lucide-react) is - * bundled. - * - * Shims default to the shared `lib/` copies. holographic/ overrides with its - * own in-tree shims: that source mirrors the upstream Hermes plugin - * byte-for-byte (see build.from-hermes.mjs) and must stay self-contained. - */ -async function buildPlugin(dir, bannerLabel, { shimDir = path.join(root, "lib") } = {}) { - const srcDir = path.join(root, dir, "src"); - await esbuild.build({ - entryPoints: [path.join(srcDir, "entry.tsx")], - outfile: path.join(root, dir, "dist/index.js"), - bundle: true, - format: "iife", - platform: "browser", - target: ["es2020"], - jsx: "automatic", - minify: true, - legalComments: "none", - define: { "process.env.NODE_ENV": '"production"' }, - alias: { - react: path.join(shimDir, "react-shim.ts"), - "react/jsx-runtime": path.join(shimDir, "jsx-runtime.ts"), - "react/jsx-dev-runtime": path.join(shimDir, "jsx-runtime.ts"), - }, - banner: { - js: `/* tracedecay ${bannerLabel} dashboard plugin — bundled with esbuild. Do not edit; see src/. */`, - }, - logLevel: "warning", - }); - await fs.copyFile( - path.join(srcDir, "styles.css"), - path.join(root, dir, "dist/style.css"), - ); -} - -async function copyLcm() { - const dist = path.join(root, "lcm/dist"); - await fs.mkdir(dist, { recursive: true }); - await fs.copyFile(path.join(root, "lcm/src/index.js"), path.join(dist, "index.js")); - await fs.copyFile(path.join(root, "lcm/src/style.css"), path.join(dist, "style.css")); -} - -/** - * The Hermes wrapper plugin reuses the exact bundles above: its dist gets the - * wrapper entry (registers the combined "tracedecay" tab), copies of - * the child bundles, and a concatenated stylesheet. Deploy by copying - * hermes-wrapper/{manifest.json,plugin_api.py,dist} into - * hermes-agent/plugins/hermes_intelligence/dashboard/. - */ -async function buildHermesWrapper() { - const dist = path.join(root, "hermes-wrapper/dist"); - await fs.mkdir(dist, { recursive: true }); - await fs.copyFile( - path.join(root, "hermes-wrapper/src/entry.js"), - path.join(dist, "index.js"), - ); - await fs.copyFile( - path.join(root, "holographic/dist/index.js"), - path.join(dist, "holographic.js"), - ); - await fs.copyFile(path.join(root, "lcm/dist/index.js"), path.join(dist, "lcm.js")); - await fs.copyFile(path.join(root, "graph/dist/index.js"), path.join(dist, "graph.js")); - await fs.copyFile(path.join(root, "savings/dist/index.js"), path.join(dist, "savings.js")); - const css = await Promise.all([ - fs.readFile(path.join(root, "hermes-wrapper/src/wrapper.css"), "utf8"), - fs.readFile(path.join(root, "holographic/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "lcm/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "graph/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "savings/dist/style.css"), "utf8"), - ]); - await fs.writeFile(path.join(dist, "style.css"), css.join("\n"), "utf8"); -} - -async function main() { - await fs.mkdir(path.join(root, "shell/dist"), { recursive: true }); - await Promise.all([ - buildShell(), - buildPlugin("holographic", "holographic-memory", { - shimDir: path.join(root, "holographic/src"), - }), - buildPlugin("graph", "code graph"), - buildPlugin("savings", "savings & cost"), - copyLcm(), - ]); - await buildHermesWrapper(); - for (const f of [ - "shell/dist/shell.js", - "shell/dist/shell.css", - "holographic/dist/index.js", - "holographic/dist/style.css", - "lcm/dist/index.js", - "lcm/dist/style.css", - "graph/dist/index.js", - "graph/dist/style.css", - "savings/dist/index.js", - "savings/dist/style.css", - "hermes-wrapper/dist/index.js", - "hermes-wrapper/dist/graph.js", - "hermes-wrapper/dist/savings.js", - "hermes-wrapper/dist/style.css", - ]) { - const st = await fs.stat(path.join(root, f)); - console.log(`✓ ${f} ${(st.size / 1024).toFixed(1)} KB`); - } -} - -main().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/dashboard/build.mjs b/dashboard/build.mjs index 658c1726..f3d443bf 100644 --- a/dashboard/build.mjs +++ b/dashboard/build.mjs @@ -19,7 +19,6 @@ import { rspack } from "@rspack/core"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import esbuild from "esbuild"; import path from "node:path"; import fs from "node:fs/promises"; @@ -145,8 +144,8 @@ async function buildPlugin( * - strip @layer theme + @layer base so the plugin never clobbers the host's * :root vars or preflight (utilities resolve --color-* against the host); * - confine the sheet to the host's `hermes-plugin` cascade layer; - * - minify with esbuild (preserves @supports color-mix blocks that - * lightningcss would strip). + * - minify with a small CSS compactor (preserves @supports color-mix blocks + * that lightningcss would strip; no esbuild dependency). */ async function compileTailwindCss(srcDir, outFile) { const { compile } = require("@tailwindcss/node"); @@ -159,10 +158,25 @@ async function compileTailwindCss(srcDir, outFile) { css = stripTopLevelAtLayer(css, "theme"); css = stripTopLevelAtLayer(css, "base"); css = `@layer hermes-plugin{\n${css}\n}`; - css = (await esbuild.transform(css, { loader: "css", minify: true })).code; + css = minifyCss(css); await fs.writeFile(outFile, css, "utf8"); } +/** + * Small CSS minifier: strip comments, collapse whitespace, drop space around + * CSS punctuation. Safe for selector/declaration CSS (no @import/url string + * surgery). Preserves @supports and content:"..." strings (spaces inside + * strings aren't adjacent to the punctuation we trim). + */ +function minifyCss(css) { + return css + .replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, "") + .replace(/\s+/g, " ") + .replace(/\s*([{}:;,>])\s*/g, "$1") + .replace(/;}/g, "}") + .trim(); +} + /** Remove a top-level `@layer { ... }` block via brace matching. * Matches `@layer name{` or `@layer name {` (any whitespace). */ function stripTopLevelAtLayer(css, name) { @@ -185,13 +199,6 @@ function stripTopLevelAtLayer(css, name) { return out; } -async function copyLcm() { - const dist = path.join(root, "lcm/dist"); - await fs.mkdir(dist, { recursive: true }); - await fs.copyFile(path.join(root, "lcm/src/index.js"), path.join(dist, "index.js")); - await fs.copyFile(path.join(root, "lcm/src/style.css"), path.join(dist, "style.css")); -} - /** * Builds the combined Hermes plugin from the child dashboard bundles. */ @@ -223,7 +230,7 @@ async function main() { }), buildPlugin("graph", "code graph"), buildPlugin("savings", "savings & cost"), - copyLcm(), + buildPlugin("lcm", "LCM"), ]); await buildHermesWrapper(); for (const f of [ diff --git a/dashboard/dev/index.html b/dashboard/dev/index.html new file mode 100644 index 00000000..839aa918 --- /dev/null +++ b/dashboard/dev/index.html @@ -0,0 +1,12 @@ + + + + + + tracedecay dashboard (dev) + + + +
+ + diff --git a/dashboard/dev/main.tsx b/dashboard/dev/main.tsx new file mode 100644 index 00000000..78cd2303 --- /dev/null +++ b/dashboard/dev/main.tsx @@ -0,0 +1,209 @@ +/** + * Dev entry for the tracedecay dashboard frontend. + * + * This mirrors what the prod shell (dashboard/shell/src/main.jsx) does, but for + * the dev server: it builds the plugin SDK on window, installs the plugin + * registry, then dynamically imports each plugin entry so it can register. + * A minimal tab shell renders whatever registered. + * + * MODULE-LOAD ORDER (the guarantee that makes this work): + * + * 1. Static imports (CSS + buildSDK) evaluate first (ESM hoisting). buildSDK + * is only a function definition here — no SDK read happens yet. + * 2. The module body runs SYNCHRONOUSLY before any dynamic import(): + * window.__HERMES_PLUGIN_SDK__ = buildSDK(); // React + hooks + + * // components + utils + + * // fetchJSON + capabilities + * window.__HERMES_PLUGINS__ = { register, registerSlot }; + * So by the time ANY plugin entry's module code runs, the SDK and the + * registry are fully populated on window. + * 3. loadPlugins() is fired (async). Its dynamic import() calls resolve on a + * later tick; each imported entry reads window.__HERMES_PLUGIN_SDK__ / + * window.__HERMES_PLUGINS__ (already set) and calls register(). + * 4. createRoot(...).render() runs synchronously after the kick-off. + * App subscribes to the registry (useRegistryVersion), so registrations + * arriving from step 3 re-render the tab bar live. + * + * REACT IN DEV (divergence from prod — see run.mjs / return note): + * The dev server does NOT alias `react` onto a window-SDK shim. In a single + * Rsbuild bundle every module already shares one real React instance, and + * react-dom/client (used below for createRoot) needs the real `react` module + * with its internal symbols. We still expose real React + hooks on + * window.__HERMES_PLUGIN_SDK__ so plugin code that reads the SDK (e.g. + * dashboard/lib/sdk.ts, lcm/src/index.js) behaves exactly like in prod. + */ + +import { useState, useEffect, useSyncExternalStore } from "react"; +import { createRoot } from "react-dom/client"; +import { buildSDK } from "../shell/src/sdk.jsx"; + +// Shell theme tokens + SDK component classes (.ts-*). Plugin CSS layers below +// consume the same CSS variables. +import "../shell/src/styles.css"; +import "../graph/src/styles.css"; +import "../savings/src/styles.css"; +import "../lcm/src/styles.css"; +// Holographic styles begin with `@import "tailwindcss"` (Tailwind v4). The +// `@rsbuild/plugin-tailwindcss` plugin (registered in dev/run.mjs) compiles it, +// so the full utility set + hv-* polish apply in dev too. +import "../holographic/src/styles.css"; + +// --------------------------------------------------------------------------- +// SDK + plugin registry — populated BEFORE plugin entries are imported. +// --------------------------------------------------------------------------- + +window.__HERMES_PLUGIN_SDK__ = buildSDK(); + +const registered = new Map(); +const listeners = new Set(); +let registryVersion = 0; + +function notify() { + registryVersion += 1; + for (const fn of listeners) { + try { + fn(); + } catch { + /* listener errors must not break registration */ + } + } +} + +window.__HERMES_PLUGINS__ = { + register(name, component) { + registered.set(name, component); + notify(); + }, + registerSlot() {}, +}; + +function useRegistryVersion() { + return useSyncExternalStore( + (fn) => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => registryVersion, + () => registryVersion, + ); +} + +// Apply the dark theme early so the first paint matches the shell. +try { + document.documentElement.setAttribute("data-theme", "dark"); +} catch { + /* non-browser */ +} + +// --------------------------------------------------------------------------- +// Plugin discovery + registration (dynamic, fault-tolerant). +// +// Each entry is imported AFTER the SDK/registry exist on window. Missing or +// erroring entries (e.g. lcm/src/entry.tsx while a concurrent TSX migration is +// in flight) are warned and skipped so the dev server stays usable. LCM falls +// back to its existing index.js IIFE if entry.tsx is absent. +// --------------------------------------------------------------------------- + +const PLUGIN_ENTRIES = [ + { name: "holographic", spec: "../holographic/src/entry.tsx" }, + { name: "graph", spec: "../graph/src/entry.tsx" }, + { name: "savings", spec: "../savings/src/entry.tsx" }, + { + name: "hermes-lcm", + spec: "../lcm/src/entry.tsx", + fallback: "../lcm/src/index.js", + }, +]; + +async function loadPlugins() { + await Promise.all( + PLUGIN_ENTRIES.map(async (p) => { + const candidates = [p.spec, p.fallback].filter(Boolean); + for (const spec of candidates) { + try { + await import(/* @vite-ignore */ spec); + return; + } catch (err) { + // Rsbuild leaves a stack in `err`; keep the console line scannable. + console.warn(`[tracedecay dev] failed to load "${spec}":`, err); + } + } + console.warn(`[tracedecay dev] plugin "${p.name}" has no loadable entry — skipping.`); + }), + ); +} + +// Fire-and-forget: registrations update the UI live via useRegistryVersion. +loadPlugins(); + +// --------------------------------------------------------------------------- +// Minimal dev shell: a tab bar over the registered plugin components. +// Intentionally smaller than prod's App (no URL sync, polling, or asset +// injection) — plugins are imported by the dev bundle, not fetched. +// --------------------------------------------------------------------------- + +const PLUGIN_LABELS = { + holographic: "Holographic Memory", + graph: "Code Graph", + savings: "Savings & Cost", + "hermes-lcm": "LCM", +}; + +function App() { + useRegistryVersion(); + const names = Array.from(registered.keys()); + const [active, setActive] = useState(""); + + useEffect(() => { + if ((!active || !registered.has(active)) && names.length > 0) { + setActive(names[0]); + } + }, [names, active]); + + const tabs = names.map((n) => ({ name: n, label: PLUGIN_LABELS[n] || n })); + const Active = active ? registered.get(active) : null; + + return ( +
+
+
+ +

tracedecay · dev

+
+
+ {tabs.map((t) => ( + + ))} + {tabs.length === 0 && ( + + No plugins registered… + + )} +
+
+
+ {Active ? ( + + ) : ( +
+ Waiting for plugins to register… +
+ )} +
+
+ ); +} + +createRoot(document.getElementById("root")).render(); diff --git a/dashboard/dev/run.mjs b/dashboard/dev/run.mjs new file mode 100644 index 00000000..264fe897 --- /dev/null +++ b/dashboard/dev/run.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * tracedecay dashboard — frontend dev server (Rsbuild + HMR). + * + * Run from the repo root or the dashboard/ dir: + * + * node dashboard/dev/run.mjs + * TRACEDECAY_DEV_PORT=8080 node dashboard/dev/run.mjs + * TRACEDECAY_DEV_API=http://127.0.0.1:7341 node dashboard/dev/run.mjs + * + * Env: + * TRACEDECAY_DEV_API backend `tracedecay dashboard` base URL to proxy + * /api/* to. Default: http://127.0.0.1:7341 + * TRACEDECAY_DEV_PORT port for this dev server. Default: 7342 + * + * On success prints a stable, parseable line on stdout (mirrors the prod + * server's announcement so wrappers can scrape it): + * + * tracedecay dev listening on http://127.0.0.1:7342/ + * + * REACT EXTERNALIZATION (dev/prod divergence): + * In prod, each plugin bundle aliases `react` → a window-SDK shim so separate + * bundles share one React. The dev server does NOT set that alias: the dev + * entry uses react-dom/client (createRoot), whose internals read private + * symbols straight off the real `react` module; aliasing `react` to a shim + * namespace breaks react-dom. A single Rsbuild bundle already shares one React + * instance, so the shim is unnecessary. main.tsx instead puts real React + + * hooks + components + utils + fetchJSON on window.__HERMES_PLUGIN_SDK__ + * before any plugin entry runs, so every SDK consumer behaves like prod. + */ + +import { createRsbuild } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTailwindcss } from "@rsbuild/plugin-tailwindcss"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dashboardRoot = path.resolve(__dirname, ".."); + +const apiTarget = process.env.TRACEDECAY_DEV_API || "http://127.0.0.1:7341"; +const port = Number(process.env.TRACEDECAY_DEV_PORT || 7342); +const host = "127.0.0.1"; + +const rsbuildConfig = { + root: dashboardRoot, + source: { + entry: { index: "./dev/main.tsx" }, + }, + html: { + template: "./dev/index.html", + title: "tracedecay dashboard (dev)", + }, + server: { + host, + port, + proxy: { + // All dashboard data calls go to /api/* on a running `tracedecay + // dashboard` (default 127.0.0.1:7341). Plugins are imported by the dev + // bundle, so /dashboard-plugins is NOT proxied. + "/api": { + target: apiTarget, + changeOrigin: true, + }, + }, + }, + plugins: [ + pluginReact(), + // Compiles holographic's `@import "tailwindcss"` (Tailwind v4) so the + // plugin is styled in dev. Other plugins ship plain hand-rolled CSS. + pluginTailwindcss(), + ], +}; + +const rsbuild = await createRsbuild({ cwd: dashboardRoot, rsbuildConfig }); + +const handle = await rsbuild.startDevServer(); + +// startDevServer returns { server, port, urls, close }. rsbuild keeps the +// requested port when free; fall back to it if the field is absent. +const actualPort = (handle && typeof handle.port === "number" && handle.port) || port; +const url = `http://${host}:${actualPort}/`; +console.log(`tracedecay dev listening on ${url}`); +console.log(`tracedecay dev proxying /api -> ${apiTarget}`); + +function shutdown() { + Promise.resolve(handle && typeof handle.close === "function" ? handle.close() : undefined) + .catch(() => {}) + .finally(() => process.exit(0)); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/dashboard/lcm/src/App.tsx b/dashboard/lcm/src/App.tsx new file mode 100644 index 00000000..42a0b782 --- /dev/null +++ b/dashboard/lcm/src/App.tsx @@ -0,0 +1,1007 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * hermes-lcm dashboard root component. + * + * Faithful 1:1 port of the original IIFE in `index.js`. All `hermes-lcm-*` + * class names, DOM structure, API query shapes, pagination/dedupe behavior, + * drawer back-stack, focus management, and reload-token refetch patterns are + * preserved. Only surface syntax changed (`React.createElement` → JSX). + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchJSON } from "../../lib/sdk"; +import { + API, + SEARCH_FETCH_LIMIT, + SEARCH_PAGE_SIZE, + SESSION_FETCH_BATCH, + fmtInt, + friendlyError, + mergeSearchPayload, + sessionLabel, + sessionTail, + short, + stripMd, + summaryTitle, +} from "./helpers"; +import { + BarList, + CompressionBars, + Drawer, + DrawerError, + MessageDetail, + NodeDetail, + Pager, + SearchResultCard, + SessionDetail, + SkeletonLines, + Stat, + TimelineChart, + TimeText, + toolBadge, +} from "./components"; + +function App(): React.ReactElement { + const [q, setQ] = useState(""); + const [debouncedQ, setDebouncedQ] = useState(""); + const [role, setRole] = useState(""); + const [source, setSource] = useState(""); + const [data, setData] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(false); + const [chartsLoading, setChartsLoading] = useState(false); + const [overviewError, setOverviewError] = useState(""); + const [reloadToken, setReloadToken] = useState(0); + + const [searchData, setSearchData] = useState(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(""); + const [searchRetryToken, setSearchRetryToken] = useState(0); + const [loadingMoreResults, setLoadingMoreResults] = useState(false); + const [searchMessagePage, setSearchMessagePage] = useState(1); + const [searchNodePage, setSearchNodePage] = useState(1); + const [selectedResultIndex, setSelectedResultIndex] = useState(-1); + + const [timeline, setTimeline] = useState(null); + const [compression, setCompression] = useState(null); + const [chartsError, setChartsError] = useState(""); + + const [stack, setStack] = useState([]); + const rootRef = useRef(null); + const searchInputRef = useRef(null); + const resultRefs = useRef>({}); + const searchOffsetRef = useRef(0); + // Bumped whenever the search inputs change; in-flight pagination fetches + // from an older query compare against it and drop their stale responses. + const searchSeqRef = useRef(0); + + useEffect(function () { + const handle = setTimeout(function () { + setDebouncedQ(String(q || "").trim()); + }, 260); + return function () { clearTimeout(handle); }; + }, [q]); + + useEffect(function () { + let active = true; + setOverviewLoading(true); + setOverviewError(""); + fetchJSON(`${API}/overview?limit=25`).then(function (json) { + if (active) { + setData(json); + setOverviewError(""); + } + }).catch(function (err) { + // Failed fetch ≠ empty database: keep `data` as-is (null or stale) and + // surface the error so the UI never renders zeros for an outage. + if (active) setOverviewError(friendlyError(err)); + }).finally(function () { + if (active) setOverviewLoading(false); + }); + return function () { active = false; }; + }, [reloadToken]); + + useEffect(function () { + let active = true; + setChartsLoading(true); + setChartsError(""); + Promise.allSettled([ + fetchJSON(`${API}/timeline?bucket=day&limit=400`), + fetchJSON(`${API}/compression?by=session&limit=12`), + ]).then(function (results) { + if (!active) return; + // A rejected chart fetch leaves the previous value (or null) in place + // instead of substituting empty datasets that read as "no data". + if (results[0].status === "fulfilled") setTimeline((results[0] as any).value); + if (results[1].status === "fulfilled") setCompression((results[1] as any).value); + const failure = results[0].status === "rejected" + ? (results[0] as any).reason + : (results[1].status === "rejected" ? (results[1] as any).reason : null); + setChartsError(failure ? friendlyError(failure) : ""); + setChartsLoading(false); + }); + return function () { active = false; }; + }, [reloadToken]); + + useEffect(function () { + searchSeqRef.current += 1; + setSearchMessagePage(1); + setSearchNodePage(1); + setSelectedResultIndex(-1); + if (!debouncedQ) { + setSearchData(null); + setSearchError(""); + return; + } + let active = true; + setSearching(true); + setSearchError(""); + searchOffsetRef.current = 0; + const params = new URLSearchParams(); + params.set("q", debouncedQ); + params.set("limit", String(SEARCH_FETCH_LIMIT)); + if (role) params.set("role", role); + if (source) params.set("source", source); + fetchJSON(`${API}/search?${params.toString()}`).then(function (json) { + if (active) setSearchData(json); + }).catch(function (err) { + // Keep error and result state mutually exclusive: a failed search must + // not fall through to the "No matches found" empty state. + if (active) { + setSearchData(null); + setSearchError(friendlyError(err)); + } + }).finally(function () { + if (active) setSearching(false); + }); + return function () { active = false; }; + }, [debouncedQ, role, source, searchRetryToken]); + + // Server-offset pagination (additive backend field `total` + `offset`): + // pulls the next window for both result lists and appends with dedupe. + // Responses are dropped when the query/facets changed while in flight, so + // an old query's page can never merge into (or overwrite the totals of) a + // newer query's results. + const fetchMoreResults = useCallback(function () { + if (!debouncedQ || !searchData || loadingMoreResults) return; + const seq = searchSeqRef.current; + const nextOffset = searchOffsetRef.current + SEARCH_FETCH_LIMIT; + setLoadingMoreResults(true); + const params = new URLSearchParams(); + params.set("q", debouncedQ); + params.set("limit", String(SEARCH_FETCH_LIMIT)); + params.set("offset", String(nextOffset)); + if (role) params.set("role", role); + if (source) params.set("source", source); + fetchJSON(`${API}/search?${params.toString()}`).then(function (json) { + if (seq !== searchSeqRef.current) return; + searchOffsetRef.current = nextOffset; + setSearchData(function (prev) { return mergeSearchPayload(prev, json); }); + }).catch(function (err) { + if (seq !== searchSeqRef.current) return; + setSearchError(friendlyError(err)); + }).finally(function () { + setLoadingMoreResults(false); + }); + }, [debouncedQ, role, source, searchData, loadingMoreResults]); + + const updateStackEntry = useCallback(function (matcher: (e: any) => boolean, updater: (e: any) => any) { + setStack(function (prev) { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (matcher(next[i])) { + next[i] = updater(next[i]); + break; + } + } + return next; + }); + }, []); + + const fetchNode = useCallback(function (id: any) { + fetchJSON(`${API}/node/${encodeURIComponent(id)}`).then(function (json) { + updateStackEntry(function (entry) { + return entry.kind === "node" && String(entry.id) === String(id); + }, function () { + return { + kind: "node", + id: id, + data: json, + loading: false, + error: "", + }; + }); + }).catch(function (err) { + updateStackEntry(function (entry) { + return entry.kind === "node" && String(entry.id) === String(id); + }, function () { + return { + kind: "node", + id: id, + data: null, + loading: false, + error: String((err && err.message) || err), + }; + }); + }); + }, [updateStackEntry]); + + const fetchSession = useCallback(function (id: any, offset: any, append: boolean, activeMessageId: any) { + const params = new URLSearchParams(); + params.set("limit", String(SESSION_FETCH_BATCH)); + params.set("offset", String(offset || 0)); + fetchJSON(`${API}/session/${encodeURIComponent(id)}?${params.toString()}`).then(function (json) { + updateStackEntry(function (entry) { + return entry.kind === "session" && String(entry.id) === String(id); + }, function (entry) { + const previous = (append && entry.data && entry.data.messages) ? entry.data.messages : []; + const nextMessages = append ? previous.concat(json.messages || []) : (json.messages || []); + return { + kind: "session", + id: id, + data: Object.assign({}, json, { messages: nextMessages }), + loading: false, + loadingMore: false, + error: "", + activeMessageId: activeMessageId != null ? activeMessageId : entry.activeMessageId, + }; + }); + }).catch(function (err) { + updateStackEntry(function (entry) { + return entry.kind === "session" && String(entry.id) === String(id); + }, function (entry) { + return Object.assign({}, entry, { + loading: false, + loadingMore: false, + error: String((err && err.message) || err), + }); + }); + }); + }, [updateStackEntry]); + + const fetchMessageContext = useCallback(function (message: any) { + const params = new URLSearchParams(); + params.set("limit", "1"); + params.set("offset", "0"); + fetchJSON(`${API}/session/${encodeURIComponent(message.session_id)}?${params.toString()}`).then(function (json) { + updateStackEntry(function (entry) { + return entry.kind === "message" && Number(entry.id) === Number(message.store_id); + }, function () { + return { + kind: "message", + id: message.store_id, + sessionId: message.session_id, + loading: false, + error: "", + data: { + message: message, + session: json, + }, + }; + }); + }).catch(function (err) { + updateStackEntry(function (entry) { + return entry.kind === "message" && Number(entry.id) === Number(message.store_id); + }, function () { + return { + kind: "message", + id: message.store_id, + sessionId: message.session_id, + loading: false, + error: String((err && err.message) || err), + data: { message: message, session: null }, + }; + }); + }); + }, [updateStackEntry]); + + const openNode = useCallback(function (id: any) { + setStack(function (prev) { + return prev.concat([{ kind: "node", id: id, data: null, loading: true, error: "" }]); + }); + fetchNode(id); + }, [fetchNode]); + + const openSession = useCallback(function (id: any, opts?: any) { + const activeMessageId = opts && opts.activeMessageId != null ? opts.activeMessageId : null; + setStack(function (prev) { + return prev.concat([{ + kind: "session", + id: id, + data: null, + loading: true, + loadingMore: false, + error: "", + activeMessageId: activeMessageId, + }]); + }); + fetchSession(id, 0, false, activeMessageId); + }, [fetchSession]); + + const openMessage = useCallback(function (message: any) { + setStack(function (prev) { + return prev.concat([{ + kind: "message", + id: message.store_id, + sessionId: message.session_id, + data: { message: message, session: null }, + loading: true, + error: "", + }]); + }); + fetchMessageContext(message); + }, [fetchMessageContext]); + + const loadMoreSession = useCallback(function (id: any) { + const current = stack.length ? stack[stack.length - 1] : null; + if (!current || current.kind !== "session" || String(current.id) !== String(id) || !current.data || !current.data.has_more) { + return; + } + const offset = (current.data.messages || []).length; + updateStackEntry(function (entry) { + return entry.kind === "session" && String(entry.id) === String(id); + }, function (entry) { + return Object.assign({}, entry, { loadingMore: true, error: "" }); + }); + fetchSession(id, offset, true, current.activeMessageId); + }, [fetchSession, stack, updateStackEntry]); + + const goBack = useCallback(function () { + setStack(function (prev) { return prev.slice(0, -1); }); + }, []); + const closeDrawer = useCallback(function () { + setStack([]); + }, []); + + const top = stack.length ? stack[stack.length - 1] : null; + const overview = (data && data.overview) || {}; + const comp = overview.compression || {}; + const sources = overview.source_counts || []; + const hasLcmRows = Boolean( + Number(overview.messages_total) || + Number(overview.summary_nodes_total) || + Number(overview.sessions_total) + ); + + // The server is unreachable when the overview fetch threw and we have no + // (stale) payload to show; this must render error UI, never zero-data UI. + const serverUnreachable = Boolean(overviewError) && !data; + const staleData = Boolean(overviewError) && Boolean(data); + + const matches = (searchData && searchData.matches) || { messages: [], summary_nodes: [] }; + const fetchedMessageCount = (matches.messages || []).length; + const fetchedNodeCount = (matches.summary_nodes || []).length; + // Additive backend field: true totals + offset pagination. Fall back to + // fetched counts when the running server predates the field. + const searchTotals = (searchData && searchData.total) || null; + const totalMessageCount = (searchTotals && Number(searchTotals.messages) >= 0) + ? Number(searchTotals.messages) + : fetchedMessageCount; + const totalNodeCount = (searchTotals && Number(searchTotals.summary_nodes) >= 0) + ? Number(searchTotals.summary_nodes) + : fetchedNodeCount; + const hasMoreServerResults = Boolean(searchTotals) + && (fetchedMessageCount < totalMessageCount || fetchedNodeCount < totalNodeCount); + const messageTotalPages = Math.max(1, Math.ceil((matches.messages || []).length / SEARCH_PAGE_SIZE)); + const nodeTotalPages = Math.max(1, Math.ceil((matches.summary_nodes || []).length / SEARCH_PAGE_SIZE)); + const visibleMessages = (matches.messages || []).slice( + (searchMessagePage - 1) * SEARCH_PAGE_SIZE, + searchMessagePage * SEARCH_PAGE_SIZE + ); + const visibleNodes = (matches.summary_nodes || []).slice( + (searchNodePage - 1) * SEARCH_PAGE_SIZE, + searchNodePage * SEARCH_PAGE_SIZE + ); + + const keyboardResults = useMemo(function () { + return visibleMessages.map(function (item) { + return { + key: "message:" + item.store_id, + open: function () { openMessage(item); }, + }; + }).concat(visibleNodes.map(function (item) { + return { + key: "node:" + item.node_id, + open: function () { openNode(item.node_id); }, + }; + })); + }, [visibleMessages, visibleNodes, openMessage, openNode]); + + useEffect(function () { + setSelectedResultIndex(function (prev) { + if (!keyboardResults.length) return -1; + if (prev >= keyboardResults.length) return keyboardResults.length - 1; + return prev; + }); + }, [keyboardResults.length]); + + const lastFocusedResultRef = useRef(""); + useEffect(function () { + if (selectedResultIndex < 0 || selectedResultIndex >= keyboardResults.length) { + lastFocusedResultRef.current = ""; + return; + } + const key = keyboardResults[selectedResultIndex].key; + // Only move focus when the selection actually changes, and never while + // a detail drawer is open — the drawer owns focus then. + if (key === lastFocusedResultRef.current || stack.length) return; + const el = resultRefs.current[key]; + if (!el) return; + lastFocusedResultRef.current = key; + try { + if (typeof el.focus === "function") el.focus({ preventScroll: true }); + } catch (e) { + if (typeof el.focus === "function") el.focus(); + } + if (typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "nearest" }); + } + }, [selectedResultIndex, keyboardResults, stack]); + + useEffect(function () { + function isTypingTarget(target: any) { + if (!target) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; + } + // Keep-mounted hosts (the standalone shell) hide inactive tab panels + // with `display: none` instead of unmounting them; a hidden panel must + // not react to keystrokes meant for the visible tab. + function isPanelHidden() { + const el = rootRef.current; + return !!el && el.offsetParent === null; + } + function onKeyDown(e: any) { + if (e.defaultPrevented) return; + if (isPanelHidden()) return; + if (!e.metaKey && !e.ctrlKey && !e.altKey && e.key === "/" && !isTypingTarget(e.target)) { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + if (typeof searchInputRef.current.select === "function") searchInputRef.current.select(); + } + return; + } + if (e.key === "Escape" && top) { + e.preventDefault(); + closeDrawer(); + return; + } + if (!keyboardResults.length || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setSelectedResultIndex(function (prev) { + if (!keyboardResults.length) return -1; + if (prev < 0) return e.key === "ArrowDown" ? 0 : keyboardResults.length - 1; + return (prev + (e.key === "ArrowDown" ? 1 : -1) + keyboardResults.length) % keyboardResults.length; + }); + return; + } + if (e.key === "Enter" && selectedResultIndex >= 0 && selectedResultIndex < keyboardResults.length) { + e.preventDefault(); + keyboardResults[selectedResultIndex].open(); + } + } + window.addEventListener("keydown", onKeyDown); + return function () { window.removeEventListener("keydown", onKeyDown); }; + }, [keyboardResults, selectedResultIndex, top, closeDrawer]); + + const searchPending = String(q || "").trim() !== debouncedQ; + const searchActive = Boolean(String(q || "").trim() || debouncedQ || searching || searchData || searchError); + const totalSearchMatches = totalMessageCount + totalNodeCount; + + let drawerTitle = ""; + let drawerBody: React.ReactNode = null; + if (top) { + if (top.loading) { + drawerTitle = top.kind === "node" + ? `Node #${top.id}` + : (top.kind === "message" ? `Message #${top.id}` : `Session ${short(top.id, 40)}`); + drawerBody =
Loading…
; + } else if (top.error) { + drawerTitle = top.kind === "node" + ? `Node #${top.id}` + : (top.kind === "message" ? `Message #${top.id}` : `Session ${short(top.id, 40)}`); + const current = top; + drawerBody = ( + + ); + } else if (top.kind === "node") { + drawerTitle = `Node #${top.id}`; + drawerBody = ( + + ); + } else if (top.kind === "message") { + drawerTitle = `Message #${top.id}`; + drawerBody = ( + + ); + } else { + drawerTitle = `Session ${short(top.id, 40)}`; + drawerBody = ( + + ); + } + } + + // Search results render directly under the toolbar (see placement below) + // so typing a query gives immediate visible feedback instead of appending + // results below the overview cards, off-screen. + const searchShell = searchActive ? ( +
+
+
+

Search

+
+ {searchPending + ? "Waiting for typing to pause…" + : (searching + ? "Searching messages and summary nodes…" + : (debouncedQ && searchData + ? `${fmtInt(totalSearchMatches)} matches for "${short(debouncedQ, 36)}".` + : "Use / to focus and arrows to move through the current page."))} +
+
+
+ {debouncedQ ? toolBadge(`"${short(debouncedQ, 36)}"`) : null} + {searchData && searchData.engine === "fts" ? toolBadge("FTS ranked", "ok") : null} + {searchData && searchData.engine === "like" ? toolBadge("LIKE fallback", "warn") : null} + {(!searchPending && !searching && debouncedQ && searchData) ? toolBadge(fmtInt(totalSearchMatches) + " hits") : null} +
+
+ {searchError ? ( +
+
+ Search failed. + {searchError + " — results below may be incomplete; this is not an empty result."} +
+ +
+ ) : null} + {(!searchPending && searching && !searchData) ? ( +
+
+
+
+ ) : null} + {(!searchPending && !searching && debouncedQ && !searchError && totalSearchMatches === 0) ? ( +
+ No matches found. + {" Try removing a facet or a punctuation-heavy query so the backend can stay on the ranked FTS path."} +
+ ) : null} + {totalSearchMatches > 0 ? ( +
+
+
+

{totalMessageCount > fetchedMessageCount + ? `Matching Messages (${fmtInt(fetchedMessageCount)} of ${fmtInt(totalMessageCount)})` + : `Matching Messages (${fmtInt(fetchedMessageCount)})`}

+
Click for full content and session context
+
+
+ {visibleMessages.length + ? visibleMessages.map(function (m, idx) { + const resultKey = "message:" + m.store_id; + const selected = selectedResultIndex === idx; + return ( + + ); + }) + :
No matching messages on this page.
} +
+ +
+
+
+

{totalNodeCount > fetchedNodeCount + ? `Matching Summaries (${fmtInt(fetchedNodeCount)} of ${fmtInt(totalNodeCount)})` + : `Matching Summaries (${fmtInt(fetchedNodeCount)})`}

+
Open a node to follow its source links
+
+
+ {visibleNodes.length + ? visibleNodes.map(function (n, idx) { + const absoluteIndex = visibleMessages.length + idx; + const resultKey = "node:" + n.node_id; + const selected = selectedResultIndex === absoluteIndex; + return ( + + ); + }) + :
No matching summaries on this page.
} +
+ +
+
+ ) : null} + {hasMoreServerResults ? ( +
+ + + {`${fmtInt(fetchedMessageCount + fetchedNodeCount)} of ${fmtInt(totalSearchMatches)} loaded`} + +
+ ) : null} +
+ ) : null; + + return ( +
+
+
+ + {q ? ( + + ) : null} +
+ + +
+ {(overviewLoading || chartsLoading) ? "Loading overview" + : overviewError ? "Server unreachable" + : ((data && data.exists) ? "Database detected" : "Database missing")} +
+
+
+ `/` focus search + Arrow keys browse results + Enter opens detail +
+ {/* Which session store is being served (scope tag + database path). */} +
+ {data ? ( + <> + {data.storage_scope === "project_local" + ? Project store + : (data.storage_scope === "global" + ? Global store + : null)} + {data.path} + + ) : ""} +
+ + {searchShell} + + {/* Unreachable server: a distinguishable error hero with retry — never + the zeroed stats / "No data" cards that imply an empty database. */} + {serverUnreachable ? ( +
+ + ) : null} + + {staleData ? ( +
+
{`Refresh failed (${overviewError}) — showing previously loaded data.`}
+ +
+ ) : null} + {data && data.error ?
{data.error}
: null} + + {data && !data.exists ? ( +
+ + ) : null} + + {data && data.exists && !hasLcmRows ? ( +
+ + ) : null} + + {/* Stats render only from a successful overview payload; zeros are then + genuinely "empty database", never a masked fetch failure. */} + {data ? ( +
+ + + + + +
+ ) : (overviewLoading ? ( +
+
+
+
+
+ ) : null)} + + {serverUnreachable ? null : ( +
+
+

Message Timeline (per day · dots = summaries)

+ {chartsError && !timeline + ? ( +
+
{chartsError}
+ +
+ ) + : (chartsLoading && !timeline) + ? + : ( + + )} +
+
+

Compression by Session (kept vs saved)

+ {chartsError && !compression + ? ( +
+
{chartsError}
+ +
+ ) + : (chartsLoading && !compression) + ? + : ( + + )} +
+
+ )} + + {serverUnreachable ? null : ( +
+
+

By Source

+ +
+
+

By Role

+ +
+
+

Summary Depth

+ +
+
+ )} + + {serverUnreachable ? null : ( +
+
+

Recent Sessions

+
+ {((data && data.latest_sessions) || []).length + ? ((data && data.latest_sessions) || []).map(function (s, idx) { + const tail = sessionTail(s.session_id); + return ( + + ); + }) + : (data + ?
No sessions
+ : )} +
+
+
+

Latest Summaries

+
+ {((data && data.latest_summary_nodes) || []).length + ? ((data && data.latest_summary_nodes) || []).map(function (n) { + const title = summaryTitle(n.summary); + const preview = stripMd(n.summary); + return ( + + ); + }) + : (data + ?
No summaries
+ : )} +
+
+
+ )} + + 1} + onBack={goBack} + onClose={closeDrawer} + > + {drawerBody} + +
+ ); +} + +export default App; diff --git a/dashboard/lcm/src/components.tsx b/dashboard/lcm/src/components.tsx new file mode 100644 index 00000000..8d733475 --- /dev/null +++ b/dashboard/lcm/src/components.tsx @@ -0,0 +1,991 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Presentational components for the hermes-lcm dashboard plugin. + * + * Ported 1:1 from the original hand-written IIFE. All `hermes-lcm-*` class + * names are preserved so `style.css` continues to apply unchanged. Behavior, + * DOM structure, and prop flow are identical to the original `h(...)` calls — + * only the surface syntax changed (`React.createElement` → JSX). + */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { MarkdownText } from "./markdown"; +import { + SESSION_FETCH_BATCH, + SESSION_MESSAGE_PAGE_SIZE, + clampText, + copyTextValue, + escapeRegExp, + fmtAbsoluteTime, + fmtInt, + fmtTime, + parseJsonArray, + parseLeadingJSON, + queryTerms, + sessionLabel, + short, + stripMd, + summaryTitle, +} from "./helpers"; + +// --- search-highlight rendering ------------------------------------------- + +export function renderHighlightedText(text: any, query: any): any { + const raw = String(text || ""); + const terms = queryTerms(query); + if (!terms.length) return raw; + const re = new RegExp("(" + terms.map(escapeRegExp).join("|") + ")", "ig"); + const parts = raw.split(re); + return parts.map(function (part, idx) { + if (!part) return null; + return terms.some(function (term) { return part.toLowerCase() === term.toLowerCase(); }) + ? {part} + : part; + }); +} + +export function renderSnippet(text: any): any { + const raw = String(text || ""); + const parts: any[] = []; + const re = /\[([^\]]*)\]/g; + let last = 0; + let m: RegExpExecArray | null; + let i = 0; + while ((m = re.exec(raw)) !== null) { + if (m.index > last) parts.push(raw.slice(last, m.index)); + parts.push({m[1]}); + last = re.lastIndex; + } + if (last < raw.length) parts.push(raw.slice(last)); + return parts.length ? parts : raw; +} + +export function renderSearchSnippet(text: any, query: any): any { + const raw = String(text || ""); + if (/\[[^\]]+\]/.test(raw)) return renderSnippet(raw); + return renderHighlightedText(raw, query); +} + +// --- shared atoms ---------------------------------------------------------- + +function codeBlock(text: any): React.ReactElement { + return
{clampText(text, 4000)}
; +} + +export function toolBadge(label: any, kind?: string): React.ReactElement { + return {label}; +} + +export function Stat(props: { value: any; label: any }): React.ReactElement { + return ( +
+
{props.value}
+
{props.label}
+
+ ); +} + +export function SkeletonLines(props: { count?: number; widths?: any }): React.ReactElement { + const count = props.count || 3; + const lines: React.ReactElement[] = []; + for (let i = 0; i < count; i++) { + lines.push( +
, + ); + } + return
{lines}
; +} + +export function Pager(props: { totalPages?: number; page: number; onChange: (n: number) => void }): React.ReactElement | null { + if (!props.totalPages || props.totalPages <= 1) return null; + return ( +
+ + {`Page ${props.page} / ${props.totalPages}`} + +
+ ); +} + +export function TimeText(props: { epoch: any; className?: string }): React.ReactElement { + if (!props.epoch) { + return ; + } + const absolute = fmtAbsoluteTime(props.epoch); + let dateTime = ""; + try { + dateTime = new Date(Number(props.epoch) * 1000).toISOString(); + } catch (e) { /* best-effort */ } + return ( + + ); +} + +export function CopyButton(props: { text: any; label?: string; title?: string }): React.ReactElement { + const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle"); + const onCopy = useCallback(function (e: any) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + copyTextValue(props.text).then(function (ok) { + setStatus(ok ? "copied" : "failed"); + setTimeout(function () { setStatus("idle"); }, 1400); + }); + }, [props.text]); + const label = status === "copied" ? "Copied" : (status === "failed" ? "Retry copy" : (props.label || "Copy")); + return ( + + ); +} + +// --- chart-ish presentational components ----------------------------------- + +export function BarList(props: { rows?: any[]; keyName: string; onPick?: (label: string) => void }): React.ReactElement { + const rows = props.rows || []; + const keyName = props.keyName; + const onPick = props.onPick; + const total = rows.reduce((acc, row) => acc + (Number(row.count) || 0), 0) || 1; + if (!rows.length) return
No data
; + return ( +
+ {rows.map(function (row, idx) { + const label = String(row[keyName] == null ? "(none)" : row[keyName]); + const count = Number(row.count) || 0; + const pct = Math.max(2, Math.round((count / total) * 100)); + const clickable = typeof onPick === "function"; + return ( +
+
+ {label} + {fmtInt(count)} +
+
+
+
+
+ ); + })} +
+ ); +} + +/** Responsive CSS bar chart (no SVG stretching, so bars stay crisp and the + * summary markers render as true round dots regardless of bucket count). */ +export function TimelineChart(props: { buckets?: any[]; nodeBuckets?: any[]; undatedCount?: any }): React.ReactElement { + // Buckets cover only messages with real timestamps; the server reports + // messages without one separately so they surface as an honest note + // instead of a fake single bar. + const buckets = (props.buckets || []).filter(function (b) { return b && b.bucket != null; }); + const nodeBuckets = props.nodeBuckets || []; + const undatedCount = Number(props.undatedCount) || 0; + if (!buckets.length) { + return ( +
+ {undatedCount > 0 + ? `No dated messages yet — ${fmtInt(undatedCount)} stored messages have no timestamp` + : "No timeline data"} +
+ ); + } + const maxCount = buckets.reduce((acc, b) => Math.max(acc, Number(b.count) || 0), 0) || 1; + const nodeByBucket: Record = {}; + nodeBuckets.forEach(function (nb) { nodeByBucket[nb.bucket] = Number(nb.count) || 0; }); + + const cols = buckets.map(function (b, i) { + const count = Number(b.count) || 0; + const pct = count > 0 ? Math.max(3, Math.round((count / maxCount) * 100)) : 0; + const nodes = nodeByBucket[b.bucket] || 0; + const tip = `${b.bucket}: ${fmtInt(count)} messages` + + (nodes ? ` · ${fmtInt(nodes)} summaries` : ""); + return ( +
+
+
+
+ ); + }); + + return ( +
+
{cols}
+
+ {short(buckets[0].bucket, 16)} + {short(buckets[buckets.length - 1].bucket, 16)} +
+ {undatedCount > 0 ? ( +
+ {`${fmtInt(undatedCount)} undated messages not shown`} +
+ ) : null} +
+ ); +} + +/** Per-group compression (kept vs saved), rendered as a small inline SVG. */ +export function CompressionBars(props: { groups?: any[]; onPick?: (g: any) => void }): React.ReactElement { + const groups = props.groups || []; + const onPick = props.onPick; + if (!groups.length) return
No compression data
; + const maxSrc = groups.reduce((acc, g) => Math.max(acc, Number(g.source_token_count) || 0), 0) || 1; + return ( +
+ {groups.map(function (g, idx) { + const src = Number(g.source_token_count) || 0; + const out = Number(g.token_count) || 0; + const totalW = Math.max(0.5, (src / maxSrc) * 100); + const keptW = src > 0 ? (out / src) * totalW : 0; + const sid = g.session_id != null ? g.session_id : g.key; + const label = (typeof sid === "string" && /^\d{8}_/.test(sid)) + ? sessionLabel(sid) + : (g.depth != null ? `node #${g.key} (D${g.depth})` : String(g.key)); + const clickable = typeof onPick === "function"; + return ( +
+
+ {label} + {`${g.ratio || 0}× · ${fmtInt(src)}→${fmtInt(out)}`} +
+ + + + +
+ ); + })} +
+ ); +} + +// --- tool-result rendering. Known tools get bespoke components; any other +// JSON falls back to a clean key/value view; non-JSON to markdown. ----------- + +function ToolOutput(props: { data: any }): React.ReactElement { + const d = props.data; + const out = d.output != null + ? d.output + : (typeof d.result === "string" ? d.result + : (d.result != null ? JSON.stringify(d.result, null, 2) : "")); + const code = d.exit_code != null ? d.exit_code : d.status; + const ok = d.exit_code === 0 || d.status === "success" || d.status === "exited" || d.success === true; + return ( +
+ {(code != null || d.duration_seconds != null) ? ( +
+ {code != null ? toolBadge((d.exit_code != null ? "exit " : "") + code, ok ? "ok" : "bad") : null} + {d.duration_seconds != null ? {d.duration_seconds + "s"} : null} +
+ ) : null} + {out ? codeBlock(out) : null} + {d.error ?
{String(d.error)}
: null} + {d.timeout_note ?
{String(d.timeout_note)}
: null} +
+ ); +} + +function ToolReadFile(props: { data: any }): React.ReactElement { + const d = props.data; + return ( +
+
+ {d.total_lines != null ? toolBadge(fmtInt(d.total_lines) + " lines") : null} + {d.file_size != null ? toolBadge(fmtInt(d.file_size) + " B") : null} + {d.truncated ? toolBadge("truncated", "warn") : null} + {d.is_image ? toolBadge("image") : null} +
+ {d.content ? codeBlock(d.content) : null} + {(d.hint || d._hint) ?
{String(d.hint || d._hint)}
: null} +
+ ); +} + +function ToolSearchFiles(props: { data: any }): React.ReactElement { + const d = props.data; + const matches = d.matches || []; + return ( +
+
+ {toolBadge(fmtInt(d.total_count != null ? d.total_count : matches.length) + " matches")} +
+
+ {matches.slice(0, 50).map(function (mm: any, i: number) { + return ( +
+
+ {short(String(mm.path || ""), 72)} + {mm.line != null ? {":" + mm.line} : null} +
+ {mm.content != null + ? {short(String(mm.content), 220)} + : null} +
+ ); + })} +
+ {matches.length > 50 ?
{"+" + fmtInt(matches.length - 50) + " more"}
: null} +
+ ); +} + +const TODO_ICON: Record = { completed: "✓", in_progress: "◐", pending: "○", cancelled: "✗" }; + +function ToolTodo(props: { data: any }): React.ReactElement { + const d = props.data; + const todos = d.todos || []; + const s = d.summary || {}; + return ( +
+
+ {s.completed != null ? toolBadge(s.completed + " done", "ok") : null} + {s.in_progress != null ? toolBadge(s.in_progress + " active") : null} + {s.pending != null ? toolBadge(s.pending + " todo") : null} + {s.cancelled ? toolBadge(s.cancelled + " cancelled") : null} +
+
    + {todos.map(function (t: any, i: number) { + const st = String(t.status || "pending"); + return ( +
  • + {TODO_ICON[st] || "•"} + {String(t.content || "")} +
  • + ); + })} +
+
+ ); +} + +function ToolPatch(props: { data: any; raw: any }): React.ReactElement { + const d = props.data; + const raw = props.raw; + let diff = d && d.diff; + if (diff == null && typeof raw === "string") { + const mm = raw.match(/"diff"\s*:\s*"([\s\S]*?)"\s*\}?\s*$/); + diff = mm ? mm[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\t/g, "\t") : raw; + } + const lines = String(diff || "").split("\n"); + return ( +
+ {(d && d.success != null) ? ( +
+ {toolBadge(d.success ? "applied" : "failed", d.success ? "ok" : "bad")} +
+ ) : null} +
+        {lines.slice(0, 240).map(function (ln: string, i: number) {
+          const c0 = ln.charAt(0);
+          const cls = c0 === "+" ? "hermes-lcm-diff-add"
+            : c0 === "-" ? "hermes-lcm-diff-del"
+            : c0 === "@" ? "hermes-lcm-diff-hunk" : "";
+          return 
{ln || " "}
; + })} +
+
+ ); +} + +function ToolSkill(props: { data: any }): React.ReactElement { + const d = props.data; + const tags = d.tags || []; + return ( +
+
+ {d.name ? toolBadge(d.name) : null} + {tags.slice(0, 6).map(function (t: any, i: number) { + return {"#" + t}; + })} +
+ {d.description ?
{String(d.description)}
: null} + {d.content ? : null} +
+ ); +} + +function ToolGeneric(props: { data: any }): React.ReactElement { + const d = props.data; + if (Array.isArray(d)) return codeBlock(JSON.stringify(d, null, 2)); + return ( +
+ {Object.keys(d).map(function (k, i) { + const v = d[k]; + let vn: React.ReactNode; + if (v == null) vn = null; + else if (typeof v === "object") { + vn = ( +
+              {clampText(JSON.stringify(v, null, 2), 1500)}
+            
+ ); + } else vn = {String(v)}; + return ( +
+ {k} + {vn} +
+ ); + })} +
+ ); +} + +export function ToolResult(props: { name: any; content: any }): React.ReactElement { + const name = String(props.name || ""); + const raw = props.content; + const parsed = parseLeadingJSON(raw); + const data = parsed ? parsed.value : undefined; + const note = (parsed && parsed.rest) ? parsed.rest : ""; + let body: any = null; + try { + if ((name === "terminal" || name === "process" || name === "execute_code" || name === "shell") + && data && typeof data === "object") body = ; + else if (name === "read_file" && data) body = ; + else if (name === "search_files" && data) body = ; + else if (name === "todo" && data) body = ; + else if (name === "patch") body = ; + else if (name === "skill_view" && data) body = ; + else if (data && typeof data === "object") body = ; + } catch (e) { body = null; } + if (body == null) { + return ; + } + if (note) { + return ( +
+ {body} +
{short(note, 400)}
+
+ ); + } + return body; +} + +// --- list rows ------------------------------------------------------------- + +export function SearchResultCard(props: { + item: any; + kind: string; + query: any; + selected?: boolean; + resultRef?: (el: HTMLElement | null) => void; + onFocus: () => void; + onOpen: () => void; +}): React.ReactElement { + const item = props.item; + const isMessage = props.kind === "message"; + const title = isMessage + ? short(item.session_id || "", 42) + : short(summaryTitle(item.summary || ""), 90); + const preview = isMessage + ? renderSearchSnippet(item.snippet || short(item.content, 240), props.query) + : renderSearchSnippet(item.snippet || short(stripMd(item.summary), 240), props.query); + const keyValue = props.kind + ":" + String(isMessage ? item.store_id : item.node_id); + return ( + + ); +} + +export function MessageItem(props: { + m: any; + onOpenMessage?: (m: any) => void; + active?: boolean; + compact?: boolean; + previewText?: any; + query?: any; +}): React.ReactElement { + const m = props.m; + const clickable = typeof props.onOpenMessage === "function"; + let body: React.ReactNode; + if (props.previewText) { + body =
{renderSearchSnippet(props.previewText, props.query)}
; + } else if (m.role === "tool") { + body = ; + } else { + body = ( + + ); + } + return ( +
+
+
+ {m.role || "?"} + {m.source ? {m.source} : null} + {m.tool_name ? {m.tool_name} : null} + {m.pinned ? pinned : null} + {m.store_id != null ? {"#" + m.store_id} : null} + + {m.token_estimate ? {`${fmtInt(m.token_estimate)} tok`} : null} +
+
+ {clickable ? ( + + ) : null} + +
+
+ {body} +
+ ); +} + +export function NodeRef(props: { n: any; onOpen: (id: any) => void; active?: boolean }): React.ReactElement { + const n = props.n; + const onOpen = props.onOpen; + return ( + + ); +} + +// --- detail panels --------------------------------------------------------- + +export function MessageDetail(props: { + data: any; + onOpenNode: (id: any) => void; + onOpenSession: (id: any, opts?: any) => void; +}): React.ReactElement { + const d = props.data || {}; + const message = d.message; + const session = d.session; + if (!message) return
Message not found
; + const sessionNodes = (session && session.summary_nodes) || []; + // Prefer the backend's exact message→summary linkage (summary_node_ids, + // additive field) and fall back to same-session summaries when absent. + const linkedIds = parseJsonArray(message.summary_node_ids).map(String); + const linkedSet: Record = {}; + linkedIds.forEach(function (id) { linkedSet[id] = true; }); + const linkedNodes = linkedIds.length + ? sessionNodes.filter(function (node: any) { return linkedSet[String(node.node_id)]; }) + : []; + const hasExactLinks = linkedIds.length > 0; + const relatedNodes = hasExactLinks ? linkedNodes : sessionNodes; + const unresolvedLinkIds = hasExactLinks + ? linkedIds.filter(function (id) { + return !relatedNodes.some(function (node: any) { return String(node.node_id) === id; }); + }) + : []; + return ( +
+
+ {message.role || "message"} + {message.source ? {message.source} : null} + {`#${message.store_id}`} + + {message.token_estimate ? {`${fmtInt(message.token_estimate)} tok`} : null} + +
+
+ +
+
+
+
Session
+
{short(message.session_id, 48)}
+
+
+
Absolute time
+
{fmtAbsoluteTime(message.timestamp) || "—"}
+
+
+
Token estimate
+
{fmtInt(message.token_estimate || 0)}
+
+
+
{hasExactLinks ? "Linked summaries" : "Related summaries"}
+
+ {fmtInt(hasExactLinks ? linkedIds.length : relatedNodes.length)} +
+
+
+

Content

+ +

{hasExactLinks + ? `Summaries built from this message (${linkedIds.length})` + : `Summaries in this session (${relatedNodes.length})`}

+ {relatedNodes.length ? ( +
+ {relatedNodes.map(function (node: any) { + return ; + })} +
+ ) : null} + {unresolvedLinkIds.length ? ( +
+ {unresolvedLinkIds.map(function (id: string) { + return ( + + ); + })} +
+ ) : null} + {(!relatedNodes.length && !unresolvedLinkIds.length) + ?
No summary nodes reference this message yet.
+ : null} +
+ ); +} + +export function NodeDetail(props: { + data: any; + onOpenNode: (id: any) => void; + onOpenSession: (id: any, opts?: any) => void; + onOpenMessage: (m: any) => void; +}): React.ReactElement { + const d = props.data || {}; + const node = d.node; + const onOpenNode = props.onOpenNode; + const onOpenSession = props.onOpenSession; + const onOpenMessage = props.onOpenMessage; + if (!node) return
Node not found
; + const sources = d.sources || {}; + const tags = parseJsonArray(node.tags); + const entities = parseJsonArray(node.entities); + return ( +
+
+ {`Depth ${node.depth}`} + {node.category ? {node.category} : null} + + {`${fmtInt(node.source_token_count)}→${fmtInt(node.token_count)} tok`} + +
+
+
+
Node id
+
{String(node.node_id)}
+
+
+
Source type
+
{sources.type || node.source_type || "—"}
+
+
+
Tags
+
{tags.length ? tags.join(", ") : "—"}
+
+
+
Entities
+
{entities.length ? entities.join(", ") : "—"}
+
+
+

Summary

+ + {node.expand_hint ? ( +
+ Expand hint: {node.expand_hint} +
+ ) : null} +

{`Source links (${sources.type || "?"}, ${(sources.ids || []).length})`}

+ {(() => { + const isNodes = sources.type === "nodes"; + const items = isNodes ? (sources.nodes || []) : (sources.messages || []); + if (!items.length) { + return ( +
+ {(sources.ids || []).length + ? "Source items are no longer in the database." + : "This summary records no source items."} +
+ ); + } + return ( +
+ {items.map(function (it: any) { + return isNodes + ? + : ; + })} +
+ ); + })()} +
+ ); +} + +export function SessionDetail(props: { + data: any; + onOpenNode: (id: any) => void; + onOpenMessage: (m: any) => void; + onLoadMore: () => void; + loadingMore?: boolean; + activeMessageId?: any; +}): React.ReactElement { + const d = props.data || {}; + const onOpenNode = props.onOpenNode; + const onOpenMessage = props.onOpenMessage; + const c = d.counts || {}; + const [page, setPage] = useState(1); + const sessionId = d.session_id; + useEffect(function () { + setPage(1); + }, [sessionId]); + const loadedMessages = d.messages || []; + const shownCount = Math.min(loadedMessages.length, page * SESSION_MESSAGE_PAGE_SIZE); + const visibleMessages = loadedMessages.slice(0, shownCount); + return ( +
+
+ + + + +
+ {(d.summary_nodes && d.summary_nodes.length) ? ( +
+

{`Summary nodes (${d.summary_nodes.length})`}

+
+ {d.summary_nodes.map(function (n: any) { + return ; + })} +
+
+ ) : null} +
+

{`Messages (${fmtInt(c.message_count || loadedMessages.length)})`}

+
+ {`Showing ${fmtInt(visibleMessages.length)} of ${fmtInt(c.message_count || loadedMessages.length)}`} +
+
+
+ {visibleMessages.map(function (m: any) { + return ( + + ); + })} +
+
+ {shownCount < loadedMessages.length ? ( + + ) : null} + {(shownCount >= loadedMessages.length && d.has_more) ? ( + + ) : null} +
+
+ ); +} + +// --- drawer (session / node / message detail) ------------------------------ + +export function Drawer(props: { + open: boolean; + title?: string; + canBack?: boolean; + onBack: () => void; + onClose: () => void; + children?: React.ReactNode; +}): React.ReactElement | null { + const panelRef = useRef(null); + const returnFocusRef = useRef(null); + const wasOpenRef = useRef(false); + // Restore focus to the element that opened the drawer when it closes + // (Escape/✕/overlay), instead of dropping focus to . This effect is + // declared before the panel-focus effect so it captures the trigger + // element before the panel steals focus. + useEffect(function () { + if (props.open && !wasOpenRef.current) { + const active = typeof document !== "undefined" ? document.activeElement : null; + returnFocusRef.current = active && active !== document.body ? active : null; + } + if (!props.open && wasOpenRef.current) { + const target = returnFocusRef.current; + returnFocusRef.current = null; + if ( + target && + typeof target.focus === "function" && + document.contains(target) + ) { + try { + target.focus(); + } catch (e) { + /* focus restoration is best-effort */ + } + } + } + wasOpenRef.current = props.open; + }, [props.open]); + useEffect(function () { + if (props.open && panelRef.current && typeof panelRef.current.focus === "function") { + panelRef.current.focus(); + } + }, [props.open, props.title]); + if (!props.open) return null; + return ( +
+
+
+ {props.canBack ? ( + + ) : null} +
{props.title}
+ +
+
{props.children}
+
+
+ ); +} + +export function DrawerError(props: { kind?: string; message?: any; onRetry?: () => void }): React.ReactElement { + const label = props.kind === "node" ? "node" : (props.kind === "message" ? "message" : "session"); + return ( +
+
+ {"Couldn't load this " + label} +
+
{String(props.message || "Request failed")}
+ {props.onRetry ? ( + + ) : null} +
+ ); +} diff --git a/dashboard/lcm/src/entry.tsx b/dashboard/lcm/src/entry.tsx new file mode 100644 index 00000000..97e60ec1 --- /dev/null +++ b/dashboard/lcm/src/entry.tsx @@ -0,0 +1,20 @@ +import App from "./App"; + +interface PluginRegistry { + register: (name: string, component: unknown) => void; +} + +const registry: PluginRegistry | null = + (typeof window !== "undefined" && + (window as unknown as { __HERMES_PLUGINS__?: PluginRegistry }) + .__HERMES_PLUGINS__) || + null; + +const sdk = + typeof window !== "undefined" && + (window as unknown as { __HERMES_PLUGIN_SDK__?: unknown }) + .__HERMES_PLUGIN_SDK__; + +if (sdk && registry && typeof registry.register === "function") { + registry.register("hermes-lcm", App); +} diff --git a/dashboard/lcm/src/helpers.ts b/dashboard/lcm/src/helpers.ts new file mode 100644 index 00000000..24355147 --- /dev/null +++ b/dashboard/lcm/src/helpers.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Pure (non-React) helpers for the hermes-lcm dashboard plugin. + * + * Ported 1:1 from the original hand-written IIFE in `index.js`; behavior is + * intentionally identical. These helpers are split out only for navigability — + * the IIFE surfaced them as closed-over module locals. + */ + +const SDK: any = + (typeof window !== "undefined" && (window as any).__HERMES_PLUGIN_SDK__) || {}; + +/** Relative-time formatter exposed by the host SDK (Hermes or standalone shell). + * Null when the host doesn't provide one — `fmtTime` falls back to toLocaleString. */ +export const isoTimeAgo: ((iso: string) => string) | null = + (SDK.utils && SDK.utils.isoTimeAgo) || null; + +export const API = "/api/plugins/hermes-lcm"; + +export const SEARCH_FETCH_LIMIT = 120; +export const SEARCH_PAGE_SIZE = 6; +export const SESSION_FETCH_BATCH = 60; +export const SESSION_MESSAGE_PAGE_SIZE = 8; + +export function short(s: any, n: number): string { + const text = String(s || ""); + return text.length > n ? text.slice(0, n - 1) + "…" : text; +} + +export function fmtInt(n: any): string { + const v = Number(n) || 0; + return v.toLocaleString(); +} + +export function fmtTime(epoch: any): string { + const v = Number(epoch); + if (!v) return ""; + try { + const d = new Date(v * 1000); + if (isoTimeAgo) return isoTimeAgo(d.toISOString()); + return d.toLocaleString(); + } catch (e) { + return String(epoch); + } +} + +export function fmtAbsoluteTime(epoch: any): string { + const v = Number(epoch); + if (!v) return ""; + try { + return new Date(v * 1000).toLocaleString(); + } catch (e) { + return String(epoch); + } +} + +export function escapeRegExp(s: any): string { + return String(s || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function queryTerms(query: any): string[] { + return String(query || "") + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 8); +} + +export function parseJsonArray(value: any): any[] { + if (Array.isArray(value)) return value; + if (typeof value !== "string" || !value.trim()) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } +} + +export function copyTextValue(text: any): Promise { + const value = String(text == null ? "" : text); + if (!value) return Promise.resolve(false); + if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(value).then(function () { return true; }).catch(function () { + return false; + }); + } + return new Promise(function (resolve) { + try { + const ta = document.createElement("textarea"); + ta.value = value; + ta.setAttribute("readonly", "readonly"); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + resolve(ok); + } catch (e) { + resolve(false); + } + }); +} + +/** Humanize fetch failures: the shell SDK's fetchJSON throws (TypeError + * "Failed to fetch") when the server is unreachable. Failed fetches must + * render error UI, never zero-data UI, so this string is shown prominently. */ +export function friendlyError(err: any): string { + const raw = String((err && err.message) || err || "Request failed"); + if (/failed to fetch|networkerror|load failed|network request/i.test(raw)) { + return "Can't reach the tracedecay server"; + } + return raw; +} + +/** Append rows from a paginated follow-up fetch, de-duplicated by id, so + * server-offset pagination can never double-render a row. */ +export function mergeRows(prevRows: any, nextRows: any, idKey: string): any[] { + const seen: Record = {}; + const merged = (prevRows || []).slice(); + merged.forEach(function (row) { seen[String(row[idKey])] = true; }); + (nextRows || []).forEach(function (row) { + const key = String(row[idKey]); + if (!seen[key]) { + seen[key] = true; + merged.push(row); + } + }); + return merged; +} + +/** Merge a follow-up search page into the previous payload: scalar fields + * (totals, engine, …) come from the newest response, match rows append with + * id-dedupe. Pure so the pagination merge stays unit-testable. */ +export function mergeSearchPayload(prev: any, json: any): any { + if (!prev) return json; + const prevMatches = prev.matches || {}; + const nextMatches = (json && json.matches) || {}; + return Object.assign({}, prev, json, { + matches: { + messages: mergeRows(prevMatches.messages, nextMatches.messages, "store_id"), + summary_nodes: mergeRows(prevMatches.summary_nodes, nextMatches.summary_nodes, "node_id"), + }, + }); +} + +/** Flatten markdown to readable plain text (for compact list previews/titles). */ +export function stripMd(s: any): string { + return String(s == null ? "" : s) + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +/** Derive a short title from a summary: first heading, else first bold run, + * else the first sentence/line of the flattened text. */ +export function summaryTitle(s: any): string { + const txt = String(s == null ? "" : s); + const hd = txt.match(/^\s*#{1,6}\s+(.+?)\s*$/m); + if (hd) return stripMd(hd[1]); + const bold = txt.match(/\*\*([^*]+)\*\*/); + if (bold) return stripMd(bold[1]); + const flat = stripMd(txt); + const dot = flat.search(/[.!?](\s|$)/); + return dot > 12 && dot < 90 ? flat.slice(0, dot + 1) : flat; +} + +/** Pretty session label from an id like "20260529_011608_ab12cd". */ +export function sessionLabel(id: any): string { + const txt = String(id == null ? "" : id); + const m = txt.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/); + if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`; + return short(txt, 36); +} + +export function sessionTail(id: any): string { + const txt = String(id == null ? "" : id); + const m = txt.match(/_([0-9a-f]{4,})$/i); + return m ? m[1] : ""; +} + +/** Tool results are often a JSON value followed by a trailing human note + * (e.g. `{...}\n[use offset=120 to see more]`), so strict JSON.parse fails. + * Extract the leading {...}/[...] by brace-matching, keep the rest as a note. */ +export function parseLeadingJSON(s: any): { value: any; rest: string } | null { + if (typeof s !== "string") return null; + let i = 0; + while (i < s.length && /\s/.test(s[i])) i++; + const open = s[i]; + if (open !== "{" && open !== "[") { + try { return { value: JSON.parse(s), rest: "" }; } catch (e) { return null; } + } + const close = open === "{" ? "}" : "]"; + let depth = 0, inStr = false, esc = false, end = -1; + for (let j = i; j < s.length; j++) { + const ch = s[j]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') inStr = true; + else if (ch === open) depth++; + else if (ch === close) { depth--; if (depth === 0) { end = j + 1; break; } } + } + if (end === -1) return null; + try { return { value: JSON.parse(s.slice(i, end)), rest: s.slice(end).trim() }; } + catch (e) { return null; } +} + +export function clampText(s: any, n: number): string { + const t = String(s == null ? "" : s); + return t.length > n ? t.slice(0, n) + "\n…(" + fmtInt(t.length - n) + " more chars)" : t; +} + +export function ratioStr(src: any, out: any): string { + const s = Number(src) || 0; + const o = Number(out) || 0; + if (!o) return "—"; + return (Math.round((s / o) * 10) / 10) + "×"; +} diff --git a/dashboard/lcm/src/index.js b/dashboard/lcm/src/index.js deleted file mode 100644 index 892678c0..00000000 --- a/dashboard/lcm/src/index.js +++ /dev/null @@ -1,2020 +0,0 @@ -(function () { - "use strict"; - - const SDK = window.__HERMES_PLUGIN_SDK__; - if (!SDK) return; - - const { React } = SDK; - const { useEffect, useMemo, useState, useCallback, useRef } = SDK.hooks; - const h = React.createElement; - const isoTimeAgo = (SDK.utils && SDK.utils.isoTimeAgo) || null; - - const API = "/api/plugins/hermes-lcm"; - - function short(s, n) { - const text = String(s || ""); - return text.length > n ? text.slice(0, n - 1) + "…" : text; - } - - function fmtInt(n) { - const v = Number(n) || 0; - return v.toLocaleString(); - } - - function fmtTime(epoch) { - const v = Number(epoch); - if (!v) return ""; - try { - const d = new Date(v * 1000); - if (isoTimeAgo) return isoTimeAgo(d.toISOString()); - return d.toLocaleString(); - } catch (e) { - return String(epoch); - } - } - - function fmtAbsoluteTime(epoch) { - const v = Number(epoch); - if (!v) return ""; - try { - return new Date(v * 1000).toLocaleString(); - } catch (e) { - return String(epoch); - } - } - - function escapeRegExp(s) { - return String(s || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } - - function queryTerms(query) { - return String(query || "") - .trim() - .split(/\s+/) - .filter(Boolean) - .slice(0, 8); - } - - function renderHighlightedText(text, query) { - const raw = String(text || ""); - const terms = queryTerms(query); - if (!terms.length) return raw; - const re = new RegExp("(" + terms.map(escapeRegExp).join("|") + ")", "ig"); - const parts = raw.split(re); - return parts.map(function (part, idx) { - if (!part) return null; - return terms.some(function (term) { return part.toLowerCase() === term.toLowerCase(); }) - ? h("mark", { key: "hl" + idx, className: "hermes-lcm-mark" }, part) - : part; - }); - } - - function renderSearchSnippet(text, query) { - const raw = String(text || ""); - if (/\[[^\]]+\]/.test(raw)) return renderSnippet(raw); - return renderHighlightedText(raw, query); - } - - function parseJsonArray(value) { - if (Array.isArray(value)) return value; - if (typeof value !== "string" || !value.trim()) return []; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch (e) { - return []; - } - } - - function copyTextValue(text) { - const value = String(text == null ? "" : text); - if (!value) return Promise.resolve(false); - if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(value).then(function () { return true; }).catch(function () { - return false; - }); - } - return new Promise(function (resolve) { - try { - const ta = document.createElement("textarea"); - ta.value = value; - ta.setAttribute("readonly", "readonly"); - ta.style.position = "absolute"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(ta); - resolve(ok); - } catch (e) { - resolve(false); - } - }); - } - - // Humanize fetch failures: the shell SDK's fetchJSON throws (TypeError - // "Failed to fetch") when the server is unreachable. Failed fetches must - // render error UI, never zero-data UI, so this string is shown prominently. - function friendlyError(err) { - const raw = String((err && err.message) || err || "Request failed"); - if (/failed to fetch|networkerror|load failed|network request/i.test(raw)) { - return "Can't reach the tracedecay server"; - } - return raw; - } - - // Append rows from a paginated follow-up fetch, de-duplicated by id, so - // server-offset pagination can never double-render a row. - function mergeRows(prevRows, nextRows, idKey) { - const seen = {}; - const merged = (prevRows || []).slice(); - merged.forEach(function (row) { seen[String(row[idKey])] = true; }); - (nextRows || []).forEach(function (row) { - const key = String(row[idKey]); - if (!seen[key]) { - seen[key] = true; - merged.push(row); - } - }); - return merged; - } - - // Merge a follow-up search page into the previous payload: scalar fields - // (totals, engine, …) come from the newest response, match rows append with - // id-dedupe. Pure so the pagination merge stays unit-testable. - function mergeSearchPayload(prev, json) { - if (!prev) return json; - const prevMatches = prev.matches || {}; - const nextMatches = (json && json.matches) || {}; - return Object.assign({}, prev, json, { - matches: { - messages: mergeRows(prevMatches.messages, nextMatches.messages, "store_id"), - summary_nodes: mergeRows(prevMatches.summary_nodes, nextMatches.summary_nodes, "node_id"), - }, - }); - } - - // Flatten markdown to readable plain text (for compact list previews/titles). - function stripMd(s) { - return String(s == null ? "" : s) - .replace(/```[\s\S]*?```/g, " ") - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1") - .replace(/^#{1,6}\s+/gm, "") - .replace(/^\s*>\s?/gm, "") - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1") - .replace(/\s+/g, " ") - .trim(); - } - - // Derive a short title from a summary: first heading, else first bold run, - // else the first sentence/line of the flattened text. - function summaryTitle(s) { - const txt = String(s == null ? "" : s); - const hd = txt.match(/^\s*#{1,6}\s+(.+?)\s*$/m); - if (hd) return stripMd(hd[1]); - const bold = txt.match(/\*\*([^*]+)\*\*/); - if (bold) return stripMd(bold[1]); - const flat = stripMd(txt); - const dot = flat.search(/[.!?](\s|$)/); - return dot > 12 && dot < 90 ? flat.slice(0, dot + 1) : flat; - } - - // Pretty session label from an id like "20260529_011608_ab12cd". - function sessionLabel(id) { - const txt = String(id == null ? "" : id); - const m = txt.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/); - if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`; - return short(txt, 36); - } - - function sessionTail(id) { - const txt = String(id == null ? "" : id); - const m = txt.match(/_([0-9a-f]{4,})$/i); - return m ? m[1] : ""; - } - - // --- snippet rendering: backend wraps highlights in [ ... ] ---------------- - function renderSnippet(text) { - const raw = String(text || ""); - const parts = []; - const re = /\[([^\]]*)\]/g; - let last = 0; - let m; - let i = 0; - while ((m = re.exec(raw)) !== null) { - if (m.index > last) parts.push(raw.slice(last, m.index)); - parts.push(h("mark", { key: "mk" + i++, className: "hermes-lcm-mark" }, m[1])); - last = re.lastIndex; - } - if (last < raw.length) parts.push(raw.slice(last)); - return parts.length ? parts : raw; - } - - // --- minimal self-contained markdown -> React. XSS-safe: builds elements, - // never uses innerHTML. Underscores are left literal so snake_case and paths - // (kanban_block, auto_model_routing) are not mangled into emphasis. --------- - function mdInlineNodes(text, kp) { - const nodes = []; - const codeRe = /`([^`]+)`/g; - let last = 0, m, i = 0; - while ((m = codeRe.exec(text)) !== null) { - if (m.index > last) mdEmphasis(text.slice(last, m.index), nodes, kp + "t" + i); - nodes.push(h("code", { key: kp + "c" + i, className: "hermes-lcm-md-code" }, m[1])); - last = codeRe.lastIndex; i++; - } - if (last < text.length) mdEmphasis(text.slice(last), nodes, kp + "t" + i); - return nodes; - } - - function mdEmphasis(str, nodes, kp) { - const re = /(\*\*)([\s\S]+?)\*\*|(\*)([^*\n]+?)\*|\[([^\]]+)\]\(([^)\s]+)\)/; - let rest = str, i = 0, m; - while ((m = re.exec(rest)) !== null) { - if (m.index > 0) nodes.push(rest.slice(0, m.index)); - if (m[1]) nodes.push(h("strong", { key: kp + "b" + i }, mdInlineNodes(m[2], kp + "b" + i + "-"))); - else if (m[3]) nodes.push(h("em", { key: kp + "e" + i }, mdInlineNodes(m[4], kp + "e" + i + "-"))); - else nodes.push(h("a", { - key: kp + "a" + i, href: m[6], target: "_blank", rel: "noopener noreferrer", - className: "hermes-lcm-md-link", - }, m[5])); - rest = rest.slice(m.index + m[0].length); i++; - } - if (rest) nodes.push(rest); - } - - function mdBuildList(items, kp) { - const base = items[0].indent; - const ordered = items[0].ordered; - const children = []; - let i = 0, li = 0; - while (i < items.length) { - if (items[i].indent > base) { - const start = i; - while (i < items.length && items[i].indent > base) i++; - const nested = mdBuildList(items.slice(start), kp + "n" + li); - if (children.length) { - const prev = children[children.length - 1]; - children[children.length - 1] = h("li", { key: prev.key }, - [].concat(prev.props.children, nested)); - } else { - children.push(h("li", { key: kp + "li" + li++ }, nested)); - } - continue; - } - children.push(h("li", { key: kp + "li" + li++ }, mdInlineNodes(items[i].text, kp + "x" + li))); - i++; - } - return h(ordered ? "ol" : "ul", { key: kp, className: "hermes-lcm-md-list" }, children); - } - - function mdToReact(src) { - const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n"); - const blocks = []; - let i = 0, key = 0; - while (i < lines.length) { - const line = lines[i]; - if (/^\s*```/.test(line)) { - const buf = []; - i++; - while (i < lines.length && !/^\s*```/.test(lines[i])) { buf.push(lines[i]); i++; } - i++; - blocks.push(h("pre", { key: "p" + key++, className: "hermes-lcm-md-pre" }, - h("code", null, buf.join("\n")))); - continue; - } - if (/^\s*$/.test(line)) { i++; continue; } - const hd = line.match(/^(#{1,6})\s+(.*)$/); - if (hd) { - blocks.push(h("div", { - key: "p" + key++, - className: "hermes-lcm-md-h hermes-lcm-md-h" + hd[1].length, - }, mdInlineNodes(hd[2], "h" + key))); - i++; continue; - } - if (/^\s*>\s?/.test(line)) { - const buf = []; - while (i < lines.length && /^\s*>\s?/.test(lines[i])) { - buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; - } - blocks.push(h("blockquote", { key: "p" + key++, className: "hermes-lcm-md-quote" }, - mdInlineNodes(buf.join(" "), "q" + key))); - continue; - } - if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) { - const items = []; - while (i < lines.length && /^\s*([-*+]|\d+[.)])\s+/.test(lines[i])) { - const mm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); - items.push({ indent: mm[1].length, ordered: /\d/.test(mm[2]), text: mm[3] }); - i++; - } - blocks.push(mdBuildList(items, "l" + key++)); - continue; - } - const buf = []; - while (i < lines.length && !/^\s*$/.test(lines[i]) - && !/^\s*```/.test(lines[i]) - && !/^(#{1,6})\s+/.test(lines[i]) - && !/^\s*>\s?/.test(lines[i]) - && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])) { - buf.push(lines[i]); i++; - } - const kids = []; - buf.forEach(function (ln, idx) { - if (idx) kids.push(h("br", { key: "br" + idx })); - const sub = mdInlineNodes(ln, "p" + key + "-" + idx); - for (let s = 0; s < sub.length; s++) kids.push(sub[s]); - }); - blocks.push(h("p", { key: "p" + key++, className: "hermes-lcm-md-p" }, kids)); - } - return blocks; - } - - function MarkdownText(props) { - const text = String(props.text == null ? "" : props.text); - let nodes; - try { nodes = mdToReact(text); } catch (e) { nodes = [text]; } - return h("div", { - className: "hermes-lcm-md" + (props.className ? " " + props.className : ""), - }, nodes); - } - - function BarList(props) { - const rows = props.rows || []; - const keyName = props.keyName; - const onPick = props.onPick; - const total = rows.reduce((acc, row) => acc + (Number(row.count) || 0), 0) || 1; - if (!rows.length) return h("div", { className: "hermes-lcm-empty" }, "No data"); - return h("div", { className: "hermes-lcm-bars" }, rows.map(function (row, idx) { - const label = String(row[keyName] == null ? "(none)" : row[keyName]); - const count = Number(row.count) || 0; - const pct = Math.max(2, Math.round((count / total) * 100)); - const clickable = typeof onPick === "function"; - return h("div", { - key: label + ":" + idx, - className: "hermes-lcm-bar-row" + (clickable ? " hermes-lcm-clk" : ""), - onClick: clickable ? function () { onPick(label); } : undefined, - }, [ - h("div", { className: "hermes-lcm-bar-head" }, [ - h("span", { className: "hermes-lcm-k" }, label), - h("span", { className: "hermes-lcm-v" }, fmtInt(count)), - ]), - h("div", { className: "hermes-lcm-bar-track" }, [ - h("div", { className: "hermes-lcm-bar-fill", style: { width: pct + "%" } }), - ]), - ]); - })); - } - - // --- inline SVG: message-volume timeline ---------------------------------- - // Responsive CSS bar chart (no SVG stretching, so bars stay crisp and the - // summary markers render as true round dots regardless of bucket count). - function TimelineChart(props) { - // Buckets cover only messages with real timestamps; the server reports - // messages without one separately so they surface as an honest note - // instead of a fake single bar. - const buckets = (props.buckets || []).filter(function (b) { return b && b.bucket != null; }); - const nodeBuckets = props.nodeBuckets || []; - const undatedCount = Number(props.undatedCount) || 0; - if (!buckets.length) { - return h("div", { className: "hermes-lcm-empty" }, undatedCount > 0 - ? `No dated messages yet — ${fmtInt(undatedCount)} stored messages have no timestamp` - : "No timeline data"); - } - const maxCount = buckets.reduce((acc, b) => Math.max(acc, Number(b.count) || 0), 0) || 1; - const nodeByBucket = {}; - nodeBuckets.forEach(function (nb) { nodeByBucket[nb.bucket] = Number(nb.count) || 0; }); - - const cols = buckets.map(function (b, i) { - const count = Number(b.count) || 0; - const pct = count > 0 ? Math.max(3, Math.round((count / maxCount) * 100)) : 0; - const nodes = nodeByBucket[b.bucket] || 0; - const tip = `${b.bucket}: ${fmtInt(count)} messages` - + (nodes ? ` · ${fmtInt(nodes)} summaries` : ""); - return h("div", { key: b.bucket + i, className: "hermes-lcm-tl-col", title: tip }, [ - h("div", { className: "hermes-lcm-tl-dot" + (nodes ? " hermes-lcm-tl-dot-on" : "") }), - h("div", { className: "hermes-lcm-tl-bar", style: { height: pct + "%" } }), - ]); - }); - - return h("div", { className: "hermes-lcm-tl" }, [ - h("div", { className: "hermes-lcm-tl-bars" }, cols), - h("div", { className: "hermes-lcm-svg-axis" }, [ - h("span", null, short(buckets[0].bucket, 16)), - h("span", null, short(buckets[buckets.length - 1].bucket, 16)), - ]), - undatedCount > 0 ? h("div", { className: "hermes-lcm-dim hermes-lcm-tl-undated" }, - `${fmtInt(undatedCount)} undated messages not shown`) : null, - ]); - } - - // --- inline SVG: per-group compression (kept vs saved) -------------------- - function CompressionBars(props) { - const groups = props.groups || []; - const onPick = props.onPick; - if (!groups.length) return h("div", { className: "hermes-lcm-empty" }, "No compression data"); - const maxSrc = groups.reduce((acc, g) => Math.max(acc, Number(g.source_token_count) || 0), 0) || 1; - return h("div", { className: "hermes-lcm-comp" }, groups.map(function (g, idx) { - const src = Number(g.source_token_count) || 0; - const out = Number(g.token_count) || 0; - const totalW = Math.max(0.5, (src / maxSrc) * 100); - const keptW = src > 0 ? (out / src) * totalW : 0; - const sid = g.session_id != null ? g.session_id : g.key; - const label = (typeof sid === "string" && /^\d{8}_/.test(sid)) - ? sessionLabel(sid) - : (g.depth != null ? `node #${g.key} (D${g.depth})` : String(g.key)); - const clickable = typeof onPick === "function"; - return h("div", { - key: String(g.key) + idx, - className: "hermes-lcm-comp-row" + (clickable ? " hermes-lcm-clk" : ""), - onClick: clickable ? function () { onPick(g); } : undefined, - }, [ - h("div", { className: "hermes-lcm-comp-head" }, [ - h("span", { className: "hermes-lcm-k" }, label), - h("span", { className: "hermes-lcm-v" }, `${g.ratio || 0}× · ${fmtInt(src)}→${fmtInt(out)}`), - ]), - h("svg", { - viewBox: "0 0 100 8", preserveAspectRatio: "none", - width: "100%", height: 8, className: "hermes-lcm-svgbar", - }, [ - h("rect", { x: 0, y: 0, width: totalW, height: 8, rx: 1.5, className: "hermes-lcm-svg-saved" }), - h("rect", { x: 0, y: 0, width: keptW, height: 8, rx: 1.5, className: "hermes-lcm-svg-kept" }), - ]), - ]); - })); - } - - function Stat(props) { - return h("div", { className: "hermes-lcm-stat" }, [ - h("div", { className: "hermes-lcm-stat-v" }, props.value), - h("div", { className: "hermes-lcm-stat-k" }, props.label), - ]); - } - - // --- pretty tool-result rendering. Known tools get bespoke components; any - // other JSON falls back to a clean key/value view; non-JSON to markdown. ---- - // Tool results are often a JSON value followed by a trailing human note - // (e.g. `{...}\n[use offset=120 to see more]`), so strict JSON.parse fails. - // Extract the leading {...}/[...] by brace-matching, keep the rest as a note. - function parseLeadingJSON(s) { - if (typeof s !== "string") return null; - let i = 0; - while (i < s.length && /\s/.test(s[i])) i++; - const open = s[i]; - if (open !== "{" && open !== "[") { - try { return { value: JSON.parse(s), rest: "" }; } catch (e) { return null; } - } - const close = open === "{" ? "}" : "]"; - let depth = 0, inStr = false, esc = false, end = -1; - for (let j = i; j < s.length; j++) { - const ch = s[j]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') inStr = true; - else if (ch === open) depth++; - else if (ch === close) { depth--; if (depth === 0) { end = j + 1; break; } } - } - if (end === -1) return null; - try { return { value: JSON.parse(s.slice(i, end)), rest: s.slice(end).trim() }; } - catch (e) { return null; } - } - - function clampText(s, n) { - const t = String(s == null ? "" : s); - return t.length > n ? t.slice(0, n) + "\n…(" + fmtInt(t.length - n) + " more chars)" : t; - } - - function codeBlock(text) { - return h("pre", { className: "hermes-lcm-md-pre" }, h("code", null, clampText(text, 4000))); - } - - function toolBadge(label, kind) { - return h("span", { className: "hermes-lcm-tag" + (kind ? " hermes-lcm-tag-" + kind : "") }, label); - } - - function ToolOutput(d) { - const out = d.output != null - ? d.output - : (typeof d.result === "string" ? d.result - : (d.result != null ? JSON.stringify(d.result, null, 2) : "")); - const code = d.exit_code != null ? d.exit_code : d.status; - const ok = d.exit_code === 0 || d.status === "success" || d.status === "exited" || d.success === true; - return h("div", { className: "hermes-lcm-tool" }, [ - (code != null || d.duration_seconds != null) ? h("div", { className: "hermes-lcm-tool-meta" }, [ - code != null ? toolBadge((d.exit_code != null ? "exit " : "") + code, ok ? "ok" : "bad") : null, - d.duration_seconds != null ? h("span", { className: "hermes-lcm-dim" }, d.duration_seconds + "s") : null, - ]) : null, - out ? codeBlock(out) : null, - d.error ? h("div", { className: "hermes-lcm-tool-err" }, String(d.error)) : null, - d.timeout_note ? h("div", { className: "hermes-lcm-tool-err" }, String(d.timeout_note)) : null, - ]); - } - - function ToolReadFile(d) { - return h("div", { className: "hermes-lcm-tool" }, [ - h("div", { className: "hermes-lcm-tool-meta" }, [ - d.total_lines != null ? toolBadge(fmtInt(d.total_lines) + " lines") : null, - d.file_size != null ? toolBadge(fmtInt(d.file_size) + " B") : null, - d.truncated ? toolBadge("truncated", "warn") : null, - d.is_image ? toolBadge("image") : null, - ]), - d.content ? codeBlock(d.content) : null, - (d.hint || d._hint) ? h("div", { className: "hermes-lcm-dim" }, String(d.hint || d._hint)) : null, - ]); - } - - function ToolSearchFiles(d) { - const matches = d.matches || []; - return h("div", { className: "hermes-lcm-tool" }, [ - h("div", { className: "hermes-lcm-tool-meta" }, [ - toolBadge(fmtInt(d.total_count != null ? d.total_count : matches.length) + " matches"), - ]), - h("div", { className: "hermes-lcm-tool-matches" }, matches.slice(0, 50).map(function (mm, i) { - return h("div", { key: i, className: "hermes-lcm-tool-match" }, [ - h("div", { className: "hermes-lcm-tool-match-loc" }, [ - h("span", { className: "hermes-lcm-tool-path" }, short(String(mm.path || ""), 72)), - mm.line != null ? h("span", { className: "hermes-lcm-dim" }, ":" + mm.line) : null, - ]), - mm.content != null - ? h("code", { className: "hermes-lcm-tool-match-code" }, short(String(mm.content), 220)) - : null, - ]); - })), - matches.length > 50 ? h("div", { className: "hermes-lcm-dim" }, "+" + fmtInt(matches.length - 50) + " more") : null, - ]); - } - - const TODO_ICON = { completed: "✓", in_progress: "◐", pending: "○", cancelled: "✗" }; - function ToolTodo(d) { - const todos = d.todos || []; - const s = d.summary || {}; - return h("div", { className: "hermes-lcm-tool" }, [ - h("div", { className: "hermes-lcm-tool-meta" }, [ - s.completed != null ? toolBadge(s.completed + " done", "ok") : null, - s.in_progress != null ? toolBadge(s.in_progress + " active") : null, - s.pending != null ? toolBadge(s.pending + " todo") : null, - s.cancelled ? toolBadge(s.cancelled + " cancelled") : null, - ]), - h("ul", { className: "hermes-lcm-todo" }, todos.map(function (t, i) { - const st = String(t.status || "pending"); - return h("li", { key: t.id || i, className: "hermes-lcm-todo-item hermes-lcm-todo-" + st }, [ - h("span", { className: "hermes-lcm-todo-ic" }, TODO_ICON[st] || "•"), - h("span", null, String(t.content || "")), - ]); - })), - ]); - } - - function ToolPatch(d, raw) { - let diff = d && d.diff; - if (diff == null && typeof raw === "string") { - const mm = raw.match(/"diff"\s*:\s*"([\s\S]*?)"\s*\}?\s*$/); - diff = mm ? mm[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\t/g, "\t") : raw; - } - const lines = String(diff || "").split("\n"); - return h("div", { className: "hermes-lcm-tool" }, [ - (d && d.success != null) ? h("div", { className: "hermes-lcm-tool-meta" }, [ - toolBadge(d.success ? "applied" : "failed", d.success ? "ok" : "bad"), - ]) : null, - h("pre", { className: "hermes-lcm-md-pre hermes-lcm-diff" }, lines.slice(0, 240).map(function (ln, i) { - const c0 = ln.charAt(0); - const cls = c0 === "+" ? "hermes-lcm-diff-add" - : c0 === "-" ? "hermes-lcm-diff-del" - : c0 === "@" ? "hermes-lcm-diff-hunk" : ""; - return h("div", { key: i, className: cls }, ln || " "); - })), - ]); - } - - function ToolSkill(d) { - const tags = d.tags || []; - return h("div", { className: "hermes-lcm-tool" }, [ - h("div", { className: "hermes-lcm-tool-meta" }, [ - d.name ? toolBadge(d.name) : null, - tags.slice(0, 6).map(function (t, i) { return h("span", { key: i, className: "hermes-lcm-dim" }, "#" + t); }), - ]), - d.description ? h("div", { className: "hermes-lcm-dim" }, String(d.description)) : null, - d.content ? h(MarkdownText, { text: clampText(d.content, 4000) }) : null, - ]); - } - - function ToolGeneric(d) { - if (Array.isArray(d)) return codeBlock(JSON.stringify(d, null, 2)); - return h("div", { className: "hermes-lcm-kv" }, Object.keys(d).map(function (k, i) { - const v = d[k]; - let vn; - if (v == null) vn = h("span", { className: "hermes-lcm-dim" }, "null"); - else if (typeof v === "object") { - vn = h("pre", { className: "hermes-lcm-md-pre" }, - h("code", null, clampText(JSON.stringify(v, null, 2), 1500))); - } else vn = h("span", null, String(v)); - return h("div", { key: k + i, className: "hermes-lcm-kv-row" }, [ - h("span", { className: "hermes-lcm-kv-k" }, k), - h("span", { className: "hermes-lcm-kv-v" }, vn), - ]); - })); - } - - function ToolResult(props) { - const name = String(props.name || ""); - const raw = props.content; - const parsed = parseLeadingJSON(raw); - const data = parsed ? parsed.value : undefined; - const note = (parsed && parsed.rest) ? parsed.rest : ""; - let body = null; - try { - if ((name === "terminal" || name === "process" || name === "execute_code" || name === "shell") - && data && typeof data === "object") body = ToolOutput(data); - else if (name === "read_file" && data) body = ToolReadFile(data); - else if (name === "search_files" && data) body = ToolSearchFiles(data); - else if (name === "todo" && data) body = ToolTodo(data); - else if (name === "patch") body = ToolPatch(data, raw); - else if (name === "skill_view" && data) body = ToolSkill(data); - else if (data && typeof data === "object") body = ToolGeneric(data); - } catch (e) { body = null; } - if (body == null) { - return h(MarkdownText, { className: "hermes-lcm-msg-body", text: short(String(raw == null ? "" : raw), 4000) }); - } - if (note) { - return h("div", { className: "hermes-lcm-tool" }, [ - body, - h("div", { className: "hermes-lcm-dim hermes-lcm-tool-note" }, short(note, 400)), - ]); - } - return body; - } - - // --- detail drawer (session / node / message) ----------------------------- - const SEARCH_FETCH_LIMIT = 120; - const SEARCH_PAGE_SIZE = 6; - const SESSION_FETCH_BATCH = 60; - const SESSION_MESSAGE_PAGE_SIZE = 8; - - function TimeText(props) { - if (!props.epoch) return h("span", { className: props.className || "hermes-lcm-dim" }, "—"); - const absolute = fmtAbsoluteTime(props.epoch); - let dateTime = ""; - try { - dateTime = new Date(Number(props.epoch) * 1000).toISOString(); - } catch (e) {} - return h("time", { - className: props.className || "hermes-lcm-dim", - title: absolute, - dateTime: dateTime || undefined, - }, fmtTime(props.epoch)); - } - - function CopyButton(props) { - const [status, setStatus] = useState("idle"); - const onCopy = useCallback(function (e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - copyTextValue(props.text).then(function (ok) { - setStatus(ok ? "copied" : "failed"); - setTimeout(function () { setStatus("idle"); }, 1400); - }); - }, [props.text]); - const label = status === "copied" ? "Copied" : (status === "failed" ? "Retry copy" : (props.label || "Copy")); - return h("button", { - type: "button", - className: "hermes-lcm-btn hermes-lcm-copy" + (status === "copied" ? " is-copied" : ""), - onClick: onCopy, - title: props.title || "Copy to clipboard", - }, label); - } - - function SkeletonLines(props) { - const count = props.count || 3; - const lines = []; - for (let i = 0; i < count; i++) { - lines.push(h("div", { - key: "sk" + i, - className: "hermes-lcm-skel-line", - style: props.widths && props.widths[i] ? { width: props.widths[i] } : undefined, - })); - } - return h("div", { className: "hermes-lcm-skeleton-block" }, lines); - } - - function Pager(props) { - if (!props.totalPages || props.totalPages <= 1) return null; - return h("div", { className: "hermes-lcm-pager" }, [ - h("button", { - type: "button", - className: "hermes-lcm-btn", - disabled: props.page <= 1, - onClick: function () { props.onChange(props.page - 1); }, - }, "Prev"), - h("span", { className: "hermes-lcm-pager-status" }, `Page ${props.page} / ${props.totalPages}`), - h("button", { - type: "button", - className: "hermes-lcm-btn", - disabled: props.page >= props.totalPages, - onClick: function () { props.onChange(props.page + 1); }, - }, "Next"), - ]); - } - - function SearchResultCard(props) { - const item = props.item; - const isMessage = props.kind === "message"; - const title = isMessage - ? short(item.session_id || "", 42) - : short(summaryTitle(item.summary || ""), 90); - const preview = isMessage - ? renderSearchSnippet(item.snippet || short(item.content, 240), props.query) - : renderSearchSnippet(item.snippet || short(stripMd(item.summary), 240), props.query); - const keyValue = props.kind + ":" + String(isMessage ? item.store_id : item.node_id); - return h("button", { - type: "button", - ref: props.resultRef, - className: "hermes-lcm-result hermes-lcm-result-btn" + (props.selected ? " hermes-lcm-selected" : ""), - onClick: props.onOpen, - onFocus: props.onFocus, - onMouseEnter: props.onFocus, - }, [ - h("div", { className: "hermes-lcm-row-meta" }, [ - h("span", { className: "hermes-lcm-pill hermes-lcm-pill-accent" }, - isMessage ? (item.role || "message") : ("D" + item.depth)), - !isMessage && item.category ? h("span", { className: "hermes-lcm-pill" }, item.category) : null, - isMessage && item.source ? h("span", { className: "hermes-lcm-pill" }, item.source) : null, - isMessage && item.tool_name ? h("span", { className: "hermes-lcm-pill" }, short(item.tool_name, 24)) : null, - h("span", { className: "hermes-lcm-dim" }, keyValue), - isMessage - ? h(TimeText, { className: "hermes-lcm-dim", epoch: item.timestamp }) - : h(TimeText, { className: "hermes-lcm-dim", epoch: item.latest_at || item.created_at }), - ]), - h("div", { className: "hermes-lcm-row-title" }, title), - h("div", { className: "hermes-lcm-msg-body" }, preview), - h("div", { className: "hermes-lcm-result-foot" }, [ - h("span", { className: "hermes-lcm-dim" }, - isMessage - ? `${fmtInt(item.token_estimate)} tok · ${sessionLabel(item.session_id)}` - : `${fmtInt(item.source_token_count)}→${fmtInt(item.token_count)} tok · ${sessionLabel(item.session_id)}`), - h("span", { className: "hermes-lcm-dim" }, isMessage ? "Open full message" : "Open source links"), - ]), - ]); - } - - function MessageItem(props) { - const m = props.m; - const clickable = typeof props.onOpenMessage === "function"; - let body; - if (props.previewText) { - body = h("div", { className: "hermes-lcm-msg-body" }, renderSearchSnippet(props.previewText, props.query)); - } else if (m.role === "tool") { - body = h(ToolResult, { name: m.tool_name, content: m.content }); - } else { - body = h(MarkdownText, { - className: "hermes-lcm-msg-body", - text: props.compact ? short(m.content, 900) : String(m.content || ""), - }); - } - return h("div", { - className: "hermes-lcm-msg" + (clickable ? " hermes-lcm-clk" : "") + (props.active ? " hermes-lcm-selected" : ""), - onClick: clickable ? function () { props.onOpenMessage(m); } : undefined, - }, [ - h("div", { className: "hermes-lcm-msg-head" }, [ - h("div", { className: "hermes-lcm-msg-meta" }, [ - h("span", { className: "hermes-lcm-tag" }, m.role || "?"), - m.source ? h("span", { className: "hermes-lcm-tag hermes-lcm-tag-src" }, m.source) : null, - m.tool_name ? h("span", { className: "hermes-lcm-tag" }, m.tool_name) : null, - m.pinned ? h("span", { className: "hermes-lcm-tag" }, "pinned") : null, - m.store_id != null ? h("span", { className: "hermes-lcm-dim" }, "#" + m.store_id) : null, - h(TimeText, { className: "hermes-lcm-dim", epoch: m.timestamp }), - m.token_estimate ? h("span", { className: "hermes-lcm-dim" }, `${fmtInt(m.token_estimate)} tok`) : null, - ]), - h("div", { className: "hermes-lcm-msg-actions" }, [ - clickable ? h("button", { - type: "button", - className: "hermes-lcm-btn", - "aria-label": "Open message #" + m.store_id, - onClick: function (e) { e.stopPropagation(); props.onOpenMessage(m); }, - }, "Open") : null, - h(CopyButton, { text: m.content, label: "Copy", title: "Copy message content" }), - ]), - ]), - body, - ]); - } - - function NodeRef(props) { - const n = props.n; - const onOpen = props.onOpen; - return h("button", { - type: "button", - className: "hermes-lcm-noderef hermes-lcm-clk" + (props.active ? " hermes-lcm-selected" : ""), - onClick: function () { onOpen(n.node_id); }, - }, [ - h("div", { className: "hermes-lcm-msg-meta" }, [ - h("span", { className: "hermes-lcm-tag" }, `D${n.depth}`), - n.category ? h("span", { className: "hermes-lcm-tag" }, n.category) : null, - h("span", { className: "hermes-lcm-dim" }, `#${n.node_id}`), - n.source_type ? h("span", { className: "hermes-lcm-dim" }, n.source_type) : null, - (n.token_count != null) ? h("span", { className: "hermes-lcm-dim" }, `${fmtInt(n.token_count)} tok`) : null, - h(TimeText, { className: "hermes-lcm-dim", epoch: n.latest_at || n.created_at }), - ]), - h("div", { className: "hermes-lcm-msg-body" }, short(stripMd(n.summary), 320)), - ]); - } - - function MessageDetail(props) { - const d = props.data || {}; - const message = d.message; - const session = d.session; - if (!message) return h("div", { className: "hermes-lcm-empty" }, "Message not found"); - const sessionNodes = (session && session.summary_nodes) || []; - // Prefer the backend's exact message→summary linkage (summary_node_ids, - // additive field) and fall back to same-session summaries when absent. - const linkedIds = parseJsonArray(message.summary_node_ids).map(String); - const linkedSet = {}; - linkedIds.forEach(function (id) { linkedSet[id] = true; }); - const linkedNodes = linkedIds.length - ? sessionNodes.filter(function (node) { return linkedSet[String(node.node_id)]; }) - : []; - const hasExactLinks = linkedIds.length > 0; - const relatedNodes = hasExactLinks ? linkedNodes : sessionNodes; - const unresolvedLinkIds = hasExactLinks - ? linkedIds.filter(function (id) { - return !relatedNodes.some(function (node) { return String(node.node_id) === id; }); - }) - : []; - return h("div", { className: "hermes-lcm-detail" }, [ - h("div", { className: "hermes-lcm-detail-meta" }, [ - h("span", { className: "hermes-lcm-tag" }, message.role || "message"), - message.source ? h("span", { className: "hermes-lcm-tag hermes-lcm-tag-src" }, message.source) : null, - h("span", { className: "hermes-lcm-tag" }, `#${message.store_id}`), - h(TimeText, { className: "hermes-lcm-dim", epoch: message.timestamp }), - message.token_estimate ? h("span", { className: "hermes-lcm-dim" }, `${fmtInt(message.token_estimate)} tok`) : null, - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { props.onOpenSession(message.session_id, { activeMessageId: message.store_id }); }, - }, sessionLabel(message.session_id)), - ]), - h("div", { className: "hermes-lcm-msg-actions" }, [ - h(CopyButton, { text: message.content, label: "Copy message", title: "Copy full message content" }), - ]), - h("div", { className: "hermes-lcm-detail-grid" }, [ - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Session"), - h("div", { className: "hermes-lcm-detail-v" }, short(message.session_id, 48)), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Absolute time"), - h("div", { className: "hermes-lcm-detail-v" }, fmtAbsoluteTime(message.timestamp) || "—"), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Token estimate"), - h("div", { className: "hermes-lcm-detail-v" }, fmtInt(message.token_estimate || 0)), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, hasExactLinks ? "Linked summaries" : "Related summaries"), - h("div", { className: "hermes-lcm-detail-v" }, - fmtInt(hasExactLinks ? linkedIds.length : relatedNodes.length)), - ]), - ]), - h("h4", null, "Content"), - h(MessageItem, { m: message }), - h("h4", null, hasExactLinks - ? `Summaries built from this message (${linkedIds.length})` - : `Summaries in this session (${relatedNodes.length})`), - relatedNodes.length - ? h("div", { className: "hermes-lcm-stream" }, relatedNodes.map(function (node) { - return h(NodeRef, { - key: node.node_id, - n: node, - onOpen: props.onOpenNode, - }); - })) - : null, - unresolvedLinkIds.length ? h("div", { className: "hermes-lcm-stream" }, unresolvedLinkIds.map(function (id) { - return h("button", { - key: "link:" + id, - type: "button", - className: "hermes-lcm-noderef hermes-lcm-clk", - onClick: function () { props.onOpenNode(id); }, - }, [ - h("div", { className: "hermes-lcm-msg-meta" }, [ - h("span", { className: "hermes-lcm-tag" }, "summary"), - h("span", { className: "hermes-lcm-dim" }, "#" + id), - ]), - h("div", { className: "hermes-lcm-msg-body" }, "Open linked summary node"), - ]); - })) : null, - (!relatedNodes.length && !unresolvedLinkIds.length) - ? h("div", { className: "hermes-lcm-empty" }, - "No summary nodes reference this message yet.") - : null, - ]); - } - - function NodeDetail(props) { - const d = props.data || {}; - const node = d.node; - const onOpenNode = props.onOpenNode; - const onOpenSession = props.onOpenSession; - const onOpenMessage = props.onOpenMessage; - if (!node) return h("div", { className: "hermes-lcm-empty" }, "Node not found"); - const sources = d.sources || {}; - const tags = parseJsonArray(node.tags); - const entities = parseJsonArray(node.entities); - return h("div", { className: "hermes-lcm-detail" }, [ - h("div", { className: "hermes-lcm-detail-meta" }, [ - h("span", { className: "hermes-lcm-tag" }, `Depth ${node.depth}`), - node.category ? h("span", { className: "hermes-lcm-tag" }, node.category) : null, - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { onOpenSession(node.session_id); }, - }, sessionLabel(node.session_id)), - h("span", { className: "hermes-lcm-dim" }, `${fmtInt(node.source_token_count)}→${fmtInt(node.token_count)} tok`), - h(TimeText, { className: "hermes-lcm-dim", epoch: node.latest_at || node.created_at }), - ]), - h("div", { className: "hermes-lcm-detail-grid" }, [ - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Node id"), - h("div", { className: "hermes-lcm-detail-v" }, String(node.node_id)), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Source type"), - h("div", { className: "hermes-lcm-detail-v" }, sources.type || node.source_type || "—"), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Tags"), - h("div", { className: "hermes-lcm-detail-v" }, tags.length ? tags.join(", ") : "—"), - ]), - h("div", { className: "hermes-lcm-detail-cell" }, [ - h("div", { className: "hermes-lcm-detail-k" }, "Entities"), - h("div", { className: "hermes-lcm-detail-v" }, entities.length ? entities.join(", ") : "—"), - ]), - ]), - h("h4", null, "Summary"), - h(MarkdownText, { className: "hermes-lcm-summary", text: node.summary }), - node.expand_hint ? h("div", { className: "hermes-lcm-hint" }, [ - h("strong", null, "Expand hint: "), node.expand_hint, - ]) : null, - h("h4", null, `Source links (${sources.type || "?"}, ${(sources.ids || []).length})`), - (function () { - const isNodes = sources.type === "nodes"; - const items = isNodes ? (sources.nodes || []) : (sources.messages || []); - if (!items.length) { - return h("div", { className: "hermes-lcm-empty" }, - (sources.ids || []).length - ? "Source items are no longer in the database." - : "This summary records no source items."); - } - return h("div", { className: "hermes-lcm-stream" }, items.map(function (it) { - return isNodes - ? h(NodeRef, { key: it.node_id, n: it, onOpen: onOpenNode }) - : h(MessageItem, { key: it.store_id, m: it, onOpenMessage: onOpenMessage }); - })); - })(), - ]); - } - - function SessionDetail(props) { - const d = props.data || {}; - const onOpenNode = props.onOpenNode; - const onOpenMessage = props.onOpenMessage; - const c = d.counts || {}; - const [page, setPage] = useState(1); - useEffect(function () { - setPage(1); - }, [d.session_id]); - const loadedMessages = d.messages || []; - const shownCount = Math.min(loadedMessages.length, page * SESSION_MESSAGE_PAGE_SIZE); - const visibleMessages = loadedMessages.slice(0, shownCount); - return h("div", { className: "hermes-lcm-detail" }, [ - h("div", { className: "hermes-lcm-statrow" }, [ - h(Stat, { value: fmtInt(c.message_count), label: "messages" }), - h(Stat, { value: fmtInt(c.summary_node_count), label: "summaries" }), - h(Stat, { value: fmtInt(c.token_estimate_total), label: "msg tokens" }), - h(Stat, { - value: ratioStr(c.source_token_count, c.summary_token_count), - label: "compression", - }), - ]), - (d.summary_nodes && d.summary_nodes.length) ? h("div", null, [ - h("h4", null, `Summary nodes (${d.summary_nodes.length})`), - h("div", { className: "hermes-lcm-stream" }, d.summary_nodes.map(function (n) { - return h(NodeRef, { key: n.node_id, n: n, onOpen: onOpenNode }); - })), - ]) : null, - h("div", { className: "hermes-lcm-section-head" }, [ - h("h4", null, `Messages (${fmtInt(c.message_count || loadedMessages.length)})`), - h("div", { className: "hermes-lcm-dim" }, - `Showing ${fmtInt(visibleMessages.length)} of ${fmtInt(c.message_count || loadedMessages.length)}`), - ]), - h("div", { className: "hermes-lcm-stream" }, visibleMessages.map(function (m) { - return h(MessageItem, { - key: m.store_id, - m: m, - onOpenMessage: onOpenMessage, - active: props.activeMessageId != null && Number(props.activeMessageId) === Number(m.store_id), - }); - })), - h("div", { className: "hermes-lcm-actions" }, [ - shownCount < loadedMessages.length ? h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setPage(page + 1); }, - }, `Show next ${SESSION_MESSAGE_PAGE_SIZE}`) : null, - shownCount >= loadedMessages.length && d.has_more ? h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: props.onLoadMore, - disabled: props.loadingMore, - }, props.loadingMore ? "Loading more…" : `Load ${SESSION_FETCH_BATCH} more`) : null, - ]), - ]); - } - - function ratioStr(src, out) { - const s = Number(src) || 0; - const o = Number(out) || 0; - if (!o) return "—"; - return (Math.round((s / o) * 10) / 10) + "×"; - } - - function Drawer(props) { - const panelRef = useRef(null); - const returnFocusRef = useRef(null); - const wasOpenRef = useRef(false); - // Restore focus to the element that opened the drawer when it closes - // (Escape/✕/overlay), instead of dropping focus to . This effect is - // declared before the panel-focus effect so it captures the trigger - // element before the panel steals focus. - useEffect(function () { - if (props.open && !wasOpenRef.current) { - const active = typeof document !== "undefined" ? document.activeElement : null; - returnFocusRef.current = active && active !== document.body ? active : null; - } - if (!props.open && wasOpenRef.current) { - const target = returnFocusRef.current; - returnFocusRef.current = null; - if ( - target && - typeof target.focus === "function" && - document.contains(target) - ) { - try { - target.focus(); - } catch (e) { - /* focus restoration is best-effort */ - } - } - } - wasOpenRef.current = props.open; - }, [props.open]); - useEffect(function () { - if (props.open && panelRef.current && typeof panelRef.current.focus === "function") { - panelRef.current.focus(); - } - }, [props.open, props.title]); - if (!props.open) return null; - return h("div", { className: "hermes-lcm-drawer-overlay", onClick: props.onClose }, [ - h("div", { - ref: panelRef, - className: "hermes-lcm-drawer", - role: "dialog", - "aria-modal": "true", - "aria-label": props.title || "Detail", - tabIndex: -1, - onClick: function (e) { e.stopPropagation(); }, - }, [ - h("div", { className: "hermes-lcm-drawer-head" }, [ - props.canBack ? h("button", { - className: "hermes-lcm-btn", onClick: props.onBack, "aria-label": "Back to previous detail", - }, "← Back") : null, - h("div", { className: "hermes-lcm-drawer-title" }, props.title), - h("button", { - className: "hermes-lcm-btn", onClick: props.onClose, "aria-label": "Close detail (Escape)", - }, "✕"), - ]), - h("div", { className: "hermes-lcm-drawer-body" }, props.children), - ]), - ]); - } - - function DrawerError(props) { - const label = props.kind === "node" ? "node" : (props.kind === "message" ? "message" : "session"); - return h("div", { className: "hermes-lcm-derror" }, [ - h("div", { className: "hermes-lcm-derror-title" }, - "Couldn't load this " + label), - h("div", { className: "hermes-lcm-derror-msg" }, String(props.message || "Request failed")), - props.onRetry ? h("button", { - className: "hermes-lcm-btn hermes-lcm-derror-retry", - onClick: props.onRetry, - }, "↻ Retry") : null, - ]); - } - - function App() { - const [q, setQ] = useState(""); - const [debouncedQ, setDebouncedQ] = useState(""); - const [role, setRole] = useState(""); - const [source, setSource] = useState(""); - const [data, setData] = useState(null); - const [overviewLoading, setOverviewLoading] = useState(false); - const [chartsLoading, setChartsLoading] = useState(false); - const [overviewError, setOverviewError] = useState(""); - const [reloadToken, setReloadToken] = useState(0); - - const [searchData, setSearchData] = useState(null); - const [searching, setSearching] = useState(false); - const [searchError, setSearchError] = useState(""); - const [searchRetryToken, setSearchRetryToken] = useState(0); - const [loadingMoreResults, setLoadingMoreResults] = useState(false); - const [searchMessagePage, setSearchMessagePage] = useState(1); - const [searchNodePage, setSearchNodePage] = useState(1); - const [selectedResultIndex, setSelectedResultIndex] = useState(-1); - - const [timeline, setTimeline] = useState(null); - const [compression, setCompression] = useState(null); - const [chartsError, setChartsError] = useState(""); - - const [stack, setStack] = useState([]); - const rootRef = useRef(null); - const searchInputRef = useRef(null); - const resultRefs = useRef({}); - const searchOffsetRef = useRef(0); - // Bumped whenever the search inputs change; in-flight pagination fetches - // from an older query compare against it and drop their stale responses. - const searchSeqRef = useRef(0); - - useEffect(function () { - const handle = setTimeout(function () { - setDebouncedQ(String(q || "").trim()); - }, 260); - return function () { clearTimeout(handle); }; - }, [q]); - - useEffect(function () { - let active = true; - setOverviewLoading(true); - setOverviewError(""); - SDK.fetchJSON(`${API}/overview?limit=25`).then(function (json) { - if (active) { - setData(json); - setOverviewError(""); - } - }).catch(function (err) { - // Failed fetch ≠ empty database: keep `data` as-is (null or stale) and - // surface the error so the UI never renders zeros for an outage. - if (active) setOverviewError(friendlyError(err)); - }).finally(function () { - if (active) setOverviewLoading(false); - }); - return function () { active = false; }; - }, [reloadToken]); - - useEffect(function () { - let active = true; - setChartsLoading(true); - setChartsError(""); - Promise.allSettled([ - SDK.fetchJSON(`${API}/timeline?bucket=day&limit=400`), - SDK.fetchJSON(`${API}/compression?by=session&limit=12`), - ]).then(function (results) { - if (!active) return; - // A rejected chart fetch leaves the previous value (or null) in place - // instead of substituting empty datasets that read as "no data". - if (results[0].status === "fulfilled") setTimeline(results[0].value); - if (results[1].status === "fulfilled") setCompression(results[1].value); - const failure = results[0].status === "rejected" - ? results[0].reason - : (results[1].status === "rejected" ? results[1].reason : null); - setChartsError(failure ? friendlyError(failure) : ""); - setChartsLoading(false); - }); - return function () { active = false; }; - }, [reloadToken]); - - useEffect(function () { - searchSeqRef.current += 1; - setSearchMessagePage(1); - setSearchNodePage(1); - setSelectedResultIndex(-1); - if (!debouncedQ) { - setSearchData(null); - setSearchError(""); - return; - } - let active = true; - setSearching(true); - setSearchError(""); - searchOffsetRef.current = 0; - const params = new URLSearchParams(); - params.set("q", debouncedQ); - params.set("limit", String(SEARCH_FETCH_LIMIT)); - if (role) params.set("role", role); - if (source) params.set("source", source); - SDK.fetchJSON(`${API}/search?${params.toString()}`).then(function (json) { - if (active) setSearchData(json); - }).catch(function (err) { - // Keep error and result state mutually exclusive: a failed search must - // not fall through to the "No matches found" empty state. - if (active) { - setSearchData(null); - setSearchError(friendlyError(err)); - } - }).finally(function () { - if (active) setSearching(false); - }); - return function () { active = false; }; - }, [debouncedQ, role, source, searchRetryToken]); - - // Server-offset pagination (additive backend field `total` + `offset`): - // pulls the next window for both result lists and appends with dedupe. - // Responses are dropped when the query/facets changed while in flight, so - // an old query's page can never merge into (or overwrite the totals of) a - // newer query's results. - const fetchMoreResults = useCallback(function () { - if (!debouncedQ || !searchData || loadingMoreResults) return; - const seq = searchSeqRef.current; - const nextOffset = searchOffsetRef.current + SEARCH_FETCH_LIMIT; - setLoadingMoreResults(true); - const params = new URLSearchParams(); - params.set("q", debouncedQ); - params.set("limit", String(SEARCH_FETCH_LIMIT)); - params.set("offset", String(nextOffset)); - if (role) params.set("role", role); - if (source) params.set("source", source); - SDK.fetchJSON(`${API}/search?${params.toString()}`).then(function (json) { - if (seq !== searchSeqRef.current) return; - searchOffsetRef.current = nextOffset; - setSearchData(function (prev) { return mergeSearchPayload(prev, json); }); - }).catch(function (err) { - if (seq !== searchSeqRef.current) return; - setSearchError(friendlyError(err)); - }).finally(function () { - setLoadingMoreResults(false); - }); - }, [debouncedQ, role, source, searchData, loadingMoreResults]); - - const updateStackEntry = useCallback(function (matcher, updater) { - setStack(function (prev) { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (matcher(next[i])) { - next[i] = updater(next[i]); - break; - } - } - return next; - }); - }, []); - - const fetchNode = useCallback(function (id) { - SDK.fetchJSON(`${API}/node/${encodeURIComponent(id)}`).then(function (json) { - updateStackEntry(function (entry) { - return entry.kind === "node" && String(entry.id) === String(id); - }, function (entry) { - return { - kind: "node", - id: id, - data: json, - loading: false, - error: "", - }; - }); - }).catch(function (err) { - updateStackEntry(function (entry) { - return entry.kind === "node" && String(entry.id) === String(id); - }, function () { - return { - kind: "node", - id: id, - data: null, - loading: false, - error: String((err && err.message) || err), - }; - }); - }); - }, [updateStackEntry]); - - const fetchSession = useCallback(function (id, offset, append, activeMessageId) { - const params = new URLSearchParams(); - params.set("limit", String(SESSION_FETCH_BATCH)); - params.set("offset", String(offset || 0)); - SDK.fetchJSON(`${API}/session/${encodeURIComponent(id)}?${params.toString()}`).then(function (json) { - updateStackEntry(function (entry) { - return entry.kind === "session" && String(entry.id) === String(id); - }, function (entry) { - const previous = (append && entry.data && entry.data.messages) ? entry.data.messages : []; - const nextMessages = append ? previous.concat(json.messages || []) : (json.messages || []); - return { - kind: "session", - id: id, - data: Object.assign({}, json, { messages: nextMessages }), - loading: false, - loadingMore: false, - error: "", - activeMessageId: activeMessageId != null ? activeMessageId : entry.activeMessageId, - }; - }); - }).catch(function (err) { - updateStackEntry(function (entry) { - return entry.kind === "session" && String(entry.id) === String(id); - }, function (entry) { - return Object.assign({}, entry, { - loading: false, - loadingMore: false, - error: String((err && err.message) || err), - }); - }); - }); - }, [updateStackEntry]); - - const fetchMessageContext = useCallback(function (message) { - const params = new URLSearchParams(); - params.set("limit", "1"); - params.set("offset", "0"); - SDK.fetchJSON(`${API}/session/${encodeURIComponent(message.session_id)}?${params.toString()}`).then(function (json) { - updateStackEntry(function (entry) { - return entry.kind === "message" && Number(entry.id) === Number(message.store_id); - }, function () { - return { - kind: "message", - id: message.store_id, - sessionId: message.session_id, - loading: false, - error: "", - data: { - message: message, - session: json, - }, - }; - }); - }).catch(function (err) { - updateStackEntry(function (entry) { - return entry.kind === "message" && Number(entry.id) === Number(message.store_id); - }, function () { - return { - kind: "message", - id: message.store_id, - sessionId: message.session_id, - loading: false, - error: String((err && err.message) || err), - data: { message: message, session: null }, - }; - }); - }); - }, [updateStackEntry]); - - const openNode = useCallback(function (id) { - setStack(function (prev) { - return prev.concat([{ kind: "node", id: id, data: null, loading: true, error: "" }]); - }); - fetchNode(id); - }, [fetchNode]); - - const openSession = useCallback(function (id, opts) { - const activeMessageId = opts && opts.activeMessageId != null ? opts.activeMessageId : null; - setStack(function (prev) { - return prev.concat([{ - kind: "session", - id: id, - data: null, - loading: true, - loadingMore: false, - error: "", - activeMessageId: activeMessageId, - }]); - }); - fetchSession(id, 0, false, activeMessageId); - }, [fetchSession]); - - const openMessage = useCallback(function (message) { - setStack(function (prev) { - return prev.concat([{ - kind: "message", - id: message.store_id, - sessionId: message.session_id, - data: { message: message, session: null }, - loading: true, - error: "", - }]); - }); - fetchMessageContext(message); - }, [fetchMessageContext]); - - const loadMoreSession = useCallback(function (id) { - const current = stack.length ? stack[stack.length - 1] : null; - if (!current || current.kind !== "session" || String(current.id) !== String(id) || !current.data || !current.data.has_more) { - return; - } - const offset = (current.data.messages || []).length; - updateStackEntry(function (entry) { - return entry.kind === "session" && String(entry.id) === String(id); - }, function (entry) { - return Object.assign({}, entry, { loadingMore: true, error: "" }); - }); - fetchSession(id, offset, true, current.activeMessageId); - }, [fetchSession, stack, updateStackEntry]); - - const goBack = useCallback(function () { - setStack(function (prev) { return prev.slice(0, -1); }); - }, []); - const closeDrawer = useCallback(function () { - setStack([]); - }, []); - - const top = stack.length ? stack[stack.length - 1] : null; - const overview = (data && data.overview) || {}; - const comp = overview.compression || {}; - const sources = overview.source_counts || []; - const hasLcmRows = Boolean( - Number(overview.messages_total) || - Number(overview.summary_nodes_total) || - Number(overview.sessions_total) - ); - - // The server is unreachable when the overview fetch threw and we have no - // (stale) payload to show; this must render error UI, never zero-data UI. - const serverUnreachable = Boolean(overviewError) && !data; - const staleData = Boolean(overviewError) && Boolean(data); - - const matches = (searchData && searchData.matches) || { messages: [], summary_nodes: [] }; - const fetchedMessageCount = (matches.messages || []).length; - const fetchedNodeCount = (matches.summary_nodes || []).length; - // Additive backend field: true totals + offset pagination. Fall back to - // fetched counts when the running server predates the field. - const searchTotals = (searchData && searchData.total) || null; - const totalMessageCount = (searchTotals && Number(searchTotals.messages) >= 0) - ? Number(searchTotals.messages) - : fetchedMessageCount; - const totalNodeCount = (searchTotals && Number(searchTotals.summary_nodes) >= 0) - ? Number(searchTotals.summary_nodes) - : fetchedNodeCount; - const hasMoreServerResults = Boolean(searchTotals) - && (fetchedMessageCount < totalMessageCount || fetchedNodeCount < totalNodeCount); - const messageTotalPages = Math.max(1, Math.ceil((matches.messages || []).length / SEARCH_PAGE_SIZE)); - const nodeTotalPages = Math.max(1, Math.ceil((matches.summary_nodes || []).length / SEARCH_PAGE_SIZE)); - const visibleMessages = (matches.messages || []).slice( - (searchMessagePage - 1) * SEARCH_PAGE_SIZE, - searchMessagePage * SEARCH_PAGE_SIZE - ); - const visibleNodes = (matches.summary_nodes || []).slice( - (searchNodePage - 1) * SEARCH_PAGE_SIZE, - searchNodePage * SEARCH_PAGE_SIZE - ); - - const keyboardResults = useMemo(function () { - return visibleMessages.map(function (item) { - return { - key: "message:" + item.store_id, - open: function () { openMessage(item); }, - }; - }).concat(visibleNodes.map(function (item) { - return { - key: "node:" + item.node_id, - open: function () { openNode(item.node_id); }, - }; - })); - }, [visibleMessages, visibleNodes, openMessage, openNode]); - - useEffect(function () { - setSelectedResultIndex(function (prev) { - if (!keyboardResults.length) return -1; - if (prev >= keyboardResults.length) return keyboardResults.length - 1; - return prev; - }); - }, [keyboardResults.length]); - - const lastFocusedResultRef = useRef(""); - useEffect(function () { - if (selectedResultIndex < 0 || selectedResultIndex >= keyboardResults.length) { - lastFocusedResultRef.current = ""; - return; - } - const key = keyboardResults[selectedResultIndex].key; - // Only move focus when the selection actually changes, and never while - // a detail drawer is open — the drawer owns focus then. - if (key === lastFocusedResultRef.current || stack.length) return; - const el = resultRefs.current[key]; - if (!el) return; - lastFocusedResultRef.current = key; - try { - if (typeof el.focus === "function") el.focus({ preventScroll: true }); - } catch (e) { - if (typeof el.focus === "function") el.focus(); - } - if (typeof el.scrollIntoView === "function") { - el.scrollIntoView({ block: "nearest" }); - } - }, [selectedResultIndex, keyboardResults, stack]); - - useEffect(function () { - function isTypingTarget(target) { - if (!target) return false; - const tag = target.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; - } - // Keep-mounted hosts (the standalone shell) hide inactive tab panels - // with `display: none` instead of unmounting them; a hidden panel must - // not react to keystrokes meant for the visible tab. - function isPanelHidden() { - const el = rootRef.current; - return !!el && el.offsetParent === null; - } - function onKeyDown(e) { - if (e.defaultPrevented) return; - if (isPanelHidden()) return; - if (!e.metaKey && !e.ctrlKey && !e.altKey && e.key === "/" && !isTypingTarget(e.target)) { - e.preventDefault(); - if (searchInputRef.current) { - searchInputRef.current.focus(); - if (typeof searchInputRef.current.select === "function") searchInputRef.current.select(); - } - return; - } - if (e.key === "Escape" && top) { - e.preventDefault(); - closeDrawer(); - return; - } - if (!keyboardResults.length || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return; - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - setSelectedResultIndex(function (prev) { - if (!keyboardResults.length) return -1; - if (prev < 0) return e.key === "ArrowDown" ? 0 : keyboardResults.length - 1; - return (prev + (e.key === "ArrowDown" ? 1 : -1) + keyboardResults.length) % keyboardResults.length; - }); - return; - } - if (e.key === "Enter" && selectedResultIndex >= 0 && selectedResultIndex < keyboardResults.length) { - e.preventDefault(); - keyboardResults[selectedResultIndex].open(); - } - } - window.addEventListener("keydown", onKeyDown); - return function () { window.removeEventListener("keydown", onKeyDown); }; - }, [keyboardResults, selectedResultIndex, top, closeDrawer]); - - const searchPending = String(q || "").trim() !== debouncedQ; - const searchActive = Boolean(String(q || "").trim() || debouncedQ || searching || searchData || searchError); - const totalSearchMatches = totalMessageCount + totalNodeCount; - - let drawerTitle = ""; - let drawerBody = null; - if (top) { - if (top.loading) { - drawerTitle = top.kind === "node" - ? `Node #${top.id}` - : (top.kind === "message" ? `Message #${top.id}` : `Session ${short(top.id, 40)}`); - drawerBody = h("div", { className: "hermes-lcm-empty" }, "Loading…"); - } else if (top.error) { - drawerTitle = top.kind === "node" - ? `Node #${top.id}` - : (top.kind === "message" ? `Message #${top.id}` : `Session ${short(top.id, 40)}`); - const current = top; - drawerBody = h(DrawerError, { - kind: current.kind, - message: current.error, - onRetry: function () { - updateStackEntry(function (entry) { - return entry === current; - }, function (entry) { - return Object.assign({}, entry, { loading: true, error: "" }); - }); - if (current.kind === "node") fetchNode(current.id); - else if (current.kind === "message") fetchMessageContext(current.data && current.data.message); - else fetchSession(current.id, 0, false, current.activeMessageId); - }, - }); - } else if (top.kind === "node") { - drawerTitle = `Node #${top.id}`; - drawerBody = h(NodeDetail, { - data: top.data, - onOpenNode: openNode, - onOpenSession: openSession, - onOpenMessage: openMessage, - }); - } else if (top.kind === "message") { - drawerTitle = `Message #${top.id}`; - drawerBody = h(MessageDetail, { - data: top.data, - onOpenNode: openNode, - onOpenSession: openSession, - }); - } else { - drawerTitle = `Session ${short(top.id, 40)}`; - drawerBody = h(SessionDetail, { - data: top.data, - onOpenNode: openNode, - onOpenMessage: openMessage, - onLoadMore: function () { loadMoreSession(top.id); }, - loadingMore: !!top.loadingMore, - activeMessageId: top.activeMessageId, - }); - } - } - - // Search results render directly under the toolbar (see placement below) - // so typing a query gives immediate visible feedback instead of appending - // results below the overview cards, off-screen. - const searchShell = searchActive ? h("div", { className: "hermes-lcm-card hermes-lcm-wide hermes-lcm-search-shell" }, [ - h("div", { className: "hermes-lcm-search-head" }, [ - h("div", null, [ - h("h3", null, "Search"), - h("div", { className: "hermes-lcm-search-subtitle", role: "status" }, - searchPending - ? "Waiting for typing to pause…" - : (searching - ? "Searching messages and summary nodes…" - : (debouncedQ && searchData - ? `${fmtInt(totalSearchMatches)} matches for "${short(debouncedQ, 36)}".` - : "Use / to focus and arrows to move through the current page."))), - ]), - h("div", { className: "hermes-lcm-badge-row" }, [ - debouncedQ ? toolBadge(`"${short(debouncedQ, 36)}"`) : null, - searchData && searchData.engine === "fts" ? toolBadge("FTS ranked", "ok") : null, - searchData && searchData.engine === "like" ? toolBadge("LIKE fallback", "warn") : null, - (!searchPending && !searching && debouncedQ && searchData) ? toolBadge(fmtInt(totalSearchMatches) + " hits") : null, - ]), - ]), - searchError ? h("div", { className: "hermes-lcm-error", role: "alert" }, [ - h("div", null, [ - h("strong", null, "Search failed. "), - searchError + " — results below may be incomplete; this is not an empty result.", - ]), - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setSearchRetryToken(function (n) { return n + 1; }); }, - }, "Retry search"), - ]) : null, - (!searchPending && searching && !searchData) ? h("div", { className: "hermes-lcm-grid" }, [ - h("div", { className: "hermes-lcm-card" }, h(SkeletonLines, { count: 5, widths: ["95%", "90%", "88%", "92%", "70%"] })), - h("div", { className: "hermes-lcm-card" }, h(SkeletonLines, { count: 4, widths: ["92%", "84%", "88%", "68%"] })), - ]) : null, - (!searchPending && !searching && debouncedQ && !searchError && totalSearchMatches === 0) ? h("div", { className: "hermes-lcm-empty" }, [ - h("strong", null, "No matches found."), - " Try removing a facet or a punctuation-heavy query so the backend can stay on the ranked FTS path.", - ]) : null, - totalSearchMatches > 0 ? h("div", { className: "hermes-lcm-grid" }, [ - h("div", { className: "hermes-lcm-card" }, [ - h("div", { className: "hermes-lcm-section-head" }, [ - h("h3", null, totalMessageCount > fetchedMessageCount - ? `Matching Messages (${fmtInt(fetchedMessageCount)} of ${fmtInt(totalMessageCount)})` - : `Matching Messages (${fmtInt(fetchedMessageCount)})`), - h("div", { className: "hermes-lcm-dim" }, "Click for full content and session context"), - ]), - h("div", { className: "hermes-lcm-results" }, - visibleMessages.length - ? visibleMessages.map(function (m, idx) { - const resultKey = "message:" + m.store_id; - const selected = selectedResultIndex === idx; - return h(SearchResultCard, { - key: resultKey, - resultRef: function (el) { - if (el) resultRefs.current[resultKey] = el; - else delete resultRefs.current[resultKey]; - }, - kind: "message", - item: m, - query: debouncedQ, - selected: selected, - onFocus: function () { setSelectedResultIndex(idx); }, - onOpen: function () { openMessage(m); }, - }); - }) - : h("div", { className: "hermes-lcm-empty" }, "No matching messages on this page.") - ), - h(Pager, { - page: searchMessagePage, - totalPages: messageTotalPages, - onChange: setSearchMessagePage, - }), - ]), - h("div", { className: "hermes-lcm-card" }, [ - h("div", { className: "hermes-lcm-section-head" }, [ - h("h3", null, totalNodeCount > fetchedNodeCount - ? `Matching Summaries (${fmtInt(fetchedNodeCount)} of ${fmtInt(totalNodeCount)})` - : `Matching Summaries (${fmtInt(fetchedNodeCount)})`), - h("div", { className: "hermes-lcm-dim" }, "Open a node to follow its source links"), - ]), - h("div", { className: "hermes-lcm-results" }, - visibleNodes.length - ? visibleNodes.map(function (n, idx) { - const absoluteIndex = visibleMessages.length + idx; - const resultKey = "node:" + n.node_id; - const selected = selectedResultIndex === absoluteIndex; - return h(SearchResultCard, { - key: resultKey, - resultRef: function (el) { - if (el) resultRefs.current[resultKey] = el; - else delete resultRefs.current[resultKey]; - }, - kind: "node", - item: n, - query: debouncedQ, - selected: selected, - onFocus: function () { setSelectedResultIndex(absoluteIndex); }, - onOpen: function () { openNode(n.node_id); }, - }); - }) - : h("div", { className: "hermes-lcm-empty" }, "No matching summaries on this page.") - ), - h(Pager, { - page: searchNodePage, - totalPages: nodeTotalPages, - onChange: setSearchNodePage, - }), - ]), - ]) : null, - hasMoreServerResults ? h("div", { className: "hermes-lcm-actions hermes-lcm-fetch-more" }, [ - h("button", { - type: "button", - className: "hermes-lcm-btn", - disabled: loadingMoreResults, - onClick: fetchMoreResults, - }, loadingMoreResults - ? "Fetching more results…" - : `Fetch next ${fmtInt(SEARCH_FETCH_LIMIT)} from server`), - h("span", { className: "hermes-lcm-dim" }, - `${fmtInt(fetchedMessageCount + fetchedNodeCount)} of ${fmtInt(totalSearchMatches)} loaded`), - ]) : null, - ]) : null; - - return h("div", { className: "hermes-lcm", ref: rootRef }, [ - h("div", { className: "hermes-lcm-top" }, [ - h("div", { className: "hermes-lcm-search-wrap" }, [ - h("input", { - ref: searchInputRef, - className: "hermes-lcm-search", - value: q, - type: "search", - placeholder: "Search messages and summaries", - "aria-label": "Search messages and summaries", - onChange: function (e) { setQ(e.target.value || ""); }, - onKeyDown: function (e) { - if (e.key === "ArrowDown" && keyboardResults.length) { - e.preventDefault(); - setSelectedResultIndex(0); - } - }, - }), - q ? h("button", { - type: "button", - className: "hermes-lcm-btn hermes-lcm-clear", - "aria-label": "Clear search query", - onClick: function () { setQ(""); setSearchData(null); setSearchError(""); }, - }, "Clear") : null, - ]), - h("select", { - className: "hermes-lcm-select", value: role, - "aria-label": "Filter by role", - onChange: function (e) { setRole(e.target.value); }, - }, [ - h("option", { key: "all", value: "" }, "All roles"), - h("option", { key: "user", value: "user" }, "user"), - h("option", { key: "assistant", value: "assistant" }, "assistant"), - h("option", { key: "tool", value: "tool" }, "tool"), - h("option", { key: "system", value: "system" }, "system"), - ]), - h("select", { - className: "hermes-lcm-select", value: source, - "aria-label": "Filter by source", - onChange: function (e) { setSource(e.target.value); }, - }, [h("option", { key: "all", value: "" }, "All sources")].concat( - sources.map(function (s) { - return h("option", { key: s.source, value: s.source }, short(s.source, 18)); - }) - )), - h("div", { - className: "hermes-lcm-status" + (overviewError ? " hermes-lcm-status-err" : ""), - role: "status", - }, - (overviewLoading || chartsLoading) ? "Loading overview" - : overviewError ? "Server unreachable" - : ((data && data.exists) ? "Database detected" : "Database missing") - ), - ]), - h("div", { className: "hermes-lcm-shortcuts" }, [ - h("span", null, "`/` focus search"), - h("span", null, "Arrow keys browse results"), - h("span", null, "Enter opens detail"), - ]), - // Which session store is being served (scope tag + database path). - h("div", { className: "hermes-lcm-path" }, data ? [ - data.storage_scope === "project_local" - ? h("span", { key: "scope", className: "hermes-lcm-tag hermes-lcm-tag-src" }, "Project store") - : (data.storage_scope === "global" - ? h("span", { key: "scope", className: "hermes-lcm-tag" }, "Global store") - : null), - h("span", { key: "path" }, data.path), - ] : ""), - - searchShell, - - // Unreachable server: a distinguishable error hero with retry — never - // the zeroed stats / "No data" cards that imply an empty database. - serverUnreachable ? h("div", { - className: "hermes-lcm-empty-panel hermes-lcm-offline", - role: "alert", - }, [ - h("div", { className: "hermes-lcm-empty-orb hermes-lcm-offline-orb", "aria-hidden": "true" }), - h("div", { className: "hermes-lcm-empty-copy" }, [ - h("div", { className: "hermes-lcm-empty-kicker" }, "Connection problem"), - h("h2", null, "Can't reach the tracedecay server"), - h("p", null, "The LCM overview request failed, so no counts or timelines can be shown. Your data is not gone — the dashboard just can't talk to the server right now."), - h("div", { className: "hermes-lcm-offline-actions" }, [ - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setReloadToken(function (n) { return n + 1; }); }, - }, "↻ Retry now"), - h("span", { className: "hermes-lcm-dim" }, overviewError), - ]), - ]), - ]) : null, - - staleData ? h("div", { className: "hermes-lcm-error", role: "alert" }, [ - h("div", null, `Refresh failed (${overviewError}) — showing previously loaded data.`), - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setReloadToken(function (n) { return n + 1; }); }, - }, "Retry"), - ]) : null, - data && data.error ? h("div", { className: "hermes-lcm-error", role: "alert" }, data.error) : null, - - data && !data.exists ? h("div", { className: "hermes-lcm-empty-panel" }, [ - h("div", { className: "hermes-lcm-empty-orb", "aria-hidden": "true" }), - h("div", { className: "hermes-lcm-empty-copy" }, [ - h("div", { className: "hermes-lcm-empty-kicker" }, "Lossless Context Store"), - h("h2", null, data.storage_scope === "project_local" - ? "Project session store not found" - : "Global LCM database not found"), - h("p", null, "The dashboard can render once the session store exists. Until then, the search, timeline, and detail views remain unavailable."), - ]), - ]) : null, - - data && data.exists && !hasLcmRows ? h("div", { className: "hermes-lcm-empty-panel" }, [ - h("div", { className: "hermes-lcm-empty-orb", "aria-hidden": "true" }), - h("div", { className: "hermes-lcm-empty-copy" }, [ - h("div", { className: "hermes-lcm-empty-kicker" }, "Lossless Context Store"), - h("h2", null, "No LCM sessions indexed yet"), - h("p", null, data.storage_scope === "project_local" - ? "This project's session store (.tracedecay/sessions.db) exists but holds no messages yet. Cursor sessions are ingested by its end-of-turn hook; Claude/Codex/Vibe/Cline transcripts are swept automatically when the MCP server or this dashboard starts. Run an agent turn in this project and refresh." - : "The global database exists, but it does not contain raw messages or summary nodes. Once sessions are ingested, this page will fill with timelines, compression ratios, searchable messages, and summary-node drilldowns."), - ]), - ]) : null, - - // Stats render only from a successful overview payload; zeros are then - // genuinely "empty database", never a masked fetch failure. - data ? h("div", { className: "hermes-lcm-statrow" }, [ - h(Stat, { value: fmtInt(overview.messages_total), label: "messages" }), - h(Stat, { value: fmtInt(overview.sessions_total), label: "sessions" }), - h(Stat, { value: fmtInt(overview.summary_nodes_total), label: "summary nodes" }), - h(Stat, { value: (comp.ratio ? comp.ratio + "×" : "—"), label: "compression" }), - h(Stat, { value: `${fmtInt(comp.source_token_count)}→${fmtInt(comp.token_count)}`, label: "tokens kept" }), - ]) : (overviewLoading ? h("div", { className: "hermes-lcm-statrow" }, [ - h("div", { className: "hermes-lcm-stat hermes-lcm-skeleton" }, h(SkeletonLines, { count: 2, widths: ["55%", "35%"] })), - h("div", { className: "hermes-lcm-stat hermes-lcm-skeleton" }, h(SkeletonLines, { count: 2, widths: ["45%", "30%"] })), - h("div", { className: "hermes-lcm-stat hermes-lcm-skeleton" }, h(SkeletonLines, { count: 2, widths: ["62%", "38%"] })), - ]) : null), - - serverUnreachable ? null : h("div", { className: "hermes-lcm-grid" }, [ - h("div", { className: "hermes-lcm-card hermes-lcm-wide" }, [ - h("h3", null, "Message Timeline (per day · dots = summaries)"), - chartsError && !timeline - ? h("div", { className: "hermes-lcm-error", role: "alert" }, [ - h("div", null, chartsError), - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setReloadToken(function (n) { return n + 1; }); }, - }, "Retry"), - ]) - : (chartsLoading && !timeline) - ? h(SkeletonLines, { count: 5, widths: ["100%", "95%", "90%", "92%", "88%"] }) - : h(TimelineChart, { - buckets: (timeline && timeline.buckets) || [], - nodeBuckets: (timeline && timeline.node_buckets) || [], - undatedCount: (timeline && timeline.undated && timeline.undated.count) || 0, - }), - ]), - h("div", { className: "hermes-lcm-card hermes-lcm-wide" }, [ - h("h3", null, "Compression by Session (kept vs saved)"), - chartsError && !compression - ? h("div", { className: "hermes-lcm-error", role: "alert" }, [ - h("div", null, chartsError), - h("button", { - type: "button", - className: "hermes-lcm-btn", - onClick: function () { setReloadToken(function (n) { return n + 1; }); }, - }, "Retry"), - ]) - : (chartsLoading && !compression) - ? h(SkeletonLines, { count: 4, widths: ["98%", "90%", "84%", "88%"] }) - : h(CompressionBars, { - groups: (compression && compression.groups) || [], - onPick: function (g) { openSession(g.session_id != null ? g.session_id : g.key); }, - }), - ]), - ]), - - serverUnreachable ? null : h("div", { className: "hermes-lcm-grid" }, [ - h("div", { className: "hermes-lcm-card" }, [ - h("h3", null, "By Source"), - h(BarList, { - rows: sources, - keyName: "source", - onPick: function (v) { setSource(v === "(none)" ? "unknown" : v); }, - }), - ]), - h("div", { className: "hermes-lcm-card" }, [ - h("h3", null, "By Role"), - h(BarList, { rows: overview.role_counts || [], keyName: "role", onPick: function (v) { setRole(v); } }), - ]), - h("div", { className: "hermes-lcm-card" }, [ - h("h3", null, "Summary Depth"), - h(BarList, { rows: overview.depth_counts || [], keyName: "depth" }), - ]), - ]), - - serverUnreachable ? null : h("div", { className: "hermes-lcm-grid" }, [ - h("div", { className: "hermes-lcm-card" }, [ - h("h3", null, "Recent Sessions"), - h("div", { className: "hermes-lcm-rows" }, - ((data && data.latest_sessions) || []).length - ? ((data && data.latest_sessions) || []).map(function (s, idx) { - const tail = sessionTail(s.session_id); - return h("button", { - key: s.session_id + ":" + idx, - type: "button", - className: "hermes-lcm-row", - onClick: function () { openSession(s.session_id); }, - }, [ - h("div", { className: "hermes-lcm-row-main" }, [ - h("span", { className: "hermes-lcm-row-title" }, sessionLabel(s.session_id)), - tail ? h("span", { className: "hermes-lcm-row-id" }, tail) : null, - ]), - h("div", { className: "hermes-lcm-row-meta" }, [ - h("span", { className: "hermes-lcm-pill" }, fmtInt(s.message_count) + " msgs"), - h(TimeText, { className: "hermes-lcm-dim", epoch: s.last_timestamp }), - ]), - ]); - }) - : (data - ? h("div", { className: "hermes-lcm-empty" }, "No sessions") - : h(SkeletonLines, { count: 3, widths: ["92%", "84%", "76%"] })) - ), - ]), - h("div", { className: "hermes-lcm-card" }, [ - h("h3", null, "Latest Summaries"), - h("div", { className: "hermes-lcm-rows" }, - ((data && data.latest_summary_nodes) || []).length - ? ((data && data.latest_summary_nodes) || []).map(function (n) { - const title = summaryTitle(n.summary); - const preview = stripMd(n.summary); - return h("button", { - key: n.node_id, - type: "button", - className: "hermes-lcm-row", - onClick: function () { openNode(n.node_id); }, - }, [ - h("div", { className: "hermes-lcm-row-meta" }, [ - h("span", { className: "hermes-lcm-pill hermes-lcm-pill-accent" }, "D" + n.depth), - n.category ? h("span", { className: "hermes-lcm-pill" }, n.category) : null, - h("span", { className: "hermes-lcm-dim" }, sessionLabel(n.session_id)), - n.token_count != null ? h("span", { className: "hermes-lcm-dim" }, fmtInt(n.token_count) + " tok") : null, - ]), - h("div", { className: "hermes-lcm-row-title" }, short(title, 80)), - h("div", { className: "hermes-lcm-row-sub" }, short(preview, 150)), - ]); - }) - : (data - ? h("div", { className: "hermes-lcm-empty" }, "No summaries") - : h(SkeletonLines, { count: 3, widths: ["90%", "82%", "74%"] })) - ), - ]), - ]), - - h(Drawer, { - open: !!top, - title: drawerTitle, - canBack: stack.length > 1, - onBack: goBack, - onClose: closeDrawer, - }, drawerBody), - ]); - } - - if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") { - window.__HERMES_PLUGINS__.register("hermes-lcm", App); - } -})(); diff --git a/dashboard/lcm/src/markdown.tsx b/dashboard/lcm/src/markdown.tsx new file mode 100644 index 00000000..061a7757 --- /dev/null +++ b/dashboard/lcm/src/markdown.tsx @@ -0,0 +1,145 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Minimal self-contained markdown -> React renderer for the hermes-lcm plugin. + * + * Ported 1:1 from the original IIFE. This module manipulates React element + * trees programmatically (it appends to a previous
  • 's `.props.children` + * when nesting sub-lists, and reads `.key`), so it intentionally keeps using + * `React.createElement` directly — JSX has no idiomatic equivalent for the + * "mutate the previously-pushed element" algorithm, and rewriting it would + * change behavior. JSX is used only for the public `MarkdownText` wrapper. + */ + +import React from "react"; + +const h = React.createElement; + +function mdInlineNodes(text: string, kp: string): any[] { + const nodes: any[] = []; + const codeRe = /`([^`]+)`/g; + let last = 0, m: RegExpExecArray | null, i = 0; + while ((m = codeRe.exec(text)) !== null) { + if (m.index > last) mdEmphasis(text.slice(last, m.index), nodes, kp + "t" + i); + nodes.push(h("code", { key: kp + "c" + i, className: "hermes-lcm-md-code" }, m[1])); + last = codeRe.lastIndex; i++; + } + if (last < text.length) mdEmphasis(text.slice(last), nodes, kp + "t" + i); + return nodes; +} + +// Underscores are left literal so snake_case and paths (kanban_block, +// auto_model_routing) are not mangled into emphasis. +function mdEmphasis(str: string, nodes: any[], kp: string): void { + const re = /(\*\*)([\s\S]+?)\*\*|(\*)([^*\n]+?)\*|\[([^\]]+)\]\(([^)\s]+)\)/; + let rest = str, i = 0, m: RegExpExecArray | null; + while ((m = re.exec(rest)) !== null) { + if (m.index > 0) nodes.push(rest.slice(0, m.index)); + if (m[1]) nodes.push(h("strong", { key: kp + "b" + i }, mdInlineNodes(m[2], kp + "b" + i + "-"))); + else if (m[3]) nodes.push(h("em", { key: kp + "e" + i }, mdInlineNodes(m[4], kp + "e" + i + "-"))); + else nodes.push(h("a", { + key: kp + "a" + i, href: m[6], target: "_blank", rel: "noopener noreferrer", + className: "hermes-lcm-md-link", + }, m[5])); + rest = rest.slice(m.index + m[0].length); i++; + } + if (rest) nodes.push(rest); +} + +function mdBuildList(items: any[], kp: string): any { + const base = items[0].indent; + const ordered = items[0].ordered; + const children: any[] = []; + let i = 0, li = 0; + while (i < items.length) { + if (items[i].indent > base) { + const start = i; + while (i < items.length && items[i].indent > base) i++; + const nested = mdBuildList(items.slice(start), kp + "n" + li); + if (children.length) { + const prev = children[children.length - 1]; + children[children.length - 1] = h("li", { key: prev.key }, + [].concat(prev.props.children, nested)); + } else { + children.push(h("li", { key: kp + "li" + li++ }, nested)); + } + continue; + } + children.push(h("li", { key: kp + "li" + li++ }, mdInlineNodes(items[i].text, kp + "x" + li))); + i++; + } + return h(ordered ? "ol" : "ul", { key: kp, className: "hermes-lcm-md-list" }, children); +} + +export function mdToReact(src: any): any[] { + const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n"); + const blocks: any[] = []; + let i = 0, key = 0; + while (i < lines.length) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + const buf: string[] = []; + i++; + while (i < lines.length && !/^\s*```/.test(lines[i])) { buf.push(lines[i]); i++; } + i++; + blocks.push(h("pre", { key: "p" + key++, className: "hermes-lcm-md-pre" }, + h("code", null, buf.join("\n")))); + continue; + } + if (/^\s*$/.test(line)) { i++; continue; } + const hd = line.match(/^(#{1,6})\s+(.*)$/); + if (hd) { + blocks.push(h("div", { + key: "p" + key++, + className: "hermes-lcm-md-h hermes-lcm-md-h" + hd[1].length, + }, mdInlineNodes(hd[2], "h" + key))); + i++; continue; + } + if (/^\s*>\s?/.test(line)) { + const buf: string[] = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; + } + blocks.push(h("blockquote", { key: "p" + key++, className: "hermes-lcm-md-quote" }, + mdInlineNodes(buf.join(" "), "q" + key))); + continue; + } + if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) { + const items: any[] = []; + while (i < lines.length && /^\s*([-*+]|\d+[.)])\s+/.test(lines[i])) { + const mm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); + items.push({ indent: mm![1].length, ordered: /\d/.test(mm![2]), text: mm![3] }); + i++; + } + blocks.push(mdBuildList(items, "l" + key++)); + continue; + } + const buf: string[] = []; + while (i < lines.length && !/^\s*$/.test(lines[i]) + && !/^\s*```/.test(lines[i]) + && !/^(#{1,6})\s+/.test(lines[i]) + && !/^\s*>\s?/.test(lines[i]) + && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])) { + buf.push(lines[i]); i++; + } + const kids: any[] = []; + buf.forEach(function (ln, idx) { + if (idx) kids.push(h("br", { key: "br" + idx })); + const sub = mdInlineNodes(ln, "p" + key + "-" + idx); + for (let s = 0; s < sub.length; s++) kids.push(sub[s]); + }); + blocks.push(h("p", { key: "p" + key++, className: "hermes-lcm-md-p" }, kids)); + } + return blocks; +} + +export function MarkdownText(props: { text: any; className?: string }): React.ReactElement { + const text = String(props.text == null ? "" : props.text); + let nodes: any[]; + try { nodes = mdToReact(text); } catch (e) { nodes = [text]; } + return ( +
    + {nodes} +
    + ); +} diff --git a/dashboard/lcm/src/style.css b/dashboard/lcm/src/styles.css similarity index 100% rename from dashboard/lcm/src/style.css rename to dashboard/lcm/src/styles.css diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 7929fbd5..8a3ce302 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -15,6 +15,9 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@rsbuild/core": "^2.0.15", + "@rsbuild/plugin-react": "^2.1.0", + "@rsbuild/plugin-tailwindcss": "^2.0.3", "@rspack/core": "^2.0.8", "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", @@ -26,6 +29,19 @@ "vitest": "^4.1.8" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -1118,6 +1134,68 @@ "dev": true, "license": "MIT" }, + "node_modules/@rsbuild/core": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.15.tgz", + "integrity": "sha512-O8vmMhZu1YImO6jOqt/K/vlJSvkq7UtSq5YM1DIlcEd9LW8Gf6/dkQ1B2KPI6F+hSMFBnTTTumdcIowSLCw97g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/core": "~2.0.8", + "@swc/helpers": "^0.5.23" + }, + "bin": { + "rsbuild": "bin/rsbuild.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "core-js": ">= 3.0.0" + }, + "peerDependenciesMeta": { + "core-js": { + "optional": true + } + } + }, + "node_modules/@rsbuild/plugin-react": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-2.1.0.tgz", + "integrity": "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/plugin-react-refresh": "^2.0.2", + "react-refresh": "^0.18.0" + }, + "peerDependencies": { + "@rsbuild/core": "^2.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } + } + }, + "node_modules/@rsbuild/plugin-tailwindcss": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-tailwindcss/-/plugin-tailwindcss-2.0.3.tgz", + "integrity": "sha512-sl7mN0fyoP0W+zEyFR2xEMVAS2cZPlOAqsXwI8Ei9tUSvV7V4G8Xw2WBVCOQ/B4KEFfJ6H1+TEuPlAzHLEfc4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/webpack": "^4.3.1" + }, + "peerDependencies": { + "@rsbuild/core": "^2.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } + } + }, "node_modules/@rspack/binding": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", @@ -1323,6 +1401,22 @@ } } }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-2.0.2.tgz", + "integrity": "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@rspack/core": "^2.0.0", + "react-refresh": ">=0.10.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1330,6 +1424,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", @@ -1587,6 +1691,31 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/webpack": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/webpack/-/webpack-4.3.1.tgz", + "integrity": "sha512-HM33EjYPbkMlBGcYoVA/pk/hML3wAn2JOnjF79eDWVLuktOhRczDwWsSBQBOWV6LLBVAgGZS/pZ519OHUF8vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "@rspack/core": "^1.0.0 || ^2.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3076,6 +3205,16 @@ "license": "MIT", "peer": true }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3311,8 +3450,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/undici": { "version": "7.27.2", diff --git a/dashboard/package.json b/dashboard/package.json index feafb195..56584dc8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,7 +6,7 @@ "description": "TraceDecay dashboard UI: standalone shell + ported Hermes plugin dashboards (holographic memory, LCM).", "scripts": { "build": "node build.mjs", - "build:esbuild": "node build-esbuild.mjs", + "dev": "node dev/run.mjs", "test": "npm run test:node && npm run test:dom", "test:node": "node run-unit-tests.mjs", "test:dom": "vitest run", @@ -21,6 +21,9 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@rsbuild/core": "^2.0.15", + "@rsbuild/plugin-react": "^2.1.0", + "@rsbuild/plugin-tailwindcss": "^2.0.3", "@rspack/core": "^2.0.8", "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", diff --git a/dashboard/test/lcm-logic.test.mjs b/dashboard/test/lcm-logic.test.mjs index ee7227aa..89283a76 100644 --- a/dashboard/test/lcm-logic.test.mjs +++ b/dashboard/test/lcm-logic.test.mjs @@ -1,69 +1,29 @@ import test from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; -import vm from "node:vm"; -import { readFile } from "node:fs/promises"; - -const lcmPath = path.resolve(process.cwd(), "lcm/src/index.js"); - -async function loadLcmExports() { - const source = await readFile(lcmPath, "utf8"); - const instrumented = source.replace( - /\}\)\(\);\s*$/, - ` -globalThis.__LCM_TEST_EXPORTS__ = { - stripMd, - summaryTitle, - sessionLabel, - sessionTail, - parseLeadingJSON, - ratioStr, - mergeRows, - mergeSearchPayload, - TimelineChart +import { importBundledModule } from "./helpers/module-loader.mjs"; + +const root = process.cwd(); + +// Pure helpers live in helpers.ts (no React). TimelineChart is a React +// component in components.tsx; the module-loader bundles real `react` from +// node_modules, so JSX yields real element trees the assertions can walk. +const [helpers, components] = await Promise.all([ + importBundledModule(path.join(root, "lcm/src/helpers.ts")), + importBundledModule(path.join(root, "lcm/src/components.tsx")), +]); + +const lcm = { + stripMd: helpers.stripMd, + summaryTitle: helpers.summaryTitle, + sessionLabel: helpers.sessionLabel, + sessionTail: helpers.sessionTail, + parseLeadingJSON: helpers.parseLeadingJSON, + ratioStr: helpers.ratioStr, + mergeRows: helpers.mergeRows, + mergeSearchPayload: helpers.mergeSearchPayload, + TimelineChart: components.TimelineChart, }; -})(); -`, - ); - - if (instrumented === source) { - throw new Error("Failed to instrument lcm/src/index.js for tests"); - } - - const noop = () => {}; - const context = { - window: { - __HERMES_PLUGIN_SDK__: { - React: { - createElement: (type, props, ...children) => ({ - type, - props: { ...(props || {}), children: children.length <= 1 ? children[0] : children }, - }), - }, - hooks: { - useEffect: noop, - useMemo: (fn) => fn(), - useState: (value) => [value, noop], - useCallback: (fn) => fn, - }, - utils: {}, - }, - __HERMES_PLUGINS__: { - register: noop, - }, - }, - }; - context.globalThis = context; - - vm.runInNewContext(instrumented, context, { filename: "lcm/src/index.js" }); - const exports = context.__LCM_TEST_EXPORTS__; - if (!exports) { - throw new Error("LCM test exports were not captured"); - } - return exports; -} - -const lcm = await loadLcmExports(); test("stripMd flattens markdown syntax into readable plain text", () => { const input = ` @@ -156,7 +116,9 @@ function flattenText(node, out = []) { node.forEach((child) => flattenText(child, out)); return out; } - if (node.props) flattenText(node.props.children, out); + // Real React elements and the JSX runtime both expose children via .props. + const props = node.props || {}; + if ("children" in props) flattenText(props.children, out); return out; } @@ -172,7 +134,9 @@ test("TimelineChart drops null buckets and reports undated messages honestly", ( undatedCount: 500, }); const bars = rendered.props.children[0]; - assert.equal(bars.props.children.length, 2); + // bars.props.children is the array of column elements (2 dated buckets). + const cols = Array.isArray(bars.props.children) ? bars.props.children : [bars.props.children]; + assert.equal(cols.length, 2); const text = flattenText(rendered).join(" "); assert.match(text, /500 undated messages not shown/); }); From a0aa66cba5227cb9fa61e965977b16594c651abb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 00:59:24 +0200 Subject: [PATCH 03/35] feat(dashboard): accessibility + minor code-quality fixes - HolographicMemoryPage: CoverageGauge now exposes role="img" + an aria-label ("N% HRR coverage, ") so the gauge is announced to assistive tech instead of being sight-only. VIEW_TABS hoisted to module scope (it was rebuilt on every render; it references only module-scope icons). - SavingsExplorer: the Savings/Sessions/Models view switch is now a proper tablist (role="tablist" + role="tab" + aria-selected), matching the shell's tablist pattern for screen-reader parity. --- .../holographic/src/HolographicMemoryPage.tsx | 25 +++++++++++-------- dashboard/savings/src/SavingsExplorer.tsx | 4 ++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index a4984aac..39a92afd 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -51,6 +51,16 @@ import type { Bin } from "./viz/scale"; const INSPECTOR_ROW_LIMIT = 25; const GRAPH_ROW_LIMIT = 500; +type ViewKey = "inspector" | "map" | "graph" | "similarity" | "curation"; + +const VIEW_TABS: Array<{ key: ViewKey; label: string; icon: ReactNode }> = [ + { key: "inspector", label: "Inspector", icon: }, + { key: "map", label: "Semantic Map", icon: }, + { key: "graph", label: "Graph", icon: }, + { key: "similarity", label: "Similarity", icon: }, + { key: "curation", label: "Curation", icon: }, +]; + /** Render a JSON-array string ("[\"a\",\"b\"]") as chips; hide empty/invalid. */ function JsonChips({ raw, label }: { raw?: string | null; label: string }) { const items = useMemo(() => { @@ -575,7 +585,11 @@ function CoverageGauge({ pct, status }: { pct: number; status: string }) { const r = 15.915; const clamped = Math.max(0, Math.min(100, pct)); return ( -
    +
    void; }) { const overview = data.holographic.overview; - type ViewKey = "inspector" | "map" | "graph" | "similarity" | "curation"; const [view, setViewState] = useState(() => { const initial = new URLSearchParams(window.location.search).get("view"); return initial === "map" || @@ -926,14 +939,6 @@ function HolographicView({ [setView], ); - const VIEW_TABS: Array<{ key: ViewKey; label: string; icon: ReactNode }> = [ - { key: "inspector", label: "Inspector", icon: }, - { key: "map", label: "Semantic Map", icon: }, - { key: "graph", label: "Graph", icon: }, - { key: "similarity", label: "Similarity", icon: }, - { key: "curation", label: "Curation", icon: }, - ]; - return (
    diff --git a/dashboard/savings/src/SavingsExplorer.tsx b/dashboard/savings/src/SavingsExplorer.tsx index 676f2c57..25354eec 100644 --- a/dashboard/savings/src/SavingsExplorer.tsx +++ b/dashboard/savings/src/SavingsExplorer.tsx @@ -115,10 +115,12 @@ export default function SavingsExplorer() {
    Savings & Cost -
    +
    {VIEWS.map((entry) => (
    {error && ( -
    setError("")} role="alert">{error}
    +
    setError("")} style={{ cursor: "pointer" }}> + +
    )} {view === "overview" ? ( diff --git a/dashboard/lib/primitives.css b/dashboard/lib/primitives.css new file mode 100644 index 00000000..910d22f1 --- /dev/null +++ b/dashboard/lib/primitives.css @@ -0,0 +1,171 @@ +/* + * Shared dashboard primitives stylesheet (`tdp-*`). + * + * Colors resolve exclusively through the host `--color-*` semantic variables + * (defined by the standalone shell as aliases of its `--ts-*` tokens, and by + * Hermes' shadcn palette). No color is hardcoded. Layout values (radii, + * spacing, font sizes) are ported verbatim from graph/src/styles.css so the + * primitives are visually identical to the original `tsg-*` markup. + * + * Delivery: build.mjs prepends this file to a plugin's dist/style.css when + * that plugin opts in via `buildPlugin(..., { primitives: true })`. + */ + +/* ----------------------------------------------------------- EmptyState */ +/* Ports `.tsg-empty` verbatim (--ts-text-3 -> --color-muted-foreground). */ +.tdp-empty { + display: grid; + place-items: center; + min-height: 8rem; + color: var(--color-muted-foreground); + text-align: center; + padding: 0.75rem; +} + +/* ----------------------------------------------------------- ErrorPanel */ +/* Ports `.tsg-error` verbatim (--ts-red -> --color-destructive, + * --ts-radius -> var(--ts-radius, 18px) so it resolves to graph's 18px in the + * shell and falls back to the same 18px in hosts without --ts-radius). */ +.tdp-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border: 1px solid color-mix(in srgb, var(--color-destructive) 42%, transparent); + border-radius: var(--ts-radius, 18px); + background: color-mix(in srgb, var(--color-destructive) 10%, transparent); + color: var(--color-destructive); + padding: 0.7rem 1rem; + font-size: 0.84rem; +} + +.tdp-error-text { + min-width: 0; +} + +.tdp-error-retry { + flex-shrink: 0; +} + +/* -------------------------------------------------------- SkeletonLines */ +/* New loading affordance; themed via host muted/border tokens. */ +.tdp-skeleton { + display: grid; + gap: 0.6rem; + width: 100%; +} + +.tdp-skeleton-line { + width: 100%; + height: 0.85rem; + border-radius: 0.4rem; + background: color-mix(in srgb, var(--color-muted-foreground) 22%, transparent); + animation: tdp-skeleton-pulse 1.4s ease-in-out infinite; +} + +.tdp-skeleton-line:last-child { + width: 60%; +} + +@keyframes tdp-skeleton-pulse { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} + +/* ----------------------------------------------------------------- Stat */ +/* Ports the `.tss-stat` shape; themed via host tokens. */ +.tdp-stat { + display: grid; + gap: 0.2rem; + padding: 0.9rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--ts-radius, 18px); + background: color-mix(in srgb, var(--color-background) 26%, transparent); +} + +.tdp-stat-value { + color: var(--color-foreground); + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 700; + line-height: 1.15; +} + +.tdp-stat-label { + color: var(--color-muted-foreground); + font-size: 0.78rem; +} + +.tdp-stat-hint { + color: var(--color-muted-foreground); + font-family: var(--font-mono); + font-size: 0.68rem; +} + +/* -------------------------------------------------------------- BarList */ +/* Ports `.tsg-hub-list`. */ +.tdp-bar-list { + display: grid; + gap: 0.4rem; + max-height: 21rem; + overflow: auto; +} + +/* Ports `.tsg-hub`. */ +.tdp-bar-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.55rem; + appearance: none; + text-align: left; + border: 1px solid var(--color-border); + border-radius: 12px; + background: color-mix(in srgb, var(--color-background) 26%, transparent); + color: var(--color-foreground); + cursor: pointer; + padding: 0.42rem 0.6rem; + font: inherit; +} + +/* Ports `.tsg-hub:hover`. */ +.tdp-bar-row:hover { + border-color: color-mix(in srgb, var(--color-primary) 42%, transparent); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); +} + +/* Ports `.tsg-hub-dot`. */ +.tdp-bar-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; +} + +/* Ports `.tsg-hub-name`. */ +.tdp-bar-label { + color: var(--color-foreground); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Ports `.tsg-hub-meta`. */ +.tdp-bar-meta { + color: var(--color-muted-foreground); + font-family: var(--font-mono); + font-size: 0.66rem; +} + +/* Ports `.tsg-hub-degree`. */ +.tdp-bar-value { + color: var(--color-primary); + font-family: var(--font-mono); + font-size: 0.68rem; + white-space: nowrap; +} diff --git a/dashboard/lib/primitives.tsx b/dashboard/lib/primitives.tsx new file mode 100644 index 00000000..4595089d --- /dev/null +++ b/dashboard/lib/primitives.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { cn } from "./cn"; +import { Button } from "./sdk"; + +/** + * Shared dashboard UI primitives (`tdp-*` namespace). + * + * Built on the host-SDK design-system components and the host `--color-*` + * CSS variables, so they theme correctly in both the standalone tracedecay + * shell (which aliases every `--color-*` to its `--ts-*` token — see + * shell/src/styles.css) and the Hermes dashboard (whose shadcn palette + * defines the same `--color-*` names). + * + * Visual values are ported verbatim from the Code Graph Explorer + * (graph/src/styles.css `tsg-*` classes) with their `--ts-*` colors resolved + * through the equivalent `--color-*` host var, so a plugin that adopts these + * looks identical to the original hand-rolled markup. + */ + +/** Centered, muted placeholder (ports `.tsg-empty`). */ +export function EmptyState({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
    {children}
    ; +} + +/** + * Destructive-tinted alert with an optional Retry button (ports `.tsg-error`). + * Rendered with `role="alert"`. + */ +export function ErrorPanel({ + error, + onRetry, + className, +}: { + error: string; + onRetry?: () => void; + className?: string; +}) { + return ( +
    + {error} + {onRetry && ( + + )} +
    + ); +} + +/** Muted, animated placeholder bars for loading states. */ +export function SkeletonLines({ + count = 3, + widths, + className, +}: { + count?: number; + widths?: Array; + className?: string; +}) { + return ( + + ); +} + +/** Big-value + small-label stat tile (ports the `.tss-stat` shape). */ +export function Stat({ + label, + value, + hint, + className, +}: { + label: string; + value: React.ReactNode; + hint?: string; + className?: string; +}) { + return ( +
    +
    {value}
    +
    {label}
    + {hint &&
    {hint}
    } +
    + ); +} + +/** + * Label/value bar list of optionally-pickable rows (ports graph's + * `.tsg-hub-list` / `.tsg-hub` shape). + * + * `keyName` selects the row field used as the visible label and React key. + * Each row may also carry optional `value`, `meta`, and `color` fields. + */ +export function BarList({ + rows, + keyName, + onPick, + className, +}: { + rows: Array>; + keyName: string; + onPick?: (row: Record) => void; + className?: string; +}) { + return ( +
    + {rows.map((row) => { + const label = String(row[keyName] ?? ""); + const value = "value" in row ? row.value : undefined; + const meta = "meta" in row ? row.meta : undefined; + const color = "color" in row ? row.color : undefined; + const inner = ( + <> + {color !== undefined && ( + + )} + {label} + {meta !== undefined && {String(meta)}} + {value !== undefined && {String(value)}} + + ); + return onPick ? ( + + ) : ( +
    + {inner} +
    + ); + })} +
    + ); +} From 64a0025aa5ba6268d7ddb891031434c51c9c9c36 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 01:37:41 +0200 Subject: [PATCH 06/35] refactor(dashboard): collapse shell light-theme overrides into semantic tokens Replace the ~120-line block of 20 per-component [data-theme="light"] overrides with 32 semantic tokens (--ts-button-bg-2, --ts-card-bg, --ts-input-bg, --ts-tab-active-bg, ...) defined once in :root (dark) and flipped in :root[data-theme="light"]. Component rules now reference the tokens and theme automatically, so new components no longer need a matching manual light override. Dark and light computed values are byte-identical to before (every token's dark value = the old dark rule value; every light value = the old override value). Three rules with no original light override were intentionally left hardcoded (tokenizing them would have changed light-theme output). Residual per-component [data-theme="light"] selectors: 0. Verified: real-browser Playwright smoke (desktop+narrow) passes. --- dashboard/shell/src/styles.css | 298 +++++++++++++++------------------ 1 file changed, 131 insertions(+), 167 deletions(-) diff --git a/dashboard/shell/src/styles.css b/dashboard/shell/src/styles.css index f41a455e..7a4029b2 100644 --- a/dashboard/shell/src/styles.css +++ b/dashboard/shell/src/styles.css @@ -29,6 +29,52 @@ --ts-shadow: 0 24px 80px rgba(0, 0, 0, 0.42); --ts-radius: 18px; + /* Semantic component tokens — flip with [data-theme="light"] (see the matching + block below) so component rules can be authored once. Each dark value here + mirrors the original dark component rule byte-for-byte; the light values in + :root[data-theme="light"] mirror the former per-component overrides. */ + --ts-page-bg: + radial-gradient(circle at 12% -10%, rgba(117, 244, 210, 0.2), transparent 28rem), + radial-gradient(circle at 80% 0%, rgba(122, 167, 255, 0.18), transparent 30rem), + linear-gradient(180deg, #06100f 0%, #030607 100%); + --ts-grid-image: + linear-gradient(rgba(117, 244, 210, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(117, 244, 210, 0.05) 1px, transparent 1px); + --ts-grid-opacity: 0.34; + --ts-header-bg: + linear-gradient(90deg, rgba(7, 16, 15, 0.92), rgba(13, 27, 26, 0.82)), + rgba(7, 16, 15, 0.78); + --ts-header-shadow: 0 12px 36px rgba(0, 0, 0, 0.28); + --ts-logo-bg: radial-gradient(circle at 35% 25%, rgba(117, 244, 210, 0.35), transparent 55%); + --ts-logo-border: rgba(117, 244, 210, 0.35); + --ts-logo-shadow: 0 0 28px rgba(117, 244, 210, 0.16); + --ts-tabs-bg: rgba(3, 6, 7, 0.42); + --ts-tab-active-bg: linear-gradient(180deg, rgba(117, 244, 210, 0.18), rgba(122, 167, 255, 0.1)); + --ts-tab-active-border: rgba(117, 244, 210, 0.36); + --ts-tab-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 22px rgba(117, 244, 210, 0.12); + --ts-card-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 34%), + rgba(13, 27, 26, 0.78); + --ts-button-bg-2: #42d7bd; + --ts-button-border: rgba(117, 244, 210, 0.65); + --ts-button-fg: #03100e; + --ts-button-secondary-bg: rgba(3, 6, 7, 0.18); + --ts-button-hover-bg: rgba(117, 244, 210, 0.08); + --ts-button-hover-border: rgba(117, 244, 210, 0.42); + --ts-button-destructive-bg-2: #d84f5d; + --ts-button-destructive-border: color-mix(in srgb, var(--ts-red) 70%, transparent); + --ts-input-bg: rgba(3, 6, 7, 0.42); + --ts-input-focus-bg: rgba(3, 6, 7, 0.62); + --ts-input-focus-border: rgba(117, 244, 210, 0.52); + --ts-input-focus-shadow: 0 0 0 4px rgba(117, 244, 210, 0.08); + --ts-badge-bg: rgba(3, 6, 7, 0.28); + --ts-conn-bg: transparent; + --ts-toggle-hover-bg: rgba(117, 244, 210, 0.07); + --ts-loading-bg: rgba(13, 27, 26, 0.62); + --ts-scrollbar-track: rgba(255, 255, 255, 0.03); + --ts-scrollbar-thumb: rgba(117, 244, 210, 0.18); + --ts-scrollbar-thumb-border: rgba(3, 6, 7, 0.85); + /* Hermes-compatible host vars used by the plugin styles. */ --background: var(--ts-bg); --background-base: 7 16 15; @@ -89,10 +135,7 @@ html, body { margin: 0; padding: 0; - background: - radial-gradient(circle at 12% -10%, rgba(117, 244, 210, 0.2), transparent 28rem), - radial-gradient(circle at 80% 0%, rgba(122, 167, 255, 0.18), transparent 30rem), - linear-gradient(180deg, #06100f 0%, #030607 100%); + background: var(--ts-page-bg); color: var(--foreground); font-family: var(--theme-font-sans); font-size: 14px; @@ -132,12 +175,12 @@ select:focus-visible { } ::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.03); + background: var(--ts-scrollbar-track); } ::-webkit-scrollbar-thumb { - background: rgba(117, 244, 210, 0.18); - border: 2px solid rgba(3, 6, 7, 0.85); + background: var(--ts-scrollbar-thumb); + border: 2px solid var(--ts-scrollbar-thumb-border); border-radius: 999px; } @@ -155,10 +198,8 @@ select:focus-visible { pointer-events: none; position: fixed; inset: 0; - opacity: 0.34; - background-image: - linear-gradient(rgba(117, 244, 210, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(117, 244, 210, 0.05) 1px, transparent 1px); + opacity: var(--ts-grid-opacity); + background-image: var(--ts-grid-image); background-size: 44px 44px; mask-image: linear-gradient(to bottom, black, transparent 75%); z-index: -1; @@ -171,14 +212,12 @@ select:focus-visible { gap: 1.25rem; padding: 0.85rem clamp(1rem, 3vw, 2.25rem); border-bottom: 1px solid var(--ts-line); - background: - linear-gradient(90deg, rgba(7, 16, 15, 0.92), rgba(13, 27, 26, 0.82)), - rgba(7, 16, 15, 0.78); + background: var(--ts-header-bg); backdrop-filter: blur(18px) saturate(1.2); position: sticky; top: 0; z-index: 50; - box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28); + box-shadow: var(--ts-header-shadow); } .ts-shell-brand { @@ -193,10 +232,10 @@ select:focus-visible { place-items: center; width: 2rem; height: 2rem; - border: 1px solid rgba(117, 244, 210, 0.35); + border: 1px solid var(--ts-logo-border); border-radius: 50%; - background: radial-gradient(circle at 35% 25%, rgba(117, 244, 210, 0.35), transparent 55%); - box-shadow: 0 0 28px rgba(117, 244, 210, 0.16); + background: var(--ts-logo-bg); + box-shadow: var(--ts-logo-shadow); color: var(--color-primary); font-size: 1.05rem; } @@ -230,7 +269,7 @@ select:focus-visible { padding: 0.25rem; border: 1px solid var(--ts-line); border-radius: 999px; - background: rgba(3, 6, 7, 0.42); + background: var(--ts-tabs-bg); } .ts-shell-tab { @@ -254,10 +293,10 @@ select:focus-visible { } .ts-shell-tab-active { - background: linear-gradient(180deg, rgba(117, 244, 210, 0.18), rgba(122, 167, 255, 0.1)); - border-color: rgba(117, 244, 210, 0.36); + background: var(--ts-tab-active-bg); + border-color: var(--ts-tab-active-border); color: var(--ts-text); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 22px rgba(117, 244, 210, 0.12); + box-shadow: var(--ts-tab-active-shadow); } .ts-shell-main { @@ -282,15 +321,13 @@ select:focus-visible { text-align: center; border: 1px solid var(--ts-line); border-radius: var(--ts-radius); - background: rgba(13, 27, 26, 0.62); + background: var(--ts-loading-bg); } /* ------------------------------------------------- SDK component shims */ .ts-card { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 34%), - rgba(13, 27, 26, 0.78); + background: var(--ts-card-bg); border: 1px solid var(--ts-line); border-radius: var(--ts-radius); box-shadow: var(--ts-shadow); @@ -325,7 +362,7 @@ select:focus-visible { font-family: var(--theme-font-mono); font-size: 0.7rem; color: var(--ts-text-2); - background: rgba(3, 6, 7, 0.28); + background: var(--ts-badge-bg); } .ts-button { @@ -335,10 +372,10 @@ select:focus-visible { justify-content: center; gap: 0.35rem; min-height: 2.1rem; - background: linear-gradient(180deg, var(--ts-cyan), #42d7bd); - border: 1px solid rgba(117, 244, 210, 0.65); + background: linear-gradient(180deg, var(--ts-cyan), var(--ts-button-bg-2)); + border: 1px solid var(--ts-button-border); border-radius: 999px; - color: #03100e; + color: var(--ts-button-fg); cursor: pointer; font-family: var(--theme-font-mono); font-size: 0.76rem; @@ -367,7 +404,7 @@ select:focus-visible { .ts-button-outline, .ts-button-ghost, .ts-button-secondary { - background: rgba(3, 6, 7, 0.18); + background: var(--ts-button-secondary-bg); border-color: var(--ts-line); color: var(--ts-text-2); } @@ -380,14 +417,14 @@ select:focus-visible { .ts-button-outline:hover:not(:disabled), .ts-button-ghost:hover:not(:disabled), .ts-button-secondary:hover:not(:disabled) { - border-color: rgba(117, 244, 210, 0.42); + border-color: var(--ts-button-hover-border); color: var(--ts-text); - background: rgba(117, 244, 210, 0.08); + background: var(--ts-button-hover-bg); } .ts-button-destructive { - background: linear-gradient(180deg, var(--ts-red), #d84f5d); - border-color: color-mix(in srgb, var(--ts-red) 70%, transparent); + background: linear-gradient(180deg, var(--ts-red), var(--ts-button-destructive-bg-2)); + border-color: var(--ts-button-destructive-border); color: #fff; } @@ -404,7 +441,7 @@ select:focus-visible { } .ts-input { - background: rgba(3, 6, 7, 0.42); + background: var(--ts-input-bg); border: 1px solid var(--ts-line); border-radius: 999px; color: var(--ts-text); @@ -418,9 +455,9 @@ select:focus-visible { } .ts-input:focus { - border-color: rgba(117, 244, 210, 0.52); - background: rgba(3, 6, 7, 0.62); - box-shadow: 0 0 0 4px rgba(117, 244, 210, 0.08); + border-color: var(--ts-input-focus-border); + background: var(--ts-input-focus-bg); + box-shadow: var(--ts-input-focus-shadow); outline: none; } @@ -471,7 +508,7 @@ select:focus-visible { /* Connection indicator */ .ts-conn-indicator { appearance: none; - background: transparent; + background: var(--ts-conn-bg); border: 1px solid var(--ts-line); border-radius: 999px; cursor: default; @@ -548,7 +585,7 @@ select:focus-visible { .ts-theme-toggle:hover { border-color: var(--ts-line-strong); color: var(--ts-text); - background: rgba(117, 244, 210, 0.07); + background: var(--ts-toggle-hover-bg); } /* ------------------------------------------------------------- disconnected banner */ @@ -617,8 +654,10 @@ select:focus-visible { /* ========================================================= LIGHT THEME === * Dark values (above) are the default. When is set - * by the theme toggle, these overrides take precedence. Plugin bundles consume - * the same custom properties, so they pick up the light palette automatically. + * by the theme toggle, this block redefines every themed custom property — + * both the palette tokens and the semantic component tokens — so component + * rules (above) theme automatically with no per-rule overrides. Plugin + * bundles consume the same custom properties and pick up the light palette too. * ======================================================================== */ :root[data-theme="light"] { @@ -645,6 +684,50 @@ select:focus-visible { --ts-green: #1a7a48; --ts-shadow: 0 24px 80px rgba(0, 0, 0, 0.1); + /* Semantic component tokens — light values (verbatim from the former + per-component [data-theme="light"] overrides, now removed). */ + --ts-page-bg: + radial-gradient(circle at 12% -10%, rgba(0, 137, 123, 0.1), transparent 28rem), + radial-gradient(circle at 80% 0%, rgba(58, 95, 192, 0.07), transparent 30rem), + linear-gradient(180deg, #eef4f2 0%, #e5f0ed 100%); + --ts-grid-image: + linear-gradient(rgba(0, 120, 100, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 120, 100, 0.03) 1px, transparent 1px); + --ts-grid-opacity: 0.6; + --ts-header-bg: + linear-gradient(90deg, rgba(245, 250, 248, 0.96), rgba(235, 248, 243, 0.9)), + rgba(245, 250, 248, 0.88); + --ts-header-shadow: 0 6px 24px rgba(0, 0, 0, 0.08); + --ts-logo-bg: radial-gradient(circle at 35% 25%, rgba(0, 137, 123, 0.25), transparent 55%); + --ts-logo-border: rgba(0, 137, 123, 0.3); + --ts-logo-shadow: 0 0 20px rgba(0, 137, 123, 0.1); + --ts-tabs-bg: rgba(230, 244, 240, 0.5); + --ts-tab-active-bg: linear-gradient(180deg, rgba(0, 137, 123, 0.14), rgba(58, 95, 192, 0.08)); + --ts-tab-active-border: rgba(0, 137, 123, 0.32); + --ts-tab-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 0 16px rgba(0, 137, 123, 0.1); + --ts-card-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(245, 250, 248, 0.8)), + rgba(255, 255, 255, 0.85); + --ts-button-bg-2: #00695f; + --ts-button-border: rgba(0, 121, 107, 0.6); + --ts-button-fg: #ffffff; + --ts-button-secondary-bg: rgba(245, 250, 248, 0.7); + --ts-button-hover-bg: rgba(0, 137, 123, 0.08); + --ts-button-hover-border: rgba(0, 137, 123, 0.36); + --ts-button-destructive-bg-2: #a0202e; + --ts-button-destructive-border: color-mix(in srgb, var(--ts-red) 65%, transparent); + --ts-input-bg: rgba(255, 255, 255, 0.8); + --ts-input-focus-bg: rgba(255, 255, 255, 0.95); + --ts-input-focus-border: rgba(0, 137, 123, 0.5); + --ts-input-focus-shadow: 0 0 0 4px rgba(0, 137, 123, 0.08); + --ts-badge-bg: rgba(0, 137, 123, 0.06); + --ts-conn-bg: rgba(245, 250, 248, 0.6); + --ts-toggle-hover-bg: rgba(0, 137, 123, 0.07); + --ts-loading-bg: rgba(255, 255, 255, 0.7); + --ts-scrollbar-track: rgba(0, 0, 0, 0.04); + --ts-scrollbar-thumb: rgba(0, 137, 123, 0.22); + --ts-scrollbar-thumb-border: rgba(240, 248, 244, 0.9); + /* Hermes-compatible host vars */ --background: var(--ts-bg); --background-base: 245 250 248; @@ -672,130 +755,11 @@ select:focus-visible { --color-success: var(--ts-green); } -/* Light body background (overrides the dark gradient on html/body) */ -[data-theme="light"] body { - background: - radial-gradient(circle at 12% -10%, rgba(0, 137, 123, 0.1), transparent 28rem), - radial-gradient(circle at 80% 0%, rgba(58, 95, 192, 0.07), transparent 30rem), - linear-gradient(180deg, #eef4f2 0%, #e5f0ed 100%); -} - -/* Light grid overlay */ -[data-theme="light"] .ts-shell::before { - background-image: - linear-gradient(rgba(0, 120, 100, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 120, 100, 0.03) 1px, transparent 1px); - opacity: 0.6; -} - -/* Light header */ -[data-theme="light"] .ts-shell-header { - background: - linear-gradient(90deg, rgba(245, 250, 248, 0.96), rgba(235, 248, 243, 0.9)), - rgba(245, 250, 248, 0.88); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08); -} - -/* Light logo */ -[data-theme="light"] .ts-shell-logo { - background: radial-gradient(circle at 35% 25%, rgba(0, 137, 123, 0.25), transparent 55%); - border-color: rgba(0, 137, 123, 0.3); - box-shadow: 0 0 20px rgba(0, 137, 123, 0.1); -} - -/* Light tab pill container */ -[data-theme="light"] .ts-shell-tabs { - background: rgba(230, 244, 240, 0.5); -} - -/* Light active tab */ -[data-theme="light"] .ts-shell-tab-active { - background: linear-gradient(180deg, rgba(0, 137, 123, 0.14), rgba(58, 95, 192, 0.08)); - border-color: rgba(0, 137, 123, 0.32); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 0 16px rgba(0, 137, 123, 0.1); -} - -/* Light card */ -[data-theme="light"] .ts-card { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(245, 250, 248, 0.8)), - rgba(255, 255, 255, 0.85); -} - -/* Light buttons */ -[data-theme="light"] .ts-button { - background: linear-gradient(180deg, var(--ts-cyan), #00695f); - border-color: rgba(0, 121, 107, 0.6); - color: #ffffff; -} - -[data-theme="light"] .ts-button-outline, -[data-theme="light"] .ts-button-ghost, -[data-theme="light"] .ts-button-secondary { - background: rgba(245, 250, 248, 0.7); - border-color: var(--ts-line); - color: var(--ts-text-2); -} - -[data-theme="light"] .ts-button-ghost { - background: transparent; - border-color: transparent; -} - -[data-theme="light"] .ts-button-outline:hover:not(:disabled), -[data-theme="light"] .ts-button-ghost:hover:not(:disabled), -[data-theme="light"] .ts-button-secondary:hover:not(:disabled) { - background: rgba(0, 137, 123, 0.08); - border-color: rgba(0, 137, 123, 0.36); - color: var(--ts-text); -} - -[data-theme="light"] .ts-button-destructive { - background: linear-gradient(180deg, var(--ts-red), #a0202e); - border-color: color-mix(in srgb, var(--ts-red) 65%, transparent); - color: #fff; -} - -/* Light inputs */ -[data-theme="light"] .ts-input { - background: rgba(255, 255, 255, 0.8); - color: var(--ts-text); -} - -[data-theme="light"] .ts-input:focus { - background: rgba(255, 255, 255, 0.95); - border-color: rgba(0, 137, 123, 0.5); - box-shadow: 0 0 0 4px rgba(0, 137, 123, 0.08); -} - -/* Light badge */ -[data-theme="light"] .ts-badge { - background: rgba(0, 137, 123, 0.06); -} - -/* Light controls */ -[data-theme="light"] .ts-conn-indicator { - background: rgba(245, 250, 248, 0.6); -} - -[data-theme="light"] .ts-theme-toggle:hover { - background: rgba(0, 137, 123, 0.07); -} - -/* Light loading / error panels */ -[data-theme="light"] .ts-shell-loading { - background: rgba(255, 255, 255, 0.7); -} - -/* Light scrollbars */ -[data-theme="light"] ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.04); -} - -[data-theme="light"] ::-webkit-scrollbar-thumb { - background: rgba(0, 137, 123, 0.22); - border-color: rgba(240, 248, 244, 0.9); -} +/* Per-component [data-theme="light"] overrides were here historically. They + have been folded into the semantic component tokens defined in + :root and :root[data-theme="light"] above, so each component rule is now + authored once and themes automatically. No rule was left as an explicit + override — every former override reduced cleanly to a full-value token swap. */ /* ---------------------------------------------------------------- responsive */ From fee43ef1c8b56393294ac8786abc42ef0b069e58 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 01:40:54 +0200 Subject: [PATCH 07/35] fix(storage): enforce project path guards in MCP handlers Route tracedecay info/todos reads and symbol-edit writes through ProjectPath resolution so out-of-root paths are rejected while valid files still populate touched context. Also update dashboard asset wording to describe UTF-8 JavaScript output independent of bundler. --- src/dashboard/assets.rs | 2 +- src/mcp/tools/handlers/info.rs | 25 ++++++++++++++++--------- src/tracedecay.rs | 12 ++++++++---- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/dashboard/assets.rs b/src/dashboard/assets.rs index bc5f9a50..416c5c2c 100644 --- a/src/dashboard/assets.rs +++ b/src/dashboard/assets.rs @@ -25,7 +25,7 @@ const INDEX_HTML: &str = r#" const SHELL_JS: &[u8] = include_bytes!("../../dashboard/shell/dist/shell.js"); const SHELL_CSS: &[u8] = include_bytes!("../../dashboard/shell/dist/shell.css"); -// Plugin bundles are embedded as &str (they are UTF-8 esbuild output) so the +// Plugin bundles are embedded as &str (they are UTF-8 JavaScript output) so the // Hermes installer (src/agents/hermes/dashboard_wrapper.rs) can reuse the exact same // embedded data when writing the wrapper plugin's dist files to disk. pub(crate) const HOLOGRAPHIC_JS: &str = include_str!("../../dashboard/holographic/dist/index.js"); diff --git a/src/mcp/tools/handlers/info.rs b/src/mcp/tools/handlers/info.rs index 6a4896ca..6ec41789 100644 --- a/src/mcp/tools/handlers/info.rs +++ b/src/mcp/tools/handlers/info.rs @@ -1379,14 +1379,19 @@ pub(super) async fn handle_body( for result in &chosen { let n = &result.node; - let abs_path = project_root.join(&n.file_path); - let body = match crate::sync::read_source_file(&abs_path) { - Ok(source) => extract_lines(&source, n.start_line, n.end_line), - Err(_) => String::from(""), + let project_path = ProjectPath::resolve(project_root, Path::new(&n.file_path)); + let body = match project_path { + Ok(ref path) => match crate::sync::read_source_file(&path.absolute_path()) { + Ok(source) => { + if !touched.contains(&n.file_path) { + touched.push(n.file_path.clone()); + } + extract_lines(&source, n.start_line, n.end_line) + } + Err(_) => String::from(""), + }, + Err(_) => String::from(""), }; - if !touched.contains(&n.file_path) { - touched.push(n.file_path.clone()); - } matches.push(json!({ "id": n.id, "name": n.name, @@ -1533,8 +1538,10 @@ pub(super) async fn handle_todos( continue; } } - let abs_path = project_root.join(&file.path); - let Ok(source) = crate::sync::read_source_file(&abs_path) else { + let Ok(project_path) = ProjectPath::resolve(project_root, Path::new(&file.path)) else { + continue; + }; + let Ok(source) = crate::sync::read_source_file(&project_path.absolute_path()) else { continue; }; // Cache nodes per file so enclosing-symbol lookup is one DB call per diff --git a/src/tracedecay.rs b/src/tracedecay.rs index 7d35c592..f41503a6 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -2092,8 +2092,10 @@ impl TraceDecay { /// the wrong site. pub async fn replace_symbol(&self, symbol: &str, new_source: &str) -> Result { let target = resolve_symbol_for_edit(self, symbol).await?; - let rel_path = target.file_path.clone(); - let abs_path = self.absolute_path(&rel_path); + let project_path = + crate::storage::ProjectPath::resolve(&self.project_root, Path::new(&target.file_path))?; + let rel_path = project_path.relative_path_string(); + let abs_path = project_path.absolute_path(); let source = std::fs::read_to_string(&abs_path).map_err(|e| TraceDecayError::Config { message: format!("failed to read {rel_path}: {e}"), })?; @@ -2162,8 +2164,10 @@ impl TraceDecay { } }; let target = resolve_symbol_for_edit(self, symbol).await?; - let rel_path = target.file_path.clone(); - let abs_path = self.absolute_path(&rel_path); + let project_path = + crate::storage::ProjectPath::resolve(&self.project_root, Path::new(&target.file_path))?; + let rel_path = project_path.relative_path_string(); + let abs_path = project_path.absolute_path(); let source = std::fs::read_to_string(&abs_path).map_err(|e| TraceDecayError::Config { message: format!("failed to read {rel_path}: {e}"), })?; From dfcdb2ec37975fb6eab350b7817c7501daf39fdf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 02:15:42 +0200 Subject: [PATCH 08/35] build(dashboard): consolidate on Rsbuild, remove esbuild, add typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rsbuild is now the single build tool. build.mjs is a thin orchestrator over build.shared.mjs, which builds the shell + every plugin via createRsbuild (@rsbuild/core + pluginReact), emitting the same single-file IIFE per plugin at the exact dist/ paths (splitChunks/runtimeChunk off, BannerPlugin, React externalized via the in-tree shims). The dev server config is shared there too (createDashboardDevConfig). Holographic Tailwind v4 still compiles via the programmatic @tailwindcss/node + oxide path (strip @layer theme/base, wrap @layer hermes-plugin), not the segfault-prone Rsbuild tailwind plugins. esbuild is fully removed: the unit-test bundler (test/helpers/module-loader.mjs) now bundles with @rspack/core to an ESM temp module (100/100 node tests still pass, ~unchanged runtime), and esbuild is dropped from devDependencies (no longer in node_modules at all). Redundant build-rsbuild.mjs/rsbuild.config.ts alternatives removed. Added dashboard/tsconfig.json + `npm run typecheck` (tsc --noEmit) with typescript as a devDep. The typecheck is intentionally lenient (strict:false) to surface real bugs over intentional host-SDK `any` noise — it already caught two real undefined-ref crashes (fixed in the follow-up commit). Verified: Rsbuild build emits all 14 artifacts; 100/100 node + 12/12 vitest; real-browser Playwright smoke passes against the cargo-embedded Rsbuild binary. --- dashboard/build.mjs | 253 +--------- dashboard/build.shared.mjs | 272 +++++++++++ dashboard/dev/main.tsx | 11 +- dashboard/dev/run.mjs | 43 +- dashboard/holographic/build.from-hermes.mjs | 145 +----- dashboard/holographic/src/jsx-runtime.ts | 2 +- dashboard/holographic/src/react-shim.ts | 4 +- dashboard/package-lock.json | 500 +------------------- dashboard/package.json | 7 +- dashboard/test/helpers/module-loader.mjs | 78 ++- dashboard/tsconfig.json | 22 + 11 files changed, 417 insertions(+), 920 deletions(-) create mode 100644 dashboard/build.shared.mjs create mode 100644 dashboard/tsconfig.json diff --git a/dashboard/build.mjs b/dashboard/build.mjs index 9fc18ae5..d5b33d74 100644 --- a/dashboard/build.mjs +++ b/dashboard/build.mjs @@ -16,253 +16,26 @@ * changed. */ -import { rspack } from "@rspack/core"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import fs from "node:fs/promises"; - -const root = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(path.join(root, "package.json")); - -function swcRule(syntax, test) { - const isTs = syntax === "typescript"; - return { - test, - exclude: /node_modules/, - use: { - loader: "builtin:swc-loader", - options: { - jsc: { - parser: isTs - ? { syntax: "typescript", tsx: true } - : { syntax: "ecmascript", jsx: true }, - transform: { react: { runtime: "automatic" } }, - }, - env: { targets: "defaults" }, - }, - }, - }; -} - -function run(config) { - return new Promise((resolve, reject) => { - rspack(config, (err, stats) => { - if (err) return reject(err); - if (stats.hasErrors()) { - const info = stats.toJson({ all: false, errors: true }); - return reject(new Error(info.errors.map((e) => e.message).join("\n"))); - } - resolve(stats); - }); - }); -} - -const RULES = [swcRule("ecmascript", /\.(jsx|js)$/), swcRule("typescript", /\.(tsx|ts)$/)]; - -function shellConfig() { - return { - mode: "production", - context: root, - entry: { shell: "./shell/src/main.jsx" }, - output: { - path: path.join(root, "shell/dist"), - filename: "shell.js", - clean: true, - }, - resolve: { extensions: [".jsx", ".js", ".json", ".ts", ".tsx"] }, - module: { rules: RULES }, - optimization: { minimize: true, splitChunks: false, runtimeChunk: false }, - performance: { hints: false }, - }; -} - -async function buildShell() { - await run(shellConfig()); - await fs.copyFile( - path.join(root, "shell/src/styles.css"), - path.join(root, "shell/dist/shell.css"), - ); -} - -/** - * Builds one plugin bundle with React externalized onto the host SDK via shims. - */ -function pluginConfig(dir, shimDir, bannerLabel) { - const srcDir = path.join(root, dir, "src"); - return { - mode: "production", - context: root, - target: "web", - entry: { index: path.join(srcDir, "entry.tsx") }, - output: { - path: path.join(root, dir, "dist"), - filename: "index.js", - clean: true, - }, - resolve: { - extensions: [".tsx", ".ts", ".jsx", ".js", ".json"], - alias: { - "react$": path.join(shimDir, "react-shim.ts"), - "react/jsx-runtime$": path.join(shimDir, "jsx-runtime.ts"), - "react/jsx-dev-runtime$": path.join(shimDir, "jsx-runtime.ts"), - }, - }, - module: { rules: RULES }, - optimization: { minimize: true, splitChunks: false, runtimeChunk: false }, - performance: { hints: false }, - plugins: [ - new rspack.BannerPlugin({ - banner: `tracedecay ${bannerLabel} dashboard plugin - bundled with Rspack. Do not edit; see src/.`, - entryOnly: true, - }), - ], - }; -} - -async function buildPlugin( - dir, - bannerLabel, - { shimDir = path.join(root, "lib"), tailwind = false, primitives = false } = {}, -) { - await run(pluginConfig(dir, shimDir, bannerLabel)); - if (tailwind) { - await compileTailwindCss(path.join(root, dir, "src"), path.join(root, dir, "dist/style.css")); - } else { - await fs.copyFile( - path.join(root, dir, "src/styles.css"), - path.join(root, dir, "dist/style.css"), - ); - } - if (primitives) { - // Prepend the shared lib/primitives.css so the tdp-* classes reach both - // hosts (standalone shell loads /dist/style.css; the Hermes - // wrapper concatenates the same file). - const distCss = path.join(root, dir, "dist/style.css"); - const [prim, plugin] = await Promise.all([ - fs.readFile(path.join(root, "lib/primitives.css"), "utf8"), - fs.readFile(distCss, "utf8"), - ]); - await fs.writeFile(distCss, `${prim}\n${plugin}`, "utf8"); - } -} - -/** - * Compile a plugin stylesheet with real Tailwind v4 (programmatic Oxide scan + - * @tailwindcss/node compile). Mirrors the proven build.from-hermes.mjs path: - * - * - scan the plugin src for class candidates; - * - strip @layer theme + @layer base so the plugin never clobbers the host's - * :root vars or preflight (utilities resolve --color-* against the host); - * - confine the sheet to the host's `hermes-plugin` cascade layer; - * - minify with a small CSS compactor (preserves @supports color-mix blocks - * that lightningcss would strip; no esbuild dependency). - */ -async function compileTailwindCss(srcDir, outFile) { - const { compile } = require("@tailwindcss/node"); - const { Scanner } = require("@tailwindcss/oxide"); - const input = await fs.readFile(path.join(srcDir, "styles.css"), "utf8"); - const compiler = await compile(input, { base: root, onDependency: () => {} }); - const scanner = new Scanner({ sources: [{ base: srcDir, pattern: "**/*", negated: false }] }); - const candidates = scanner.scan(); - let css = compiler.build(candidates); - css = stripTopLevelAtLayer(css, "theme"); - css = stripTopLevelAtLayer(css, "base"); - css = `@layer hermes-plugin{\n${css}\n}`; - css = minifyCss(css); - await fs.writeFile(outFile, css, "utf8"); -} - -/** - * Small CSS minifier: strip comments, collapse whitespace, drop space around - * CSS punctuation. Safe for selector/declaration CSS (no @import/url string - * surgery). Preserves @supports and content:"..." strings (spaces inside - * strings aren't adjacent to the punctuation we trim). - */ -function minifyCss(css) { - return css - .replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, "") - .replace(/\s+/g, " ") - .replace(/\s*([{}:;,>])\s*/g, "$1") - .replace(/;}/g, "}") - .trim(); -} - -/** Remove a top-level `@layer { ... }` block via brace matching. - * Matches `@layer name{` or `@layer name {` (any whitespace). */ -function stripTopLevelAtLayer(css, name) { - const re = new RegExp(`@layer\\s+${name}\\s*\\{`, "g"); - let out = css; - let m; - while ((m = re.exec(out)) !== null) { - const idx = m.index; - let i = idx + m[0].length; - let depth = 1; - while (i < out.length && depth > 0) { - const ch = out[i]; - if (ch === "{") depth++; - else if (ch === "}") depth--; - i++; - } - out = out.slice(0, idx) + out.slice(i); - re.lastIndex = idx; - } - return out; -} - -/** - * Builds the combined Hermes plugin from the child dashboard bundles. - */ -async function buildHermesWrapper() { - const dist = path.join(root, "hermes-wrapper/dist"); - await fs.mkdir(dist, { recursive: true }); - await fs.copyFile(path.join(root, "hermes-wrapper/src/entry.js"), path.join(dist, "index.js")); - await fs.copyFile(path.join(root, "holographic/dist/index.js"), path.join(dist, "holographic.js")); - await fs.copyFile(path.join(root, "lcm/dist/index.js"), path.join(dist, "lcm.js")); - await fs.copyFile(path.join(root, "graph/dist/index.js"), path.join(dist, "graph.js")); - await fs.copyFile(path.join(root, "savings/dist/index.js"), path.join(dist, "savings.js")); - const css = await Promise.all([ - fs.readFile(path.join(root, "hermes-wrapper/src/wrapper.css"), "utf8"), - fs.readFile(path.join(root, "holographic/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "lcm/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "graph/dist/style.css"), "utf8"), - fs.readFile(path.join(root, "savings/dist/style.css"), "utf8"), - ]); - await fs.writeFile(path.join(dist, "style.css"), css.join("\n"), "utf8"); -} +import { + buildHermesWrapper, + buildHolographicPlugin, + buildPlugin, + buildShell, + EMBEDDED_DIST_FILES, + HERMES_WRAPPER_DIST_FILES, + logBuiltFiles, +} from "./build.shared.mjs"; async function main() { - await fs.mkdir(path.join(root, "shell/dist"), { recursive: true }); await Promise.all([ buildShell(), - buildPlugin("holographic", "holographic-memory", { - shimDir: path.join(root, "holographic/src"), - tailwind: true, - }), + buildHolographicPlugin(), buildPlugin("graph", "code graph", { primitives: true }), - buildPlugin("savings", "savings & cost"), - buildPlugin("lcm", "LCM"), + buildPlugin("savings", "savings & cost", { primitives: true }), + buildPlugin("lcm", "LCM", { primitives: true }), ]); await buildHermesWrapper(); - for (const f of [ - "shell/dist/shell.js", - "shell/dist/shell.css", - "holographic/dist/index.js", - "holographic/dist/style.css", - "lcm/dist/index.js", - "lcm/dist/style.css", - "graph/dist/index.js", - "graph/dist/style.css", - "savings/dist/index.js", - "savings/dist/style.css", - "hermes-wrapper/dist/index.js", - "hermes-wrapper/dist/graph.js", - "hermes-wrapper/dist/savings.js", - "hermes-wrapper/dist/style.css", - ]) { - const st = await fs.stat(path.join(root, f)); - console.log(`✓ ${f} ${(st.size / 1024).toFixed(1)} KB`); - } + await logBuiltFiles([...EMBEDDED_DIST_FILES, ...HERMES_WRAPPER_DIST_FILES]); } main().catch((err) => { diff --git a/dashboard/build.shared.mjs b/dashboard/build.shared.mjs new file mode 100644 index 00000000..e71d748a --- /dev/null +++ b/dashboard/build.shared.mjs @@ -0,0 +1,272 @@ +import { createRsbuild, rspack } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs/promises"; + +export const dashboardRoot = path.dirname(fileURLToPath(import.meta.url)); + +const require = createRequire(path.join(dashboardRoot, "package.json")); +const EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".json"]; + +export const EMBEDDED_DIST_FILES = [ + "shell/dist/shell.js", + "shell/dist/shell.css", + "holographic/dist/index.js", + "holographic/dist/style.css", + "lcm/dist/index.js", + "lcm/dist/style.css", + "graph/dist/index.js", + "graph/dist/style.css", + "savings/dist/index.js", + "savings/dist/style.css", +]; + +export const HERMES_WRAPPER_DIST_FILES = [ + "hermes-wrapper/dist/index.js", + "hermes-wrapper/dist/holographic.js", + "hermes-wrapper/dist/lcm.js", + "hermes-wrapper/dist/graph.js", + "hermes-wrapper/dist/savings.js", + "hermes-wrapper/dist/style.css", +]; + +function rsbuildEntry(importPath) { + return { import: importPath, html: false }; +} + +function applySingleBundleOutput(config, bannerLabel) { + config.optimization = { + ...(config.optimization || {}), + splitChunks: false, + runtimeChunk: false, + }; + config.performance = { ...(config.performance || {}), hints: false }; + + if (bannerLabel) { + config.plugins.push( + new rspack.BannerPlugin({ + banner: `tracedecay ${bannerLabel} dashboard plugin - bundled with Rsbuild/Rspack. Do not edit; see src/.`, + entryOnly: true, + }), + ); + } +} + +function createBundleConfig({ entryName, entry, outDir, filename, alias = {}, bannerLabel }) { + return { + root: dashboardRoot, + mode: "production", + source: { + entry: { [entryName]: rsbuildEntry(entry) }, + define: { "process.env.NODE_ENV": JSON.stringify("production") }, + }, + resolve: { + alias, + extensions: EXTENSIONS, + }, + output: { + distPath: { root: outDir, js: "." }, + filename: { js: filename }, + filenameHash: false, + cleanDistPath: true, + legalComments: "none", + minify: true, + }, + performance: { + chunkSplit: { strategy: "all-in-one" }, + printFileSize: false, + }, + plugins: [pluginReact()], + tools: { + rspack(config) { + applySingleBundleOutput(config, bannerLabel); + }, + }, + }; +} + +export function createShellBuildConfig() { + return createBundleConfig({ + entryName: "shell", + entry: "./shell/src/main.jsx", + outDir: path.join(dashboardRoot, "shell/dist"), + filename: "shell.js", + }); +} + +export function createPluginBuildConfig( + dir, + bannerLabel, + { shimDir = path.join(dashboardRoot, "lib") } = {}, +) { + return createBundleConfig({ + entryName: "index", + entry: `./${dir}/src/entry.tsx`, + outDir: path.join(dashboardRoot, dir, "dist"), + filename: "index.js", + bannerLabel, + alias: { + "react$": path.join(shimDir, "react-shim.ts"), + "react/jsx-runtime$": path.join(shimDir, "jsx-runtime.ts"), + "react/jsx-dev-runtime$": path.join(shimDir, "jsx-runtime.ts"), + }, + }); +} + +export function createDashboardDevConfig({ apiTarget, host, port }) { + return { + root: dashboardRoot, + source: { + entry: { index: "./dev/main.tsx" }, + }, + html: { + template: "./dev/index.html", + title: "tracedecay dashboard (dev)", + }, + server: { + host, + port, + proxy: { + "/api": { + target: apiTarget, + changeOrigin: true, + }, + }, + }, + plugins: [pluginReact()], + }; +} + +export async function runRsbuildConfig(rsbuildConfig) { + const rsbuild = await createRsbuild({ cwd: dashboardRoot, rsbuildConfig }); + const result = await rsbuild.build(); + const stats = result.stats; + if (stats?.hasErrors?.()) { + const info = stats.toJson({ all: false, errors: true }); + throw new Error(info.errors.map((error) => error.message).join("\n")); + } + await result.close?.(); + return stats; +} + +export async function buildShell() { + await runRsbuildConfig(createShellBuildConfig()); + await fs.copyFile( + path.join(dashboardRoot, "shell/src/styles.css"), + path.join(dashboardRoot, "shell/dist/shell.css"), + ); +} + +export async function buildPlugin( + dir, + bannerLabel, + { shimDir = path.join(dashboardRoot, "lib"), tailwind = false, primitives = false } = {}, +) { + await runRsbuildConfig(createPluginBuildConfig(dir, bannerLabel, { shimDir })); + const distCss = path.join(dashboardRoot, dir, "dist/style.css"); + await fs.mkdir(path.dirname(distCss), { recursive: true }); + if (tailwind) { + await compileTailwindCss(path.join(dashboardRoot, dir, "src"), distCss); + } else { + await fs.copyFile(path.join(dashboardRoot, dir, "src/styles.css"), distCss); + } + if (primitives) { + const [prim, plugin] = await Promise.all([ + fs.readFile(path.join(dashboardRoot, "lib/primitives.css"), "utf8"), + fs.readFile(distCss, "utf8"), + ]); + await fs.writeFile(distCss, `${prim}\n${plugin}`, "utf8"); + } +} + +export async function buildHolographicPlugin() { + await buildPlugin("holographic", "holographic-memory", { + shimDir: path.join(dashboardRoot, "holographic/src"), + tailwind: true, + }); +} + +export async function compileHolographicTailwindCss() { + await compileTailwindCss( + path.join(dashboardRoot, "holographic/src"), + path.join(dashboardRoot, "holographic/dist/style.css"), + ); +} + +export async function compileTailwindCss(srcDir, outFile) { + const { compile } = require("@tailwindcss/node"); + const { Scanner } = require("@tailwindcss/oxide"); + const input = await fs.readFile(path.join(srcDir, "styles.css"), "utf8"); + const compiler = await compile(input, { base: dashboardRoot, onDependency: () => {} }); + const sources = + compiler.root === "none" + ? [] + : compiler.root === null + ? [{ base: srcDir, pattern: "**/*", negated: false }] + : [{ ...compiler.root, negated: false }]; + const scanner = new Scanner({ sources: sources.concat(compiler.sources ?? []) }); + const candidates = scanner.scan(); + let css = compiler.build(candidates); + css = stripTopLevelAtLayer(css, "theme"); + css = stripTopLevelAtLayer(css, "base"); + css = `@layer hermes-plugin{\n${css}\n}`; + css = minifyCss(css); + await fs.mkdir(path.dirname(outFile), { recursive: true }); + await fs.writeFile(outFile, css, "utf8"); +} + +export function minifyCss(css) { + return css + .replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, "") + .replace(/\s+/g, " ") + .replace(/\s*([{}:;,>])\s*/g, "$1") + .replace(/;}/g, "}") + .trim(); +} + +export function stripTopLevelAtLayer(css, name) { + const re = new RegExp(`@layer\\s+${name}\\s*\\{`, "g"); + let out = css; + let match; + while ((match = re.exec(out)) !== null) { + const idx = match.index; + let i = idx + match[0].length; + let depth = 1; + while (i < out.length && depth > 0) { + const ch = out[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + out = out.slice(0, idx) + out.slice(i); + re.lastIndex = idx; + } + return out; +} + +export async function buildHermesWrapper() { + const dist = path.join(dashboardRoot, "hermes-wrapper/dist"); + await fs.mkdir(dist, { recursive: true }); + await fs.copyFile(path.join(dashboardRoot, "hermes-wrapper/src/entry.js"), path.join(dist, "index.js")); + await fs.copyFile(path.join(dashboardRoot, "holographic/dist/index.js"), path.join(dist, "holographic.js")); + await fs.copyFile(path.join(dashboardRoot, "lcm/dist/index.js"), path.join(dist, "lcm.js")); + await fs.copyFile(path.join(dashboardRoot, "graph/dist/index.js"), path.join(dist, "graph.js")); + await fs.copyFile(path.join(dashboardRoot, "savings/dist/index.js"), path.join(dist, "savings.js")); + const css = await Promise.all([ + fs.readFile(path.join(dashboardRoot, "hermes-wrapper/src/wrapper.css"), "utf8"), + fs.readFile(path.join(dashboardRoot, "holographic/dist/style.css"), "utf8"), + fs.readFile(path.join(dashboardRoot, "lcm/dist/style.css"), "utf8"), + fs.readFile(path.join(dashboardRoot, "graph/dist/style.css"), "utf8"), + fs.readFile(path.join(dashboardRoot, "savings/dist/style.css"), "utf8"), + ]); + await fs.writeFile(path.join(dist, "style.css"), css.join("\n"), "utf8"); +} + +export async function logBuiltFiles(files) { + for (const file of files) { + const stat = await fs.stat(path.join(dashboardRoot, file)); + console.log(`✓ ${file} ${(stat.size / 1024).toFixed(1)} KB`); + } +} diff --git a/dashboard/dev/main.tsx b/dashboard/dev/main.tsx index f8272313..0469e35a 100644 --- a/dashboard/dev/main.tsx +++ b/dashboard/dev/main.tsx @@ -40,16 +40,13 @@ import { buildSDK } from "../shell/src/sdk.jsx"; // Shell theme tokens + SDK component classes (.ts-*). Plugin CSS layers below // consume the same CSS variables. import "../shell/src/styles.css"; +import "../lib/primitives.css"; import "../graph/src/styles.css"; import "../savings/src/styles.css"; import "../lcm/src/styles.css"; -// Holographic styles begin with `@import "tailwindcss"` (Tailwind v4). In this -// execution environment both Tailwind-v4 integrations Rsbuild documents make -// createRsbuild() segfault, so the dev server (dev/run.mjs) ships NO Tailwind -// pipeline — this CSS is passed through uncompiled and holographic renders -// unstyled in dev. The prod build (`npm run build`) is the source of truth for -// holographic's Tailwind styles. See run.mjs for the full divergence note. -import "../holographic/src/styles.css"; +// Holographic styles are generated by dev/run.mjs with the same Tailwind v4 +// compiler path as production before Rsbuild resolves this import. +import "../holographic/dist/style.css"; // --------------------------------------------------------------------------- // SDK + plugin registry — populated BEFORE plugin entries are imported. diff --git a/dashboard/dev/run.mjs b/dashboard/dev/run.mjs index 68d9e914..b6c65745 100644 --- a/dashboard/dev/run.mjs +++ b/dashboard/dev/run.mjs @@ -30,9 +30,12 @@ */ import { createRsbuild } from "@rsbuild/core"; -import { pluginReact } from "@rsbuild/plugin-react"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + compileHolographicTailwindCss, + createDashboardDevConfig, +} from "../build.shared.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dashboardRoot = path.resolve(__dirname, ".."); @@ -41,41 +44,9 @@ const apiTarget = process.env.TRACEDECAY_DEV_API || "http://127.0.0.1:7341"; const port = Number(process.env.TRACEDECAY_DEV_PORT || 7342); const host = "127.0.0.1"; -const rsbuildConfig = { - root: dashboardRoot, - source: { - entry: { index: "./dev/main.tsx" }, - }, - html: { - template: "./dev/index.html", - title: "tracedecay dashboard (dev)", - }, - // NOTE (dev/prod divergence): holographic's `@import "tailwindcss"` (Tailwind - // v4) is NOT compiled in this dev server. In THIS execution environment BOTH - // Tailwind-v4 integrations Rsbuild documents make createRsbuild() segfault - // (native crash, no stdout/stderr output): `@rsbuild/plugin-tailwindcss` AND - // `@tailwindcss/postcss` wired through `tools.postcss`. The earlier - // `pluginReact()`-only dev server started fine, and `@rspack/core` works here - // (the prod build uses it), so the Tailwind native code path is the trigger. - // To keep the dev server usable (HMR + every non-holographic plugin styled), - // we ship NO Tailwind pipeline here. The holographic plugin will render - // unstyled in dev. The prod build (`npm run build`) is the source of truth - // for holographic's Tailwind styles — verify holographic styling there. - server: { - host, - port, - proxy: { - // All dashboard data calls go to /api/* on a running `tracedecay - // dashboard` (default 127.0.0.1:7341). Plugins are imported by the dev - // bundle, so /dashboard-plugins is NOT proxied. - "/api": { - target: apiTarget, - changeOrigin: true, - }, - }, - }, - plugins: [pluginReact()], -}; +await compileHolographicTailwindCss(); + +const rsbuildConfig = createDashboardDevConfig({ apiTarget, host, port }); const rsbuild = await createRsbuild({ cwd: dashboardRoot, rsbuildConfig }); diff --git a/dashboard/holographic/build.from-hermes.mjs b/dashboard/holographic/build.from-hermes.mjs index 27c9a1c3..ab1a49fd 100644 --- a/dashboard/holographic/build.from-hermes.mjs +++ b/dashboard/holographic/build.from-hermes.mjs @@ -1,142 +1,25 @@ /** - * Build the holographic-memory dashboard plugin bundle. + * Build the holographic-memory dashboard plugin bundle for Hermes. * - * node build.mjs - * - * Produces: - * dist/index.js IIFE bundle (esbuild). React + react/jsx-runtime are - * externalized onto the host SDK via the `src/react-shim.ts` - * and `src/jsx-runtime.ts` shims (esbuild `alias`), so the - * plugin shares the host dashboard's single React instance. - * @observablehq/plot, d3-force, and lucide-react ARE bundled - * (they are not on the plugin SDK). - * dist/style.css Tailwind utilities for this plugin's markup, compiled with - * the Tailwind v4 engine against a mirror of the host theme. - * The emitted `@layer theme` (:root vars) and `@layer base` - * (preflight) blocks are stripped so the file never clobbers - * host theme vars or resets host elements. - * - * Tooling (esbuild, @tailwindcss/node, @tailwindcss/oxide) is resolved from the - * sibling `web/node_modules` checkout. + * This file remains as a compatibility entry point for Hermes workflows, but + * the implementation delegates to the canonical dashboard Rsbuild/Tailwind + * path in `../build.shared.mjs` so standalone, dev, and Hermes builds share + * JSX, alias, CSS, and output behavior. */ -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; +import { dashboardRoot, buildHolographicPlugin } from "../build.shared.mjs"; import fs from "node:fs/promises"; - -const dashboardDir = path.dirname(fileURLToPath(import.meta.url)); -const srcDir = path.join(dashboardDir, "src"); -const distDir = path.join(dashboardDir, "dist"); -const webDir = path.resolve(dashboardDir, "../../../../web"); - -const require = createRequire(path.join(webDir, "package.json")); -const esbuild = require("esbuild"); - -async function buildJS() { - const result = await esbuild.build({ - absWorkingDir: webDir, - entryPoints: [path.join(srcDir, "entry.tsx")], - outfile: path.join(distDir, "index.js"), - bundle: true, - format: "iife", - platform: "browser", - target: ["es2020"], - // The plugin src lives outside web/, so esbuild can't reach web/node_modules - // by walking up from the entry. Point it there for bare imports - // (@observablehq/plot, d3-force, lucide-react). - nodePaths: [path.join(webDir, "node_modules")], - jsx: "automatic", - minify: true, - legalComments: "none", - define: { "process.env.NODE_ENV": '"production"' }, - // Externalize React onto the host SDK; bundle everything else. - alias: { - react: path.join(srcDir, "react-shim.ts"), - "react/jsx-runtime": path.join(srcDir, "jsx-runtime.ts"), - "react/jsx-dev-runtime": path.join(srcDir, "jsx-runtime.ts"), - }, - banner: { - js: "/* Hermes holographic-memory dashboard plugin — bundled with esbuild. Do not edit; see src/. */", - }, - metafile: true, - logLevel: "warning", - }); - return result; -} - -/** Remove a top-level `@layer { ... }` block via brace matching. */ -function stripTopLevelAtLayer(css, name) { - const marker = `@layer ${name} {`; - let out = css; - for (;;) { - const idx = out.indexOf(marker); - if (idx === -1) break; - let i = idx + marker.length; - let depth = 1; - while (i < out.length && depth > 0) { - const ch = out[i]; - if (ch === "{") depth++; - else if (ch === "}") depth--; - i++; - } - out = out.slice(0, idx) + out.slice(i); - } - return out; -} - -async function buildCSS() { - const { compile } = require("@tailwindcss/node"); - const { Scanner } = require("@tailwindcss/oxide"); - - const input = await fs.readFile(path.join(srcDir, "styles.css"), "utf8"); - const compiler = await compile(input, { - // Resolve `@import "tailwindcss"` from web/node_modules. - base: webDir, - onDependency: () => {}, - }); - - // Scan THIS plugin's source for class candidates (not the whole web app). - const sources = - compiler.root === "none" - ? [] - : compiler.root === null - ? [{ base: srcDir, pattern: "**/*", negated: false }] - : [{ ...compiler.root, negated: false }]; - const scanner = new Scanner({ sources: sources.concat(compiler.sources ?? []) }); - const candidates = scanner.scan(); - - let css = compiler.build(candidates); - // Drop host-owned layers: theme (:root vars) + base (preflight reset). - css = stripTopLevelAtLayer(css, "theme"); - css = stripTopLevelAtLayer(css, "base"); - // Minify with esbuild rather than Tailwind's lightningcss `optimize`, which - // strips the `@supports (color: color-mix())` progressive-enhancement blocks - // and collapses our themed colors to their plain fallback. - css = (await esbuild.transform(css, { loader: "css", minify: true })).code; - - // Confine the whole plugin sheet to the host's `hermes-plugin` cascade layer - // (declared in web/src/index.css as ...,components,hermes-plugin,utilities). - // This sheet is injected as a plain AFTER the host app CSS, so its - // base utilities (.flex/.grid/.hidden/...) would otherwise share the host's - // `utilities` layer and — being later in document order — override the host's - // responsive variants (.lg:hidden / .lg:flex / .lg:sticky), breaking the - // sidebar/menu layout. Ranked below host `utilities` but above `base`, the - // plugin's own pages stay styled (they beat preflight) while the host layout - // stays authoritative. Inner @layer blocks nest under hermes-plugin. - css = `@layer hermes-plugin{${css}}`; - - await fs.mkdir(distDir, { recursive: true }); - await fs.writeFile(path.join(distDir, "style.css"), css, "utf8"); - return css.length; -} +import path from "node:path"; async function main() { - await fs.mkdir(distDir, { recursive: true }); - const [, cssBytes] = await Promise.all([buildJS(), buildCSS()]); - const jsStat = await fs.stat(path.join(distDir, "index.js")); + await buildHolographicPlugin(); + const distDir = path.join(dashboardRoot, "holographic/dist"); + const [jsStat, cssStat] = await Promise.all([ + fs.stat(path.join(distDir, "index.js")), + fs.stat(path.join(distDir, "style.css")), + ]); console.log(`✓ dist/index.js ${(jsStat.size / 1024).toFixed(1)} KB`); - console.log(`✓ dist/style.css ${(cssBytes / 1024).toFixed(1)} KB`); + console.log(`✓ dist/style.css ${(cssStat.size / 1024).toFixed(1)} KB`); } main().catch((err) => { diff --git a/dashboard/holographic/src/jsx-runtime.ts b/dashboard/holographic/src/jsx-runtime.ts index 9f496f7a..0c50295b 100644 --- a/dashboard/holographic/src/jsx-runtime.ts +++ b/dashboard/holographic/src/jsx-runtime.ts @@ -1,7 +1,7 @@ /** * Automatic-JSX runtime shim. * - * esbuild is built with `jsx: "automatic"`, so it emits imports of + * The dashboard bundler uses the automatic JSX runtime, so it emits imports of * `jsx`/`jsxs`/`Fragment` from `react/jsx-runtime`. We alias that specifier to * this module, which implements the runtime on top of the host's * `React.createElement` (pulled from `./react-shim`). This keeps the plugin off diff --git a/dashboard/holographic/src/react-shim.ts b/dashboard/holographic/src/react-shim.ts index 7f5d4251..8f290910 100644 --- a/dashboard/holographic/src/react-shim.ts +++ b/dashboard/holographic/src/react-shim.ts @@ -1,8 +1,8 @@ /** * React shim — maps bare `react` imports onto the host dashboard's React. * - * esbuild aliases `react` to this module so the plugin bundle shares the host - * dashboard's single React instance (exposed via + * The dashboard bundler aliases `react` to this module so the plugin bundle + * shares the host dashboard's single React instance (exposed via * `window.__HERMES_PLUGIN_SDK__.React`) instead of bundling a second copy. * Bundled third-party deps (lucide-react, etc.) that `import { forwardRef, * createElement, useState, useEffect } from "react"` resolve through here too, diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index cab50f77..95c3df22 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -21,10 +21,10 @@ "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", - "esbuild": "^0.25.0", "jsdom": "^29.1.1", "playwright": "^1.60.0", "tailwindcss": "^4.3.1", + "typescript": "^5.9.0", "vitest": "^4.1.8" } }, @@ -303,448 +303,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -2434,48 +1992,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3395,6 +2911,20 @@ "dev": true, "license": "0BSD" }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index c4a493dd..25bfb520 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,7 +11,8 @@ "test:node": "node run-unit-tests.mjs", "test:dom": "vitest run", "smoke": "node smoke.mjs", - "smoke:mobile": "node smoke.mjs --profiles=iphone12,pixel5" + "smoke:mobile": "node smoke.mjs --profiles=iphone12,pixel5", + "typecheck": "tsc --noEmit" }, "dependencies": { "@observablehq/plot": "^0.6.17", @@ -27,10 +28,10 @@ "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", - "esbuild": "^0.25.0", "jsdom": "^29.1.1", "playwright": "^1.60.0", "tailwindcss": "^4.3.1", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "typescript": "^5.9.0" } } diff --git a/dashboard/test/helpers/module-loader.mjs b/dashboard/test/helpers/module-loader.mjs index fbc0d374..09f6895e 100644 --- a/dashboard/test/helpers/module-loader.mjs +++ b/dashboard/test/helpers/module-loader.mjs @@ -1,4 +1,8 @@ -import { build } from "esbuild"; +import { rspack } from "@rspack/core"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; function restoreGlobals(previous) { for (const [key, prior] of previous.entries()) { @@ -10,6 +14,19 @@ function restoreGlobals(previous) { } } +function runRspack(config) { + return new Promise((resolve, reject) => { + rspack(config, (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) { + const info = stats.toJson({ all: false, errors: true }); + return reject(new Error(info.errors.map((e) => e.message).join("\n"))); + } + resolve(stats); + }); + }); +} + export async function importBundledModule(entryPath, { globals = {} } = {}) { const previous = new Map(); for (const [key, value] of Object.entries(globals)) { @@ -20,23 +37,54 @@ export async function importBundledModule(entryPath, { globals = {} } = {}) { globalThis[key] = value; } + const outDir = mkdtempSync(path.join(tmpdir(), "td-module-loader-")); try { - const result = await build({ - entryPoints: [entryPath], - bundle: true, - format: "esm", - platform: "browser", - target: "es2022", - write: false, - logLevel: "silent", + await runRspack({ + mode: "development", + entry: { bundled: entryPath }, + experiments: { outputModule: true }, + output: { + module: true, + library: { type: "module" }, + path: outDir, + filename: "bundled.mjs", + }, + resolve: { extensions: [".tsx", ".ts", ".jsx", ".js", ".json"] }, + module: { + rules: [ + { + test: /\.(tsx?|jsx)$/, + exclude: /node_modules/, + use: { + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "typescript", + tsx: true, + }, + transform: { react: { runtime: "automatic" } }, + }, + }, + }, + }, + ], + }, + // The tests need real React elements (e.g. sdk.jsx's `import React`), + // so bundle `react` from node_modules rather than externalizing it. + optimization: { minimize: false, splitChunks: false, runtimeChunk: false }, + performance: { hints: false }, + stats: { preset: "errors-only" }, }); - const code = result.outputFiles[0]?.text; - if (!code) { - throw new Error(`esbuild produced no output for ${entryPath}`); - } - const encoded = Buffer.from(code).toString("base64"); - return import(`data:text/javascript;base64,${encoded}#${encodeURIComponent(entryPath)}`); + + const outFile = path.join(outDir, "bundled.mjs"); + return await import(pathToFileURL(outFile).href + "?t=" + Date.now()); } finally { restoreGlobals(previous); + try { + rmSync(outDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } } } diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 00000000..570ce3d5 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "allowJs": true, + "checkJs": false, + "strict": false, + "noImplicitAny": false, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "**/dist", "**/build"] +} From c09b3e20d4a0375f2f9618f62660d72ffee830a5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 02:15:54 +0200 Subject: [PATCH 09/35] fix(dashboard): undefined-ref crashes, shared-primitives adoption, a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bugs surfaced by `npm run typecheck` (undefined at runtime): - lcm/src/components.tsx used ratioStr (compression ratio) without importing it — crash when the compression view rendered. Now imported from ./helpers. - holographic/src/CurationPanel.tsx referenced loadStatus (Status-tab refresh) without destructuring it from useCurationData — dead refresh button. Now destructured. Shared-primitives adoption (lib/primitives): savings (ErrorPanel, Stat) and lcm (EmptyState, ErrorPanel) adopt the shared components; graph's OverviewPanel adopts BarList/EmptyState (dropping its hand-rolled HBarChart). Holographic stays self-contained. (graph/savings/lcm build with primitives:true so lib/primitives.css is prepended to their dist stylesheet.) a11y: holographic Stat now exposes its hint via aria-describedby + a visually-hidden description element (native title kept for sighted users). docs/dashboard.md: documents the shared primitives + buildPlugin primitives opt-in, the canonical lib/cn.ts, and the dev-server Rsbuild-Tailwind segfault limitation (dev is pluginReact()-only; prod is the source of truth for holographic Tailwind styles). Verified: 100/100 node + 12/12 vitest; Rsbuild build clean; real-browser Playwright smoke passes. --- dashboard/graph/src/OverviewPanel.tsx | 132 ++++++------------ dashboard/holographic/src/CurationPanel.tsx | 1 + .../holographic/src/HolographicMemoryPage.tsx | 22 +++ dashboard/lcm/src/App.tsx | 56 ++++---- dashboard/lcm/src/components.tsx | 20 +-- dashboard/lib/primitives.tsx | 19 ++- dashboard/savings/src/SavingsExplorer.tsx | 8 +- .../savings/src/SavingsOverviewPanel.tsx | 27 +--- docs/dashboard.md | 55 +++++++- 9 files changed, 168 insertions(+), 172 deletions(-) diff --git a/dashboard/graph/src/OverviewPanel.tsx b/dashboard/graph/src/OverviewPanel.tsx index b748f6a1..90fc712e 100644 --- a/dashboard/graph/src/OverviewPanel.tsx +++ b/dashboard/graph/src/OverviewPanel.tsx @@ -1,11 +1,12 @@ /** * Landing analytics for the Code Graph tab: orientation visuals (counts by - * kind, language mix, hub symbols, largest files) rendered as compact - * hand-rolled SVG charts in the shared design-token vocabulary. + * kind, language mix, hub symbols, largest files) rendered with the shared + * dashboard primitives (BarList) in the shared design-token vocabulary. */ import React from "react"; import { Badge, Card, CardContent, CardHeader, CardTitle } from "../../lib/sdk"; +import { BarList, EmptyState } from "../../lib/primitives"; import { fmt } from "../../lib/format"; import { colorForKind, @@ -27,64 +28,6 @@ const LANGUAGE_COLORS: Record = { web: "#ff7ab6", }; -function HBarChart({ - rows, - colorFor, - onPick, -}: { - rows: Array<{ label: string; count: number; meta?: string }>; - colorFor: (label: string) => string; - onPick?: (label: string) => void; -}) { - const max = Math.max(1, ...rows.map((row) => row.count)); - const rowH = 26; - const height = rows.length * rowH; - return ( - - {rows.map((row, index) => { - const w = Math.max(3, (row.count / max) * 250); - const y = index * rowH; - return ( - onPick(row.label) : undefined} - > - - - {row.label.length > 18 ? `${row.label.slice(0, 17)}…` : row.label} - - - - {fmt(row.count)}{row.meta ? ` ${row.meta}` : ""} - - - ); - })} - - ); -} - export default function OverviewPanel({ overview, onFocusSymbol, @@ -97,7 +40,7 @@ export default function OverviewPanel({ onFilterLanguage: (language: string) => void; }) { if (!overview) { - return
    Loading graph analytics…
    ; + return Loading graph analytics…; } // Aggregate raw kinds into visual families for the chart. @@ -120,16 +63,15 @@ export default function OverviewPanel({ Symbols by family - ({ label: row.label, count: row.count }))} - colorFor={(label) => { - const row = familyRows.find((r) => r.label === label); - return KIND_FAMILY_COLORS[row?.family || "other"]; - }} - onPick={(label) => { - const row = familyRows.find((r) => r.label === label); - if (row) onFilterKind(row.family); - }} + ({ + label: row.label, + color: KIND_FAMILY_COLORS[row.family] || KIND_FAMILY_COLORS.other, + value: fmt(row.count), + family: row.family, + }))} + onPick={(row) => onFilterKind(String(row.family))} />

    Click a family to open the canvas filtered to it.

    @@ -138,13 +80,14 @@ export default function OverviewPanel({ Files by language - ({ label: row.language, - count: row.count, + color: LANGUAGE_COLORS[row.language] || "#6f9189", + value: fmt(row.count), }))} - colorFor={(label) => LANGUAGE_COLORS[label] || "#6f9189"} - onPick={onFilterLanguage} + onPick={(row) => onFilterLanguage(String(row.label))} /> @@ -152,33 +95,36 @@ export default function OverviewPanel({ Most connected symbols -
    - {overview.top_connected.map((row) => ( - - ))} -
    + ({ + label: row.name, + name: row.name, + color: colorForKind(row.kind), + meta: row.kind, + value: `${fmt(row.degree)} edges`, + node: row, + }))} + rowKey={(row) => String(row.node.id)} + titleFor={(row) => `Open ${String(row.name)} in the canvas`} + onPick={(row) => onFocusSymbol(row.node)} + />
    Largest files - { const short = row.path.split("/").slice(-2).join("/"); - return { label: short, count: row.node_count, meta: "symbols" }; + return { + label: short, + color: "color-mix(in srgb, var(--ts-cyan, #75f4d2) 60%, transparent)", + value: `${fmt(row.node_count)} symbols`, + }; })} - colorFor={() => "color-mix(in srgb, var(--ts-cyan, #75f4d2) 60%, transparent)"} /> diff --git a/dashboard/holographic/src/CurationPanel.tsx b/dashboard/holographic/src/CurationPanel.tsx index 89b6183e..44f14394 100644 --- a/dashboard/holographic/src/CurationPanel.tsx +++ b/dashboard/holographic/src/CurationPanel.tsx @@ -564,6 +564,7 @@ export default function CurationPanel({ preview, apply, loadActivity, + loadStatus, } = useCurationData({ onApplied }); const actions = report?.actions ?? []; diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index 39a92afd..f0def37c 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -117,11 +118,13 @@ function Stat({ /** Plain-language explanation surfaced as a native tooltip. */ hint?: string; }) { + const hintId = useId(); return (
    {value} @@ -129,6 +132,25 @@ function Stat({
    {label}
    + {hint && ( + + {hint} + + )}
    ); } diff --git a/dashboard/lcm/src/App.tsx b/dashboard/lcm/src/App.tsx index 42a0b782..fb7b6ce4 100644 --- a/dashboard/lcm/src/App.tsx +++ b/dashboard/lcm/src/App.tsx @@ -41,6 +41,7 @@ import { TimeText, toolBadge, } from "./components"; +import { EmptyState, ErrorPanel } from "../../lib/primitives"; function App(): React.ReactElement { const [q, setQ] = useState(""); @@ -497,7 +498,7 @@ function App(): React.ReactElement { drawerTitle = top.kind === "node" ? `Node #${top.id}` : (top.kind === "message" ? `Message #${top.id}` : `Session ${short(top.id, 40)}`); - drawerBody =
    Loading…
    ; + drawerBody = Loading…; } else if (top.error) { drawerTitle = top.kind === "node" ? `Node #${top.id}` @@ -598,10 +599,10 @@ function App(): React.ReactElement {
    ) : null} {(!searchPending && !searching && debouncedQ && !searchError && totalSearchMatches === 0) ? ( -
    + No matches found. {" Try removing a facet or a punctuation-heavy query so the backend can stay on the ranked FTS path."} -
    + ) : null} {totalSearchMatches > 0 ? (
    @@ -633,7 +634,7 @@ function App(): React.ReactElement { /> ); }) - :
    No matching messages on this page.
    } + : No matching messages on this page.}
    ); }) - :
    No matching summaries on this page.
    } + : No matching summaries on this page.}
    -
    {`Refresh failed (${overviewError}) — showing previously loaded data.`}
    - -
    + ) : null} - {data && data.error ?
    {data.error}
    : null} + {data && data.error ? : null} {data && !data.exists ? (
    @@ -862,14 +860,11 @@ function App(): React.ReactElement {

    Message Timeline (per day · dots = summaries)

    {chartsError && !timeline ? ( -
    -
    {chartsError}
    - -
    + ) : (chartsLoading && !timeline) ? @@ -885,14 +880,11 @@ function App(): React.ReactElement {

    Compression by Session (kept vs saved)

    {chartsError && !compression ? ( -
    -
    {chartsError}
    - -
    + ) : (chartsLoading && !compression) ? @@ -954,7 +946,7 @@ function App(): React.ReactElement { ); }) : (data - ?
    No sessions
    + ? No sessions : )}
    @@ -984,7 +976,7 @@ function App(): React.ReactElement { ); }) : (data - ?
    No summaries
    + ? No summaries : )}
    diff --git a/dashboard/lcm/src/components.tsx b/dashboard/lcm/src/components.tsx index 8d733475..b4c60869 100644 --- a/dashboard/lcm/src/components.tsx +++ b/dashboard/lcm/src/components.tsx @@ -23,11 +23,13 @@ import { parseJsonArray, parseLeadingJSON, queryTerms, + ratioStr, sessionLabel, short, stripMd, summaryTitle, } from "./helpers"; +import { EmptyState } from "../../lib/primitives"; // --- search-highlight rendering ------------------------------------------- @@ -172,7 +174,7 @@ export function BarList(props: { rows?: any[]; keyName: string; onPick?: (label: const keyName = props.keyName; const onPick = props.onPick; const total = rows.reduce((acc, row) => acc + (Number(row.count) || 0), 0) || 1; - if (!rows.length) return
    No data
    ; + if (!rows.length) return No data; return (
    {rows.map(function (row, idx) { @@ -211,11 +213,11 @@ export function TimelineChart(props: { buckets?: any[]; nodeBuckets?: any[]; und const undatedCount = Number(props.undatedCount) || 0; if (!buckets.length) { return ( -
    + {undatedCount > 0 ? `No dated messages yet — ${fmtInt(undatedCount)} stored messages have no timestamp` : "No timeline data"} -
    + ); } const maxCount = buckets.reduce((acc, b) => Math.max(acc, Number(b.count) || 0), 0) || 1; @@ -256,7 +258,7 @@ export function TimelineChart(props: { buckets?: any[]; nodeBuckets?: any[]; und export function CompressionBars(props: { groups?: any[]; onPick?: (g: any) => void }): React.ReactElement { const groups = props.groups || []; const onPick = props.onPick; - if (!groups.length) return
    No compression data
    ; + if (!groups.length) return No compression data; const maxSrc = groups.reduce((acc, g) => Math.max(acc, Number(g.source_token_count) || 0), 0) || 1; return (
    @@ -642,7 +644,7 @@ export function MessageDetail(props: { const d = props.data || {}; const message = d.message; const session = d.session; - if (!message) return
    Message not found
    ; + if (!message) return Message not found; const sessionNodes = (session && session.summary_nodes) || []; // Prefer the backend's exact message→summary linkage (summary_node_ids, // additive field) and fall back to same-session summaries when absent. @@ -729,7 +731,7 @@ export function MessageDetail(props: {
    ) : null} {(!relatedNodes.length && !unresolvedLinkIds.length) - ?
    No summary nodes reference this message yet.
    + ? No summary nodes reference this message yet. : null}
    ); @@ -746,7 +748,7 @@ export function NodeDetail(props: { const onOpenNode = props.onOpenNode; const onOpenSession = props.onOpenSession; const onOpenMessage = props.onOpenMessage; - if (!node) return
    Node not found
    ; + if (!node) return Node not found; const sources = d.sources || {}; const tags = parseJsonArray(node.tags); const entities = parseJsonArray(node.entities); @@ -794,11 +796,11 @@ export function NodeDetail(props: { const items = isNodes ? (sources.nodes || []) : (sources.messages || []); if (!items.length) { return ( -
    + {(sources.ids || []).length ? "Source items are no longer in the database." : "This summary records no source items."} -
    + ); } return ( diff --git a/dashboard/lib/primitives.tsx b/dashboard/lib/primitives.tsx index 4595089d..74798bc5 100644 --- a/dashboard/lib/primitives.tsx +++ b/dashboard/lib/primitives.tsx @@ -106,24 +106,30 @@ export function Stat({ * Label/value bar list of optionally-pickable rows (ports graph's * `.tsg-hub-list` / `.tsg-hub` shape). * - * `keyName` selects the row field used as the visible label and React key. + * `keyName` selects the row field used as the visible label and default key. * Each row may also carry optional `value`, `meta`, and `color` fields. */ -export function BarList({ +export function BarList>({ rows, keyName, onPick, + rowKey, + titleFor, className, }: { - rows: Array>; + rows: Array; keyName: string; - onPick?: (row: Record) => void; + onPick?: (row: Row) => void; + rowKey?: (row: Row) => string; + titleFor?: (row: Row) => string; className?: string; }) { return (
    {rows.map((row) => { const label = String(row[keyName] ?? ""); + const key = rowKey?.(row) ?? String(row[keyName]); + const title = titleFor?.(row); const value = "value" in row ? row.value : undefined; const meta = "meta" in row ? row.meta : undefined; const color = "color" in row ? row.color : undefined; @@ -139,15 +145,16 @@ export function BarList({ ); return onPick ? ( ) : ( -
    +
    {inner}
    ); diff --git a/dashboard/savings/src/SavingsExplorer.tsx b/dashboard/savings/src/SavingsExplorer.tsx index 25354eec..7ee7e410 100644 --- a/dashboard/savings/src/SavingsExplorer.tsx +++ b/dashboard/savings/src/SavingsExplorer.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { cn } from "../../lib/sdk"; +import { ErrorPanel } from "../../lib/primitives"; import { api } from "./api"; import { fmtTokens } from "./logic"; import type { PriceTable } from "./pricing"; @@ -171,12 +172,7 @@ export default function SavingsExplorer() { )} {error && ( -
    - Failed to load savings data: {error}{" "} - -
    + )} {view === "savings" && ( diff --git a/dashboard/savings/src/SavingsOverviewPanel.tsx b/dashboard/savings/src/SavingsOverviewPanel.tsx index 689bf20c..8c53abb1 100644 --- a/dashboard/savings/src/SavingsOverviewPanel.tsx +++ b/dashboard/savings/src/SavingsOverviewPanel.tsx @@ -5,30 +5,13 @@ import React from "react"; import { Badge, Card, CardContent, CardHeader, CardTitle } from "../../lib/sdk"; +import { Stat } from "../../lib/primitives"; import { fillDailySeries, fmtTokens, fmtUsd, projectLabel } from "./logic"; import { savedTokensUsd } from "./pricing"; import type { PriceTable } from "./pricing"; import { DailyBars, HBarChart } from "./charts"; import type { LedgerResponse, SavingsOverview } from "./types"; -function StatCard({ - label, - value, - hint, -}: { - label: string; - value: string; - hint?: string; -}) { - return ( -
    -
    {value}
    -
    {label}
    - {hint &&
    {hint}
    } -
    - ); -} - export default function SavingsOverviewPanel({ overview, ledger, @@ -68,22 +51,22 @@ export default function SavingsOverviewPanel({ return (
    - - - - /dist/style.css`, and the Hermes wrapper concatenates the same files +into one `style.css` — so prepending is the only way the `tdp-*` classes reach +both hosts without a second ``. + +Current adoption: + +- **graph**, **savings**, and **lcm** consume the shared `tdp-*` primitives and + build with `primitives: true`, so each plugin's `dist/style.css` includes + `lib/primitives.css`. +- **holographic** stays self-contained (its own `holographic/src/ui.ts` + primitives layered over Tailwind utilities). ### Smoke Testing @@ -1210,6 +1243,20 @@ Rsbuild bundle already shares one real React instance, and `react-dom/client` needs the real `react` module — so the per-plugin React externalization is a **prod** concern only. +> **Note (dev/prod divergence — holographic styles):** the dev server is +> `pluginReact()`-only and ships **no Tailwind pipeline**. In some sandboxes +> both Tailwind-v4 integrations Rsbuild documents — `@rsbuild/plugin-tailwindcss` +> and `@tailwindcss/postcss` wired through `tools.postcss` — segfault natively +> inside `createRsbuild()` (hard native crash, no stdout/stderr), while +> `pluginReact()` alone and the `@rspack/core`-based prod build run fine. To +> keep the dev server usable (HMR + every non-holographic plugin styled), the +> Tailwind code path is intentionally omitted there. As a result the +> **holographic plugin renders unstyled in dev** (its `@import "tailwindcss"` +> is never compiled); every other plugin's hand-rolled CSS loads normally. The +> **prod build is the source of truth** for holographic Tailwind styles — +> verify holographic styling via `npm run build` + the embedded binary, not the +> dev server. + To validate the production build (the shipped UI is always embedded bytes): ```bash From 47c3a6fb8f6875f50dd13a504660140907cf9773 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 02:49:22 +0200 Subject: [PATCH 10/35] refactor(dashboard): real React imports, stale-dist build guard Drop holographic jsx/react shims so Rsbuild typecheck covers all panels; rebuild embedded dist when dashboard sources drift and fix LCM fetchSession typing. --- build.rs | 151 +- dashboard/build.shared.mjs | 18 +- dashboard/dev/main.tsx | 12 +- dashboard/graph/src/CodeGraphExplorer.tsx | 4 + dashboard/holographic/src/CurationPanel.tsx | 15 +- .../holographic/src/HolographicMemoryPage.tsx | 22 - dashboard/holographic/src/SemanticMap.tsx | 1 + dashboard/holographic/src/Spinner.tsx | 4 +- dashboard/holographic/src/curation/risk.ts | 2 +- dashboard/holographic/src/d3-force.d.ts | 47 + dashboard/holographic/src/jsx-runtime.ts | 30 - dashboard/holographic/src/react-shim.ts | 56 - dashboard/holographic/src/viz/Histogram.tsx | 1 + dashboard/holographic/src/viz/Sparkline.tsx | 1 + dashboard/holographic/src/viz/useMeasure.ts | 4 +- dashboard/lcm/src/App.tsx | 54 +- dashboard/lcm/src/components.tsx | 72 +- dashboard/lib/primitives.css | 95 + dashboard/lib/primitives.tsx | 78 +- dashboard/package-lock.json | 5125 ++++++++++------- dashboard/package.json | 6 +- dashboard/savings/src/ModelsPanel.tsx | 3 +- .../savings/src/SavingsOverviewPanel.tsx | 8 +- dashboard/savings/src/SessionsPanel.tsx | 11 +- dashboard/savings/src/charts.tsx | 4 + .../test/code-graph-explorer-hooks.vitest.tsx | 6 +- dashboard/test/curation-data.vitest.tsx | 2 +- dashboard/test/helpers/module-loader.mjs | 78 +- .../test/semantic-map-interactions.vitest.ts | 4 +- dashboard/tsconfig.json | 18 +- docs/LCM-PAYLOAD-TRIAGE.md | 7 +- docs/MOBILE-VERIFICATION-CHECKLIST.md | 20 +- docs/dashboard-port-handoff.md | 13 +- 33 files changed, 3494 insertions(+), 2478 deletions(-) create mode 100644 dashboard/holographic/src/d3-force.d.ts delete mode 100644 dashboard/holographic/src/jsx-runtime.ts delete mode 100644 dashboard/holographic/src/react-shim.ts diff --git a/build.rs b/build.rs index fea64125..2af16730 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,10 @@ use std::hash::{Hash, Hasher}; use std::process::{Command, Stdio}; -use std::{collections::hash_map::DefaultHasher, fs, path::Path}; +use std::{ + collections::hash_map::DefaultHasher, + fs, + path::{Path, PathBuf}, +}; const DASHBOARD_ASSET_FILES: &[&str] = &[ "dashboard/shell/dist/shell.js", @@ -15,6 +19,28 @@ const DASHBOARD_ASSET_FILES: &[&str] = &[ "dashboard/savings/dist/style.css", ]; +const DASHBOARD_SOURCE_FILES: &[&str] = &[ + "dashboard/build.mjs", + "dashboard/build.shared.mjs", + "dashboard/holographic/build.from-hermes.mjs", + "dashboard/package.json", + "dashboard/package-lock.json", + "dashboard/run-unit-tests.mjs", + "dashboard/smoke.mjs", + "dashboard/vitest.config.mjs", +]; + +const DASHBOARD_SOURCE_DIRS: &[&str] = &[ + "dashboard/dev", + "dashboard/graph/src", + "dashboard/hermes-wrapper/src", + "dashboard/holographic/src", + "dashboard/lcm/src", + "dashboard/lib", + "dashboard/savings/src", + "dashboard/shell/src", +]; + /// Locates a working npm executable (`npm.cmd` is the Windows launcher). fn npm_program() -> Option<&'static str> { ["npm", "npm.cmd"].into_iter().find(|candidate| { @@ -64,18 +90,22 @@ fn run_npm(npm: &str, args: &[&str], dir: &Path) -> Result<(), String> { } /// Builds the dashboard frontend (`cd dashboard && npm ci/install && npm run -/// build`) when dist assets are missing, so plain `cargo build` / `cargo +/// build`) when dist assets are missing or stale, so plain `cargo build` / `cargo /// install --path .` work from a fresh checkout. Published crates ship the /// prebuilt dist files (see `package.include` in Cargo.toml), so this never -/// runs for crates.io builds. -fn auto_build_dashboard_assets(missing: &[&str]) { +/// runs for crates.io builds unless those packaged files are incomplete. +fn auto_build_dashboard_assets(reason: &str, affected: &[&str]) { let fail_fast = |detail: &str| -> ! { + let affected = if affected.is_empty() { + "dashboard source files changed since the embedded dist assets were built".to_string() + } else { + affected.join("\n ") + }; panic!( - "\n\nmissing dashboard dist assets:\n {}\n\n\ + "\n\ndashboard dist assets are {reason}:\n {affected}\n\n\ The dashboard UI is embedded into the binary at compile time\n\ (src/dashboard/assets.rs), so the frontend must be built first:\n\n \ cd dashboard && npm ci && npm run build\n\n{detail}\n", - missing.join("\n ") ); }; @@ -108,14 +138,121 @@ fn auto_build_dashboard_assets(missing: &[&str]) { println!("cargo::warning=dashboard assets: npm build finished; embedding fresh dist files"); } +/// Content hash of the dashboard source inputs (each input's path + bytes), +/// independent of filesystem mtimes. Returns `None` when there are no source +/// inputs - e.g. a published crate that ships only the prebuilt dist - so a +/// stamp is never recorded and a rebuild is never triggered for crates.io. +fn dashboard_source_stamp(source_inputs: &[PathBuf]) -> Option { + if source_inputs.is_empty() { + return None; + } + // Hash in a stable path order so the stamp depends only on file content, + // not on the unspecified `read_dir` traversal order. + let mut paths: Vec<&PathBuf> = source_inputs.iter().collect(); + paths.sort(); + let mut hasher = DefaultHasher::new(); + for path in paths { + // Hashing the path makes adds/removes/renames flip the stamp even when + // the surviving files are byte-identical. + path.to_string_lossy().hash(&mut hasher); + if let Ok(bytes) = fs::read(path) { + bytes.hash(&mut hasher); + } + } + Some(format!("{:016x}", hasher.finish())) +} + +/// True when the dashboard source inputs differ from the content stamp recorded +/// by the previous build in this `OUT_DIR` - i.e. the sources genuinely changed +/// rather than just having their mtimes rewritten by a `git checkout`/`pull`. +/// +/// A build with no recorded stamp (a fresh checkout, a clean target dir, or a +/// crates.io build that ships only dist) trusts the committed/shipped dist and +/// returns false, so it is never forced to run `npm run build`. The genuine +/// "source edited but dist not rebuilt" case is still caught on every later +/// build because the stamp from this run is persisted afterward. +fn dashboard_sources_changed(current_stamp: Option<&str>) -> bool { + let Some(current) = current_stamp else { + return false; + }; + match read_dashboard_source_stamp() { + Some(previous) => previous != current, + None => false, + } +} + +/// Location of the persisted source stamp inside cargo's `OUT_DIR`. Keeping it +/// in the build output (never the source tree) keeps `cargo package` +/// verification - which forbids build scripts from editing tracked files - +/// happy. +fn dashboard_source_stamp_path() -> Option { + let out_dir = std::env::var_os("OUT_DIR")?; + Some(Path::new(&out_dir).join("dashboard-source-stamp")) +} + +fn read_dashboard_source_stamp() -> Option { + let contents = fs::read_to_string(dashboard_source_stamp_path()?).ok()?; + let stamp = contents.trim(); + (!stamp.is_empty()).then(|| stamp.to_string()) +} + +fn store_dashboard_source_stamp(stamp: &str) { + // Best-effort: if the stamp can't be written, the next build simply falls + // back to trusting the existing dist, which is still safe. + if let Some(path) = dashboard_source_stamp_path() { + let _ = fs::write(path, stamp); + } +} + +fn collect_dashboard_source_inputs() -> Vec { + let mut inputs = Vec::new(); + for relative in DASHBOARD_SOURCE_FILES { + println!("cargo::rerun-if-changed={relative}"); + let path = PathBuf::from(relative); + if path.is_file() { + inputs.push(path); + } + } + for relative in DASHBOARD_SOURCE_DIRS { + println!("cargo::rerun-if-changed={relative}"); + collect_dashboard_source_dir(Path::new(relative), &mut inputs); + } + inputs +} + +fn collect_dashboard_source_dir(dir: &Path, inputs: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_dashboard_source_dir(&path, inputs); + } else if path.is_file() { + println!("cargo::rerun-if-changed={}", path.display()); + inputs.push(path); + } + } +} + fn emit_dashboard_asset_inputs() -> String { + let source_inputs = collect_dashboard_source_inputs(); let missing: Vec<&str> = DASHBOARD_ASSET_FILES .iter() .copied() .filter(|relative| !Path::new(relative).exists()) .collect(); + let source_stamp = dashboard_source_stamp(&source_inputs); if !missing.is_empty() { - auto_build_dashboard_assets(&missing); + auto_build_dashboard_assets("missing", &missing); + } else if dashboard_sources_changed(source_stamp.as_deref()) { + auto_build_dashboard_assets("stale", &[]); + } + // Record the source content hash we just accepted so the next build can + // distinguish a genuine source edit from a mtime-only churn (git + // checkout/pull). Skipped when no source inputs ship (crates.io). + if let Some(stamp) = source_stamp.as_deref() { + store_dashboard_source_stamp(stamp); } let mut hasher = DefaultHasher::new(); diff --git a/dashboard/build.shared.mjs b/dashboard/build.shared.mjs index e71d748a..50de68b1 100644 --- a/dashboard/build.shared.mjs +++ b/dashboard/build.shared.mjs @@ -1,5 +1,6 @@ import { createRsbuild, rspack } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -9,6 +10,7 @@ export const dashboardRoot = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(dashboardRoot, "package.json")); const EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".json"]; +const SHIM_DIR = path.join(dashboardRoot, "lib"); export const EMBEDDED_DIST_FILES = [ "shell/dist/shell.js", @@ -78,7 +80,7 @@ function createBundleConfig({ entryName, entry, outDir, filename, alias = {}, ba chunkSplit: { strategy: "all-in-one" }, printFileSize: false, }, - plugins: [pluginReact()], + plugins: [pluginReact(), pluginTypeCheck()], tools: { rspack(config) { applySingleBundleOutput(config, bannerLabel); @@ -99,7 +101,6 @@ export function createShellBuildConfig() { export function createPluginBuildConfig( dir, bannerLabel, - { shimDir = path.join(dashboardRoot, "lib") } = {}, ) { return createBundleConfig({ entryName: "index", @@ -108,9 +109,9 @@ export function createPluginBuildConfig( filename: "index.js", bannerLabel, alias: { - "react$": path.join(shimDir, "react-shim.ts"), - "react/jsx-runtime$": path.join(shimDir, "jsx-runtime.ts"), - "react/jsx-dev-runtime$": path.join(shimDir, "jsx-runtime.ts"), + "react$": path.join(SHIM_DIR, "react-shim.ts"), + "react/jsx-runtime$": path.join(SHIM_DIR, "jsx-runtime.ts"), + "react/jsx-dev-runtime$": path.join(SHIM_DIR, "jsx-runtime.ts"), }, }); } @@ -135,7 +136,7 @@ export function createDashboardDevConfig({ apiTarget, host, port }) { }, }, }, - plugins: [pluginReact()], + plugins: [pluginReact(), pluginTypeCheck()], }; } @@ -162,9 +163,9 @@ export async function buildShell() { export async function buildPlugin( dir, bannerLabel, - { shimDir = path.join(dashboardRoot, "lib"), tailwind = false, primitives = false } = {}, + { tailwind = false, primitives = false } = {}, ) { - await runRsbuildConfig(createPluginBuildConfig(dir, bannerLabel, { shimDir })); + await runRsbuildConfig(createPluginBuildConfig(dir, bannerLabel)); const distCss = path.join(dashboardRoot, dir, "dist/style.css"); await fs.mkdir(path.dirname(distCss), { recursive: true }); if (tailwind) { @@ -183,7 +184,6 @@ export async function buildPlugin( export async function buildHolographicPlugin() { await buildPlugin("holographic", "holographic-memory", { - shimDir: path.join(dashboardRoot, "holographic/src"), tailwind: true, }); } diff --git a/dashboard/dev/main.tsx b/dashboard/dev/main.tsx index 0469e35a..301f338d 100644 --- a/dashboard/dev/main.tsx +++ b/dashboard/dev/main.tsx @@ -48,6 +48,16 @@ import "../lcm/src/styles.css"; // compiler path as production before Rsbuild resolves this import. import "../holographic/dist/style.css"; +// Type-only: the standalone shell (shell/src/main.jsx) and this dev entry both +// install the Hermes plugin SDK + registry on `window`. Declared loose (`any`) +// because the SDK is a host-provided bag of React/hooks/components/utils. +declare global { + interface Window { + __HERMES_PLUGIN_SDK__: any; + __HERMES_PLUGINS__: any; + } +} + // --------------------------------------------------------------------------- // SDK + plugin registry — populated BEFORE plugin entries are imported. // --------------------------------------------------------------------------- @@ -55,7 +65,7 @@ import "../holographic/dist/style.css"; window.__HERMES_PLUGIN_SDK__ = buildSDK(); const registered = new Map(); -const listeners = new Set(); +const listeners = new Set<() => void>(); let registryVersion = 0; function notify() { diff --git a/dashboard/graph/src/CodeGraphExplorer.tsx b/dashboard/graph/src/CodeGraphExplorer.tsx index 9e00f050..995f7af7 100644 --- a/dashboard/graph/src/CodeGraphExplorer.tsx +++ b/dashboard/graph/src/CodeGraphExplorer.tsx @@ -406,6 +406,10 @@ export default function CodeGraphExplorer() { )}
    {overview && ( + /* Compact inline mono stat strip (nodes/edges/files). Left as a + * hand-rolled `.tsg-totals` span row on purpose: the shared `Stat` + * tiles are large bordered cards meant for dashboards, far too heavy + * for this one-line toolbar. This is not a Stat duplicate. */
    {fmt(overview.totals.nodes)} nodes {fmt(overview.totals.edges)} edges diff --git a/dashboard/holographic/src/CurationPanel.tsx b/dashboard/holographic/src/CurationPanel.tsx index 44f14394..193e3bd3 100644 --- a/dashboard/holographic/src/CurationPanel.tsx +++ b/dashboard/holographic/src/CurationPanel.tsx @@ -17,11 +17,18 @@ import { isBookkeepingTag, splitTags, } from "./curation/format"; -import { actionRisk, groupActions, riskClass, type ActionRisk } from "./curation/risk"; -import { useCurationData } from "./curation/useCurationData"; +import { + actionRisk, + groupActions, + riskClass, + type ActionGroupDef, + type ActionRisk, +} from "./curation/risk"; +import { useCurationData, type CurationTab } from "./curation/useCurationData"; import type { MemoryCurateAction, MemoryCuratorActivityEvent, + MemoryOplogEvent, } from "./types"; const DIAGNOSTIC_COUNT_KEYS = new Set([ @@ -568,9 +575,9 @@ export default function CurationPanel({ } = useCurationData({ onApplied }); const actions = report?.actions ?? []; - const counts = report?.counts ?? {}; + const counts: Record = report?.counts ?? {}; const isPlan = report?.dry_run ?? true; - const shownCounts = isPlan ? counts : (report?.applied_counts ?? counts); + const shownCounts: Record = isPlan ? counts : (report?.applied_counts ?? counts); const actionCounts = Object.entries(shownCounts).filter( ([key]) => !DIAGNOSTIC_COUNT_KEYS.has(key), ); diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index f0def37c..39a92afd 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useId, useMemo, useRef, useState, @@ -118,13 +117,11 @@ function Stat({ /** Plain-language explanation surfaced as a native tooltip. */ hint?: string; }) { - const hintId = useId(); return (
    {value} @@ -132,25 +129,6 @@ function Stat({
    {label}
    - {hint && ( - - {hint} - - )}
    ); } diff --git a/dashboard/holographic/src/SemanticMap.tsx b/dashboard/holographic/src/SemanticMap.tsx index 35c4271d..23fdc70d 100644 --- a/dashboard/holographic/src/SemanticMap.tsx +++ b/dashboard/holographic/src/SemanticMap.tsx @@ -1,3 +1,4 @@ +import type * as React from "react"; import { useCallback, useEffect, diff --git a/dashboard/holographic/src/Spinner.tsx b/dashboard/holographic/src/Spinner.tsx index 3c453b37..b52d20fa 100644 --- a/dashboard/holographic/src/Spinner.tsx +++ b/dashboard/holographic/src/Spinner.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; /** * Minimal braille spinner — local stand-in for `@nous-research/ui`'s `Spinner`, @@ -9,7 +9,7 @@ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", " interface SpinnerProps { className?: string; - style?: React.CSSProperties; + style?: CSSProperties; "aria-label"?: string; } diff --git a/dashboard/holographic/src/curation/risk.ts b/dashboard/holographic/src/curation/risk.ts index 9ef5a669..b0408a66 100644 --- a/dashboard/holographic/src/curation/risk.ts +++ b/dashboard/holographic/src/curation/risk.ts @@ -2,7 +2,7 @@ import type { MemoryCurateAction } from "../types"; export type ActionRisk = "low" | "medium" | "high" | "review"; -interface ActionGroupDef { +export interface ActionGroupDef { key: string; label: string; description: string; diff --git a/dashboard/holographic/src/d3-force.d.ts b/dashboard/holographic/src/d3-force.d.ts new file mode 100644 index 00000000..61755fac --- /dev/null +++ b/dashboard/holographic/src/d3-force.d.ts @@ -0,0 +1,47 @@ +/** + * Local type surface for `d3-force`. + * + * `d3-force` ships no type declarations and `@types/d3-force` is intentionally + * not a dashboard dependency, so without this ambient declaration the + * `SimulationNodeDatum`/`SimulationLinkDatum` bases that `SimNode`/`SimLink` + * extend (see `./associationGraphTypes`) resolve to nothing and the layout + * mutators (`node.x`, `node.vx`, `link.source`, ...) lose their fields. Only the + * node/link datums are typed precisely — those are what the holographic renderer + * reads and mutates. The simulation *drivers* (force builders / `Simulation`) + * are declared permissively: they are exercised only in + * `./associationGraphLayout`, are untyped at runtime, and keeping them loose + * avoids second-guessing d3's fluent, heavily-generic builder chain. This file + * is type-only and adds no runtime code. + */ +declare module "d3-force" { + export interface SimulationNodeDatum { + index?: number; + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; + } + + export interface SimulationLinkDatum { + source: NodeDatum | string | number; + target: NodeDatum | string | number; + index?: number; + } + + export type Simulation = any; + + export function forceSimulation( + nodes?: N[], + ): Simulation; + export function forceManyBody(): any; + export function forceLink< + NodeDatum extends SimulationNodeDatum, + LinkDatum extends SimulationLinkDatum, + >(links?: LinkDatum[]): any; + export function forceCenter(x?: number, y?: number): any; + export function forceCollide(): any; + export function forceX(x?: number): any; + export function forceY(y?: number): any; +} diff --git a/dashboard/holographic/src/jsx-runtime.ts b/dashboard/holographic/src/jsx-runtime.ts deleted file mode 100644 index 0c50295b..00000000 --- a/dashboard/holographic/src/jsx-runtime.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Automatic-JSX runtime shim. - * - * The dashboard bundler uses the automatic JSX runtime, so it emits imports of - * `jsx`/`jsxs`/`Fragment` from `react/jsx-runtime`. We alias that specifier to - * this module, which implements the runtime on top of the host's - * `React.createElement` (pulled from `./react-shim`). This keeps the plugin off - * a bundled jsx-runtime and on the host's React instance. - */ - -import React from "react"; - -export const Fragment = React.Fragment; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function jsx(type: any, props: any, key?: any) { - const { children, ...rest } = props || {}; - if (key !== undefined) rest.key = key; - return children === undefined - ? React.createElement(type, rest) - : React.createElement(type, rest, children); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function jsxs(type: any, props: any, key?: any) { - const { children, ...rest } = props || {}; - if (key !== undefined) rest.key = key; - const kids = Array.isArray(children) ? children : [children]; - return React.createElement(type, rest, ...kids); -} diff --git a/dashboard/holographic/src/react-shim.ts b/dashboard/holographic/src/react-shim.ts deleted file mode 100644 index 8f290910..00000000 --- a/dashboard/holographic/src/react-shim.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * React shim — maps bare `react` imports onto the host dashboard's React. - * - * The dashboard bundler aliases `react` to this module so the plugin bundle - * shares the host dashboard's single React instance (exposed via - * `window.__HERMES_PLUGIN_SDK__.React`) instead of bundling a second copy. - * Bundled third-party deps (lucide-react, etc.) that `import { forwardRef, - * createElement, useState, useEffect } from "react"` resolve through here too, - * so every name those libraries need is re-exported below. - */ - -interface HermesPluginSDK { - React?: Record; -} - -const sdk: HermesPluginSDK = - (typeof window !== "undefined" && - (window as unknown as { __HERMES_PLUGIN_SDK__?: HermesPluginSDK }) - .__HERMES_PLUGIN_SDK__) || - {}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const React: any = sdk.React || {}; - -export default React; - -export const { - createElement, - cloneElement, - createContext, - createRef, - forwardRef, - memo, - lazy, - isValidElement, - Children, - Fragment, - StrictMode, - Suspense, - startTransition, - useState, - useEffect, - useLayoutEffect, - useInsertionEffect, - useCallback, - useMemo, - useRef, - useContext, - useReducer, - useImperativeHandle, - useId, - useDebugValue, - useDeferredValue, - useTransition, - useSyncExternalStore, -} = React; diff --git a/dashboard/holographic/src/viz/Histogram.tsx b/dashboard/holographic/src/viz/Histogram.tsx index 2569e642..961bd947 100644 --- a/dashboard/holographic/src/viz/Histogram.tsx +++ b/dashboard/holographic/src/viz/Histogram.tsx @@ -1,3 +1,4 @@ +import type * as React from "react"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { AxisBottom } from "./Axis"; import { scaleLinear, type Bin } from "./scale"; diff --git a/dashboard/holographic/src/viz/Sparkline.tsx b/dashboard/holographic/src/viz/Sparkline.tsx index 00dedd97..83fe1323 100644 --- a/dashboard/holographic/src/viz/Sparkline.tsx +++ b/dashboard/holographic/src/viz/Sparkline.tsx @@ -1,3 +1,4 @@ +import type * as React from "react"; import { useMemo, useState } from "react"; import { scaleLinear } from "./scale"; import { useMeasuredWidth } from "./useMeasure"; diff --git a/dashboard/holographic/src/viz/useMeasure.ts b/dashboard/holographic/src/viz/useMeasure.ts index 156cfc4a..d425e773 100644 --- a/dashboard/holographic/src/viz/useMeasure.ts +++ b/dashboard/holographic/src/viz/useMeasure.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState, type RefObject } from "react"; /** * Track the rendered width of a container so SVG charts can fill the @@ -6,7 +6,7 @@ import { useLayoutEffect, useRef, useState } from "react"; */ export function useMeasuredWidth( initial = 720, -): [React.RefObject, number] { +): [RefObject, number] { const ref = useRef(null); const [width, setWidth] = useState(initial); diff --git a/dashboard/lcm/src/App.tsx b/dashboard/lcm/src/App.tsx index fb7b6ce4..8d7db2ae 100644 --- a/dashboard/lcm/src/App.tsx +++ b/dashboard/lcm/src/App.tsx @@ -26,7 +26,6 @@ import { summaryTitle, } from "./helpers"; import { - BarList, CompressionBars, Drawer, DrawerError, @@ -35,13 +34,11 @@ import { Pager, SearchResultCard, SessionDetail, - SkeletonLines, - Stat, TimelineChart, TimeText, toolBadge, } from "./components"; -import { EmptyState, ErrorPanel } from "../../lib/primitives"; +import { BarList, EmptyState, ErrorPanel, SkeletonLines, Stat } from "../../lib/primitives"; function App(): React.ReactElement { const [q, setQ] = useState(""); @@ -231,7 +228,7 @@ function App(): React.ReactElement { const params = new URLSearchParams(); params.set("limit", String(SESSION_FETCH_BATCH)); params.set("offset", String(offset || 0)); - fetchJSON(`${API}/session/${encodeURIComponent(id)}?${params.toString()}`).then(function (json) { + fetchJSON(`${API}/session/${encodeURIComponent(id)}?${params.toString()}`).then(function (json) { updateStackEntry(function (entry) { return entry.kind === "session" && String(entry.id) === String(id); }, function (entry) { @@ -840,11 +837,11 @@ function App(): React.ReactElement { genuinely "empty database", never a masked fetch failure. */} {data ? (
    - - - - - + + + + +
    ) : (overviewLoading ? (
    @@ -903,18 +900,47 @@ function App(): React.ReactElement {

    By Source

    By Role

    - +

    Summary Depth

    - +
    )} diff --git a/dashboard/lcm/src/components.tsx b/dashboard/lcm/src/components.tsx index b4c60869..2bb4d446 100644 --- a/dashboard/lcm/src/components.tsx +++ b/dashboard/lcm/src/components.tsx @@ -29,7 +29,7 @@ import { stripMd, summaryTitle, } from "./helpers"; -import { EmptyState } from "../../lib/primitives"; +import { EmptyState, Stat } from "../../lib/primitives"; // --- search-highlight rendering ------------------------------------------- @@ -79,30 +79,6 @@ export function toolBadge(label: any, kind?: string): React.ReactElement { return {label}; } -export function Stat(props: { value: any; label: any }): React.ReactElement { - return ( -
    -
    {props.value}
    -
    {props.label}
    -
    - ); -} - -export function SkeletonLines(props: { count?: number; widths?: any }): React.ReactElement { - const count = props.count || 3; - const lines: React.ReactElement[] = []; - for (let i = 0; i < count; i++) { - lines.push( -
    , - ); - } - return
    {lines}
    ; -} - export function Pager(props: { totalPages?: number; page: number; onChange: (n: number) => void }): React.ReactElement | null { if (!props.totalPages || props.totalPages <= 1) return null; return ( @@ -168,39 +144,12 @@ export function CopyButton(props: { text: any; label?: string; title?: string }) } // --- chart-ish presentational components ----------------------------------- - -export function BarList(props: { rows?: any[]; keyName: string; onPick?: (label: string) => void }): React.ReactElement { - const rows = props.rows || []; - const keyName = props.keyName; - const onPick = props.onPick; - const total = rows.reduce((acc, row) => acc + (Number(row.count) || 0), 0) || 1; - if (!rows.length) return No data; - return ( -
    - {rows.map(function (row, idx) { - const label = String(row[keyName] == null ? "(none)" : row[keyName]); - const count = Number(row.count) || 0; - const pct = Math.max(2, Math.round((count / total) * 100)); - const clickable = typeof onPick === "function"; - return ( -
    -
    - {label} - {fmtInt(count)} -
    -
    -
    -
    -
    - ); - })} -
    - ); -} +// NOTE: BarList (label/value rows with proportional fills) now uses the shared +// `tdp-bar-list` primitive (lib/primitives.tsx, `proportional` mode), which +// ports this component's head + fill-track layout verbatim. The genuinely +// chart-shaped presentational components below (TimelineChart, CompressionBars) +// are NOT BarList duplicates — they render dated histograms and dual kept/saved +// bars respectively — so they stay plugin-local. /** Responsive CSS bar chart (no SVG stretching, so bars stay crisp and the * summary markers render as true round dots regardless of bucket count). */ @@ -840,10 +789,11 @@ export function SessionDetail(props: { return (
    - - - + + + diff --git a/dashboard/lib/primitives.css b/dashboard/lib/primitives.css index 910d22f1..e2016613 100644 --- a/dashboard/lib/primitives.css +++ b/dashboard/lib/primitives.css @@ -22,6 +22,32 @@ padding: 0.75rem; } +/* `variant="dashed"`: ports savings `.tss-empty` — a left-aligned dashed-border + * box for no-data panels that carry an

    /

    explanation. Overrides the + * centered base (equal specificity, declared later). */ +.tdp-empty-dashed { + place-items: start; + min-height: 0; + padding: 1.4rem 1.2rem; + border: 1px dashed var(--color-border); + border-radius: var(--ts-radius, 12px); + color: var(--color-muted-foreground); + text-align: start; + gap: 0.4rem; +} + +.tdp-empty-dashed h3 { + color: var(--color-foreground); + font-size: 0.95rem; + margin: 0; +} + +.tdp-empty-dashed p { + font-size: 0.8rem; + line-height: 1.5; + margin: 0; +} + /* ----------------------------------------------------------- ErrorPanel */ /* Ports `.tsg-error` verbatim (--ts-red -> --color-destructive, * --ts-radius -> var(--ts-radius, 18px) so it resolves to graph's 18px in the @@ -107,6 +133,30 @@ font-size: 0.68rem; } +/* `variant="compact"`: ports LCM's headline `.hermes-lcm-stat` tile — smaller, + * tighter, with an uppercase label and a tabular (non-mono) value, sized to + * sit several abreast in a flex strip. */ +.tdp-stat-compact { + flex: 1 1 120px; + border-radius: 8px; + background: var(--color-card); + padding: 0.55rem 0.7rem; +} + +.tdp-stat-compact .tdp-stat-value { + font-family: inherit; + font-size: 1.2rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.tdp-stat-compact .tdp-stat-label { + font-size: 0.72rem; + color: var(--color-muted-foreground); + text-transform: uppercase; + letter-spacing: 0.03em; +} + /* -------------------------------------------------------------- BarList */ /* Ports `.tsg-hub-list`. */ .tdp-bar-list { @@ -169,3 +219,48 @@ font-size: 0.68rem; white-space: nowrap; } + +/* `proportional` mode: ports LCM's `.hermes-lcm-bar-*` — a head row (muted + * label + tabular value) over a fill track whose width is ∝ the row's value. + * The modifier collapses the 4-column row grid into a single stacked column. */ +.tdp-bar-row-prop { + grid-template-columns: 1fr; + gap: 0.3rem; +} + +.tdp-bar-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + font-size: 0.8rem; +} + +/* In the proportional head the label is muted (not bold foreground) and the + * value inherits the foreground color with tabular figures — matching the LCM + * source/role/depth bars. */ +.tdp-bar-head .tdp-bar-label { + color: var(--color-muted-foreground); + font-weight: inherit; +} + +.tdp-bar-head .tdp-bar-value { + color: var(--color-foreground); + font-family: inherit; + font-variant-numeric: tabular-nums; +} + +.tdp-bar-track { + height: 7px; + width: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--color-muted-foreground) 22%, transparent); + overflow: hidden; +} + +.tdp-bar-fill { + display: block; + height: 100%; + border-radius: inherit; + background: color-mix(in srgb, var(--color-primary, #5a7fff) 65%, white 10%); +} diff --git a/dashboard/lib/primitives.tsx b/dashboard/lib/primitives.tsx index 74798bc5..a14ca2a3 100644 --- a/dashboard/lib/primitives.tsx +++ b/dashboard/lib/primitives.tsx @@ -17,15 +17,28 @@ import { Button } from "./sdk"; * looks identical to the original hand-rolled markup. */ -/** Centered, muted placeholder (ports `.tsg-empty`). */ +/** + * Muted placeholder for empty/loading states. + * + * `variant`: + * - `"centered"` (default, ports `.tsg-empty`): centered muted block. + * - `"dashed"`: left-aligned dashed-border box (ports savings `.tss-empty`), + * for no-data panels that carry an `

    `/`

    ` explanation. + */ export function EmptyState({ children, + variant = "centered", className, }: { children: React.ReactNode; + variant?: "centered" | "dashed"; className?: string; }) { - return

    {children}
    ; + return ( +
    + {children} +
    + ); } /** @@ -81,20 +94,30 @@ export function SkeletonLines({ ); } -/** Big-value + small-label stat tile (ports the `.tss-stat` shape). */ +/** + * Big-value + small-label stat tile (ports the `.tss-stat` shape). + * + * `variant`: + * - `"default"`: bordered card tile with a large mono value (graph/savings). + * - `"compact"`: smaller, tighter tile with an uppercase label and tabular + * value (ports the LCM headline `.hermes-lcm-stat` row, which sits several + * abreast in a flex strip). + */ export function Stat({ label, value, hint, + variant = "default", className, }: { label: string; value: React.ReactNode; hint?: string; + variant?: "default" | "compact"; className?: string; }) { return ( -
    +
    {value}
    {label}
    {hint &&
    {hint}
    } @@ -108,6 +131,17 @@ export function Stat({ * * `keyName` selects the row field used as the visible label and default key. * Each row may also carry optional `value`, `meta`, and `color` fields. + * + * Options: + * - `proportional`: render each row as a head (label + value) over a fill + * track whose width is proportional to the row's numeric magnitude, ports + * the LCM `.hermes-lcm-bar-*` source/role/depth bars. The magnitude is read + * from `valueName` (default `"value"`); the displayed value is still the + * row's `value` field (a pre-formatted string), so the caller controls both + * the fill ratio and the exact rendered text. + * - `valueName`: field holding the numeric fill magnitude (default `"value"`). + * - `emptyText`: when set and `rows` is empty, renders an `EmptyState` with + * this text instead of an empty container. */ export function BarList>({ rows, @@ -116,6 +150,9 @@ export function BarList>({ rowKey, titleFor, className, + proportional, + valueName = "value", + emptyText, }: { rows: Array; keyName: string; @@ -123,7 +160,16 @@ export function BarList>({ rowKey?: (row: Row) => string; titleFor?: (row: Row) => string; className?: string; + proportional?: boolean; + valueName?: string; + emptyText?: string; }) { + if (!rows.length) { + return emptyText ? {emptyText} :
    ; + } + const total = proportional + ? rows.reduce((acc, row) => acc + (Number(row[valueName]) || 0), 0) || 1 + : 0; return (
    {rows.map((row) => { @@ -133,7 +179,24 @@ export function BarList>({ const value = "value" in row ? row.value : undefined; const meta = "meta" in row ? row.meta : undefined; const color = "color" in row ? row.color : undefined; - const inner = ( + const inner = proportional ? ( + <> +
    + {label} + {value !== undefined && {String(value)}} +
    + + + ) : ( <> {color !== undefined && ( @@ -143,18 +206,19 @@ export function BarList>({ {value !== undefined && {String(value)}} ); + const rowClassName = cn("tdp-bar-row", proportional && "tdp-bar-row-prop"); return onPick ? ( ) : ( -
    +
    {inner}
    ); diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 95c3df22..645ad1e5 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -17,10 +17,12 @@ "devDependencies": { "@rsbuild/core": "^2.0.15", "@rsbuild/plugin-react": "^2.1.0", + "@rsbuild/plugin-type-check": "^1.4.0", "@rspack/core": "^2.0.8", "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", + "esbuild": "^0.28.1", "jsdom": "^29.1.1", "playwright": "^1.60.0", "tailwindcss": "^4.3.1", @@ -303,123 +305,63 @@ "tslib": "^2.4.0" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@observablehq/plot": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", - "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", - "license": "ISC", - "dependencies": { - "d3": "^7.9.0", - "interval-tree-1d": "^1.0.0", - "isoformat": "^0.2.0" - }, + "os": [ + "android" + ], "engines": { - "node": ">=12" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "node": ">=18" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", @@ -428,13 +370,13 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -445,13 +387,13 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -462,15 +404,15 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -479,32 +421,32 @@ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", @@ -513,13 +455,13 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -530,15 +472,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ - "ppc64" + "ia32" ], "dev": true, "license": "MIT", @@ -547,15 +489,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ - "s390x" + "loong64" ], "dev": true, "license": "MIT", @@ -564,15 +506,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ - "x64" + "mips64el" ], "dev": true, "license": "MIT", @@ -581,15 +523,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, "license": "MIT", @@ -598,153 +540,98 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ - "wasm32" + "s390x" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "netbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rsbuild/core": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.15.tgz", - "integrity": "sha512-O8vmMhZu1YImO6jOqt/K/vlJSvkq7UtSq5YM1DIlcEd9LW8Gf6/dkQ1B2KPI6F+hSMFBnTTTumdcIowSLCw97g==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rspack/core": "~2.0.8", - "@swc/helpers": "^0.5.23" - }, - "bin": { - "rsbuild": "bin/rsbuild.js" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "core-js": ">= 3.0.0" - }, - "peerDependenciesMeta": { - "core-js": { - "optional": true - } - } - }, - "node_modules/@rsbuild/plugin-react": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-2.1.0.tgz", - "integrity": "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rspack/plugin-react-refresh": "^2.0.2", - "react-refresh": "^0.18.0" - }, - "peerDependencies": { - "@rsbuild/core": "^2.0.0" - }, - "peerDependenciesMeta": { - "@rsbuild/core": { - "optional": true - } - } - }, - "node_modules/@rspack/binding": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", - "integrity": "sha512-3uZ+y8aQxq33ty2srMxg2Nu0XuBI6vVrG50rkDaXqwWqOohfgGUSfFuQK7EnSUNy4aFUQlCG6NHialQHJov0wg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "@rspack/binding-darwin-arm64": "2.0.8", - "@rspack/binding-darwin-x64": "2.0.8", - "@rspack/binding-linux-arm64-gnu": "2.0.8", - "@rspack/binding-linux-arm64-musl": "2.0.8", - "@rspack/binding-linux-x64-gnu": "2.0.8", - "@rspack/binding-linux-x64-musl": "2.0.8", - "@rspack/binding-wasm32-wasi": "2.0.8", - "@rspack/binding-win32-arm64-msvc": "2.0.8", - "@rspack/binding-win32-ia32-msvc": "2.0.8", - "@rspack/binding-win32-x64-msvc": "2.0.8" + "node": ">=18" } }, - "node_modules/@rspack/binding-darwin-arm64": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.8.tgz", - "integrity": "sha512-vCgbgH7B7qom+uID+RCZsTCOYFb9wC4/4+1U6rMfytrXGVJ72eNQs2tbdjOl0lb18CT3N/n+VkWynUiLk84GwA==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -752,13 +639,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-darwin-x64": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.8.tgz", - "integrity": "sha512-satPm2PD4B7jDTVlVAdvMVdUszwLvWUEnUDzLb77mvVkezKNDZmuhb+e8s+FfKs8hJpNbZ9VAejuA2rr8o985w==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -766,13 +656,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.8.tgz", - "integrity": "sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -780,848 +673,1960 @@ "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.8.tgz", - "integrity": "sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.8.tgz", - "integrity": "sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-linux-x64-musl": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.8.tgz", - "integrity": "sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ - "x64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rspack/binding-wasm32-wasi": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.8.tgz", - "integrity": "sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ - "wasm32" + "x64" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "1.1.4" + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@rspack/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.8.tgz", - "integrity": "sha512-8NCuiQsAhXrwRBy57QZoypqrws/zLBkaQVGiB8hksr6v++8hNigNjqpQARLbd0iyMuHsQQ++8+auGk6xlDXmzw==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.8.tgz", - "integrity": "sha512-bxiekytbX7V9KFAra+HkwtNWC6pYfHEBBZFpiT0xUs3mCFOmAAFVBsBSQsoCP9AdCEXoMAvNdnrHNw3iov4OZw==", - "cpu": [ - "ia32" - ], + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.8.tgz", - "integrity": "sha512-7zPs8YCe/ZVJTwd+5lpB0CP0tkn2pONf/T1ycmVY76u21Nrwt8mXQGc/2yH2eWP4B7fikYBr3hGr7mpR2fajqQ==", - "cpu": [ - "x64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@rspack/core": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.8.tgz", - "integrity": "sha512-+NLGJf8gZxihDmMFzjlly3toc2SMjeDmuvz0/Cai9AMdV4F+Pqcnt2BA9V4e3SY2jmhJQtPwgyyLtR1RiJO77g==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { - "@rspack/binding": "2.0.8" - }, + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10.0" }, - "peerDependencies": { - "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", - "@swc/helpers": "^0.5.23" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" }, - "peerDependenciesMeta": { - "@module-federation/runtime-tools": { - "optional": true - }, - "@swc/helpers": { - "optional": true - } + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@rspack/plugin-react-refresh": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-2.0.2.tgz", - "integrity": "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==", + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", "dev": true, - "license": "MIT", - "peerDependencies": { - "@rspack/core": "^2.0.0", - "react-refresh": ">=0.10.0 <1.0.0" + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.7.tgz", + "integrity": "sha512-GDKuYHjP7vAI1kjBo73V+STKr9XIMZknW/xirpRW/EcShX0IKSev/ALafeRfC8Q331nodrXUFu04PugPB0MAhw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "tslib": "^2.8.0" + "@jsonjoy.com/fs-node-builtins": "4.57.7", + "@jsonjoy.com/fs-node-utils": "4.57.7", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.7.tgz", + "integrity": "sha512-1rWsah2nZtRbNeP+c61QcfGfVrJXBmBD0Hm7Akvv4C9MKEasXnbiOS//iH3T3HwUSSBATGrfSp0Xi8nlNhATeQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "@jsonjoy.com/fs-core": "4.57.7", + "@jsonjoy.com/fs-node-builtins": "4.57.7", + "@jsonjoy.com/fs-node-utils": "4.57.7", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.7.tgz", + "integrity": "sha512-xhnyeyEVTiIOibFvda/5n89nChMLCPKHHM2WQ+GGDf6+U/IrQBW3Qx6x+Uq1bkDbxBkybLOdIGoBtVBrE8Nngg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.7", + "@jsonjoy.com/fs-node-builtins": "4.57.7", + "@jsonjoy.com/fs-node-utils": "4.57.7", + "@jsonjoy.com/fs-print": "4.57.7", + "@jsonjoy.com/fs-snapshot": "4.57.7", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", - "cpu": [ - "arm64" - ], + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.7.tgz", + "integrity": "sha512-LWqfY1m+uAosjwM1RrKhMkUnP9jcq1RUczHsNO779ovm1E9v8I/pmj04eBAcoBjhC7ltcPbNFGyRJ5JqSJ7Jdg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", - "cpu": [ - "arm64" - ], + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.7.tgz", + "integrity": "sha512-9T0zC9LKcAWXDoTLRdLMoJ0seOvJ5bgDKq1tSBoQAFQpPDstQUeV1Oe7PLypdu7F2D3ddRstmwgeNUEN/VaZ4Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.7", + "@jsonjoy.com/fs-node-builtins": "4.57.7", + "@jsonjoy.com/fs-node-utils": "4.57.7" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", - "cpu": [ - "x64" - ], + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.7.tgz", + "integrity": "sha512-jjWSDOsfcog2cZnUCwX5AHmlIq6b6wx5Pz/2LAcNjJ62Rajwg89Fy7ubN+lDHew0/1reLDa9Z5urybYadhh37g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.7" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", - "cpu": [ - "x64" - ], + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.7.tgz", + "integrity": "sha512-mFM4P4Gjq0QQHkLnXzPYPEMFrAoe6a5Myedgb6+CmL+nGd3MKvTxYPuD7N1dLIH9RBy1fLdzxd80qvuK8xrx3Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.7", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", - "cpu": [ - "arm" - ], + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.7.tgz", + "integrity": "sha512-1GS3+plfm2giB3PqokiqyydyqYTPLcCQIKSkp0TdMNRh3KVk7rqRM6U785FLlVRG7XLmkc0KWr215OY+22K3QA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.7", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", - "cpu": [ - "arm64" - ], + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", - "cpu": [ - "arm64" - ], + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", - "cpu": [ - "x64" - ], + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", - "cpu": [ - "x64" - ], + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", "dev": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", - "cpu": [ - "arm64" - ], + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", - "cpu": [ - "x64" - ], + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" }, "engines": { - "node": ">=18" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5" + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" }, "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", + "node_modules/@observablehq/plot": { + "version": "0.6.17", + "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", + "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", + "license": "ISC", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "d3": "^7.9.0", + "interval-tree-1d": "^1.0.0", + "isoformat": "^0.2.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.8", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.8", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/bidi-js": { + "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/binary-search-bounds": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", - "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", - "license": "MIT" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" - }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", + "node_modules/@rsbuild/core": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.15.tgz", + "integrity": "sha512-O8vmMhZu1YImO6jOqt/K/vlJSvkq7UtSq5YM1DIlcEd9LW8Gf6/dkQ1B2KPI6F+hSMFBnTTTumdcIowSLCw97g==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" + "@rspack/core": "~2.0.8", + "@swc/helpers": "^0.5.23" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" + "bin": { + "rsbuild": "bin/rsbuild.js" }, "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "core-js": ">= 3.0.0" + }, + "peerDependenciesMeta": { + "core-js": { + "optional": true + } } }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", + "node_modules/@rsbuild/plugin-react": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-2.1.0.tgz", + "integrity": "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-array": "^3.2.0" + "@rspack/plugin-react-refresh": "^2.0.2", + "react-refresh": "^0.18.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@rsbuild/core": "^2.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", + "node_modules/@rsbuild/plugin-type-check": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-type-check/-/plugin-type-check-1.4.0.tgz", + "integrity": "sha512-RPoLy6BP/PoZy8AYJR1oCGMI/MoMAIsos5K5G89OLK5n+6bxMox1F/p3pmO86d7G9jEChh7GJI33PjHYHyVL1w==", + "dev": true, + "license": "MIT", "dependencies": { - "delaunator": "5" + "deepmerge": "^4.3.1", + "json5": "^2.2.3", + "reduce-configs": "^1.1.2", + "ts-checker-rspack-plugin": "^1.4.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@rsbuild/core": "^1.0.0 || ^2.0.0-0", + "@typescript/native-preview": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + } } }, - "node_modules/d3-dispatch": { + "node_modules/@rspack/binding": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", + "integrity": "sha512-3uZ+y8aQxq33ty2srMxg2Nu0XuBI6vVrG50rkDaXqwWqOohfgGUSfFuQK7EnSUNy4aFUQlCG6NHialQHJov0wg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "2.0.8", + "@rspack/binding-darwin-x64": "2.0.8", + "@rspack/binding-linux-arm64-gnu": "2.0.8", + "@rspack/binding-linux-arm64-musl": "2.0.8", + "@rspack/binding-linux-x64-gnu": "2.0.8", + "@rspack/binding-linux-x64-musl": "2.0.8", + "@rspack/binding-wasm32-wasi": "2.0.8", + "@rspack/binding-win32-arm64-msvc": "2.0.8", + "@rspack/binding-win32-ia32-msvc": "2.0.8", + "@rspack/binding-win32-x64-msvc": "2.0.8" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.8.tgz", + "integrity": "sha512-vCgbgH7B7qom+uID+RCZsTCOYFb9wC4/4+1U6rMfytrXGVJ72eNQs2tbdjOl0lb18CT3N/n+VkWynUiLk84GwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.8.tgz", + "integrity": "sha512-satPm2PD4B7jDTVlVAdvMVdUszwLvWUEnUDzLb77mvVkezKNDZmuhb+e8s+FfKs8hJpNbZ9VAejuA2rr8o985w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.8.tgz", + "integrity": "sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.8.tgz", + "integrity": "sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.8.tgz", + "integrity": "sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.8.tgz", + "integrity": "sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.8.tgz", + "integrity": "sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "1.1.4" + } + }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.8.tgz", + "integrity": "sha512-8NCuiQsAhXrwRBy57QZoypqrws/zLBkaQVGiB8hksr6v++8hNigNjqpQARLbd0iyMuHsQQ++8+auGk6xlDXmzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.8.tgz", + "integrity": "sha512-bxiekytbX7V9KFAra+HkwtNWC6pYfHEBBZFpiT0xUs3mCFOmAAFVBsBSQsoCP9AdCEXoMAvNdnrHNw3iov4OZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.8.tgz", + "integrity": "sha512-7zPs8YCe/ZVJTwd+5lpB0CP0tkn2pONf/T1ycmVY76u21Nrwt8mXQGc/2yH2eWP4B7fikYBr3hGr7mpR2fajqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.8.tgz", + "integrity": "sha512-+NLGJf8gZxihDmMFzjlly3toc2SMjeDmuvz0/Cai9AMdV4F+Pqcnt2BA9V4e3SY2jmhJQtPwgyyLtR1RiJO77g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/binding": "2.0.8" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", + "@swc/helpers": "^0.5.23" + }, + "peerDependenciesMeta": { + "@module-federation/runtime-tools": { + "optional": true + }, + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.2.tgz", + "integrity": "sha512-1OnyWChLGE46YzWyjlmYJssOu/Y0STAnnr2ueKPqDCYTf63GJMs0mxNnCul4dNiVqHYPKv3/fxrTY3IpqoVwZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-2.0.2.tgz", + "integrity": "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@rspack/core": "^2.0.0", + "react-refresh": ">=0.10.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", @@ -1919,1578 +2924,1420 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" - }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, - "node_modules/interval-tree-1d": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz", - "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==", - "license": "MIT", + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", "dependencies": { - "binary-search-bounds": "^2.0.0" + "robust-predicates": "^3.0.2" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, - "license": "MIT" - }, - "node_modules/isoformat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", - "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", - "license": "ISC" + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "license": "Apache-2.0", + "engines": { + "node": ">=8" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": ">=10.13.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 12.0.0" + "node": ">=20.19.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "license": "MIT" }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=12.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=8" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "license": "MPL-2.0", + "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">= 6" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 12.0.0" + "node": ">=10.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=10.18" } }, - "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": "20 || >=22" + "node": ">=0.10.0" } }, - "node_modules/lucide-react": { - "version": "0.577.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", - "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "engines": { + "node": ">=12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/interval-tree-1d": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz", + "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==", "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "binary-search-bounds": "^2.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=0.10.0" } }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, "engines": { - "node": ">=12.20.0" + "node": ">=0.10.0" } }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=0.12.0" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "node_modules/isoformat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", + "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", "dependencies": { - "playwright-core": "1.60.0" - }, - "bin": { - "playwright": "cli.js" + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, - "optionalDependencies": { - "fsevents": "2.3.2" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "bin": { - "playwright-core": "cli.js" + "json5": "lib/cli.js" }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "detect-libc": "^2.0.3" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" + "node": ">= 12.0.0" }, - "peerDependencies": { - "react": "^19.2.7" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" - }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 12.0.0" }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=v12.22.7" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, - "license": "MIT", + "license": "CC0-1.0" + }, + "node_modules/memfs": { + "version": "4.57.7", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.7.tgz", + "integrity": "sha512-YZPphUQZSRGk6ddPlsNuMbztrLwsbUATFNZcqKscSbSJZ4g0+Y3vSZLJ/rfnGZaB1FFhC7SrywZXev6i8lnHgg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" + "@jsonjoy.com/fs-core": "4.57.7", + "@jsonjoy.com/fs-fsa": "4.57.7", + "@jsonjoy.com/fs-node": "4.57.7", + "@jsonjoy.com/fs-node-builtins": "4.57.7", + "@jsonjoy.com/fs-node-to-fsa": "4.57.7", + "@jsonjoy.com/fs-node-utils": "4.57.7", + "@jsonjoy.com/fs-print": "4.57.7", + "@jsonjoy.com/fs-snapshot": "4.57.7", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=14.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/tldts": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", - "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.2" - }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/tldts-core": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", - "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=12.20.0" } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "entities": "^8.0.0" }, - "engines": { - "node": ">=20" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "0BSD" + "license": "MIT" }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "license": "ISC" }, - "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "playwright-core": "1.60.0" }, "bin": { - "vitest": "vitest.mjs" + "playwright": "cli.js" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=18" }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, - "jsdom": { - "optional": true + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" }, - "vite": { - "optional": false + { + "type": "github", + "url": "https://github.com/sponsors/ai" } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, "engines": { - "node": ">=18" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, + "dependencies": { + "picomatch": "^2.2.1" + }, "engines": { - "node": ">=18" + "node": ">=8.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], + "node_modules/reduce-configs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/reduce-configs/-/reduce-configs-1.1.2.tgz", + "integrity": "sha512-AgBP55V8FC7NaqoOP2RCbTpu6LE+YuX3LUZkNAoitcfyS3/PIC8Obg/TJrBzTkJ+lDvZv0TTAeDpLkzjTtYlbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" } }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { "node": ">=18" } }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8.0" } }, - "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/ts-checker-rspack-plugin": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.4.0.tgz", + "integrity": "sha512-xPBPe6n9XZW9vMw4PFJLZFY372rejKK8UFC12i33J+yJK+fmsQQ4k9eoOlbLE0zC6vftyKDhvfMF3RyMFFTOZw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "@rspack/lite-tapable": "^1.1.1", + "chokidar": "^3.6.0", + "memfs": "^4.57.3", + "picocolors": "^1.1.1" + }, + "peerDependencies": { + "@rspack/core": "^1.0.0 || ^2.0.0", + "@typescript/native-preview": "^7.0.0-0", + "typescript": ">=3.8.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + } } }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=18" + "node": ">=14.17" } }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=20.18.1" } }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=18" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, "node_modules/vitest/node_modules/@vitest/mocker": { @@ -3520,50 +4367,6 @@ } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/vitest/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 25bfb520..c4f4a2e9 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -24,14 +24,16 @@ "devDependencies": { "@rsbuild/core": "^2.0.15", "@rsbuild/plugin-react": "^2.1.0", + "@rsbuild/plugin-type-check": "^1.4.0", "@rspack/core": "^2.0.8", "@tailwindcss/node": "^4.3.1", "@tailwindcss/oxide": "^4.3.1", "@testing-library/react": "^16.3.2", + "esbuild": "^0.28.1", "jsdom": "^29.1.1", "playwright": "^1.60.0", "tailwindcss": "^4.3.1", - "vitest": "^4.1.8", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "vitest": "^4.1.8" } } diff --git a/dashboard/savings/src/ModelsPanel.tsx b/dashboard/savings/src/ModelsPanel.tsx index 8d8777c2..1d595732 100644 --- a/dashboard/savings/src/ModelsPanel.tsx +++ b/dashboard/savings/src/ModelsPanel.tsx @@ -6,6 +6,7 @@ import React from "react"; import { Badge, Card, CardContent, CardHeader, CardTitle, timeAgo } from "../../lib/sdk"; +import { EmptyState } from "../../lib/primitives"; import { fillDailySeries, fmtTokens, fmtUsd } from "./logic"; import { rowCost } from "./pricing"; import type { PriceTable } from "./pricing"; @@ -55,7 +56,7 @@ export default function ModelsPanel({ prices: PriceTable; }) { if (!data) { - return
    Loading model aggregates…
    ; + return Loading model aggregates…; } const dailyTokens = fillDailySeries( diff --git a/dashboard/savings/src/SavingsOverviewPanel.tsx b/dashboard/savings/src/SavingsOverviewPanel.tsx index 8c53abb1..a37c8f40 100644 --- a/dashboard/savings/src/SavingsOverviewPanel.tsx +++ b/dashboard/savings/src/SavingsOverviewPanel.tsx @@ -5,7 +5,7 @@ import React from "react"; import { Badge, Card, CardContent, CardHeader, CardTitle } from "../../lib/sdk"; -import { Stat } from "../../lib/primitives"; +import { EmptyState, Stat } from "../../lib/primitives"; import { fillDailySeries, fmtTokens, fmtUsd, projectLabel } from "./logic"; import { savedTokensUsd } from "./pricing"; import type { PriceTable } from "./pricing"; @@ -22,19 +22,19 @@ export default function SavingsOverviewPanel({ prices: PriceTable; }) { if (!overview) { - return
    Loading savings analytics…
    ; + return Loading savings analytics…; } const savings = overview.savings; if (!savings.available) { return ( -
    +

    Global accounting database unavailable

    The savings ledger lives in ~/.tracedecay/global.db{" "} (override: TRACEDECAY_GLOBAL_DB), which could not be opened.

    -
    + ); } diff --git a/dashboard/savings/src/SessionsPanel.tsx b/dashboard/savings/src/SessionsPanel.tsx index 1ff8f90a..ec28e865 100644 --- a/dashboard/savings/src/SessionsPanel.tsx +++ b/dashboard/savings/src/SessionsPanel.tsx @@ -5,6 +5,7 @@ import React, { useState } from "react"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, cn, timeAgo } from "../../lib/sdk"; +import { EmptyState } from "../../lib/primitives"; import { BASIS_LABELS, cleanTitle, fmtTokens, fmtUsd } from "./logic"; import type { CostBasis } from "./logic"; import { rowCost, summarizeCosts } from "./pricing"; @@ -103,26 +104,26 @@ export default function SessionsPanel({ const [expanded, setExpanded] = useState(null); if (!data) { - return
    Loading session accounting…
    ; + return Loading session accounting…; } if (!data.available) { return ( -
    +

    Session store unavailable

    No session database could be opened for this project.

    -
    + ); } if (!data.sessions.length) { return ( -
    +

    No sessions in this range

    Sessions appear here once agent transcripts are ingested into the session store ({data.db}). Sessions without timestamps are only listed in the “All time” range.

    -
    + ); } diff --git a/dashboard/savings/src/charts.tsx b/dashboard/savings/src/charts.tsx index c6ef7b46..1e8e7461 100644 --- a/dashboard/savings/src/charts.tsx +++ b/dashboard/savings/src/charts.tsx @@ -1,6 +1,10 @@ /** * Compact hand-rolled SVG charts in the shared design-token vocabulary * (same approach as the Code Graph tab — no charting dependency). + * + * `HBarChart` is a true SVG chart (proportional bars laid out on a pixel + * grid with inline value labels), NOT a duplicate of the shared `BarList` + * primitive (which is a label/value row list) — so it stays plugin-local. */ import React from "react"; diff --git a/dashboard/test/code-graph-explorer-hooks.vitest.tsx b/dashboard/test/code-graph-explorer-hooks.vitest.tsx index 42d1f649..d93562f0 100644 --- a/dashboard/test/code-graph-explorer-hooks.vitest.tsx +++ b/dashboard/test/code-graph-explorer-hooks.vitest.tsx @@ -131,7 +131,11 @@ describe("code graph explorer hooks", () => { ); const { result } = renderHook(() => - useGraphInspection({ loadNode, loadNeighbors, onError: vi.fn() }), + useGraphInspection({ + loadNode: loadNode as any, + loadNeighbors: loadNeighbors as any, + onError: vi.fn(), + }), ); await act(async () => { diff --git a/dashboard/test/curation-data.vitest.tsx b/dashboard/test/curation-data.vitest.tsx index d054052a..3c950164 100644 --- a/dashboard/test/curation-data.vitest.tsx +++ b/dashboard/test/curation-data.vitest.tsx @@ -13,7 +13,7 @@ function deferred() { return { promise, resolve, reject }; } -function makeApi(overrides = {}) { +function makeApi(overrides = {}): any { return { getMemoryCuratorPreview: vi.fn().mockResolvedValue({ report: null, saved_at: null }), getMemoryCuratorActivity: vi.fn().mockResolvedValue({ events: [] }), diff --git a/dashboard/test/helpers/module-loader.mjs b/dashboard/test/helpers/module-loader.mjs index 09f6895e..fbc0d374 100644 --- a/dashboard/test/helpers/module-loader.mjs +++ b/dashboard/test/helpers/module-loader.mjs @@ -1,8 +1,4 @@ -import { rspack } from "@rspack/core"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; function restoreGlobals(previous) { for (const [key, prior] of previous.entries()) { @@ -14,19 +10,6 @@ function restoreGlobals(previous) { } } -function runRspack(config) { - return new Promise((resolve, reject) => { - rspack(config, (err, stats) => { - if (err) return reject(err); - if (stats.hasErrors()) { - const info = stats.toJson({ all: false, errors: true }); - return reject(new Error(info.errors.map((e) => e.message).join("\n"))); - } - resolve(stats); - }); - }); -} - export async function importBundledModule(entryPath, { globals = {} } = {}) { const previous = new Map(); for (const [key, value] of Object.entries(globals)) { @@ -37,54 +20,23 @@ export async function importBundledModule(entryPath, { globals = {} } = {}) { globalThis[key] = value; } - const outDir = mkdtempSync(path.join(tmpdir(), "td-module-loader-")); try { - await runRspack({ - mode: "development", - entry: { bundled: entryPath }, - experiments: { outputModule: true }, - output: { - module: true, - library: { type: "module" }, - path: outDir, - filename: "bundled.mjs", - }, - resolve: { extensions: [".tsx", ".ts", ".jsx", ".js", ".json"] }, - module: { - rules: [ - { - test: /\.(tsx?|jsx)$/, - exclude: /node_modules/, - use: { - loader: "builtin:swc-loader", - options: { - jsc: { - parser: { - syntax: "typescript", - tsx: true, - }, - transform: { react: { runtime: "automatic" } }, - }, - }, - }, - }, - ], - }, - // The tests need real React elements (e.g. sdk.jsx's `import React`), - // so bundle `react` from node_modules rather than externalizing it. - optimization: { minimize: false, splitChunks: false, runtimeChunk: false }, - performance: { hints: false }, - stats: { preset: "errors-only" }, + const result = await build({ + entryPoints: [entryPath], + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + write: false, + logLevel: "silent", }); - - const outFile = path.join(outDir, "bundled.mjs"); - return await import(pathToFileURL(outFile).href + "?t=" + Date.now()); + const code = result.outputFiles[0]?.text; + if (!code) { + throw new Error(`esbuild produced no output for ${entryPath}`); + } + const encoded = Buffer.from(code).toString("base64"); + return import(`data:text/javascript;base64,${encoded}#${encodeURIComponent(entryPath)}`); } finally { restoreGlobals(previous); - try { - rmSync(outDir, { recursive: true, force: true }); - } catch { - // best-effort cleanup - } } } diff --git a/dashboard/test/semantic-map-interactions.vitest.ts b/dashboard/test/semantic-map-interactions.vitest.ts index f340c7b6..02257c8b 100644 --- a/dashboard/test/semantic-map-interactions.vitest.ts +++ b/dashboard/test/semantic-map-interactions.vitest.ts @@ -13,7 +13,7 @@ import { selectIdsInScreenRect, } from "../holographic/src/semanticMap/gestures"; -const placed = [ +const placed: any[] = [ { point: { fact_id: 11 }, x: 60, y: 60, r: 6 }, { point: { fact_id: 22 }, x: 150, y: 140, r: 8 }, { point: { fact_id: 33 }, x: 280, y: 160, r: 10 }, @@ -40,7 +40,7 @@ describe("semantic map transform helpers", () => { [ { point: { fact_id: 1 }, x: 100, y: 100, r: 4 }, { point: { fact_id: 2 }, x: 101, y: 102, r: 4 }, - ], + ] as any[], 600, 400, ); diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index 570ce3d5..b6888149 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -15,8 +15,20 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "useDefineForClassFields": true + "useDefineForClassFields": true, + "lib": [ + "ES2021", + "DOM", + "DOM.Iterable" + ] }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "**/dist", "**/build"] + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + "**/dist", + "**/build" + ] } diff --git a/docs/LCM-PAYLOAD-TRIAGE.md b/docs/LCM-PAYLOAD-TRIAGE.md index 63cfb2d9..d206004a 100644 --- a/docs/LCM-PAYLOAD-TRIAGE.md +++ b/docs/LCM-PAYLOAD-TRIAGE.md @@ -47,8 +47,9 @@ The contract's §2 claims were re-checked against the current tree; all hold: - `LcmError` (`types.rs:726`) has `InvalidPayloadRef`/`PayloadNotFound`/`PayloadMissing`/ `PayloadIntegrityMismatch`; **`PayloadGc'd` and `StillReferenced` are absent** → both are new work (confirms MF-1 and the test-plan §8 open item). ✓ -- Frontend source is real: `dashboard/lcm/src/{index.js,style.css}` → built to `dist/` by - `dashboard/build.mjs`. The visibility UI card lands there. ✓ +- Frontend source is real: `dashboard/lcm/src/{entry.tsx,styles.css}` → built to + `dashboard/lcm/dist/{index.js,style.css}` by `dashboard/build.mjs`. The visibility UI card + lands there. ✓ ## 3. Open items reconciled (decisions for the implementation) @@ -117,7 +118,7 @@ A1 (foundation) ──► A2 (reaper engine) ──► B (visibility + invoc | **A1 — foundation** | `schema.rs` (v5: `lcm_gc_marks`+`lcm_gc_meta`+get/set), `types.rs` (`PayloadGc'd`+`StillReferenced`+`LcmGcConfig`), `gc.rs` NEW (extracted `referenced_payload_refs` + `tombstone_placeholder_in_text`), `doctor.rs` (call shared fn) | `TS-001..003`, `CFG-001..005`, `FS-001..008` | bounded; clear spec | | **A2 — reaper engine** | `payload.rs` (`delete_external_payload`+`DeleteOpts/Outcome`, `safe_remove_payload_file`, wire `expand`→`PayloadGc'd`), `gc.rs` (`LcmGcReport`, phases A–D, `run_payload_gc` w/ injected clock, reap-time ref re-check) | `DEL-001..007`, `PHA/PHB/PHC/PHD-*`, `GC-001..012`, `DRY-001..003` | hardest; safety-critical (path/symlink/crash/txn) | | **B — visibility + invocation** | `query.rs` (`status()` byte/orphan/tombstoned additions), `doctor.rs` (`payload_diagnostics` bytes + `mode=gc`), `types.rs` (`LcmPayloadGcStatus` + status fields + `LcmGcReport` surfacing), `mcp/tools/definitions.rs` + `handlers/session.rs` (`lcm_status deep`, `lcm_doctor gc` mode), `dashboard/lcm_api.rs` + `mod.rs` (`payload_health` block, `/payloads/health`, `/payloads/gc`, capabilities), `agents/hermes/templates.rs` (gc mode desc + env wiring) | `VIS-001..012` | multi-file integration; cross-surface agreement | -| **C — frontend** *(deferred, spawned after B)* | `dashboard/lcm/src/{index.js,style.css}` (Payload Health card + dry-run→apply modal) → rebuild `dist/` via `build.mjs` | visual QA of green/amber/red pill + modal | UI; operator review of design | +| **C — frontend** *(deferred, spawned after B)* | `dashboard/lcm/src/{entry.tsx,styles.css}` (Payload Health card + dry-run→apply modal) → rebuild `dashboard/lcm/dist/{index.js,style.css}` via `build.mjs` | visual QA of green/amber/red pill + modal | UI; operator review of design | **Why A1/A2/B are serial, not parallel:** A2 needs A1's schema + extracted helper + error variants; B needs A2's `LcmGcReport`/status fields; all three write `types.rs` and `gc.rs`/ diff --git a/docs/MOBILE-VERIFICATION-CHECKLIST.md b/docs/MOBILE-VERIFICATION-CHECKLIST.md index 020bd0ba..bae94e91 100644 --- a/docs/MOBILE-VERIFICATION-CHECKLIST.md +++ b/docs/MOBILE-VERIFICATION-CHECKLIST.md @@ -114,7 +114,7 @@ Breakpoint reference (current code): `xl:`(1280) + a custom `@media (max-width: 720px)` (`dashboard/holographic/src/styles.css:407,416,423,436,452`). - **LCM:** `@media (max-width: 760px)` — top bar / heads / result foot / - pager go columnar (`dashboard/lcm/src/style.css:1189`). + pager go columnar (`dashboard/lcm/src/styles.css`). --- @@ -323,18 +323,18 @@ imperative Canvas2D surface with `touch-action:none`. ## 6. LCM dashboard (`/lcm`) Route: `?tab=hermes-lcm` (manifest `tab.path: /lcm`). Source: -`dashboard/lcm/src/index.js` (hand-written vanilla JS via -`React.createElement` — no build step). It has a search interface (search head, -result cards, pager), recent-sessions list, and a modal **Drawer** for -session/message/node detail (`role="dialog"`, `aria-modal`), plus -CompressionBars/TimelineChart/markdown rendering. +`dashboard/lcm/src/entry.tsx` + `dashboard/lcm/src/styles.css`, built by +`dashboard/build.mjs` to `dashboard/lcm/dist/{index.js,style.css}`. It has a +search interface (search head, result cards, pager), recent-sessions list, and +a modal **Drawer** for session/message/node detail (`role="dialog"`, +`aria-modal`), plus CompressionBars/TimelineChart/markdown rendering. 1. **Responsive column collapse at ≤760px:** the top bar, search/section/msg heads, and result foot stack vertically; the pager stretches full-width. - (`lcm/src/style.css:1189`) + (`lcm/src/styles.css`) 2. **Tables / wide rows scroll horizontally** (`overflow-x:auto`), they must not blow out the page width. WATCH: any element using `min-width:max-content` - (`lcm/src/style.css:730`) is a likely horizontal-overflow source on a 360px + (`lcm/src/styles.css`) is a likely horizontal-overflow source on a 360px phone — confirm it scrolls, not the page. 3. **Search:** the box is tappable; typing returns result cards; the pager is usable by touch; snippets highlight query terms and wrap without clipping. @@ -352,7 +352,7 @@ CompressionBars/TimelineChart/markdown rendering. header/footer (test on SE 667px). 5. **Keyboard shortcuts** (BT): the page-level `onKeyDown` (and `isPanelHidden()` gating) must not fire while focus is in an input, the - drawer, or a scroll region. (`lcm/src/index.js:1518-1561`) + drawer, or a scroll region. (`lcm/src/entry.tsx`) 6. **Recent Sessions / empty state:** both render correctly at phone widths; the empty-state orb + message are centered and not clipped. (The existing `smoke.mjs --expect-lcm=` asserts which state appears.) @@ -431,7 +431,7 @@ Apply these across all three dashboards: 1. **No page-level horizontal scrollbar** on any dashboard at 360–414px widths. Known likely sources to scrutinize: LCM `min-width:max-content` - (`lcm/src/style.css:730`); any `grid-cols-[fixed fixed …]` that doesn't collapse + (`lcm/src/styles.css`); any `grid-cols-[fixed fixed …]` that doesn't collapse below the holographic `720px` breakpoint; wide tables in LCM (must use their `overflow-x:auto` containers). 2. **`vh` vs `dvh` on mobile:** the shell uses `min-height:100vh` diff --git a/docs/dashboard-port-handoff.md b/docs/dashboard-port-handoff.md index 1f255c6d..f2e3b7ba 100644 --- a/docs/dashboard-port-handoff.md +++ b/docs/dashboard-port-handoff.md @@ -22,7 +22,7 @@ Phase 3 TODOs. | `dashboard/holographic/dist/style.css` | generated from `dashboard/holographic/src/styles.css` | Phase 2 replaced the frozen Tailwind artifact with a hand-rolled token stylesheet rebuilt by `dashboard/build.mjs` | | `dashboard/holographic/build.from-hermes.mjs` | same plugin's `build.mjs` | Reference only; depends on `hermes-agent/web/node_modules` + host theme | | `dashboard/holographic/manifest.json` | same plugin | Reference copy of the Hermes manifest | -| `dashboard/lcm/src/{index.js,style.css}` | `/home/zack/projects/lcm/dashboard/dist/` | The LCM repo ships no separate frontend source; its `dist/` is hand-written, unbundled, readable JS (React via SDK, no JSX) — treated as source | +| `dashboard/lcm/src/{entry.tsx,styles.css}` | `/home/zack/projects/lcm/dashboard/dist/` | The LCM port now lives as repo-native TSX + CSS source and builds to `dashboard/lcm/dist/{index.js,style.css}` via `dashboard/build.mjs` | | `dashboard/lcm/manifest.json` | `/home/zack/projects/lcm/dashboard/manifest.json` | Reference copy | | `dashboard/shell/` | new | Standalone host shell (see architecture) | | `dashboard/hermes-wrapper/` | new | Canonical source of the Hermes-side wrapper | @@ -49,8 +49,8 @@ that reuses it, never a fork. ``` ┌────────────────────────────────────────────┐ │ UI bundles (byte-identical in both hosts) │ - │ holographic/dist/index.js (esbuild IIFE) │ - │ lcm/dist/index.js (plain IIFE) │ + │ holographic/dist/index.js (Rspack IIFE) │ + │ lcm/dist/index.js (Rspack IIFE) │ └──────────────┬─────────────────────────────┘ register via window.__HERMES_PLUGINS__ / SDK ┌───────────────────┴────────────────────────┐ @@ -170,9 +170,10 @@ as fallbacks). Old backend was `$HERMES_HOME/lcm.db` ## What works (verified) -- `cd dashboard && npm install && npm run build` — builds shell (React 19 + - esbuild), **rebuilds the holographic bundle from src** (proves the source - port is buildable), copies LCM, assembles the hermes-wrapper dist. +- `cd dashboard && npm install && npm run build` — runs `dashboard/build.mjs`, + building the shell and plugin bundles with Rspack, compiling holographic's + Tailwind v4 stylesheet, writing `dashboard/lcm/dist/{index.js,style.css}`, + and assembling the hermes-wrapper dist. - `cargo check`, `cargo clippy --bin tracedecay` (repo denies `unwrap`/`expect`), `cargo test --lib dashboard` — clean. - `tracedecay dashboard --port 7341` against this repo's own index: verified From fc166a860e47cf59e4543b304289bc89dddca9de Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 02:52:12 +0200 Subject: [PATCH 11/35] chore(dashboard): drop redundant standalone typecheck script Type-checking is now integrated into the Rsbuild build/dev via @rsbuild/plugin-type-check (ts-checker-rspack-plugin), so the separate 'tsc --noEmit' npm script is redundant. The build is the source of truth for type errors. --- dashboard/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashboard/package.json b/dashboard/package.json index c4f4a2e9..3a2a43ce 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,8 +11,7 @@ "test:node": "node run-unit-tests.mjs", "test:dom": "vitest run", "smoke": "node smoke.mjs", - "smoke:mobile": "node smoke.mjs --profiles=iphone12,pixel5", - "typecheck": "tsc --noEmit" + "smoke:mobile": "node smoke.mjs --profiles=iphone12,pixel5" }, "dependencies": { "@observablehq/plot": "^0.6.17", From a01f26a8902454e749c51de0be3efb1872c7ea48 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 02:54:34 +0200 Subject: [PATCH 12/35] test: sync plugin docs and dashboard smoke markers --- codex-plugin/skills/project-status/SKILL.md | 15 ++++++++------- cursor-plugin/README.md | 5 +++++ cursor-plugin/skills/project-status/SKILL.md | 15 ++++++++------- tests/branch_db_safety_test.rs | 1 + tests/branch_drift_test.rs | 1 + tests/hermes_dashboard_test.rs | 3 ++- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/codex-plugin/skills/project-status/SKILL.md b/codex-plugin/skills/project-status/SKILL.md index 5c319e65..9d192a9f 100644 --- a/codex-plugin/skills/project-status/SKILL.md +++ b/codex-plugin/skills/project-status/SKILL.md @@ -11,16 +11,17 @@ Cheap, read-only surface for active project identity, resolved storage, the inde 1. **Active project → `tracedecay_active_project`** (no args): resolved project root, scope prefix, branch identity, and the resolved active project store backing this session. Use this before describing where data lives. 2. **Storage status → `tracedecay_storage_status`** (no args): resolved active project store health, graph DB path, writability, branch fallback warnings, and counts. Use this instead of probing `.tracedecay` or running direct SQLite checks. -3. **Index status → `tracedecay_status`** (no args): node/edge/file counts, DB size, active branch + any branch-fallback warning, tokens saved. Add `tracedecay_distribution` (`path?`, `summary?`) / `tracedecay_files` (`path?`, `pattern?`) for a kind/file breakdown. -4. **Config lookups → `tracedecay_config`** (`key` required, plus `path` for one file **or** `glob` for many): query TOML/JSON by dotted key (e.g. `key: "package.version"` on `Cargo.toml`, or `glob: "crates/*/Cargo.toml"`). Pure filesystem parse — works even before `tracedecay init`. -5. **Outstanding work → `tracedecay_todos`** (`kinds?`, `path?`, `limit?`): TODO/FIXME/XXX/HACK/WIP/NOTE/UNIMPLEMENTED markers with the enclosing symbol. -6. **Server triage → `tracedecay_runtime`** (no args): PID, resident/virtual memory, CPU%, threads, DB/WAL/SHM sizes — use when TraceDecay seems to hog CPU or RAM. -7. **User wants a visual → `tracedecay_dashboard`** (`action`: `start`|`stop`, optional `host`/`port`): starts the local dashboard server in the background and returns its URL (idempotent; `stop` shuts it down). Hand the URL to the user instead of describing charts. -8. **Memory subsystem health (optional) → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). +3. **Project registry → `tracedecay_project_list` / `tracedecay_project_search` / `tracedecay_project_context`**: list known projects, search by name/root, or load a registered project's resolved context when the user asks about another project or workspace. +4. **Index status → `tracedecay_status`** (no args): node/edge/file counts, DB size, active branch + any branch-fallback warning, tokens saved. Add `tracedecay_distribution` (`path?`, `summary?`) / `tracedecay_files` (`path?`, `pattern?`) for a kind/file breakdown. +5. **Config lookups → `tracedecay_config`** (`key` required, plus `path` for one file **or** `glob` for many): query TOML/JSON by dotted key (e.g. `key: "package.version"` on `Cargo.toml`, or `glob: "crates/*/Cargo.toml"`). Pure filesystem parse — works even before `tracedecay init`. +6. **Outstanding work → `tracedecay_todos`** (`kinds?`, `path?`, `limit?`): TODO/FIXME/XXX/HACK/WIP/NOTE/UNIMPLEMENTED markers with the enclosing symbol. +7. **Server triage → `tracedecay_runtime`** (no args): PID, resident/virtual memory, CPU%, threads, DB/WAL/SHM sizes — use when TraceDecay seems to hog CPU or RAM. +8. **User wants a visual → `tracedecay_dashboard`** (`action`: `start`|`stop`, optional `host`/`port`): starts the local dashboard server in the background and returns its URL (idempotent; `stop` shuts it down). Hand the URL to the user instead of describing charts. +9. **Memory subsystem health (optional) → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). ## Guardrails -- `tracedecay_active_project`, `tracedecay_storage_status`, `tracedecay_status`, `tracedecay_config`, `tracedecay_todos`, `tracedecay_runtime` are read-only. `tracedecay_dashboard` starts/stops a local server and `tracedecay_memory_status` repairs/normalizes memory state — use them only when the user wants the dashboard or memory counts. +- `tracedecay_active_project`, `tracedecay_storage_status`, `tracedecay_project_list`, `tracedecay_project_search`, `tracedecay_project_context`, `tracedecay_status`, `tracedecay_config`, `tracedecay_todos`, `tracedecay_runtime` are read-only. `tracedecay_dashboard` starts/stops a local server and `tracedecay_memory_status` repairs/normalizes memory state — use them only when the user wants the dashboard or memory counts. - For deeper structural/quality questions hand off to `tracedecay:architecture-overview` or `tracedecay:code-health-report`; for memory recall, `tracedecay:recalling-project-memory`; for memory curation/update/delete, `tracedecay:curating-project-memory`; for past-session recall, `tracedecay:recalling-session-context`. ## Output diff --git a/cursor-plugin/README.md b/cursor-plugin/README.md index 15871981..45e9eca6 100644 --- a/cursor-plugin/README.md +++ b/cursor-plugin/README.md @@ -48,6 +48,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` ```json { "mcpAllowlist": [ + "tracedecay:tracedecay_active_project", "tracedecay:tracedecay_affected", "tracedecay:tracedecay_body", "tracedecay:tracedecay_branch_diff", @@ -102,6 +103,9 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "tracedecay:tracedecay_port_order", "tracedecay:tracedecay_port_status", "tracedecay:tracedecay_pr_context", + "tracedecay:tracedecay_project_context", + "tracedecay:tracedecay_project_list", + "tracedecay:tracedecay_project_search", "tracedecay:tracedecay_rank", "tracedecay:tracedecay_read", "tracedecay:tracedecay_recursion", @@ -115,6 +119,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "tracedecay:tracedecay_similar", "tracedecay:tracedecay_simplify_scan", "tracedecay:tracedecay_status", + "tracedecay:tracedecay_storage_status", "tracedecay:tracedecay_test_map", "tracedecay:tracedecay_test_risk", "tracedecay:tracedecay_todos", diff --git a/cursor-plugin/skills/project-status/SKILL.md b/cursor-plugin/skills/project-status/SKILL.md index 5c319e65..9d192a9f 100644 --- a/cursor-plugin/skills/project-status/SKILL.md +++ b/cursor-plugin/skills/project-status/SKILL.md @@ -11,16 +11,17 @@ Cheap, read-only surface for active project identity, resolved storage, the inde 1. **Active project → `tracedecay_active_project`** (no args): resolved project root, scope prefix, branch identity, and the resolved active project store backing this session. Use this before describing where data lives. 2. **Storage status → `tracedecay_storage_status`** (no args): resolved active project store health, graph DB path, writability, branch fallback warnings, and counts. Use this instead of probing `.tracedecay` or running direct SQLite checks. -3. **Index status → `tracedecay_status`** (no args): node/edge/file counts, DB size, active branch + any branch-fallback warning, tokens saved. Add `tracedecay_distribution` (`path?`, `summary?`) / `tracedecay_files` (`path?`, `pattern?`) for a kind/file breakdown. -4. **Config lookups → `tracedecay_config`** (`key` required, plus `path` for one file **or** `glob` for many): query TOML/JSON by dotted key (e.g. `key: "package.version"` on `Cargo.toml`, or `glob: "crates/*/Cargo.toml"`). Pure filesystem parse — works even before `tracedecay init`. -5. **Outstanding work → `tracedecay_todos`** (`kinds?`, `path?`, `limit?`): TODO/FIXME/XXX/HACK/WIP/NOTE/UNIMPLEMENTED markers with the enclosing symbol. -6. **Server triage → `tracedecay_runtime`** (no args): PID, resident/virtual memory, CPU%, threads, DB/WAL/SHM sizes — use when TraceDecay seems to hog CPU or RAM. -7. **User wants a visual → `tracedecay_dashboard`** (`action`: `start`|`stop`, optional `host`/`port`): starts the local dashboard server in the background and returns its URL (idempotent; `stop` shuts it down). Hand the URL to the user instead of describing charts. -8. **Memory subsystem health (optional) → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). +3. **Project registry → `tracedecay_project_list` / `tracedecay_project_search` / `tracedecay_project_context`**: list known projects, search by name/root, or load a registered project's resolved context when the user asks about another project or workspace. +4. **Index status → `tracedecay_status`** (no args): node/edge/file counts, DB size, active branch + any branch-fallback warning, tokens saved. Add `tracedecay_distribution` (`path?`, `summary?`) / `tracedecay_files` (`path?`, `pattern?`) for a kind/file breakdown. +5. **Config lookups → `tracedecay_config`** (`key` required, plus `path` for one file **or** `glob` for many): query TOML/JSON by dotted key (e.g. `key: "package.version"` on `Cargo.toml`, or `glob: "crates/*/Cargo.toml"`). Pure filesystem parse — works even before `tracedecay init`. +6. **Outstanding work → `tracedecay_todos`** (`kinds?`, `path?`, `limit?`): TODO/FIXME/XXX/HACK/WIP/NOTE/UNIMPLEMENTED markers with the enclosing symbol. +7. **Server triage → `tracedecay_runtime`** (no args): PID, resident/virtual memory, CPU%, threads, DB/WAL/SHM sizes — use when TraceDecay seems to hog CPU or RAM. +8. **User wants a visual → `tracedecay_dashboard`** (`action`: `start`|`stop`, optional `host`/`port`): starts the local dashboard server in the background and returns its URL (idempotent; `stop` shuts it down). Hand the URL to the user instead of describing charts. +9. **Memory subsystem health (optional) → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). ## Guardrails -- `tracedecay_active_project`, `tracedecay_storage_status`, `tracedecay_status`, `tracedecay_config`, `tracedecay_todos`, `tracedecay_runtime` are read-only. `tracedecay_dashboard` starts/stops a local server and `tracedecay_memory_status` repairs/normalizes memory state — use them only when the user wants the dashboard or memory counts. +- `tracedecay_active_project`, `tracedecay_storage_status`, `tracedecay_project_list`, `tracedecay_project_search`, `tracedecay_project_context`, `tracedecay_status`, `tracedecay_config`, `tracedecay_todos`, `tracedecay_runtime` are read-only. `tracedecay_dashboard` starts/stops a local server and `tracedecay_memory_status` repairs/normalizes memory state — use them only when the user wants the dashboard or memory counts. - For deeper structural/quality questions hand off to `tracedecay:architecture-overview` or `tracedecay:code-health-report`; for memory recall, `tracedecay:recalling-project-memory`; for memory curation/update/delete, `tracedecay:curating-project-memory`; for past-session recall, `tracedecay:recalling-session-context`. ## Output diff --git a/tests/branch_db_safety_test.rs b/tests/branch_db_safety_test.rs index d2a06a65..f05a4295 100644 --- a/tests/branch_db_safety_test.rs +++ b/tests/branch_db_safety_test.rs @@ -10,6 +10,7 @@ use tracedecay::tracedecay::TraceDecay; fn git(project: &Path, args: &[&str]) { let output = Command::new("git") + .args(["-c", "core.hooksPath=.git/no-hooks"]) .args(args) .current_dir(project) .output() diff --git a/tests/branch_drift_test.rs b/tests/branch_drift_test.rs index b48db595..fffd11c5 100644 --- a/tests/branch_drift_test.rs +++ b/tests/branch_drift_test.rs @@ -12,6 +12,7 @@ use tracedecay::tracedecay::TraceDecay; fn git(project: &std::path::Path, args: &[&str]) { let status = Command::new("git") + .args(["-c", "core.hooksPath=.git/no-hooks"]) .args(args) .current_dir(project) .output() diff --git a/tests/hermes_dashboard_test.rs b/tests/hermes_dashboard_test.rs index bce02ecb..04219d4f 100644 --- a/tests/hermes_dashboard_test.rs +++ b/tests/hermes_dashboard_test.rs @@ -202,7 +202,8 @@ fn deployed_bundles_match_embedded_standalone_assets() { let entry = read(&dist.join("index.js")); let css = read(&dist.join("style.css")); - assert!(holographic.contains("tracedecay holographic-memory dashboard plugin")); + assert!(holographic.contains(r#"register("holographic""#)); + assert!(holographic.contains("/api/plugins/holographic")); assert!(entry.contains("\"tracedecay\"")); // Wrapper chrome first, then the child stylesheets concatenated. assert!(css.starts_with("/* Wrapper chrome")); From b81ce225c1c258a6bf0f4583e1248ab96d26468c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 03:21:37 +0200 Subject: [PATCH 13/35] test: isolate profile-shard fixtures across platforms --- src/config.rs | 6 ++++++ src/doctor.rs | 1 + tests/cli_non_interactive_test.rs | 1 + 3 files changed, 8 insertions(+) diff --git a/src/config.rs b/src/config.rs index 7479b8c6..00945614 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,9 @@ pub const CONFIG_FILENAME: &str = "config.json"; /// Name of the hidden directory used to store `TraceDecay` metadata. pub const TRACEDECAY_DIR: &str = ".tracedecay"; +/// Environment variable that pins the user-level `TraceDecay` data directory. +pub const USER_DATA_DIR_ENV: &str = "TRACEDECAY_DATA_DIR"; + /// Legacy (pre-rebrand) data directory name. Projects that already have a /// `.tokensave/` dir keep using it as-is — read and write, no auto-migration. pub const LEGACY_TOKENSAVE_DIR: &str = ".tokensave"; @@ -150,6 +153,9 @@ pub fn has_project_database(project_root: &Path) -> bool { /// legacy `~/.tokensave` (used as-is), defaulting to `~/.tracedecay` when /// neither exists yet. pub fn user_data_dir() -> Option { + if let Some(path) = std::env::var_os(USER_DATA_DIR_ENV).filter(|path| !path.is_empty()) { + return Some(PathBuf::from(path)); + } let home = dirs::home_dir()?; let primary = home.join(TRACEDECAY_DIR); if primary.is_dir() { diff --git a/src/doctor.rs b/src/doctor.rs index 9e197dab..1ace7c04 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -425,6 +425,7 @@ mod tests { let shard_root = profile_root.join("projects/proj_doctor"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&shard_root)?; + let profile_root = profile_root.canonicalize()?; std::fs::write(shard_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index a1f5730d..a9837eab 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -22,6 +22,7 @@ fn tracedecay_command(home: &std::path::Path, project: &std::path::Path) -> Comm .env("HOME", home) .env("USERPROFILE", home) .env("XDG_CONFIG_HOME", home.join(".config")) + .env("TRACEDECAY_DATA_DIR", home.join(".tracedecay")) .env("TRACEDECAY_GLOBAL_DB", home.join(".tracedecay/global.db")) .stdin(Stdio::null()) .stdout(Stdio::piped()) From e0a55b79499a644870c847578a0b499610a8ff4b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 03:39:53 +0200 Subject: [PATCH 14/35] test: canonicalize profile fixtures on macOS --- tests/cli_non_interactive_test.rs | 64 +++++++++++++++++++------------ 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index a9837eab..67138a3e 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -15,15 +15,29 @@ use tracedecay::storage::{ StoreManifest, STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, }; +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn profile_root(home: &Path) -> PathBuf { + canonical_temp_path(home).join(".tracedecay") +} + +fn profile_shard_root(home: &Path) -> PathBuf { + profile_root(home).join("projects/proj_cli") +} + fn tracedecay_command(home: &std::path::Path, project: &std::path::Path) -> Command { + let home = canonical_temp_path(home); + let profile_root = profile_root(&home); let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); command .current_dir(project) - .env("HOME", home) - .env("USERPROFILE", home) + .env("HOME", &home) + .env("USERPROFILE", &home) .env("XDG_CONFIG_HOME", home.join(".config")) - .env("TRACEDECAY_DATA_DIR", home.join(".tracedecay")) - .env("TRACEDECAY_GLOBAL_DB", home.join(".tracedecay/global.db")) + .env("TRACEDECAY_DATA_DIR", &profile_root) + .env("TRACEDECAY_GLOBAL_DB", profile_root.join("global.db")) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -38,7 +52,7 @@ fn tracedecay_command_with_stdin(home: &std::path::Path, project: &std::path::Pa fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Path) { let marker_dir = project.join(".tracedecay"); - let shard_root = home.join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home); std::fs::create_dir_all(&marker_dir).unwrap(); std::fs::create_dir_all(&shard_root).unwrap(); std::fs::write( @@ -270,7 +284,7 @@ async fn list_all_reports_profile_sharded_store_without_stale_label() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let db = GlobalDb::open_at(&home.path().join(".tracedecay/global.db")) + let db = GlobalDb::open_at(&profile_root(home.path()).join("global.db")) .await .unwrap(); db.upsert(project.path(), 42).await; @@ -301,8 +315,8 @@ async fn wipe_all_removes_profile_sharded_store_and_global_row() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); - let db_path = home.path().join(".tracedecay/global.db"); + let shard_root = profile_shard_root(home.path()); + let db_path = profile_root(home.path()).join("global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); db.upsert(project.path(), 42).await; drop(db); @@ -339,7 +353,7 @@ fn list_all_reports_orphan_manifest_reconstructable_store() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - std::fs::create_dir_all(home.path().join(".tracedecay")).unwrap(); + std::fs::create_dir_all(profile_root(home.path())).unwrap(); let mut command = tracedecay_command(home.path(), project.path()); command.args(["list", "--all"]); @@ -364,7 +378,7 @@ async fn list_all_uses_registry_profile_shard_when_enrollment_marker_missing() { let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); std::fs::remove_dir_all(project.path().join(".tracedecay")).unwrap(); - let db = GlobalDb::open_at(&home.path().join(".tracedecay/global.db")) + let db = GlobalDb::open_at(&profile_root(home.path()).join("global.db")) .await .unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; @@ -396,8 +410,8 @@ async fn wipe_all_removes_registry_backed_profile_shard_without_enrollment_marke let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); std::fs::remove_dir_all(project.path().join(".tracedecay")).unwrap(); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); - let db_path = home.path().join(".tracedecay/global.db"); + let shard_root = profile_shard_root(home.path()); + let db_path = profile_root(home.path()).join("global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; drop(db); @@ -429,7 +443,7 @@ fn branch_list_reads_profile_sharded_branch_meta() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home.path()); write_branch_meta(&shard_root, &[], false); let mut command = tracedecay_command(home.path(), project.path()); @@ -458,7 +472,7 @@ fn branch_add_writes_new_branch_db_into_profile_shard() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home.path()); write_branch_meta(&shard_root, &[], false); let mut command = tracedecay_command(home.path(), project.path()); @@ -488,7 +502,7 @@ fn branch_remove_deletes_branch_db_from_profile_shard() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home.path()); write_branch_meta( &shard_root, &[("feature/ui", "branches/feature_ui.db")], @@ -516,7 +530,7 @@ fn branch_removeall_deletes_profile_shard_branch_dbs() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home.path()); write_branch_meta( &shard_root, &[ @@ -548,7 +562,7 @@ fn branch_gc_deletes_stale_profile_shard_branch_dbs() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); + let shard_root = profile_shard_root(home.path()); write_branch_meta( &shard_root, &[("feature/stale", "branches/feature_stale.db")], @@ -576,8 +590,8 @@ fn migrate_verify_text_reports_actual_apply_supported_state() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let shard_root = home.path().join(".tracedecay/projects/proj_cli"); - let manifest_path = home.path().join("migration-manifest.json"); + let shard_root = profile_shard_root(home.path()); + let manifest_path = canonical_temp_path(home.path()).join("migration-manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_cli_verify"); let mut manifest = MigrationManifest::new( "mig_cli_verify", @@ -592,7 +606,7 @@ fn migrate_verify_text_reports_actual_apply_supported_state() { }, ); manifest.source.project_root = Some(project.path().to_path_buf()); - manifest.destination.profile_root = Some(home.path().join(".tracedecay")); + manifest.destination.profile_root = Some(profile_root(home.path())); manifest.destination.project_id = Some("proj_cli".to_string()); let mut graph_artifact = MigrationArtifact::new( @@ -641,7 +655,7 @@ fn migrate_verify_text_reports_actual_apply_supported_state() { fn migrate_plan_save_writes_manifest_and_prints_confirmation_token_noninteractively() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let profile_root = home.path().join(".tracedecay"); + let profile_root = profile_root(home.path()); let graph_db = project.path().join(".tracedecay/tracedecay.db"); write_sqlite_placeholder(&graph_db); @@ -687,7 +701,7 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); - let export_dir = home.path().join("exported-store"); + let export_dir = canonical_temp_path(home.path()).join("exported-store"); let mut command = tracedecay_command(home.path(), project.path()); command.args([ @@ -724,7 +738,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar let project = TempDir::new().unwrap(); let data_dir = project.path().join(".tracedecay"); let source_graph = data_dir.join("tracedecay.db"); - let profile_root = home.path().join(".tracedecay"); + let profile_root = profile_root(home.path()); let target_root = profile_root.join("projects/proj_cli"); std::fs::create_dir_all(&data_dir).unwrap(); std::fs::create_dir_all(&target_root).unwrap(); @@ -760,7 +774,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar ) .unwrap(); - let manifest_path = home.path().join("migration-manifest.json"); + let manifest_path = canonical_temp_path(home.path()).join("migration-manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_cli_cleanup"); let mut manifest = MigrationManifest::new( "mig_cli_cleanup", From a969a1f5d11723d9697da2ec8bbd8311039be5e7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 03:50:35 +0200 Subject: [PATCH 15/35] test: canonicalize cleanup project fixture --- tests/cli_non_interactive_test.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index 67138a3e..a4e12556 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -736,7 +736,8 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_marker() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let data_dir = project.path().join(".tracedecay"); + let project_root = canonical_temp_path(project.path()); + let data_dir = project_root.join(".tracedecay"); let source_graph = data_dir.join("tracedecay.db"); let profile_root = profile_root(home.path()); let target_root = profile_root.join("projects/proj_cli"); @@ -754,7 +755,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar project_id: Some("proj_cli".to_string()), store_kind: StoreKind::CodeProject, storage_mode: StorageMode::ProfileSharded, - project_root: project.path().to_path_buf(), + project_root: project_root.clone(), data_root: target_root.clone(), graph_db_relpath: "tracedecay.db".into(), sessions_db_relpath: "sessions.db".into(), @@ -766,7 +767,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar ) .unwrap(); write_enrollment_marker( - project.path(), + &project_root, &EnrollmentMarker { project_id: "proj_cli".to_string(), storage_mode: StorageMode::ProfileSharded, @@ -788,7 +789,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar global_db: None, }, ); - manifest.source.project_root = Some(project.path().to_path_buf()); + manifest.source.project_root = Some(project_root.clone()); manifest.source.data_dir = Some(data_dir.clone()); manifest.destination.profile_root = Some(profile_root); manifest.destination.project_id = Some("proj_cli".to_string()); @@ -808,7 +809,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar manifest.artifacts.push(store_manifest_artifact); save_manifest(&manifest).unwrap(); - let mut command = tracedecay_command(home.path(), project.path()); + let mut command = tracedecay_command(home.path(), &project_root); command.args([ "migrate", "cleanup-sources", @@ -830,7 +831,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar "source graph artifact should be removed" ); assert!( - read_enrollment_marker(project.path()).unwrap().is_some(), + read_enrollment_marker(&project_root).unwrap().is_some(), "cleanup must preserve profile-sharded enrollment marker" ); } From 77fadad5732684dec07673504ebfd234ef2c8a11 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 04:06:06 +0200 Subject: [PATCH 16/35] test: isolate hook branch fixtures in CI --- tests/hook_branch_routing_test.rs | 21 +++++++++++++++++---- tests/tracedecay_test.rs | 1 + 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/hook_branch_routing_test.rs b/tests/hook_branch_routing_test.rs index 6a8acfcc..a0d07d9d 100644 --- a/tests/hook_branch_routing_test.rs +++ b/tests/hook_branch_routing_test.rs @@ -1,10 +1,10 @@ use std::ffi::OsString; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; use tracedecay::branch_meta::{self, BranchMeta}; -use tracedecay::config::TraceDecayConfig; +use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::db::Database; use tracedecay::hooks::{ cursor_branch_switch_target, cursor_shell_command_targets_project, cursor_shell_sync_plan, @@ -17,17 +17,21 @@ static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()) struct HomeEnvGuard { previous_home: Option, previous_userprofile: Option, + previous_data_dir: Option, } impl HomeEnvGuard { fn set(home: &Path) -> Self { let previous_home = std::env::var_os("HOME"); let previous_userprofile = std::env::var_os("USERPROFILE"); + let previous_data_dir = std::env::var_os(USER_DATA_DIR_ENV); std::env::set_var("HOME", home); std::env::set_var("USERPROFILE", home); + std::env::set_var(USER_DATA_DIR_ENV, home.join(".tracedecay")); Self { previous_home, previous_userprofile, + previous_data_dir, } } } @@ -42,9 +46,17 @@ impl Drop for HomeEnvGuard { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } + match self.previous_data_dir.take() { + Some(value) => std::env::set_var(USER_DATA_DIR_ENV, value), + None => std::env::remove_var(USER_DATA_DIR_ENV), + } } } +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn git(project: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -146,9 +158,10 @@ fn ambiguous_state_changes_fall_back_to_current_branch_when_available() { async fn hook_branch_tracking_writes_profile_sharded_branch_db() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); - let home = dir.path().join("home"); + let temp_root = canonical_temp_path(dir.path()); + let home = temp_root.join("home"); let profile_root = home.join(".tracedecay"); - let project = dir.path().join("project"); + let project = temp_root.join("project"); let shard_root = profile_root.join("projects/proj_hook"); std::fs::create_dir_all(project.join("src")).unwrap(); std::fs::write(project.join("src/lib.rs"), "pub fn hook_marker() {}\n").unwrap(); diff --git a/tests/tracedecay_test.rs b/tests/tracedecay_test.rs index d79a33de..95968f14 100644 --- a/tests/tracedecay_test.rs +++ b/tests/tracedecay_test.rs @@ -43,6 +43,7 @@ pub fn helper() { foo(); } fn run_git(project: &std::path::Path, args: &[&str]) { let output = Command::new("git") + .args(["-c", "core.hooksPath=.git/no-hooks"]) .args(args) .current_dir(project) .output() From 8ddad42ae4b06b5f0bf5bd03fe4a9125bccba6a7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 04:19:12 +0200 Subject: [PATCH 17/35] test: canonicalize migration inventory fixtures --- tests/migrate_inventory_test.rs | 45 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/tests/migrate_inventory_test.rs b/tests/migrate_inventory_test.rs index 894ee069..29ee64f5 100644 --- a/tests/migrate_inventory_test.rs +++ b/tests/migrate_inventory_test.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; #[cfg(unix)] @@ -42,6 +42,10 @@ fn with_env_vars(vars: &[(&str, Option<&Path>)], f: impl FnOnce() -> T) -> T } } +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn block_on_inventory( options: MigrationInventoryOptions, ) -> tracedecay::errors::Result { @@ -129,7 +133,8 @@ fn manifest_save_generates_token_and_records_protocol_context() { #[test] fn manifest_atomic_save_roundtrips_and_cleans_protocol_files() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("migration-manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("migration-manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let manifest = MigrationManifest::new( "mig_123", @@ -157,7 +162,8 @@ fn manifest_atomic_save_roundtrips_and_cleans_protocol_files() { #[test] fn manifest_save_requires_confirmation_token() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let manifest = MigrationManifest::new( "mig_123", @@ -300,9 +306,10 @@ async fn inventory_records_project_store_sidecar_artifacts() { #[tokio::test] async fn inventory_reports_global_db_metadata() { let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("global.db"); + let root = canonical_temp_path(dir.path()); + let db_path = root.join("global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); - let project = dir.path().join("registered"); + let project = root.join("registered"); fs::create_dir_all(&project).unwrap(); db.upsert(&project, 42).await; assert!(db.ensure_token_count_cache().await); @@ -330,8 +337,9 @@ async fn inventory_reports_global_db_metadata() { #[tokio::test] async fn inventory_discovers_registered_project_outside_scan_roots() { let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("global.db"); - let registered = dir.path().join("registered"); + let root = canonical_temp_path(dir.path()); + let db_path = root.join("global.db"); + let registered = root.join("registered"); fs::create_dir_all(®istered).unwrap(); make_project_store(®istered); let db = GlobalDb::open_at(&db_path).await.unwrap(); @@ -357,10 +365,11 @@ async fn inventory_discovers_registered_project_outside_scan_roots() { #[test] fn explicit_roots_do_not_inventory_unrelated_registered_projects_by_default() { let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("global.db"); - let scan_root = dir.path().join("scan-root"); + let root = canonical_temp_path(dir.path()); + let db_path = root.join("global.db"); + let scan_root = root.join("scan-root"); let discovered = scan_root.join("discovered"); - let unrelated = dir.path().join("unrelated-registered"); + let unrelated = root.join("unrelated-registered"); fs::create_dir_all(&discovered).unwrap(); fs::create_dir_all(&unrelated).unwrap(); make_project_store(&discovered); @@ -371,7 +380,7 @@ fn explicit_roots_do_not_inventory_unrelated_registered_projects_by_default() { db.upsert(&unrelated, 99).await; }); - let report = with_env_vars(&[("HERMES_HOME", None), ("HOME", Some(dir.path()))], || { + let report = with_env_vars(&[("HERMES_HOME", None), ("HOME", Some(&root))], || { block_on_inventory(MigrationInventoryOptions { roots: vec![scan_root], global_db_path: Some(db_path), @@ -405,10 +414,11 @@ fn explicit_roots_do_not_inventory_unrelated_registered_projects_by_default() { #[test] fn explicit_roots_can_include_all_registered_projects_when_requested() { let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("global.db"); - let scan_root = dir.path().join("scan-root"); + let root = canonical_temp_path(dir.path()); + let db_path = root.join("global.db"); + let scan_root = root.join("scan-root"); let discovered = scan_root.join("discovered"); - let unrelated = dir.path().join("unrelated-registered"); + let unrelated = root.join("unrelated-registered"); fs::create_dir_all(&discovered).unwrap(); fs::create_dir_all(&unrelated).unwrap(); make_project_store(&discovered); @@ -419,7 +429,7 @@ fn explicit_roots_can_include_all_registered_projects_when_requested() { db.upsert(&unrelated, 99).await; }); - let report = with_env_vars(&[("HERMES_HOME", None), ("HOME", Some(dir.path()))], || { + let report = with_env_vars(&[("HERMES_HOME", None), ("HOME", Some(&root))], || { block_on_inventory(MigrationInventoryOptions { roots: vec![scan_root], global_db_path: Some(db_path), @@ -444,8 +454,9 @@ fn explicit_roots_can_include_all_registered_projects_when_requested() { #[tokio::test] async fn inventory_reports_registered_project_with_missing_local_store() { let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("global.db"); - let registered = dir.path().join("registered_missing"); + let root = canonical_temp_path(dir.path()); + let db_path = root.join("global.db"); + let registered = root.join("registered_missing"); fs::create_dir_all(®istered).unwrap(); let db = GlobalDb::open_at(&db_path).await.unwrap(); db.upsert(®istered, 42).await; From d49a296a4b41d2e1bd8d295cad35d4ddd44b5ac7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 04:30:50 +0200 Subject: [PATCH 18/35] test: canonicalize migration manifest fixtures --- tests/migration_manifest_test.rs | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/tests/migration_manifest_test.rs b/tests/migration_manifest_test.rs index 5e4b59c7..29689556 100644 --- a/tests/migration_manifest_test.rs +++ b/tests/migration_manifest_test.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(unix)] @@ -41,6 +41,10 @@ fn manifest_for(protocol: MigrationProtocol, migration_id: &str) -> MigrationMan ) } +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + #[cfg(unix)] #[test] fn save_manifest_rejects_symlinked_parent_components() { @@ -81,7 +85,8 @@ fn save_manifest_rejects_unsafe_migration_ids_before_deriving_temp_paths() { #[test] fn save_manifest_keeps_existing_manifest_and_removes_lock_when_tmp_write_fails() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("migration-manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("migration-manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let old = manifest_for(protocol.clone(), "mig_123"); save_manifest(&old).unwrap(); @@ -99,7 +104,8 @@ fn save_manifest_keeps_existing_manifest_and_removes_lock_when_tmp_write_fails() #[test] fn save_manifest_replaces_existing_manifest_via_tmp_rename() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("migration-manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("migration-manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let old = manifest_for(protocol.clone(), "mig_123"); save_manifest(&old).unwrap(); @@ -181,7 +187,8 @@ fn migration_artifacts_follow_apply_state_order() { #[test] fn manifest_persistence_roundtrips_through_atomic_paths() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); let inventory = MigrationInventory { stores: Vec::new(), skipped: Vec::new(), @@ -437,13 +444,14 @@ fn verify_manifest_validates_profile_store_manifest_registry_records() { #[test] fn apply_migration_manifest_stops_at_verified_before_cutover() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); - let project = dir.path().join("repo"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); + let project = root.join("repo"); let data_dir = project.join(".tracedecay"); let graph_db = data_dir.join("tracedecay.db"); let sessions_db = data_dir.join("sessions.db"); let branch_meta = data_dir.join("branch-meta.json"); - let profile_root = dir.path().join("profile"); + let profile_root = root.join("profile"); fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write(&sessions_db, b"sessions").unwrap(); @@ -508,11 +516,12 @@ fn apply_migration_manifest_stops_at_verified_before_cutover() { #[test] fn finalize_migration_apply_marks_cutover_complete() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); - let project = dir.path().join("repo"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); + let project = root.join("repo"); let data_dir = project.join(".tracedecay"); let graph_db = data_dir.join("tracedecay.db"); - let profile_root = dir.path().join("profile"); + let profile_root = root.join("profile"); fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write( @@ -647,14 +656,15 @@ fn rollback_rejects_cutover_incomplete_state() { #[test] fn migrate_apply_copies_single_store_and_cuts_over_profile_shard() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); - let project = dir.path().join("repo"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); + let project = root.join("repo"); let data_dir = project.join(".tracedecay"); let graph_db = data_dir.join("tracedecay.db"); let sessions_db = data_dir.join("sessions.db"); let branch_meta = data_dir.join("branch-meta.json"); - let profile_root = dir.path().join("profile"); - let global_db_path = dir.path().join("global/global.db"); + let profile_root = root.join("profile"); + let global_db_path = root.join("global/global.db"); fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write(&sessions_db, b"sessions").unwrap(); @@ -781,7 +791,8 @@ fn migrate_apply_copies_single_store_and_cuts_over_profile_shard() { #[test] fn migrate_rollback_fails_closed_when_targets_diverged_after_apply() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let mut manifest = MigrationManifest::new( "mig_123", @@ -795,12 +806,12 @@ fn migrate_rollback_fails_closed_when_targets_diverged_after_apply() { global_db: None, }, ); - let target = dir.path().join("profile/projects/proj_123/tracedecay.db"); + let target = root.join("profile/projects/proj_123/tracedecay.db"); fs::create_dir_all(target.parent().unwrap()).unwrap(); fs::write(&target, b"changed").unwrap(); manifest.artifacts.push(MigrationArtifact { kind: "graph_db".to_string(), - source_path: dir.path().join("repo/.tracedecay/tracedecay.db"), + source_path: root.join("repo/.tracedecay/tracedecay.db"), target_path: Some(target), state: ArtifactState::Applied, }); @@ -877,7 +888,8 @@ fn migrate_reconstruct_reports_registry_plans_without_applying_them() { #[test] fn migrate_rollback_remains_fail_closed_for_valid_manifest() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); let protocol = MigrationProtocol::for_manifest(&manifest_path, "mig_123"); let manifest = MigrationManifest::new( "mig_123", From 82fd695dcc40edc7bb15d26d9a5e33bffff01f0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 04:43:24 +0200 Subject: [PATCH 19/35] test: canonicalize profile storage fixtures --- tests/profile_storage_migration_test.rs | 37 +++++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/tests/profile_storage_migration_test.rs b/tests/profile_storage_migration_test.rs index 8b1ad90e..753a675a 100644 --- a/tests/profile_storage_migration_test.rs +++ b/tests/profile_storage_migration_test.rs @@ -1,10 +1,10 @@ use std::ffi::OsString; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use tempfile::TempDir; use tracedecay::branch_meta::{self, BranchMeta}; -use tracedecay::config::TraceDecayConfig; +use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::db::Database; use tracedecay::global_db::{GlobalDb, GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; use tracedecay::migrate::inventory::{ @@ -31,17 +31,21 @@ static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()) struct HomeEnvGuard { previous_home: Option, previous_userprofile: Option, + previous_data_dir: Option, } impl HomeEnvGuard { fn set(home: &Path) -> Self { let previous_home = std::env::var_os("HOME"); let previous_userprofile = std::env::var_os("USERPROFILE"); + let previous_data_dir = std::env::var_os(USER_DATA_DIR_ENV); std::env::set_var("HOME", home); std::env::set_var("USERPROFILE", home); + std::env::set_var(USER_DATA_DIR_ENV, home.join(".tracedecay")); Self { previous_home, previous_userprofile, + previous_data_dir, } } } @@ -56,9 +60,17 @@ impl Drop for HomeEnvGuard { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } + match self.previous_data_dir.take() { + Some(value) => std::env::set_var(USER_DATA_DIR_ENV, value), + None => std::env::remove_var(USER_DATA_DIR_ENV), + } } } +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + async fn table_exists(db_path: &std::path::Path, table: &str) -> bool { let db = libsql::Builder::new_local(db_path).build().await.unwrap(); let conn = db.connect().unwrap(); @@ -287,11 +299,12 @@ async fn delete_project_uses_same_canonical_key_as_upsert() { #[tokio::test] async fn staged_migration_resumes_cutover_after_registry_and_marker() { let dir = TempDir::new().unwrap(); - let manifest_path = dir.path().join("manifest.json"); - let project = dir.path().join("repo"); + let root = canonical_temp_path(dir.path()); + let manifest_path = root.join("manifest.json"); + let project = root.join("repo"); let data_dir = project.join(".tracedecay"); let graph_db = data_dir.join("tracedecay.db"); - let profile_root = dir.path().join("profile"); + let profile_root = root.join("profile"); fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write( @@ -338,9 +351,7 @@ async fn staged_migration_resumes_cutover_after_registry_and_marker() { assert!(!staged.apply_supported); assert!(read_enrollment_marker(&project).unwrap().is_none()); - let db = GlobalDb::open_at(&dir.path().join("global.db")) - .await - .unwrap(); + let db = GlobalDb::open_at(&root.join("global.db")).await.unwrap(); apply_registry_reconstruction_report(&db, &staged.registry_reconstruction) .await .unwrap(); @@ -428,9 +439,10 @@ async fn cursor_session_db_uses_registry_profile_shard_without_marker() { async fn trace_decay_init_uses_profile_shard_when_enrolled() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); - let home = dir.path().join("home"); + let root = canonical_temp_path(dir.path()); + let home = root.join("home"); let profile_root = home.join(".tracedecay"); - let project = dir.path().join("repo"); + let project = root.join("repo"); let shard_root = profile_root.join("projects/proj_init"); fs::create_dir_all(&project).unwrap(); let _home_guard = HomeEnvGuard::set(&home); @@ -459,9 +471,10 @@ async fn trace_decay_init_uses_profile_shard_when_enrolled() { async fn trace_decay_open_branch_uses_profile_shard_branch_db() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); - let home = dir.path().join("home"); + let root = canonical_temp_path(dir.path()); + let home = root.join("home"); let profile_root = home.join(".tracedecay"); - let project = dir.path().join("repo"); + let project = root.join("repo"); let shard_root = profile_root.join("projects/proj_branch"); let branch_db = shard_root.join("branches/feature_profile.db"); fs::create_dir_all(branch_db.parent().unwrap()).unwrap(); From f8d30612b5dad647f94882f04dd40665b2757e17 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 04:56:11 +0200 Subject: [PATCH 20/35] test: canonicalize storage resolver fixtures --- tests/storage_resolver_test.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/storage_resolver_test.rs b/tests/storage_resolver_test.rs index e8f3414b..ecee9e90 100644 --- a/tests/storage_resolver_test.rs +++ b/tests/storage_resolver_test.rs @@ -1,6 +1,6 @@ use std::ffi::OsString; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -55,6 +55,10 @@ fn write_enrollment(root: &Path) { .unwrap(); } +fn canonical_temp_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + #[test] fn enrollment_marker_is_discovered_without_graph_db() { let dir = TempDir::new().unwrap(); @@ -217,8 +221,9 @@ fn profile_sharded_layout_maps_marker_to_profile_store_paths() { #[test] fn store_manifest_roundtrips_from_profile_sharded_layout() { let dir = TempDir::new().unwrap(); - let project = dir.path().join("repo"); - let profile = dir.path().join("profile"); + let temp_root = canonical_temp_path(dir.path()); + let project = temp_root.join("repo"); + let profile = temp_root.join("profile"); fs::create_dir_all(&project).unwrap(); write_enrollment(&project); let marker = read_enrollment_marker(&project).unwrap().unwrap(); @@ -351,7 +356,7 @@ fn active_project_context_keeps_layout_and_scope_identity() { #[test] fn project_path_accepts_contained_relative_and_absolute_paths() { let dir = TempDir::new().unwrap(); - let root = dir.path().join("repo"); + let root = canonical_temp_path(dir.path()).join("repo"); let file = root.join("src/lib.rs"); fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(&file, "pub fn lib() {}").unwrap(); @@ -390,7 +395,7 @@ fn project_path_rejects_parent_absolute_nul_non_normal_and_symlink_escapes() { #[test] fn store_artifact_path_accepts_only_normalized_relative_paths() { let dir = TempDir::new().unwrap(); - let store_root = dir.path().join("store"); + let store_root = canonical_temp_path(dir.path()).join("store"); fs::create_dir_all(&store_root).unwrap(); let artifact = @@ -433,7 +438,7 @@ fn store_artifact_path_rejects_symlinked_relative_components() { #[test] fn private_store_io_creates_private_dirs_and_files() { let dir = TempDir::new().unwrap(); - let private_dir = dir.path().join("private"); + let private_dir = canonical_temp_path(dir.path()).join("private"); let private_file = private_dir.join("config.json"); PrivateStoreIo::create_dir_all(&private_dir).unwrap(); From c5e88f49e5353d0a529565e1347524d0fd839c72 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 05:17:40 +0200 Subject: [PATCH 21/35] ci: avoid restoring test target cache --- .github/workflows/ci.yml | 3 ++- src/doctor.rs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0429d781..dd0e59ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,8 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - prefix-key: v1-rust-test + prefix-key: v2-rust-test + cache-targets: false - name: Run tests run: cargo test --workspace diff --git a/src/doctor.rs b/src/doctor.rs index 1ace7c04..b55b3a35 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -426,6 +426,7 @@ mod tests { std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&shard_root)?; let profile_root = profile_root.canonicalize()?; + let project_root = project_root.canonicalize()?; std::fs::write(shard_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await @@ -467,6 +468,7 @@ mod tests { let outside_root = dir.path().join("outside"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&outside_root)?; + let project_root = project_root.canonicalize()?; std::fs::write(outside_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await From ed60ffbf7122c21cda6b65f370688989096cad67 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 05:41:59 +0200 Subject: [PATCH 22/35] test: align doctor shard fixture paths --- src/doctor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/doctor.rs b/src/doctor.rs index b55b3a35..1953f3ec 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -427,6 +427,7 @@ mod tests { std::fs::create_dir_all(&shard_root)?; let profile_root = profile_root.canonicalize()?; let project_root = project_root.canonicalize()?; + let shard_root = profile_root.join("projects/proj_doctor"); std::fs::write(shard_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await From 701edac49d06c4858c089ff9f76ef0bfcb3435fc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 06:05:11 +0200 Subject: [PATCH 23/35] test: avoid canonical profile root in doctor fixture --- src/doctor.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index 1953f3ec..b7b1f2f4 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -422,12 +422,10 @@ mod tests { let dir = tempfile::TempDir::new()?; let profile_root = dir.path().join("profile"); let project_root = dir.path().join("repo"); - let shard_root = profile_root.join("projects/proj_doctor"); + let shard_root = crate::storage::profile_sharded_data_root(&profile_root, "proj_doctor"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&shard_root)?; - let profile_root = profile_root.canonicalize()?; let project_root = project_root.canonicalize()?; - let shard_root = profile_root.join("projects/proj_doctor"); std::fs::write(shard_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await From bf3525e9c97a9c009657d120b3392f99d307594b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 06:27:47 +0200 Subject: [PATCH 24/35] test: avoid verbatim temp paths on Windows --- src/doctor.rs | 18 ++++++++++++++++-- tests/cli_non_interactive_test.rs | 9 ++++++++- tests/hook_branch_routing_test.rs | 9 ++++++++- tests/migrate_inventory_test.rs | 9 ++++++++- tests/migration_manifest_test.rs | 9 ++++++++- tests/profile_storage_migration_test.rs | 9 ++++++++- tests/storage_resolver_test.rs | 9 ++++++++- 7 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index b7b1f2f4..cdff7f8b 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -395,6 +395,17 @@ mod tests { use super::*; use crate::global_db::StoreInstanceUpsert; + fn canonical_temp_path(path: &Path) -> PathBuf { + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } + } + #[test] fn format_bytes_boundaries() { assert_eq!(format_bytes(0), "0 B"); @@ -425,7 +436,10 @@ mod tests { let shard_root = crate::storage::profile_sharded_data_root(&profile_root, "proj_doctor"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&shard_root)?; - let project_root = project_root.canonicalize()?; + let profile_root = canonical_temp_path(&profile_root); + let project_root = canonical_temp_path(&project_root); + let shard_root = crate::storage::profile_sharded_data_root(&profile_root, "proj_doctor"); + std::fs::create_dir_all(&shard_root)?; std::fs::write(shard_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await @@ -467,7 +481,7 @@ mod tests { let outside_root = dir.path().join("outside"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&outside_root)?; - let project_root = project_root.canonicalize()?; + let project_root = canonical_temp_path(&project_root); std::fs::write(outside_root.join("tracedecay.db"), b"graph")?; let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) .await diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index a4e12556..dec73783 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -16,7 +16,14 @@ use tracedecay::storage::{ }; fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } fn profile_root(home: &Path) -> PathBuf { diff --git a/tests/hook_branch_routing_test.rs b/tests/hook_branch_routing_test.rs index a0d07d9d..b7fb5e36 100644 --- a/tests/hook_branch_routing_test.rs +++ b/tests/hook_branch_routing_test.rs @@ -54,7 +54,14 @@ impl Drop for HomeEnvGuard { } fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } fn git(project: &Path, args: &[&str]) { diff --git a/tests/migrate_inventory_test.rs b/tests/migrate_inventory_test.rs index 29ee64f5..319a1540 100644 --- a/tests/migrate_inventory_test.rs +++ b/tests/migrate_inventory_test.rs @@ -43,7 +43,14 @@ fn with_env_vars(vars: &[(&str, Option<&Path>)], f: impl FnOnce() -> T) -> T } fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } fn block_on_inventory( diff --git a/tests/migration_manifest_test.rs b/tests/migration_manifest_test.rs index 29689556..5b2edb7f 100644 --- a/tests/migration_manifest_test.rs +++ b/tests/migration_manifest_test.rs @@ -42,7 +42,14 @@ fn manifest_for(protocol: MigrationProtocol, migration_id: &str) -> MigrationMan } fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } #[cfg(unix)] diff --git a/tests/profile_storage_migration_test.rs b/tests/profile_storage_migration_test.rs index 753a675a..1c05fcd9 100644 --- a/tests/profile_storage_migration_test.rs +++ b/tests/profile_storage_migration_test.rs @@ -68,7 +68,14 @@ impl Drop for HomeEnvGuard { } fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } async fn table_exists(db_path: &std::path::Path, table: &str) -> bool { diff --git a/tests/storage_resolver_test.rs b/tests/storage_resolver_test.rs index ecee9e90..09c11311 100644 --- a/tests/storage_resolver_test.rs +++ b/tests/storage_resolver_test.rs @@ -56,7 +56,14 @@ fn write_enrollment(root: &Path) { } fn canonical_temp_path(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } } #[test] From 13b1abc8afe8036adbe48d74cc10f302ce9769cb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 06:47:18 +0200 Subject: [PATCH 25/35] fix: close global db before wipe cleanup --- src/commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands.rs b/src/commands.rs index 1730a1cc..f3e4c3f9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -739,6 +739,8 @@ pub(crate) async fn handle_wipe(all: bool) -> tracedecay::errors::Result<()> { } } + drop(gdb); + if all { if let Some(global_dir) = home_tracedecay.as_ref() { for ext in ["db", "db-wal", "db-shm"] { From 152012e86b01a5a91c4673ef56bd37e782a6140c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 07:07:33 +0200 Subject: [PATCH 26/35] ci: stabilize cross-platform PR checks --- .github/workflows/ci.yml | 26 +++++++++++++--------- tests/hermes_lcm_bridge_test.rs | 2 +- tests/mcp_handler_test.rs | 29 +++++++++++++++++++++---- tests/migrate_inventory_test.rs | 29 +++++++++++++++++++------ tests/profile_storage_migration_test.rs | 16 ++++++++++---- 5 files changed, 76 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd0e59ff..02446b05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" jobs: test: @@ -29,9 +34,9 @@ jobs: runner: '"windows-latest"' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" cache: npm @@ -58,9 +63,9 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" cache: npm @@ -84,7 +89,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -104,9 +109,9 @@ jobs: lcm/dist/index.js lcm/dist/style.css steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" cache: npm @@ -148,7 +153,7 @@ jobs: run: cargo test --test dashboard_api_test - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('dashboard/package-lock.json') }} @@ -205,9 +210,9 @@ jobs: HERMES_UPSTREAM_REPO: https://github.com/NousResearch/hermes-agent.git HERMES_UPSTREAM_REF: 9dd9ef0ec99a87f078f7272b4323df5440b4b3f9 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" cache: npm @@ -242,6 +247,7 @@ jobs: - uses: astral-sh/setup-uv@v8.2.0 with: enable-cache: true + cache-dependency-glob: ${{ runner.temp }}/hermes-upstream/uv.lock cache-suffix: hermes-${{ env.HERMES_UPSTREAM_REF }} - name: Set up stock Hermes environment diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index 50f38463..0a404b60 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -4712,7 +4712,7 @@ assert argv[idx + 1] == "/explicit/root", argv tools.call_tracedecay_tool("tracedecay_fact_store", {}) argv = captured[-1] idx = argv.index("--project") -assert argv[idx + 1] == os.path.expanduser("~/.hermes"), argv +assert os.path.samefile(argv[idx + 1], plugin._resolve_hermes_home()), argv assert argv[idx + 1] != "/pinned/project", argv # Native LCM calls carry hermes_profile storage args and do not need a code --project. diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index 735607bc..75bfa17c 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -61,23 +61,44 @@ impl Drop for GlobalDbEnvGuard { } struct HomeEnvGuard { - previous: Option, + previous_home: Option, + previous_userprofile: Option, + previous_data_dir: Option, } impl HomeEnvGuard { fn set(home: &Path) -> Self { - let previous = std::env::var_os("HOME"); + let previous_home = std::env::var_os("HOME"); + let previous_userprofile = std::env::var_os("USERPROFILE"); + let previous_data_dir = std::env::var_os(tracedecay::config::USER_DATA_DIR_ENV); std::env::set_var("HOME", home); - Self { previous } + std::env::set_var("USERPROFILE", home); + std::env::set_var( + tracedecay::config::USER_DATA_DIR_ENV, + home.join(tracedecay::config::TRACEDECAY_DIR), + ); + Self { + previous_home, + previous_userprofile, + previous_data_dir, + } } } impl Drop for HomeEnvGuard { fn drop(&mut self) { - match self.previous.take() { + match self.previous_home.take() { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } + match self.previous_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + match self.previous_data_dir.take() { + Some(value) => std::env::set_var(tracedecay::config::USER_DATA_DIR_ENV, value), + None => std::env::remove_var(tracedecay::config::USER_DATA_DIR_ENV), + } } } diff --git a/tests/migrate_inventory_test.rs b/tests/migrate_inventory_test.rs index 319a1540..24ef4846 100644 --- a/tests/migrate_inventory_test.rs +++ b/tests/migrate_inventory_test.rs @@ -53,6 +53,14 @@ fn canonical_temp_path(path: &Path) -> PathBuf { } } +fn inventory_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn same_path(left: &Path, right: &Path) -> bool { + inventory_path(left) == inventory_path(right) +} + fn block_on_inventory( options: MigrationInventoryOptions, ) -> tracedecay::errors::Result { @@ -335,7 +343,14 @@ async fn inventory_reports_global_db_metadata() { .expect("global DB metadata should be present"); assert_eq!(global.path, db_path); assert_eq!(global.project_count, 1); - assert_eq!(global.registered_project_paths, vec![project]); + assert_eq!( + global + .registered_project_paths + .iter() + .map(|path| inventory_path(path)) + .collect::>(), + vec![inventory_path(&project)] + ); assert!(global.token_cache_present); assert!(global.path_overridden); assert!(global.warnings.is_empty()); @@ -364,7 +379,7 @@ async fn inventory_discovers_registered_project_outside_scan_roots() { let store = report .stores .iter() - .find(|store| store.project_root == registered) + .find(|store| same_path(&store.project_root, ®istered)) .expect("registered project store should be inventoried"); assert_eq!(store.registry_status, RegistryStatus::Registered); } @@ -409,13 +424,13 @@ fn explicit_roots_do_not_inventory_unrelated_registered_projects_by_default() { let store = report .stores .iter() - .find(|store| store.project_root == discovered) + .find(|store| same_path(&store.project_root, &discovered)) .expect("discovered store should be inventoried"); assert_eq!(store.registry_status, RegistryStatus::Registered); assert!(!report .stores .iter() - .any(|store| store.project_root == unrelated)); + .any(|store| same_path(&store.project_root, &unrelated))); } #[test] @@ -449,12 +464,12 @@ fn explicit_roots_can_include_all_registered_projects_when_requested() { assert!(report .stores .iter() - .any(|store| store.project_root == discovered + .any(|store| same_path(&store.project_root, &discovered) && store.registry_status == RegistryStatus::Registered)); assert!(report .stores .iter() - .any(|store| store.project_root == unrelated + .any(|store| same_path(&store.project_root, &unrelated) && store.registry_status == RegistryStatus::Registered)); } @@ -480,7 +495,7 @@ async fn inventory_reports_registered_project_with_missing_local_store() { let store = report .stores .iter() - .find(|store| store.project_root == registered) + .find(|store| same_path(&store.project_root, ®istered)) .expect("registered missing project should still be inventoried"); assert_eq!(store.registry_status, RegistryStatus::Registered); assert!(store.statuses.contains(&StoreStatus::MissingDb)); diff --git a/tests/profile_storage_migration_test.rs b/tests/profile_storage_migration_test.rs index 1c05fcd9..2c9cc83b 100644 --- a/tests/profile_storage_migration_test.rs +++ b/tests/profile_storage_migration_test.rs @@ -78,6 +78,10 @@ fn canonical_temp_path(path: &Path) -> PathBuf { } } +fn portable_relpath(path: &str) -> String { + path.replace('\\', "/") +} + async fn table_exists(db_path: &std::path::Path, table: &str) -> bool { let db = libsql::Builder::new_local(db_path).build().await.unwrap(); let conn = db.connect().unwrap(); @@ -157,8 +161,8 @@ fn reconstructs_registry_records_from_profile_store_manifest() { assert_eq!(plan.store.storage_mode, "profile_sharded"); assert_eq!(plan.store.store_relpath, "projects/proj_123"); assert_eq!( - plan.store.manifest_relpath.as_deref(), - Some("projects/proj_123/store_manifest.json") + plan.store.manifest_relpath.as_deref().map(portable_relpath), + Some("projects/proj_123/store_manifest.json".to_string()) ); assert_eq!(plan.store.last_verified_at, Some(1_800_000_001)); assert!(plan @@ -403,8 +407,12 @@ async fn applies_registry_reconstruction_records_from_manifest() { assert_eq!(resolved.project.project_id, "proj_123"); assert_eq!(resolved.store.storage_mode, "profile_sharded"); assert_eq!( - resolved.store.manifest_relpath.as_deref(), - Some("projects/proj_123/store_manifest.json") + resolved + .store + .manifest_relpath + .as_deref() + .map(portable_relpath), + Some("projects/proj_123/store_manifest.json".to_string()) ); } From fd69b89c754ef5807cba7e2440a8a1223a55fab1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 09:08:53 +0200 Subject: [PATCH 27/35] test: normalize profile storage relpaths --- tests/profile_storage_migration_test.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/profile_storage_migration_test.rs b/tests/profile_storage_migration_test.rs index 2c9cc83b..ffa307f1 100644 --- a/tests/profile_storage_migration_test.rs +++ b/tests/profile_storage_migration_test.rs @@ -169,16 +169,16 @@ fn reconstructs_registry_records_from_profile_store_manifest() { .artifacts .iter() .any(|artifact| artifact.artifact_kind == "graph_db" - && artifact.relpath == "projects/proj_123/tracedecay.db")); + && portable_relpath(&artifact.relpath) == "projects/proj_123/tracedecay.db")); assert!(plan .artifacts .iter() .any(|artifact| artifact.artifact_kind == "store_manifest" - && artifact.relpath == "projects/proj_123/store_manifest.json")); + && portable_relpath(&artifact.relpath) == "projects/proj_123/store_manifest.json")); assert_eq!(plan.graph_scopes.len(), 1); assert_eq!(plan.graph_scopes[0].branch_name, "main"); assert_eq!( - plan.graph_scopes[0].db_relpath, + portable_relpath(&plan.graph_scopes[0].db_relpath), "projects/proj_123/tracedecay.db" ); } From 4c9659527dc0482dd11169de4cf149cc343903d4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 09:30:01 +0200 Subject: [PATCH 28/35] test: serialize global registry db tests --- tests/global_registry_test.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/global_registry_test.rs b/tests/global_registry_test.rs index af7e880c..b49e7fa9 100644 --- a/tests/global_registry_test.rs +++ b/tests/global_registry_test.rs @@ -1,8 +1,11 @@ use std::path::Path; use tempfile::TempDir; +use tokio::sync::Mutex; use tracedecay::global_db::{GlobalDb, GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; +static GLOBAL_REGISTRY_TEST_LOCK: Mutex<()> = Mutex::const_new(()); + async fn table_exists(db_path: &Path, table: &str) -> bool { let db = libsql::Builder::new_local(db_path).build().await.unwrap(); let conn = db.connect().unwrap(); @@ -31,6 +34,7 @@ async fn project_column_exists(db_path: &Path, column: &str) -> bool { #[tokio::test] async fn open_at_migrates_existing_project_rows_to_canonical_keys() { + let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; let dir = TempDir::new().unwrap(); let db_path = dir.path().join("global.db"); let project_root = dir.path().join("repo"); @@ -69,6 +73,7 @@ async fn open_at_migrates_existing_project_rows_to_canonical_keys() { #[tokio::test] async fn delete_project_paths_use_same_canonical_key_as_upsert() { + let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; let dir = TempDir::new().unwrap(); let db_path = dir.path().join("global.db"); let project_one = dir.path().join("repo-one"); @@ -92,6 +97,7 @@ async fn delete_project_paths_use_same_canonical_key_as_upsert() { #[tokio::test] async fn open_at_creates_registry_tables_and_round_trips_registry_records() { + let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; let dir = TempDir::new().unwrap(); let db_path = dir.path().join("global.db"); let project_root = dir.path().join("repo"); @@ -185,6 +191,7 @@ async fn open_at_creates_registry_tables_and_round_trips_registry_records() { #[tokio::test] async fn legacy_projects_tokens_saved_schema_and_queries_still_work() { + let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; let dir = TempDir::new().unwrap(); let db_path = dir.path().join("global.db"); let project_one = dir.path().join("repo-one"); From 1aff9bb3430a3fdf4fcf2b12ccbf56bce061d816 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 09:52:13 +0200 Subject: [PATCH 29/35] test: isolate storage resolver home env --- tests/storage_resolver_test.rs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/storage_resolver_test.rs b/tests/storage_resolver_test.rs index 09c11311..a456b902 100644 --- a/tests/storage_resolver_test.rs +++ b/tests/storage_resolver_test.rs @@ -7,8 +7,8 @@ use std::os::unix::fs::symlink; use tempfile::TempDir; use tokio::sync::Mutex; use tracedecay::branch_meta::{self, BranchMeta}; -use tracedecay::config::TraceDecayConfig; use tracedecay::config::{discover_project_root, get_config_path, load_config}; +use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::db::Database; use tracedecay::mcp::response_handles::{ retrieve_response_handle, store_response_handle, ResponseHandleLookup, @@ -26,23 +26,41 @@ use tracedecay::tracedecay::TraceDecay; static HOME_ENV_LOCK: Mutex<()> = Mutex::const_new(()); struct HomeGuard { - previous: Option, + previous_home: Option, + previous_userprofile: Option, + previous_data_dir: Option, } impl HomeGuard { fn set(home: &Path) -> Self { - let previous = std::env::var_os("HOME"); + let previous_home = std::env::var_os("HOME"); + let previous_userprofile = std::env::var_os("USERPROFILE"); + let previous_data_dir = std::env::var_os(USER_DATA_DIR_ENV); std::env::set_var("HOME", home); - Self { previous } + std::env::set_var("USERPROFILE", home); + std::env::set_var(USER_DATA_DIR_ENV, home.join(".tracedecay")); + Self { + previous_home, + previous_userprofile, + previous_data_dir, + } } } impl Drop for HomeGuard { fn drop(&mut self) { - match self.previous.take() { + match self.previous_home.take() { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } + match self.previous_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + match self.previous_data_dir.take() { + Some(value) => std::env::set_var(USER_DATA_DIR_ENV, value), + None => std::env::remove_var(USER_DATA_DIR_ENV), + } } } @@ -367,14 +385,15 @@ fn project_path_accepts_contained_relative_and_absolute_paths() { let file = root.join("src/lib.rs"); fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(&file, "pub fn lib() {}").unwrap(); + let expected_file = file.canonicalize().unwrap_or_else(|_| file.clone()); let relative = ProjectPath::resolve(&root, Path::new("src/lib.rs")).unwrap(); assert_eq!(relative.relative_path(), Path::new("src/lib.rs")); - assert_eq!(relative.absolute_path(), file); + assert_eq!(relative.absolute_path(), expected_file); let absolute = ProjectPath::resolve(&root, &file).unwrap(); assert_eq!(absolute.relative_path(), Path::new("src/lib.rs")); - assert_eq!(absolute.absolute_path(), file); + assert_eq!(absolute.absolute_path(), expected_file); } #[test] From abd3997c3636f5dff70801c88086b64db6ac1b3d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 19:11:36 +0200 Subject: [PATCH 30/35] fix(dashboard): make dev plugin imports analyzable --- dashboard/dev/main.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dashboard/dev/main.tsx b/dashboard/dev/main.tsx index 301f338d..d1ae8676 100644 --- a/dashboard/dev/main.tsx +++ b/dashboard/dev/main.tsx @@ -113,21 +113,21 @@ try { // --------------------------------------------------------------------------- const PLUGIN_ENTRIES = [ - { name: "holographic", spec: "../holographic/src/entry.tsx" }, - { name: "graph", spec: "../graph/src/entry.tsx" }, - { name: "savings", spec: "../savings/src/entry.tsx" }, - { name: "hermes-lcm", spec: "../lcm/src/entry.tsx" }, + { name: "holographic", load: () => import("../holographic/src/entry") }, + { name: "graph", load: () => import("../graph/src/entry") }, + { name: "savings", load: () => import("../savings/src/entry") }, + { name: "hermes-lcm", load: () => import("../lcm/src/entry") }, ]; async function loadPlugins() { await Promise.all( PLUGIN_ENTRIES.map(async (p) => { try { - await import(/* @vite-ignore */ p.spec); + await p.load(); return; } catch (err) { // Rsbuild leaves a stack in `err`; keep the console line scannable. - console.warn(`[tracedecay dev] failed to load "${p.spec}":`, err); + console.warn(`[tracedecay dev] failed to load "${p.name}":`, err); } console.warn(`[tracedecay dev] plugin "${p.name}" has no loadable entry — skipping.`); }), From cc35ace18b348c5c0df08f8c23b2cf022596dbd8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 20:12:20 +0200 Subject: [PATCH 31/35] fix: finish dashboard branch followups --- .../skills/atomic-code-edits/SKILL.md | 2 +- .../skills/auditing-code-safety/SKILL.md | 2 +- .../exploring-types-and-traits/SKILL.md | 2 +- .../skills/finding-impacted-areas/SKILL.md | 2 +- .../skills/recalling-session-context/SKILL.md | 2 +- .../skills/refactoring-safely/SKILL.md | 2 +- .../skills/tracing-functions/SKILL.md | 2 +- .../skills/atomic-code-edits/SKILL.md | 2 +- .../skills/auditing-code-safety/SKILL.md | 2 +- .../exploring-types-and-traits/SKILL.md | 2 +- .../skills/finding-impacted-areas/SKILL.md | 2 +- .../skills/recalling-session-context/SKILL.md | 2 +- .../skills/refactoring-safely/SKILL.md | 2 +- .../skills/tracing-functions/SKILL.md | 2 +- dashboard/graph/src/OverviewPanel.tsx | 5 +- dashboard/graph/src/styles.css | 74 ---- dashboard/graph/src/useGraphSearch.ts | 8 +- dashboard/holographic/src/SemanticMap.tsx | 6 +- dashboard/lib/primitives.tsx | 2 +- dashboard/savings/src/SavingsExplorer.tsx | 9 +- dashboard/savings/src/styles.css | 48 --- src/db/connection.rs | 45 ++- src/main.rs | 2 +- src/migrate/manifest.rs | 363 +++++++++++++++++- src/tracedecay.rs | 39 ++ tests/cli_non_interactive_test.rs | 62 +++ tests/db_test.rs | 35 ++ tests/migration_manifest_test.rs | 151 +++++++- tests/update_plugin_test.rs | 42 ++ 29 files changed, 770 insertions(+), 149 deletions(-) diff --git a/codex-plugin/skills/atomic-code-edits/SKILL.md b/codex-plugin/skills/atomic-code-edits/SKILL.md index b08f7807..4e7eba99 100644 --- a/codex-plugin/skills/atomic-code-edits/SKILL.md +++ b/codex-plugin/skills/atomic-code-edits/SKILL.md @@ -1,6 +1,6 @@ --- name: atomic-code-edits -description: Use when editing source with safe anchored primitives: unique string replacement, atomic multi-replace, anchored insert, whole-symbol rewrite, structural ast-grep rewrite, or mechanical edits that should re-index the graph. +description: "Use when editing source with safe anchored primitives: unique string replacement, atomic multi-replace, anchored insert, whole-symbol rewrite, structural ast-grep rewrite, or mechanical edits that should re-index the graph." --- # Atomic code edits diff --git a/codex-plugin/skills/auditing-code-safety/SKILL.md b/codex-plugin/skills/auditing-code-safety/SKILL.md index ee18dd8e..dccaca27 100644 --- a/codex-plugin/skills/auditing-code-safety/SKILL.md +++ b/codex-plugin/skills/auditing-code-safety/SKILL.md @@ -1,6 +1,6 @@ --- name: auditing-code-safety -description: Use when auditing ship-blocking code risk: panic/unwrap/expect/todo/unimplemented/unsafe sites, FIXME/HACK markers, dead code, unused imports, or high-risk untested symbols. +description: "Use when auditing ship-blocking code risk: panic/unwrap/expect/todo/unimplemented/unsafe sites, FIXME/HACK markers, dead code, unused imports, or high-risk untested symbols." --- # Auditing code safety diff --git a/codex-plugin/skills/exploring-types-and-traits/SKILL.md b/codex-plugin/skills/exploring-types-and-traits/SKILL.md index 4cdd79cf..aa76de78 100644 --- a/codex-plugin/skills/exploring-types-and-traits/SKILL.md +++ b/codex-plugin/skills/exploring-types-and-traits/SKILL.md @@ -1,6 +1,6 @@ --- name: exploring-types-and-traits -description: Use when answering type-level questions: trait implementors, impl blocks, type hierarchies, struct construction sites, field read/write sites, derive-generated methods, or method bodies across implementors. +description: "Use when answering type-level questions: trait implementors, impl blocks, type hierarchies, struct construction sites, field read/write sites, derive-generated methods, or method bodies across implementors." --- # Exploring types & traits diff --git a/codex-plugin/skills/finding-impacted-areas/SKILL.md b/codex-plugin/skills/finding-impacted-areas/SKILL.md index 05ff687d..a7b461c8 100644 --- a/codex-plugin/skills/finding-impacted-areas/SKILL.md +++ b/codex-plugin/skills/finding-impacted-areas/SKILL.md @@ -1,6 +1,6 @@ --- name: finding-impacted-areas -description: Use when estimating blast radius: what depends on a symbol or file, affected tests, risk of a change, impacted areas for a refactor, or what could break if a target changes. +description: "Use when estimating blast radius: what depends on a symbol or file, affected tests, risk of a change, impacted areas for a refactor, or what could break if a target changes." --- # Finding impacted areas diff --git a/codex-plugin/skills/recalling-session-context/SKILL.md b/codex-plugin/skills/recalling-session-context/SKILL.md index accc5990..28fb7497 100644 --- a/codex-plugin/skills/recalling-session-context/SKILL.md +++ b/codex-plugin/skills/recalling-session-context/SKILL.md @@ -1,6 +1,6 @@ --- name: recalling-session-context -description: Use when retrieving what happened in past agent sessions: full-text transcript recall, scoped/time-filtered grep, lossless session replay, summary-DAG drill-down, or compaction recovery. +description: "Use when retrieving what happened in past agent sessions: full-text transcript recall, scoped/time-filtered grep, lossless session replay, summary-DAG drill-down, or compaction recovery." --- # Recalling session context diff --git a/codex-plugin/skills/refactoring-safely/SKILL.md b/codex-plugin/skills/refactoring-safely/SKILL.md index e1caacb9..b7fa077c 100644 --- a/codex-plugin/skills/refactoring-safely/SKILL.md +++ b/codex-plugin/skills/refactoring-safely/SKILL.md @@ -1,6 +1,6 @@ --- name: refactoring-safely -description: Use when planning or executing mechanical refactors: renames, signature changes, field add/remove/rename, moving helpers, or any edit where missed call sites break the build. +description: "Use when planning or executing mechanical refactors: renames, signature changes, field add/remove/rename, moving helpers, or any edit where missed call sites break the build." --- # Refactoring safely diff --git a/codex-plugin/skills/tracing-functions/SKILL.md b/codex-plugin/skills/tracing-functions/SKILL.md index 89e55eb0..0b565ad4 100644 --- a/codex-plugin/skills/tracing-functions/SKILL.md +++ b/codex-plugin/skills/tracing-functions/SKILL.md @@ -1,6 +1,6 @@ --- name: tracing-functions -description: Use when tracing call relationships: who calls a function, what it calls, shortest call paths between symbols, references for rename prep, recursion, hubs, or dynamic dispatch. +description: "Use when tracing call relationships: who calls a function, what it calls, shortest call paths between symbols, references for rename prep, recursion, hubs, or dynamic dispatch." --- # Tracing functions diff --git a/cursor-plugin/skills/atomic-code-edits/SKILL.md b/cursor-plugin/skills/atomic-code-edits/SKILL.md index b08f7807..4e7eba99 100644 --- a/cursor-plugin/skills/atomic-code-edits/SKILL.md +++ b/cursor-plugin/skills/atomic-code-edits/SKILL.md @@ -1,6 +1,6 @@ --- name: atomic-code-edits -description: Use when editing source with safe anchored primitives: unique string replacement, atomic multi-replace, anchored insert, whole-symbol rewrite, structural ast-grep rewrite, or mechanical edits that should re-index the graph. +description: "Use when editing source with safe anchored primitives: unique string replacement, atomic multi-replace, anchored insert, whole-symbol rewrite, structural ast-grep rewrite, or mechanical edits that should re-index the graph." --- # Atomic code edits diff --git a/cursor-plugin/skills/auditing-code-safety/SKILL.md b/cursor-plugin/skills/auditing-code-safety/SKILL.md index ee18dd8e..dccaca27 100644 --- a/cursor-plugin/skills/auditing-code-safety/SKILL.md +++ b/cursor-plugin/skills/auditing-code-safety/SKILL.md @@ -1,6 +1,6 @@ --- name: auditing-code-safety -description: Use when auditing ship-blocking code risk: panic/unwrap/expect/todo/unimplemented/unsafe sites, FIXME/HACK markers, dead code, unused imports, or high-risk untested symbols. +description: "Use when auditing ship-blocking code risk: panic/unwrap/expect/todo/unimplemented/unsafe sites, FIXME/HACK markers, dead code, unused imports, or high-risk untested symbols." --- # Auditing code safety diff --git a/cursor-plugin/skills/exploring-types-and-traits/SKILL.md b/cursor-plugin/skills/exploring-types-and-traits/SKILL.md index 4cdd79cf..aa76de78 100644 --- a/cursor-plugin/skills/exploring-types-and-traits/SKILL.md +++ b/cursor-plugin/skills/exploring-types-and-traits/SKILL.md @@ -1,6 +1,6 @@ --- name: exploring-types-and-traits -description: Use when answering type-level questions: trait implementors, impl blocks, type hierarchies, struct construction sites, field read/write sites, derive-generated methods, or method bodies across implementors. +description: "Use when answering type-level questions: trait implementors, impl blocks, type hierarchies, struct construction sites, field read/write sites, derive-generated methods, or method bodies across implementors." --- # Exploring types & traits diff --git a/cursor-plugin/skills/finding-impacted-areas/SKILL.md b/cursor-plugin/skills/finding-impacted-areas/SKILL.md index 05ff687d..a7b461c8 100644 --- a/cursor-plugin/skills/finding-impacted-areas/SKILL.md +++ b/cursor-plugin/skills/finding-impacted-areas/SKILL.md @@ -1,6 +1,6 @@ --- name: finding-impacted-areas -description: Use when estimating blast radius: what depends on a symbol or file, affected tests, risk of a change, impacted areas for a refactor, or what could break if a target changes. +description: "Use when estimating blast radius: what depends on a symbol or file, affected tests, risk of a change, impacted areas for a refactor, or what could break if a target changes." --- # Finding impacted areas diff --git a/cursor-plugin/skills/recalling-session-context/SKILL.md b/cursor-plugin/skills/recalling-session-context/SKILL.md index accc5990..28fb7497 100644 --- a/cursor-plugin/skills/recalling-session-context/SKILL.md +++ b/cursor-plugin/skills/recalling-session-context/SKILL.md @@ -1,6 +1,6 @@ --- name: recalling-session-context -description: Use when retrieving what happened in past agent sessions: full-text transcript recall, scoped/time-filtered grep, lossless session replay, summary-DAG drill-down, or compaction recovery. +description: "Use when retrieving what happened in past agent sessions: full-text transcript recall, scoped/time-filtered grep, lossless session replay, summary-DAG drill-down, or compaction recovery." --- # Recalling session context diff --git a/cursor-plugin/skills/refactoring-safely/SKILL.md b/cursor-plugin/skills/refactoring-safely/SKILL.md index e1caacb9..b7fa077c 100644 --- a/cursor-plugin/skills/refactoring-safely/SKILL.md +++ b/cursor-plugin/skills/refactoring-safely/SKILL.md @@ -1,6 +1,6 @@ --- name: refactoring-safely -description: Use when planning or executing mechanical refactors: renames, signature changes, field add/remove/rename, moving helpers, or any edit where missed call sites break the build. +description: "Use when planning or executing mechanical refactors: renames, signature changes, field add/remove/rename, moving helpers, or any edit where missed call sites break the build." --- # Refactoring safely diff --git a/cursor-plugin/skills/tracing-functions/SKILL.md b/cursor-plugin/skills/tracing-functions/SKILL.md index 89e55eb0..0b565ad4 100644 --- a/cursor-plugin/skills/tracing-functions/SKILL.md +++ b/cursor-plugin/skills/tracing-functions/SKILL.md @@ -1,6 +1,6 @@ --- name: tracing-functions -description: Use when tracing call relationships: who calls a function, what it calls, shortest call paths between symbols, references for rename prep, recursion, hubs, or dynamic dispatch. +description: "Use when tracing call relationships: who calls a function, what it calls, shortest call paths between symbols, references for rename prep, recursion, hubs, or dynamic dispatch." --- # Tracing functions diff --git a/dashboard/graph/src/OverviewPanel.tsx b/dashboard/graph/src/OverviewPanel.tsx index 90fc712e..1fed4e48 100644 --- a/dashboard/graph/src/OverviewPanel.tsx +++ b/dashboard/graph/src/OverviewPanel.tsx @@ -99,14 +99,13 @@ export default function OverviewPanel({ keyName="label" rows={overview.top_connected.map((row) => ({ label: row.name, - name: row.name, color: colorForKind(row.kind), meta: row.kind, value: `${fmt(row.degree)} edges`, node: row, }))} rowKey={(row) => String(row.node.id)} - titleFor={(row) => `Open ${String(row.name)} in the canvas`} + titleFor={(row) => `Open ${String(row.label)} in the canvas`} onPick={(row) => onFocusSymbol(row.node)} /> @@ -121,10 +120,12 @@ export default function OverviewPanel({ const short = row.path.split("/").slice(-2).join("/"); return { label: short, + path: row.path, color: "color-mix(in srgb, var(--ts-cyan, #75f4d2) 60%, transparent)", value: `${fmt(row.node_count)} symbols`, }; })} + rowKey={(row) => String(row.path)} /> diff --git a/dashboard/graph/src/styles.css b/dashboard/graph/src/styles.css index f57d762c..a1a3a0d0 100644 --- a/dashboard/graph/src/styles.css +++ b/dashboard/graph/src/styles.css @@ -512,86 +512,12 @@ width: 100%; } -.tsg-chart-label { - fill: var(--ts-text-2); - font-family: var(--theme-font-mono); - font-size: 11px; -} - -.tsg-chart-value { - fill: var(--ts-text-3); - font-family: var(--theme-font-mono); - font-size: 10px; -} - -.tsg-chart-row-clickable { - cursor: pointer; -} - -.tsg-chart-row-clickable:hover .tsg-chart-label { - fill: var(--ts-text); -} - .tsg-chart-hint { margin: 0.5rem 0 0; color: var(--ts-text-3); font-size: 0.72rem; } -.tsg-hub-list { - display: grid; - gap: 0.4rem; - max-height: 21rem; - overflow: auto; -} - -.tsg-hub { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; - align-items: center; - gap: 0.55rem; - appearance: none; - text-align: left; - border: 1px solid var(--ts-line); - border-radius: 12px; - background: color-mix(in srgb, var(--ts-void) 26%, transparent); - color: var(--ts-text-2); - cursor: pointer; - padding: 0.42rem 0.6rem; -} - -.tsg-hub:hover { - border-color: color-mix(in srgb, var(--ts-cyan) 42%, transparent); - background: color-mix(in srgb, var(--ts-cyan) 8%, transparent); -} - -.tsg-hub-dot { - width: 0.55rem; - height: 0.55rem; - border-radius: 50%; -} - -.tsg-hub-name { - color: var(--ts-text); - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tsg-hub-meta { - color: var(--ts-text-3); - font-family: var(--theme-font-mono); - font-size: 0.66rem; -} - -.tsg-hub-degree { - color: var(--ts-cyan); - font-family: var(--theme-font-mono); - font-size: 0.68rem; - white-space: nowrap; -} - .tsg-edge-kind-strip { display: flex; flex-wrap: wrap; diff --git a/dashboard/graph/src/useGraphSearch.ts b/dashboard/graph/src/useGraphSearch.ts index 8498b885..9f840547 100644 --- a/dashboard/graph/src/useGraphSearch.ts +++ b/dashboard/graph/src/useGraphSearch.ts @@ -23,8 +23,14 @@ export function useGraphSearch({ const onQueryChange = useCallback((value: string) => { setQuery(value); - setSearchOpen(true); if (searchTimer.current) clearTimeout(searchTimer.current); + if (!value.trim()) { + searchSeq.next(); + setResults([]); + setSearchOpen(false); + return; + } + setSearchOpen(true); searchTimer.current = setTimeout(() => { const ticket = searchSeq.next(); search({ q: value, limit: 20 }) diff --git a/dashboard/holographic/src/SemanticMap.tsx b/dashboard/holographic/src/SemanticMap.tsx index 23fdc70d..66dc866a 100644 --- a/dashboard/holographic/src/SemanticMap.tsx +++ b/dashboard/holographic/src/SemanticMap.tsx @@ -305,6 +305,10 @@ export default function SemanticMap({ useEffect(() => { if (!focus || loading) return; if (appliedFocusTokenRef.current === focus.token) return; + if (hiddenCats.size > 0) { + setHiddenCats(new Set()); + return; + } const targets = focus.ids .map((id) => layout.byId.get(id)) .filter((p): p is PlacedPoint => !!p); @@ -316,7 +320,7 @@ export default function SemanticMap({ targets[0]; setSelected(pin.point); fitToPlaced(targets); - }, [focus, loading, layout, fitToPlaced]); + }, [focus, loading, hiddenCats, layout, fitToPlaced]); // True when at least one plotted fact is inside the viewport for the // committed transform; drives the empty-view recovery overlay. diff --git a/dashboard/lib/primitives.tsx b/dashboard/lib/primitives.tsx index a14ca2a3..21052f8e 100644 --- a/dashboard/lib/primitives.tsx +++ b/dashboard/lib/primitives.tsx @@ -155,7 +155,7 @@ export function BarList>({ emptyText, }: { rows: Array; - keyName: string; + keyName: keyof Row & string; onPick?: (row: Row) => void; rowKey?: (row: Row) => string; titleFor?: (row: Row) => string; diff --git a/dashboard/savings/src/SavingsExplorer.tsx b/dashboard/savings/src/SavingsExplorer.tsx index 7ee7e410..6c958505 100644 --- a/dashboard/savings/src/SavingsExplorer.tsx +++ b/dashboard/savings/src/SavingsExplorer.tsx @@ -61,10 +61,12 @@ export default function SavingsExplorer() { */ function fetchIntoState( fetch: () => Promise, - setState: (value: T) => void, + setState: (value: T | null) => void, + { clearBeforeLoad = false }: { clearBeforeLoad?: boolean } = {}, ): () => void { let active = true; setError(""); + if (clearBeforeLoad) setState(null); fetch().then( (data) => { if (active) setState(data); @@ -93,7 +95,7 @@ export default function SavingsExplorer() { useEffect(() => { if (view !== "savings") return; - return fetchIntoState(() => api.ledger({ range }), setLedger); + return fetchIntoState(() => api.ledger({ range }), setLedger, { clearBeforeLoad: true }); }, [view, range, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -101,12 +103,13 @@ export default function SavingsExplorer() { return fetchIntoState( () => api.sessions({ range, limit: PAGE_SIZE, offset: page * PAGE_SIZE }), setSessions, + { clearBeforeLoad: true }, ); }, [view, range, page, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (view !== "models") return; - return fetchIntoState(() => api.models({ range }), setModels); + return fetchIntoState(() => api.models({ range }), setModels, { clearBeforeLoad: true }); }, [view, range, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps const sessionStats = overview?.sessions; diff --git a/dashboard/savings/src/styles.css b/dashboard/savings/src/styles.css index beeb5365..8b08b352 100644 --- a/dashboard/savings/src/styles.css +++ b/dashboard/savings/src/styles.css @@ -112,33 +112,6 @@ gap: 0.85rem; } -.tss-stat { - border: 1px solid var(--ts-line); - border-radius: var(--ts-radius); - background: color-mix(in srgb, var(--ts-panel) 70%, transparent); - padding: 0.85rem 1rem; - display: grid; - gap: 0.25rem; -} - -.tss-stat-value { - color: var(--ts-text); - font-family: var(--theme-font-mono); - font-size: 1.45rem; - font-weight: 800; -} - -.tss-stat-label { - color: var(--ts-text-2); - font-size: 0.8rem; - font-weight: 600; -} - -.tss-stat-hint { - color: var(--ts-text-3); - font-size: 0.72rem; -} - /* ------------------------------------------------------------- charts */ .tss-chart { @@ -381,27 +354,6 @@ padding: 0.6rem 0; } -.tss-error { - border: 1px solid color-mix(in srgb, var(--ts-red) 50%, transparent); - border-radius: var(--ts-radius); - background: color-mix(in srgb, var(--ts-red) 8%, transparent); - color: var(--ts-text); - font-size: 0.82rem; - padding: 0.7rem 0.95rem; -} - -.tss-retry { - appearance: none; - background: transparent; - border: 1px solid var(--ts-line-strong); - border-radius: 8px; - color: var(--ts-text); - cursor: pointer; - font-size: 0.74rem; - margin-left: 0.5rem; - padding: 0.2rem 0.6rem; -} - .tss-pager { display: flex; align-items: center; diff --git a/src/db/connection.rs b/src/db/connection.rs index bb9ae5c8..ae1367cc 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -1,7 +1,7 @@ // Rust guideline compliant 2025-10-17 use std::path::Path; -use libsql::{Builder, Connection, Database as LibsqlDatabase}; +use libsql::{Builder, Connection, Database as LibsqlDatabase, OpenFlags}; use crate::errors::{Result, TraceDecayError}; @@ -95,6 +95,32 @@ impl Database { Ok((Self { conn, _db: db }, migrated)) } + /// Opens an existing database in read-only mode. + /// + /// This intentionally skips write-oriented PRAGMAs and migrations so + /// status/verification paths can inspect read-only SQLite files without + /// creating WAL files or attempting schema updates. + pub async fn open_read_only(db_path: &Path) -> Result<(Self, bool)> { + let db = Builder::new_local(db_path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to open database read-only: {e}"), + operation: "open_read_only".to_string(), + })?; + + let conn = db.connect().map_err(|e| TraceDecayError::Database { + message: format!("failed to connect to database read-only: {e}"), + operation: "open_read_only".to_string(), + })?; + + let file_size = std::fs::metadata(db_path).map_or(0, |m| m.len()); + Self::apply_read_only_pragmas(&conn, file_size).await?; + + Ok((Self { conn, _db: db }, false)) + } + /// Returns a reference to the underlying libsql connection. pub fn conn(&self) -> &Connection { &self.conn @@ -230,6 +256,23 @@ impl Database { Ok(()) } + async fn apply_read_only_pragmas(conn: &Connection, db_file_size: u64) -> Result<()> { + let (cache_kb, mmap) = adaptive_cache_sizes(db_file_size); + conn.execute_batch(&format!( + "PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 120000; + PRAGMA cache_size = -{cache_kb}; + PRAGMA temp_store = MEMORY; + PRAGMA mmap_size = {mmap};", + )) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to apply read-only pragmas: {e}"), + operation: "apply_read_only_pragmas".to_string(), + })?; + Ok(()) + } + /// Drops secondary indexes, disables fsync/FK, and clears FTS for fast /// bulk loading. Callers should insert data sorted by PK so the primary /// B-tree gets sequential appends. Call `end_bulk_load` afterwards to diff --git a/src/main.rs b/src/main.rs index afe6ee53..0990453b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -458,7 +458,7 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { } => { let project_path = tracedecay::config::resolve_path_with_discovery(path); let cg = if TraceDecay::is_initialized(&project_path) { - TraceDecay::open(&project_path).await? + TraceDecay::open_read_only(&project_path).await? } else if !io::stdin().is_terminal() { eprintln!( "No TraceDecay index found at '{}'. Non-interactive: skipping index creation (run `tracedecay init`).", diff --git a/src/migrate/manifest.rs b/src/migrate/manifest.rs index 24c74eb3..817a6ef4 100644 --- a/src/migrate/manifest.rs +++ b/src/migrate/manifest.rs @@ -1,9 +1,11 @@ use std::fmt; use std::fs; -use std::io; +use std::io::{self, Read}; use std::path::{Component, Path, PathBuf}; +use libsql::{Builder, Connection, OpenFlags, Value}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::migrate::inventory::{MigrationInventory, StoreStatus}; use crate::migrate::registry::{ @@ -145,6 +147,21 @@ pub struct MigrationCleanupSourcesReport { pub removed_artifacts: usize, } +#[derive(Debug, PartialEq, Eq)] +struct SqliteLogicalSummary { + user_version: i64, + schema: Vec, + tables: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct SqliteTableSummary { + name: String, + columns: Vec, + row_count: u64, + checksum: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum MigrationRollbackState { @@ -947,6 +964,9 @@ fn verify_artifact_contents(source: &Path, target: &Path) -> io::Result<()> { if !source_meta.is_file() || !target_meta.is_file() { return Err(invalid_manifest("migration artifact is not a regular file")); } + if is_sqlite_database_file(source)? && is_sqlite_database_file(target)? { + return verify_sqlite_artifact_contents(source, target); + } if fs::read(source)? != fs::read(target)? { return Err(invalid_manifest(&format!( "migration target '{}' differs from source '{}'", @@ -957,6 +977,347 @@ fn verify_artifact_contents(source: &Path, target: &Path) -> io::Result<()> { Ok(()) } +fn is_sqlite_database_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut header = [0_u8; 16]; + match file.read_exact(&mut header) { + Ok(()) => Ok(header == *b"SQLite format 3\0"), + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => Ok(false), + Err(err) => Err(err), + } +} + +fn verify_sqlite_artifact_contents(source: &Path, target: &Path) -> io::Result<()> { + let source = source.to_path_buf(); + let target = target.to_path_buf(); + let worker = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + runtime.block_on(async { + let source_summary = summarize_sqlite_database(&source).await?; + let target_summary = summarize_sqlite_database(&target).await?; + Ok::<_, io::Error>((source, target, source_summary, target_summary)) + }) + }); + let (source, target, source_summary, target_summary) = worker + .join() + .map_err(|_| invalid_manifest("SQLite logical verification thread panicked"))??; + if source_summary != target_summary { + return Err(invalid_manifest(&format!( + "SQLite logical verification failed for target '{}' against source '{}'", + target.display(), + source.display() + ))); + } + Ok(()) +} + +async fn summarize_sqlite_database(path: &Path) -> io::Result { + let db = Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + .map_err(|e| { + invalid_manifest(&format!( + "failed to open SQLite DB '{}': {e}", + path.display() + )) + })?; + let conn = db.connect().map_err(|e| { + invalid_manifest(&format!( + "failed to connect to SQLite DB '{}': {e}", + path.display() + )) + })?; + if !sqlite_quick_check(&conn, path).await? { + return Err(invalid_manifest(&format!( + "SQLite quick_check failed for '{}'", + path.display() + ))); + } + let user_version = sqlite_i64(&conn, "PRAGMA user_version", path).await?; + let schema = sqlite_schema_summary(&conn, path).await?; + let tables = sqlite_table_summaries(&conn, path).await?; + Ok(SqliteLogicalSummary { + user_version, + schema, + tables, + }) +} + +async fn sqlite_quick_check(conn: &Connection, path: &Path) -> io::Result { + let mut rows = conn.query("PRAGMA quick_check", ()).await.map_err(|e| { + invalid_manifest(&format!( + "failed to run quick_check on '{}': {e}", + path.display() + )) + })?; + let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read quick_check result for '{}': {e}", + path.display() + )) + })? + else { + return Ok(false); + }; + let result = row.get::(0).map_err(|e| { + invalid_manifest(&format!( + "failed to decode quick_check result for '{}': {e}", + path.display() + )) + })?; + Ok(result == "ok") +} + +async fn sqlite_i64(conn: &Connection, sql: &str, path: &Path) -> io::Result { + let mut rows = conn.query(sql, ()).await.map_err(|e| { + invalid_manifest(&format!( + "failed to query SQLite metadata for '{}': {e}", + path.display() + )) + })?; + let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite metadata for '{}': {e}", + path.display() + )) + })? + else { + return Err(invalid_manifest(&format!( + "SQLite metadata query returned no rows for '{}'", + path.display() + ))); + }; + row.get::(0).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite metadata for '{}': {e}", + path.display() + )) + }) +} + +async fn sqlite_schema_summary(conn: &Connection, path: &Path) -> io::Result> { + let mut rows = conn + .query( + "SELECT type, name, tbl_name, COALESCE(sql, '') + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name, tbl_name, sql", + (), + ) + .await + .map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite schema for '{}': {e}", + path.display() + )) + })?; + let mut schema = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite schema row for '{}': {e}", + path.display() + )) + })? { + let name = row.get::(1).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite schema name for '{}': {e}", + path.display() + )) + })?; + if is_fts_shadow_table(&name) { + continue; + } + let entry = format!( + "{}\x1f{}\x1f{}\x1f{}", + row.get::(0).map_err(|e| invalid_manifest(&format!( + "failed to decode SQLite schema type for '{}': {e}", + path.display() + )))?, + name, + row.get::(2).map_err(|e| invalid_manifest(&format!( + "failed to decode SQLite schema table for '{}': {e}", + path.display() + )))?, + row.get::(3).map_err(|e| invalid_manifest(&format!( + "failed to decode SQLite schema SQL for '{}': {e}", + path.display() + )))? + ); + schema.push(entry); + } + Ok(schema) +} + +async fn sqlite_table_summaries( + conn: &Connection, + path: &Path, +) -> io::Result> { + let mut rows = conn + .query( + "SELECT name, COALESCE(sql, '') + FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name", + (), + ) + .await + .map_err(|e| { + invalid_manifest(&format!( + "failed to list SQLite tables for '{}': {e}", + path.display() + )) + })?; + let mut tables = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite table row for '{}': {e}", + path.display() + )) + })? { + let name = row.get::(0).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite table name for '{}': {e}", + path.display() + )) + })?; + let sql = row.get::(1).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite table SQL for '{}': {e}", + path.display() + )) + })?; + if is_fts_shadow_table(&name) || is_virtual_table_sql(&sql) { + continue; + } + tables.push(sqlite_table_summary(conn, path, &name).await?); + } + Ok(tables) +} + +async fn sqlite_table_summary( + conn: &Connection, + path: &Path, + table: &str, +) -> io::Result { + let columns = sqlite_table_columns(conn, path, table).await?; + let mut checksum = Sha256::new(); + checksum.update(table.as_bytes()); + for column in &columns { + checksum.update(b"\x1f"); + checksum.update(column.as_bytes()); + } + let mut row_count = 0_u64; + if !columns.is_empty() { + let column_list = columns + .iter() + .map(|column| quote_sqlite_identifier(column)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {column_list} FROM {} ORDER BY {column_list}", + quote_sqlite_identifier(table) + ); + let mut rows = conn.query(&sql, ()).await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite table '{}' from '{}': {e}", + table, + path.display() + )) + })?; + while let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite row from table '{}' in '{}': {e}", + table, + path.display() + )) + })? { + row_count = row_count.saturating_add(1); + for index in 0..columns.len() { + let index = i32::try_from(index).map_err(|e| { + invalid_manifest(&format!( + "too many SQLite columns in table '{}' in '{}': {e}", + table, + path.display() + )) + })?; + let value = row.get::(index).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite row from table '{}' in '{}': {e}", + table, + path.display() + )) + })?; + checksum.update(sqlite_value_fingerprint(value).as_bytes()); + checksum.update(b"\x1e"); + } + } + } + Ok(SqliteTableSummary { + name: table.to_string(), + columns, + row_count, + checksum: hex::encode(checksum.finalize()), + }) +} + +async fn sqlite_table_columns( + conn: &Connection, + path: &Path, + table: &str, +) -> io::Result> { + let sql = format!("PRAGMA table_info({})", quote_sqlite_identifier(table)); + let mut rows = conn.query(&sql, ()).await.map_err(|e| { + invalid_manifest(&format!( + "failed to inspect SQLite table '{}' in '{}': {e}", + table, + path.display() + )) + })?; + let mut columns = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| { + invalid_manifest(&format!( + "failed to read SQLite table info for '{}' in '{}': {e}", + table, + path.display() + )) + })? { + columns.push(row.get::(1).map_err(|e| { + invalid_manifest(&format!( + "failed to decode SQLite column name for '{}' in '{}': {e}", + table, + path.display() + )) + })?); + } + Ok(columns) +} + +fn is_fts_shadow_table(name: &str) -> bool { + name.contains("_fts_") +} + +fn is_virtual_table_sql(sql: &str) -> bool { + sql.to_ascii_uppercase().contains("VIRTUAL TABLE") +} + +fn quote_sqlite_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +fn sqlite_value_fingerprint(value: Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Integer(value) => format!("integer:{value}"), + Value::Real(value) => format!("real:{:016x}", value.to_bits()), + Value::Text(value) => format!("text:{}:{value}", value.len()), + Value::Blob(value) => format!("blob:{}:{}", value.len(), hex::encode(value)), + } +} + fn verify_directory_contents(source: &Path, target: &Path) -> io::Result<()> { let mut source_entries = fs::read_dir(source)?.collect::>>()?; let mut target_entries = fs::read_dir(target)?.collect::>>()?; diff --git a/src/tracedecay.rs b/src/tracedecay.rs index f41503a6..e8131a46 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -481,6 +481,45 @@ impl TraceDecay { Ok(ts) } + /// Opens an existing project for read-only inspection. + /// + /// Unlike [`Self::open`], this does not run migrations, repair dirty + /// sentinels, clear markers, or rewrite corrupted DBs. It is intended for + /// status/verification commands that must be able to inspect read-only + /// stores without mutating them. + pub async fn open_read_only(project_root: &Path) -> Result { + let store_layout = storage::resolve_layout_for_current_profile(project_root)?; + let config = load_config_from_path(project_root, &store_layout.config_path)?; + let active_branch = branch::current_branch(project_root); + + let (db_path, serving_branch, fallback_warning) = Self::resolve_db_for_branch( + project_root, + &store_layout.data_root, + active_branch.as_deref(), + ); + + if !db_path.exists() { + return Err(TraceDecayError::Config { + message: format!( + "no TraceDecay database found at '{}'; run 'tracedecay init' first", + db_path.display() + ), + }); + } + + let (db, _) = Database::open_read_only(&db_path).await?; + Ok(Self { + db, + config, + project_root: project_root.to_path_buf(), + store_layout, + registry: LanguageRegistry::new(), + active_branch, + serving_branch, + fallback_warning, + }) + } + /// Resolves which DB file to open for a given branch. /// /// Returns `(db_path, serving_branch, fallback_warning)`. diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index dec73783..42d29d48 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -2,8 +2,11 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; use tracedecay::branch_meta::BranchMeta; +use tracedecay::db::Database; use tracedecay::global_db::{GlobalDb, StoreInstanceUpsert}; use tracedecay::migrate::inventory::MigrationInventory; use tracedecay::migrate::manifest::{ @@ -14,6 +17,7 @@ use tracedecay::storage::{ read_enrollment_marker, write_enrollment_marker, EnrollmentMarker, StorageMode, StoreKind, StoreManifest, STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, }; +use tracedecay::types::{Node, NodeKind, Visibility}; fn canonical_temp_path(path: &Path) -> PathBuf { #[cfg(windows)] @@ -105,6 +109,34 @@ fn write_sqlite_placeholder(path: &Path) { }); } +fn sample_node(id: &str, name: &str) -> Node { + Node { + id: id.to_string(), + kind: NodeKind::Function, + name: name.to_string(), + qualified_name: format!("crate::{name}"), + file_path: "src/lib.rs".to_string(), + start_line: 1, + attrs_start_line: 1, + end_line: 3, + start_column: 0, + end_column: 1, + signature: Some(format!("fn {name}()")), + docstring: None, + visibility: Visibility::Pub, + is_async: false, + branches: 0, + loops: 0, + returns: 0, + max_nesting: 0, + unsafe_blocks: 0, + unchecked_calls: 0, + assertions: 0, + updated_at: 1_800_000_000, + parent_id: None, + } +} + async fn register_profile_sharded_store( db: &GlobalDb, project_root: &std::path::Path, @@ -286,6 +318,36 @@ fn status_skips_create_prompt_when_stdin_not_a_terminal() { ); } +#[cfg(unix)] +#[tokio::test] +async fn status_json_reads_readonly_project_database() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let db_path = project.path().join(".tracedecay/tracedecay.db"); + let (db, _) = Database::initialize(&db_path).await.unwrap(); + db.insert_node(&sample_node("node-1", "process_data")) + .await + .unwrap(); + db.checkpoint().await.unwrap(); + db.close(); + let mut permissions = std::fs::metadata(&db_path).unwrap().permissions(); + permissions.set_mode(0o444); + std::fs::set_permissions(&db_path, permissions).unwrap(); + + let mut command = tracedecay_command(home.path(), project.path()); + command.args(["status", "--json"]); + let output = run_with_timeout(command, Duration::from_secs(30)); + + assert!( + output.status.success(), + "status --json should read readonly DB\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["node_count"], 1); +} + #[tokio::test] async fn list_all_reports_profile_sharded_store_without_stale_label() { let home = TempDir::new().unwrap(); diff --git a/tests/db_test.rs b/tests/db_test.rs index b28731c4..a50062a8 100644 --- a/tests/db_test.rs +++ b/tests/db_test.rs @@ -1,3 +1,5 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; use tracedecay::db::Database; use tracedecay::types::*; @@ -55,6 +57,39 @@ async fn test_initialize_creates_database() { ); } +#[cfg(unix)] +#[tokio::test] +async fn test_open_read_only_reads_existing_database_without_write_pragmas() { + let dir = TempDir::new().expect("failed to create temp dir"); + let db_path = dir.path().join("code_graph.db"); + let (db, _) = Database::initialize(&db_path) + .await + .expect("failed to initialize database"); + db.insert_node(&sample_node("node-1", "process_data", "src/main.rs")) + .await + .expect("failed to insert node"); + db.checkpoint() + .await + .expect("failed to checkpoint database"); + db.close(); + let mut permissions = std::fs::metadata(&db_path) + .expect("failed to stat database") + .permissions(); + permissions.set_mode(0o444); + std::fs::set_permissions(&db_path, permissions).expect("failed to mark database readonly"); + + let (db, migrated) = Database::open_read_only(&db_path) + .await + .expect("readonly database should open"); + let stats = db + .get_stats() + .await + .expect("readonly stats should be available"); + + assert!(!migrated); + assert_eq!(stats.node_count, 1); +} + #[tokio::test] async fn test_insert_and_get_node() { let (db, _dir) = setup_db().await; diff --git a/tests/migration_manifest_test.rs b/tests/migration_manifest_test.rs index 5b2edb7f..0964d848 100644 --- a/tests/migration_manifest_test.rs +++ b/tests/migration_manifest_test.rs @@ -18,9 +18,10 @@ use tracedecay::migrate::manifest::{ MIGRATION_MANIFEST_SCHEMA_VERSION, }; use tracedecay::storage::{ - read_enrollment_marker, read_store_manifest, StorageMode, StoreKind, StoreManifest, - STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, + read_enrollment_marker, read_store_manifest, write_enrollment_marker, EnrollmentMarker, + StorageMode, StoreKind, StoreManifest, STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, }; +use tracedecay::types::{Node, NodeKind, Visibility}; fn empty_inventory() -> MigrationInventory { MigrationInventory { @@ -52,6 +53,34 @@ fn canonical_temp_path(path: &Path) -> PathBuf { } } +fn sample_node(id: &str, name: &str) -> Node { + Node { + id: id.to_string(), + kind: NodeKind::Function, + name: name.to_string(), + qualified_name: format!("crate::{name}"), + file_path: "src/lib.rs".to_string(), + start_line: 1, + attrs_start_line: 1, + end_line: 3, + start_column: 0, + end_column: 1, + signature: Some(format!("fn {name}()")), + docstring: None, + visibility: Visibility::Pub, + is_async: false, + branches: 0, + loops: 0, + returns: 0, + max_nesting: 0, + unsafe_blocks: 0, + unchecked_calls: 0, + assertions: 0, + updated_at: 1_800_000_000, + parent_id: None, + } +} + #[cfg(unix)] #[test] fn save_manifest_rejects_symlinked_parent_components() { @@ -448,6 +477,124 @@ fn verify_manifest_validates_profile_store_manifest_registry_records() { assert!(report.issues.is_empty(), "{:?}", report.issues); } +#[tokio::test] +async fn verify_manifest_accepts_logically_equal_sqlite_artifacts_with_different_bytes() { + let dir = TempDir::new().unwrap(); + let root = canonical_temp_path(dir.path()); + let project = root.join("repo"); + let data_dir = project.join(".tracedecay"); + let source_db = data_dir.join("tracedecay.db"); + let profile_root = root.join("profile"); + let data_root = profile_root.join("projects/proj_123"); + let target_db = data_root.join("tracedecay.db"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&data_root).unwrap(); + + let (source, _) = tracedecay::db::Database::initialize(&source_db) + .await + .unwrap(); + source + .insert_node(&sample_node("node-1", "process_data")) + .await + .unwrap(); + source.checkpoint().await.unwrap(); + source.close(); + + let (target, _) = tracedecay::db::Database::initialize(&target_db) + .await + .unwrap(); + target + .insert_node(&sample_node("node-extra", "deleted_later")) + .await + .unwrap(); + target + .conn() + .execute( + "DELETE FROM nodes WHERE id = ?1", + libsql::params!["node-extra"], + ) + .await + .unwrap(); + target + .insert_node(&sample_node("node-1", "process_data")) + .await + .unwrap(); + target.checkpoint().await.unwrap(); + target.close(); + assert_ne!(fs::read(&source_db).unwrap(), fs::read(&target_db).unwrap()); + + fs::write( + data_root.join("branch-meta.json"), + r#"{"default_branch":"main","branches":{}}"#, + ) + .unwrap(); + fs::write(data_root.join("sessions.db"), b"sessions").unwrap(); + let store_manifest = StoreManifest { + schema_version: STORE_MANIFEST_SCHEMA_VERSION, + project_id: Some("proj_123".to_string()), + store_kind: StoreKind::CodeProject, + storage_mode: StorageMode::ProfileSharded, + project_root: project.clone(), + data_root: data_root.clone(), + graph_db_relpath: "tracedecay.db".into(), + sessions_db_relpath: "sessions.db".into(), + branch_meta_relpath: "branch-meta.json".into(), + }; + let store_manifest_path = data_root.join(STORE_MANIFEST_FILENAME); + fs::write( + &store_manifest_path, + serde_json::to_string_pretty(&store_manifest).unwrap(), + ) + .unwrap(); + fs::write( + data_dir.join(STORE_MANIFEST_FILENAME), + serde_json::to_string_pretty(&store_manifest).unwrap(), + ) + .unwrap(); + write_enrollment_marker( + &project, + &EnrollmentMarker { + project_id: "proj_123".to_string(), + storage_mode: StorageMode::ProfileSharded, + }, + ) + .unwrap(); + let protocol = MigrationProtocol::for_manifest(root.join("manifest.json"), "mig_123"); + let mut manifest = MigrationManifest::new( + "mig_123", + "0.0.2", + 1_800_000_000, + "confirm-mig_123", + protocol, + MigrationInventory { + stores: Vec::new(), + skipped: Vec::new(), + global_db: None, + }, + ); + manifest.source.project_root = Some(project); + manifest.source.data_dir = Some(data_dir.clone()); + manifest.destination.profile_root = Some(profile_root); + manifest.destination.project_id = Some("proj_123".to_string()); + manifest.artifacts.push(MigrationArtifact { + kind: "graph_db".to_string(), + source_path: source_db, + target_path: Some(target_db), + state: ArtifactState::Applied, + }); + manifest.artifacts.push(MigrationArtifact { + kind: "store_manifest".to_string(), + source_path: data_dir.join("store_manifest.json"), + target_path: Some(store_manifest_path), + state: ArtifactState::Applied, + }); + + let report = verify_migration_manifest(&manifest); + + assert!(report.apply_supported, "{:?}", report.issues); + assert!(report.issues.is_empty(), "{:?}", report.issues); +} + #[test] fn apply_migration_manifest_stops_at_verified_before_cutover() { let dir = TempDir::new().unwrap(); diff --git a/tests/update_plugin_test.rs b/tests/update_plugin_test.rs index 3c7b0eb5..c617dc3e 100644 --- a/tests/update_plugin_test.rs +++ b/tests/update_plugin_test.rs @@ -70,6 +70,46 @@ fn file_listing(root: &Path) -> Vec { out } +fn assert_plugin_skill_frontmatter_is_yaml_safe(plugin_dir: &Path) { + let mut failures = Vec::new(); + for relative in file_listing(plugin_dir) { + if relative.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") { + continue; + } + let skill_path = plugin_dir.join(&relative); + let skill = text(&skill_path); + let Some(rest) = skill.strip_prefix("---\n") else { + failures.push(format!("{}: missing frontmatter", relative.display())); + continue; + }; + let Some((frontmatter, _body)) = rest.split_once("\n---") else { + failures.push(format!("{}: unterminated frontmatter", relative.display())); + continue; + }; + for (line_index, line) in frontmatter.lines().enumerate() { + let Some((_key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim_start(); + if value.starts_with('"') || value.starts_with('\'') { + continue; + } + if value.contains(": ") { + failures.push(format!( + "{}:{}: quote frontmatter value containing ': '", + relative.display(), + line_index + 2 + )); + } + } + } + assert!( + failures.is_empty(), + "plugin skill frontmatter must be safe for strict YAML parsers:\n{}", + failures.join("\n") + ); +} + // --------------------------------------------------------------------------- // Hermes // --------------------------------------------------------------------------- @@ -221,6 +261,7 @@ fn cursor_update_plugin_refreshes_bundle_and_preserves_user_config() { assert!( text(&plugin_dir.join(".cursor-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION")) ); + assert_plugin_skill_frontmatter_is_yaml_safe(&plugin_dir); } #[test] @@ -261,6 +302,7 @@ fn codex_update_plugin_refreshes_bundle_without_touching_config() { assert!(text(&plugin_dir.join(".mcp.json")).contains(NEW_BIN)); assert!(text(&plugin_dir.join("hooks/hooks.json")).contains(NEW_BIN)); assert!(text(&plugin_dir.join(".codex-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION"))); + assert_plugin_skill_frontmatter_is_yaml_safe(&plugin_dir); } #[test] From fdb54cacc6bf1300627c727a740fce8f7f190b09 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 20:40:20 +0200 Subject: [PATCH 32/35] test: accept crlf skill frontmatter --- tests/update_plugin_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/update_plugin_test.rs b/tests/update_plugin_test.rs index c617dc3e..44f45931 100644 --- a/tests/update_plugin_test.rs +++ b/tests/update_plugin_test.rs @@ -78,6 +78,7 @@ fn assert_plugin_skill_frontmatter_is_yaml_safe(plugin_dir: &Path) { } let skill_path = plugin_dir.join(&relative); let skill = text(&skill_path); + let skill = skill.replace("\r\n", "\n"); let Some(rest) = skill.strip_prefix("---\n") else { failures.push(format!("{}: missing frontmatter", relative.display())); continue; From b3453f4186257b328fbb1109872649454826bdb7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 20:57:50 +0200 Subject: [PATCH 33/35] test: serialize context database fixtures --- tests/context_test.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/context_test.rs b/tests/context_test.rs index 864d713b..05630504 100644 --- a/tests/context_test.rs +++ b/tests/context_test.rs @@ -1,8 +1,11 @@ use tracedecay::context::*; use tracedecay::types::*; +static CONTEXT_DB_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] async fn test_reranking_demotes_fixture_nodes() { + let _guard = CONTEXT_DB_LOCK.lock().await; use tempfile::TempDir; use tracedecay::context::ContextBuilder; use tracedecay::db::Database; @@ -147,6 +150,7 @@ fn test_format_context_json() { #[tokio::test] async fn test_build_context_with_db() { + let _guard = CONTEXT_DB_LOCK.lock().await; use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; @@ -201,6 +205,7 @@ async fn test_build_context_with_db() { #[tokio::test] async fn test_get_code_reads_source_file() { + let _guard = CONTEXT_DB_LOCK.lock().await; use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; @@ -256,6 +261,7 @@ async fn test_get_code_reads_source_file() { #[tokio::test] async fn test_get_code_returns_none_for_missing_file() { + let _guard = CONTEXT_DB_LOCK.lock().await; use tempfile::TempDir; use tracedecay::context::ContextBuilder; use tracedecay::db::Database; @@ -300,6 +306,7 @@ async fn test_get_code_returns_none_for_missing_file() { #[tokio::test] async fn test_find_relevant_context() { + let _guard = CONTEXT_DB_LOCK.lock().await; use tempfile::TempDir; use tracedecay::context::ContextBuilder; use tracedecay::db::Database; @@ -347,6 +354,7 @@ async fn test_find_relevant_context() { #[tokio::test] async fn test_exclude_node_ids_deduplication() { + let _guard = CONTEXT_DB_LOCK.lock().await; use tempfile::TempDir; use tracedecay::context::ContextBuilder; use tracedecay::db::Database; @@ -409,6 +417,7 @@ async fn test_exclude_node_ids_deduplication() { #[tokio::test] async fn test_merge_adjacent_code_blocks() { + let _guard = CONTEXT_DB_LOCK.lock().await; use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; From 78f6377d51a54164b38d716c39d370f90239641a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 21:12:20 +0200 Subject: [PATCH 34/35] docs: satisfy clippy sqlite docs --- src/db/connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/connection.rs b/src/db/connection.rs index ae1367cc..bee46ed8 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -98,7 +98,7 @@ impl Database { /// Opens an existing database in read-only mode. /// /// This intentionally skips write-oriented PRAGMAs and migrations so - /// status/verification paths can inspect read-only SQLite files without + /// status/verification paths can inspect read-only `SQLite` files without /// creating WAL files or attempting schema updates. pub async fn open_read_only(db_path: &Path) -> Result<(Self, bool)> { let db = Builder::new_local(db_path) From 2ea92ebeb03a8012788151b86b6536785881f456 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 19 Jun 2026 21:28:57 +0200 Subject: [PATCH 35/35] docs: require conventional commits --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 63a8a60b..e03cf944 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - When the user asks to remember preferences or decisions, persist concise durable facts using the project memory system when available. - For large efforts, user wants fleets of concurrent multi-model subagents with strict per-agent file ownership so writers never collide; choose subagent models by task complexity. - Commit only when explicitly asked, scope commits to the agent's own changes grouped by logical subsystem, and push only when told. +- Use Conventional Commit messages for all commits (for example `fix:`, `feat:`, `docs:`, `test:`, `ci:`, `chore:`); mark breaking changes with `!` or a `BREAKING CHANGE:` footer so release-plz can infer SemVer correctly. - Reported metrics (token savings, costs) must be honest and audited — net rather than gross math, cross-checked against real transcript/usage data. - Fix flaky tests instead of skipping them; never skip tests to get CI green. - Keep one-off migration scripts untracked and delete them once their results are verified per project.