Skip to content

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

Merged
andiwand merged 12 commits into
mainfrom
feat/html-sheet-chrome
Aug 9, 2026
Merged

feat(html): read a sheet in a quieter grid, under a ruler that stays put#670
andiwand merged 12 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 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
@andiwand
andiwand force-pushed the feat/html-sheet-chrome branch from d24ec7f to 5fb7907 Compare August 9, 2026 12:25
andiwand and others added 10 commits August 9, 2026 14:31
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
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 5fb7907 to d890ced Compare August 9, 2026 12:33
The renderer's stylesheets and scripts were `const char *`, so their length was
a `strlen` away wherever it was wanted. `std::string_view` carries it, and an
`Asset` field that names a mime type or a file name says so in its type.

The two places that need an owning string — `HtmlResource::create` and
`File::from_memory` — convert explicitly. Emitted output is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk1S12YmjBsmksSJ4tVB3X
@andiwand
andiwand marked this pull request as ready for review August 9, 2026 12:36

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d8d1bc46a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/odr/internal/html/frontend.cpp Outdated
`embed_shipped_resources=false` makes the filesystem, text and media views link
their stylesheets and scripts, but their services only ever answered for their
own view path — so `HttpServer` returned 404 and `write("filesystem.css")`
threw, and the view loaded unstyled. Only `bring_offline` worked, because it
writes the `HtmlResources` the render hands back.

Each of the three now locates its assets up front, without rendering, and
answers `exists`/`mimetype`/`write` for them. The document service already did
this off its warmup cache; the lookup all four share is `resource_at`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk1S12YmjBsmksSJ4tVB3X
@andiwand
andiwand merged commit 82eb08f into main Aug 9, 2026
29 checks passed
@andiwand
andiwand deleted the feat/html-sheet-chrome branch August 9, 2026 12:53
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