fix(web): an error cell must not lose its colour to the dimming or the selection

Found by reviewing the restyling before pushing it, and demonstrated before
it was fixed.

`td.error` and `td:not(.editable)` used to set DIFFERENT properties — colour
and opacity — so both applied and a dimmed error cell was a dimmed red. The
restyling made the dimming a colour too, at the same specificity and below
`td.error` in the file, so an error in an aggregate cell rendered plain grey:
rgb(107, 103, 95) where the error colour is rgb(168, 50, 45). `td.selected`
and `td.typing` did the same by setting `color: var(--fg)`.

`#grid td.error` is the only place on this page where colour carries MEANING
rather than emphasis, and it is the one a reader takes at face value: a
circular reference or a `mixed` aggregate reading as an ordinary number is
worse than it reading as nothing.

So the dimming moves ABOVE the semantic colours, and a selection is a ring
and a tint and never a colour. That is the class of defect a stylesheet has
and a program does not — two rules that were independent become one property,
and nothing in any language fails.

Also: an expanded notice now caps at 40vh and scrolls. `main` is free to
shrink to nothing, so on a short viewport opening it could have pushed the
grid out entirely.

The test is aimed at the STYLESHEET rather than at a document, because the
question is which rule wins and a fixture per combination would be three
documents testing three documents. It appends a <td> carrying each
combination to the real page's real table and compares two controls taken the
same way, rather than pinning absolute colours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 22:33:02 -07:00
parent 8ad5c0806d
commit 52f615d14d
4 changed files with 103 additions and 8 deletions
+12
View File
@@ -1726,6 +1726,18 @@ stylesheet rather than the page working out which row is selected. And
`.notice-toggle`'s glyph is chosen by `[open]`, which is the one piece of state
on this page that genuinely is the browser's and that nothing else knows.
**One rule in it is load-bearing and is the stylesheet's own failure mode.**
`td.error` is the only place on this page where colour carries *meaning*
rather than emphasis — a circular reference or a `mixed` aggregate reading as
an ordinary number is worse than it reading as nothing. It used to coexist with
the dimming of unsettable cells because they set **different** properties
(`color` against `opacity`); once both are `color` at the same specificity,
only source order separates them, and nothing in any language fails when the
order is wrong. So the dimming sits **above** the semantic colours, and a
selection is a ring and a tint and never a colour. That is the class of defect
a stylesheet has and a program does not, and it is why the one assertion this
file makes about colour is about which rule wins rather than about a value.
**The harness cannot see any of this**, and that is the honest scope. Playwright
asserts what a browser can answer: that the notice starts collapsed and
remembers, that it opens from the keyboard, and that the minibuffer is inside
+34
View File
@@ -3257,3 +3257,37 @@ body's before and is now strictly larger.
The precondition matters: `#mode` must read `INSERT` before anything is
measured, because `getBoundingClientRect` on a hidden element returns zeroes
and every comparison below would then be about the origin.
### "an error cell stays an error cell when it is also dimmed or selected"
Found reviewing the styling change that introduced it, before it was pushed.
`td.error` and `td:not(.editable)` used to set **different** properties —
`color: var(--error)` and `opacity: 0.7` — so both applied and a dimmed error
cell was a dimmed red. The restyling made the dimming a `color` too, at the
same specificity, and put it **after** `td.error` in the file, so an error in
an aggregate cell rendered plain grey: measured `rgb(107, 103, 95)` where the
error colour is `rgb(168, 50, 45)`. `td.selected` and `td.typing` had the same
effect by setting `color: var(--fg)`, which is how a *selected* error lost its
colour as well.
That is the whole class of defect a stylesheet has and a program does not:
two rules that were independent become one property, and nothing in any
language fails. `#grid td.error` is the only place in this page where colour
carries meaning rather than emphasis, and it is the one a reader takes at face
value — a circular reference or a `mixed` aggregate reading as an ordinary
number is worse than it reading as nothing.
The fix is ordering plus a rule: the dimming goes **above** the semantic
colours, and a selection is a ring and a tint and never a colour. The test is
aimed at the stylesheet rather than at a document, deliberately — the question
is which rule wins, and a fixture per combination (a dimmed error, a selected
error, an error being typed over) would be three documents testing three
documents. It appends a `<td>` carrying each combination to the real page's
real table, reads `getComputedStyle`, and compares against two controls taken
the same way: a plain unsettable cell (what it must not be) and an error the
dimming cannot reach (what it must be). Reading absolute colours instead would
pin the palette, which is not the property.
Inject the fault by moving the `td:not(.editable)` block back below
`td.error`, or by restoring either `color: var(--fg)`; each makes exactly the
corresponding assertion fail.
+45
View File
@@ -505,3 +505,48 @@ test.describe("the shell puts the application first", () => {
await expect(page.locator("#minibuffer")).toBeHidden();
});
});
// The stylesheet's own cascade, which no other test here can reach: an
// error is a colour now, where it used to be a colour AND an opacity, so
// the order of two equally-specific rules decides it. See
// context/test-evidence/clients.md.
test.describe("the grid's semantic colours survive the cascade", () => {
test("an error cell stays an error cell when it is also dimmed or selected", async ({
page,
}) => {
await page.goto("/app/index.html?doc=error-colour.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
// The question is about the STYLESHEET, so the combinations are put to it
// directly: the document that produces each of them is a separate matter,
// and a fixture per combination would test the fixture.
const colours = await page.evaluate(() => {
const row = document.querySelector("#grid tbody tr");
const read = (classes) => {
const td = document.createElement("td");
td.className = classes;
td.textContent = "#err";
row.appendChild(td);
const colour = getComputedStyle(td).color;
td.remove();
return colour;
};
return {
// An aggregate error, which is the common case: `editable` is false
// for every cell that is not a stored record.
dimmed: read("error"),
selected: read("error selected"),
typing: read("error typing"),
// What "dimmed" must NOT be, read off a plain unsettable cell.
dim: read(""),
// …and what it must be, read off an error the dimming cannot reach.
error: read("error editable"),
};
});
expect(colours.error).not.toBe(colours.dim);
expect(colours.dimmed).toBe(colours.error);
expect(colours.selected).toBe(colours.error);
expect(colours.typing).toBe(colours.error);
});
});
+12 -8
View File
@@ -203,6 +203,10 @@ header h1 {
}
.notice-body {
/* Expanded, this must not take the whole window: `main` is free to shrink
to nothing, so a short viewport would lose the grid entirely. */
max-height: 40vh;
overflow: auto;
padding: 0 0.9rem 0.6rem;
max-width: 100ch;
color: var(--dim);
@@ -391,6 +395,14 @@ td.numeric {
text-align: right;
}
/* Everything else on the grid is an aggregate, and dimming it is what makes
the settable cells read as the surface you can act on. */
td:not(.editable) {
color: var(--dim);
}
/* …and the semantic colours come AFTER it, because they are now the same
property at the same specificity and only the order separates them. */
td.error {
color: var(--error);
font-weight: 600;
@@ -405,12 +417,6 @@ td.editable:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
/* Everything else on the grid is an aggregate, and dimming it is what makes
the settable cells read as the surface you can act on. */
td:not(.editable) {
color: var(--dim);
}
/* The selected cell, and the row header that names it. `:has()` is what lets
the header light up without the page deriving which row is selected — the
selection is already on the cell, decided in Rust. */
@@ -418,7 +424,6 @@ td.selected {
background: var(--accent-soft);
outline: 2px solid var(--accent);
outline-offset: -2px;
color: var(--fg);
font-weight: 700;
}
@@ -434,7 +439,6 @@ td.typing {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: var(--accent-soft);
color: var(--fg);
font-weight: 700;
}