Skip to content

feat(table): row/column deletion actions + readable selected headers - #207

Merged
obvious-autobuild-staging[bot] merged 3 commits into
masterfrom
feat/table-delete-actions
Sep 8, 2026
Merged

feat(table): row/column deletion actions + readable selected headers#207
obvious-autobuild-staging[bot] merged 3 commits into
masterfrom
feat/table-delete-actions

Conversation

@obvious-autobuild-staging

@obvious-autobuild-staging obvious-autobuild-staging Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Why

Two defects from dogfooding the Glide table (PR #206): a selected column header renders as a solid inverted block — near-white fill with near-white text in dark mode, near-black on near-black in light — because buildTheme() mapped accentColor to the theme's inverted ink while textHeaderSelected sits in the same value range. And the table grew selection, editing, and fill, but no way to remove rows or columns: fixing a bad row means hand-editing the raw CSV, which is exactly the low-trust path the table was built to replace.

What

Selected-header contrast fix. The selected header now uses a wash treatment: a soft fill (bgHeaderHovered) plus a 2px accentColor bottom border, with a genuinely contrasting textHeaderSelected in both themes. Idle headers are pixel-identical to merged master (muted uppercase 11px mono, hairline bottom border) — only the selected-state mapping changed. The custom drawHeader callback (sort glyph, gutter #) keeps working and paints the wash for both data and gutter headers.

Row/column deletion — input table only. The output table renders through the same CsvTable, so every deletion affordance is gated on the editable instance.

  • Toolbar actions join the existing selection-driven strip (Delete N rows / Delete N columns, Trash2 icon, same dense chrome as Copy).
  • Keyboard Delete/Backspace routes through Glide's onDelete (6.0.3 signature verified: returns boolean | GridSelection; we return false so Glide's default cell-clearing mutation never runs — our handler owns the mutation).
  • Semantics: deletion mutates the parsed table (rows: drop the selected source rows; columns: drop the header plus each row's cell), then serializeCsvTablesetInput → the existing PR feat(output): editable JSON output with guarded regeneration #198 guarded reconversion — the identical path to a cell edit. Raw CSV text stays the single source of truth.
  • Source-row numbers renumber (positions in the source, consistent with cell-edit behavior); search/filter/sort re-derive from the new parse; deleting the column a sort was pinned to resets the sort.
  • Guard rails: deleting the last remaining column is blocked — disabled action with an explanatory tooltip (the serializer needs ≥1 column); deleting all rows is allowed and falls through to the existing empty-filter/empty state.
  • If the raw input carries hand edits the guard would clobber, deletion routes through the same guarded prompt as cell edits — never a silent clobber.

No new undo stack this increment. Recovery is the existing guard/revert affordances (the guarded prompt keeps the previous raw text; reload restores it).

How to review: app/src/lib/tableDeletion.ts (pure transforms), app/src/components/CsvTable.tsx (theme mapping, toolbar actions, onDelete), app/src/test/glideDataEditorMock.tsx (mock affordances). Intentionally excluded: output-table deletion (read-only by design), undo (deferred), GET endpoints (none added).

Test Evidence

Selected-header contrast, both themes: BEFORE (master 85651b0, pixel-verified) — dark fills the selected header near-white rgb(250,250,250) under muted-gray text (~1.7:1); light fills it near-black rgb(9,9,11) with same-value text. AFTER (a6845fe) — bgHeaderHovered wash with contrasting text: dark rgb(39,39,42)+rgb(161,161,170) ~4.6:1, light rgb(244,244,245)+rgb(113,113,122) ~4.5:1. Before shots use a programmatically dispatched column selection on the master build; selection verified via the '1 column selected' chip
Selected-header contrast, both themes: BEFORE (master 85651b0, pixel-verified) — dark fills the selected header near-white rgb(250,250,250) under muted-gray text (~1.7:1); light fills it near-black rgb(9,9,11) with same-value text. AFTER (a6845fe) — bgHeaderHovered wash with contrasting text: dark rgb(39,39,42)+rgb(161,161,170) ~4.6:1, light rgb(244,244,245)+rgb(113,113,122) ~4.5:1. Before shots use a programmatically dispatched column selection on the master build; selection verified via the '1 column selected' chip — before
Selected-header contrast, both themes: BEFORE (master 85651b0, pixel-verified) — dark fills the selected header near-white rgb(250,250,250) under muted-gray text (~1.7:1); light fills it near-black rgb(9,9,11) with same-value text. AFTER (a6845fe) — bgHeaderHovered wash with contrasting text: dark rgb(39,39,42)+rgb(161,161,170) ~4.6:1, light rgb(244,244,245)+rgb(113,113,122) ~4.5:1. Before shots use a programmatically dispatched column selection on the master build; selection verified via the '1 column selected' chip — before
Selected-header contrast, both themes: BEFORE (master 85651b0, pixel-verified) — dark fills the selected header near-white rgb(250,250,250) under muted-gray text (~1.7:1); light fills it near-black rgb(9,9,11) with same-value text. AFTER (a6845fe) — bgHeaderHovered wash with contrasting text: dark rgb(39,39,42)+rgb(161,161,170) ~4.6:1, light rgb(244,244,245)+rgb(113,113,122) ~4.5:1. Before shots use a programmatically dispatched column selection on the master build; selection verified via the '1 column selected' chip — after
Selected-header contrast, both themes: BEFORE (master 85651b0, pixel-verified) — dark fills the selected header near-white rgb(250,250,250) under muted-gray text (~1.7:1); light fills it near-black rgb(9,9,11) with same-value text. AFTER (a6845fe) — bgHeaderHovered wash with contrasting text: dark rgb(39,39,42)+rgb(161,161,170) ~4.6:1, light rgb(244,244,245)+rgb(113,113,122) ~4.5:1. Before shots use a programmatically dispatched column selection on the master build; selection verified via the '1 column selected' chip — after

Hand-edit guard: after editing the raw text ('White Blood Cells (hand edit)'), deleting a selected row splices into the live hand-edited text — the hand edit survives (verified in-pane) while the selected row is removed; no silent clobber
Hand-edit guard: after editing the raw text ('White Blood Cells (hand edit)'), deleting a selected row splices into the live hand-edited text — the hand edit survives (verified in-pane) while the selected row is removed; no silent clobber — before
Hand-edit guard: after editing the raw text ('White Blood Cells (hand edit)'), deleting a selected row splices into the live hand-edited text — the hand edit survives (verified in-pane) while the selected row is removed; no silent clobber — after

Last-column guard: on a one-column CSV the selected header shows a disabled 'Delete 1 column' action with the tooltip 'At least one column must remain — the converter needs a column to detect'
Last-column guard: on a one-column CSV the selected header shows a disabled 'Delete 1 column' action with the tooltip 'At least one column must remain — the converter needs a column to detect' — result

Column deletion: selecting the year header shows 'Delete 1 column'; the action removes the header and every row's cell, and the reconverted output no longer contains the year field
Column deletion: selecting the year header shows 'Delete 1 column'; the action removes the header and every row's cell, and the reconverted output no longer contains the year field — recording
Column deletion: selecting the year header shows 'Delete 1 column'; the action removes the header and every row's cell, and the reconverted output no longer contains the year field — after

Row deletion: selecting a row via the gutter shows 'Delete 1 row'; the action removes the source row, raw CSV updates, and the output reconverts (6 rows → 5)
Row deletion: selecting a row via the gutter shows 'Delete 1 row'; the action removes the source row, raw CSV updates, and the output reconverts (6 rows → 5) — recording
Row deletion: selecting a row via the gutter shows 'Delete 1 row'; the action removes the source row, raw CSV updates, and the output reconverts (6 rows → 5) — after

  • 438 tests green (28 files): 9 new unit tests for the deletion transforms (filtering, multi-select, headers-only output, RFC 4180 quoting, round-trips) and 13 new component tests (affordance gating, sorted-row deletion, column deletion, last-column guard, keyboard paths, selection clearing, empty-rows state), plus a new App-level guard test: deleting rows while the output is edited keeps the frozen output.
  • Canvas screen-reader limitation, stated honestly: Glide renders to canvas, so deleted rows/columns are not exposed to assistive technology as DOM table content — there are no invented ARIA roles to paper over this. The toolbar actions are real buttons with accessible names ("Delete 3 rows", "Delete 2 columns") and the last-column guard is announced via its title.

🔗 Obvious Project · 🧵 Obvious Thread

Input table only (output stays read-only): toolbar actions and
keyboard Delete/Backspace remove selected rows or columns from the
parsed table, serialize through the CSV serializer, and commit through
the same guarded reconversion path as a cell edit — raw text stays the
single source of truth. Deleting the last remaining column is blocked
with an explanatory tooltip; deleting all rows falls through to the
existing empty state; hand-edited raw routes through the guarded prompt
like any cell edit.

Selected column headers switch from the inverted-ink fill (white-on-
white / black-on-black) to a wash treatment with contrasting text in
both themes; idle header rendering is untouched.

Changelog entry 10; no new undo stack — recovery is the existing
guard/revert affordances.
@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review September 8, 2026 04:17

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT

Summary

  • Blocker: 0
  • High: 0
  • Medium: 1

Medium

  • app/src/components/CsvTable.tsx:915 — Empty-rows overlay renders in the read-only output pane with input-pane guidance.

View full review in Obvious

Comment thread app/src/components/CsvTable.tsx Outdated
</button>
</div>
)}
{!isFiltered && view.length === 0 && (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — The new empty-rows overlay has no editable gate, and the output pane renders this same component with no onCellCommit (OutputPane.tsx:184), so a headers-only CSV output — exactly what deleting all input rows produces — shows "No rows. Paste or upload data to start over." in a strictly read-only pane. On that surface the guidance is wrong: the output accepts neither paste nor upload. Gate it on editable ({editable && !isFiltered && view.length === 0 && ...}) to match the delete buttons, and assert output-table-empty-rows stays absent for headers-only output in the output suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9431833 — the empty-rows overlay is now gated on editable ({editable && !isFiltered && view.length === 0}), so the read-only output pane no longer shows input-pane guidance for headers-only output. Added the requested assertion: the output-style table (no onCellCommit) keeps csv-table-empty-rows absent while the editable input table still shows it when all rows are deleted.

Headers-only output — what deleting all input rows produces — rendered
"No rows. Paste or upload data to start over." in the read-only output
pane, which accepts neither paste nor upload. The overlay now renders
only for the editable input table, matching the delete buttons' gate.

Review finding: obvious-autobuild-staging[bot] Medium · reliability
(PR #207). Adds the output-pane absence assertion it asked for.
@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Fixed the Medium finding in 9431833: the empty-rows overlay is gated on editable, so headers-only output in the read-only pane shows no input-pane guidance; added the output-pane absence assertion as requested. All 439 tests green.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 2 (9431833)

Verdict: COMMENT

Pass 1's Medium (empty-rows overlay rendered in the read-only output pane) is resolved — CsvTable.tsx:918 now gates on editable, and the rebuilt dist matches.

Summary

  • Blocker: 0
  • High: 0
  • Medium: 1 (new)

Medium

  • app/src/components/CsvTable.test.tsx:539 — The new regression test is vacuous: the harness fixture has 2 data rows, so csv-table-empty-rows is absent under both the old and new code; the test does not pin the editable gate it was added for.

View full review in Obvious

Comment thread app/src/components/CsvTable.test.tsx Outdated
});

it("keeps the empty-rows overlay out of the read-only output table", () => {
render(<DeletionHarness initial={"album\nDe Stijl\nElephant"} editable={false} />);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — This test does not pin the gate it was added for: initial={"album\nDe Stijl\nElephant"} yields headers plus 2 data rows, so the overlay (view.length === 0) is absent under the old ungated code too — reverting the editable gate would still pass. Exercise the guarded path with a headers-only fixture: render(<DeletionHarness initial={"album"} editable={false} />) then assert queryByTestId("csv-table-empty-rows") is null. The editable=true + 0-rows presence case is already covered by "falls through to the empty state when every row is deleted".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair catch — the 2-row fixture made the test vacuous. Fixed in a6845fe: the fixture is now headers-only ("album"), which makes the ungated code render the overlay (I verified by reverting the gate locally: the test fails without it, passes with it), and the output table keeps it absent only under the gate. Also asserts the headers still render for the headers-only shape.

The regression test used a 2-row fixture, so csv-table-empty-rows was
absent under the ungated code too — reverting the editable gate would
still pass. A headers-only fixture makes the ungated code render the
overlay (verified: reverting the gate fails this test), and the output
table keeps it absent only under the gate.
@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Fixed the vacuous-test finding in a6845fe: the fixture is now headers-only ("album"), so the ungated code would render the overlay there (verified locally — reverting the gate fails the test), and the read-only output table keeps it absent only under the editable gate. 439 tests green.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 3 (a6845fe)

Verdict: COMMENT

No findings — clean pass. Pass 2's Medium (the regression test did not pin the editable gate) is resolved: the fixture is now headers-only ("album", so view.length === 0), with a comment explaining the vacuity trap and a headers sanity assert so the fixture cannot silently degenerate again. Against ungated code this test would now fail.

Verification

Full test suite run at this head in a clean worktree with fresh install: 28 files / 439 tests, all passing.

Summary

  • Blocker: 0
  • High: 0
  • Medium: 0

View full review in Obvious

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Thanks — Pass 3 acknowledged. Good catch on the fixture degeneration guard; the headers-only fixture plus sanity assert is the right pin. No further changes needed from this pass.

@obvious-autobuild-staging
obvious-autobuild-staging Bot merged commit 9a1579a into master Sep 8, 2026
3 checks passed
@obvious-autobuild-staging
obvious-autobuild-staging Bot deleted the feat/table-delete-actions branch September 8, 2026 04:56
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