Skip to content

Attach robustness + interactive report UI#6

Merged
jddunn merged 2 commits into
masterfrom
feat/attach-robustness-ui
Jul 21, 2026
Merged

Attach robustness + interactive report UI#6
jddunn merged 2 commits into
masterfrom
feat/attach-robustness-ui

Conversation

@jddunn

@jddunn jddunn commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Wunderland-side of the robustness/UX wave (pairs with framerslab/agentos-extensions#36):

  • Interactive report UI in the export pipeline: client-side sort/search/filter, value heat-shading, stat cards, light/dark toggle, client CSV export — self-contained single file.
  • Config knobs: WUNDERLAND_ATTACH_DRYRUN, WUNDERLAND_ATTACH_DEADLINE_MS; classify browser_attach_control under search.
    All unit-tested.

Summary by Sourcery

Introduce a self-contained interactive HTML report export alongside existing static report output and extend attach runtime configuration for robustness.

New Features:

  • Add an interactive HTML report generator with client-side sorting, search, filtering, heat shading, stat cards, theme toggle, and CSV export.
  • Allow the export pipeline to optionally emit a separate interactive report file in addition to CSV, static HTML, and PDF.

Enhancements:

  • Extend attach runtime options to support dry-run mode and configurable attach deadline sourced from environment variables.
  • Classify the browser_attach_control tool under the search category in tool access profiles.

Tests:

  • Add unit tests covering the interactive HTML report generator, including self-containment, embedded metadata, controls wiring, data escaping, and theme handling.
  • Add unit tests validating parsing and validation of new attach environment knobs and the updated tool category mapping.

jddunn added 2 commits July 20, 2026 22:21
Self-contained interactive HTML report: client-side column sort (numeric
aware), live search, value heat-shading (higher/lower-better), stat cards,
light/dark toggle, and a client CSV export of the current view. No external
resources — inlined CSS/JS, safe to share as one file. </script> in embedded
data is escaped to prevent breakout. exportReport gains an interactive opt.
Add browser_attach_control to the search category; add WUNDERLAND_ATTACH_DRYRUN
and WUNDERLAND_ATTACH_DEADLINE_MS env knobs for the flexibility surface.
@sourcery-ai

sourcery-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a self-contained interactive HTML export variant for reports, introduces environment-configurable attach dry-run and deadline options, and classifies the browser_attach_control tool under the search category, with accompanying tests.

Sequence diagram for interactive report export pipeline

sequenceDiagram
  actor Caller
  participant exportReport
  participant toCsv
  participant toHtml
  participant toInteractiveHtml
  participant renderPdf
  participant FS as node_fs

  Caller->>exportReport: exportReport(ds, outDir, opts)
  exportReport->>FS: mkdirSync(outDir)
  exportReport->>toCsv: toCsv(ds)
  toCsv-->>exportReport: csvContent
  exportReport->>FS: writeFileSync(report.csv, csvContent)
  exportReport->>toHtml: toHtml(ds)
  toHtml-->>exportReport: htmlDoc
  exportReport->>FS: writeFileSync(report.html, htmlDoc)
  alt opts.interactive is truthy
    exportReport->>toInteractiveHtml: toInteractiveHtml(ds, interactiveOpts)
    toInteractiveHtml-->>exportReport: interactiveHtml
    exportReport->>FS: writeFileSync(report.interactive.html, interactiveHtml)
  end
  alt opts.pdf !== false
    exportReport->>renderPdf: renderPdf(htmlDoc, report.pdf, opts)
    renderPdf-->>exportReport: pdfPath
  end
  exportReport-->>Caller: { csv, html, pdf?, interactive? }
Loading

File-Level Changes

Change Details Files
Add a self-contained interactive HTML report variant with client-side interactivity and integrate it into the export pipeline.
  • Define ColumnSpec and InteractiveOptions interfaces to describe interactive report columns, stats, and theme behavior.
  • Implement toInteractiveHtml to render an inlined CSS/JS HTML document supporting client-side sort, search, heat-shading, theme toggle, and CSV export, and safely embed dataset JSON.
  • Extend exportReport to optionally generate an .interactive.html artifact and return its path when interactive export is enabled.
src/cli/export/report-export.ts
Test coverage for interactive report export behavior.
  • Import toInteractiveHtml into the report export test suite.
  • Add tests verifying self-contained HTML (no external resources), correct embedding of data and column metadata, shipped UI controls, safe </script> handling, and theme option semantics.
src/cli/export/__tests__/report-export.test.ts
Expose new attach robustness knobs via environment variables and validate them.
  • Extend AttachOptions with dryRun and deadlineMs fields.
  • Update attachOptionsFromEnv to parse WUNDERLAND_ATTACH_DRYRUN and WUNDERLAND_ATTACH_DEADLINE_MS, accepting only truthy dry-run flags and positive finite deadlines.
src/runtime/tools/attach-env.ts
Test parsing behavior of new attach env knobs.
  • Add tests asserting correct parsing of dry-run and positive numeric deadlines and ignoring non-numeric or non-positive deadline values.
src/runtime/tools/__tests__/attach-env.test.ts
Classify browser_attach_control as a search tool in access profiles and keep tests in sync.
  • Map browser_attach_control to the search category in TOOL_CATEGORY_MAP.
  • Update ToolAccessProfiles tests to expect browser_attach_control under the search category along with other browser attach tools.
src/autonomy/social/ToolAccessProfiles.ts
src/autonomy/social/__tests__/ToolAccessProfiles.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The large inline HTML/CSS/JS template in toInteractiveHtml is quite dense; consider factoring the CSS and script generation into helper functions or a tagged template to improve readability and make future changes to the interactive report UI easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The large inline HTML/CSS/JS template in `toInteractiveHtml` is quite dense; consider factoring the CSS and script generation into helper functions or a tagged template to improve readability and make future changes to the interactive report UI easier to maintain.

## Individual Comments

### Comment 1
<location path="src/cli/export/report-export.ts" line_range="109-111" />
<code_context>
+ */
+export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {
+  const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
+  const colMeta = specs.map((s) => ({
+    key: s.key,
+    label: s.label ?? s.key,
+    numeric: !!s.numeric,
+    heat: s.heat ?? null,
</code_context>
<issue_to_address>
**🚨 issue (security):** Column labels are injected into HTML without escaping, which can lead to broken markup or XSS in the interactive report.

In the interactive table header, `c.label` is written directly into `innerHTML` and can come from `InteractiveOptions.columns`, so unescaped characters like `<`, `&`, or quotes may corrupt the HTML or enable XSS in the exported report. Please ensure labels are HTML-escaped before insertion (e.g., by storing both raw and escaped values in `colMeta`, or escaping at header rendering time), consistent with how `ds.title` and `stats` are handled.
</issue_to_address>

### Comment 2
<location path="src/cli/export/report-export.ts" line_range="107" />
<code_context>
+ * value heat-shading, and a light/dark toggle. No external resources — all CSS
+ * and JS are inlined, so it opens offline and is safe to share as one file.
+ */
+export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {
+  const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
+  const colMeta = specs.map((s) => ({
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the interactive report’s CSS/JS and interactive file-writing logic into small helper functions so `toInteractiveHtml` and `exportReport` stay focused on data wiring.

You can keep all functionality while reducing complexity by factoring out CSS/JS and a bit of orchestration logic. This keeps `toInteractiveHtml` readable without changing behavior.

### 1. Split CSS and JS into named constants

Move the inline CSS and JS into separate template helpers, so `toInteractiveHtml` is mostly about data wiring.

```ts
function buildInteractiveCss(): string {
  return `
  :root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
  :root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
  @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
  *{box-sizing:border-box}
  body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
  /* ... keep the rest unchanged, just formatted more readably ... */
  `;
}

function buildInteractiveScript(dataJson: string, theme: InteractiveOptions['theme']): string {
  const themeInit =
    theme && theme !== 'auto'
      ? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});`
      : '';

  return `
  const DATA = ${dataJson};
  const state = { sortKey: null, dir: 1, q: '' };

  const num = v => {
    const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,''));
    return isNaN(n) ? null : n;
  };

  function heatRange(key) {
    let lo = Infinity, hi = -Infinity;
    for (const r of DATA.rows) {
      const n = num(r[key]);
      if (n == null) continue;
      lo = Math.min(lo, n);
      hi = Math.max(hi, n);
    }
    return { lo, hi };
  }

  const heats = {};
  for (const c of DATA.cols) if (c.heat) heats[c.key] = heatRange(c.key);

  function shade(c, v) {
    const h = heats[c.key];
    if (!h) return '';
    const n = num(v);
    if (n == null || h.hi === h.lo) return '';
    let t = (n - h.lo) / (h.hi - h.lo);
    if (c.heat === 'lower-better') t = 1 - t;
    const a = (0.06 + 0.20 * t).toFixed(3);
    return 'background:color-mix(in srgb, var(--heat) ' + (a * 100).toFixed(1) + '%, transparent)';
  }

  function getRows() {
    let rs = DATA.rows;
    if (state.q) {
      const q = state.q.toLowerCase();
      rs = rs.filter(r =>
        DATA.cols.some(c => String(r[c.key] ?? '').toLowerCase().includes(q)),
      );
    }
    if (state.sortKey) {
      const c = DATA.cols.find(x => x.key === state.sortKey);
      rs = rs.slice().sort((a, b) => {
        let av = a[state.sortKey], bv = b[state.sortKey];
        if (c && c.numeric) {
          av = num(av) ?? -Infinity;
          bv = num(bv) ?? -Infinity;
          return (av - bv) * state.dir;
        }
        return String(av ?? '').localeCompare(String(bv ?? '')) * state.dir;
      });
    }
    return rs;
  }

  function render() {
    const th = document.getElementById('thead');
    const tb = document.getElementById('tbody');

    th.innerHTML = '<tr>' + DATA.cols.map(c =>
      '<th class="' + (c.numeric ? 'num ' : '') +
      (state.sortKey === c.key ? 'sorted' : '') +
      '" data-k="' + c.key + '">' +
      c.label +
      '<span class="ar">' +
      (state.sortKey === c.key ? (state.dir > 0 ? '▲' : '▼') : '⇅') +
      '</span></th>',
    ).join('') + '</tr>';

    const rs = getRows();
    tb.innerHTML = rs.length
      ? rs.map(r =>
          '<tr>' + DATA.cols.map(c => {
            const v = r[c.key] ?? '';
            return '<td class="' + (c.numeric ? 'num' : 'wrapcell') +
              '" style="' + shade(c, v) + '">' +
              String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;') +
              '</td>';
          }).join('') + '</tr>',
        ).join('')
      : '<tr><td class="empty" colspan="' + DATA.cols.length +
        '">No matching rows</td></tr>';

    document.getElementById('count').textContent =
      rs.length + ' / ' + DATA.rows.length + ' rows';

    th.querySelectorAll('th').forEach(h => {
      h.onclick = () => {
        const k = h.dataset.k;
        if (state.sortKey === k) state.dir *= -1;
        else { state.sortKey = k; state.dir = 1; }
        render();
      };
    });
  }

  document.getElementById('q').addEventListener('input', e => {
    state.q = e.target.value;
    render();
  });

  document.getElementById('themeBtn').onclick = () => {
    const r = document.documentElement;
    const cur = r.getAttribute('data-theme') ||
      (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
    r.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
  };

  document.getElementById('csvBtn').onclick = () => {
    const esc = v => {
      const s = String(v ?? '');
      return /[",\\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
    };
    const cols = DATA.cols.map(c => c.key);
    const csv = [cols.map(esc).join(',')]
      .concat(getRows().map(r => cols.map(c => esc(r[c])).join(',')))
      .join('\\n');
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
    a.download = 'report.csv';
    a.click();
  };

  ${themeInit}
  render();
  `;
}
```

Then `toInteractiveHtml` becomes shorter and easier to scan:

```ts
export function toInteractiveHtml(
  ds: ReportDataset,
  opts: InteractiveOptions = {},
): string {
  const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
  const colMeta = specs.map((s) => ({
    key: s.key,
    label: s.label ?? s.key,
    numeric: !!s.numeric,
    heat: s.heat ?? null,
  }));

  const stats = opts.stats ?? [];
  const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
  const statsHtml = stats
    .map((s) =>
      `<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`,
    )
    .join('');
  const theme = opts.theme ?? 'auto';

  return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>${buildInteractiveCss()}</style></head><body>
<header>
  <div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
  <button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
  <input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
  <button class="btn" id="csvBtn" type="button">⬇ CSV</button>
  <span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>${buildInteractiveScript(dataJson, theme)}</script>
</body></html>`;
}
```

This keeps the “single-file, self-contained” property but isolates behavior and styling from the data mapping.

### 2. Reduce responsibility in `exportReport` with a helper

The interactive export branch is simple but you can make `exportReport` more declarative:

```ts
function writeInteractiveReport(
  ds: ReportDataset,
  outDir: string,
  base: string,
  interactive: boolean | InteractiveOptions | undefined,
  writeFileSync: typeof import('node:fs')['writeFileSync'],
): string | undefined {
  if (!interactive) return;
  const iv = join(outDir, `${base}.interactive.html`);
  const opts = typeof interactive === 'object' ? interactive : {};
  writeFileSync(iv, toInteractiveHtml(ds, opts));
  return iv;
}
```

Then inside `exportReport`:

```ts
const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };

result.interactive = writeInteractiveReport(ds, outDir, base, opts.interactive, writeFileSync);
```

These small extra helpers significantly lower cognitive load while preserving all current features and behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +109 to +111
const colMeta = specs.map((s) => ({
key: s.key,
label: s.label ?? s.key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Column labels are injected into HTML without escaping, which can lead to broken markup or XSS in the interactive report.

In the interactive table header, c.label is written directly into innerHTML and can come from InteractiveOptions.columns, so unescaped characters like <, &, or quotes may corrupt the HTML or enable XSS in the exported report. Please ensure labels are HTML-escaped before insertion (e.g., by storing both raw and escaped values in colMeta, or escaping at header rendering time), consistent with how ds.title and stats are handled.

* value heat-shading, and a light/dark toggle. No external resources — all CSS
* and JS are inlined, so it opens offline and is safe to share as one file.
*/
export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the interactive report’s CSS/JS and interactive file-writing logic into small helper functions so toInteractiveHtml and exportReport stay focused on data wiring.

You can keep all functionality while reducing complexity by factoring out CSS/JS and a bit of orchestration logic. This keeps toInteractiveHtml readable without changing behavior.

1. Split CSS and JS into named constants

Move the inline CSS and JS into separate template helpers, so toInteractiveHtml is mostly about data wiring.

function buildInteractiveCss(): string {
  return `
  :root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
  :root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
  @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
  *{box-sizing:border-box}
  body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
  /* ... keep the rest unchanged, just formatted more readably ... */
  `;
}

function buildInteractiveScript(dataJson: string, theme: InteractiveOptions['theme']): string {
  const themeInit =
    theme && theme !== 'auto'
      ? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});`
      : '';

  return `
  const DATA = ${dataJson};
  const state = { sortKey: null, dir: 1, q: '' };

  const num = v => {
    const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,''));
    return isNaN(n) ? null : n;
  };

  function heatRange(key) {
    let lo = Infinity, hi = -Infinity;
    for (const r of DATA.rows) {
      const n = num(r[key]);
      if (n == null) continue;
      lo = Math.min(lo, n);
      hi = Math.max(hi, n);
    }
    return { lo, hi };
  }

  const heats = {};
  for (const c of DATA.cols) if (c.heat) heats[c.key] = heatRange(c.key);

  function shade(c, v) {
    const h = heats[c.key];
    if (!h) return '';
    const n = num(v);
    if (n == null || h.hi === h.lo) return '';
    let t = (n - h.lo) / (h.hi - h.lo);
    if (c.heat === 'lower-better') t = 1 - t;
    const a = (0.06 + 0.20 * t).toFixed(3);
    return 'background:color-mix(in srgb, var(--heat) ' + (a * 100).toFixed(1) + '%, transparent)';
  }

  function getRows() {
    let rs = DATA.rows;
    if (state.q) {
      const q = state.q.toLowerCase();
      rs = rs.filter(r =>
        DATA.cols.some(c => String(r[c.key] ?? '').toLowerCase().includes(q)),
      );
    }
    if (state.sortKey) {
      const c = DATA.cols.find(x => x.key === state.sortKey);
      rs = rs.slice().sort((a, b) => {
        let av = a[state.sortKey], bv = b[state.sortKey];
        if (c && c.numeric) {
          av = num(av) ?? -Infinity;
          bv = num(bv) ?? -Infinity;
          return (av - bv) * state.dir;
        }
        return String(av ?? '').localeCompare(String(bv ?? '')) * state.dir;
      });
    }
    return rs;
  }

  function render() {
    const th = document.getElementById('thead');
    const tb = document.getElementById('tbody');

    th.innerHTML = '<tr>' + DATA.cols.map(c =>
      '<th class="' + (c.numeric ? 'num ' : '') +
      (state.sortKey === c.key ? 'sorted' : '') +
      '" data-k="' + c.key + '">' +
      c.label +
      '<span class="ar">' +
      (state.sortKey === c.key ? (state.dir > 0 ? '▲' : '▼') : '⇅') +
      '</span></th>',
    ).join('') + '</tr>';

    const rs = getRows();
    tb.innerHTML = rs.length
      ? rs.map(r =>
          '<tr>' + DATA.cols.map(c => {
            const v = r[c.key] ?? '';
            return '<td class="' + (c.numeric ? 'num' : 'wrapcell') +
              '" style="' + shade(c, v) + '">' +
              String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;') +
              '</td>';
          }).join('') + '</tr>',
        ).join('')
      : '<tr><td class="empty" colspan="' + DATA.cols.length +
        '">No matching rows</td></tr>';

    document.getElementById('count').textContent =
      rs.length + ' / ' + DATA.rows.length + ' rows';

    th.querySelectorAll('th').forEach(h => {
      h.onclick = () => {
        const k = h.dataset.k;
        if (state.sortKey === k) state.dir *= -1;
        else { state.sortKey = k; state.dir = 1; }
        render();
      };
    });
  }

  document.getElementById('q').addEventListener('input', e => {
    state.q = e.target.value;
    render();
  });

  document.getElementById('themeBtn').onclick = () => {
    const r = document.documentElement;
    const cur = r.getAttribute('data-theme') ||
      (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
    r.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
  };

  document.getElementById('csvBtn').onclick = () => {
    const esc = v => {
      const s = String(v ?? '');
      return /[",\\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
    };
    const cols = DATA.cols.map(c => c.key);
    const csv = [cols.map(esc).join(',')]
      .concat(getRows().map(r => cols.map(c => esc(r[c])).join(',')))
      .join('\\n');
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
    a.download = 'report.csv';
    a.click();
  };

  ${themeInit}
  render();
  `;
}

Then toInteractiveHtml becomes shorter and easier to scan:

export function toInteractiveHtml(
  ds: ReportDataset,
  opts: InteractiveOptions = {},
): string {
  const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
  const colMeta = specs.map((s) => ({
    key: s.key,
    label: s.label ?? s.key,
    numeric: !!s.numeric,
    heat: s.heat ?? null,
  }));

  const stats = opts.stats ?? [];
  const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
  const statsHtml = stats
    .map((s) =>
      `<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`,
    )
    .join('');
  const theme = opts.theme ?? 'auto';

  return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>${buildInteractiveCss()}</style></head><body>
<header>
  <div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
  <button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
  <input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
  <button class="btn" id="csvBtn" type="button">⬇ CSV</button>
  <span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>${buildInteractiveScript(dataJson, theme)}</script>
</body></html>`;
}

This keeps the “single-file, self-contained” property but isolates behavior and styling from the data mapping.

2. Reduce responsibility in exportReport with a helper

The interactive export branch is simple but you can make exportReport more declarative:

function writeInteractiveReport(
  ds: ReportDataset,
  outDir: string,
  base: string,
  interactive: boolean | InteractiveOptions | undefined,
  writeFileSync: typeof import('node:fs')['writeFileSync'],
): string | undefined {
  if (!interactive) return;
  const iv = join(outDir, `${base}.interactive.html`);
  const opts = typeof interactive === 'object' ? interactive : {};
  writeFileSync(iv, toInteractiveHtml(ds, opts));
  return iv;
}

Then inside exportReport:

const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };

result.interactive = writeInteractiveReport(ds, outDir, base, opts.interactive, writeFileSync);

These small extra helpers significantly lower cognitive load while preserving all current features and behavior.

@jddunn
jddunn merged commit fe2cb28 into master Jul 21, 2026
1 of 2 checks passed
@jddunn
jddunn deleted the feat/attach-robustness-ui branch July 21, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant