Skip to content

feat(html): read a sheet in a quieter grid, under a ruler that stays put - #670

Draft
andiwand wants to merge 10 commits into
mainfrom
feat/html-sheet-chrome
Draft

feat(html): read a sheet in a quieter grid, under a ruler that stays put#670
andiwand wants to merge 10 commits into
mainfrom
feat/html-sheet-chrome

Conversation

@andiwand

@andiwand andiwand commented Aug 9, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

A spreadsheet rendered as the browser's default table: one weight of hairline
rule everywhere, Arial 10pt flush against the gridline, and a ruler of column
letters and row numbers that scrolled away with the data it labelled. It now has
a ruler that stays put and reads as chrome, lighter gridlines, room around the
text, canvas past the last row and column, a row and column wash under the
pointer, and a sort control per column. An archive, listed until now as a run of
<p>s in monospace, becomes a table: path, size in binary multiples, download
glyph.

Two things underneath. embed_shipped_resources and friends, left inert by #648,
work again — without them every stylesheet change rewrites every reference file.
And sheet_name read an attribute the worksheet part does not have, so every
xlsx sheet was nameless; it comes from the workbook now.

Worth a second opinion:

  • Sticky cells in a collapsed border model do not repaint their borders in Chrome
    or WebKit, so the ruler draws its separators with inset shadows and leaves
    border to the grid.
  • Column highlighting and sorting are both off for a sheet with a merged cell:
    the highlight is one generated :nth-child rule rather than a pass over every
    cell, which is what keeps it free on a large sheet and wrong under a colspan.
  • text-overflow:ellipsis was dropped, not fixed — it sat on td, whose
    overflow is visible, so it never did anything.

Still base64'd: every file of an archive, whole, into the listing.

Reference output regenerated and pinned. Full suite: 817 passed, 8 skipped, no
failures; new Document.xlsx_sheet_names and html.archive_listing. Highlight
and sort verified in Chrome against sampledatainsurance.xlsx.

andiwand and others added 9 commits August 9, 2026 14:07
Dropping odr.js made the css and js string constants written into every
document, which is what a self-contained view wants but not what a corpus of
them wants: the ~20 KB frontend is repeated in each file, and a one-character
stylesheet change rewrites all of them.

`HtmlConfig::embed_shipped_resources`, `resource_path`,
`relative_resource_paths` and `HtmlResource::is_shipped` were left accepted and
inert by that change. They mean again what they used to, except for what made
them painful: nothing has to be installed next to the library, because the
bytes are still the compiled-in constants. Each asset is registered as a
shipped `HtmlResource` over a `MemoryFile`, so the existing locator decides
between `<style>`/`<script>` and `<link>`/`<src>`, and `bring_offline` writes
the file out — the same path images have always taken.

`GlobalParams::odr_core_data_path` stays inert and stays on the list to remove;
it is about finding a data directory, which is the part that is gone for good.
`HtmlConfig` no longer seeds `resource_path` from it, so reviving the option
cannot silently aim output at it.

The reference-output suite links them from one `resources/` directory per test
repository, the layout it already had in the odr.js era, so that a change to
the frontend moves those files rather than every document under them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
The column letters and row numbers were plain `<td>`s carrying their alignment
as an inline style repeated on every row, so nothing in css or js could tell
the grid's ruler from its contents, and the sheet table itself was addressable
only as `table`.

They are `<th>`s in a `<thead>`/`<tbody>` now, under `.odr-sheet` with
`-corner`, `-column-header`, `-row-header` and a `-gutter` `<col>`, and the
constant styling moves into the stylesheet — which makes the output smaller,
since what was repeated per row is now written once. Only the row's own height
stays inline, being the one part that varies.

The letters and numbers get no `scope`: they label positions rather than the
contents of what they head, and a screen reader announcing "A" ahead of every
cell in a column would be noise.

Appearance is unchanged; this is the handle the styling and the scripts need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
The sheet was the browser's default table: hairline #c0c0c0 rules of the same
weight everywhere, Arial 10pt, text set flush against the gridline, and a ruler
that scrolled away with the content it labelled.

The ruler now sticks to the edges it labels, so the column letters and row
numbers stay put however far down or across a sheet goes — the one change that
makes a large sheet navigable at all. Sticky cells in a collapsed border model
do not repaint their borders in Chrome or WebKit, so it draws its own
separators with inset shadows and leaves `border` to the grid.

The rest is restraint: lighter gridlines, a firmer rule where the ruler meets
the grid, the ruler set smaller and greyer than the data so the eye skips it,
a system font stack, and 6px of horizontal padding so text no longer touches
the line beside it. Vertical padding stays at a hair, because a row's height is
the document's to state and cell padding would fight it.

The gutter is sized from the longest row number rather than the flat 30px that
truncated anything past four digits. Under `table-layout:fixed` the first row
sizes the columns, so the width goes on the corner cell, where `ch` resolves
against the ruler's own font.

