Skip to content

Commit 1361efe

Browse files
phodalcodex
andcommitted
perf(studio): virtualize wide workbook columns
Extends the large-workbook preview spec with horizontal virtualization, merge-aware row and column windows, and keyboard scrolling to off-screen coordinates. The change keeps ArtifactView generic and adds a 256-column browser regression. Validated with the Node 24 Studio build and 40-file/243-test Vitest suite, 36 Playwright tests, focused oxlint, and the Homology cross-repository Studio verifier. Co-authored-by: Codex (GPT 5.6 Sol) <codex@openai.com>
1 parent 90252e1 commit 1361efe

5 files changed

Lines changed: 142 additions & 23 deletions

File tree

docs/specs/2026-08-24-studio-pdf-fbx-and-large-workbook-previews.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ Artifact View already dispatches through server-owned adapter and surface bindin
1111

1212
- ArtifactView remains a renderer-agnostic dispatcher. It must not inspect PDF, FBX, Office, or Canvas payload details.
1313
- PDF is a Studio-owned, read-only data surface. The adapter binds the exact catalog revision, exposes only the PDF bytes plus bounded page metadata, and the browser renders pages with PDF.js. PDF JavaScript, form actions, attachments, and external network access are not executed.
14-
- FBX is a Provider-owned external-hosted surface. The Homology Provider binds the exact catalog revision, parses it with `@homology/diagram-fbx`, and owns the rendered document. Better Harness only validates and mounts the common opaque hosted surface.
15-
- XLSX remains a Studio-owned read-only data surface. The projection may expose up to the existing populated-cell budget, and the client virtualizes rows so a large sparse worksheet does not create one DOM row for every worksheet row.
14+
- FBX is a Provider-owned external-hosted surface. The Homology Provider binds the exact catalog revision, parses it with `@homology/diagram-fbx`, and owns the precompiled WebGL document and interaction runtime. Better Harness only validates and mounts the common opaque hosted surface.
15+
- XLSX remains a Studio-owned read-only data surface. The projection may expose up to the existing populated-cell budget, and the client virtualizes rows and columns so a large sparse worksheet does not create one DOM cell for every worksheet coordinate.
1616
- Revision, adapter, renderer, hosted-runtime, provider fingerprint, capability, and security-profile identity continue to decide surface retention. Late data from an older revision must not replace the current view.
1717

1818
## Acceptance criteria
1919

2020
1. A real multipage PDF appears as a native Artifact View with page count, page navigation, zoom, keyboard operation, and virtualized page mounting.
2121
2. PDF bytes are served only from an immutable revision resource URI. Oversized files, excessive page counts, malformed files, password-protected files, and revision drift fail closed with a browser-safe diagnostic.
22-
3. A real ASCII or binary FBX appears through the existing external-hosted iframe lane, includes mesh/vertex/polygon metadata, and remains usable without adding an FBX branch to `ArtifactView.tsx` or the Studio surface registry.
22+
3. A real ASCII or binary FBX appears as an interactive WebGL model through the existing external-hosted iframe lane, includes mesh/vertex/polygon metadata, and remains usable without adding an FBX branch to `ArtifactView.tsx` or the Studio surface registry.
2323
4. FBX provider activation is explicit and receipt-bound. Ordinary TSX/JSX, Canvas TSX, diagrams, notebooks, Office files, and unknown files retain their existing resolution rules.
24-
5. XLSX retains populated cells beyond row 200, renders only the visible row window plus overscan, preserves sheet and cell selection across compatible revisions, and supports keyboard navigation to an off-screen row.
24+
5. XLSX retains populated cells beyond row 200 and column 64, renders only visible row/column windows plus overscan, expands a window when required to preserve a merged cell, preserves revision-scoped sheet/cell state, and supports keyboard navigation to off-screen coordinates.
2525
6. Unit tests cover model validation, catalog resolution, exact-revision resource reads, fail-closed inputs, provider receipts, and renderer selection. Browser tests cover wide, compact, and narrow layouts with no page errors, console errors, or unintended horizontal page overflow.
2626

2727
## Limits and non-goals
@@ -32,7 +32,7 @@ Artifact View already dispatches through server-owned adapter and surface bindin
3232

3333
## Verification
3434

35-
- Better Harness Node 24 generated-code check, Harness build/Vitest (19 files, 162 tests), Studio build/Vitest (40 files, 243 tests), Playwright (35 tests), and package verification passed.
36-
- Homology `diagram-fbx` build and integration Provider check passed (4 files, 13 tests; pack 1,851,262 bytes / 6,056,207 bytes unpacked / 11 entries), plus the cross-repository `verify:studio` route check.
37-
- A real three-page PDF, binary FBX, and generated 420-row XLSX returned exact-revision snapshots/resources; PDF and XLSX used native bindings, while FBX used the receipt-bound opaque hosted Provider.
38-
- Browser inspection at 1440x900, 1024x768, and 390x844 found zero page overflow and zero final console warning/error. PDF rendered real canvases and navigated to page 2; XLSX mounted 37 visible rows and materialized A420 only near the bottom; FBX pan/zoom controls changed scale from 1.0 to 1.2.
35+
- Better Harness Node 24 Harness build/Vitest (19 files, 162 tests), Studio build/Vitest (40 files, 243 tests), Playwright (36 tests), and package verification passed.
36+
- Homology integration Provider check passed (4 files, 13 tests; pack 2,014,441 bytes / 6,687,943 bytes unpacked / 12 entries), plus the cross-repository `verify:studio` route check.
37+
- A real three-page PDF, binary FBX, and generated 420-row by 256-column XLSX returned exact-revision snapshots/resources; PDF and XLSX used native bindings, while FBX used the receipt-bound opaque hosted Provider.
38+
- Browser inspection at 1440x900, 1024x768, and 390x844 found zero page overflow and zero final console warning/error. PDF rendered real canvases and navigated to page 2; XLSX mounted bounded row/column windows and materialized IV4 only at the far horizontal edge; FBX created one WebGL mesh, used the normalized-scene fallback for the minimal binary fixture, and changed its interaction counter after Reset.

packages/harness-studio/src/app/artifacts/xlsx/XlsxArtifactView.tsx

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,61 @@ function XlsxGrid(props: {
6767
estimateSize: (index) => rowHeight(props.sheet, index + 1),
6868
overscan: 8,
6969
});
70-
const rows = rowVirtualizer.getVirtualItems();
71-
const topSpacer = rows[0]?.start ?? 0;
72-
const bottomSpacer = rows.length === 0 ? 0 : rowVirtualizer.getTotalSize() - rows[rows.length - 1]!.end;
70+
const columnVirtualizer = useVirtualizer({
71+
count: props.sheet.columnCount,
72+
getScrollElement: () => props.scrollElement,
73+
estimateSize: (index) => columnWidth(props.sheet, index + 1),
74+
horizontal: true,
75+
overscan: 4,
76+
});
77+
const rowItems = rowVirtualizer.getVirtualItems();
78+
const columnItems = columnVirtualizer.getVirtualItems();
79+
const rowOffsets = useMemo(
80+
() => dimensionOffsets(props.sheet.rowCount, (index) => rowHeight(props.sheet, index)),
81+
[props.sheet],
82+
);
83+
const columnOffsets = useMemo(
84+
() => dimensionOffsets(props.sheet.columnCount, (index) => columnWidth(props.sheet, index)),
85+
[props.sheet],
86+
);
87+
const rowWindow = expandMergedWindow(
88+
rowItems[0]?.index === undefined ? 1 : rowItems[0].index + 1,
89+
rowItems.at(-1)?.index === undefined ? Math.min(1, props.sheet.rowCount) : rowItems.at(-1)!.index + 1,
90+
props.sheet.mergedRanges,
91+
"row",
92+
);
93+
const columnWindow = expandMergedWindow(
94+
columnItems[0]?.index === undefined ? 1 : columnItems[0].index + 1,
95+
columnItems.at(-1)?.index === undefined ? Math.min(1, props.sheet.columnCount) : columnItems.at(-1)!.index + 1,
96+
props.sheet.mergedRanges,
97+
"column",
98+
);
99+
const rows = integerRange(rowWindow.start, rowWindow.end);
100+
const columns = integerRange(columnWindow.start, columnWindow.end);
101+
const topSpacer = rowOffsets[rowWindow.start - 1] ?? 0;
102+
const bottomSpacer = (rowOffsets.at(-1) ?? 0) - (rowOffsets[rowWindow.end] ?? 0);
103+
const leftSpacer = columnOffsets[columnWindow.start - 1] ?? 0;
104+
const rightSpacer = (columnOffsets.at(-1) ?? 0) - (columnOffsets[columnWindow.end] ?? 0);
105+
const physicalColumnCount = 1 + columns.length + Number(leftSpacer > 0) + Number(rightSpacer > 0);
73106
return <table className="xlsx-grid" role="grid" aria-rowcount={props.sheet.rowCount} aria-colcount={props.sheet.columnCount}>
74-
<colgroup><col className="xlsx-row-number-column" />{Array.from({ length: props.sheet.columnCount }, (_, index) => <col key={index} style={{ width: `${columnWidth(props.sheet, index + 1)}px` }} />)}</colgroup>
75-
<thead><tr><th aria-hidden="true" />{Array.from({ length: props.sheet.columnCount }, (_, index) => <th key={index} scope="col">{columnLabel(index + 1)}</th>)}</tr></thead>
107+
<colgroup>
108+
<col className="xlsx-row-number-column" />
109+
{leftSpacer > 0 && <col className="xlsx-column-spacer" style={{ width: `${leftSpacer}px` }} />}
110+
{columns.map((column) => <col key={column} style={{ width: `${columnWidth(props.sheet, column)}px` }} />)}
111+
{rightSpacer > 0 && <col className="xlsx-column-spacer" style={{ width: `${rightSpacer}px` }} />}
112+
</colgroup>
113+
<thead><tr>
114+
<th aria-hidden="true" />
115+
{leftSpacer > 0 && <th className="xlsx-column-spacer" aria-hidden="true" />}
116+
{columns.map((column) => <th key={column} scope="col">{columnLabel(column)}</th>)}
117+
{rightSpacer > 0 && <th className="xlsx-column-spacer" aria-hidden="true" />}
118+
</tr></thead>
76119
<tbody>
77-
{topSpacer > 0 && <tr className="xlsx-virtual-spacer" aria-hidden="true"><td colSpan={props.sheet.columnCount + 1} style={{ height: `${topSpacer}px` }} /></tr>}
78-
{rows.map((virtualRow) => {
79-
const row = virtualRow.index + 1;
80-
return <tr key={row} data-index={virtualRow.index} ref={rowVirtualizer.measureElement} style={{ height: `${rowHeight(props.sheet, row)}px` }}>
120+
{topSpacer > 0 && <tr className="xlsx-virtual-spacer" aria-hidden="true"><td colSpan={physicalColumnCount} style={{ height: `${topSpacer}px` }} /></tr>}
121+
{rows.map((row) => <tr key={row} data-index={row - 1} ref={rowVirtualizer.measureElement} style={{ height: `${rowHeight(props.sheet, row)}px` }}>
81122
<th scope="row">{row}</th>
82-
{Array.from({ length: props.sheet.columnCount }, (_, columnOffset) => {
83-
const column = columnOffset + 1;
123+
{leftSpacer > 0 && <td className="xlsx-column-spacer" aria-hidden="true" />}
124+
{columns.map((column) => {
84125
const key = coordinateKey(row, column);
85126
if (model.covered.has(key)) return undefined;
86127
const cell = model.cells.get(key);
@@ -108,16 +149,55 @@ function XlsxGrid(props: {
108149
props.sheet.columnCount,
109150
props.onSelect,
110151
(index, options) => rowVirtualizer.scrollToIndex(index, options),
152+
(index, options) => columnVirtualizer.scrollToIndex(index, options),
111153
)}
112154
>{cell?.display ?? ""}</td>;
113155
})}
114-
</tr>;
115-
})}
116-
{bottomSpacer > 0 && <tr className="xlsx-virtual-spacer" aria-hidden="true"><td colSpan={props.sheet.columnCount + 1} style={{ height: `${bottomSpacer}px` }} /></tr>}
156+
{rightSpacer > 0 && <td className="xlsx-column-spacer" aria-hidden="true" />}
157+
</tr>)}
158+
{bottomSpacer > 0 && <tr className="xlsx-virtual-spacer" aria-hidden="true"><td colSpan={physicalColumnCount} style={{ height: `${bottomSpacer}px` }} /></tr>}
117159
</tbody>
118160
</table>;
119161
}
120162

163+
function dimensionOffsets(count: number, size: (index: number) => number): number[] {
164+
const offsets = Array.from({ length: count + 1 }, () => 0);
165+
for (let index = 1; index <= count; index += 1) offsets[index] = offsets[index - 1]! + size(index);
166+
return offsets;
167+
}
168+
169+
function expandMergedWindow(
170+
start: number,
171+
end: number,
172+
merges: readonly XlsxMergedRange[],
173+
axis: "column" | "row",
174+
): { start: number; end: number } {
175+
let nextStart = start;
176+
let nextEnd = end;
177+
let changed = true;
178+
while (changed) {
179+
changed = false;
180+
for (const merge of merges) {
181+
const mergeStart = axis === "row" ? merge.startRow : merge.startColumn;
182+
const mergeEnd = axis === "row" ? merge.endRow : merge.endColumn;
183+
if (mergeEnd < nextStart || mergeStart > nextEnd) continue;
184+
if (mergeStart < nextStart) {
185+
nextStart = mergeStart;
186+
changed = true;
187+
}
188+
if (mergeEnd > nextEnd) {
189+
nextEnd = mergeEnd;
190+
changed = true;
191+
}
192+
}
193+
}
194+
return { start: nextStart, end: nextEnd };
195+
}
196+
197+
function integerRange(start: number, end: number): number[] {
198+
return end < start ? [] : Array.from({ length: end - start + 1 }, (_, index) => start + index);
199+
}
200+
121201
function gridModel(sheet: XlsxWorksheetSnapshot): {
122202
cells: Map<string, XlsxCellSnapshot>;
123203
merges: Map<string, XlsxMergedRange>;
@@ -146,6 +226,7 @@ function handleCellKeyDown(
146226
columnCount: number,
147227
onSelect: (address: string) => void,
148228
scrollToRow: (index: number, options?: { align?: "auto" | "center" | "end" | "start" }) => void,
229+
scrollToColumn: (index: number, options?: { align?: "auto" | "center" | "end" | "start" }) => void,
149230
): void {
150231
if (event.key === "Enter" || event.key === " ") {
151232
event.preventDefault();
@@ -175,6 +256,7 @@ function handleCellKeyDown(
175256
const address = `${columnLabel(column)}${row}`;
176257
onSelect(address);
177258
scrollToRow(row - 1, { align: "auto" });
259+
scrollToColumn(column - 1, { align: "auto" });
178260
globalThis.requestAnimationFrame?.(() => {
179261
target = grid?.querySelector<HTMLElement>(`[role='gridcell'][data-row='${row}'][data-column='${column}']`);
180262
target?.focus();

packages/harness-studio/src/app/styles/workbench.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,7 @@
13551355
.xlsx-grid td.selected { position: relative; z-index: 1; outline: 2px solid var(--color-primary); outline-offset: -2px; }
13561356
.xlsx-grid td:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
13571357
.xlsx-grid .xlsx-virtual-spacer td { height: 0; padding: 0; border: 0; }
1358+
.xlsx-grid .xlsx-column-spacer { min-width: 0; padding: 0; border-right: 0; background: var(--color-document-paper); }
13581359
.xlsx-sheet-tabs { min-width: 0; padding: 0 var(--space-sm); display: flex; align-items: end; gap: 1px; overflow-x: auto; border-top: 1px solid var(--color-border); background: var(--color-panel); }
13591360
.xlsx-sheet-tabs button { height: calc(var(--pane-header-height) - 4px); min-width: 72px; padding: 0 var(--space-md); border: 0; border-top: 2px solid transparent; color: var(--color-text-muted); background: transparent; font: inherit; font-size: var(--type-meta-size); white-space: nowrap; }
13601361
.xlsx-sheet-tabs button:hover { color: var(--color-text); background: var(--color-surface-hover); }

packages/harness-studio/test/browser/artifact-host.spec.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,31 @@ test("renders a read-only XLSX snapshot with sheets, formulas, merges, and style
615615
expect(failures).toEqual([]);
616616
});
617617

618+
test("virtualizes wide XLSX columns and keeps the far edge selectable", async ({ page }) => {
619+
const failures = watchFailures(page);
620+
await page.setViewportSize({ width: 1024, height: 768 });
621+
try {
622+
await writeFile(join(artifactDirectory, "workbook.xlsx"), createXlsxFixture({ farColumn: 256 }));
623+
await openArtifacts(page);
624+
await page.getByRole("button", { name: /workbook\.xlsx/ }).click();
625+
const viewer = page.locator(".xlsx-artifact-viewer");
626+
await viewer.getByRole("button", { name: "Summary" }).click();
627+
await expect(viewer.locator(".xlsx-column-spacer").first()).toBeVisible();
628+
expect(await viewer.getByRole("gridcell").count()).toBeLessThan(100);
629+
await viewer.locator(".xlsx-grid-scroll").evaluate((element) => {
630+
element.scrollLeft = element.scrollWidth;
631+
element.dispatchEvent(new Event("scroll"));
632+
});
633+
const farCell = viewer.locator('[data-address="IV4"]');
634+
await expect(farCell).toBeVisible();
635+
await farCell.click();
636+
await expect(viewer.locator(".xlsx-formula-bar strong")).toHaveText("IV4");
637+
} finally {
638+
await writeFile(join(artifactDirectory, "workbook.xlsx"), createXlsxFixture());
639+
}
640+
expect(failures).toEqual([]);
641+
});
642+
618643
test("resets XLSX sheet and cell state to the new snapshot default", async ({ page }, testInfo) => {
619644
const failures = watchFailures(page);
620645
await page.setViewportSize({ width: 1440, height: 900 });

packages/harness-studio/test/xlsx-fixture.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface XlsxFixtureOptions {
55
workbookRelationshipTarget?: string;
66
formulaResult?: number;
77
farRow?: number;
8+
farColumn?: number;
89
}
910

1011
/** Produces real ZIP/OPC XLSX bytes with two sheets, formulas, merges, and styles. */
@@ -82,7 +83,7 @@ export function createXlsxFixture(options: XlsxFixtureOptions = {}): Uint8Array
8283
<x:sheetData>
8384
<x:row r="1" ht="30" customHeight="1"><x:c r="A1" s="1" t="str"><x:v>Studio XLSX Fixture</x:v></x:c><x:c r="B1" s="1"/></x:row>
8485
<x:row r="3"><x:c r="A3" t="str"><x:v>Planned</x:v></x:c><x:c r="B3" s="4" t="n"><x:f>SUM('Data'!B2:B3)</x:f><x:v>${formulaResult}</x:v></x:c></x:row>
85-
<x:row r="4"><x:c r="A4" t="str"><x:v>Completion</x:v></x:c><x:c r="B4" s="2" t="n"><x:v>0.75</x:v></x:c></x:row>
86+
<x:row r="4"><x:c r="A4" t="str"><x:v>Completion</x:v></x:c><x:c r="B4" s="2" t="n"><x:v>0.75</x:v></x:c>${options.farColumn === undefined ? "" : `<x:c r="${xlsxColumnLabel(options.farColumn)}4" t="str"><x:v>Virtualized column</x:v></x:c>`}</x:row>
8687
${options.farRow === undefined ? "" : `<x:row r="${options.farRow}"><x:c r="A${options.farRow}" t="str"><x:v>Virtualized row</x:v></x:c><x:c r="B${options.farRow}" t="n"><x:v>${options.farRow}</x:v></x:c></x:row>`}
8788
</x:sheetData>
8889
<x:mergeCells count="1"><x:mergeCell ref="A1:B1"/></x:mergeCells>
@@ -97,3 +98,13 @@ export function createXlsxFixture(options: XlsxFixtureOptions = {}): Uint8Array
9798
</x:worksheet>`),
9899
});
99100
}
101+
102+
function xlsxColumnLabel(column: number): string {
103+
let value = column;
104+
let label = "";
105+
while (value > 0) {
106+
label = String.fromCharCode(65 + (value - 1) % 26) + label;
107+
value = Math.floor((value - 1) / 26);
108+
}
109+
return label;
110+
}

0 commit comments

Comments
 (0)