Merge pull request #57 from fiddlerwoaroof/fix/web-review-findings
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix(web): land the review findings main is missing
This commit is contained in:
@@ -923,6 +923,84 @@ because Product then shares the column axis with `_Measure`, so the label
|
|||||||
appears once per header level — which is itself a small confirmation that
|
appears once per header level — which is itself a small confirmation that
|
||||||
gotcha 44's multi-level frame reaches the browser.
|
gotcha 44's multi-level frame reaches the browser.
|
||||||
|
|
||||||
|
**Three review findings, applied.** The second is the one worth carrying.
|
||||||
|
|
||||||
|
**The page had two spellings of the same triangle, inside the file that
|
||||||
|
declares it has one.** `indicator()` carries the comment *"only this spelling is
|
||||||
|
the page's (gotcha 26)"* — singular — and `categoryList` did not call it: it
|
||||||
|
inlined U+25B8/U+25BE (the SMALL triangles) where `indicator()`, the page's own
|
||||||
|
grid group headers and `src/ui/category_panel.rs` all draw U+25B6/U+25BC. So the
|
||||||
|
browser's category panel disagreed with the TUI's *and* with its own grid.
|
||||||
|
`categoryList` now calls `indicator(!row.expanded)`.
|
||||||
|
|
||||||
|
**And the first version of this spec pinned the wrong spelling.** It asserted
|
||||||
|
`/▸ Product/` and `▾` — the divergent pair — so the obvious cure, which the
|
||||||
|
file's own comment implies should already be in place, would have *failed a test
|
||||||
|
this work added*. That is this repo's characteristic defect exactly: not a wrong
|
||||||
|
line, a test that cements a state the doctrine says is wrong, and it makes the
|
||||||
|
cure more expensive the longer it sits. The spec now asserts `▶`/`▼`, and the
|
||||||
|
change was proved discriminating by running it against the pre-fix bundle, where
|
||||||
|
it fails with `Received string: "▸ Product (4) R"`.
|
||||||
|
|
||||||
|
Note what this does **not** settle: whether glyphs should move to the protocol
|
||||||
|
the way `short_label()` did. That is **improvise-h04z**, not answered here —
|
||||||
|
what is fixed is the page contradicting itself. `viewRow` still spells `"▶ "`
|
||||||
|
as a literal duplicating `src/ui/view_panel.rs:62`; the two agree today, which
|
||||||
|
is precisely the state `categoryList` was in before it drifted.
|
||||||
|
|
||||||
|
**And the drift was already spreading, which is the evidence that ticket needs.**
|
||||||
|
`harness/tests/pointer.spec.js`, written on a different branch, had
|
||||||
|
*independently* grown the same `▸`/`▾` assertions — so the corrected
|
||||||
|
`indicator()` broke it too. Three files converging on a spelling nothing
|
||||||
|
compares is the failure design-principles §5 predicts, observed rather than
|
||||||
|
argued, and it is what answers "is one duplicated literal worth a protocol
|
||||||
|
change" with data instead of doctrine.
|
||||||
|
|
||||||
|
**`el.tiles.hidden` was a no-op**, removed with its comment. `#tiles` declares
|
||||||
|
`display: flex`, an author-origin rule that beats the UA sheet's
|
||||||
|
`[hidden] { display: none }` at any specificity, so the guard never hid
|
||||||
|
anything and "an empty tile list draws nothing at all" was false. `#views`,
|
||||||
|
`#formulas` and `#help` declare no `display` and so are genuinely hidden by the
|
||||||
|
attribute. Worth knowing before anyone "fixes" it: because `#tiles` is always
|
||||||
|
laid out, its height never changes, so it feeds nothing back into the
|
||||||
|
`ResizeObserver` — restoring the guard would introduce exactly the first-paint
|
||||||
|
height change the observer argument assumes away.
|
||||||
|
|
||||||
|
**The `ResizeObserver` loop is no longer "argued, not tested", and the proof is
|
||||||
|
worth more than the measurement.** It was measured: 820 combinations — four
|
||||||
|
panel states (none / views / formulas / tile-select) × heights 360-440 in 2px
|
||||||
|
steps × five widths straddling the `COL_PX = 110` boundary, each counting
|
||||||
|
`#grid` child replacements in a 500ms quiet window. Every size settled; nothing
|
||||||
|
repainted twice. But the reason is structural, so the measurement is a check on
|
||||||
|
it rather than the evidence itself: **the `frameCost` dependency graph is
|
||||||
|
acyclic.** `frameCost.cols` is `row_levels`, the number of row-axis header
|
||||||
|
*levels*, which does not depend on how many rows are projected.
|
||||||
|
`frameCost.rows` derives from `col_levels.length` and `col_groups`, which depend
|
||||||
|
on the projected *column* window, which depends on width and `frameCost.cols`.
|
||||||
|
So `cols → rows`, and nothing depends on `rows`. There is no cycle to oscillate.
|
||||||
|
A change that made either term depend on the projected row count would create
|
||||||
|
one, and that — not the pixel arithmetic — is the thing to check.
|
||||||
|
|
||||||
|
**A harness port collision can make a run test somebody else's tree, silently.**
|
||||||
|
`playwright.config.js` hard-coded 8099 with `reuseExistingServer: true`, so a
|
||||||
|
`python3 -m http.server` left by another worktree is *adopted*, and the run then
|
||||||
|
exercises that checkout with no warning. The port is now
|
||||||
|
`IMPROVISE_HARNESS_PORT`, defaulting to 8099 so CI is unchanged. The tell is a
|
||||||
|
result that disagrees with the file you just read: check
|
||||||
|
`lsof -a -p <pid> -d cwd` on the listener before believing it.
|
||||||
|
|
||||||
|
**It fires in both directions, and the dangerous one is the false PASS.** Two
|
||||||
|
agents hit it in one session. One got a false *negative* — a glyph fix reported
|
||||||
|
absent, because the adopted tree did not have it — which is merely confusing,
|
||||||
|
since a failure sends you looking. The other got `widgets.spec.js` reporting
|
||||||
|
**5 passed against a tree it had content-verified as having no `#tiles` and no
|
||||||
|
`paintTiles` at all**, which is impossible and was caught only because that
|
||||||
|
agent knew what the tree contained and refused to believe the number. **A false
|
||||||
|
pass prompts nobody.** That is what makes a hard-coded port with
|
||||||
|
`reuseExistingServer` a correctness bug rather than an inconvenience, and it is
|
||||||
|
the same rule as the rest of this file: a green result is evidence only once you
|
||||||
|
know what it measured.
|
||||||
|
|
||||||
### a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row
|
### a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row
|
||||||
|
|
||||||
improvise-7ybz's whole difficulty in one assertion, and the reason the click
|
improvise-7ybz's whole difficulty in one assertion, and the reason the click
|
||||||
@@ -996,3 +1074,66 @@ the panel** (`PanelKeymap`'s Esc is `enter-mode normal`, nothing more). That is
|
|||||||
the only state in which the "focus" third of the click is observable — from a
|
the only state in which the "focus" third of the click is observable — from a
|
||||||
freshly opened panel the mode is already `CategoryPanel` and the assertion
|
freshly opened panel the mode is already `CategoryPanel` and the assertion
|
||||||
would pass without the focus commands existing at all.
|
would pass without the focus commands existing at all.
|
||||||
|
|
||||||
|
#### harness/tests/drill.spec.js — the file's own scope
|
||||||
|
|
||||||
|
The fourth widget the user reported, and the one with a **different answer from
|
||||||
|
the other three**. The tile bar, view panel and formula panel were each one
|
||||||
|
`RenderModel` field short — cheap. Drill is not: **there is no wire vocabulary
|
||||||
|
for it**, which is improvise-gd28, and these tests document the refusal rather
|
||||||
|
than pretending the gap is a bug to be fixed here.
|
||||||
|
|
||||||
|
**Where it stops, by layer.** Everything below the command exists and was
|
||||||
|
verified in the browser, not read: `project::grid_of` emits `CellId::Aggregate`,
|
||||||
|
the client receives it (`{"Aggregate":"750ab1f5ffa96d5e…"}` in `render_state`),
|
||||||
|
`render.rs::editable` correctly answers false for it, and `resolve.rs` reaches
|
||||||
|
the aggregate arm. What is missing is one link: `reduce.rs` contains **no
|
||||||
|
`Drill` variant at all** — not `Command`, not `ModelCommand`. The seam is ready
|
||||||
|
and the sentence is not sayable.
|
||||||
|
|
||||||
|
**Why it was left out is a decision, not an oversight** (gotcha 38): drilling
|
||||||
|
creates the `_Drill` view and writes `drill_state`, which gotchas 30 and 1 rule
|
||||||
|
**session-local** — `pending_edits` are keyed by `(row, RecordCol)` and belong
|
||||||
|
to whoever staged them. A `Command::Model` is routed with **no `SessionId`**,
|
||||||
|
which is right exactly when a change is addressed to *no one* session; drill is
|
||||||
|
addressed to precisely one. That is the same argument that removed
|
||||||
|
`ApplyAndClearDrill` from the wire in improvise-3x3u, so adding
|
||||||
|
`ModelCommand::Drill` would reintroduce the bug that removal fixed. It needs the
|
||||||
|
**addressed** form — ADR 001 layer 2.
|
||||||
|
|
||||||
|
**The two entry points fail differently, and that asymmetry is the finding.**
|
||||||
|
`i`/`a` on an aggregate declines **out loud**: `edit_or_drill` answers the
|
||||||
|
`CellId::Aggregate` arm with a status the user can read. But `>`
|
||||||
|
(`drill-into-cell`) is a name `resolve::step` does not handle, so it becomes
|
||||||
|
`KeyOutcome::Upstream` → `ClientFrame::Key` → `Host::handle`, whose `Key` arm is
|
||||||
|
an explicit empty vec — **a black hole, not a slow path** (gotcha 47). Pressing
|
||||||
|
`>` in the browser does nothing whatsoever: no status, no mode change, nothing.
|
||||||
|
That is why this reads as "never confirmed working" rather than as a refusal.
|
||||||
|
|
||||||
|
**The TUI is the control and it works**: 12 drill tests pass across
|
||||||
|
`cmd/grid.rs`, `cmd/mode.rs`, `ui/effect.rs` and `ui/app.rs`. So this is
|
||||||
|
isolated to the client/wire, not a general regression.
|
||||||
|
|
||||||
|
**A fixture fact that is itself part of the answer.** `web/demo.improv` has **no
|
||||||
|
aggregate cell at all** — gotcha 46 puts exactly one category on each visible
|
||||||
|
axis precisely so every cell is a `Record`, and `tests/web_demo_model.rs`
|
||||||
|
asserts it. So on the shipped demo a user cannot even reach the refusal without
|
||||||
|
first changing an axis. `anAggregateGrid` therefore *makes* one, through the
|
||||||
|
tile bar, the way a user would: `Quarter` cycles Page → None and every cell
|
||||||
|
becomes an aggregate. The precondition is then **asserted** (`td.editable`
|
||||||
|
count 0) rather than assumed, so neither test can pass against a grid of
|
||||||
|
Records.
|
||||||
|
|
||||||
|
**Both injections, run.** Replacing the aggregate arm with `None` — silencing
|
||||||
|
the honest refusal — fails **both** tests, and the second fails on its
|
||||||
|
*`check_the_checker` control*, which is the point: with nothing answering, the
|
||||||
|
control can no longer tell "`>` is silent" from "the page is dead", and the test
|
||||||
|
correctly refuses to pass. Adding `"drill-into-cell" => Some(vec![SetStatus(…)])`
|
||||||
|
to `step` — making `>` resolve — fails **only** the second test and leaves the
|
||||||
|
first green. Two faults, two different failure signatures, neither test passing
|
||||||
|
for the other's reason.
|
||||||
|
|
||||||
|
That control is load-bearing and not decoration: a test asserting *nothing
|
||||||
|
happened* passes against a page that has stopped listening, so it presses `i`
|
||||||
|
afterwards and requires `#status` to change. Without that, this file would be
|
||||||
|
folklore with a green tick.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
// The one place a line of web/*.js is executed, and where a change to either
|
// The one place a line of web/*.js is executed, and where a change to either
|
||||||
// file belongs. Why, and why Playwright: context/code/clients.md.
|
// file belongs. Why, and why Playwright: context/code/clients.md.
|
||||||
|
// `reuseExistingServer` will happily adopt a server another checkout started on
|
||||||
|
// this port, and then you test THAT tree. See context/code/clients.md.
|
||||||
|
const PORT = process.env.IMPROVISE_HARNESS_PORT ?? "8099";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@@ -11,7 +15,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? [["github"], ["list"]] : "list",
|
reporter: process.env.CI ? [["github"], ["list"]] : "list",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://127.0.0.1:8099",
|
baseURL: `http://127.0.0.1:${PORT}`,
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
},
|
},
|
||||||
// Chromium + Firefox run the OPFS battery; WebKit has no OPFS at all and is
|
// Chromium + Firefox run the OPFS battery; WebKit has no OPFS at all and is
|
||||||
@@ -30,10 +34,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "python3 -m http.server 8099 --bind 127.0.0.1 --directory serve",
|
command: `python3 -m http.server ${PORT} --bind 127.0.0.1 --directory serve`,
|
||||||
// The APP, not the site root — since improvise-kh8 the root is the landing
|
// The APP, not the site root — since improvise-kh8 the root is the landing
|
||||||
// page, which would answer 200 with no bundle assembled at all.
|
// page, which would answer 200 with no bundle assembled at all.
|
||||||
url: "http://127.0.0.1:8099/app/index.html",
|
url: `http://127.0.0.1:${PORT}/app/index.html`,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Drill in the browser: what it does today, which is refuse. improvise-gd28
|
||||||
|
// says why, and context/test-evidence/clients.md records the layer it stops at.
|
||||||
|
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/// `web/demo.improv` has NO aggregate cell by construction — gotcha 46 puts
|
||||||
|
/// exactly one category on each visible axis so every cell is a Record, and
|
||||||
|
/// `tests/web_demo_model.rs` asserts it. So a drill test must MAKE one first.
|
||||||
|
async function anAggregateGrid(page) {
|
||||||
|
await page.goto("/app/index.html");
|
||||||
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||||
|
await page.locator("#grid").focus();
|
||||||
|
await expect(page.locator("#grid td.editable").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Quarter is on Page; one cycle takes it to None, so every cell aggregates
|
||||||
|
// over the four quarters. The tile bar is how a user would do it.
|
||||||
|
await page.keyboard.press("Shift+T");
|
||||||
|
const tiles = page.locator("#tiles .tile");
|
||||||
|
const quarter = tiles.filter({ hasText: "Quarter" });
|
||||||
|
await expect(quarter).toHaveText("Quarter Pag");
|
||||||
|
const names = await tiles.allTextContents();
|
||||||
|
for (let i = 0; i < names.findIndex((t) => t.startsWith("Quarter")); i++) {
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
}
|
||||||
|
await expect(quarter).toHaveClass(/selected/);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(quarter).toHaveText("Quarter ·", { timeout: 10_000 });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
// The precondition, asserted rather than assumed: nothing is editable now,
|
||||||
|
// which is `CellId::Aggregate` reaching `render.rs::editable` (ADR 002).
|
||||||
|
await expect(page.locator("#grid td.editable")).toHaveCount(0);
|
||||||
|
await expect(page.locator("#grid tbody td").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("drill is refused in the browser, not broken", () => {
|
||||||
|
// The honest half: `i`/`a` on an aggregate declines OUT LOUD, because
|
||||||
|
// `edit_or_drill` answers the `CellId::Aggregate` arm with a status.
|
||||||
|
test("editing an aggregate says drill needs the host", async ({ page }) => {
|
||||||
|
await anAggregateGrid(page);
|
||||||
|
|
||||||
|
await page.keyboard.press("i");
|
||||||
|
await expect(page.locator("#status")).toContainText(
|
||||||
|
"drilling into an aggregate needs the host",
|
||||||
|
);
|
||||||
|
// And it really is a refusal: no editor opened, no mode change.
|
||||||
|
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||||
|
await expect(page.locator("#grid input")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The silent half, and the one worth pinning. `>` is `drill-into-cell`,
|
||||||
|
// which `resolve::step` does not name, so it becomes `KeyOutcome::Upstream`
|
||||||
|
// -> `ClientFrame::Key` -> `Host::handle`, whose Key arm is an empty vec
|
||||||
|
// (gotcha 47). An upstream key is a black hole, not a slow path.
|
||||||
|
test("the drill key does nothing at all, and the page is not merely dead", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await anAggregateGrid(page);
|
||||||
|
const before = await page.locator("#status").textContent();
|
||||||
|
|
||||||
|
await page.keyboard.press(">");
|
||||||
|
// Nothing moves: not the mode, not the status, not the grid.
|
||||||
|
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||||
|
await expect(page.locator("#status")).toHaveText(before);
|
||||||
|
await expect(page.locator("#grid td.editable")).toHaveCount(0);
|
||||||
|
|
||||||
|
// check_the_checker: a test that asserts "nothing happened" passes against
|
||||||
|
// a page that has stopped listening. This proves the keyboard is live, so
|
||||||
|
// `>`'s silence is specific to `>` — the refusal below DOES change #status.
|
||||||
|
await page.keyboard.press("i");
|
||||||
|
await expect(page.locator("#status")).not.toHaveText(before);
|
||||||
|
await expect(page.locator("#status")).toContainText(
|
||||||
|
"drilling into an aggregate needs the host",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ test.describe("clicking the category panel", () => {
|
|||||||
await openPanelThenLeave(page);
|
await openPanelThenLeave(page);
|
||||||
|
|
||||||
const product = page.locator("#categories li").filter({ hasText: "Product" });
|
const product = page.locator("#categories li").filter({ hasText: "Product" });
|
||||||
await expect(product).toHaveText(/▸ Product \(4\)/);
|
await expect(product).toHaveText(/▶ Product \(4\)/);
|
||||||
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
|
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
|
||||||
|
|
||||||
await product.click();
|
await product.click();
|
||||||
@@ -57,7 +57,7 @@ test.describe("clicking the category panel", () => {
|
|||||||
// All three, and the third is what the user reported as impossible.
|
// All three, and the third is what the user reported as impossible.
|
||||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||||
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
|
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
|
||||||
await expect(product).toHaveText(/▾ Product \(4\)/);
|
await expect(product).toHaveText(/▼ Product \(4\)/);
|
||||||
await expect(page.locator("#categories li.item")).toHaveText([
|
await expect(page.locator("#categories li.item")).toHaveText([
|
||||||
"Espresso",
|
"Espresso",
|
||||||
"Latte",
|
"Latte",
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ test.describe("the category panel", () => {
|
|||||||
await page.keyboard.press("Shift+C");
|
await page.keyboard.press("Shift+C");
|
||||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||||
// web/demo.improv: _Index, _Dim, _Measure, Product, Quarter. Product is
|
// web/demo.improv: _Index, _Dim, _Measure, Product, Quarter. Product is
|
||||||
// the first with items a user would recognise.
|
// the first with items a user would recognise. The glyphs are
|
||||||
|
// `indicator()`'s, which is the page's ONE spelling and the TUI's too.
|
||||||
for (let i = 0; i < 3; i++) await page.keyboard.press("j");
|
for (let i = 0; i < 3; i++) await page.keyboard.press("j");
|
||||||
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▸ Product \(4\)/);
|
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▶ Product \(4\)/);
|
||||||
|
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▾ Product \(4\)/);
|
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▼ Product \(4\)/);
|
||||||
await expect(page.locator("#categories li.item")).toHaveText([
|
await expect(page.locator("#categories li.item")).toHaveText([
|
||||||
"Espresso",
|
"Espresso",
|
||||||
"Latte",
|
"Latte",
|
||||||
|
|||||||
+4
-3
@@ -427,9 +427,8 @@ function formulaRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The tile bar. `selected` is only ever true in tile-select mode, which is
|
/** The tile bar. `selected` is only ever true in tile-select mode, which is
|
||||||
* decided in Rust; an empty tile list draws nothing at all. */
|
* decided in Rust. The strip is always laid out — context/code/clients.md. */
|
||||||
function paintTiles(model) {
|
function paintTiles(model) {
|
||||||
el.tiles.hidden = model.tiles.length === 0;
|
|
||||||
const tiles = model.tiles.map((tile, index) => {
|
const tiles = model.tiles.map((tile, index) => {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "tile";
|
span.className = "tile";
|
||||||
@@ -447,8 +446,10 @@ function categoryList(model) {
|
|||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = row.is_category ? "category" : "item";
|
li.className = row.is_category ? "category" : "item";
|
||||||
li.classList.toggle("selected", row.selected);
|
li.classList.toggle("selected", row.selected);
|
||||||
|
// `indicator`, not a second pair of triangles: this inlined U+25B8/U+25BE
|
||||||
|
// where the TUI and the page's own group headers draw U+25B6/U+25BC.
|
||||||
li.textContent = row.is_category
|
li.textContent = row.is_category
|
||||||
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
|
? `${indicator(!row.expanded)} ${row.label} (${row.item_count}) ${row.axis}`
|
||||||
: row.label;
|
: row.label;
|
||||||
li.addEventListener("click", () => send(client.click_category(index)));
|
li.addEventListener("click", () => send(client.click_category(index)));
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
|
|||||||
Reference in New Issue
Block a user