A workbook opens as one file per sheet, so a view titled `odr` left every
browser tab of it identical; a sheet view is titled with its sheet's name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
`sheet_name` read a `name` attribute off the worksheet part's root element,
which has none — the name is on the `<sheet>` entry in `workbook.xml` that
points at the part. Every xlsx sheet was therefore nameless: `Sheet::name()`
returned "", the html views were named "" and the sheet titles came out blank.

The registry's `Sheet` carries the name now, set from that entry while the
workbook's children are parsed. `.ods` and `.xls` already did this correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
`body{padding:5px}` sat in the shared document stylesheet, so every view kept a
five pixel border against the window. On a sheet that is exactly the wrong
place for it: the ruler pins itself to the scrollport edge, and the padding
left a sliver of page showing outside it.

The rule goes, rather than being overridden per document type — paged content
already sets its own padding through `.odr-background`, so what it held back
was the two kinds of view that reflow.

Past the last row and column a sheet now shows canvas instead of more white,
which is where the grid ends rather than where the window does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
Reading a wide row meant tracking a line of cells by eye across a grid with no
markings, and there was no way to say "this one" at all.

The row and column under the pointer are now washed, and a click pins them —
on a cell, its row and its column; on a ruler cell, just what it labels. The
pinned cell is outlined, clicking it again or pressing Escape lets go. The
ruler washes harder than the cells, since the label is what the eye is looking
for at the end of a column.

A row is `tr:hover` in css. A column has no such selector, and walking every
cell of one on each pointer move would not survive a sheet of thousands, so a
single generated `:nth-child` rule lights the whole column at once — free per
cell, but only correct while cell and column line up. A sheet containing a
merged cell therefore gets the row half and not the column half, rather than a
column highlight pointing one cell off.

The wash goes on through `background-image`, which layers over whatever
background the document gave a cell instead of replacing it, and leaves
`box-shadow` to the ruler's separators.

This is the first script a spreadsheet view carries, so
`write_spreadsheet_script` joins the style beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
A rendered sheet could only be read in the order it was written in.

Each column header grows a control on hover that sorts by that column,
ascending, then descending, then back to the document's own order. The header
itself still pins the column, so a sort is never something a click lands on by
accident, and the control is the only thing that reorders anything.

Values sort the way a spreadsheet sorts them: numbers first, then text, then
blanks, with blanks last in both directions because a blank is the absence of a
value rather than the smallest one. That ordering is per cell rather than per
column, so a column of figures under a label still sorts by its figures. The
sort is stable, so equal values keep the order the document had them in.

The number behind a cell is not in the markup, only the text it rendered as, so
a cell the document calls numeric is read back with the later of `.` and `,`
taken as the decimal separator — enough for 1,234.56 and 1.234,56 without
knowing the locale — and anything that will not read back stays text.

The row numbers do not renumber: they say which row of the document this is,
which is the only thing that makes a sorted view legible.

A sheet with a merged cell gets no sort control at all. A `rowspan` reaches
into a row that would no longer be under it, and there is no honest way to
reorder around that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
An archive listed as a run of `<p>`s in monospace, each a row of `<span>`s
reading "path file 1048576 download" — the size in bytes with no separators,
the kind spelled out per entry, and no alignment between one line and the next.

It is a table now, because it always was one: a path, a size and a download
control, with a row under the pointer picking itself out and the sizes
right-aligned in tabular figures so they can be compared down the column. A
directory says so with the trailing separator every file listing has used and
carries no size, rather than spending a column on the distinction.

Sizes read as binary multiples and are named as such, so 1.4 MiB is not a
number whose base the reader has to guess.

Nothing in it is written in a language. There is no header row, because a
path, a size and a download arrow do not need to be labelled and the labels
would have been the only words on the page. Download is a glyph rather than the
word — U+2193, not one of the download arrows, which are missing from enough
system fonts to leave a tofu box where a control with no text has nothing to
fall back on. Its title is the file's name, which is what a tooltip and a
screen reader read and is not a word in anyone's language.

The listing gets its own stylesheet rather than the one rule it had inline, so
it travels the same way the rest of the frontend does.

No archive reaches the reference-output suite — every zip in the test data is
opened as the document inside it — so this comes with the test that renders one
by forcing the type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
11px was small enough that a column letter took a second look to read, which is
the opposite of what a ruler is for. The letters, the row numbers and the sheet
tabs go to 12px, and the header row grows the two pixels that keeps them off
its edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7
@andiwand andiwand changed the title Make a spreadsheet legible: a quieter grid, a ruler that stays put, and a way through it feat(html): read a sheet in a quieter grid, under a ruler that stays put Aug 9, 2026
Every html the generic renderer writes changed: the css and js are linked
rather than inlined, a sheet's ruler is `<thead>`/`<th>`, and an archive is a
table. The reference repos carry the regenerated output and a `filesystem.css`
that did not exist before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk1S12YmjBsmksSJ4tVB3X
@andiwand
andiwand force-pushed the feat/html-sheet-chrome branch from d24ec7f to 5fb7907 Compare August 9, 2026 12:25
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