@kaizenreport/kensho-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html ADDED
@@ -0,0 +1,35 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Kensho · Test Report</title>
7
+ <link rel="icon" href="assets/kaizen-mark.svg">
8
+ <link rel="stylesheet" href="assets/tokens.css">
9
+ <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
10
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
11
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
12
+ </head>
13
+ <body>
14
+ <div id="app">
15
+ <noscript>Kensho report requires JavaScript to render.</noscript>
16
+ <div class="kv-boot">
17
+ <div class="kv-boot-mark">
18
+ <img src="assets/kaizen-mark.svg" width="40" height="40" alt="">
19
+ </div>
20
+ <div class="kv-boot-text">Loading report…</div>
21
+ </div>
22
+ </div>
23
+
24
+ <!-- Pre-compiled by packages/viewer/scripts/build.js — the .jsx sources are
25
+ copied alongside for human readability, but the browser only needs .js. -->
26
+ <script src="assets/data-loader.js"></script>
27
+ <script src="assets/data-bridge.js"></script>
28
+ <script src="assets/components.js"></script>
29
+ <script src="assets/charts.js"></script>
30
+ <script src="assets/test-detail.js"></script>
31
+ <script src="assets/tree-detail.js"></script>
32
+ <script src="assets/pages.js"></script>
33
+ <script src="assets/app.js"></script>
34
+ </body>
35
+ </html>
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@kaizenreport/kensho-viewer",
3
+ "version": "0.1.0",
4
+ "description": "Static HTML/JS/CSS viewer bundled by the Kensho CLI into each generated report. Also exports an embeddable React component for the Kaizen platform.",
5
+ "type": "module",
6
+ "main": "index.html",
7
+ "files": [
8
+ "index.html",
9
+ "assets",
10
+ "src",
11
+ "scripts",
12
+ "dist",
13
+ "build.js",
14
+ "README.md"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.html",
18
+ "./component": {
19
+ "types": "./dist/component.d.ts",
20
+ "import": "./dist/component.js",
21
+ "default": "./dist/component.js"
22
+ },
23
+ "./style.css": "./assets/tokens.css",
24
+ "./data": "./src/data.js",
25
+ "./assets/*": "./assets/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "peerDependencies": {
29
+ "react": ">=18.0.0",
30
+ "react-dom": ">=18.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "react": {
34
+ "optional": true
35
+ },
36
+ "react-dom": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@babel/core": "^7.29.7",
42
+ "@babel/preset-react": "^7.27.1",
43
+ "esbuild": "^0.24.0"
44
+ },
45
+ "license": "Apache-2.0",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/brandon1794/kensho.git",
52
+ "directory": "packages/viewer"
53
+ },
54
+ "homepage": "https://github.com/brandon1794/kensho/tree/main/packages/viewer#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/brandon1794/kensho/issues"
57
+ },
58
+ "engines": {
59
+ "node": ">=22"
60
+ },
61
+ "scripts": {
62
+ "build": "node scripts/build.js",
63
+ "build:no-bundle": "node scripts/build.js --no-bundle"
64
+ }
65
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // Build pipeline for @kaizenreport/kensho-viewer.
3
+ //
4
+ // Two outputs:
5
+ //
6
+ // 1. Static-report assets — the pre-compiled .js files that index.html
7
+ // references via plain <script src="..."> tags. Each assets/*.jsx is
8
+ // transpiled in place to assets/*.js via @babel/preset-react. The
9
+ // browser does NOT need @babel/standalone at runtime. We also produce
10
+ // assets/data-loader.js which exposes window.__KENSHO_LOAD_DATA so the
11
+ // static `data-bridge.js` shim can call into the shared loader.
12
+ //
13
+ // 2. Embed bundle — dist/component.js + dist/component.d.ts. An ESM build
14
+ // of the React `<KenshoViewer />` component (and the shared loader),
15
+ // bundled with esbuild. React/ReactDOM are external peer-deps so the
16
+ // host app picks them up.
17
+ //
18
+ // Both outputs are produced from the same set of sources so the two modes
19
+ // stay in sync.
20
+
21
+ import { readdirSync, readFileSync, writeFileSync, statSync, mkdirSync, copyFileSync, existsSync } from 'node:fs';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { transformSync } from '@babel/core';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const ROOT = resolve(__dirname, '..');
28
+ const ASSETS = join(ROOT, 'assets');
29
+ const SRC = join(ROOT, 'src');
30
+ const DIST = join(ROOT, 'dist');
31
+
32
+ mkdirSync(ASSETS, { recursive: true });
33
+ mkdirSync(DIST, { recursive: true });
34
+
35
+ // -------------------------------------------------------------------------
36
+ // 1. Compile every .jsx in assets/ → sibling .js
37
+ // -------------------------------------------------------------------------
38
+ const files = readdirSync(ASSETS).filter(f => f.endsWith('.jsx'));
39
+ let bytesIn = 0, bytesOut = 0;
40
+ for (const f of files) {
41
+ const src = readFileSync(join(ASSETS, f), 'utf8');
42
+ const { code } = transformSync(src, {
43
+ presets: [['@babel/preset-react', { runtime: 'classic' }]],
44
+ babelrc: false,
45
+ configFile: false,
46
+ filename: f,
47
+ sourceMaps: false,
48
+ compact: false,
49
+ comments: true,
50
+ });
51
+ const out = f.replace(/\.jsx$/, '.js');
52
+ const banner = `/* Auto-generated from ${f} by packages/viewer/scripts/build.js. Edit the .jsx — DO NOT edit this file. */\n`;
53
+ writeFileSync(join(ASSETS, out), banner + code);
54
+ bytesIn += Buffer.byteLength(src);
55
+ bytesOut += Buffer.byteLength(code);
56
+ console.log(` ${f.padEnd(20)} → ${out} (${(Buffer.byteLength(code)/1024).toFixed(1)} KB)`);
57
+ }
58
+
59
+ // -------------------------------------------------------------------------
60
+ // 2. Static-report data loader — wraps src/data.js into a non-module script
61
+ // that exposes window.__KENSHO_LOAD_DATA. The static report loads this
62
+ // via a plain <script> tag (no `type="module"`, no bundler at runtime).
63
+ //
64
+ // The loader source itself is plain ESM, so we strip the `export` keywords
65
+ // and wrap in an IIFE that pins the named export onto window.
66
+ // -------------------------------------------------------------------------
67
+ const dataSrc = readFileSync(join(SRC, 'data.js'), 'utf8');
68
+ // Replace `export function name(` and `export async function name(` → `function name(`.
69
+ // Then expose `loadKenshoData` (and a couple helpers) on window at the end.
70
+ const dataNoExport = dataSrc.replace(/^export\s+(async\s+function|function|const|let|var)\b/gm, '$1');
71
+ const dataLoaderJs =
72
+ '/* Auto-generated. Wraps src/data.js for the static-report bootstrap. */\n' +
73
+ '(function () {\n' +
74
+ dataNoExport +
75
+ '\nwindow.__KENSHO_LOAD_DATA = loadKenshoData;\n' +
76
+ '})();\n';
77
+ writeFileSync(join(ASSETS, 'data-loader.js'), dataLoaderJs);
78
+ console.log(` ${'src/data.js'.padEnd(20)} → assets/data-loader.js (${(Buffer.byteLength(dataLoaderJs)/1024).toFixed(1)} KB)`);
79
+
80
+ // -------------------------------------------------------------------------
81
+ // 3. ESM bundle for embedding — dist/component.js + dist/component.d.ts.
82
+ // Lazily required (esbuild is an optional dev dep — if it's not installed,
83
+ // the static-report path still builds). Skip with --no-bundle.
84
+ // -------------------------------------------------------------------------
85
+ const skipBundle = process.argv.includes('--no-bundle');
86
+ if (!skipBundle) {
87
+ let esbuild;
88
+ try {
89
+ esbuild = await import('esbuild');
90
+ } catch (e) {
91
+ console.warn('[kensho-viewer] esbuild not installed — skipping embed bundle.');
92
+ console.warn(' run `pnpm add -D esbuild` inside packages/viewer/ to enable it.');
93
+ }
94
+ if (esbuild) {
95
+ await esbuild.build({
96
+ entryPoints: [join(SRC, 'component.jsx')],
97
+ outfile: join(DIST, 'component.js'),
98
+ bundle: true,
99
+ format: 'esm',
100
+ target: ['es2020'],
101
+ platform: 'browser',
102
+ jsx: 'automatic',
103
+ jsxImportSource: 'react',
104
+ external: ['react', 'react-dom', 'react/jsx-runtime'],
105
+ logLevel: 'info',
106
+ minify: false,
107
+ });
108
+ // Copy the .d.ts (hand-written, kept tiny).
109
+ const dts = join(SRC, 'component.d.ts');
110
+ if (existsSync(dts)) {
111
+ copyFileSync(dts, join(DIST, 'component.d.ts'));
112
+ }
113
+ console.log(` ${'src/component.jsx'.padEnd(20)} → dist/component.js`);
114
+ }
115
+ }
116
+
117
+ console.log(`[kensho-viewer] built ${files.length} static asset${files.length === 1 ? '' : 's'} · ${(bytesIn/1024).toFixed(1)} KB → ${(bytesOut/1024).toFixed(1)} KB`);
@@ -0,0 +1,115 @@
1
+ // Type declarations for @kaizenreport/kensho-viewer/component.
2
+ //
3
+ // Hand-written; the implementation is JSX. Kept minimal — covers the public
4
+ // API only.
5
+
6
+ import type * as React from 'react';
7
+
8
+ export interface KenshoSidebarItem {
9
+ /** Stable key, used for React reconciliation. */
10
+ id: string;
11
+ /** Visible label. */
12
+ label: string;
13
+ /** Lucide icon name (https://lucide.dev/icons). */
14
+ icon: string;
15
+ /** Render the page body when this item is active. */
16
+ render: () => React.ReactNode;
17
+ }
18
+
19
+ export interface KenshoExtraTab {
20
+ /** Stable key, used for React reconciliation. */
21
+ id: string;
22
+ /** Visible tab label. */
23
+ label: string;
24
+ /** Render the tab body for the given test (`RichTest` shape). */
25
+ render: (test: any) => React.ReactNode;
26
+ }
27
+
28
+ export interface KenshoViewerProps {
29
+ /**
30
+ * URL to a Kensho `data/` directory. The component will fetch
31
+ * `${dataUrl}/index.json` and (lazily) `${dataUrl}/cases/<id>.json`.
32
+ * Trailing slashes are normalized.
33
+ */
34
+ dataUrl: string;
35
+
36
+ /**
37
+ * Override for per-case JSON URLs. Default: `${dataUrl}/cases/<id>.json`.
38
+ */
39
+ caseUrl?: (caseId: string) => string;
40
+
41
+ /**
42
+ * URL to the directory containing the viewer's compiled `assets/*.js`
43
+ * files. Required if you don't drop a `<link data-kensho-viewer-assets="…">`
44
+ * marker in your <head>.
45
+ */
46
+ assetsUrl?: string;
47
+
48
+ /** Fired when a case is opened (deep-link integration). */
49
+ onCaseOpen?: (caseId: string | null) => void;
50
+
51
+ /** Fired when the user navigates to a different sidebar page. */
52
+ onPageChange?: (page: string) => void;
53
+
54
+ /**
55
+ * Optional extra sidebar items rendered after the built-ins.
56
+ */
57
+ extraSidebar?: KenshoSidebarItem[];
58
+
59
+ /**
60
+ * Optional extra tabs injected at the end of the test detail tab list.
61
+ */
62
+ extraTabs?: KenshoExtraTab[];
63
+
64
+ /**
65
+ * Initial state — useful for SSR / deep-linked URLs.
66
+ */
67
+ initial?: {
68
+ page?: string;
69
+ caseId?: string;
70
+ };
71
+
72
+ /**
73
+ * When `true`, the viewer suppresses its own hash-router and keyboard
74
+ * shortcuts. The host must update `initial.page` / `initial.caseId` (or
75
+ * remount) in response to the `onPageChange` / `onCaseOpen` callbacks.
76
+ */
77
+ ownKeyboard?: boolean;
78
+ }
79
+
80
+ export declare function KenshoViewer(props: KenshoViewerProps): JSX.Element;
81
+ export default KenshoViewer;
82
+
83
+ /**
84
+ * Pure data loader — fetches `${dataUrl}/index.json` and normalizes the
85
+ * Kensho v1 schema into the shape the viewer expects. Exposed for hosts that
86
+ * want to introspect the loaded data themselves.
87
+ */
88
+ export declare function loadKenshoData(
89
+ dataUrl: string,
90
+ opts?: {
91
+ caseUrl?: (caseId: string) => string;
92
+ fetch?: typeof fetch;
93
+ }
94
+ ): Promise<KenshoState>;
95
+
96
+ export interface KenshoState {
97
+ kenshoIndex: any;
98
+ reportType: 'unit' | 'e2e' | 'mixed' | string;
99
+ run: any;
100
+ env: Array<[string, string]>;
101
+ suites: Array<{ name: string; segs: Array<{ k: string; n: number }>; total: number }>;
102
+ tests: any[];
103
+ richTests: Record<string, any>;
104
+ suiteTree: any[];
105
+ behaviorTree: any[];
106
+ categories: any[];
107
+ timelineTests: any[];
108
+ trendRuns: any[];
109
+ histogram: Array<{ label: string; n: number }>;
110
+ historyRuns: any[];
111
+ ensureCaseLoaded: (richTest: any) => Promise<any>;
112
+ loadCase: (id: string) => Promise<any>;
113
+ fmtDuration: (ms: number) => string;
114
+ relTime: (iso: string) => string;
115
+ }
@@ -0,0 +1,340 @@
1
+ // =============================================================
2
+ // <KenshoViewer /> — embeddable React component.
3
+ //
4
+ // Same code path as the static report, but:
5
+ // * Fetches data from the URL passed in `props.dataUrl` (instead of
6
+ // hard-coded "data/").
7
+ // * Suppresses hash-router and keyboard shortcuts when the host opts in
8
+ // (`ownKeyboard: true`), bubbling navigation events to callbacks.
9
+ // * Lets the host inject extra sidebar items + extra detail-pane tabs.
10
+ //
11
+ // The static report itself does NOT load this file. It loads the original
12
+ // pre-compiled `assets/*.js` that read from window globals.
13
+ // =============================================================
14
+
15
+ import React, { useEffect, useRef, useState } from 'react';
16
+ import { loadKenshoData } from './data.js';
17
+
18
+ // ---- Context ------------------------------------------------------------
19
+ //
20
+ // Components in the static-report bundle continue to read window.RICH_TESTS
21
+ // etc. directly — they aren't aware of this context. The context is only
22
+ // consumed by:
23
+ //
24
+ // * Sidebar/DetailPane — to discover extraSidebar / extraTabs
25
+ // * The internal navigation shims — to bypass hash routing when
26
+ // `ownKeyboard` is set.
27
+ //
28
+ // When useKenshoCtx() returns null (= component used outside a provider, ie.
29
+ // the static-report path), callers fall back to window globals as before.
30
+
31
+ const KenshoContext = React.createContext(null);
32
+
33
+ export function useKenshoCtx() {
34
+ return React.useContext(KenshoContext);
35
+ }
36
+
37
+ // Make it accessible to the global components (they live in window.*) so
38
+ // Sidebar/DetailPane can opt in.
39
+ if (typeof window !== 'undefined') {
40
+ window.__KenshoContext = KenshoContext;
41
+ }
42
+
43
+ // ---- Snapshot of the rendered <App> tree --------------------------------
44
+ //
45
+ // `App`, `Sidebar`, `DetailPane`, etc. are defined by the legacy assets/*
46
+ // scripts (loaded as plain <script> tags by index.html). When this component
47
+ // is bundled for embedding via esbuild, those scripts are NOT loaded.
48
+ //
49
+ // We need *some* way to render the same UI from inside the bundle. There
50
+ // are two viable shapes:
51
+ //
52
+ // A) Re-implement <App> here. Lots of duplication, drifts over time.
53
+ //
54
+ // B) Resolve <App> from window at mount time. Requires the host to load
55
+ // the legacy assets up front. Bigger ask on integrators.
56
+ //
57
+ // We pick (C): the component bundle dynamically injects the legacy compiled
58
+ // scripts into the host page (one-time, idempotent) and then renders the
59
+ // resolved `window.App`. This keeps a single source of truth and makes the
60
+ // embed a true mirror of the static-report. See `loadLegacyScripts()` below.
61
+
62
+ const LEGACY_SCRIPTS = [
63
+ 'data-loader.js', // exposes window.__KENSHO_LOAD_DATA
64
+ 'components.js',
65
+ 'charts.js',
66
+ 'test-detail.js',
67
+ 'tree-detail.js',
68
+ 'pages.js',
69
+ 'app.js',
70
+ ];
71
+
72
+ // Track which (assetsBaseUrl, scriptName) pairs we've injected so a re-mount
73
+ // with the same base doesn't double-load.
74
+ const _injected = new Set();
75
+
76
+ function injectScript(url) {
77
+ return new Promise((resolve, reject) => {
78
+ const existing = document.querySelector(`script[data-kv-src="${url}"]`);
79
+ if (existing) {
80
+ if (existing.dataset.kvLoaded === '1') return resolve();
81
+ existing.addEventListener('load', () => resolve(), { once: true });
82
+ existing.addEventListener('error', () => reject(new Error('failed: ' + url)), { once: true });
83
+ return;
84
+ }
85
+ const s = document.createElement('script');
86
+ s.src = url;
87
+ s.dataset.kvSrc = url;
88
+ s.async = false; // preserve order
89
+ s.addEventListener('load', () => { s.dataset.kvLoaded = '1'; resolve(); }, { once: true });
90
+ s.addEventListener('error', () => reject(new Error('failed: ' + url)), { once: true });
91
+ document.head.appendChild(s);
92
+ });
93
+ }
94
+
95
+ async function loadLegacyAssets(assetsBaseUrl) {
96
+ const base = String(assetsBaseUrl).replace(/\/+$/, '');
97
+ for (const name of LEGACY_SCRIPTS) {
98
+ const url = `${base}/${name}`;
99
+ if (_injected.has(url)) continue;
100
+ await injectScript(url);
101
+ _injected.add(url);
102
+ }
103
+ // Lucide icons — same as the static report's index.html.
104
+ if (!window.lucide) {
105
+ await injectScript('https://unpkg.com/lucide@latest/dist/umd/lucide.min.js');
106
+ }
107
+ }
108
+
109
+ // `app.js`'s top-level code calls window.__KENSHO_BOOT.then(() => ReactDOM.createRoot(...).render(<App/>)).
110
+ // We don't want THAT to happen — we render <App/> ourselves into our own
111
+ // container. So before `app.js` evaluates, install a shim that swallows the
112
+ // auto-mount.
113
+ function installNoAutoMount() {
114
+ // Resolve __KENSHO_BOOT immediately with `null`. The `.then(() => render…)`
115
+ // body will run, but our shim throws before it can render.
116
+ if (!window.__KENSHO_BOOT) window.__KENSHO_BOOT = Promise.resolve();
117
+ // Swap out ReactDOM.createRoot with a noop the first time it's accessed
118
+ // FROM the legacy app.js. Easiest: monkey-patch `getElementById` to return
119
+ // null for the legacy "app" id, which makes createRoot throw — caught by
120
+ // app.js's `.catch` (it doesn't have one!). So instead we replace
121
+ // ReactDOM.createRoot during the brief window the script evaluates.
122
+ const dom = window.ReactDOM;
123
+ if (!dom) {
124
+ // ReactDOM isn't on window in embed mode (we use the host's react-dom
125
+ // import). Provide a stub so app.js's `ReactDOM.createRoot(...).render`
126
+ // is a noop.
127
+ window.ReactDOM = { createRoot: () => ({ render() {}, unmount() {} }) };
128
+ }
129
+ // Same for React (legacy uses `const { useState, useEffect } = React;`).
130
+ if (!window.React) {
131
+ window.React = React;
132
+ }
133
+ }
134
+
135
+ // Restore real ReactDOM/React after the legacy scripts have evaluated, so
136
+ // we don't permanently mess with the global. (We installed stubs only; if
137
+ // the host had its own globals, we leave them alone via the if-checks
138
+ // above.)
139
+
140
+ // ---- The component itself ------------------------------------------------
141
+
142
+ export function KenshoViewer(props) {
143
+ const {
144
+ dataUrl,
145
+ caseUrl,
146
+ assetsUrl, // optional: where to load the viewer's compiled JS from. Default: same package.
147
+ onCaseOpen,
148
+ onPageChange,
149
+ extraSidebar,
150
+ extraTabs,
151
+ initial,
152
+ ownKeyboard = false,
153
+ } = props;
154
+
155
+ if (!dataUrl) throw new Error('<KenshoViewer dataUrl="..." /> is required');
156
+
157
+ const containerRef = useRef(null);
158
+ const [phase, setPhase] = useState('boot'); // 'boot' | 'loading' | 'ready' | 'error'
159
+ const [errMsg, setErrMsg] = useState('');
160
+ const [state, setState] = useState(null);
161
+
162
+ // Resolve the URL to load the legacy compiled scripts from. By default we
163
+ // assume the host bundled this component; the legacy assets live in the
164
+ // same package. The host can override via `assetsUrl` if they self-host.
165
+ const resolvedAssetsUrl = assetsUrl || guessDefaultAssetsUrl();
166
+
167
+ // 1. Inject the legacy compiled scripts (one-time).
168
+ useEffect(() => {
169
+ let cancelled = false;
170
+ setPhase('loading');
171
+ installNoAutoMount();
172
+ loadLegacyAssets(resolvedAssetsUrl)
173
+ .then(() => loadKenshoData(dataUrl, { caseUrl: caseUrl ? (id) => caseUrl(id) : undefined }))
174
+ .then(s => {
175
+ if (cancelled) return;
176
+ // Push the loaded state onto window.* so the legacy components keep
177
+ // working. (They were written before the context refactor.) This is
178
+ // the documented limitation: 1 embedded viewer per page. Re-mount
179
+ // with a different `dataUrl` works because we re-write the globals.
180
+ applyToWindow(s);
181
+ setState(s);
182
+ setPhase('ready');
183
+ })
184
+ .catch(err => {
185
+ if (cancelled) return;
186
+ console.error('[KenshoViewer] failed to boot:', err);
187
+ setErrMsg(err?.message || String(err));
188
+ setPhase('error');
189
+ });
190
+ return () => { cancelled = true; };
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [dataUrl]); // intentionally don't depend on caseUrl/assetsUrl — they're stable in practice.
193
+
194
+ // (Step 2 removed.) Navigation is bridged inside the legacy <App>: when its
195
+ // context says `ownKeyboard: true` it calls `ctx.onPageChange` /
196
+ // `ctx.onCaseOpen` instead of pushing to window.location.hash. See
197
+ // assets/app.jsx. We don't override window.__navTo from out here because
198
+ // that would skip the legacy App's local page state update.
199
+
200
+ // 3. Mount the legacy <App/> into our container once data is ready.
201
+ // We do it imperatively because <App/> lives on window and isn't a
202
+ // JSX-importable export.
203
+ //
204
+ // Important: we mount ONCE per `state` (data reload) and re-render the
205
+ // same root with a fresh `ctxValue` when extras / callbacks change. If
206
+ // we unmounted on every prop change, the legacy App's local state
207
+ // (selected page, search filters, splitter width, …) would be wiped.
208
+ const rootRef = useRef(null);
209
+ // (a) Mount on phase=ready / data changes.
210
+ useEffect(() => {
211
+ if (phase !== 'ready' || !state || !containerRef.current) return;
212
+ const App = window.App;
213
+ if (typeof App !== 'function') {
214
+ console.error('[KenshoViewer] window.App not found after legacy load. Build out of date?');
215
+ return;
216
+ }
217
+ let cancelled = false;
218
+ import('react-dom/client').then(({ createRoot }) => {
219
+ if (cancelled) return;
220
+ const root = createRoot(containerRef.current);
221
+ rootRef.current = root;
222
+ renderLegacy(root);
223
+ });
224
+ return () => {
225
+ cancelled = true;
226
+ try { rootRef.current?.unmount(); } catch {}
227
+ rootRef.current = null;
228
+ };
229
+ // eslint-disable-next-line react-hooks/exhaustive-deps
230
+ }, [phase, state]);
231
+
232
+ // (b) Re-render with fresh ctxValue whenever extras / callbacks change.
233
+ useEffect(() => {
234
+ if (rootRef.current) renderLegacy(rootRef.current);
235
+ // eslint-disable-next-line react-hooks/exhaustive-deps
236
+ }, [extraSidebar, extraTabs, onCaseOpen, onPageChange, ownKeyboard]);
237
+
238
+ function renderLegacy(root) {
239
+ const App = window.App;
240
+ if (typeof App !== 'function') return;
241
+ const ctxValue = {
242
+ state,
243
+ extraSidebar: extraSidebar || [],
244
+ extraTabs: extraTabs || [],
245
+ onCaseOpen,
246
+ onPageChange,
247
+ ownKeyboard,
248
+ // `page` / `caseId` here are the host-controlled values from
249
+ // `props.initial`. The legacy App reads `ctx?.page` only as an
250
+ // optional one-way sync source (see app.jsx). Local navigation
251
+ // inside the viewer keeps using the App's own `useState`.
252
+ page: initial?.page,
253
+ caseId: initial?.caseId,
254
+ };
255
+ const legacyApp = React.createElement(App, null);
256
+ root.render(
257
+ React.createElement(KenshoContext.Provider, { value: ctxValue }, legacyApp)
258
+ );
259
+ }
260
+
261
+ // 4. Suppress legacy keyboard shortcuts when the host opts in. The legacy
262
+ // shortcut handler is registered on `window` from `app.jsx`. We can't
263
+ // easily un-register it, so instead capture all keydown events at the
264
+ // container and stop propagation before they bubble to window.
265
+ useEffect(() => {
266
+ if (!ownKeyboard) return;
267
+ const node = containerRef.current;
268
+ if (!node) return;
269
+ const onKeyDown = (e) => {
270
+ // Don't swallow chords inside text inputs the host may add.
271
+ e.stopPropagation();
272
+ };
273
+ node.addEventListener('keydown', onKeyDown, true);
274
+ return () => node.removeEventListener('keydown', onKeyDown, true);
275
+ }, [ownKeyboard, phase]);
276
+
277
+ if (phase === 'error') {
278
+ return React.createElement(
279
+ 'div',
280
+ { className: 'kv-embed-error', style: { padding: 24, color: '#E5484D', fontFamily: 'sans-serif' } },
281
+ React.createElement('h3', null, 'Failed to load Kensho report'),
282
+ React.createElement('pre', { style: { whiteSpace: 'pre-wrap', background: '#fcebec', padding: 12, borderRadius: 6 } }, errMsg)
283
+ );
284
+ }
285
+
286
+ return React.createElement('div', {
287
+ ref: containerRef,
288
+ className: 'kv-embed-root',
289
+ 'data-kensho-viewer': '',
290
+ style: { width: '100%', height: '100%', minHeight: 480 },
291
+ });
292
+ }
293
+
294
+ // Where do we load `assets/*.js` from?
295
+ // * If the host imported from `@kaizenreport/kensho-viewer/component`, we
296
+ // don't have a direct way to know the package URL.
297
+ // * Convention: the host should serve the package's `assets/` directory
298
+ // (e.g. via Vite's static asset import) and pass `assetsUrl` explicitly.
299
+ // * As a sane default, we look for a <script data-kensho-viewer-assets>
300
+ // marker tag the host can drop in their <head>.
301
+ function guessDefaultAssetsUrl() {
302
+ if (typeof document === 'undefined') return '/kensho-viewer-assets';
303
+ const marker = document.querySelector('[data-kensho-viewer-assets]');
304
+ if (marker) return marker.getAttribute('data-kensho-viewer-assets') || marker.getAttribute('href');
305
+ // Last-resort default — relative to the page.
306
+ return './kensho-viewer-assets';
307
+ }
308
+
309
+ // Apply a loaded `KenshoState` onto window globals so the legacy components
310
+ // can read them. Mirrors the assignments in `assets/data-bridge.jsx`.
311
+ function applyToWindow(state) {
312
+ Object.assign(window, {
313
+ KENSHO_INDEX: state.kenshoIndex,
314
+ KENSHO_REPORT_TYPE: state.reportType,
315
+ RUN: state.run,
316
+ ENV: state.env,
317
+ SUITES: state.suites,
318
+ TESTS: state.tests,
319
+ RICH_TESTS: state.richTests,
320
+ SUITE_TREE: state.suiteTree,
321
+ BEHAVIOR_TREE: state.behaviorTree,
322
+ CATEGORIES: state.categories,
323
+ TIMELINE_TESTS: state.timelineTests,
324
+ TREND_RUNS: state.trendRuns,
325
+ HISTOGRAM: state.histogram,
326
+ HISTORY_RUNS: state.historyRuns,
327
+ _kenshoEnsureCase: state.ensureCaseLoaded,
328
+ _kenshoLoadCase: state.loadCase,
329
+ _kenshoFmtDuration: state.fmtDuration,
330
+ _kenshoRelTime: state.relTime,
331
+ });
332
+ // Mark __KENSHO_BOOT as resolved so any late-arriving legacy code path
333
+ // (which awaits it) proceeds without waiting for a real fetch.
334
+ window.__KENSHO_BOOT = Promise.resolve();
335
+ }
336
+
337
+ // Re-export for advanced consumers.
338
+ export { loadKenshoData };
339
+
340
+ export default KenshoViewer;