diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index d8f561058..383bf15d3 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -174,6 +174,12 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
+ // The copy tick on a result table: shown only after the clipboard write
+ // resolved, so it reports a copy that happened; nothing is written to the
+ // record.
+ "src/components/insights/coach-panel/copy-table-button.tsx": {
+ "text-success": 1,
+ },
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
diff --git a/src/components/insights/coach-panel/__tests__/result-table.test.tsx b/src/components/insights/coach-panel/__tests__/result-table.test.tsx
new file mode 100644
index 000000000..b2c34e0c4
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/result-table.test.tsx
@@ -0,0 +1,114 @@
+/**
+ * v1.39.4 — the result table's anatomy: caption with title and method,
+ * column headers scoped, numbers right-aligned in tabular figures, absence
+ * shown as a dash and announced, and the first twelve rows before
+ * "Show all". SSR harness, like the other Coach panel tests; the toggle
+ * itself is exercised in the browser suite.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { CoachResultTable as Table } from "@/lib/ai/coach/types";
+
+import { CoachResultTable, RESULT_TABLE_PREVIEW_ROWS } from "../result-table";
+
+function table(rowCount: number): Table {
+ return {
+ ref: "r1",
+ source: {
+ tool: "get_metric_table",
+ domain: "bp",
+ window: "last30days",
+ period: "current",
+ granularity: "day",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.byDay",
+ title: "Blood pressure by day",
+ rowCount,
+ chartKind: null,
+ displayed: true,
+ columns: [
+ {
+ key: "day",
+ kind: "period",
+ labelKey: "coach.result.column.day",
+ label: "Day",
+ },
+ {
+ key: "systolic",
+ kind: "number",
+ labelKey: "coach.result.column.systolic",
+ label: "Systolic",
+ unit: "mmHg",
+ decimals: 0,
+ },
+ {
+ key: "readings",
+ kind: "count",
+ labelKey: "coach.result.column.readings",
+ label: "Readings",
+ },
+ ],
+ rows: Array.from({ length: rowCount }, (_, i) => [
+ `2026-09-${String(i + 1).padStart(2, "0")}`,
+ i === 1 ? null : 120.4 + i,
+ i === 1 ? null : 1,
+ ]),
+ truncated: false,
+ chart: null,
+ };
+}
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+describe("CoachResultTable", () => {
+ it("captions the table with its title and the method line", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(
+ /
]*>.*Blood pressure by day.*30 readings, daily means.*<\/caption>/s,
+ );
+ });
+
+ it("scopes headers to columns and right-aligns numbers in tabular figures", () => {
+ const html = render( );
+ expect(html.match(/]*scope="col"/g)).toHaveLength(3);
+ expect(html).toContain("(mmHg)");
+ expect(html).toMatch(/ ]*text-right tabular-nums[^>]*>120<\/td>/);
+ });
+
+ it("shows a day without a reading as a dash and says so to a screen reader", () => {
+ const html = render( );
+ expect(html).toContain(
+ '— ',
+ );
+ expect(html).toContain('no reading ');
+ expect(html).not.toMatch(/>0<\/td>/);
+ });
+
+ it("shows the first twelve rows and offers the rest", () => {
+ const html = render( );
+ expect(html.match(/ ]*data-slot="table-row"/g)).toHaveLength(
+ RESULT_TABLE_PREVIEW_ROWS + 1,
+ );
+ const button = html.match(
+ /]*aria-expanded="false"[^>]*>[^<]*<\/button>/,
+ )?.[0];
+ expect(button).toContain("Show all (20)");
+ const controls = button?.match(/aria-controls="([^"]+)"/)?.[1];
+ expect(controls).toBeTruthy();
+ expect(html).toContain(`id="${controls}"`);
+ });
+
+ it("offers nothing to expand for a short table", () => {
+ const html = render( );
+ expect(html).not.toContain("aria-expanded");
+ });
+});
diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx
new file mode 100644
index 000000000..29758f854
--- /dev/null
+++ b/src/components/insights/coach-panel/copy-table-button.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * v1.39.4 — copy a Coach result table: for a spreadsheet (tab-separated
+ * values plus an HTML table, so a document gets a real table too) or as
+ * aligned plain text.
+ *
+ * A check mark and a polite live "Copied" confirm for two seconds; a failure
+ * says so in a toast. Absent where the Clipboard API is (plain-HTTP
+ * self-hosts), rather than failing on every tap.
+ */
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useTranslations } from "@/lib/i18n/context";
+import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
+import {
+ copyResultTable,
+ copyResultText,
+ type ClipboardGrid,
+} from "@/lib/insights/coach-result-clipboard";
+
+import { COACH_ICON_BUTTON, useClipboardSupported } from "./read-aloud";
+
+/** How long the confirmation stays up. */
+export const COPY_CONFIRM_MS = 2_000;
+
+export interface CopyTableButtonProps {
+ /** Built on demand, so a table nobody copies formats nothing extra. */
+ grid: () => ClipboardGrid;
+ caption: string;
+}
+
+export function CopyTableButton({ grid, caption }: CopyTableButtonProps) {
+ const { t } = useTranslations();
+ const supported = useClipboardSupported();
+ const [copied, setCopied] = useState(false);
+ const timer = useRef(null);
+
+ useEffect(
+ () => () => {
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ },
+ [],
+ );
+
+ const run = useCallback(
+ async (copy: (g: ClipboardGrid, c: string) => Promise) => {
+ try {
+ await copy(grid(), caption);
+ setCopied(true);
+ if (timer.current !== null) window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => {
+ setCopied(false);
+ timer.current = null;
+ }, COPY_CONFIRM_MS);
+ } catch {
+ toast.error(t(COACH_RESULT_UI_KEYS.copyFailed));
+ }
+ },
+ [grid, caption, t],
+ );
+
+ if (!supported) return null;
+ const label = t(COACH_RESULT_UI_KEYS.copyMenu);
+ return (
+ <>
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ void run(copyResultTable)}
+ >
+ {t(COACH_RESULT_UI_KEYS.copyForSpreadsheet)}
+
+ void run(copyResultText)}
+ >
+ {t(COACH_RESULT_UI_KEYS.copyAsText)}
+
+
+
+
+ {copied ? t(COACH_RESULT_UI_KEYS.copied) : ""}
+
+ >
+ );
+}
diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx
index 591d5b378..eae730822 100644
--- a/src/components/insights/coach-panel/result-table.tsx
+++ b/src/components/insights/coach-panel/result-table.tsx
@@ -1,18 +1,253 @@
"use client";
/**
- * v1.39.4 — one table of values a Coach turn read: a captioned table with
- * the first rows shown, the rest behind "Show all", and copy for a
- * spreadsheet or as text.
+ * v1.39.4 — one table of values a Coach turn read.
*
- * Not built yet: renders nothing.
+ * A captioned table (title, then the method line when the caller passes
+ * one), numbers right-aligned in tabular figures, a period without a reading
+ * shown as "—" and announced as "no reading". The first twelve rows show; a
+ * "Show all (n)" button opens the rest in a scrolling region whose header
+ * row stays put. Copy for a spreadsheet or as text sits beside the title.
+ *
+ * Periods are calendar days (or the Monday of a week, or a month), so they
+ * are spelled by the UTC-pinned bucket formatters: the label names the day
+ * the server keyed, in the reader's date order, whatever their zone.
*/
-import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types";
+import { useCallback, useId, useMemo, useState, type ReactNode } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
+import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
+import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
+import type {
+ CoachResultCell,
+ CoachResultColumn,
+ CoachResultTable as CoachResultTableData,
+} from "@/lib/ai/coach/types";
+import type { ClipboardGrid } from "@/lib/insights/coach-result-clipboard";
+import { cn } from "@/lib/utils";
+
+import { CopyTableButton } from "./copy-table-button";
+
+/** Rows shown before "Show all". */
+export const RESULT_TABLE_PREVIEW_ROWS = 12;
export interface CoachResultTableProps {
result: CoachResultTableData;
+ /** The method line for this table, shown under the title. */
+ method?: ReactNode;
}
-export function CoachResultTable(_props: CoachResultTableProps) {
- return null;
+type CellFormatter = (
+ cell: CoachResultCell,
+ column: CoachResultColumn,
+) => string | null;
+
+const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
+const MONTH_KEY = /^\d{4}-\d{2}$/;
+
+function isNumeric(column: CoachResultColumn): boolean {
+ return column.kind === "number" || column.kind === "count";
+}
+
+/**
+ * The formatter a table's cells go through, for the screen and the
+ * clipboard alike. `grouped` adds thousands separators to counts on screen;
+ * the clipboard leaves them out so a spreadsheet reads a number.
+ */
+function useCellFormatter(): (grouped: boolean) => CellFormatter {
+ const { locale } = useTranslations();
+ const dateFormat = useDateFormatPreference();
+ return useMemo(() => {
+ const fmt = makeBucketLabelFormatters(locale, dateFormat);
+ const month = new Intl.DateTimeFormat(locale, {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+ return (grouped: boolean): CellFormatter =>
+ (cell, column) => {
+ if (cell === null) return null;
+ if (typeof cell === "number") {
+ if (column.kind === "count") {
+ return grouped ? fmt.integer(cell) : fmt.number(cell, 0);
+ }
+ return fmt.number(cell, column.decimals ?? 1);
+ }
+ if (column.kind === "period") {
+ if (DAY_KEY.test(cell)) {
+ return fmt.date(new Date(`${cell}T12:00:00Z`));
+ }
+ if (MONTH_KEY.test(cell)) {
+ return month.format(new Date(`${cell}-01T12:00:00Z`));
+ }
+ }
+ return cell;
+ };
+ }, [locale, dateFormat]);
+}
+
+function headingText(column: CoachResultColumn): string {
+ return column.unit ? `${column.label} (${column.unit})` : column.label;
+}
+
+export function CoachResultTable({ result, method }: CoachResultTableProps) {
+ const { t } = useTranslations();
+ const formatter = useCellFormatter();
+ const [expanded, setExpanded] = useState(false);
+ const titleId = useId();
+ const regionId = useId();
+
+ const format = useMemo(() => formatter(true), [formatter]);
+ const hasMore = result.rows.length > RESULT_TABLE_PREVIEW_ROWS;
+ const rows = expanded
+ ? result.rows
+ : result.rows.slice(0, RESULT_TABLE_PREVIEW_ROWS);
+
+ const grid = useCallback((): ClipboardGrid => {
+ const plain = formatter(false);
+ return {
+ header: result.columns.map(headingText),
+ rows: result.rows.map((row) =>
+ result.columns.map((column, index) =>
+ plain(row[index] ?? null, column),
+ ),
+ ),
+ alignRight: result.columns.map(isNumeric),
+ };
+ }, [formatter, result.columns, result.rows]);
+
+ const noReading = t(COACH_RESULT_UI_KEYS.noReading);
+
+ return (
+
+
+
+
+
+
+
+ {result.title}
+
+ {method ? (
+
+ {method}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {result.columns.map((column) => (
+
+ {column.label}
+ {column.unit ? (
+
+ {" "}
+ ({column.unit})
+
+ ) : null}
+
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {result.columns.map((column, index) => {
+ const text = format(row[index] ?? null, column);
+ return (
+
+ {text === null ? (
+ <>
+
+ —
+
+ {noReading}
+ >
+ ) : (
+ text
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ {hasMore || result.truncated ? (
+
+ {result.truncated ? (
+
+ {t(COACH_RESULT_UI_KEYS.truncated, {
+ shown: result.rows.length,
+ total: result.rowCount,
+ })}
+
+ ) : (
+
+ )}
+ {hasMore ? (
+
setExpanded((open) => !open)}
+ >
+ {expanded
+ ? t(COACH_RESULT_UI_KEYS.showFewer)
+ : t(COACH_RESULT_UI_KEYS.showAll, {
+ count: result.rows.length,
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ );
}
diff --git a/src/lib/insights/__tests__/coach-result-clipboard.test.ts b/src/lib/insights/__tests__/coach-result-clipboard.test.ts
new file mode 100644
index 000000000..cf5d99d81
--- /dev/null
+++ b/src/lib/insights/__tests__/coach-result-clipboard.test.ts
@@ -0,0 +1,152 @@
+/**
+ * v1.39.4 — copying a result table: a grid a spreadsheet parses back, cells
+ * that cannot start a formula, escaped HTML, aligned text, and the fallback
+ * when the rich clipboard is missing.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ copyResultTable,
+ copyResultText,
+ sanitiseCell,
+ toHtmlTable,
+ toPlainText,
+ toTsv,
+ type ClipboardGrid,
+} from "../coach-result-clipboard";
+
+const GRID: ClipboardGrid = {
+ header: ["Day", "Systolic (mmHg)", "Readings"],
+ rows: [
+ ["Sep 21, 2026", "131", "2"],
+ ["Sep 22, 2026", null, null],
+ ["Sep 23, 2026", "-4.5", "1"],
+ ],
+ alignRight: [false, true, true],
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("sanitiseCell", () => {
+ it.each([
+ ['=HYPERLINK("http://x")', '\'=HYPERLINK("http://x")'],
+ ["+1+2", "'+1+2"],
+ ["-2+3", "'-2+3"],
+ ["@SUM(A1)", "'@SUM(A1)"],
+ ])("neutralises a formula start: %s", (input, expected) => {
+ expect(sanitiseCell(input)).toBe(expected);
+ });
+
+ it("turns a leading tab into a space, which no spreadsheet reads as a formula", () => {
+ expect(sanitiseCell("\t=1")).toBe(" =1");
+ });
+
+ it("leaves plain negatives and ordinary text alone", () => {
+ expect(sanitiseCell("-4.5")).toBe("-4.5");
+ expect(sanitiseCell("-1 234,5")).toBe("-1 234,5");
+ expect(sanitiseCell("LDL")).toBe("LDL");
+ expect(sanitiseCell(null)).toBe("");
+ });
+
+ it("flattens tabs and line breaks inside a cell", () => {
+ expect(sanitiseCell("two\tparts\nhere")).toBe("two parts here");
+ });
+});
+
+describe("toTsv", () => {
+ it("round-trips into the same grid a spreadsheet would read", () => {
+ const tsv = toTsv({
+ header: GRID.header,
+ rows: [...GRID.rows, ["line\nbreak", "=cmd|' /C calc'!A0", "3"]],
+ });
+ const parsed = tsv.split("\n").map((line) => line.split("\t"));
+ expect(parsed).toEqual([
+ ["Day", "Systolic (mmHg)", "Readings"],
+ ["Sep 21, 2026", "131", "2"],
+ ["Sep 22, 2026", "", ""],
+ ["Sep 23, 2026", "-4.5", "1"],
+ ["line break", "'=cmd|' /C calc'!A0", "3"],
+ ]);
+ // Every row has the same width: no cell split or swallowed a column.
+ expect(new Set(parsed.map((row) => row.length)).size).toBe(1);
+ });
+});
+
+describe("toHtmlTable", () => {
+ it("escapes every cell and the caption", () => {
+ const html = toHtmlTable(
+ {
+ header: ["Test "],
+ rows: [[" "], ["a & 'b'"]],
+ },
+ 'Labs "latest"',
+ );
+ expect(html).not.toContain(" ");
+ expect(html).toContain("<img src=x onerror=alert(1)>");
+ expect(html).toContain("a & 'b'");
+ expect(html).toContain("Labs "latest" ");
+ });
+});
+
+describe("toPlainText", () => {
+ it("lines the columns up and shows a missing reading as a dash", () => {
+ const text = toPlainText(GRID, "Blood pressure by day");
+ expect(text.split("\n")).toEqual([
+ "Blood pressure by day",
+ "Day Systolic (mmHg) Readings",
+ "Sep 21, 2026 131 2",
+ "Sep 22, 2026 — —",
+ "Sep 23, 2026 -4.5 1",
+ ]);
+ });
+});
+
+describe("copyResultTable", () => {
+ it("writes TSV and HTML as one clipboard item", async () => {
+ const write = vi.fn(async () => undefined);
+ const writeText = vi.fn(async () => undefined);
+ class FakeItem {
+ constructor(public items: Record) {}
+ }
+ vi.stubGlobal("ClipboardItem", FakeItem);
+ vi.stubGlobal("navigator", { clipboard: { write, writeText } });
+ await copyResultTable(GRID, "Blood pressure by day");
+ expect(write).toHaveBeenCalledTimes(1);
+ const [[items]] = write.mock.calls as unknown as [[FakeItem[]]];
+ expect(Object.keys(items[0].items).sort()).toEqual([
+ "text/html",
+ "text/plain",
+ ]);
+ expect(await items[0].items["text/plain"].text()).toBe(toTsv(GRID));
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the TSV alone without ClipboardItem", async () => {
+ const writeText = vi.fn(async () => undefined);
+ vi.stubGlobal("ClipboardItem", undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
+ await copyResultTable(GRID);
+ expect(writeText).toHaveBeenCalledWith(toTsv(GRID));
+ });
+
+ it("falls back when the rich write is refused", async () => {
+ const write = vi.fn(async () => {
+ throw new Error("NotAllowedError");
+ });
+ const writeText = vi.fn(async () => undefined);
+ vi.stubGlobal("ClipboardItem", class {});
+ vi.stubGlobal("navigator", { clipboard: { write, writeText } });
+ await copyResultTable(GRID);
+ expect(writeText).toHaveBeenCalledWith(toTsv(GRID));
+ });
+
+ it("copies the aligned text on request", async () => {
+ const writeText = vi.fn(async () => undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
+ await copyResultText(GRID, "Title");
+ expect(writeText).toHaveBeenCalledWith(toPlainText(GRID, "Title"));
+ });
+});
diff --git a/src/lib/insights/coach-result-clipboard.ts b/src/lib/insights/coach-result-clipboard.ts
new file mode 100644
index 000000000..1a86f2b9d
--- /dev/null
+++ b/src/lib/insights/coach-result-clipboard.ts
@@ -0,0 +1,138 @@
+/**
+ * Copying a Coach result table: as tab-separated values and an HTML table
+ * together (a spreadsheet takes the grid, a document takes the table), or
+ * as plain text with aligned columns.
+ *
+ * The cells arrive already formatted the way the table shows them. Two
+ * things are done to them on the way out:
+ *
+ * - Formula injection. A cell that starts with `=`, `+`, `-` or `@` (or a
+ * tab / carriage return) is read by a spreadsheet as a formula, and a
+ * table can carry text the person did not type — a lab analyte read off
+ * an uploaded report. Such a cell gets a leading `'`, which spreadsheets
+ * treat as "this is text". A plain negative number is left alone.
+ * - Separators. A tab or line break inside a cell would split the grid;
+ * both become a space.
+ *
+ * Client-safe: no server import.
+ */
+
+/** One row of formatted cells; `null` is a period without a reading. */
+export type ClipboardRow = ReadonlyArray;
+
+export interface ClipboardGrid {
+ /** Column headings, units included. */
+ header: ReadonlyArray;
+ rows: ReadonlyArray;
+ /** Right-align these columns in the plain-text form (the numbers). */
+ alignRight?: ReadonlyArray;
+}
+
+const FORMULA_START = /^[=+\-@\t\r]/;
+const NEGATIVE_NUMBER = /^-\s?\d[\d\s., ]*$/;
+
+/** A cell with its separators flattened and a formula start neutralised. */
+export function sanitiseCell(value: string | null): string {
+ if (value === null) return "";
+ const flat = value.replace(/[\t\r\n]+/g, " ");
+ if (FORMULA_START.test(flat) && !NEGATIVE_NUMBER.test(flat)) {
+ return `'${flat}`;
+ }
+ return flat;
+}
+
+/** Tab-separated values, one line per row, the header first. */
+export function toTsv(grid: ClipboardGrid): string {
+ return [grid.header, ...grid.rows]
+ .map((row) => row.map(sanitiseCell).join("\t"))
+ .join("\n");
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/** An HTML table, every cell escaped, with an optional caption. */
+export function toHtmlTable(grid: ClipboardGrid, caption?: string): string {
+ const cell = (tag: "th" | "td", value: string | null) =>
+ `<${tag}>${escapeHtml(sanitiseCell(value))}${tag}>`;
+ const head = `${grid.header.map((h) => cell("th", h)).join("")} `;
+ const body = `${grid.rows
+ .map((row) => `${row.map((v) => cell("td", v)).join("")} `)
+ .join("")} `;
+ const captionHtml = caption
+ ? `${escapeHtml(caption)} `
+ : "";
+ return `${captionHtml}${head}${body}
`;
+}
+
+/**
+ * Plain text with the columns padded to line up in a monospaced font; a
+ * period without a reading reads as "—". Separators are flattened, but no
+ * formula guard: this form is for reading, not for a spreadsheet.
+ */
+export function toPlainText(grid: ClipboardGrid, caption?: string): string {
+ const lines = [grid.header, ...grid.rows].map((row) =>
+ row.map((value) =>
+ value === null ? "—" : value.replace(/[\t\r\n]+/g, " "),
+ ),
+ );
+ const widths = grid.header.map((_, index) =>
+ Math.max(...lines.map((row) => [...(row[index] ?? "")].length)),
+ );
+ const body = lines.map((row) =>
+ row
+ .map((value, index) => {
+ const pad = " ".repeat(widths[index] - [...value].length);
+ return grid.alignRight?.[index] ? pad + value : value + pad;
+ })
+ .join(" ")
+ .trimEnd(),
+ );
+ return [...(caption ? [caption] : []), ...body].join("\n");
+}
+
+/**
+ * Put the table on the clipboard as TSV and HTML in one item. Falls back to
+ * the TSV alone where `ClipboardItem` or `clipboard.write` is missing.
+ * Rejects when neither works; the caller says so.
+ */
+export async function copyResultTable(
+ grid: ClipboardGrid,
+ caption?: string,
+): Promise {
+ const tsv = toTsv(grid);
+ const clipboard = navigator.clipboard;
+ if (
+ typeof ClipboardItem !== "undefined" &&
+ typeof clipboard.write === "function"
+ ) {
+ try {
+ await clipboard.write([
+ new ClipboardItem({
+ "text/plain": new Blob([tsv], { type: "text/plain" }),
+ "text/html": new Blob([toHtmlTable(grid, caption)], {
+ type: "text/html",
+ }),
+ }),
+ ]);
+ return;
+ } catch {
+ // Some browsers refuse the HTML flavour; the grid alone still helps.
+ }
+ }
+ await clipboard.writeText(tsv);
+}
+
+/** Put the plain-text form on the clipboard. */
+export async function copyResultText(
+ grid: ClipboardGrid,
+ caption?: string,
+): Promise {
+ await navigator.clipboard.writeText(toPlainText(grid, caption));
+}