feat(coach): result table with copy for a spreadsheet or as text

The table shows its caption, scoped headers, right-aligned tabular
numbers and a dash for a missing reading, twelve rows before Show all.
Copy writes TSV and an HTML table together, falling back to TSV, with
formula starts neutralised and separators flattened inside cells.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:54:31 +02:00
1 parent a967ccac44
commit 47efdd6606
6 files changed
+763 -7

No files matched your search

@@ -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,
},
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("CoachResultTable", () => {
it("captions the table with its title and the method line", () => {
const html = render(
<CoachResultTable result={table(3)} method="30 readings, daily means" />,
);
expect(html).toMatch(
/<caption[^>]*>.*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(<CoachResultTable result={table(3)} />);
expect(html.match(/<th[^>]*scope="col"/g)).toHaveLength(3);
expect(html).toContain("(mmHg)");
expect(html).toMatch(/<td[^>]*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(<CoachResultTable result={table(3)} />);
expect(html).toContain(
'<span class="text-muted-foreground" aria-hidden="true">—</span>',
);
expect(html).toContain('<span class="sr-only">no reading</span>');
expect(html).not.toMatch(/>0<\/td>/);
});
it("shows the first twelve rows and offers the rest", () => {
const html = render(<CoachResultTable result={table(20)} />);
expect(html.match(/<tr[^>]*data-slot="table-row"/g)).toHaveLength(
RESULT_TABLE_PREVIEW_ROWS + 1,
);
const button = html.match(
/<button[^>]*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(<CoachResultTable result={table(5)} />);
expect(html).not.toContain("aria-expanded");
});
});
@@ -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<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const run = useCallback(
async (copy: (g: ClipboardGrid, c: string) => Promise<void>) => {
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-slot="coach-copy-table"
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-slot="coach-copy-table-spreadsheet"
onSelect={() => void run(copyResultTable)}
>
{t(COACH_RESULT_UI_KEYS.copyForSpreadsheet)}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-copy-table-text"
onSelect={() => void run(copyResultText)}
>
{t(COACH_RESULT_UI_KEYS.copyAsText)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="sr-only" role="status" aria-live="polite">
{copied ? t(COACH_RESULT_UI_KEYS.copied) : ""}
</span>
</>
);
}
@@ -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 (
<div
data-slot="coach-result-table"
data-ref={result.ref}
className="bg-card rounded-lg border"
>
<div
id={regionId}
{...(expanded
? { role: "region", tabIndex: 0, "aria-labelledby": titleId }
: {})}
className={cn(
"overflow-x-auto rounded-lg",
expanded &&
"focus-visible:ring-ring/50 max-h-96 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2",
)}
>
<table className="w-full caption-top text-sm">
<caption className="px-3 pt-2 pb-1 text-left">
<span className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 pt-1.5">
<span
id={titleId}
className="text-foreground block font-medium"
>
{result.title}
</span>
{method ? (
<span className="text-muted-foreground block text-xs">
{method}
</span>
) : null}
</span>
<span className="shrink-0">
<CopyTableButton grid={grid} caption={result.title} />
</span>
</span>
</caption>
<TableHeader className="bg-card sticky top-0 z-10">
<TableRow className="hover:bg-transparent">
{result.columns.map((column) => (
<TableHead
key={column.key}
scope="col"
className={cn("h-9 px-3", isNumeric(column) && "text-right")}
>
{column.label}
{column.unit ? (
<span className="text-muted-foreground font-normal">
{" "}
({column.unit})
</span>
) : null}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={`${String(row[0])}-${rowIndex}`}>
{result.columns.map((column, index) => {
const text = format(row[index] ?? null, column);
return (
<TableCell
key={column.key}
className={cn(
"px-3 py-1.5",
isNumeric(column) && "text-right tabular-nums",
)}
>
{text === null ? (
<>
<span
className="text-muted-foreground"
aria-hidden="true"
>
—
</span>
<span className="sr-only">{noReading}</span>
</>
) : (
text
)}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</table>
</div>
{hasMore || result.truncated ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-1.5">
{result.truncated ? (
<p className="text-muted-foreground text-xs">
{t(COACH_RESULT_UI_KEYS.truncated, {
shown: result.rows.length,
total: result.rowCount,
})}
</p>
) : (
<span />
)}
{hasMore ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
aria-expanded={expanded}
aria-controls={regionId}
onClick={() => setExpanded((open) => !open)}
>
{expanded
? t(COACH_RESULT_UI_KEYS.showFewer)
: t(COACH_RESULT_UI_KEYS.showAll, {
count: result.rows.length,
})}
</Button>
) : null}
</div>
) : null}
</div>
);
}
@@ -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: ["<b>Test</b>"],
rows: [["<img src=x onerror=alert(1)>"], ["a & 'b'"]],
},
'Labs "latest"',
);
expect(html).not.toContain("<img");
expect(html).not.toContain("<b>");
expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
expect(html).toContain("a &amp; &#39;b&#39;");
expect(html).toContain("<caption>Labs &quot;latest&quot;</caption>");
});
});
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<string, Blob>) {}
}
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"));
});
});
+138
View File
@@ -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<string | null>;
export interface ClipboardGrid {
/** Column headings, units included. */
header: ReadonlyArray<string>;
rows: ReadonlyArray<ClipboardRow>;
/** Right-align these columns in the plain-text form (the numbers). */
alignRight?: ReadonlyArray<boolean>;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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 = `<thead><tr>${grid.header.map((h) => cell("th", h)).join("")}</tr></thead>`;
const body = `<tbody>${grid.rows
.map((row) => `<tr>${row.map((v) => cell("td", v)).join("")}</tr>`)
.join("")}</tbody>`;
const captionHtml = caption
? `<caption>${escapeHtml(caption)}</caption>`
: "";
return `<table>${captionHtml}${head}${body}</table>`;
}
/**
* 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<void> {
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<void> {
await navigator.clipboard.writeText(toPlainText(grid, caption));
}