diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 979b039e..30e15357 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,7 @@ {"_type":"issue","id":"improvise-dj1l","title":"kb-merge loses and corrupts clauses: keys are matched by unification","description":"Found by review of PR #68 after it merged. Two faults with one root: scripts/kb-merge.pl matches clause keys by UNIFICATION rather than variant equality.\n\n1. Intra-delta loss. check_self_consistent compares keys with ==, but findall hands back fresh variables for every collected key, so two clauses with variant heads are never == and the duplicate gate passes. foldl_merge then matches by unification, so the second clause removes the first. Two clauses in, one out, reported as success. Reproduced: a delta of two rules over one predicate went 536 before, 2 in delta, 537 after.\n\n2. Destruction and MUTATION. Adding 'probe_fact(X) :- doctrine(X,_).' to a kb holding probe_fact(a). and probe_fact(b). deleted probe_fact(a) AND wrote the rule as 'probe_fact(a):-doctrine(a,A)' -- X was bound by the match, so the clause written is not the clause supplied. The round-trip verify cannot see this: it compares the file against an intended list already corrupted by the same unification.\n\nFix: same_key/2 (copy_term + numbervars) in has_key, conflicts and absent; canonicalize before duplicate detection; a RULE keys on the whole clause, since two clauses with one head shape and different bodies is ordinary Prolog; and check_total/2 asserts the arithmetic the tool already PRINTS -- 'N in delta' beside a total that did not account for N was visible in its own output twice and nothing compared them.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T23:26:47Z","created_by":"claude-agent-scripts","updated_at":"2026-09-20T23:35:13Z","started_at":"2026-09-20T23:26:51Z","closed_at":"2026-09-20T23:35:13Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vz54","title":"node --test with an unmatched glob passes with zero tests","description":"Both CI node steps use a shell glob: `node --test web/tests/bundle/*.test.js` (browser job) and `node --test web/tests/*.test.js` (js job). CI runs them under `shell: /usr/bin/bash -e {0}`. Bash leaves an unmatched glob as a literal; node 22 treats it as a glob, matches nothing, and reports success with 0 tests and exit 0. Demonstrated. Rename or move either directory and the gate goes green having asserted nothing.\n\nPre-existing, not introduced by PR #63 - but #63 is the moment web/tests/bundle/ stopped being co-located with the job that builds its inputs, which makes a future move more likely.\n\nNote the local-shell trap that hid it: zsh ABORTS on an unmatched glob, so the probe only reproduces under bash. Local shell != CI shell is a fault-injection axis in its own right.\n\nFix: guard the step so an empty match fails loudly - a non-empty check on the expansion, or a minimum-test-count assertion. Prove it by renaming the directory and watching the step fail.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:05:55Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:10Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:10Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9c8c","title":"Move the reasoning out of code comments into context/ prose, cap comments at two lines","description":"USER INSTRUCTION 2026-09-20: \"cleanup all the comments in code and put the important stuff as prose files in context/. If code comments are needed, at most two consecutive lines of comments and possibly a context/ doc reference for more details (but don't overdo this).\"\n\nSCOPE RULED BY THE USER, both maximal:\n * The two-line cap applies to `///` and `//!` DOC COMMENTS as well as `//` implementation comments.\n * It applies to TEST doc-comments too. The bug narratives this repo's doctrine puts on a regression test move to prose keyed by test name.\n\n## The measurement\n\n103 Rust files, 12,533 comment lines, 2,961 comment runs of which 1,306 are longer than two lines, and 12 doctest fences inside doc comments. Worst single runs: worker.rs 121 consecutive lines, ui/reduce.rs 119, improvise-gp/lib.rs 84, gp-tree/examples/fit_csv.rs 79, wasm-client/keys.rs 78, cmd/fit.rs 59, dataset.rs 57, registry.rs 56, basis.rs 56, vfs.rs 55.\n\n## What makes this hard, and why it is not a formatting change\n\nTHE REASONING IN THOSE COMMENTS IS THE MOST EXPENSIVE ARTEFACT IN THE REPOSITORY. The post-wave documentation review concluded that the code's doc comments are now BETTER than context/repo-map.md, naming src/ui/grid.rs, worker-server/src/lib.rs, ui/reduce.rs and protocol/render.rs as carrying the full argument correctly. Several of them were written in response to a bug that cost a review round to find, and they exist because someone could not re-derive the rule from the code.\n\nSO THIS IS A MOVE, NOT A DELETION. Every argument either lands in context/ prose or is dropped DELIBERATELY as a restatement of what the code plainly says. The failure mode to avoid is a tidy diff that quietly loses the one sentence explaining why a check exists — which is the shape that reintroduces the bug the check guards.\n\n## The convention\n\n * A comment run is AT MOST TWO CONSECUTIVE LINES.\n * It may carry a `context/` reference for the rest -- but SPARINGLY, per the user (\"don't overdo this\"). A pointer on every function is noise; a pointer where a reader would otherwise re-derive a rule wrongly is the point.\n * Prose lands in context/ files owned by ONE area each, so parallel work does not collide.\n * Test evidence -- what broke, what was injected, why the test can fail -- lands in a test-evidence file keyed by TEST NAME.\n\n## Known hazards\n\n * TWELVE DOCTEST FENCES live inside doc comments. Capping a `///` block that contains a ``` example DELETES A RUNNING TEST. Find them first and decide each: promote to a real test, or keep the fence and cap around it.\n * A `//!` module doc is often the only orientation a file has. Losing it entirely costs more than it saves; the cap applies, so what survives must be the two lines that matter.\n * `#[allow(...)]`, `#[cfg(...)]` and clippy directives are ATTRIBUTES, not comments -- do not touch them.\n * A test name is the key into the evidence file, so RENAMING A TEST ORPHANS ITS ENTRY. Whatever convention is chosen must make that detectable.\n","acceptance_criteria":"No comment run in any .rs file exceeds two consecutive lines, verified by a script that is kept in the repo so the rule is checkable rather than remembered. Every argument that was in a capped comment is findable in context/ -- spot-checked by sampling the worst ten files and confirming their reasoning survived. cargo test --workspace --no-fail-fast stays at 1309 passing (or higher, if a doctest fence was promoted to a real test), and git diff filtered for non-comment lines is EMPTY -- no behaviour changed. The twelve doctest fences are each accounted for explicitly. rustdoc still builds.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:10:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:10:53Z","labels":["area:docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.\nTHE PREMISE IS ALREADY FALSE (verified 2026-09-20 at 585f25b by wave3 protocol agent, by enumerating the resolver rather than reasoning about it).\n\nThis ticket's own note says it is deferred on a CONDITION -- 'it is unreachable today ONLY because no shipped client emits Command::SwitchView' -- and asks for an interim guard that asserts exactly that. I wrote the guard. IT FAILED IMMEDIATELY. Two live paths, both in the browser's own resolver at crates/improvise-protocol/src/resolve.rs:359-368:\n\n VIEWS: switch-view-at-cursor -\u003e switch_view_at() -\u003e Command::SwitchView\n VIEWS: create-and-switch-view -\u003e CreateView + Command::SwitchView\n\nand the other half is live too: resolve.rs:331 composes ModelCommand::TransposeAxes locally. So the exact scenario the note describes -- 'a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does' -- is reachable TODAY from the shipped browser view panel. It is not a future risk; it is a live P2-turned-P1 bug.\n\nEscalated to P1 on that evidence.\n\nThe guard landed anyway, as the_keys_that_compose_a_switch_view_are_these in resolve.rs, but as an ASSERT_EQ against those two names rather than an assert-empty -- it pins reality and fails on a third live path, and its message says the list is not an exemption list. When lltt is fixed the list goes empty and the test goes with it.\n\nNOT FIXED HERE, deliberately: the honest fix is the protocol change this ticket describes (the nine view-configuration ModelCommand variants naming their view), which means every view-writing effect taking a view name instead of calling App::view_mut(). That is a whole work unit and it should not be smuggled into one alongside nine other tickets.\nNOT CARRIED BY PR #78. Releasing the claim so the tracker stops implying someone is on it.\n\nWHAT #78 CONTAINS: only the DETECTOR. the_keys_that_compose_a_switch_view_are_these (improvise-protocol/src/resolve.rs) enumerates every binding every mode can reach and asserts the set composing Command::SwitchView is exactly {switch-view-at-cursor, create-and-switch-view}. That is what overturned this ticket's deferral premise and got it escalated to P1. It fails if a THIRD live path appears; it does not fix any of the two that exist.\n\nWHAT #78 DOES NOT CONTAIN: the fix. No ModelCommand variant names a view; no effect takes a view name; App::view_mut() is still what every view-configuration effect writes through. The bug is exactly as described in the original report.\n\nWHY NOT: the honest fix is the protocol change this ticket describes -- the nine view-configuration variants (SetAxis, CycleAxis, TransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, SetNumberFormat) naming their view, which means every view-writing effect taking a view name instead of reading the ambient App::view_mut(). That touches ui/effect.rs broadly and ui/reduce.rs's mapping, and it is a work unit. Landing it inside a PR already carrying nine other tickets would have got it the least review of anything in the PR, which is the wrong trade for a P1 in the concurrency layer.\n\nONE THING FOR WHOEVER TAKES IT, which the ticket does not say: the bug is already written into a PASSING TEST'S COMMENT. a_reshape_of_one_view_leaves_a_client_on_another_view_alone carries 'Aimed at the host's own view... HideItem writes view_mut()' -- a correct description of the defect, read for as long as it has been there as a fixture note. Worth grepping for siblings of that shape before starting.\n\nUnclaimed, still P1, still open.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T04:10:12Z","started_at":"2026-09-21T03:28:15Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9c8c","title":"Move the reasoning out of code comments into context/ prose, cap comments at two lines","description":"USER INSTRUCTION 2026-09-20: \"cleanup all the comments in code and put the important stuff as prose files in context/. If code comments are needed, at most two consecutive lines of comments and possibly a context/ doc reference for more details (but don't overdo this).\"\n\nSCOPE RULED BY THE USER, both maximal:\n * The two-line cap applies to `///` and `//!` DOC COMMENTS as well as `//` implementation comments.\n * It applies to TEST doc-comments too. The bug narratives this repo's doctrine puts on a regression test move to prose keyed by test name.\n\n## The measurement\n\n103 Rust files, 12,533 comment lines, 2,961 comment runs of which 1,306 are longer than two lines, and 12 doctest fences inside doc comments. Worst single runs: worker.rs 121 consecutive lines, ui/reduce.rs 119, improvise-gp/lib.rs 84, gp-tree/examples/fit_csv.rs 79, wasm-client/keys.rs 78, cmd/fit.rs 59, dataset.rs 57, registry.rs 56, basis.rs 56, vfs.rs 55.\n\n## What makes this hard, and why it is not a formatting change\n\nTHE REASONING IN THOSE COMMENTS IS THE MOST EXPENSIVE ARTEFACT IN THE REPOSITORY. The post-wave documentation review concluded that the code's doc comments are now BETTER than context/repo-map.md, naming src/ui/grid.rs, worker-server/src/lib.rs, ui/reduce.rs and protocol/render.rs as carrying the full argument correctly. Several of them were written in response to a bug that cost a review round to find, and they exist because someone could not re-derive the rule from the code.\n\nSO THIS IS A MOVE, NOT A DELETION. Every argument either lands in context/ prose or is dropped DELIBERATELY as a restatement of what the code plainly says. The failure mode to avoid is a tidy diff that quietly loses the one sentence explaining why a check exists — which is the shape that reintroduces the bug the check guards.\n\n## The convention\n\n * A comment run is AT MOST TWO CONSECUTIVE LINES.\n * It may carry a `context/` reference for the rest -- but SPARINGLY, per the user (\"don't overdo this\"). A pointer on every function is noise; a pointer where a reader would otherwise re-derive a rule wrongly is the point.\n * Prose lands in context/ files owned by ONE area each, so parallel work does not collide.\n * Test evidence -- what broke, what was injected, why the test can fail -- lands in a test-evidence file keyed by TEST NAME.\n\n## Known hazards\n\n * TWELVE DOCTEST FENCES live inside doc comments. Capping a `///` block that contains a ``` example DELETES A RUNNING TEST. Find them first and decide each: promote to a real test, or keep the fence and cap around it.\n * A `//!` module doc is often the only orientation a file has. Losing it entirely costs more than it saves; the cap applies, so what survives must be the two lines that matter.\n * `#[allow(...)]`, `#[cfg(...)]` and clippy directives are ATTRIBUTES, not comments -- do not touch them.\n * A test name is the key into the evidence file, so RENAMING A TEST ORPHANS ITS ENTRY. Whatever convention is chosen must make that detectable.\n","acceptance_criteria":"No comment run in any .rs file exceeds two consecutive lines, verified by a script that is kept in the repo so the rule is checkable rather than remembered. Every argument that was in a capped comment is findable in context/ -- spot-checked by sampling the worst ten files and confirming their reasoning survived. cargo test --workspace --no-fail-fast stays at 1309 passing (or higher, if a doctest fence was promoted to a real test), and git diff filtered for non-comment lines is EMPTY -- no behaviour changed. The twelve doctest fences are each accounted for explicitly. rustdoc still builds.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:10:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T02:39:08Z","closed_at":"2026-09-21T02:39:08Z","close_reason":"The comment cap and the test-evidence keys are enforced by CI: .github/workflows/ci.yml runs check-comment-runs.py in all three modes (default, --self-test, --check-evidence) in the test job. The gate landed last, deliberately, so its first green run was evidence rather than vacuous -- decision(improvise-9c8c, gate_the_comment_cap_last). PR #69 then closed both of the checker's own blind spots (#### headings, and the test-to-evidence direction).","labels":["area:docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"closed","priority":1,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:08Z","started_at":"2026-09-20T18:05:07Z","closed_at":"2026-09-20T18:05:08Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oai9","title":"[bug] The worker's errors reach the console with their cause stripped","description":"FOUND BY THE FIRST REAL BROWSER RUN of the deployed page (2026-09-20), and it is the reason that run produced no actionable information.\n\nThe console said:\n\n improvise worker: failed to start\n Error: Cannot open demo.improv\n\n\"Cannot open demo.improv\" is the CONTEXT vfs::open attaches with with_context. anyhow Display prints ONLY THE OUTERMOST CONTEXT, so bindings::start mapping through e.to_string() kept the label and threw away the cause -- the Storage failure or the parse failure that actually happened.\n\nWHY THIS IS THE WORST POSSIBLE PLACE FOR IT: bindings.rs is cfg(target_arch = wasm32) and is the one module in the crate that NO TEST IN THE REPOSITORY CAN REACH. A devtools console is the only instrument pointed at it. So the single code path with no test coverage was also the one discarding its own diagnostics.\n\nFIXED: a report() helper rendering with the alternate form {:#}, which walks the chain joining with \": \". It lives in lib.rs rather than bindings.rs SO IT CAN BE TESTED AT ALL -- bindings is wasm-gated. Five call sites (start, on_message, flush, documents, refresh).\n\nTEST WRITTEN AGAINST THE OLD RENDERING FIRST: a_reported_error_carries_its_cause_not_just_its_label asserts that anyhow to_string() IS just the label (pinning the trap, so the helper stops being needed if that ever changes) and that report() contains BOTH the label and the cause.\n\nTHIS DOES NOT FIX THE UNDERLYING FAILURE -- it makes the next reload name it. Redeploy and read the console again.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:55:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:07Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:07Z","close_reason":"Closed by PR #21 (WU-I) ON EVIDENCE, with NO CODE WRITTEN -- the fix described in this ticket was already in the tree.\n\nVerified rather than assumed: report() renders with the alternate form {:#}, which walks the anyhow chain joining with \": \"; it lives in lib.rs rather than the wasm-gated bindings.rs so it can be tested at all; five call sites (start, on_message, flush, documents, refresh); and a_reported_error_carries_its_cause_not_just_its_label pins both halves -- that anyhow's to_string() IS just the outermost label, and that report() carries the cause too.\n\nFAULT INJECTED BEFORE CLOSING: changed {e:#} back to {e} and watched the test fail on contains(\"no such document\"). So the test is discriminating and the claim is evidence rather than description.\n\nThe ticket's own closing line still stands and is not discharged by this: it does not fix the underlying failure, it makes the next reload name it.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-anr9","title":"Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once","description":"Two tickets turn out to be ONE piece of work, noticed by improvise-bpq2 while walking the bindings.\n\nimprovise-s1xw (multi-level grid labels -- the browser shows Food/Alice where the TUI shows two columns) and the bindings H / z / gz need THE SAME FIVE FACTS, which gotcha 44 already enumerates as the grid frame the cache does not carry:\n row_cats / col_cats -- how many header levels\n row_items -- multi-level label TUPLES and inline group headers\n col_group_for -- column group spans\n is_group_collapsed -- the collapse indicator\n(record_col is the sixth and is CORRECT to keep off: session-local, keys pending drill edits.)\n\nSo:\n * hide-selected-row-item (H) needs to know WHICH CATEGORY a row label belongs to -- impossible while row_labels slash-joins a tuple.\n * toggle-group / gz need group membership and collapsed state.\n * And the browser renders the slash-joined form, which the TUI own test asserts must not appear.\n\nDOING THEM SEPARATELY MEANS TOUCHING row_labels TWICE. It is a co-ordinated type change either way -- improvise-wasm-client/src/render.rs consumes both row_labels and col_labels -- so the cheap moment is once.\n\nDO NOT add row_levels beside row_labels. Two answers to \"what are the labels\" is the disease (improvise-edp), and bpq2 has just spent a wave demonstrating what one shared derivation buys.\n\nSupersedes/absorbs improvise-s1xw -- decide which id survives.","notes":"USER RULING 2026-09-20: 'Most of the view functionality should be a wasm blob that's shared between the TUI and the web UI' -- clarified as ONE SHARED CRATE, not the TUI loading wasm at runtime. The view derivations live in improvise-protocol; the browser gets them as wasm, the TUI links them natively, and ONLY THE PAINTING differs (ratatui vs DOM).\n\nTHIS TICKET IS NOW THE LAST STEP OF THAT, which is why it is P1.\n\nMeasured 2026-09-20: every other widget already takes a shared cache and no \u0026Model -- FormulaContent(\u0026FormulaCache), ViewContent(\u0026ViewListCache), TileBar(\u0026TileBarCache), CategoryContent(\u0026CategoryTreeCache), WizardWidget(\u0026WizardCache), HelpWidget(usize), WhichKeyWidget(\u0026[..]). THE REMAINING DUPLICATION IS ONE FILE: src/ui/grid.rs still derives 14 things straight off layout./view., and those are exactly the six frame facts in gotcha 44.\n\nSo closing this ticket is what makes 'most of the view functionality shared' TRUE rather than nearly true, and it fixes the Food/Alice divergence and unblocks H/z/gz as the same change.\n\nSEQUENCING NOTE: this touches improvise-protocol/src/render.rs, src/ui/grid.rs AND improvise-wasm-client/src/render.rs together -- it is a co-ordinated type change on row_labels/col_labels, not an additive one. Do not start it while another agent is in improvise-protocol; the client-side keymap work (improvise-bpq2 successor) is there now.","status":"closed","priority":1,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:32Z","started_at":"2026-09-20T05:32:26Z","closed_at":"2026-09-20T07:41:32Z","close_reason":"Closed by PR #23 (WU-A). The coordinated type change gotcha 44 had been waiting for.\n\nGridCache::row_labels/col_labels were Vec\u003cString\u003e from GridLayout::row_label, which SLASH-JOINS a multi-level tuple -- the exact rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must not appear. They are now ONE AxisFrame PER AXIS carrying all five facts: cats (hence levels()), FrameEntry::Item(Vec\u003cString\u003e) tuples, FrameEntry::Group(FrameGroup) inline headers, group_of(idx) spans and FrameGroup::collapsed.\n\nIt is ONE TYPE, NOT A row_levels BESIDE A row_labels -- the ticket's explicit constraint, honoured. The rules that were written twice are now methods both ends call: shows_level/header_labels (repeat suppression), group_of/groups, shares_ancestry, body_from. GridWidget lost its \u0026View; nothing in it reads the view any more. RenderModel gained a RenderRow ENUM so a group header cannot carry cells.\n\nEVIDENCE: the_whole_frame_is_unchanged passes byte for byte, which is the proof the TUI did not move -- and its doc comment now states honestly what it does NOT cover (both its axes are single-level, so it exercises no multi-level suppression), with the multi-level half pinned by the new tests/grid_frame_agreement.rs. That test drives the ratatui widget into a buffer and the browser client through a Snapshot OF THE SAME CACHE and compares WHAT EACH DRAWS -- comparing caches would be vacuous, since it is one cache. Six faults injected, and an independent reviewer re-ran all six and confirmed each fails on the assertion it is named after.\n\nTHE USER'S P1, found on the PR and larger than it first looked: a cell's identity is cats zipped with its item tuple, and invalidate_relabelled compared items alone. The pre-anr9 code could not distinguish a category change either -- but it never HAD cats to compare, so this migration did not break a check, IT OUTGREW ONE. The sharp case is an AXIS SWAP rather than a rename: two categories may legally share an item name, so the preserved CellId::Record stays VALID and addresses the OTHER record, a genuine misdirected write. And it was wrong end to end, not only in apply: grid_updates diffs cells against the FRAMED mirror, so an apply that kept them made the producer agree they matched and SEND NOTHING AT ALL, leaving the client re-identified with nothing in the stream that could ever correct it -- wrong forever, the direction Dirty::of is deliberately sloppy to avoid. Fixed with frame-only regression tests (no Cells or Totals behind them) and a load-bearing negative arm.\n\nWHAT THE ACCESSOR MIGRATION COST, which no test could see: replacing GridLayout access with AxisFrame accessors put an O(n) scan behind every item(idx), so a draw loop calling it per position per level became quadratic AND proportional to the absolute scroll offset. The old widget's one materializing line was load-bearing. Cured with bulk forms (header_labels, groups, shares_ancestry) and by deleting the per-position siblings nothing needed; the four that survive now carry \"not for a draw loop\" on each.\n\nFOLLOW-UPS FILED: improvise-gd83 (group_of is nearest-preceding, not membership -- with THE_MEMBERSHIP_CORRECT_SPANS and an assert_ne! that starts failing the day it is fixed), improvise-jq8l (no scrollIntoView), improvise-plcx (the harness needs a grouped multi-level fixture), improvise-0o6x (a group header first on its axis is never drawn).","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -39,11 +40,12 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T04:01:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-20T23:42:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-20T23:04:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:38:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:23:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T03:27:55Z","started_at":"2026-09-21T03:27:55Z","lease_expires_at":"2026-09-21T03:32:55Z","heartbeat_at":"2026-09-21T03:27:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-78v6","title":"PR #61 deleted the help-overlay spec and nothing replaced it","description":"Found by review of PR #65, 2026-09-20 (finding 3).\n\nPR #61 deleted `? opens help, and its rows are the shared ones` from\nharness/tests/shell.spec.js. It was added by #47 (commit 9838fc0) for\nimprovise-ktv1, whose whole point is that the help overlay draws the SHARED\ncontent — the rows, their keys and the page titles are\nimprovise_protocol::help's, the same ones the TUI draws — rather than the page\nspelling keys of its own.\n\nNothing replaced it. `grep -rn help web/tests/` is empty and no spec touches\n`#help`. So today:\n\n - paintHelp can stop being called and no test notices;\n - `help`, `help_page` and `help_titles` reach no assertion at all;\n - `?` entering AppMode::Help in the browser is unguarded.\n\ncontract.spec.js asserts the help overlay's tabs and that a `dt` is visible,\nwhich covers the first two bullets partially — but not the content, which is\nimprovise-igxp.\n\nWHAT THIS TICKET IS REALLY ABOUT, and why it is worth more than the test: a PR\nwhose subject was \"#61 dropped a guard\" did not diff #61's test names. That is\none command:\n\n git show 692aa0b~1:harness/tests/shell.spec.js | grep -o 'test(\"[^\"]*\"'\n\nagainst the same for the parent of #61's merge. It would have found this in the\nsame pass that found the socket-routing loss. Whatever replaces the help test\nshould come with that diff run over #61's whole range, in case there is a\nfourth.\n","notes":"PR #72 (branch wave/harness-coverage). harness/tests/help.spec.js replaces the deleted test; the test-name diff over #61's range found a FOURTH loss -- 'a click on an item row filters, as Enter does' -- now restored in contract.spec.js.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:41Z","created_by":"wave3-jstests","updated_at":"2026-09-21T00:24:04Z","started_at":"2026-09-20T23:21:55Z","closed_at":"2026-09-21T00:24:04Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-56ek","title":"SetPanelOpen maps a Panel to a field twice, in two crates","description":"`SetPanelOpen` is matched to a panel twice, in two crates, and a guard on either says nothing about the other.\n\n- `crates/improvise-command/src/ui/effect.rs` — `effect::SetPanelOpen::apply`, a three-way match on `Panel` writing `app.view_state.*_panel_open`. This is what the TUI runs.\n- `crates/improvise-protocol/src/reduce.rs` — `reduce_view`'s `Command::SetPanelOpen` arm, the same three-way match writing the same three fields on a client's own `ViewState`. This is what the browser folds.\n\nFound while repairing the fallout of the launch-default ruling (PR #60). Both copies' Category arm had gone vacuous for the same reason, and **each had to be fixed independently** — the Chromium harness passed 3/3 against a no-op'd `effect::SetPanelOpen`, correctly, because the browser never executes it. That is the tell: a fault in one copy is invisible to the other's tests by construction.\n\nThis is design-principles §5's named disease (\"a derivation done twice is the defect, even when both copies are correct today\"), and it is the same shape as improvise-edp (the category tree flattened twice) and gotcha 44 (the grid frame derived twice). Both of those were cured by moving the derivation to one home both ends call.\n\nWhy it is filed rather than fixed in #60: the fix is a shared `fn set_panel(view: \u0026mut ViewState, panel: Panel, open: bool)` in `improvise-protocol` that both arms call, and that is a change to the effect layer's shape rather than to the pointer work #60 is about. It is also not urgent — both copies are correct today, and now both are guarded.\n\nTwo things to preserve when fixing it:\n\n1. **The cure is one home, not a test holding two copies in step.** `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` — if the fix leaves a \"the two agree\" test alive, it is not finished.\n2. **`Panel::ALL` already exists** in `ui/app.rs` and drives the toggle keys, so a fourth panel reaches every keymap from one entry. The field-per-panel layout on `ViewState` is what forces the three-way match; whether that becomes a map keyed by `Panel` is the real question, and it touches serde (gotcha 25) and every reader.\n\nAcceptance: one derivation; injecting a wrong-panel write fails a test in whichever crate hosts it; no compatibility test between the two.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:10:47Z","created_by":"wave3-webui","updated_at":"2026-09-20T23:59:39Z","closed_at":"2026-09-20T23:59:39Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -57,21 +59,20 @@ {"_type":"issue","id":"improvise-7ybz","title":"The browser category panel has no pointer affordance at all","description":"The user reported 'I can't expand categories'. Expanding works fully by KEYBOARD (Shift-C, j/k, Enter -\u003e Command::ToggleCatExpand, resolved client-side in improvise-protocol/src/resolve.rs:598). What does not work is the MOUSE: web/app.js::categoryList builds plain \u003cli\u003e elements with no click listener, so clicking the collapsed triangle does nothing. Verified in Chromium: clicking the 'Product' row leaves the panel byte-identical.\n\nThis is the whole page's pointer story, not one widget: #grid cells get a click handler ONLY when cell.editable, so there is no click-to-select either, and the tile bar and view rows added by fix/web-ui-correctness are likewise keyboard-only.\n\nThe DECISION needed before coding: does the browser get a pointer interaction model at all, and if so what does a click mean? Clicking a category row plausibly means (a) move cat_panel_cursor there, (b) also focus the panel (enter CategoryPanel mode) if it is not focused, and (c) toggle expand if the row is a header. That is three commands from one click, and app.js must stay protocol-free - so the shape would be a wasm entry point (Client::click_category(index) -\u003e Option\u003cString\u003e) mirroring typed_cell, not JS composing Commands. Same question for a grid cell click and a tile click.\n\nNot filed as part of the fix because it is a product call, not a defect in an implemented feature.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:10Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:56Z","started_at":"2026-09-20T19:30:56Z","comments":[{"id":"89e3844a-2923-598b-87dc-5cddf6486dec","issue_id":"improvise-7ybz","author":"wave3-webui","text":"USER RULING on the tile click, recorded here because the reasoning is worth more than the behaviour: **a click SELECTS, a second click CYCLES.**\n\nThe first click on a tile moves the tile cursor and enters `TileSelect` without touching the document; cycling the axis needs a second click on the *already-selected* tile. `Enter`/`Space` keep cycling directly from the keyboard.\n\n**What it buys.** Every click in the UI is now non-mutating by default. That is a property of the whole pointer story, not a tile-specific concession, and the strongest version of the argument is a consequence the ruling was made without: a tile cycle is an `EffectKind::Model(LayoutGeometry)` change, so it bumps `App::cursor_epoch`, which `Sessions::adopt_cursor_epoch` broadcasts as `Command::ResetCursor` **to every connected client** (gotcha 24). A single stray click therefore used to cost every other viewer their cursor position. The pointer is the easiest input to trigger by accident, and it was wired to the one class of change that reaches other people.\n\n**What it cost, accepted knowingly.** Two clicks for the common action, and a **deliberate divergence from the keyboard**, where `Enter` cycles in one keystroke. Record it as a divergence chosen on purpose, not an inconsistency to tidy later — otherwise someone reads the keymap and \"fixes\" the pointer to match, which silently reinstates the broadcast-on-accident.\n\n**The implementation detail that is easy to get wrong.** \"Already selected\" must be `mode == TileSelect` **and** `tile_cat_idx == index`. The index alone is not enough: `tile_cat_idx` is 0 before anything has selected anything, so an index-only test reads tile 0 as already selected and cycles it on its very first click — the guarantee then holds for every tile but one. And \"second click\" must mean *on the already-selected tile*, not merely the second click of a session, or clicking A then B cycles B and the guarantee holds only for a session's first click. Both are asserted (`the_first_click_on_tile_zero_selects_it_like_any_other`, `clicking_a_different_tile_selects_it_rather_than_cycling_it`).\n\nEvidence discipline: the non-mutation is asserted **positively as well as negatively** — a test saying \"the axis did not change\" passes against a page where nothing happened at all. The fixture publishes a basis deliberately, so a missing `Command::Model` is a decision rather than a client that could not compose one.\n","created_at":"2026-09-20T20:41:14Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T23:59:39Z","closed_at":"2026-09-20T23:59:39Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T23:59:37Z","closed_at":"2026-09-20T23:59:37Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T03:28:14Z","started_at":"2026-09-21T03:28:14Z","lease_expires_at":"2026-09-21T03:33:14Z","heartbeat_at":"2026-09-21T03:28:14Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it","description":"Found by improvise-i5tk's benchmark (`cargo bench --bench grid_projection`), which\nwas run to decide a *different* question and answered this one instead.\n\nMEASURED, on this machine, one `project::grid_of` call = one TUI frame's grid cache:\n\n typical 100 x 4 = 400 cells whole-grid 1.902ms col_widths 45%\n large 1200 x 8 = 9600 cells whole-grid 185.644ms col_widths 64%\n 18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms col_widths 42%\n pathological 9000 x 10 = 90000 cells whole-grid 5.235s col_widths 44%\n\nA 60fps frame is 16.7ms. At 18,000 cells -- the size gotcha 41 already treats as\nrealistic -- a TUI redraw takes about four tenths of a second, and the TUI redraws\non every keypress.\n\nWHY: the whole grid is evaluated roughly THREE TIMES per frame.\n\n1. `ui/viewport.rs::compute_col_widths` walks every (row, col) calling\n `layout.display_text`, to size the columns.\n2. The SAME function then walks every (row, col) AGAIN summing column totals, to\n size the total row.\n3. `project::grid_of` then evaluates the visible cells via `cell_display`, and\n `column_totals` sums every row again for each visible column.\n\nEvery one of those goes through `Model::evaluate_aggregated`, and every result is\nthrown away at the end of the frame.\n\nWHAT THIS IS NOT: it is not the viewport question. improvise-i5tk asked whether\nnarrowing the TUI's `GridCache` to its viewport would help, and the benchmark says\nit buys only 1.2-1.5x, because passes 1 and 2 are viewport-INDEPENDENT by\nconstruction -- `compute_col_widths` takes no viewport and a column total must sum\nevery row including scrolled-past ones (gotcha 44). So narrowing removes part of\none pass out of three. The fix here is caching or a single fused pass, not windowing.\n\n## Steps to Reproduce\n\n1. `cargo bench --bench grid_projection` on this branch.\n2. Read the `whole-grid` column: 402ms for 18,000 cells, against a 16.7ms frame.\n3. Read the `ratio` column: windowing the cache buys 1.5x, so the cost is not in\n the part a viewport would narrow.\n4. Read the `col_widths` column: one function, taking two whole-grid passes of its\n own, is 42-64% of the frame.","acceptance_criteria":"A frame's grid cache evaluates each cell at most once. The benchmark in benches/grid_projection.rs is the evidence both ways: it must show the whole-grid figure dropping by roughly the number of redundant passes removed, and it must still be run AFTER the change rather than the change argued from the structure. Note what the measurement already rules out: windowing the cache to a viewport is not the fix (1.2-1.5x measured, improvise-i5tk), because compute_col_widths takes no viewport and a column total must sum every row (gotcha 44). Do not lose the totals' whole-grid correctness while removing the whole-grid cost.","notes":"SECOND RUN 2026-09-20, same machine. The col_widths share is stable at 45-46%, not 42-64%: the single 64% in the first run's 'large' row was noise. whole-grid 1.9ms / 190ms / 327ms / 5.0s; ratio 1.2-1.5x throughout. The two figures the decision rests on -- ratio ~1.4x and col_widths ~half the frame -- reproduce; the per-row percentages do not to better than a few points. Quote ranges, not single runs.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:57Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"PR #72. Tagged @mocked-peer; firefox testMatch widened with grepInvert. Measured 2.0s for the standalone pair, 10/10 under --repeat-each=5.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:06Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:06Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-20T18:03:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:15Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:43Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:16Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-75ld","title":"A refused or no-op AddCategory still bumps layout_version and raises dirty","description":"Found by the post-wave code consistency review, CONFIRMED BY EXECUTION against a real App.\n\nApp::apply_effects (crates/improvise-command/src/ui/app.rs:637-650) runs `effect.apply(); dirty.union(of(kind)); dirty = true; layout_version += 1;` and ONLY THEN checks abort_effects. So the aborting effect itself is counted.\n\n## Steps to Reproduce\n\n1. Fill a document to MAX_CATEGORIES (12), then add a 13th via `:add-cat X` or the `+cat` records column. The category is NOT created — `AddCategory` now sets abort_effects on refusal (PR #27) — yet `layout_version` goes 12 -\u003e 13 and `model_state.dirty` -\u003e true.\n2. Or simply `:add-cat Region` twice. `Model::add_category` returns Ok for a duplicate (types.rs:177), so this does not even abort: a pure no-op bumps both.\n\n## Consequences\n\n * EVERY OTHER CONNECTED CLIENT'S NEXT Command::Model IS REFUSED with Rejection::LayoutMoved, for a keystroke that changed nothing.\n * The browser worker's `if app.model_state.dirty { persist() }` (worker.rs:178) rewrites the whole OPFS document — and Storage is whole-document (gotcha 41).\n\n## Why this is a contradiction and not just an over-approximation\n\nThe `dirty` half IS argued as acceptable over-approximation in the comment at app.rs:617-622. The `layout_version` half is NOT — the same comment block says \"an aborted batch must not claim the effects it never ran (gotcha 21)\", which is exactly what it does. The code and its own stated rule disagree, four lines apart.\n\n## Note before fixing\n\nMoving the two bumps below the abort check closes the MAX_CATEGORIES arm but NOT the duplicate arm, because a duplicate AddCategory does not abort at all. The reachable, silent case is the duplicate. Consider bumping on observed change instead — `apply_effects` already fingerprints records for the CellData tier (gotcha 40), so \"did anything actually change\" is machinery that exists.\n","acceptance_criteria":"A refused AddCategory (MAX_CATEGORIES) and a duplicate AddCategory both leave layout_version and model_state.dirty untouched. Note the duplicate arm does not abort, so moving the bumps below the abort check is not sufficient on its own — the reachable silent case is the duplicate. A test drives both arms through App::apply_effects and asserts neither counter moved; injecting the old order fails it. State in the commit what the rule now is: a counter is bumped on an observed change, not on a declared intent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T23:59:38Z","closed_at":"2026-09-20T23:59:38Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yqpd","title":"Transactional rollback: a frame of mutations applies whole or not at all (ADR 001 clause 4)","description":"Clause 4 of the transaction contract the user specified 2026-09-20. Clauses 1-3 landed in PR #26; this is the half that was deliberately deferred with a costed design note.\n\nTHE CONTRACT, in the user's own words: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses:\n\n 1. A frame states ONE basis, describing the base the client composed against. [DONE, PR #26]\n 2. Checked ONCE, before anything is applied. [DONE, PR #26]\n 3. Applied IN ORDER, each command seeing what its predecessor left. [DONE, PR #26]\n 4. ALL OR NOTHING -- a failure leaves the document exactly as the base described. [THIS BEAD]\n\nWHERE THE MECHANISM BELONGS: App::apply_effects, NOT Host::commands. A boundary drawn around a sequence of COMMANDS gets redrawn the day one command becomes a sequence -- and the user has said a command could eventually be \"run script\", which makes an unbounded number of mutations internally. So arity is not a property of a variant (doctrine(arity_is_not_a_property_of_a_variant)): a classifier can honestly say WHETHER a command changes the document, never HOW MANY TIMES. Design at the one mutation site and let both the frame case and the future script case use it.\n\nTHE CORE PROBLEM IN ONE LINE: abort_effects stops a batch and UNDOES NOTHING. That is an abort, not a rollback (gotcha 21), and clause 4 needs the latter. Two live examples sit in main today: a failed Save, and AddCategory refusing on MAX_CATEGORIES (PR #27).\n\nTHE DESIGN NOTE FROM PR #26, whose arguments should not be re-derived:\n * SNAPSHOT AND RESTORE, not speculative validation and not inverse effects. Speculation fails a concrete divergence argument: AcceptWizardModel calls take_built(), which CONSUMES the operand a real run would need. Inverse effects degenerate -- the general inverse of a document-replacing effect IS a snapshot -- so they do not avoid the clone, they multiply the surface by ~50.\n * THE FINGERPRINTS CANNOT BE AN UNDO LOG. The per-record XOR (gotcha 40) is a change DETECTOR, not invertible, and covers CellData only.\n * THE BOUNDARY IS THE DOCUMENT AND ITS TIERS, NOT ALL App STATE. AddCategory writes its refusal into view_state.status_msg and THEN aborts, so rolling that back would undo the explanation along with the damage.\n * WHY ROLLBACK IS SOUND AT ALL, structurally rather than by a flag: the three storage-WRITING effects (Save, SaveAs, ExportCsv) are all ViewOnly and unreachable from ModelCommand; LoadModel and ImportJsonHeadless only read. That is testable, and it becomes an acceptance criterion for any future RunScript -- a script language that can run :w breaks it.\n\nTWO ACCEPTANCE CRITERIA THAT CAME OUT OF THE P1 THE REVIEWER FOUND, and they are not obvious:\n * THE CHECKS MUST STAY BEFORE ANY APPLICATION. A frame that writes ONE RECORD TWICE is legitimate and likely -- two measures on one record IS one record -- and it works only because the second write is compared to the record's PRE-FRAME counter rather than the one its own sibling just bumped. Proved by injection: moving the record check into the apply loop fails that arm. `a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 is the guard.\n * CLAUSE 4'S ROLLBACK IS NOT THE RECORD TIER. They fail on DISJOINT frames, which is exactly why the P1 could not be deferred into this bead: rollback never fires on a frame where every command SUCCEEDS, and the lost update was a wrong answer fully applied.\n\nTHE TRADE NOBODY HAD NAMED, and the reason arm 3 exists: INTERLEAVING THE RECORD CHECK WITH THE APPLIES WOULD CLOSE THIS BEAD'S SILENCE FOR FREE. [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying. So check-before-apply is not a free win -- it BUYS the legitimate double-write and SELLS the loud failure on a broken chain. Clause 4 must pay that back some other way, and arm 3 is what stops someone taking the trade without noticing they made it. Confirmed by two independent injections (the author's and the reviewer's, which differ in detail and agree on the direction).\n\nTHE BIGGEST AND LEAST OBVIOUS COST: ROLLING BACK THE App IS NOT ENOUGH. Host::command projects PER COMMAND, and apply_switch/apply_model adopt mirrors inline, so by the time a later command fails every session's mirror and believed grid cache have already moved. Deferring projection and relay to commit is the right fix and is also a bandwidth win -- but it changes Applied and restructures project.rs, so it is a merge-order problem as well as a size one.\n\nPROTOCOL MOVE THAT BELONGS HERE: clause 1 says the FRAME states one basis, but the wire still carries a Basis inside each Command::Model -- right when one command was one transaction. Later commands' Basis fields are currently IGNORED. Moving it to ClientFrame::Commands makes \"one basis per frame\" unrepresentable-otherwise instead of a convention.\n\nALSO FOLD IN: the `answered`/`frame_basis` classifier is a matches! whose failure direction is safe (a missed variant over-checks, never under-checks) -- make it exhaustive when the classifier returns, for the reason mutates_the_document was made exhaustive before it was deleted.\n\nESTIMATED COST from the design note: ~350-450 lines of production and as much again in tests, across app.rs, reduce.rs, project.rs, host.rs, improvise-protocol and an ADR section.\n\nINVERT `a_frame_whose_later_mutation_fails_is_not_yet_undone` when this lands. It currently characterizes the hole; all three of its assertions flip.\n","acceptance_criteria":"A ClientFrame::Commands whose later command fails leaves the document exactly as its stated basis described. The mechanism sits at or near App::apply_effects, not Host::commands, so a future multi-mutation command (RunScript) uses the same machinery. The checks stay BEFORE any application — a frame writing one record twice is legitimate and refuses itself otherwise, which a_later_write_in_a_chain_still_answers_for_its_own_record arm 3 guards. Rolling back the App is not enough: Host::command projects per command, so mirrors and believed grid caches must be deferred to commit. a_frame_whose_later_mutation_fails_is_not_yet_undone inverts. And the Basis moves from Command::Model to ClientFrame::Commands, making one-basis-per-frame unrepresentable-otherwise.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0o6x","title":"A group header first on its axis is never drawn, so a leading collapsed group has no way back","description":"Found by the independent adversarial review of PR #23, which calls it \"the most valuable thing I found that nobody named\". PRE-EXISTING and correctly characterized, but improvise-anr9 changes what it costs.\n\nTHE BEHAVIOUR: body_from(idx) starts at the first ITEM, so at row_offset == 0 a leading FrameEntry::Group is skipped. GROUPED_FRAME shows it plainly -- the group header \"Coastal\" above \"East\" is simply not drawn -- and that constant is BYTE-IDENTICAL to main, so the TUI has always behaved this way and the characterization is honest.\n\nWHY IT MATTERS MORE NOW: a COLLAPSED group that is first on its axis has NO HEADER ANYWHERE ON SCREEN, in either front end, and therefore NO WAY BACK. Its rows are hidden and the only affordance for unhiding them is the header that is not drawn.\n\nAND IT SITS EXACTLY WHERE THE WINDOWING RULE SAYS IT MUST NOT. improvise-anr9's axis_frame carries ALL headers between two in-window items specifically because \"a collapsed group's header is the only thing on screen the user can click to get its rows back\". That clause is correct and the rule is right. Position zero is the one place the rule does not reach -- not because the frame omits the header (it does not) but because body_from drops it on the way to the screen.\n\nTHE AUTHOR KNEW, WHICH IS WHY THIS IS FILED RATHER THAN REDISCOVERED: tests/grid_frame_agreement.rs deliberately orders its fixture with \"Carol\" before \"Family\" to dodge the case, and says so in a comment. It was not in the PR body and no ticket was cited, so the knowledge lived in one comment in one test.\n\nFILE THIS BEFORE ANYONE BUILDS GROUP INTERACTION IN THE BROWSER. web/app.js's group \u003cth\u003e has no click listener today and `toggle-group-under-cursor` is Upstream -- a black hole, doctrine(an_upstream_key_is_a_black_hole) -- so the affordance does not exist in the browser at all yet. The moment it does, a leading collapsed group becomes a trap: rows hidden, nothing to click.\n\nWHAT THE FIX TOUCHES, so nobody starts casually: body_from/visual_of decide what a renderer paints from a window offset. Drawing a leading header changes the row count of the painted body, which moves the exact-frame characterization buffers in src/ui/grid.rs -- so this is a deliberate re-pinning with the reason in the commit message, the same acceptance clause improvise-q0vb had. Check the interaction with the windowing rule's first clause (the nearest header ABOVE the window is carried for group_of and discarded by body_from): at offset zero there is no \"above\", so the leading header is an in-window entry that body_from is discarding, which may make the fix a one-line boundary condition rather than a redesign. Establish which before estimating.\n\nRELATED: improvise-plcx (the harness needs a grouped, multi-level fixture) is what would put this in front of a browser at all.\n\n## Steps to Reproduce\n\n1. Build a view whose row axis has a group as its FIRST entry — e.g. a category whose first item belongs to group `Coastal`.\n2. Open it in the TUI at `row_offset == 0`.\n3. The `▼ Coastal` header is not drawn at all: `body_from(0)` starts at the first ITEM and skips the leading `FrameEntry::Group`. `GROUPED_FRAME` in `src/ui/grid.rs` pins this, byte-identical to pre-anr9.\n4. Now collapse that group. Its rows disappear and its header is still not drawn — so there is nothing on screen to act on, in either front end.\n","acceptance_criteria":"A group header that is first on its axis is drawn, in the TUI and in the browser, so a collapsed group at position zero has an affordance. The exact-frame characterization buffers in src/ui/grid.rs are re-pinned DELIBERATELY, with the commit message naming which moved and why — the improvise-q0vb acceptance shape. Establish first whether this is a boundary condition in body_from rather than a redesign: at offset zero there is no 'above', so the leading header is an in-window entry body_from is discarding.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:26:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:12Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-0o6x","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0o6x","title":"A group header first on its axis is never drawn, so a leading collapsed group has no way back","description":"Found by the independent adversarial review of PR #23, which calls it \"the most valuable thing I found that nobody named\". PRE-EXISTING and correctly characterized, but improvise-anr9 changes what it costs.\n\nTHE BEHAVIOUR: body_from(idx) starts at the first ITEM, so at row_offset == 0 a leading FrameEntry::Group is skipped. GROUPED_FRAME shows it plainly -- the group header \"Coastal\" above \"East\" is simply not drawn -- and that constant is BYTE-IDENTICAL to main, so the TUI has always behaved this way and the characterization is honest.\n\nWHY IT MATTERS MORE NOW: a COLLAPSED group that is first on its axis has NO HEADER ANYWHERE ON SCREEN, in either front end, and therefore NO WAY BACK. Its rows are hidden and the only affordance for unhiding them is the header that is not drawn.\n\nAND IT SITS EXACTLY WHERE THE WINDOWING RULE SAYS IT MUST NOT. improvise-anr9's axis_frame carries ALL headers between two in-window items specifically because \"a collapsed group's header is the only thing on screen the user can click to get its rows back\". That clause is correct and the rule is right. Position zero is the one place the rule does not reach -- not because the frame omits the header (it does not) but because body_from drops it on the way to the screen.\n\nTHE AUTHOR KNEW, WHICH IS WHY THIS IS FILED RATHER THAN REDISCOVERED: tests/grid_frame_agreement.rs deliberately orders its fixture with \"Carol\" before \"Family\" to dodge the case, and says so in a comment. It was not in the PR body and no ticket was cited, so the knowledge lived in one comment in one test.\n\nFILE THIS BEFORE ANYONE BUILDS GROUP INTERACTION IN THE BROWSER. web/app.js's group \u003cth\u003e has no click listener today and `toggle-group-under-cursor` is Upstream -- a black hole, doctrine(an_upstream_key_is_a_black_hole) -- so the affordance does not exist in the browser at all yet. The moment it does, a leading collapsed group becomes a trap: rows hidden, nothing to click.\n\nWHAT THE FIX TOUCHES, so nobody starts casually: body_from/visual_of decide what a renderer paints from a window offset. Drawing a leading header changes the row count of the painted body, which moves the exact-frame characterization buffers in src/ui/grid.rs -- so this is a deliberate re-pinning with the reason in the commit message, the same acceptance clause improvise-q0vb had. Check the interaction with the windowing rule's first clause (the nearest header ABOVE the window is carried for group_of and discarded by body_from): at offset zero there is no \"above\", so the leading header is an in-window entry that body_from is discarding, which may make the fix a one-line boundary condition rather than a redesign. Establish which before estimating.\n\nRELATED: improvise-plcx (the harness needs a grouped, multi-level fixture) is what would put this in front of a browser at all.\n\n## Steps to Reproduce\n\n1. Build a view whose row axis has a group as its FIRST entry — e.g. a category whose first item belongs to group `Coastal`.\n2. Open it in the TUI at `row_offset == 0`.\n3. The `▼ Coastal` header is not drawn at all: `body_from(0)` starts at the first ITEM and skips the leading `FrameEntry::Group`. `GROUPED_FRAME` in `src/ui/grid.rs` pins this, byte-identical to pre-anr9.\n4. Now collapse that group. Its rows disappear and its header is still not drawn — so there is nothing on screen to act on, in either front end.\n","acceptance_criteria":"A group header that is first on its axis is drawn, in the TUI and in the browser, so a collapsed group at position zero has an affordance. The exact-frame characterization buffers in src/ui/grid.rs are re-pinned DELIBERATELY, with the commit message naming which moved and why — the improvise-q0vb acceptance shape. Establish first whether this is a boundary condition in body_from rather than a redesign: at offset zero there is no 'above', so the leading header is an in-window entry body_from is discarding.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:26:33Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-0o6x","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-plcx","title":"The browser harness needs a grouped, multi-level fixture — the demo has neither","description":"Named by improvise-anr9 (PR #23) as \"the highest-value follow-up in this hand-back\", and it is a gap in the NEW harness rather than in the old blind spot.\n\nmain now carries harness/tests/shell.spec.js (improvise-vvl3), which renders web/app.js in Chromium, Firefox and WebKit and asserts the grid draws, a header reads \"Revenue\", an editable cell exists, and click-to-edit commits and shows the host's value. It PASSED on PR #23's merged head, which means the DOM rewrite that PR shipped is genuinely browser-verified.\n\nBUT ONLY ON THE SIMPLE PATH. web/demo.improv has NO GROUPS AND ONE CATEGORY PER VISIBLE AXIS -- deliberately, per gotcha 46, because before improvise-anr9 a second category on an axis put the slash-join gap on the front page. That is no longer a reason, and it is now the harness's blind spot: the multi-level header, group-span, collapse-indicator and inline-group-row paths of gridTable are EXACTLY the code improvise-anr9 added, and the harness never exercises any of them.\n\nWHAT IT NEEDS: a second .improv served by the harness -- not a change to web/demo.improv, which is the shipped front page and is correct as it is -- carrying at least two categories on one axis, a group with a couple of members, a collapsed group, and enough rows to scroll. Then assertions that the DOM has real header levels rather than slash-joined labels, that a group header spans its members, that the collapse indicator renders, and that an inline group row carries no cells (RenderRow::Group exists so it cannot, which is worth asserting from the outside).\n\nWHY IT IS WORTH DOING SOON: it would be the FIRST TEST IN THIS REPOSITORY able to see that code in a browser, and improvise-anr9's own hand-back says so. It also unblocks the sibling scrollIntoView bead, which needs a document big enough to scroll.\n\nNOTE THE STALE-DOCUMENTATION LESSON THAT CAME WITH IT, recorded because it cost real caution: improvise-anr9 wrote \"web/app.js is never executed here\" into three comments and a PR body on the strength of gotcha 42 -- while a harness that renders it in three browsers had ALREADY LANDED on main and was passing on its own code. Gotcha 48 says a fact stated in several places is a grep. This is the version where the stale copy is the one telling you what you CANNOT test, and it makes you under-claim your own evidence.\n","acceptance_criteria":"The harness serves a SECOND .improv (not web/demo.improv, which is the shipped front page and correct as it is) carrying at least two categories on one axis, a group with several members, a collapsed group, and enough rows to scroll. Specs assert: real header levels rather than slash-joined labels; a group header spanning its members; the collapse indicator rendering; and an inline group row carrying no cells (RenderRow::Group makes that unconstructible, which is worth asserting from outside). Each assertion is proved discriminating before it is kept.","notes":"PR #72. harness/fixtures/grouped.improv + harness/tests/grouped.spec.js, five injections. Note: the leading group header (Coastal) is never drawn -- improvise-0o6x observed in the wild; the spec asserts 2 group rows and says so.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:56Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-plcx","depends_on_id":"improvise-0o6x","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:17Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:18Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:16Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-p1ym","title":"Project the page-axis item lists so [ and ] work in a browser","description":"The one key pair an independent smoke test found inert that the shared keymap did NOT fix, and it is a genuine projection gap rather than a resolution gap.\n\nGridCache::page_coords carries (category, SELECTED ITEM). PageNext/PagePrev need THE ITEM LIST TO CYCLE THROUGH, which nothing projects. So [ and ] resolve to a command name the client holds but cannot compose an argument for, and they reach Host::handle empty vec -- doctrine(an_upstream_key_is_a_black_hole), so they do nothing at all.\n\nCONFIRMED BY SMOKE TEST 2026-09-20 against the live page: \"[ / ] did not cycle Quarter (never saw Q2 ~17,100)\". The demo model puts Quarter on the page axis specifically so paging is a thing a visitor would try.\n\nTHE AGENT WAS TOLD NOT TO FORCE IT and did not -- a truthful \"these four keys work, [ / ] needs X\" is worth more than five half-working keys.\n\nSCOPE: extend page_coords (or add a sibling) to carry the item list per page-axis category. Note gotcha 27 -- \"what data is visible\" is View::filtering_cats() (Page UNION Filter) while \"what the user can page\" is categories_on(Axis::Page). This needs the SECOND one; projecting the first would let a client try to cycle a Filter axis, which by design it must not.\n\nWhile you are there: check whether the item list should be the EFFECTIVE items (hidden items excluded) rather than all of them, since [ and ] in the TUI cycle what the user can actually select.","acceptance_criteria":"GridCache carries the item list per page-axis category, so PageNext/PagePrev compose client-side and [ / ] cycle Quarter on the live page. It projects categories_on(Axis::Page), NOT filtering_cats() — projecting the latter would let a client try to cycle a Filter axis, which by design it must not (gotcha 27). Decide and state whether the list is the EFFECTIVE items (hidden excluded), since that is what the TUI cycles. Verified against the demo, which puts Quarter on the page axis precisely because paging is a thing a visitor tries.","status":"closed","priority":2,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:59Z","started_at":"2026-09-20T18:00:04Z","closed_at":"2026-09-20T18:05:59Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -187,11 +188,16 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8shu","title":"Constrain the .improv grammar generator's strategy; improvise-io's lib binary is the run's outlier","description":"Split out of improvise-8ec3, whose second half (stop pinning one machine's number) is done. This is the first half, left open.\n\nimprovise-io's lib binary is an order of magnitude above every other binary in the run on every machine it has been measured on: 1.40-1.45s over three runs here (vs a ~0.06s median), 3.70-5.24s on the machine that filed 8ec3. The ratio is the portable signal; the seconds are not.\n\nIts slow half is the grammar-walking generator in persistence/parse.rs plus the .improv roundtrip proptests at the default 256 cases. A grammar walk with no size bound generates deeply nested documents whose cost is not uniform across cases, so a few cases dominate.\n\ndoctrine(proptest_cases) rules out the cheap fix: constrain the strategy, do NOT lower the count below 256.\n\nAcceptance: improvise-io's lib binary is no longer an order of magnitude above the run's median, with the case count still 256 and the properties still covering groups, formulas, views, hidden items and pipe-quoting edges. Report the before/after ratio, not seconds.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:14:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:14:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"Found by the adversarial review of PR #78.\n\nimprovise-ni6k introduced view_frame (the drawn frame, drives the cursor) beside view_shape (the configuration, drives layout_version). They answer different questions -- AddCategory of an empty category moves view_shape and not view_frame -- and context/code/command.md section 'Two observations, not one' now records the argument.\n\nTHE GUARD IS ONE-DIRECTIONAL. Tests assert specific commands move specific counters. Nothing asserts the general relation, in either direction:\n\n- Is every reframe also a reshape? (Probably yes: a drawn-frame change should always be a config change. If so it is a property worth pinning, and its failure would mean layout_version under-reports, which per gotcha 35's asymmetry leaves a client wrong FOREVER.)\n- Is every reshape a reframe? (Definitely not -- that is the AddCategory case, and it is the whole reason there are two.)\n\nSo the first is a candidate property test and the second is a documented asymmetry. Today a change that accidentally made view_frame a superset of view_shape, or vice versa, would pass the suite.\n\nWorth doing as a proptest over a generated workbook plus a generated model command, comparing the two differing-key sets. Note the fixture trap improvise-gfvj's investigation turned up: pressing R to enter records mode fires effect::SortData first, so a records-mode fixture built through the key path has an already-sorted store and cannot demonstrate order-sensitive behaviour.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T04:01:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h0iv","title":"group_runs coalesces by name alone, so two runs under different headers merge","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::group_runs`\nas the one home of column-group run coalescing (improvise-8qlr).\n\n`group_runs` coalesces adjacent positions by group **name**:\n\n Some((prev, span)) if prev.map(|p| p.name.as_str()) == name =\u003e *span += 1,\n\nSo two runs under two DIFFERENT headers that happen to share a name read as one\nrun, and the browser draws one `\u003cth colspan\u003e` across both. `FrameGroup` carries\n`cat` as well as `name` — precisely so a client never has to work out which\ncategory a label came from — and comparing the pair would fix it.\n\nWHY PR #77 WAS RIGHT NOT TO FIX IT, so this is a decision and not a\nrediscovery: that PR's job was to make one rule out of two. Both prior copies\n— `src/ui/grid.rs`'s `group_name != prev_group` and\n`improvise-wasm-client/src/render.rs::col_groups`' `run.label == label` —\ncoalesced by name alone, so name-only is what the consolidation had to preserve\nto stay a consolidation. Changing the rule in the same commit that moves it\nwould have meant the agreement test could no longer tell \"the two ends agree\"\nfrom \"the two ends changed together\", which is the whole evidence of the move.\n\nTHE RISK OF LEAVING IT IS SPECIFIC AND NEW: a single home reads as decided.\nThe next person to look at `group_runs` finds one rule, one call site per front\nend, and a test pinning both — and has no reason to suspect the comparison is\nnarrower than the data it is given. That is the cost of a good consolidation\nand the reason to file rather than to note.\n\nReachability today: two categories on one axis, where the inner category has\ngroups of the same name under two different outer items — e.g. `Month` grouped\ninto `Q1` nested under `Region`, giving `North/Q1` and `South/Q1` adjacent. The\nexisting fixture in `tests/grid_frame_agreement.rs` has exactly this shape but\nis saved by the ungrouped `Sep` sitting between the two `Q1` runs, which is\nalso what makes improvise-gd83's spans come out as\n`[(Q1,2), (None,1), (Q1,2), (None,1)]` rather than merged. Remove `Sep` and the\ntwo runs merge.\n\nNot obviously a bug in every case: two adjacent runs of the same group name may\nwell be one visual span a user WANTS spanned. That is the question to answer\nfirst, and it is a question about what a group header means across a nesting\nboundary — which is why this is filed rather than fixed.\n\n## Steps to Reproduce\n\n1. Build a two-level column axis whose inner category is grouped and whose\n groups repeat under each outer item, with NO ungrouped item between them.\n2. `AxisFrame::group_runs` returns one run spanning both.\n3. Both front ends draw one group header across the boundary, because both now\n ask the same (name-only) rule.\n\n## Acceptance Criteria\n\nDecide whether two adjacent same-named runs under different headers are one\nspan or two, and say why in the commit. If two, `group_runs` compares\n`(cat, name)` and the agreement-test fixture gains a case with no separating\nungrouped item — the current one cannot see this.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:18Z","created_by":"agent-render-frame","updated_at":"2026-09-21T04:00:18Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1wl0","title":"AxisFrame::first is serde(default), so a frame with no origin claims it leads the axis","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::first`.\n\n`#[serde(default)]` on `first` forges the PERMISSIVE claim. `first: 0` is not\nan absence — it is the assertion \"this frame LEADS the axis\", and that is\nexactly what `body_from` acts on:\n\n match self.first + idx == 0 { true =\u003e \u0026self.entries[..], ... }\n\nSo an older peer's frame, or any frame whose `first` is dropped in transit,\ndeserializes as leading the axis. A scrolled browser then draws the carried\nheader ABOVE its first row — the header `body_from` exists to discard there,\nand the mirror image of the improvise-0o6x bug the field was added to fix.\n\nThis is the same argument that gave `Basis` no `Default` and no public\nconstructor (gotcha 43): a zero-valued claim that happens to be TRUE in the\ncommon case is a forgeable bypass rather than an absence. `first` is worse in\none respect — a `Basis` at least has to be constructed deliberately, while a\nserde default is applied silently by a missing field.\n\nNO SKEW SHIPS TODAY. Both ends are built from one workspace at one commit, so\nthere is no older peer. This is about the day there is one, and about the\nattribute reading as a considered compatibility decision when it was not.\n\nOptions, not yet chosen:\n\n * Drop the attribute, so a frame missing `first` fails to deserialize. Loud,\n and correct if a frame is never partially constructed. Note gotcha 37's\n observation that `#[serde(default)]` on an Option is documentation and not\n mechanism — here it is the opposite, it IS the mechanism.\n * Make the leading-ness unrepresentable-otherwise: `Option\u003cusize\u003e`, or a\n two-variant enum (`LeadsAxis` / `StartsAt(usize)`), so \"unknown\" cannot\n silently mean \"leads\".\n\nWhichever, say which in the commit, because the reason to prefer one is about\nwire compatibility and not about the type.\n\n## Steps to Reproduce\n\n1. Serialize an `AxisFrame` built for a scrolled window (`first \u003e 0`) and\n remove the `first` key from the JSON — the shape an older peer sends.\n2. Deserialize it. `first` is 0.\n3. `body_from(0)` now takes the `first + idx == 0` arm and returns the whole\n entry list, including the carried group header.\n4. The browser draws a group header above its first row, at every scroll\n position. Nothing reports anything.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:14Z","created_by":"agent-render-frame","updated_at":"2026-09-21T04:00:14Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gfvj","title":"A pure record reorder invalidates no cursor, and I could not build a fixture that reorders","description":"Left open by improvise-ni6k, which closed the rest of its class by observing the grid frame.\n\nni6k names SortData as its SHARPEST case: it changes neither the row count nor any label, and every position still names a different record. The frame fingerprint ni6k landed (App::view_frame, hashing GridLayout row_items/col_items) CANNOT see it, because records-mode row_items are positional (gotcha 1) -- they are 0,1,2... whatever order the store is in.\n\nI wrote the mechanism -- hashing layout.records[].coords alongside the labels -- and then REMOVED it before committing, because I could not demonstrate it catches anything. Every fixture I built came back in key order whatever order the cells were inserted in:\n\n wb.add_item(Region, North); wb.add_item(Region, East);\n set_cell(North/Revenue); set_cell(East/Revenue);\n -\u003e records mode row order is [East, North] BEFORE any SortData\n\nSo either SortData is a no-op for that shape, or something between DataStore::live_records / matching_cells / RecordRow::group is ordering records that gotcha 18 says arrive in insertion order. I did not chase which, and I am not asserting a bug in gotcha 18 on this evidence -- matching_positions sorts POSITIONS (which preserves insertion order), so the explanation is probably elsewhere and probably boring.\n\nTWO THINGS TO DO, in order:\n1. Find out what actually orders records-mode rows, and whether SortData can reorder them at all. If it cannot, this ticket closes as 'not a case' and ni6k's note should be corrected -- its sharpest example would be hypothetical.\n2. Only if it can: put the coords back into view_frame and pin it with the fixture that reproduces.\n\nWHY IT WAS NOT SHIPPED UNVERIFIED: a confident comment over a mechanism nobody has seen fire is this repo's named defect. See doctrine(check_the_checker) and the wave brief's 'a green suite is not evidence'.","notes":"REPRODUCED AND FIXED. My original conclusion was WRONG, and the way it was wrong is the useful part.\n\nI claimed I could not build a fixture where SortData reorders records. The reviewer of PR #78 reproduced it in thirty lines. I re-derived it: insert West/North/East, call sort_by_key, and both live_records() and the records-mode GridLayout reorder to East/North/West.\n\nWHY MY FIXTURE HID IT -- not the reviewer's guess (insertion already sorted), and better than that: MY FIXTURE ENTERED RECORDS MODE BY PRESSING R, and ToggleRecordsMode (command/cmd/grid.rs:476) pushes effect::SortData AS ITS FIRST EFFECT. So the store was sorted by the act of entering records mode, and the SortData I then applied was a genuine no-op. My two-record store did reorder at the DataStore level -- I checked after the review -- so the fixture, not the store, was the problem.\n\nTHE GENERAL TRAP, worth carrying: a records-mode fixture built through the key path has an already-sorted store by construction, so it cannot demonstrate anything order-sensitive. Build one by setting _Index/_Dim axes directly. That is now written into context/test-evidence/command.md under sorting_the_store_in_records_mode_invalidates_the_cursor.\n\nTHE FIX: the layout.records coords hash I removed is restored in App::view_frame, and sorting_the_store_in_records_mode_invalidates_the_cursor pins it -- asserting the row COUNT and the LABELS are both unchanged, so it is the sharp case and not a shape change wearing its name. Proved by removing the coords hash again: that test fails and NOTHING ELSE IN THE CRATE does.\n\nMY PROCESS ERROR, for the record: I concluded 'cannot reproduce' from ONE fixture without varying it, and then filed that conclusion as a finding. Removing the unverified mechanism was right; concluding the hole was not real was not. 'I could not reproduce it' and 'it does not reproduce' are different claims and I filed the second having only earned the first.\n\nClosing.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T03:47:15Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T04:01:44Z","closed_at":"2026-09-21T04:01:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9za9","title":"Demoting an evidence heading to prose is an unguarded escape hatch","description":"check-evidence classifies a heading as a claim by SHAPE -- a bare identifier is a Rust test, a quoted string a JS test, anything else is prose. So any orphaned heading can be retired by rewriting it with a space and a dash, and nothing checks whether the prose is honest.\n\nDEMONSTRATED: appending '#### Helper -- `thing`' to an evidence file is silently unchecked. None of the 14 new --self-test evidence cases covers this, and no mechanism could: the checker cannot know whether prose is truthful.\n\nThis is disclosed rather than hidden -- the docstring names it and points at context/code/tooling.md before loosening -- and the safe direction is confirmed: prose that LOOKS like an identifier is classified as a Rust claim and fails loudly ('#### Background' reports as an orphan) rather than passing silently.\n\nThe live instance was PR #69 demoting '? opens help, and its rows are the shared ones' while PR #72 was restoring that coverage under a different name. That is the argument for treating a demotion as something a reviewer must read rather than something a gate can hold. Worth deciding whether a demotion should require a marker the checker can count, so at least the NUMBER of retired claims is visible.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T00:11:25Z","created_by":"orchestrator","updated_at":"2026-09-21T00:11:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w8ih","title":"extract web/assemble.sh's stamp gate so it can be exercised in isolation","description":"Found by review of PR #69. improvise-x4wv made stamped() record every missing cache-bust stamp and put the fail in a four-line 'test -z $missing' gate after the four calls. That gate is the whole guard, and deleting it is invisible: both failures still print and assemble.sh exits 0, proved by injection on #69.\n\nThe decline of a full assemble harness stands -- it would have to fabricate web/pkg and web/worker-pkg, a second answer to 'what does the site assemble from', which improvise-vvl3 exists to prevent. But the gate alone does not need the tree: extract it (a function taking the missing list, or a tiny script) and exercise it the way scripts/node-test-test.sh and scripts/kb-test.sh exercise theirs -- empty list exits 0, non-empty exits 1 naming every entry. Wire it into CI in the same change.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T00:01:05Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:01:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1xf4","title":"layout_geometry_effects_actually_change_view_config asserts against a test-only third fingerprint","description":"The test hand-lists 6 of the 13 LayoutGeometry effects and asserts against effect.rs::tests::view_config_fingerprint -- a THIRD fingerprint that exists only in test code, beside the production document_config and view_shape introduced by PR #70.\n\nSince model_state.dirty for LayoutGeometry is now decided solely by document_config, a future geometry effect that writes something view_config_fingerprint sees but document_config does not would pass this test and NEVER mark the document dirty -- i.e. never be saved.\n\nAll 13 are fine today; checked by hand (AddCategory, RemoveCategory, CreateView, DeleteView, SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty). AddFormula is CellData, so formulas -- which document_config does not hash -- still raise dirty off the declaration.\n\nFix: point the test at document_config itself and list all 13, which turns a third derivation into the guard for the production one.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2avl","title":"layout_version moved outside the effect loop and neither abort arm is tested","description":"PR #70 moved layout_version from 'once per LayoutGeometry effect, inside the loop' to 'once per batch, after the loop'. Gotcha 40 records the inside-the-loop position as load-bearing: an aborted batch must not claim effects it never ran.\n\nThe author's argument for why the end-of-batch position is safe for layout_version specifically -- nothing ever CLEARS layout_version, so an end comparison sees only the effects that actually ran, whereas dirty must stay in the loop because Save unsets it mid-batch -- is sound and is written into context/code/command.md. But an argument is not a test.\n\nTwo arms, neither covered: an aborted batch that DID reshape before aborting (must bump), and one that aborted before reshaping (must not).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y4xg","title":"CmdContext carries a fourth Panel-to-field mapping, and it is completely unguarded","description":"registry.rs:359 and :393 map Panel -\u003e CmdContext fields (formula_panel_open / category_panel_open / view_panel_open) in the interactive arms of toggle-panel-visibility and toggle-panel-and-focus. CmdContext's flags are copied from view_state at app.rs:528, so it is the same fact wearing a different struct.\n\nDEMONSTRATED unguarded: swapping Formula and Category at both sites leaves cargo test --workspace --no-fail-fast at 1400 passed, 0 failed. With the swap in place, toggling the formula panel reads the CATEGORY panel's open flag to decide open-vs-close -- a visible wrong behaviour with zero coverage.\n\nPre-existing (identical at 00f7678) and outside improvise-56ek's scope, which consolidated the Panel -\u003e ViewState mapping. The cure is a CmdContext::panel_open(panel) accessor in command/cmd/core.rs, or a command-side sibling of a_panel_command_writes_that_panel_and_no_other.\n\nNOT the same defect: resolve.rs:492 and registry.rs:447 map Panel -\u003e (cursor, collection length). That is a composite -- the length half is genuinely per-panel and per-source -- so the match must exist; it is lockstep.rs's subject and the permutation injection does reach it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:48Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xf4","title":"layout_geometry_effects_actually_change_view_config asserts against a test-only third fingerprint","description":"The test hand-lists 6 of the 13 LayoutGeometry effects and asserts against effect.rs::tests::view_config_fingerprint -- a THIRD fingerprint that exists only in test code, beside the production document_config and view_shape introduced by PR #70.\n\nSince model_state.dirty for LayoutGeometry is now decided solely by document_config, a future geometry effect that writes something view_config_fingerprint sees but document_config does not would pass this test and NEVER mark the document dirty -- i.e. never be saved.\n\nAll 13 are fine today; checked by hand (AddCategory, RemoveCategory, CreateView, DeleteView, SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty). AddFormula is CellData, so formulas -- which document_config does not hash -- still raise dirty off the declaration.\n\nFix: point the test at document_config itself and list all 13, which turns a third derivation into the guard for the production one.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:01Z","started_at":"2026-09-21T03:28:18Z","closed_at":"2026-09-21T03:47:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2avl","title":"layout_version moved outside the effect loop and neither abort arm is tested","description":"PR #70 moved layout_version from 'once per LayoutGeometry effect, inside the loop' to 'once per batch, after the loop'. Gotcha 40 records the inside-the-loop position as load-bearing: an aborted batch must not claim effects it never ran.\n\nThe author's argument for why the end-of-batch position is safe for layout_version specifically -- nothing ever CLEARS layout_version, so an end comparison sees only the effects that actually ran, whereas dirty must stay in the loop because Save unsets it mid-batch -- is sound and is written into context/code/command.md. But an argument is not a test.\n\nTwo arms, neither covered: an aborted batch that DID reshape before aborting (must bump), and one that aborted before reshaping (must not).","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:01Z","started_at":"2026-09-21T03:28:19Z","closed_at":"2026-09-21T03:47:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y4xg","title":"CmdContext carries a fourth Panel-to-field mapping, and it is completely unguarded","description":"registry.rs:359 and :393 map Panel -\u003e CmdContext fields (formula_panel_open / category_panel_open / view_panel_open) in the interactive arms of toggle-panel-visibility and toggle-panel-and-focus. CmdContext's flags are copied from view_state at app.rs:528, so it is the same fact wearing a different struct.\n\nDEMONSTRATED unguarded: swapping Formula and Category at both sites leaves cargo test --workspace --no-fail-fast at 1400 passed, 0 failed. With the swap in place, toggling the formula panel reads the CATEGORY panel's open flag to decide open-vs-close -- a visible wrong behaviour with zero coverage.\n\nPre-existing (identical at 00f7678) and outside improvise-56ek's scope, which consolidated the Panel -\u003e ViewState mapping. The cure is a CmdContext::panel_open(panel) accessor in command/cmd/core.rs, or a command-side sibling of a_panel_command_writes_that_panel_and_no_other.\n\nNOT the same defect: resolve.rs:492 and registry.rs:447 map Panel -\u003e (cursor, collection length). That is a composite -- the length half is genuinely per-panel and per-source -- so the match must exist; it is lockstep.rs's subject and the permutation injection does reach it.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:48Z","created_by":"orchestrator","updated_at":"2026-09-21T03:27:56Z","started_at":"2026-09-21T03:27:56Z","lease_expires_at":"2026-09-21T03:32:56Z","heartbeat_at":"2026-09-21T03:27:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2f97","title":"kb_consistent does not check a clause name cited inside another clause's string","description":"prolog-sessions/kb.pl clauses cross-reference each other by name inside their argument strings - e.g. one doctrine's text cites doctrine(an_advisory_gate_is_not_a_gate) by name. Nothing in kb_consistent/0 checks that a name mentioned inside a string resolves to a clause that exists.\n\nConsequence: dropping or renaming a clause silently dangles every other clause that cites it, and the KB has exactly the fact-stated-twice hazard its own doctrine warns about in prose.\n\nFound while batching wave-3 additions: two queued clauses had this shape, plus a related positional bug - one clause opened 'The companion to the doctrine below', which is meaningless after a loadProgram, since replacing a whole predicate does not preserve 'below'.\n\nFix: a kb_error/1 arm that extracts name-shaped tokens from clause argument strings and requires each to resolve as a doctrine/2, invariant/2 or decision/3 head. Needs a deliberate token rule so ordinary prose does not false-positive - probably only tokens appearing inside doctrine(...)/invariant(...)/decision(...) brackets within the string.\n\nAlso worth ruling on: whether positional language ('below', 'above', 'the previous clause') should be rejected outright, since predicate order is not stable across a loadProgram.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T22:06:35Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:13Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:13Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-669b","title":"The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble","description":"PR #65 single-homed the handshake window: web/shell.js exports HANDSHAKE_TIMEOUT_MS and both web/app.js and harness/tests/shell.spec.js import it. That closes the demonstrated vacuity where an understated spec constant let the whole suite pass against reverted socket routing.\n\nBut the home is single in the TREE, not in what the page RUNS. The spec imports ../../web/shell.js (source); the page loads harness/serve/app/shell.js (the assembled copy). Demonstrated by review: shrink the constant in web/shell.js only, leave serve/ at 8000, revert both socket arms - 6/6 green. The exact vacuity, restored by a stale assemble.\n\nMuch narrower than before (it needs a stale serve/, and a stale serve/ invalidates the whole harness anyway - playwright.config.js already warns about reuseExistingServer adopting another checkout's server). Not urgent.\n\nTwo fixes, either sufficient: one sentence in context/test-evidence/clients.md naming the assemble as the remaining coupling, or have outlastTheWatchdog read the page's own timeoutMs through the window.__improviseShell hook it already uses for timesArmed() and compare.\n\nALSO IN SCOPE, same file, one line: context/test-evidence/clients.md:522 still says HANDSHAKE_TIMEOUT_MS 'restates web/app.js's own constant, because two tests have to outlast it'. Now false twice over - it restates nothing, and one test outlasts it. Line 610 in the same file states the new arrangement correctly. Gotcha 48's third clause: when a fact is stated in more than one place, changing it is a grep, not an edit.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:31:43Z","created_by":"orchestrator","updated_at":"2026-09-20T21:31:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-res9","title":"Doc site: derive the published set, so an unpublished context/ doc fails the build","description":"Found by review on PR #62 (improvise-djoo). scripts/build-doc-site.mjs publishes by a hand-maintained SECTIONS manifest. A commit that adds a context/code/*.md or context/test-evidence/*.md publishes nothing and says nothing: build green, link check green, doc simply absent.\n\nThis is EXACTLY the hardcoded-48 failure mode that PR documents in context/code/doc-site.md section 6 -- silent in precisely the direction the list moves, since the list only ever grows. Verified latent, not live: 23 markdown files under context/ plus docs/design-notes.md, 21 published, and the only two unpublished are context/plan.md and context/cloud-agents.md, which are deliberate omissions argued in doc-site.md section 2.\n\nCURE (the same derive-then-verify applied twice already in that PR): walk context/**/*.md plus docs/design-notes.md, subtract the published set, and fail unless the remainder is exactly a named OMITTED allowlist carrying those two files. An allowlist rather than a count, because the omissions are decisions and a new one should have to be restated in code next to the list rather than only in prose.\n\nSECOND, SMALLER ITEM in the same file: nothing enforces that the generator is the LAST command in web/assemble.sh. It must run last because its link check resolves ../index.html and ../app/ against the filesystem. PR #63 appended a module-import loop and it happened to land above the generator; a future append below it would make the outward-link check pass for the wrong reason -- silently, because the links would resolve against a half-built tree.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:18:44Z","created_by":"wave3-docsite","updated_at":"2026-09-20T21:18:44Z","comments":[{"id":"84622e30-3d60-5c8b-adec-f2990ae70cf5","issue_id":"improvise-res9","author":"wave3-docsite","text":"A TESTED PATCH EXISTS (reviewer, round 6 follow-up). 20 lines, fault-injected in both directions, exit codes observed:\n\n A clean tree at 702c1d4 exit=0\n B new unpublished doc exit=1 \"context/code/newarea.md is neither published nor a named omission\"\n C omission file vanished exit=1 \"context/plan.md is listed as omitted but does not exist\"\n\nDropped in after `const TICKETS = readTickets();`, needs readdirSync added to the node:fs import:\n\n const OMITTED = new Map([\n [\"context/plan.md\", \"a prompt, not a plan; cites a context/SPEC.md that no longer exists\"],\n [\"context/cloud-agents.md\", \"operational notes for agents, not about the program\"],\n ]);\n {\n const onDisk = [];\n (function walk(d) {\n for (const e of readdirSync(join(root, d), { withFileTypes: true })) {\n const rel = `${d}/${e.name}`;\n if (e.isDirectory()) walk(rel);\n else if (e.name.endsWith(\".md\")) onDisk.push(rel);\n }\n })(\"context\");\n onDisk.push(\"docs/design-notes.md\");\n const unaccounted = onDisk.filter((f) =\u003e !PAGE_BY_SOURCE.has(f) \u0026\u0026 !OMITTED.has(f));\n const vanished = [...OMITTED.keys()].filter((f) =\u003e !existsSync(join(root, f)));\n if (unaccounted.length || vanished.length) {\n for (const f of unaccounted) console.error(`doc-site: ${f} is neither published nor a named omission`);\n for (const f of vanished) console.error(`doc-site: ${f} is listed as omitted but does not exist`);\n process.exit(1);\n }\n }\n\nTHE C ARM IS THE POINT, and is the half that would not get written first. Without it, deleting or renaming plan.md leaves a stale entry asserting a reason for a file nobody can find -- the same failure as a dead ticket id, which AGENTS.md already calls worse than no id. With it, an omission must be restated the moment it stops being true, which is what makes OMITTED a decision record rather than a suppression list.\n\nIt also puts the reason in the code beside the list. Today \"why is plan.md unpublished\" lives in doc-site.md section 2 and \"plan.md is unpublished\" lives in the manifest, with nothing connecting them -- the two-copies shape gotcha 48's third paragraph is about.\n\nONE JUDGEMENT LEFT OPEN by the reviewer, worth deciding when this is picked up: the prototype runs at module scope, before any page is generated, so it fails before doing work (good) but is NOT covered by the \"generator runs last\" ordering argument in the header comment. Consequence: a bare `node scripts/build-doc-site.mjs out` outside an assembled tree would report a missing doc BEFORE it reports the 48 outward-link failures. Whether that is a useful early answer or a confusing interleaving of two unrelated failure modes is the call to make.\n\nNOT DONE IN PR #62 deliberately: latent rather than live (verified -- 21 of 23 context docs published, the two omissions are the intended ones), the PR was already on the orchestrator's desk having been extended three times, and the reviewer who found it recommended the follow-up.\n","created_at":"2026-09-20T21:19:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} @@ -199,38 +205,38 @@ {"_type":"issue","id":"improvise-x7cq","title":"A kb.pl inconsistency can still reach the published site","description":"pages-deploy needs [test, wasm, js, browser, harness-lint, build]. The kb job is not among them, in the base or after PR #63 - so a failing 'Knowledge base consistency' check does not block a Pages deploy.\n\nProbably right (kb.pl is not shipped by the site), but it is currently an omission rather than a decision. Either add kb to the needs list, or write down why it is deliberately absent so the next person auditing the graph does not 'fix' it.\n\nAlso in scope, same file, same audit: README's browser section says a deploy happens 'after cargo test, the wasm32 check and the Playwright browser harness have passed'. That list is now incomplete - harness-lint, js and build also gate it. Gotcha 48's rule applies: when a fact is stated in more than one place, changing it is a grep.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:06:02Z","created_by":"orchestrator","updated_at":"2026-09-20T21:06:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4ktt","title":"The js job's bundle-freedom is a convention, not a gate","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 6).\n\nPR #61 states a constraint as load-bearing: the `js` CI job must never need a\nbuilt wasm bundle, because the moment it does it inherits the browser job's\nthree wasm-pack builds and its whole value — seconds instead of ~4m30s —\nevaporates. context/code/clients.md says so in prose.\n\nThe MECHANISM is a shell glob and a directory convention:\n\n js job: node --test web/tests/*.test.js\n browser job: node --test web/tests/bundle/*.test.js\n\nNothing stops someone adding `readFileSync('../pkg/…')` to a file directly in\nweb/tests/. It would pass locally (the bundle is usually built) and fail in the\n`js` job with a confusing ENOENT — or, worse, be \"fixed\" by adding a build step\nto the job, which silently spends the four minutes the split exists to avoid.\n\nThis is the shape the repo already dislikes: a rule stated in prose with no\ngate, where the failure direction looks like ordinary breakage.\n\nPOSSIBLE FIXES, cheapest first:\n1. A node test in web/tests/ that reads its own sibling files and asserts none\n of them mentions `pkg/` or `worker-pkg/` — the same static shape as\n render_surface.test.js, which reads app.js and index.html.\n2. Run the `js` job in a container/step with web/pkg absent, so the dependency\n is impossible rather than discouraged. Costs a clean checkout, which the job\n already has: `web/pkg` is gitignored, so in CI it is ALREADY absent — which\n means (2) is really \"assert it stays that way\", i.e. (1).\n\nNote the constraint is currently satisfied and is checked by nothing.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T20:53:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r0qa","title":"The evidence checker validates only heading-to-test, never test-to-evidence","description":"Found by adversarial review of PR #59, 2026-09-20, as the first case to walk into the gap.\n\nscripts/check-comment-runs.py --check-evidence validates ONE direction: every '### name' heading in context/test-evidence/ must name a test that exists. design-principles §7 states that choice deliberately — 'a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation.'\n\nThe blind spot is the reverse. PR #59 added a Rust test whose doc comment says 'see context/test-evidence/clients.md', and that file has NO entry for the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons. The checker passed (503 headings, all resolving). So the new assertion's proof lived only in the PR body — which is not in the repo, and which design-principles §6 now says is exactly where an argument must not live, because nothing can audit it.\n\nWHY THE ORIGINAL ASYMMETRY IS STILL RIGHT, and why this is a narrower fix than 'check both ways': most tests have no evidence and should not. The check is not 'every test has evidence' — it is 'a test that CITES evidence cites evidence that exists'. That is a different predicate and it has no false positives: a doc comment naming context/test-evidence/\u003cfile\u003e.md (and optionally a heading) is a claim, and the claim is checkable.\n\nNote the failure mode it closes is the one this repo keeps finding: a pointer that reads as a promise the thing exists. Same argument as a dead ticket ID, in another key.\n\n## Steps to Reproduce\n\n1. Add a Rust test whose doc comment cites context/test-evidence/clients.md for a section that does not exist.\n2. python3 scripts/check-comment-runs.py --check-evidence\n3. It passes.","acceptance_criteria":"A test whose doc comment cites context/test-evidence/ for a section that does not exist fails the checker. Demonstrated by pointing a test's doc comment at an absent heading and watching --check-evidence fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:39:27Z","created_by":"orchestrator","updated_at":"2026-09-20T20:39:27Z","comments":[{"id":"503ea75f-8d90-5bdd-bb0e-fa3aa18b0e61","issue_id":"improvise-r0qa","author":"wave3-jstests","text":"A second, sharper instance of the same blind spot, found by adversarial review of PR #61 (2026-09-20). --check-evidence resolves only `### ` headings. context/test-evidence/clients.md uses `#### \"quoted test name\"` for the Playwright specs, which is one `#` deeper — so the checker cannot see them at all. PR #61 deleted the test named at clients.md:547 and the gate stayed green while the heading became exactly the dangling explanation design-principles §7 describes: it read as a promise that a guard existed, for a property that had in fact been lost (the PR #21 close-routing half, lost for the third time). The heading is accurate again only because the follow-up restored the test under its exact original name. Whatever fix this ticket takes should normalise the heading depth or accept both levels; the `####` form is used deliberately for JS test names, which cannot be `### ` because the checker would then demand a Rust `fn` of that name.\n","created_at":"2026-09-20T20:54:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:18:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-21T03:28:12Z","started_at":"2026-09-21T03:28:12Z","lease_expires_at":"2026-09-21T03:33:12Z","heartbeat_at":"2026-09-21T03:28:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-wsaw","title":"The browser ignores category_panel_open, so C toggles nothing visible","description":"RenderModel::category_panel_open has existed since the panel was added and web/app.js has never read it: repaint() calls categoryList(model) unconditionally, so #categories is permanently visible. Verified in Chromium: on a fresh load the client's own view slice says category_panel_open:false while the sidebar is drawn.\n\nConsequences. The TUI draws the category panel only when the flag is set (src/draw.rs:322), so the two front ends disagree about what the panel toggle does. In the browser, Shift-C and Ctrl-C change the mode label and nothing else - the user gets no feedback that the panel opened, which is part of why 'I can't expand categories' reads as broken.\n\nThe DECISION: honour the flag (matching the TUI, but the sidebar then disappears on load and the user must press C to see any category), or keep the always-visible sidebar and treat the flag as not applying to this front end (in which case app.js should stop carrying a field it ignores, and the browser's C should do something else visible - focus styling on the panel, say).\n\nNote fix/web-ui-correctness added #views and #formulas and DOES honour their flags, so the three panels are currently inconsistent with each other. That inconsistency is the concrete cost of leaving this open.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:16Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:59Z","started_at":"2026-09-20T19:30:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8ec3","title":"improvise-io's test binary breaches the 2s per-binary rule on some machines, and the pinned figure is not reproducible","description":"`doctrine(suite_speed)` and design-principles §7 both state the rule that bites\nas **no single test binary over 2 seconds**, and pin it at `22d43b2` with\n\"longest single binary 1.42s\".\n\nThat number is not reproducible across machines. Measured on PR #51's branch:\n\n- This agent's machine: `improvise_io` lib, 1.42-1.68s over four runs.\n- The reviewing agent's machine, same branch: **3.70-5.24s**, and **4.82s on\n `main`** — so the breach is pre-existing, not introduced by #51.\n\nTwo things follow, and the second is the one worth doing.\n\n**The stated number is a measurement of one machine presented as a property of\nthe suite.** `doctrine(suite_speed)` already says \"re-MEASURE rather than\nre-assert\", and this is what that costs when the measurement is 3x apart on two\nmachines: a PR body claiming \"longest single binary 1.47s\" is checkable and\nfalse for the reader who checks it, which is worse than no claim.\n\n**`improvise-io` is the binary at issue, and it is proptest-dominated.** Its\nslow half is the grammar-walking generator in `persistence/parse.rs` plus the\n`.improv` roundtrip proptests, at the default 256 cases.\n`doctrine(proptest_cases)` says constrain the strategy rather than lower the\ncount, so the work is to look at what the generator actually produces — a\ngrammar walk with no size bound generates deeply nested documents whose cost is\nnot uniform across cases.\n\n## Steps to Reproduce\n\n1. `cargo test -p improvise-io --lib` and read the `finished in` it prints.\n2. Compare against the \"1.42s\" pinned in `context/design-principles.md` §7 and\n in `doctrine(suite_speed)`.\n3. On a loaded or slower machine the figure exceeds the stated 2-second\n per-binary limit, on `main` as well as on any branch.\n","acceptance_criteria":"Either improvise-io's lib binary is brought under 2s on the slow measurement (by constraining the .improv grammar generator's strategy, NOT by lowering proptest cases below 256 — doctrine(proptest_cases)), or design-principles §7 and doctrine(suite_speed) stop pinning a single machine's number and say what the rule is measured against. A figure that is 3x apart on two machines is not checkable, and doctrine(suite_speed) already says re-measure rather than re-assert.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:53:08Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wd6n","title":"A fit may still not split on col.missing or a date sub-feature","description":"improvise-4zl7 closed the categorical third of improvise-8xhy: a one-hot over a\nreal category is now emitted as `Cat IS Item`. Two artefact families remain\n`Spelling::Artefact` and are still refused by `fitted_formula`:\n\n- `col.missing`, the 0/1 indicator `preprocess` adds for a column with any\n missing cell. It means \"this record has no value for measure X\" — a question\n the formula language has no word for at all. `Expr::Ref` over an absent cell\n is an error, not a zero, so there is no existing spelling to reuse.\n- the date sub-features `when.epoch_day`, `when.year`, `when.month`,\n `when.day`, `when.dow`, and with a time component `when.hour`, `when.minute`.\n These need date arithmetic improvise does not have anywhere: a `.improv` cell\n holding a date is `CellValue::Text`, and nothing in `eval_expr` reads one.\n\nBoth are visible to a user only as a refusal naming the feature, which is the\nright interim. Closing either is a formula-language change, not an emitter\nchange, and they are independent of each other.\n\n## Steps to Reproduce\n\n1. Build a document whose only usable feature is a measure with some cells\n cleared, so `preprocess` emits `\u003cmeasure\u003e.missing`, and whose target is best\n explained by which records have that measure at all.\n2. `fit-measure \u003ctarget\u003e`.\n3. The champion splits on `\u003cmeasure\u003e.missing` and `fit_measure` refuses with\n \"the fitted tree splits on [\\\"\u003cmeasure\u003e.missing\\\"], which the formula\n language cannot spell at all\".\n\nThe date half reproduces the same way with a column of ISO dates: see\n`crates/gp-tree/src/data.rs::build_date` for the sub-features it emits, and\n`the_missing_flag_and_the_date_sub_features_are_refused_too` in\n`crates/improvise-gp/src/lib.rs` for the refusal asserted.\n","acceptance_criteria":"A fitted tree that splits on a col.missing flag or a date sub-feature is emitted as a formula that evaluates, or the family is declined in writing with the reason. Whichever way it goes, the two families are answered separately: they need different vocabulary. improvise-4zl7's route-then-aggregate rule (context/code/core.md 13) must keep holding for whatever is emitted.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T18:50:51Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T18:50:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cl7y","title":"A browser reports \"Formula added\" for a formula only the host can parse","description":"Found by the lockstep test improvise-e24v added for commit-buffer, once it\nwas widened to cover the Formula kind (the review's Finding 2).\n\nCommitBuffer emits its optimistic status FIRST and the add effect LAST, so on\nthe host a parse failure overwrites \"Formula added\" with \"Formula error: ...\"\n(improvise-6t3). The client mirrors the command line for line, which is\ncorrect -- but it cannot parse, so it folds \"Formula added\" and stops there.\nThe two ends then disagree on the status line, and the host's correction is a\nViewOnly effect that reaches nobody.\n\nThe user sees a browser confidently report a formula it did not add. This is\nthe same room improvise-fdt6 describes from the other side.\n\nNot a defect in the mirror: doctrine(mirror_the_cmd_line_for_line) says the\nclient reproduces its Cmd, and it does. The gap is that AddFormula's success\nis not observable to the composer. Pinned, not fixed, by\na_formula_the_client_cannot_parse_diverges_on_the_status_line, which asserts\nBOTH sides of the divergence so a fix has to come there and change it.\n\n## Steps to Reproduce\n1. Open the browser client and press F, then n, to open the formula prompt.\n2. Type an unparseable formula -- \"P=\" -- and press Enter.\n3. The status line reads \"Formula added\". No formula was added, and the host's\n \"Formula error\" never arrives.","acceptance_criteria":"A client that cannot parse does not claim success. Either the status\nfor a fallible add is withheld until the host answers (which needs the host to\naddress a status back to the composer, as ClientFrame::Save's rejection does),\nor the formula text is validated where both ends can do it. Whichever, the\ntest named above must change, because it currently asserts the divergence.","notes":"Checked during the adversarial review of PR #50, so nobody re-litigates it:\n\"the client cannot parse\" is true of the DEPENDENCY GRAPH, not merely of the\ncode as written. The repo map lists improvise-formula as a browser-bundle\ncrate, which reads as though parse_formula were a free call away -- it is not.\nimprovise-formula is a dependency of neither improvise-protocol nor\nimprovise-wasm-client; it reaches the bundle only through improvise-io and\nimprovise-command, neither of which a thin client has.\n\nSo closing this is a dependency decision (put improvise-formula on the thin\nclient's graph, and pay whatever that costs the bundle) rather than a call\nsomebody forgot to make. The other route -- have the host address a status\nback to the composer, the way ClientFrame::Save's rejection already does --\ncosts no dependency and is probably the cheaper answer.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pubt","title":"[bug] display_value_in reads the host's drill edits against a foreign layout","description":"Found by the adversarial review of PR #48 (improvise-jytm).\n\n`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`,\nand that function consults `App::drill_state` — the host's staged, uncommitted drill\nedits, which are session-local residue belonging to whoever opened the drill\n(gotcha 30, improvise-h0b).\n\nBefore improvise-jytm every client shared one layout, so a client seeing the host's\npending drill text was at least seeing it against the coordinates it was staged\nagainst: shared, arguably wrong, but coherent. That is improvise-tom1's territory.\n\nAfter the split a client can be on a DIFFERENT view, and `grid_in` derives a separate\n`GridLayout` for it. `display_value_in` is then handed that foreign layout while\n`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the HOST's\ndrill layout (gotcha 1). Row indices are positions, and positions in one layout mean\nnothing in another — so if that path is reachable, a client is shown another client's\nuncommitted text against coordinates it was never staged for.\n\nWhy it is probably not reachable today, and why \"probably\" is the reason to file it\nrather than to fix it blind: the `synthetic_record_info(key)` arm only fires for the\nsynthetic `(_Index, _Dim)` keys that records/drill mode produces, and a foreign\nlayout built by `GridLayout::new` carries no frozen records, so it should not produce\none. That argument is a read, not a test, and it is the kind of read this repo has\nbeen wrong about before.\n\nTwo things to do, in order: write the test that decides whether it is reachable, and\nif it is, stop `display_value_in` consulting `drill_state` for a layout that is not\nthe host's — the drill snapshot is the host's session-local residue and a foreign\nlayout is by definition not its addressee.\n\n## Steps to Reproduce\n\nNot confirmed reachable; establishing that is the first half of the work.\n\n1. Host opens a drill and types into a cell without committing, so\n `App::drill_state.pending_edits` is non-empty.\n2. A connected client switches to a different view (`Command::SwitchView`), so\n `grid_in` derives a layout that is not `App::layout`.\n3. Project that client's grid and read `CellDisplay::edit_text`.\n\nIf any cell's `edit_text` comes from `pending_edits`, that is the bug: the row index\nthose edits are keyed by addresses a row in the host's drill, not in this client's\nlayout.\n","acceptance_criteria":"A test establishes whether the drill arm of display_value_in is reachable with a layout that is not App::layout. If it is, drill_state is not consulted for a foreign layout. If it is not, the test stays as the guard that says so, because the argument for unreachability is currently a read rather than a check.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:17:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cl7y","title":"A browser reports \"Formula added\" for a formula only the host can parse","description":"Found by the lockstep test improvise-e24v added for commit-buffer, once it\nwas widened to cover the Formula kind (the review's Finding 2).\n\nCommitBuffer emits its optimistic status FIRST and the add effect LAST, so on\nthe host a parse failure overwrites \"Formula added\" with \"Formula error: ...\"\n(improvise-6t3). The client mirrors the command line for line, which is\ncorrect -- but it cannot parse, so it folds \"Formula added\" and stops there.\nThe two ends then disagree on the status line, and the host's correction is a\nViewOnly effect that reaches nobody.\n\nThe user sees a browser confidently report a formula it did not add. This is\nthe same room improvise-fdt6 describes from the other side.\n\nNot a defect in the mirror: doctrine(mirror_the_cmd_line_for_line) says the\nclient reproduces its Cmd, and it does. The gap is that AddFormula's success\nis not observable to the composer. Pinned, not fixed, by\na_formula_the_client_cannot_parse_diverges_on_the_status_line, which asserts\nBOTH sides of the divergence so a fix has to come there and change it.\n\n## Steps to Reproduce\n1. Open the browser client and press F, then n, to open the formula prompt.\n2. Type an unparseable formula -- \"P=\" -- and press Enter.\n3. The status line reads \"Formula added\". No formula was added, and the host's\n \"Formula error\" never arrives.","acceptance_criteria":"A client that cannot parse does not claim success. Either the status\nfor a fallible add is withheld until the host answers (which needs the host to\naddress a status back to the composer, as ClientFrame::Save's rejection does),\nor the formula text is validated where both ends can do it. Whichever, the\ntest named above must change, because it currently asserts the divergence.","notes":"Checked during the adversarial review of PR #50, so nobody re-litigates it:\n\"the client cannot parse\" is true of the DEPENDENCY GRAPH, not merely of the\ncode as written. The repo map lists improvise-formula as a browser-bundle\ncrate, which reads as though parse_formula were a free call away -- it is not.\nimprovise-formula is a dependency of neither improvise-protocol nor\nimprovise-wasm-client; it reaches the bundle only through improvise-io and\nimprovise-command, neither of which a thin client has.\n\nSo closing this is a dependency decision (put improvise-formula on the thin\nclient's graph, and pay whatever that costs the bundle) rather than a call\nsomebody forgot to make. The other route -- have the host address a status\nback to the composer, the way ClientFrame::Save's rejection already does --\ncosts no dependency and is probably the cheaper answer.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T03:28:13Z","started_at":"2026-09-21T03:28:13Z","lease_expires_at":"2026-09-21T03:33:13Z","heartbeat_at":"2026-09-21T03:28:13Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pubt","title":"[bug] display_value_in reads the host's drill edits against a foreign layout","description":"Found by the adversarial review of PR #48 (improvise-jytm).\n\n`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`,\nand that function consults `App::drill_state` — the host's staged, uncommitted drill\nedits, which are session-local residue belonging to whoever opened the drill\n(gotcha 30, improvise-h0b).\n\nBefore improvise-jytm every client shared one layout, so a client seeing the host's\npending drill text was at least seeing it against the coordinates it was staged\nagainst: shared, arguably wrong, but coherent. That is improvise-tom1's territory.\n\nAfter the split a client can be on a DIFFERENT view, and `grid_in` derives a separate\n`GridLayout` for it. `display_value_in` is then handed that foreign layout while\n`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the HOST's\ndrill layout (gotcha 1). Row indices are positions, and positions in one layout mean\nnothing in another — so if that path is reachable, a client is shown another client's\nuncommitted text against coordinates it was never staged for.\n\nWhy it is probably not reachable today, and why \"probably\" is the reason to file it\nrather than to fix it blind: the `synthetic_record_info(key)` arm only fires for the\nsynthetic `(_Index, _Dim)` keys that records/drill mode produces, and a foreign\nlayout built by `GridLayout::new` carries no frozen records, so it should not produce\none. That argument is a read, not a test, and it is the kind of read this repo has\nbeen wrong about before.\n\nTwo things to do, in order: write the test that decides whether it is reachable, and\nif it is, stop `display_value_in` consulting `drill_state` for a layout that is not\nthe host's — the drill snapshot is the host's session-local residue and a foreign\nlayout is by definition not its addressee.\n\n## Steps to Reproduce\n\nNot confirmed reachable; establishing that is the first half of the work.\n\n1. Host opens a drill and types into a cell without committing, so\n `App::drill_state.pending_edits` is non-empty.\n2. A connected client switches to a different view (`Command::SwitchView`), so\n `grid_in` derives a layout that is not `App::layout`.\n3. Project that client's grid and read `CellDisplay::edit_text`.\n\nIf any cell's `edit_text` comes from `pending_edits`, that is the bug: the row index\nthose edits are keyed by addresses a row in the host's drill, not in this client's\nlayout.\n","acceptance_criteria":"A test establishes whether the drill arm of display_value_in is reachable with a layout that is not App::layout. If it is, drill_state is not consulted for a foreign layout. If it is not, the test stays as the guard that says so, because the argument for unreachability is currently a read rather than a check.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T03:27:55Z","started_at":"2026-09-21T03:27:55Z","lease_expires_at":"2026-09-21T03:32:55Z","heartbeat_at":"2026-09-21T03:27:55Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T03:46:59Z","started_at":"2026-09-21T03:28:15Z","closed_at":"2026-09-21T03:46:59Z","close_reason":"Closed","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.\nDONE except SortData, which is improvise-gfvj.\n\nClosed as a consequence: improvise-7kvw (SetPageSelection under prune_empty), pinned by paging_across_a_prune_that_drops_a_row_invalidates_the_cursor. Note 7kvw is claimed by claude-agent-cmd with an expired lease -- if that agent also patched it per-effect, the two changes overlap and the per-effect guard should go, since the observation covers it.\n\nAlso covered, with no arm of their own: AddFormula/RemoveFormula (added to the shared reshaping_commands table), TogglePruneEmpty on a grid with nothing empty, and HideItem/RemoveItem on a category parked on Axis::None -- all three now answer by observation.\n\nCost measured as the AC required, benches/reshape_observation.rs: +13% to +20% on one SetCell batch at 400 / 9.6k / 18k / 90k cells. The batch is dominated by rebuild_layout and recompute_formulas.\n\nTHE DESIGN POINT WORTH CARRYING: the cursor's question and layout_version's question are NOT the same, so this is a second observation beside view_shape and not a replacement for it. AddCategory of an empty category moves no row or column but does change the coordinates a client's CellKey hangs off. Unifying them broke tests in both directions -- that is how the distinction was found, not by reasoning.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:21Z","started_at":"2026-09-21T03:28:18Z","closed_at":"2026-09-21T03:47:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T03:28:12Z","started_at":"2026-09-21T03:28:12Z","lease_expires_at":"2026-09-21T03:33:12Z","heartbeat_at":"2026-09-21T03:28:12Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rm4s","title":"The evidence check accepts headings naming tests that never run","description":"Found by adversarial review of PR #44 (finding F9), demonstrated with a built fixture that returned 'test-evidence: 3 entries, all name a test that exists', exit 0.\n\nscripts/check-comment-runs.py --check-evidence scrapes source text line by line for 'fn NAME' preceded by a test attribute. Three shapes defeat it:\n\n1. A test behind a cfg feature nobody enables — compiled out, never runs.\n2. A test attribute and fn inside a STRING LITERAL (an r-hash fixture). The scanner reads inside string literals, so this names a test that does not exist at all.\n3. A #[cfg(target_arch = wasm32)] test. This is the realistic one for THIS repo: it has real wasm32-only code (improvise-io/src/opfs.rs, improvise-worker-server) and a wasm CI job that only runs cargo check. Evidence keyed to such a test is accepted while cargo test --workspace never executes it.\n\nAll three are the same defect one layer up from the one the gate exists to prevent: evidence for a check that never runs.\n\nCheap fix the reviewer proposed and I verified is currently free: diff the heading set against the test names cargo actually reports (cargo test -- --list, or the run log) instead of scraping source. Cross-checking all 451 current headings against the executed test names from a real cargo test run came back EMPTY — every heading names a test that really ran — so adopting the stricter source costs nothing today.\n\n## Steps to Reproduce\n\n1. Create context/test-evidence/x.md with a heading naming a_test_that_is_only_text.\n2. Put a #[test] fn a_test_that_is_only_text() {} inside an r-hash string constant in any scanned .rs file.\n3. python3 scripts/check-comment-runs.py --check-evidence -\u003e passes, exit 0.","acceptance_criteria":"A heading naming a cfg-gated, wasm32-only, or string-literal 'test' is rejected; the check is diffed against the test names cargo actually reports rather than scraped from source text. Demonstrated by adding each of the three shapes and watching the check fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:06:00Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ahdf","title":"repo-map gotchas 36 and 43 still name Sessions::apply as the production door","description":"Found by adversarial review of PR #45 (finding F11).\n\nVerified: Sessions::apply has NO production caller. Host calls apply_checked (crates/improvise-command/src/ui/host.rs:144); apply (project.rs:774) delegates to apply_checked (:783) after Checked::against, and its only call sites are tests. context/code/command.md already records this.\n\nTwo repo-map gotchas still say otherwise:\n * gotcha 36: 'Sessions::apply(app, id, cmd) is the only way a connected client's Command reaches the server'\n * gotcha 43: 'Sessions::apply answers once, before the dispatch'\n\nBoth are PRE-EXISTING rather than introduced by #45 — but they are the siblings of the sentence #45 fixed in design-principles §5, and gotcha 48 is the rule they break: 'when a fact is stated in more than one place, changing it is a grep, not an edit.' #45 fixed §5 and the now-false note in command.md; these two were left because they sit inside long paragraphs whose surrounding argument needs reading, not a sed.\n\n## Steps to Reproduce\n\n1. grep -n 'Sessions::apply' context/repo-map.md\n2. Compare against crates/improvise-command/src/ui/host.rs:144 and the note in context/code/command.md.\n\nNote the claims are not merely imprecise: a future agent told that apply is 'the only way' may add a caller to it, bypassing the CheckedBatch frame-level answer that host.rs actually performs.","acceptance_criteria":"Gotchas 36 and 43 name apply_checked where they mean the door production uses, or say explicitly that apply is the single-command convenience its tests drive; a grep for 'Sessions::apply' across context/ finds no claim that contradicts context/code/command.md.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-7kvw","depends_on_id":"improvise-ni6k","type":"related","created_at":"2026-09-20T18:24:00Z","created_by":"orchestrator","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T03:28:13Z","started_at":"2026-09-21T03:28:13Z","lease_expires_at":"2026-09-21T03:33:13Z","heartbeat_at":"2026-09-21T03:28:13Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T03:28:15Z","started_at":"2026-09-21T03:28:15Z","lease_expires_at":"2026-09-21T03:33:15Z","heartbeat_at":"2026-09-21T03:28:15Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-21T03:27:54Z","started_at":"2026-09-21T03:27:54Z","lease_expires_at":"2026-09-21T03:32:54Z","heartbeat_at":"2026-09-21T03:27:54Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-7kvw","depends_on_id":"improvise-ni6k","type":"related","created_at":"2026-09-20T18:24:00Z","created_by":"orchestrator","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mbmf","title":"An assertion message in gp-tree states the opposite of what it checks","description":"crates/gp-tree/src/fit.rs, in the_ranking_is_ordered_shares_with_the_unused_features_dropped:\n\n assert_eq!(ranked.len(), 2, \"the zero-importance feature is listed\");\n\nimportance_ranking is given three features, one of zero importance, and the\nassertion requires TWO entries back — i.e. the zero one was dropped. The\nmessage says it is listed.\n\nThe message is only ever read on failure, which is exactly when someone is\ntrying to work out what the intended behaviour was; here it would tell them\nthe opposite. The test name and the doc comment both say 'dropped', so the\nmessage is the only wrong copy of three.\n\n## Steps to Reproduce\n\n1. Change importance_ranking to list zero-importance features at 0%.\n2. cargo test -p gp-tree the_ranking_is_ordered\n3. The failure reads 'the zero-importance feature is listed' — describing the\n new (wrong) behaviour as though it were the requirement.\n\nFound during the 2026-09-20 comment-cleanup wave review.","acceptance_criteria":"The message names what the assertion actually requires (the zero-importance feature is dropped); a deliberate injection of a 3-entry ranking makes the test fail with a message that reads correctly.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:49:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:12:59Z","closed_at":"2026-09-20T18:12:59Z","close_reason":"Fixed in PR #45 (commit 56ff51e): the assertion message in the_ranking_is_ordered_shares_with_the_unused_features_dropped now says 'the zero-importance feature is dropped', matching what the assertion checks and what the test name and doc comment already said. The Closes: trailer was written before the bead was actually closed — flagged by adversarial review as a dead promise of the same class as a dead ticket ID.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-23qd","title":"The Projection::Basis record set is derived twice, and the second copy is the tests' yardstick","description":"Found by the post-wave code consistency review.\n\nThe set of records a client's Projection::Basis should carry is derived TWICE:\n * crates/improvise-command/src/ui/project.rs:883-892 — ClientSession::basis_update, the producer\n * crates/improvise-command/src/ui/project.rs:1350-1364 — `server_truth`, a #[cfg(test)] helper\n\nand `server_truth` IS THE GROUND TRUTH the tests measure against (used at :1632, :1660, :2511). It re-implements \"which records does this client's grid name\" rather than asking the producer.\n\nSo a change to WHICH records get published would move both together and be invisible to every test measured against it. That is doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing) in its most dangerous form — not a helper that fabricates an input, but a helper that fabricates the EXPECTED OUTPUT.\n\nThe fix is small: have server_truth call the same filter, or extract it as a pub(crate) fn both use. What must not happen is the two staying separate with a comment saying they agree.\n\n## Steps to Reproduce\n\nNot a runtime failure — a test-integrity one, which is why it is filed rather than patched.\n\n1. Change which records `ClientSession::basis_update` publishes (`project.rs:883-892`).\n2. Make the matching change in `server_truth` (`project.rs:1350-1364`), the `#[cfg(test)]` helper the basis tests measure against at `:1632`, `:1660` and `:2511`.\n3. Every test passes, whatever the change was — including one that publishes the wrong set — because the yardstick moved with the thing it measures.\n","acceptance_criteria":"server_truth calls the same filter the producer uses, or the filter is extracted as a pub(crate) fn with both calling it. Changing which records are published then moves the producer and the yardstick together by construction, and a test proves it: alter the filter and watch the basis tests fail rather than silently track it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e1z5","title":"AppMode::minibuffer and strands_the_user opt out of exhaustiveness, and both fail silently","description":"Found by the post-wave code consistency review. Two instances of one shape, filed together because the fix is identical and trivial.\n\n1. `AppMode::minibuffer()` (crates/improvise-protocol/src/lib.rs:322-332) ends in `_ =\u003e None`, where its sibling `panel()` (:303-320) is exhaustive. SavePrompt — added this wave — had to be listed BY HAND, and the compiler could not have caught its omission. The failure mode is silent: a text-entry mode whose minibuffer is invisible to EnterMode's clear, to keys.rs::entered, and to the TextEntry builder.\n\n2. `strands_the_user` (crates/improvise-wasm-client/src/keys.rs:498-503) is a `matches!` over AppMode. Its own doc says \"a third such prompt belongs here\" — and a fourth would answer false in silence, which is precisely the dead-room bug found against SavePrompt in the first place (doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key)).\n\nBoth are one or-pattern arm away from being exhaustive, at no cost. The repo's compiler-exhaustiveness theme says business rules go through the enum so the compiler names every site; these two opted out.\n","acceptance_criteria":"Both are exhaustive matches over AppMode with no wildcard, so adding a variant fails the build until someone answers for it. Verified by adding a throwaway AppMode variant and watching both sites fail to compile.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:56Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-seik","title":"Client::viewport is kept for a reason web/app.js falsified in the same wave","description":"Found by the post-wave code consistency review.\n\ncrates/improvise-wasm-client/src/lib.rs:141-158. `Client::viewport` is a #[wasm_bindgen]-exported door with NO PRODUCTION CALLER. Its doc says it is \"kept because web/app.js already spells it and retiring it is a co-ordinated change\" — and web/app.js:137-144 now says the OPPOSITE in its own comment and calls `set_viewport`.\n\nSo the justification for keeping it was falsified IN THE SAME WAVE that wrote it.\n\nThis matters more than an ordinary unused function because its documented failure mode is SILENT: calling `viewport` instead of `set_viewport` does not error, it quietly turns a whole feature off — the window is not recorded, so cursor keys stop resolving locally and every one of them becomes an upstream black hole (doctrine(an_upstream_key_is_a_black_hole)). Gotcha 47 is explicit that the announced window is NOT derivable from row_labels.len(), which is the window clipped to the grid.\n\ndoctrine(a_documented_door_with_no_caller_is_the_dangerous_kind), with the justification removed.\n\nSIBLING, same file, lower severity: `Client::render_state` (lib.rs:120-124) is exported to JS with ZERO callers anywhere, while the crate's module doc (lib.rs:8) still names it as what the DOM renderer consumes. The page consumes `render_model`. `revision()` is likewise uncalled by the page, but `on_frame` returns the revision anyway, so that one is harmless.\n","acceptance_criteria":"Client::viewport is deleted and its tests move onto set_viewport, or its doc states a reason that is true at HEAD. Same for render_state: deleted, or the crate module doc stops naming it as what the DOM renderer consumes. Check web/app.js and the generated .d.ts before deleting — the page is not compiled against the crate, so a removed export is a runtime failure there, not a build error.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:55Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:55Z","labels":["area:browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-748f","title":"A drill exited with no edits, and view-back, mark the document dirty","description":"Found by the independent adversarial review of PR #26, sharpening something that PR had recorded in an assertion but framed too kindly.\n\nTHE BEHAVIOUR: ApplyAndClearDrill::apply RETURNS IMMEDIATELY when drill_state is None -- it writes nothing at all -- but declares EffectKind::Model(CellData), so App::apply_effects raises model_state.dirty anyway. And ViewNavigate { forward: false } (view-back) emits that effect UNCONDITIONALLY, whether or not a drill was ever opened.\n\nEXECUTED: on a clean document with drill_state == None, applying [ApplyAndClearDrill] gives dirty == true.\n\nFAILURE SCENARIO: open a document, press view-back, press :q. The quit is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nWHY THE \"SAME AS LoadModel\" FRAMING IS WRONG, and this is the part worth keeping: a SUCCEEDING LoadModel leaving the document dirty is a CORRECT answer to a real divergence -- LoadModel does not move file_path, so the in-memory document really is a different one from the file the path names, and PR #26's own test pins that REASON rather than just the flag. This is a WRONG answer to no divergence at all, on a routine navigation keypress. Calling them the same over-approximation flattens a distinction another test in the same PR was making.\n\nPRE-EXISTING: true since improvise-gv9r's fix (0b298d7) made dirtiness come off the effect kind, and invisible until now only because ApplyAndClearDrill's tests drove apply directly, bypassing apply_effects -- they measured the effect in isolation rather than the path production takes.\n\nTHE OBVIOUS FIX IS UNAVAILABLE, stated so the next person does not try: Effect::kind(\u0026self) takes no App, so the classification CANNOT be state-dependent. An effect cannot declare \"Model if there is a drill to commit, ViewOnly otherwise\".\n\nTHE CHEAP FIX ALREADY EXISTS IN THE CODEBASE: apply_effects already computes a per-record XOR fingerprint diff whenever a batch declares a CellData effect (gotcha 40), so \"did any record actually change\" is ALREADY KNOWN at the end of the batch. Raising dirty off that observation for the CellData tier is strictly more accurate and reuses machinery that is already there for the basis tiers. It does NOT cover LayoutGeometry or WholeWorkbook, so it is a partial move and a design call -- which is why this is a bead and not a patch.\n\nDIRECTION OF FAILURE IS THE SAFE ONE (a spurious prompt, never a lost edit), which is why it is P3 rather than higher. But it is a wrong answer the user meets on an ordinary keypress, and the fix reuses machinery that exists.\n\n## Steps to Reproduce\n\n1. Open any document and make no edit of any kind.\n2. Press `view-back`, which emits `ApplyAndClearDrill` unconditionally.\n3. `ApplyAndClearDrill::apply` returns immediately because `drill_state` is `None` and writes nothing — but it declares `EffectKind::Model(CellData)`, so `App::apply_effects` raises `model_state.dirty`.\n4. Press `:q`. It is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nDirectly executable: on a clean document with `drill_state == None`, applying `[ApplyAndClearDrill]` gives `dirty == true`.\n","acceptance_criteria":"A drill exited with no edits, and view-back on a document with no drill open, leave the document clean. Effect::kind cannot be state-dependent (it has no App), so the fix is elsewhere: App::apply_effects already computes the per-record fingerprint diff for CellData batches, so 'did any record actually change' is already known at the end of the batch. Raising dirty off that observation covers the CellData tier; say explicitly what LayoutGeometry and WholeWorkbook still over-report and why that is acceptable.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:15Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jq8l","title":"web/app.js never calls scrollIntoView, so a selection can sit outside the window","description":"Surfaced by PR #23's viewport-reservation discussion (improvise-anr9) and confirmed by reading: web/app.js CALLS scrollIntoView NOWHERE AT ALL.\n\nSo a selection that moves outside the rendered window is simply not brought into view. The grid repaints with the cursor somewhere the user cannot see, and the only way back is to scroll manually until it appears.\n\nPRE-EXISTING AND NOT GROUP-SPECIFIC. It came up while arguing about whether inline group rows should be reserved in extent(), and the conclusion there was that the residual gap is \"neither new nor group-specific\" -- this is that gap, filed separately so it does not read as a consequence of the frame work.\n\nNOTE WHAT IT INTERACTS WITH, because the fix is not simply calling scrollIntoView:\n * The browser's frame is WINDOWED and ViewState::selected is ABSOLUTE (gotcha 42). render.rs reconciles by ADDING the scroll offset -- `r + cursor.row_offset == sel_row` -- which is why there is no underflow path and why a selection above the window simply never matches. A scroll-into-view fix must reconcile the same way or it will fight the projection.\n * Client::set_viewport records the window the page announced (gotcha 47), and the announced window is NOT derivable from row_labels.len(), which is the window CLIPPED to the grid. The two differ precisely at the bottom edge, which is where this bug lives.\n * Moving the selection is the client's own business -- \"the model is shared, the UI is not\" -- so this is local work, not a projection change.\n\nUNTESTABLE UNTIL THE HARNESS GROWS: harness/tests/shell.spec.js renders web/app.js in three browsers, but web/demo.improv is small enough that nothing scrolls. See the sibling bead asking for a bigger, grouped fixture.\n\n## Steps to Reproduce\n\n1. Open a document in the browser with more rows than fit the window.\n2. Move the selection below the visible window (cursor keys resolve client-side since improvise-bpq2).\n3. The grid repaints with the cursor outside the rendered window and nothing scrolls — `web/app.js` calls `scrollIntoView` nowhere at all.\n4. The only way back is to scroll manually until the selection reappears.\n\nNot currently reachable by the Playwright harness: `web/demo.improv` is small enough that nothing scrolls. See improvise-plcx.\n","acceptance_criteria":"A selection moved outside the rendered window brings itself into view. The fix reconciles the absolute selection against the announced window by ADDING the scroll offset, the way render.rs does (there is no underflow path, and a selection above the window simply never matches). It uses the window Client::set_viewport recorded, not row_labels.len(), which is the window clipped to the grid and differs exactly at the bottom edge. Verified in the harness once improvise-plcx gives it a document big enough to scroll.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:14Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-jq8l","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:21:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gd83","title":"AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group","description":"Found by Copilot on PR #23 (improvise-anr9), DECLINED there with reasons on the thread, and filed so it is a decision rather than a rediscovery. COPILOT IS RIGHT AND THIS IS A REAL BUG.\n\nAxisFrame::group_of answers with the NEAREST PRECEDING GROUP HEADER, which is not the same as the item's group membership. GridLayout::expand_category emits a header on ENTERING a group and NO BOUNDARY ON LEAVING ONE, so an ungrouped item appearing after a grouped run inherits that group -- in the frame, and in the span the TUI paints.\n\nIT IS GridLayout's BUG, NOT THE FRAME'S. The TUI has painted that span forever; improvise-anr9 faithfully reproduced the existing rule when it moved the frame onto the wire, which is what a migration should do (doctrine(characterization_survives_the_move)). What changed is that the rule is now WRITTEN DOWN and therefore visible.\n\nWHY IT WAS RIGHT TO DECLINE IT THERE: fixing it means changing AxisEntry in improvise-core to carry a group-end boundary, and changing what the TUI DRAWS -- which would destroy `the_whole_frame_is_unchanged`, the exact-buffer characterization that was PR #23's only evidence the migration changed nothing. Same shape as improvise-q0vb's decline on PR #12: a finding that is correct and still wrong to fix in that PR, because of what it costs in evidence.\n\nTHE EVIDENCE IS ALREADY WRITTEN AND IT IS THE GOOD KIND. `the_two_front_ends_draw_the_same_column_header` asserts the WRONG answer as the current one -- Q1 spanning all six columns -- with THE CORRECTED EXPECTATION `[(Some(\"Q1\"), 4), (None, 2)]` WRITTEN BESIDE IT. So the day someone fixes AxisEntry, that test fails and the fix is one line of expectation away, with the intended answer already argued. The doc on group_of states the rule and names the defect.\n\nWHAT THE FIX COSTS, so nobody starts it casually: AxisEntry is in improvise-core; a group-end boundary changes the shape every consumer of row_items/col_items walks; and the characterization buffers in src/ui/grid.rs move, which means re-pinning them deliberately and saying so in the commit, exactly as improvise-q0vb's acceptance clause required.\n\n## Steps to Reproduce\n\n1. Build an axis with a grouped run followed by an UNGROUPED item — `expand_category` emits a header on entering the group and no boundary on leaving it.\n2. Ask `AxisFrame::group_of(idx)` for the ungrouped item's index.\n3. It answers with the nearest preceding header, so the ungrouped item is reported as a member of the group that ended before it.\n4. The TUI paints the same span, so this is `GridLayout`'s rule faithfully reproduced rather than a frame defect.\n\n`the_two_front_ends_draw_the_same_column_header` in `tests/grid_frame_agreement.rs` asserts the wrong answer as the current one, with `THE_MEMBERSHIP_CORRECT_SPANS` and an `assert_ne!` beside it that starts failing the day this is fixed.\n","acceptance_criteria":"AxisEntry carries a group-end boundary, so group_of answers membership rather than nearest-preceding. THE_MEMBERSHIP_CORRECT_SPANS in tests/grid_frame_agreement.rs becomes the asserted value and its assert_ne! is removed. The TUI's painted spans change, so the characterization buffers move deliberately and the commit says so.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:13Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-23qd","title":"The Projection::Basis record set is derived twice, and the second copy is the tests' yardstick","description":"Found by the post-wave code consistency review.\n\nThe set of records a client's Projection::Basis should carry is derived TWICE:\n * crates/improvise-command/src/ui/project.rs:883-892 — ClientSession::basis_update, the producer\n * crates/improvise-command/src/ui/project.rs:1350-1364 — `server_truth`, a #[cfg(test)] helper\n\nand `server_truth` IS THE GROUND TRUTH the tests measure against (used at :1632, :1660, :2511). It re-implements \"which records does this client's grid name\" rather than asking the producer.\n\nSo a change to WHICH records get published would move both together and be invisible to every test measured against it. That is doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing) in its most dangerous form — not a helper that fabricates an input, but a helper that fabricates the EXPECTED OUTPUT.\n\nThe fix is small: have server_truth call the same filter, or extract it as a pub(crate) fn both use. What must not happen is the two staying separate with a comment saying they agree.\n\n## Steps to Reproduce\n\nNot a runtime failure — a test-integrity one, which is why it is filed rather than patched.\n\n1. Change which records `ClientSession::basis_update` publishes (`project.rs:883-892`).\n2. Make the matching change in `server_truth` (`project.rs:1350-1364`), the `#[cfg(test)]` helper the basis tests measure against at `:1632`, `:1660` and `:2511`.\n3. Every test passes, whatever the change was — including one that publishes the wrong set — because the yardstick moved with the thing it measures.\n","acceptance_criteria":"server_truth calls the same filter the producer uses, or the filter is extracted as a pub(crate) fn with both calling it. Changing which records are published then moves the producer and the yardstick together by construction, and a test proves it: alter the filter and watch the basis tests fail rather than silently track it.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:17Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e1z5","title":"AppMode::minibuffer and strands_the_user opt out of exhaustiveness, and both fail silently","description":"Found by the post-wave code consistency review. Two instances of one shape, filed together because the fix is identical and trivial.\n\n1. `AppMode::minibuffer()` (crates/improvise-protocol/src/lib.rs:322-332) ends in `_ =\u003e None`, where its sibling `panel()` (:303-320) is exhaustive. SavePrompt — added this wave — had to be listed BY HAND, and the compiler could not have caught its omission. The failure mode is silent: a text-entry mode whose minibuffer is invisible to EnterMode's clear, to keys.rs::entered, and to the TextEntry builder.\n\n2. `strands_the_user` (crates/improvise-wasm-client/src/keys.rs:498-503) is a `matches!` over AppMode. Its own doc says \"a third such prompt belongs here\" — and a fourth would answer false in silence, which is precisely the dead-room bug found against SavePrompt in the first place (doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key)).\n\nBoth are one or-pattern arm away from being exhaustive, at no cost. The repo's compiler-exhaustiveness theme says business rules go through the enum so the compiler names every site; these two opted out.\n","acceptance_criteria":"Both are exhaustive matches over AppMode with no wildcard, so adding a variant fails the build until someone answers for it. Verified by adding a throwaway AppMode variant and watching both sites fail to compile.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:02Z","started_at":"2026-09-21T03:28:19Z","closed_at":"2026-09-21T03:47:02Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-seik","title":"Client::viewport is kept for a reason web/app.js falsified in the same wave","description":"Found by the post-wave code consistency review.\n\ncrates/improvise-wasm-client/src/lib.rs:141-158. `Client::viewport` is a #[wasm_bindgen]-exported door with NO PRODUCTION CALLER. Its doc says it is \"kept because web/app.js already spells it and retiring it is a co-ordinated change\" — and web/app.js:137-144 now says the OPPOSITE in its own comment and calls `set_viewport`.\n\nSo the justification for keeping it was falsified IN THE SAME WAVE that wrote it.\n\nThis matters more than an ordinary unused function because its documented failure mode is SILENT: calling `viewport` instead of `set_viewport` does not error, it quietly turns a whole feature off — the window is not recorded, so cursor keys stop resolving locally and every one of them becomes an upstream black hole (doctrine(an_upstream_key_is_a_black_hole)). Gotcha 47 is explicit that the announced window is NOT derivable from row_labels.len(), which is the window clipped to the grid.\n\ndoctrine(a_documented_door_with_no_caller_is_the_dangerous_kind), with the justification removed.\n\nSIBLING, same file, lower severity: `Client::render_state` (lib.rs:120-124) is exported to JS with ZERO callers anywhere, while the crate's module doc (lib.rs:8) still names it as what the DOM renderer consumes. The page consumes `render_model`. `revision()` is likewise uncalled by the page, but `on_frame` returns the revision anyway, so that one is harmless.\n","acceptance_criteria":"Client::viewport is deleted and its tests move onto set_viewport, or its doc states a reason that is true at HEAD. Same for render_state: deleted, or the crate module doc stops naming it as what the DOM renderer consumes. Check web/app.js and the generated .d.ts before deleting — the page is not compiled against the crate, so a removed export is a runtime failure there, not a build error.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:28:14Z","started_at":"2026-09-21T03:28:14Z","lease_expires_at":"2026-09-21T03:33:14Z","heartbeat_at":"2026-09-21T03:28:14Z","labels":["area:browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-748f","title":"A drill exited with no edits, and view-back, mark the document dirty","description":"Found by the independent adversarial review of PR #26, sharpening something that PR had recorded in an assertion but framed too kindly.\n\nTHE BEHAVIOUR: ApplyAndClearDrill::apply RETURNS IMMEDIATELY when drill_state is None -- it writes nothing at all -- but declares EffectKind::Model(CellData), so App::apply_effects raises model_state.dirty anyway. And ViewNavigate { forward: false } (view-back) emits that effect UNCONDITIONALLY, whether or not a drill was ever opened.\n\nEXECUTED: on a clean document with drill_state == None, applying [ApplyAndClearDrill] gives dirty == true.\n\nFAILURE SCENARIO: open a document, press view-back, press :q. The quit is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nWHY THE \"SAME AS LoadModel\" FRAMING IS WRONG, and this is the part worth keeping: a SUCCEEDING LoadModel leaving the document dirty is a CORRECT answer to a real divergence -- LoadModel does not move file_path, so the in-memory document really is a different one from the file the path names, and PR #26's own test pins that REASON rather than just the flag. This is a WRONG answer to no divergence at all, on a routine navigation keypress. Calling them the same over-approximation flattens a distinction another test in the same PR was making.\n\nPRE-EXISTING: true since improvise-gv9r's fix (0b298d7) made dirtiness come off the effect kind, and invisible until now only because ApplyAndClearDrill's tests drove apply directly, bypassing apply_effects -- they measured the effect in isolation rather than the path production takes.\n\nTHE OBVIOUS FIX IS UNAVAILABLE, stated so the next person does not try: Effect::kind(\u0026self) takes no App, so the classification CANNOT be state-dependent. An effect cannot declare \"Model if there is a drill to commit, ViewOnly otherwise\".\n\nTHE CHEAP FIX ALREADY EXISTS IN THE CODEBASE: apply_effects already computes a per-record XOR fingerprint diff whenever a batch declares a CellData effect (gotcha 40), so \"did any record actually change\" is ALREADY KNOWN at the end of the batch. Raising dirty off that observation for the CellData tier is strictly more accurate and reuses machinery that is already there for the basis tiers. It does NOT cover LayoutGeometry or WholeWorkbook, so it is a partial move and a design call -- which is why this is a bead and not a patch.\n\nDIRECTION OF FAILURE IS THE SAFE ONE (a spurious prompt, never a lost edit), which is why it is P3 rather than higher. But it is a wrong answer the user meets on an ordinary keypress, and the fix reuses machinery that exists.\n\n## Steps to Reproduce\n\n1. Open any document and make no edit of any kind.\n2. Press `view-back`, which emits `ApplyAndClearDrill` unconditionally.\n3. `ApplyAndClearDrill::apply` returns immediately because `drill_state` is `None` and writes nothing — but it declares `EffectKind::Model(CellData)`, so `App::apply_effects` raises `model_state.dirty`.\n4. Press `:q`. It is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nDirectly executable: on a clean document with `drill_state == None`, applying `[ApplyAndClearDrill]` gives `dirty == true`.\n","acceptance_criteria":"A drill exited with no edits, and view-back on a document with no drill open, leave the document clean. Effect::kind cannot be state-dependent (it has no App), so the fix is elsewhere: App::apply_effects already computes the per-record fingerprint diff for CellData batches, so 'did any record actually change' is already known at the end of the batch. Raising dirty off that observation covers the CellData tier; say explicitly what LayoutGeometry and WholeWorkbook still over-report and why that is acceptable.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:27:53Z","started_at":"2026-09-21T03:27:53Z","lease_expires_at":"2026-09-21T03:32:53Z","heartbeat_at":"2026-09-21T03:27:53Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jq8l","title":"web/app.js never calls scrollIntoView, so a selection can sit outside the window","description":"Surfaced by PR #23's viewport-reservation discussion (improvise-anr9) and confirmed by reading: web/app.js CALLS scrollIntoView NOWHERE AT ALL.\n\nSo a selection that moves outside the rendered window is simply not brought into view. The grid repaints with the cursor somewhere the user cannot see, and the only way back is to scroll manually until it appears.\n\nPRE-EXISTING AND NOT GROUP-SPECIFIC. It came up while arguing about whether inline group rows should be reserved in extent(), and the conclusion there was that the residual gap is \"neither new nor group-specific\" -- this is that gap, filed separately so it does not read as a consequence of the frame work.\n\nNOTE WHAT IT INTERACTS WITH, because the fix is not simply calling scrollIntoView:\n * The browser's frame is WINDOWED and ViewState::selected is ABSOLUTE (gotcha 42). render.rs reconciles by ADDING the scroll offset -- `r + cursor.row_offset == sel_row` -- which is why there is no underflow path and why a selection above the window simply never matches. A scroll-into-view fix must reconcile the same way or it will fight the projection.\n * Client::set_viewport records the window the page announced (gotcha 47), and the announced window is NOT derivable from row_labels.len(), which is the window CLIPPED to the grid. The two differ precisely at the bottom edge, which is where this bug lives.\n * Moving the selection is the client's own business -- \"the model is shared, the UI is not\" -- so this is local work, not a projection change.\n\nUNTESTABLE UNTIL THE HARNESS GROWS: harness/tests/shell.spec.js renders web/app.js in three browsers, but web/demo.improv is small enough that nothing scrolls. See the sibling bead asking for a bigger, grouped fixture.\n\n## Steps to Reproduce\n\n1. Open a document in the browser with more rows than fit the window.\n2. Move the selection below the visible window (cursor keys resolve client-side since improvise-bpq2).\n3. The grid repaints with the cursor outside the rendered window and nothing scrolls — `web/app.js` calls `scrollIntoView` nowhere at all.\n4. The only way back is to scroll manually until the selection reappears.\n\nNot currently reachable by the Playwright harness: `web/demo.improv` is small enough that nothing scrolls. See improvise-plcx.\n","acceptance_criteria":"A selection moved outside the rendered window brings itself into view. The fix reconciles the absolute selection against the announced window by ADDING the scroll offset, the way render.rs does (there is no underflow path, and a selection above the window simply never matches). It uses the window Client::set_viewport recorded, not row_labels.len(), which is the window clipped to the grid and differs exactly at the bottom edge. Verified in the harness once improvise-plcx gives it a document big enough to scroll.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:28:12Z","started_at":"2026-09-21T03:28:12Z","lease_expires_at":"2026-09-21T03:33:12Z","heartbeat_at":"2026-09-21T03:28:12Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-jq8l","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:21:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gd83","title":"AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group","description":"Found by Copilot on PR #23 (improvise-anr9), DECLINED there with reasons on the thread, and filed so it is a decision rather than a rediscovery. COPILOT IS RIGHT AND THIS IS A REAL BUG.\n\nAxisFrame::group_of answers with the NEAREST PRECEDING GROUP HEADER, which is not the same as the item's group membership. GridLayout::expand_category emits a header on ENTERING a group and NO BOUNDARY ON LEAVING ONE, so an ungrouped item appearing after a grouped run inherits that group -- in the frame, and in the span the TUI paints.\n\nIT IS GridLayout's BUG, NOT THE FRAME'S. The TUI has painted that span forever; improvise-anr9 faithfully reproduced the existing rule when it moved the frame onto the wire, which is what a migration should do (doctrine(characterization_survives_the_move)). What changed is that the rule is now WRITTEN DOWN and therefore visible.\n\nWHY IT WAS RIGHT TO DECLINE IT THERE: fixing it means changing AxisEntry in improvise-core to carry a group-end boundary, and changing what the TUI DRAWS -- which would destroy `the_whole_frame_is_unchanged`, the exact-buffer characterization that was PR #23's only evidence the migration changed nothing. Same shape as improvise-q0vb's decline on PR #12: a finding that is correct and still wrong to fix in that PR, because of what it costs in evidence.\n\nTHE EVIDENCE IS ALREADY WRITTEN AND IT IS THE GOOD KIND. `the_two_front_ends_draw_the_same_column_header` asserts the WRONG answer as the current one -- Q1 spanning all six columns -- with THE CORRECTED EXPECTATION `[(Some(\"Q1\"), 4), (None, 2)]` WRITTEN BESIDE IT. So the day someone fixes AxisEntry, that test fails and the fix is one line of expectation away, with the intended answer already argued. The doc on group_of states the rule and names the defect.\n\nWHAT THE FIX COSTS, so nobody starts it casually: AxisEntry is in improvise-core; a group-end boundary changes the shape every consumer of row_items/col_items walks; and the characterization buffers in src/ui/grid.rs move, which means re-pinning them deliberately and saying so in the commit, exactly as improvise-q0vb's acceptance clause required.\n\n## Steps to Reproduce\n\n1. Build an axis with a grouped run followed by an UNGROUPED item — `expand_category` emits a header on entering the group and no boundary on leaving it.\n2. Ask `AxisFrame::group_of(idx)` for the ungrouped item's index.\n3. It answers with the nearest preceding header, so the ungrouped item is reported as a member of the group that ended before it.\n4. The TUI paints the same span, so this is `GridLayout`'s rule faithfully reproduced rather than a frame defect.\n\n`the_two_front_ends_draw_the_same_column_header` in `tests/grid_frame_agreement.rs` asserts the wrong answer as the current one, with `THE_MEMBERSHIP_CORRECT_SPANS` and an `assert_ne!` beside it that starts failing the day this is fixed.\n","acceptance_criteria":"AxisEntry carries a group-end boundary, so group_of answers membership rather than nearest-preceding. THE_MEMBERSHIP_CORRECT_SPANS in tests/grid_frame_agreement.rs becomes the asserted value and its assert_ne! is removed. The TUI's painted spans change, so the characterization buffers move deliberately and the commit says so.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:26Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n\n## Steps to Reproduce\n\n1. Serve the page and point it at a reachable `improvise-ws-server` so the socket actually OPENS (status reads `connected`).\n2. Stop the server, or otherwise drop the connection mid-session.\n3. The status line reads `disconnected` — correct.\n4. Wait eight seconds. The handshake watchdog, armed on open and never disarmed, replaces it with `accepted the connection but sent no document`.\n\nReproduces on macOS/Chromium. Does NOT reproduce on the GitHub runner, where the order of `error`/`close`/timer on the host's network stack differs — which is why this cannot be pinned as a CI guard. See `harness/tests/shell.spec.js` for the measurement.\n","acceptance_criteria":"The watchdog is disarmed when a socket that had OPENED subsequently closes, so a mid-session disconnect keeps its own message. The never-opened path is unchanged (the watchdog is the only thing that speaks there). Stated in the code why this is not a CI guard: the trigger is the host network stack's ordering of error/close/timer, so an expected-failure would invert CI's meaning rather than flake.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T08:05:26Z","closed_at":"2026-09-20T08:05:26Z","close_reason":"CLOSED AS INVALID 2026-09-20. THE BUG DOES NOT EXIST, and I filed it without checking the merged code.\n\nTHE CLAIM WAS: on the open-then-close path the handshake watchdog stays armed, so eight seconds after a genuine mid-session disconnect the status line replaces \"disconnected\" with \"accepted the connection but sent no document\".\n\nTHE CODE SAYS OTHERWISE, verified by reading at main 22d43b2:\n * web/app.js:248-250 routes BOTH arms of the close listener through setShellFailure.\n * setShellFailure (app.js:165-169) calls clearTimeout(watchdog) synchronously and nulls it.\n * The comment at app.js:241-247 states the property explicitly and ends \"Neither arm leaves one armed.\"\n\nHOW A REAL MEASUREMENT PRODUCED A FALSE BEAD, which is the part worth keeping:\n\nThe harness agent (improvise-vvl3) MEASURED the 8s overwrite for real. But its worktree was based at 97ea489 -- BEFORE PR #21 landed improvise-taxx's fix. It then reported the bug \"survives improvise-taxx's fix, confirmed by simulating PR #21's opened flag\". THAT SIMULATION WAS INCOMPLETE. PR #21's fix is not only the `opened` flag; it is routing BOTH ARMS THROUGH setShellFailure. Simulating the flag alone leaves the timer armed and faithfully reproduces a bug the real fix does not have.\n\nSo the measurement was honest, the simulation was not the fix it named, and I filed the bead on the report without checking the merged tree. doctrine(an_injected_fault_must_be_the_fault_you_named) one level up: A SIMULATED FIX MUST BE THE FIX YOU NAMED. A partial simulation of a repair reproduces the original bug by construction and reads as evidence that the repair failed.\n\nFOUND BY: a reviewer (ChatGPT) on PR #30, which had propagated the false claim into context/repo-map.md's gotcha 48 and into harness/tests/shell.spec.js's comment block. Both are being corrected.\n\nNOT WASTED: PR #30 adds the test the claim should always have had -- the open-then-close arm now runs past HANDSHAKE_TIMEOUT_MS and asserts \"sent no document\" never appears, proved discriminating by making setShellFailure not clear the timer. So the property is executed rather than argued, and the next person cannot re-derive this conclusion from a partial simulation.\n\nimprovise-taxx (the close-listener fix) remains correctly closed and is unaffected.","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n\n## Decision\n\nLeave the first-match heuristic as it is, and track it as a known limit. Ruled by the user 2026-09-20.\n\n## Rationale\n\nThe import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks — the same argument `detect_short_year_format` already recorded for its own guess.\n\nWhat made it worth tracking rather than ignoring is that PR #22 changed its character: `analyze_records` used to iterate a `HashSet`, so the ten samples `detect_date_format` inspects were a DIFFERENT ten every run, and a column whose eleventh value ruled out `%m/%d/%Y` detected a different format run to run. It is now wrong DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\n## Alternatives Considered\n\n**Change the default to day-first.** Rejected: it only moves which half of the world imports silently wrong.\n\n**Surface the ambiguity instead of resolving it.** The honest fix and larger than the heuristic — it changes `FieldProposal`'s shape and the wizard step that displays it. Worth doing deliberately rather than as a patch, which is why it is not folded in here.\n\n**Widen the ten-sample window.** Does not address ambiguity at all; a fully ambiguous column stays fully ambiguous.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:41Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:03Z","started_at":"2026-09-21T03:28:20Z","closed_at":"2026-09-21T03:47:03Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:27:54Z","started_at":"2026-09-21T03:27:54Z","lease_expires_at":"2026-09-21T03:32:54Z","heartbeat_at":"2026-09-21T03:27:54Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j7v6","title":"The typed-string classification lives in two crates, and its guard test asserts by hand","description":"Named by improvise-r4lo (PR #21) while deleting the OTHER duplication, and deliberately left rather than folded in -- r4lo was about two answers on the CLIENT side, and deleting Client::set_cell/set_cell_at settled that. This is the duplication one level out, and it crosses a crate boundary.\n\nwasm-client/src/socket.rs::typed classifies a user-typed string into a CellValue. So does improvise-command's commit_regular_cell_value, on the host's own key path. Two implementations of \"what does this string mean\", in two crates, with nothing holding them together.\n\nBOTH ARE LEGITIMATE, which is why this is not simply r4lo again. The browser has no App to ask and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; a host typing locally has an App and classifies there. The defect is that the RULE is written twice, not that it is applied in two places.\n\nTHE TEST THAT LOOKS LIKE A GUARD AND IS NOT: a_typed_string_is_classified_as_the_host_classifies_it states the obligation IN ITS NAME and does not discharge it -- it asserts three outcomes BY HAND rather than comparing against the host's function. So the two can drift and the test stays green, which is this repository's characteristic defect (doctrine(check_the_checker)): a test that passes while the thing it names is broken.\n\nTHE FIX: one classification function in improvise-protocol, which both crates already depend on, and both call sites calling it. Then the test either disappears (type identity replaces it) or becomes a real comparison. Note doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) -- if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not done.\n\nWATCH ONE THING WHILE MOVING IT: the classification must stay consistent with what the .improv format considers a number versus pipe-quoted text (gotcha 34, and format.rs::pipe_quote exists precisely so a text cell cannot read back as a number). A shared classifier that disagrees with the persistence layer would be a third answer wearing the badge of the first.\n","acceptance_criteria":"One classification function in improvise-protocol, called by socket.rs::typed and by commit_regular_cell_value. a_typed_string_is_classified_as_the_host_classifies_it either disappears (type identity replaces it) or becomes a real comparison against the shared function — if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not finished. Check the result stays consistent with what .improv considers a number versus pipe-quoted text, or the shared classifier is a third answer wearing the badge of the first.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j7v6","title":"The typed-string classification lives in two crates, and its guard test asserts by hand","description":"Named by improvise-r4lo (PR #21) while deleting the OTHER duplication, and deliberately left rather than folded in -- r4lo was about two answers on the CLIENT side, and deleting Client::set_cell/set_cell_at settled that. This is the duplication one level out, and it crosses a crate boundary.\n\nwasm-client/src/socket.rs::typed classifies a user-typed string into a CellValue. So does improvise-command's commit_regular_cell_value, on the host's own key path. Two implementations of \"what does this string mean\", in two crates, with nothing holding them together.\n\nBOTH ARE LEGITIMATE, which is why this is not simply r4lo again. The browser has no App to ask and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; a host typing locally has an App and classifies there. The defect is that the RULE is written twice, not that it is applied in two places.\n\nTHE TEST THAT LOOKS LIKE A GUARD AND IS NOT: a_typed_string_is_classified_as_the_host_classifies_it states the obligation IN ITS NAME and does not discharge it -- it asserts three outcomes BY HAND rather than comparing against the host's function. So the two can drift and the test stays green, which is this repository's characteristic defect (doctrine(check_the_checker)): a test that passes while the thing it names is broken.\n\nTHE FIX: one classification function in improvise-protocol, which both crates already depend on, and both call sites calling it. Then the test either disappears (type identity replaces it) or becomes a real comparison. Note doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) -- if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not done.\n\nWATCH ONE THING WHILE MOVING IT: the classification must stay consistent with what the .improv format considers a number versus pipe-quoted text (gotcha 34, and format.rs::pipe_quote exists precisely so a text cell cannot read back as a number). A shared classifier that disagrees with the persistence layer would be a third answer wearing the badge of the first.\n","acceptance_criteria":"One classification function in improvise-protocol, called by socket.rs::typed and by commit_regular_cell_value. a_typed_string_is_classified_as_the_host_classifies_it either disappears (type identity replaces it) or becomes a real comparison against the shared function — if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not finished. Check the result stays consistent with what .improv considers a number versus pipe-quoted text, or the shared classifier is a third answer wearing the badge of the first.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:03Z","started_at":"2026-09-21T03:28:20Z","closed_at":"2026-09-21T03:47:03Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:35Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -329,14 +335,14 @@ {"_type":"issue","id":"improvise-r08u","title":"IS and WHERE differ on trailing word boundary in formula.pest","description":"improvise-4zl7 gave `IS` a trailing word-boundary lookahead: `is_expr` is compound-atomic so `Region ISLAND` no longer parses as `Is(Region, LAND)`. `^\"WHERE\"` in `where_clause` and `inline_where` still has the identical hole — `SUM(Rev WHEREAS x = y)` would read as a WHERE on a category `AS`. It is far less exposed than IS was (WHERE sits in one fixed position after an expression, where IS follows any identifier in expression position), which is why it was left alone rather than fixed in the same pass: it is pre-existing and changing it is a grammar change with its own round-trip risk.\n\n## Steps to Reproduce\n\n1. `parse_formula(\"X = SUM(Rev WHEREAS x = y)\", \"Cat\")`\n2. It parses, reading `AS` as the filter category, where it should be a syntax error.","acceptance_criteria":"Either `^\"WHERE\"` gets the same `!bare_char` lookahead `IS` has and a test asserting `WHEREAS` does not parse as a WHERE clause, or the asymmetry is declined in writing in context/code/formula-and-io.md with the reason. The grammar-walking generator proptests must still pass either way.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:16:46Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:16:46Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1l9o","title":"furnished_client hand-builds a basis instead of taking the server's","description":"A nit from the adversarial review of PR #50, explicitly not worth a push at\nthe time.\n\nlockstep.rs's furnished_client builds its BasisCache by walking the client's\nown grid for Record cells and calling basis::published(0, 0, records) with\nevery version hard-coded to 0. That is a small re-implementation of\nproject.rs::basis_update's record selection, in a test whose entire purpose is\nto rule out re-implementations.\n\nIt is harmless today -- a fresh App really is at generation 0, layout 0, and\nevery record at version 0 -- which is exactly why it will stop being harmless\nsilently if any of those three stops being true.\n\nThe reviewer demonstrated the alternative while checking something else: seed\nthe ClientState from a real Sessions::connect snapshot and let the server's own\nProjection::Basis populate the cache. That removes the fabrication and makes\nthe fixture a client the server actually produced.\n\nNote the same fabrication exists in client_ready_to_edit, which predates this\nPR, so a fix should take both.","acceptance_criteria":"The lockstep fixtures get their basis from a real Sessions::connect\nsnapshot rather than constructing one. Both furnished_client and\nclient_ready_to_edit, since they share the fabrication. The tests that use\nthem must still pass unchanged -- if seeding from the server changes an\noutcome, that is a finding about the fixture and worth stopping for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:58:19Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:19Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-thpe","title":"help::compact discriminates a chord by parsing a string it formatted","description":"Finding 3 of the adversarial review of PR #50. Not live-broken and\ndeliberately not fixed in that PR.\n\nimprovise_protocol::help::compact drops chorded keys from a status-bar hint by\ntesting whether the formatted label starts with \"Ctrl-\" or \"Alt-\". The\nreviewer enumerated every format_key_label output and none begins with either,\nso there is no false positive today.\n\nThe defect is the shape, not the behaviour. keymap.rs::labels_for HAS the\nstructured fact -- it matches KeyPattern::Key(code, mods) and then throws mods\naway into a string -- and compact then reconstructs a worse version of it by\nparsing. A future modifier spelled any other way (Super-, Cmd-, a bare glyph)\nis silently not a chord, lands in the status bar, and nothing fails.\n\nThe fix is to carry the fact rather than re-derive it: either Hint keeps the\nKeyModifiers beside each label, or labels_for returns (KeyCode, KeyModifiers)\npairs and help.rs formats at the end. The second is tidier and is what\nwhich_key already does with key_label(code, *mods).\n\n## Steps to Reproduce\n1. Add a modifier to keymap.rs::key_label spelled with any prefix other than\n \"Ctrl-\" or \"Alt-\" -- \"Super-x\", say.\n2. Bind a command advertised by a mode's hint to that chord only.\n3. hint_line puts it in the status bar. Nothing fails.","acceptance_criteria":"compact stops parsing a string to learn something the keymap already\nknew: either Hint carries the KeyModifiers beside each label, or labels_for\nreturns (KeyCode, KeyModifiers) and help.rs formats last. Proved by adding a\nmodifier spelled differently and watching a test fail -- the point is that\ntoday it would not.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:36Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:45:36Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:48:10Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","notes":"PR #76 (branch wave/command-effect-gaps) does HALF of this: App::resolve_view's fallback carries back the .expect(\"active_view always names an existing view\") message, guarded by a_default_that_names_nothing_says_which_invariant_broke in ui/app.rs. The SECOND half is untouched — whether a host-initiated DeleteView's session adoption belongs at the Sessions door or at Host::project is a decision about ui/project.rs and ui/host.rs, which were another agent's ground in this wave. Reported rather than guessed.","status":"in_progress","priority":4,"issue_type":"task","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T03:42:18Z","started_at":"2026-09-21T03:27:56Z","lease_expires_at":"2026-09-21T03:32:56Z","heartbeat_at":"2026-09-21T03:27:56Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"closed","priority":4,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"closed","priority":4,"issue_type":"task","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:16Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"PR #72. harness/tests/landing.spec.js presses play on all four casts and asserts rendered lines plus four distinct 200s; ./casts/ -\u003e ./cats/ fails it. Note the bead's path is stale: the landing page is at the site ROOT (/index.html) since improvise-kh8, not /landing/.","status":"closed","priority":4,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:36Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"closed","priority":4,"issue_type":"task","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-taxx","title":"[bug] The socket close listener overwrites a precise error with disconnected","description":"Found during the PR #13 review, reported and not fixed -- same family as the watchdog bug Copilot found, different question.\n\nweb/app.js socket close listener writes \"disconnected\" UNCONDITIONALLY, so a connection that failed loudly (the error listener already wrote \"cannot reach ... -- is improvise-ws-server running?\") is immediately followed by a close event that replaces it with the vaguer message. The user sees the least informative of the two things that happened.\n\nTHE WATCHDOG BUG WAS THE SAME SHAPE AND WAS FIXED: a timer fired 8s later and overwrote whatever a loud failure had already written; setShellFailure now clears it. This one was left because the question is genuinely different -- what should the page say when a connection BOTH failed AND went away? -- and because suppressing close unconditionally could hide a GENUINE MID-SESSION DISCONNECT, which is the one thing that listener is really for.\n\nLIKELY ANSWER: distinguish \"never connected\" from \"was connected and dropped\", which the socket readyState at close time can tell you. Not \"skip close if error fired\".\n\nUntestable here (no browser, no headless DOM), which is the standing caveat on every web/*.js finding -- see the browser harness ticket.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). Fixed -- AND THE TICKET'S PROPOSED MECHANISM WAS WRONG, which is the part worth keeping.\n\nThe ticket said: \"distinguish 'never connected' from 'was connected and dropped', which the socket readyState at close time can tell you.\" IT CANNOT. readyState is already CLOSED by the time any close listener runs, whichever happened; and the CloseEvent cannot separate them either, because a refused connection and a mid-session drop are BOTH code 1006 with wasClean:false. Nothing on the event carries the distinction.\n\nThe only discriminator is whether `open` ever fired, which the page must REMEMBER ITSELF. Hence an `opened` flag set in the open handler.\n\nDeliberately NOT the watchdog's fix: the watchdog is disarmed because it exists for silence only, whereas suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for.\n\nA DETAIL FOUND WHILE FIXING IT, worth knowing before touching this again: armWatchdog runs only AFTER `transport.ready` resolves -- on `open` -- so on the never-opened path the watchdog never exists at all. That is not what its own comment leads you to expect, and it means the original watchdog bug could only ever have fired on a connection that SUCCEEDED.\n\nSTILL UNVERIFIED BY EXECUTION, and stated rather than glossed: web/app.js has never been run here. The change is argued from the WebSocket spec and checked with node --check (proved discriminating by appending an unbalanced paren and watching exit 1), not observed. The four assertions this fix owes are recorded on improvise-vvl3 so the browser harness can discharge them -- including the one that must FAIL against the previous unconditional handler, which is the guard and not the decoration.","dependencies":[{"issue_id":"improvise-taxx","depends_on_id":"improvise-xps8","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7v62","title":"Checked does not bind the App it was checked against","description":"Residual hole in improvise-ondj, found and documented by the agent that closed it. Not reachable today; filed so it is a decision rather than a discovery.\n\nChecked\u003ca\u003e is a private-field newtype over a \u0026Command whose only constructor, Checked::against(\u0026App, \u0026Command), runs the basis check. But the resulting Checked carries no evidence of WHICH App it was checked against, so a multi-document host could in principle check against one App and apply to another -- and the versions would be meaningless, since document_generation, layout_version and record_versions all start at zero in a fresh App and are per document (ADR 001 scopes them that way deliberately).\n\nWHY IT IS NOT REACHABLE TODAY: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter), and Registry hands out one (App, Sessions) pair per DocumentId. It is documented on the type.\n\nTHE OPTIONS, in increasing cost: leave it documented (defensible -- this is a coordination check, not a security boundary, and the misuse requires a host to actively mix two documents); give Checked a lifetime tied to the \u0026App so the borrow checker forbids the mix; or carry a DocumentId. The middle one is probably free and worth trying before accepting the first.\n\nWON T-FIX IS A LEGITIMATE OUTCOME -- but say so deliberately, because improvise-j31x made this a multi-document server on purpose and ADR 001 notes that a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current. The invariant is load-bearing.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:49Z","started_at":"2026-09-20T05:32:30Z","closed_at":"2026-09-20T07:29:49Z","close_reason":"Closed WON'T FIX by PR #26 (WU-C), deliberately and with the compiler output on the type, which is what the ticket asked for (\"won't-fix is a legitimate outcome -- but say so deliberately\").\n\nThe ticket's middle option -- give Checked a lifetime tied to the \u0026App so the borrow checker forbids applying it to a different document -- WAS TRIED. It is not free, and the reason generalises: A PROOF DISCHARGED BEFORE A MUTATION CANNOT BE REPRESENTED AS A BORROW HELD ACROSS IT. A Checked is evidence produced by reading the App and whose whole job is to be carried INTO the mutation; a borrow must stay live DURING it. Every call site becomes E0502, starting with Sessions::apply. Now doctrine(a_proof_obligation_is_not_a_borrow).\n\nThe DocumentId option is worse placed than it looks: DocumentId lives in the SERVER'S REGISTRY and an App does not have one, because the unit of authority is one document per App. Threading it in would put a server concept into the command layer to guard against a misuse the server's own structure already prevents.\n\nWHAT KEEPS THE MISUSE UNREACHABLE, unchanged and load-bearing: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter) and Registry hands out one (App, Sessions) pair per DocumentId. ADR 001 notes a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current.\n\nThe ticket was right that this is a coordination check and not a security boundary, and right that the invariant is load-bearing. It is documented on the type, with the reason the cheap fix is unavailable, so the next person does not re-derive it.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-q0vb","title":"Pad the import wizard's hint line to the panel width","description":"Pre-existing, found by Copilot on PR #12 and deliberately NOT fixed there -- see the pushback at https://github.com/fiddlerwoaroof/improvise/pull/12#discussion_r4055516143\n\nFour buf.set_string(x, hint_y, ...) sites in src/ui/import_wizard_ui.rs write the hint without padding to the panel width, so a SHORTER message leaves the tail of the longer previous string visible.\n\nTHE REPRODUCTION IS ALREADY IN THE SUITE, as an EXPECTATION: the DefineFormulas characterization test asserts \"|something went wrongcel\" -- the \"cel\" being the tail of \"Esc: cancel\". The artifact is currently pinned as correct output.\n\nWHY IT WAS NOT FIXED IN THE MIGRATION PR: those exact-frame characterization tests were that PR's ONLY evidence that the widget migration changed nothing (doctrine(characterization_survives_the_move)). Rewriting them in the same change would have destroyed the evidence for the change. The sibling finding in the same file (the non-ASCII truncate panic) WAS applied, precisely because it is byte-identical on ASCII and therefore costs no evidence. The two findings differ in what they cost, not in whether they are right.\n\nACCEPTANCE: the tail is gone AND the affected characterization expectations are updated IN THE SAME COMMIT, so the frame change is the point of the commit rather than a side effect of one.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:46Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:29:00Z","started_at":"2026-09-20T05:32:39Z","closed_at":"2026-09-20T06:29:00Z","close_reason":"Closed by PR #22 (WU-L). The acceptance clause was met exactly: the tail is gone AND the affected characterization expectation moved in the same commit, with the commit message naming it.\n\nTHE DIAGNOSIS WAS BIGGER THAN THE TICKET'S. The bottom row had FIVE WRITERS -- four step hints plus the message that overrides them -- and none owned clearing it. So the defect was not a forgotten pad at four call sites; it was a row with five writers and no owner. One helper, set_bottom_line, now truncates to the panel width and pads to it, and all five go through it.\n\nEXACTLY ONE EXPECTATION MOVED, of eight:\n FORMULA_STEP bottom row: \"|something went wrongcel ...\" -\u003e \"|something went wrong ...\"\nThe \"cel\" was three characters of a hint the message had replaced; it was never part of the message. The other seven frames are BYTE-IDENTICAL, because padding with spaces is invisible to buf_text (which trims each row) -- so the change can only show where it ERASES something. That is the evidence this is a fix and not a rewrite of the characterization PR #12 was protecting.\n\nTWO OF COPILOT'S FOUR FINDINGS LANDED IN THIS FILE and both were about measurement rather than logic: padding by chars().count() over-pads a wide character past the panel border and under-pads a combining mark (now UnicodeWidthStr::width, as grid.rs does); and the test written to catch the second of those PASSED FOR THE WRONG REASON, because drawing through the widget cannot see under-padding at all -- the hint ran first and had already padded the row, so the cells the message failed to reach held spaces either way. Replaced with a sentinel-filled row asserted directly against set_bottom_line.\n\nTHE GENERAL LESSON, now doctrine(an_ascii_fixture_cannot_see_a_width_bug): the eight exact-frame fixtures in that file are ASCII, so bytes, chars and cells all coincide and they are blind to every question about display width. Eight green frames sat either side of both bugs.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index ce1defca..c748b673 100644 --- a/README.md +++ b/README.md @@ -238,26 +238,19 @@ optimising for: `http://localhost` is not a secure-context problem, so nothing below is needed to develop, to demo locally, or to share a TUI-hosted document with another tab on the same machine. -One caveat even there, for the reason spelled out below: a WebSocket is exempt -from the same-origin policy, and **nothing in the server checks `Origin`** — so -a page you did not open can connect to `ws://127.0.0.1:8787` and read or edit -the document while the server runs. +The server checks `Origin` on the handshake, which is what makes that safe. A +WebSocket is exempt from the same-origin policy — there is no CORS preflight — +so without the check any page you visited could connect to `ws://127.0.0.1:8787` +and read or edit the document while the server runs. **Pages served from +loopback are allowed by default**, which is exactly the recipe above; every +other page is refused unless you name it with `--allow-origin`. A client that +sends no `Origin` at all — curl, a script, a native client — is allowed, because +no browser omits it and refusing them would answer a question `--bind` already +answers. -How much the *browser* mitigates that is in flux and should not be leaned on. -Chromium has been rolling out **Local Network Access**, which puts requests -from a public-network origin to a loopback or private-network address behind a -permission prompt; per Chrome's own documentation WebSockets were **not yet** -covered when the prompt began shipping, with coverage planned, so whether a -given browser and version gates *this* socket is not something this project can -state — and nothing here can test it: the Playwright harness serves a loopback -origin, so Local Network Access never engages in it. Two gaps are structural -rather than version-dependent: it is a Chromium feature, so other browsers need -not behave the same; and it only engages for a **public** origin, so the -locally-served page this section is about is outside it entirely. - -Treat it, then, as tolerable for a local session you start and stop, and as a -reason not to leave one running unattended. The durable fix is the server -checking `Origin` itself, which it does not yet do. +This is not authentication (see the next section) and it does not depend on the +browser: whatever Chromium's **Local Network Access** work ends up covering, the +handshake is refused here rather than at the browser's discretion. ### Sharing a TUI-hosted document with a remote browser @@ -311,9 +304,13 @@ any hostile page an allowed user visits can open a socket to your endpoint *from that user's own allowed address*, and read and write the document. An address check cannot see that, because by then the request genuinely does come from an allowed client. Only the **`Origin` header** distinguishes them, and -`improvise-ws-server` **never looks at it** — it accepts every handshake. So a -proxy needs an Origin allow-list **in addition to**, never instead of, -whichever control below you pick. The worked example carries both. +`improvise-ws-server` checks it: pages served from loopback are allowed, and +anything else must be named with `--allow-origin`. **Deploying behind a proxy +therefore needs the flag** — the deployed page's origin is +`https://fiddlerwoaroof.github.io`, which is not loopback. Keep the Origin +allow-list in the proxy as well: it is defence in depth now rather than the only +defence, and it is what covers a proxy configuration that strips or rewrites the +header before the server can see it. So the honest controls are the ones that need **no credential from the page**: @@ -352,11 +349,11 @@ improvise.example.com { @denied not remote_ip 203.0.113.4 198.51.100.0/24 abort @denied - # ALSO not optional, and it is NOT redundant with the address check. - # A WebSocket is exempt from the same-origin policy, so any hostile page - # an allowed user visits can open this socket FROM THAT ALLOWED ADDRESS - # and read and edit the document. The address check cannot see that; only - # the Origin can. Name the exact origins the page is served from. + # Defence in depth, and NOT redundant with the address check. A WebSocket + # is exempt from the same-origin policy, so any hostile page an allowed + # user visits can open this socket FROM THAT ALLOWED ADDRESS. The address + # check cannot see that; only the Origin can. The server checks it too — + # this is what covers a proxy that strips or rewrites the header. @badorigin not header Origin https://fiddlerwoaroof.github.io abort @badorigin @@ -365,8 +362,11 @@ improvise.example.com { ``` ```sh -improvise-ws-server bank-info.improv --port 8787 # still plain ws, still loopback -caddy run # terminates TLS, proxies to it +# --allow-origin is required here: the deployed page is not a loopback origin, +# so without it the server refuses the handshake the proxy forwards. +improvise-ws-server bank-info.improv --port 8787 \ + --allow-origin https://fiddlerwoaroof.github.io # still plain ws, still loopback +caddy run # terminates TLS, proxies to it ``` On the same terms as the browser caveat below: **this configuration has not @@ -390,8 +390,9 @@ answered as an ordinary request and the socket never opens. For a one-off share with no DNS name and no certificate of your own, `cloudflared tunnel --url http://127.0.0.1:8787` or `ngrok http 8787` each print an https hostname that works the same way — pass the hostname to `?ws=` -without a scheme. Both print a **public** URL and neither authenticates -anything by default, and an unguessable URL is not a control. +without a scheme, and pass the page's own origin to `--allow-origin`, since it +is not loopback. Both print a **public** URL and neither authenticates anything +by default, and an unguessable URL is not a control. Two things carry over from above. `ngrok http --basic-auth` does **not** help here — the page cannot answer a basic-auth challenge on a socket handshake — diff --git a/context/code/clients.md b/context/code/clients.md index 88ef19ad..a1f3074b 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -255,6 +255,58 @@ document it serves, so it binds a loopback address by default and `serve` says so. `--bind` exists so the limit is a choice you can see rather than one buried in the code. ADR 001 owns what multi-writer safety becomes. +### Origin + +`src/origin.rs` is the handshake's one access control, and the reason it cannot +be delegated: **a websocket handshake is exempt from the same-origin policy**. +There is no CORS preflight, so any page a user visits can open a socket to the +endpoint *from that user's own address*, and read and edit every document the +server holds — cross-site websocket hijacking (improvise-kc7n). An address +check cannot see it, because the request genuinely comes from an allowed +address; an IP allow-list, a private network and loopback binding all answer +"may this **client** reach the endpoint", and the attacker's answer is honestly +yes. Only `Origin` says what is **driving** the client, which is +`doctrine(who_may_connect_is_two_questions)`. **Loopback is the least protected +case, not the safest**: there is no proxy in front of it to check anything, and +loopback is not a boundary a browser enforces, so while the server runs any page +open in that browser can reach `ws://127.0.0.1:8787`. + +**The default allows loopback pages and nothing else**, because a default that +fails open is worth nothing and a default that breaks the shipped recipe is a +different bug. The two shipped paths were the constraint. The local pairing +recipe serves the page with `python3 -m http.server 8080 --directory web`, so +its origin is `http://localhost:8080` while the socket is on another loopback +port — loopback is therefore the *default*, not a flag, or the one case worth +optimising for would need configuring. The deployed Pages page sends +`https://fiddlerwoaroof.github.io` and can only reach the server through a TLS +proxy (`improvise-ws-server` speaks plain `ws://`), which is already a +multi-step setup with an Origin allow-list in it; one `--allow-origin` is +therefore an honest cost there and the project's own deployment origin is not +hard-coded as a default. `OriginPolicy::permits` is the whole decision, and the +transport's `admitted` is the only caller. + +**An absent `Origin` is allowed, decided rather than defaulted.** Every browser +sends one on a handshake, unconditionally — unlike `fetch`, there is no +no-`Origin` mode to fall into — so an absent one is a non-browser client: curl, +a native client, this crate's own `tests/socket.rs`. Refusing those would break +the repo's own tests and every scripted client while buying nothing, since an +attacker who can run a non-browser client already has direct network access, +which is the question `--bind` and a proxy answer. The cost is stated rather +than hidden: **a proxy that strips `Origin` turns this check off**, so the +proxy's own Origin allow-list stays in the recipe as defence in depth. + +Two shapes state no origin a policy can answer for, and both are refused rather +than read as absent: an `Origin` that is not valid text, and **two `Origin` +headers**. `HeaderMap::get` is first-wins, and the deployment this README +recommends puts a proxy in front of the socket — one that appends rather than +sets the header leaves two, and answering from the first lets the second be +anything at all. No browser sends two. + +**What this does not buy is authentication.** It answers "what page is driving +this client", never "who is connecting"; anything that is not a browser page +sends no `Origin` and is unaffected by any of it. improvise-kpnr is still open +and is a different question. + --- ## `improvise-protocol/src/resolve.rs` — one keypress → `Command`s @@ -537,6 +589,82 @@ The classes that remain: - **Drill.** Session-local residue, and the one case that does **not** go upstream silently — see below. +#### The three improvise-e24v listed as free, and what each really costs + +improvise-4bwa. Each of the three is short exactly one fact, and the three +answers are different in kind — which is the point, because "needs enrichment" +read as one bucket is what let them be listed as free. + +**`view-back` is declined, permanently as things stand, and it is not an +enrichment at all.** `ViewNavigate { forward: false }` emits +`ApplyAndClearDrill` *before* `ViewBack`, and `ApplyAndClearDrill` is +deliberately off the wire: its operand is `drill_state.pending_edits`, session- +local residue belonging to whoever staged it, and a `Command::Model` is routed +with no `SessionId` (gotcha 38, improvise-3x3u). A client that mirrored +`view-back` by reconstructing the drill commit would commit **somebody else's +uncommitted typing** — which is the exact thing 3x3u removed, and the reason it +must not be "mirrored line for line" here despite that being the rule everywhere +else in this module. So it stays upstream until ADR 001 layer 2 gives the +addressed form. Note the sharper reading: `mirror_the_cmd_line_for_line` is a +rule about *resolvers*, and a `Cmd` whose first effect is unaddressable is not a +`Cmd` a thin client may mirror at all. The doctrine does not conflict with this; +it simply does not reach it. + +**`toggle-prune-empty` needs one bool on a per-view projection.** +`TogglePruneEmpty::execute` reads `ctx.view.prune_empty` only to word its status +— "Showing all rows/columns" against "Hiding empty rows/columns". The +`ModelCommand` itself is composable today; it is the sentence beside it that a +client cannot write, so mirroring it would put the two ends out of step on the +status line in exactly half of all presses. `View::prune_empty` is on the `View` +inside the `Workbook`, in no widget cache and not in `ViewState`. + +**`delete-formula-at-cursor` needs an element-type change, and the shape of the +change is the whole decision.** The effect is `RemoveFormula { target, +target_category }`; `FormulaCache` carries the formula's *raw text* +(`"Profit = Revenue - Cost"`), from which neither field is recoverable without +reimplementing `parse_formula` in the client. Adding a `targets` vector +**beside** `formulas` would be two answers to "what is in the formula panel", +which is improvise-edp's disease and the mistake improvise-anr9 had to pay a +type change to undo. So the element type grows the fields; it does not acquire a +parallel array. + +Both of those are ordinary projection additions and both live in +`improvise-protocol` (`render.rs`, `resolve.rs`) and `improvise-command` +(`ui/project.rs`) — not in this crate, which is why this section records the +design and the work lands with whoever owns those files. Whatever lands, the +named list `the_command_names_that_still_need_the_host_are_these` shrinks by +exactly what closed. + +#### What a client that could parse would cost: measured + +improvise-cl7y asks a client to stop claiming "Formula added" for text only the +host can parse. Its own note says the two routes are a dependency decision or a +status addressed back to the composer, and leaves the cost of the first +unmeasured. It is measured now, by adding `improvise-formula` to this crate, +calling `parse_formula` from an exported method so nothing is stripped, and +building the bundle both ways: + +| | `improvise_wasm_client_bg.wasm` | gzipped | +|---|---|---| +| today | 503,854 | 191,613 | +| with `improvise-formula` | 634,515 | 238,973 | +| cost | **+130,661 (+25.9%)** | **+47,360 (+24.7%)** | + +The raw column is exact and byte-verified; the gzipped one is a `gzip -9` +**proxy** and not what a visitor downloads, since GitHub Pages serves brotli. +The conclusion is unaffected — the raw figure alone carries it — but do not +quote "+47,360 gzipped" as a transfer size. + +A quarter of the download, for a status string. That is the evidence for the +other route — the host addressing a status back to the composer, the way +`ClientFrame::Save`'s rejection already does — and it costs no dependency. + +Note what the measurement does **not** settle, because it is the more +interesting half: a client that could parse would be a **second derivation** of +whether a formula is well formed, agreeing with the host only for as long as +both call the same function. The status line is not the only thing that wants +it, so the number above is a floor rather than the price of a feature. + ### `stranding`: a prompt this client can enter but cannot commit `enter-mode` is trivially resolvable here, and that is the trap: the user @@ -1112,7 +1240,7 @@ reaching it from https means terminating TLS in front of it). From an https page an explicit `ws://` is **upgraded** rather than passed through, since passing it through can only produce a failure the user cannot act on. -### `set_viewport`, not `Client.viewport` +### `set_viewport`, and the twin that is gone Same frame, but it also **records** the window. A client learns its window only from what it announced — the row frame's own length is the window clipped to the @@ -1121,6 +1249,23 @@ arithmetic matters. Without it the client cannot clamp, so every cursor key goes upstream into `Host::handle`'s empty vec and the arrow keys silently do nothing (improvise-bpq2). +There used to be a frame-only twin, `Client::viewport`, exported beside it and +called by nobody. Its doc said it was kept because `web/app.js` already spelled +it — and `web/app.js` called `set_viewport` in the same wave, so the reason was +false before it was read (improvise-seik). That matters more than an ordinary +unused function, because **the two are one letter apart and the wrong one fails +silently**: it sends an identical frame and simply forgets the window, which +turns every cursor key into the black hole above. It is deleted, along with the +exported `init()` free function that duplicated `new Client()`. + +A deletion is not the guard, though — the next export with no caller arrives the +same way. `web/tests/bundle/wasm_surface.test.js` now reads the generated +`.d.ts` and requires every declared method to be one `app.js` calls, or to be +named in an `UNCALLED` table with its reason. `free` and `initSync` are +wasm-bindgen's own; `revision` is there because `on_frame` returns it. Proved by +injecting a spare `static viewport(...)` declaration into the built `.d.ts` and +watching it fail by name. + ### `frameCost`: what the grid's own frame costs, in whole cells One header row and one header column was right until improvise-anr9: the column @@ -1216,6 +1361,70 @@ Neither arm leaves one armed. 1200 reads `"1,200"` and committing that stores the *string* `"1,200"`. Enter removes the focused ``, which fires `blur`, which calls `close` again: harmless, but only by accident. +- **The title bar is three fields and one joining.** `doc_name`, `doc_dirty` and + `doc_stored_as` come off the cache `src/draw.rs::draw_title` folds; the joining + — `name (stored_as) [+]`, in that order — is `shell.js::documentTitle`, which + is the TUI's own spelling minus the `improvise ·` chrome and the right-hand + key strip. The three were projected by improvise-z754 and painted by nothing + for a wave, and `render_surface.test.js` could not see it: that check is a + **substring search**, so a field named by a function nothing calls reads as + painted. What sees it is `contract.spec.js`, which loads the page and asserts + the text (improvise-5jna). The marker itself is a node test rather than a + harness one, because the standalone deployment persists on every accepted + batch and clears the flag in the same round trip, so `[+]` is transient there. + +### The selection scrolls itself into view + +`web/app.js` called `scrollIntoView` nowhere at all, so a selection driven past +the bottom of `#grid` stayed there, invisible, with no way back but scrolling by +hand (improvise-jq8l). Two things about it are easy to get wrong. + +**It is not a projection bug and not a `row_offset` bug.** The row had been sent: +`#grid` has `overflow: auto` and the page announces an extent from `ROW_PX`/ +`COL_PX`, which **under-estimate** — a real row is 28.6px against the assumed 26 +— and inline group rows are deliberately not charged for at all. So the page +renders more rows than its own box can show, and the overflow is the DOM's, not +the window's. Measured on `harness/fixtures/tall.improv`: 105px of overflow, the +selected cell 96px below the visible bottom, `scrollTop` still 0. + +**`block: "nearest"` is the whole option.** It scrolls only when the element is +actually out of view, and it scrolls the nearest scroll container rather than the +document, which is what keeps the page itself from moving sideways. + +The reason this was untestable until now is the fixture: `web/demo.improv` is +four rows and `harness/fixtures/grouped.improv` is eight, and neither overflows a +484px box. `tall.improv` is thirty rows in three groups, and the spec asserts the +overflow exists **before** asserting anything about the scroll — without that +line the whole test passes on a page that never scrolls. + +### The pointer takes cells on `mousedown` + +A cell's handler is `mousedown` with `preventDefault()`, not `click`, and the +reason is an ordering trap worth the paragraph (improvise-0x9u). From an +**editing** state a `click` never arrived at all: the mousedown moved focus, the +``'s `blur` ran `close`, `close` repainted, `replaceChildren` destroyed +the `` the mousedown had landed on, and no `click` was ever dispatched. The +pointer was dead until the user pressed Escape or clicked outside the grid. + +Three things had to move together, and each is load-bearing: + +- **`mousedown`, so the handler runs before any focus change.** +- **`preventDefault()`, so no focus change happens at all.** Otherwise the + default action focuses `#grid` *after* the handler has called `input.focus()` + on the new editor, and blurs it immediately. The keyboard listener is on + `window`, so nothing needs `#grid` focused. +- **`close` no longer repaints, and `blur` checks `closed` first.** Removing a + focused `` fires `blur` **synchronously**, so a `close` that repainted + from inside itself detached the very `` the handler was about to open an + editor on. This is the same repair one level down, and it is why the first + attempt at the fix still failed: the `blur` arm re-entered and rebuilt the + table. `close` restores the **drawn** text rather than the seed, since nothing + repaints behind it any more. + +The state the old spec could not see is the whole point: it pressed Escape +before clicking the second cell, so it only ever clicked from a non-editing +page — this repo's characteristic defect, a test that passes while the thing it +names is broken in a neighbouring state. ### The keyboard @@ -1284,6 +1493,57 @@ where one bad frame would poison every later one. --- +## `docs/` — the landing page and its casts +### The casts are recorded by a script, not by `asciinema rec` + +`docs/record-casts.py` drives `improvise` on a pty it owns and writes asciicast +v2 itself. `asciinema rec` records a **shell**, and the shell it records is the +recorder's own: every previous cast opened and closed on +`(0) Mac: ~gf/u/edwlan/improvise`, on what improvise-kh8 had since +made the site root, and `formulas.cast` and `import.cast` showed that prompt as +their visible **opening frame** (improvise-2oxt). + +The repair is structural rather than careful: `improvise` is the pty's only +child, so there is no shell for a prompt to leak from — nothing to remember to +clean, and nothing that comes back when somebody re-records on a different +machine. Three other things stopped being accidents at the same time. The +**geometry** is an input (120×37, the numbers `docs/index.html` already spelled, +which is why they were not changed — a 30-row player over a 37-row cast clips +the tile bar and the status line, exactly where the output is). The **pacing** +is a per-step table rather than typing speed. And the header carries **no `env` +and no `timestamp`**: the first held `$SHELL`, the second the recording +machine's clock, and neither is anything a visitor is owed. + +Two decisions inside the scripts that are not obvious and cost a re-record each: + +- **No `:q` is typed at the end.** The player loops, so the last frame is also + the frame before the first, and a minibuffer reading `:q` is what it would + show. The process is signalled instead. +- **Each scenario has to be arranged so the thing it names is visible.** The + drill cast drilled into a cell with no records behind it — a true frame about + nothing — so it now moves `Customer` off the page axis first, which makes the + cell an aggregate over nine customers and gives the records grid more than one + row. The pivot cast pressed `c` on a category that was already on the column + axis, which is a no-op with a status line. Neither was visible from the + keystrokes; both were found by rendering the recording back and reading it. + +That last point is the method, and it is reusable: a cast can be replayed +through a small ANSI reconstruction and read as text, so "what does frame N +actually show" is a question with an answer rather than a guess. It is how the +`+cat` and `+` columns in `drill.cast` — improvise-2oxt's substantive +complaint, since the old recording predated both — were confirmed present. + +What the harness can check, and does: `landing.spec.js` asserts over the cast +**files** that no header carries `env` or `timestamp`, that the geometry is the +one the page configures, and that no chunk contains an OSC window-title +sequence, which is the leak's signature — `improvise` emits none and a shell +emits one per prompt, carrying the cwd. Asserted over the file rather than the +rendering because that is where the leak lives: a player draws a prompt as +ordinary text, and a screenshot assertion would have to know what the hostname +had been. + +--- + ## `harness/` — the browser instrument ### Why it exists, and what belongs in it diff --git a/context/code/core.md b/context/code/core.md index 9a6d587c..ec156b1a 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -39,6 +39,29 @@ differences are the whole content of the design (improvise-kdp): for `SUM`/`COUNT`, which have an identity, and an error for `AVG`/`MIN`/`MAX`, which do not. +### `evaluate_aggregated` takes `Aggregation`, not a list it cannot read + +The parameter was `none_cats: &[String]`, and the body consulted it exactly +once, for emptiness — a boolean wearing a list's type, since the aggregation +itself runs over `key.0` through `matching_values`. It never filtered by that +list, in any revision: `git log -S` back to the commit that introduced +`Axis::None` shows the same `is_empty()` and nothing else. So the name promised +a filter the body has never performed, and the next caller to build a careful +list would have threaded it into a parameter that cannot use it. + +`Aggregation::{Exact, OverHidden}` admits only the two states the body +distinguishes, and `Aggregation::of(none_cats)` performs the reduction once, at +the boundary that holds the list (improvise-5jm5, +`doctrine(an_invariant_is_made_unrepresentable_not_validated)` — the same answer +`prune_axis_items` and `gp-tree`'s `Row` got). It is an enum rather than a +`bool` because the two states have names worth reading at the call site, and the +regression guard is the **signature**: the thing the ticket describes — two +different lists, same emptiness, same answer — is no longer constructible. + +Note what does *not* change: `recompute_formulas` and `aggregate_raw` keep +`&[String]`, because they walk the elements (stripping each hidden coordinate +off a stem). Two parameters that looked alike were doing two different jobs. + ### Non-numeric values reduce by unanimity, and the branch order is the rule `aggregate_cell_values` checks the **all-numeric branch first**. If every value diff --git a/context/code/formula-and-io.md b/context/code/formula-and-io.md index 36375ee8..dd2a0d12 100644 --- a/context/code/formula-and-io.md +++ b/context/code/formula-and-io.md @@ -92,6 +92,28 @@ round-tripped from the day it exists. What the *evaluator* does with it, and why a fitted tree is always wrapped in a `SUM`, is `context/code/core.md` §13. +### `WHERE` carries the same boundary, and the mechanism has to be generatable + +`where_clause` and `inline_where` are compound-atomic with `sp+` after the +keyword, exactly as `is_expr` is (improvise-r08u). Non-atomic, pest auto-skipped +`WHITESPACE` after `^"WHERE"`, so `SUM(Rev WHEREAS x = y)` read as a `WHERE` on +a category `AS` — a silently wrong filter where it should have been a syntax +error, the same class of defect `IS` had. + +Two things decided the shape, and the second is the one worth carrying. A +negative lookahead `!bare_char` with `sp*` would have been *strictly* more +permissive — it keeps `WHERE|Region|=East`, which the old rule accepted — but +**the grammar-walking generator cannot honour a predicate**: it walks +`formula.pest` through `pest_meta` and emits `WHEREa=""`, text the grammar then +refuses, so `generated_formula_parses` goes red. A rule whose only guard is a +lookahead is a rule the generator can violate. `sp+` is generatable, matches +`IS`, and costs exactly one accepted spelling: a category may no longer abut the +keyword, so `WHERE |Region|` needs its space. + +Note compound-atomicity *alone* closes the ticket's own reproducer, because +`AS x` cannot be one identifier once implicit whitespace is gone. `sp+` is what +closes the residue, `WHEREAS=y`, and the negative test names both. + ## A formula target is unquoted, exactly as a reference is `parse_formula` strips the pipes off the target the same way it does off an @@ -158,6 +180,18 @@ format order-independent — a `## View:` may name a category defined further down. `PCategory::items` is a `Vec` in file order because `IndexMap` insertion order is load-bearing (gotcha 18), so pass 2 must replay the list as written. +Two `## Category:` sections naming one category are **one** category, and the +merge is a concatenation performed in pass 1 (`ParsedFile::push_category`, +improvise-349m). Merging in pass 2 — building each section in turn — was not +idempotent. A group is created either by the item that names it or by a `>` +line, and `build_category` creates its own section's item-groups *before* its +own `>` lines; so the first section's `>` groups landed ahead of the second +section's item-groups, while `format_md` writes one section with all the items +before all the `>` lines. The reparse then built the other order, and the two +`format_md` outputs differed. Concatenating first makes the built order the +written order by construction. Refusing a duplicate header was the alternative +and would have made a legal hand-written file an error for no gain. + ### A section header takes its name as the whole line, totally `header_name = { pipe_quoted ~ &NEWLINE | line_name }` (improvise-pczv). The @@ -390,7 +424,21 @@ and the derived `_Month`/`_Quarter` categories inherit the error. What makes it tolerable is that the wizard shows the detected format and lets the user change it — the same argument two-digit years already rested on. It is pinned by a test so that changing the answer is a decision somebody makes rather than a side -effect of reordering the table. Filed as **improvise-l8rh**. +effect of reordering the table. + +**Ruled a known limit** (improvise-l8rh, the user, 2026-09-20). Two things the +ruling rests on. Changing the default to day-first only moves which half of the +world imports silently wrong; and the honest fix is larger than the heuristic — +an ambiguous column should *surface* its ambiguity rather than resolve it, which +changes `FieldProposal`'s shape and the wizard step that displays it, the way +`Dataset::warnings` returns a decision the user should hear about as data. Worth +doing deliberately, not as a patch. + +What makes the limit reasonable to live with is that it is now *deterministic*: +`analyze_records` iterated a `HashSet` until improvise-7yx, so the ten samples +`detect_date_format` inspects were a different ten every run and a column whose +eleventh value ruled out `%m/%d/%Y` detected a different format run to run. The +ten-sample window itself is unchanged and is part of the limit. ### CSV parsing diff --git a/context/code/protocol.md b/context/code/protocol.md index ff3da03b..c8810e9b 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -477,20 +477,30 @@ the top edge (improvise-jjx). **`body_from(idx)`** drops the group header that owns the first drawn item: a frame opening mid-group carries that header so `group_of` can answer for it, but it is *above* the first drawn row and must not be painted as one. This is -the same cut `GridLayout::data_row_to_visual` has always made. Its argument -exists only because the two front ends scroll differently — a browser's frame -is already windowed and passes `0`, the TUI's covers the whole grid and passes -its row offset (gotcha 44) — **not** because the rule differs. +the same cut `GridLayout::data_row_to_visual` has always made. -**`group_of` answers "nearest preceding header", not membership, and -deliberately so: it is what the TUI has always painted.** `expand_category` -emits a header on *entering* a group and no boundary on leaving one, so an -ungrouped item following a grouped run is answered with the run's group — and -the TUI's column-group row spans it under that label today. Encoding -membership, or an end boundary, means changing `AxisEntry` in -`improvise-core` and changing what the TUI paints: a fix to the layout, not to -this projection of it, and it is filed rather than smuggled into the migration -that made the two ends agree (improvise-anr9, raised by Copilot on PR #23). +Two things about its argument, and the first of them **used to be the whole +story and was wrong**. It exists partly because the two front ends scroll +differently — a browser's frame is already windowed and passes `0`, the TUI's +covers the whole grid and passes its row offset (gotcha 44). But `idx` alone +cannot say whether the frame *leads the axis*, and at the top there is no +header "above" to drop: it is content, and a collapsed first group has no +other affordance. That is `AxisFrame::first` and improvise-0o6x — §3a. + +**`group_of` answers membership**, and the paragraph that stood here said the +opposite: that it answers "nearest preceding header", deliberately, because +that is what the TUI had always painted, and that encoding an end boundary +would mean changing `AxisEntry` in `improvise-core` and was "filed rather than +smuggled into" improvise-anr9. All of that was true when written. **The filed +ticket is improvise-gd83 and it has landed** — `AxisEntry::GroupEnd` closes a +run, `groups()` clears on it, and the TUI's painted span changed with it. The +mechanism and its two emission arms are §3a. + +That correction is here rather than only in §3a on purpose. This paragraph +forbade, in terms, the change §3a describes; a reader arriving at the stale +copy first would conclude the rule had been smuggled past its own prohibition. +When a fact is stated twice, changing it is a grep (gotcha 48) — and the two +copies being in *one file* is what made this worth saying out loud. ### The width rule has one home @@ -751,6 +761,57 @@ Nothing is defined for the help overlay: its content is static and --- +## §3a. The frame's group boundaries and its position in the axis + +Two facts `AxisFrame` gained after `improvise-anr9`, both about group runs. + +**`FrameEntry::GroupEnd` closes a run.** `group_of` used to answer "nearest +preceding header", which is not membership: `expand_category` emitted a header +on *entering* a group and nothing on leaving one, so an ungrouped item after a +grouped run inherited it (`improvise-gd83`). The §3 paragraph that recorded the +old rule — and forbade this change — is corrected there, not merely superseded +here. `AxisEntry::GroupEnd` is +the boundary, carried onto the wire as `FrameEntry::GroupEnd`; `groups()` clears +`current` on it and `group_of` stops walking. It is emitted only on +`Some -> None`, never between two groups — the next header already ends the +previous run — and at the end of an expansion whose last item was grouped, which +is the across-prefix arm `cross_product` creates. + +Nothing draws a `GroupEnd`. Both front ends skip it and it consumes no data +index, so it is invisible except through `group_of`/`groups`. + +**`AxisFrame::first` is the absolute data index of the frame's first item.** The +windowing rule carries the nearest header *above* a scrolled window so +`group_of` can answer, and `body_from` drops it on the way to the screen. At the +top of an axis there is no "above", so that same leading header is content — a +collapsed first group has no other affordance (`improvise-0o6x`). `idx` alone +cannot tell the two apart, because the browser's frame is already windowed and +always passes `0`; `first + idx == 0` can. + +The TUI builds over the whole grid so `first` is always `0` there, and the +browser's comes from the projected window. A frame built by hand in a test must +set it, which is why it is a plain field rather than derived. + +It carries `#[serde(default)]`, and **that is a defect rather than a decision** +(improvise-1wl0): `first: 0` is not an absence but the claim "this frame leads +the axis", which is the claim `body_from` acts on — so a frame that loses the +field deserializes as leading. No skew ships today, both ends being one +workspace at one commit. Same argument as `Basis` having no `Default`. + +**`group_runs` / `group_run_heads` are the coalescing rule's one home.** The TUI +and `render.rs::col_groups` each coalesced adjacent columns sharing a group, +kept in step by a comment (`improvise-8qlr`). Runs coalesce by group **name**, +so two adjacent runs under two different headers of the same name read as one — +that is what both ends did before and is preserved deliberately, because +changing the rule in the commit that moves it would leave the agreement test +unable to tell "both ends agree" from "both ends changed together". + +`FrameGroup` carries `cat` and could fix it, and **a single home reads as +decided** — which is the new risk and why it is filed as improvise-h0iv rather +than left as a note. The TUI's one +divergence is that it labels its first *visible* column even mid-run, so a +scrolled grid does not draw an unlabelled group line. + ## 4. `frame.rs` — one envelope pair, two deployments ### The framing adds as little as it can get away with diff --git a/context/code/tui.md b/context/code/tui.md index 07a42210..750436cf 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -121,11 +121,34 @@ enum, and only the last translation is local.** `mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and `Color` are ratatui types and `improvise-command`, where `AppMode` lives, -carries no rendering-framework dependency) — **but it is not the same shape.** -It matches on `AppMode` directly with a `_ =>` wildcard for "the default chrome -for every non-highlighted mode", so a new `AppMode` variant silently inherits -that chrome instead of failing to compile, and there is no semantic enum in the -shared layer to decide it. That is `improvise-zx8t`, filed and not fixed. +carries no rendering-framework dependency), and since `improvise-zx8t` it is +**exhaustive**: the fourteen variants that take the default chrome are spelled +out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the +build here until someone chooses its colour. + +It is still not quite the same shape as `axis_fg` and its two siblings, and the +difference is deliberate rather than pending. Those three map a *semantic enum* +the shared layer owns; `mode_style` matches `AppMode` itself, because there is +no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here +for two reasons: the colour is the only thing at stake, and the ticket says to +decide it together with `improvise-k099`, which asks the same question of +`hint_text`/`label` and is not in this unit. Exhaustiveness is what the +repo map's "the compiler names every one you must fill in" promise needed; a +semantic enum is a separate, larger answer and is still open. + +**The guard is the compiler, not a test.** Nothing asserts the colours, and a +test enumerating the variants would not fail when a fifteenth is added — only +the missing match arm does. Verified by deleting the `AppMode::Help` arm: +`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`. + +### The minibuffer shares the bottom row with the status line + +`draw_bottom_bar` used to render the minibuffer *instead of* `draw_status`, so a +status message written while any text-entry mode was open was never drawn +(`improvise-mai9`, and the stranding gotcha 21 describes). `minibuffer_line` +composes both: the prompt and buffer on the left, any pending `status_msg` +right-aligned. The alternatives and why they lost are in +`context/test-evidence/tui.md`. ### The caches are built here, per frame diff --git a/context/design-principles.md b/context/design-principles.md index 0b2b8bc8..b96bd51e 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -145,11 +145,18 @@ Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline). This convention is shared between the `.improv` persistence format and the formula parser's identifier syntax. -### Formula Tokenizer: Identifiers and Quoting +### Formula Identifiers and Quoting -**Bare identifiers** support multi-word names (e.g., `Total Revenue`) by -allowing spaces when followed by non-operator, non-keyword characters. Keywords -(`WHERE`, `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`, `IF`) act as token boundaries. +**Bare identifiers** are `(ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")*` +— no spaces, the same shape `improv.pest`'s `bare_name` has. A multi-word name +such as `Total Revenue` must be pipe-quoted. + +**Keywords need a word boundary after them, not before.** `IS`, `WHERE` and the +aggregate names are spelled `^"…"` in `formula.pest`, and `is_expr`/`where_clause`/ +`inline_where` are compound-atomic with `sp+` so that `Region ISLAND` and +`SUM(Rev WHEREAS x = y)` are parse errors rather than silently-wrong filters. A +`SUM`/`IF` without parens falls through to `ref_expr`, which is why a bare +keyword still names an item; the writer quotes it anyway (`quote::quote_name`). **Pipe-quoted identifiers** (`|...|`) allow any characters — including spaces, keywords, and operators — inside the delimiters. Use pipes when a category or @@ -162,11 +169,10 @@ item name collides with a keyword or contains special characters: SUM(|Net Revenue| WHERE |Region Name| = |East Coast|) ``` -Pipes produce `Token::Ident` (same as bare identifiers), so they work -everywhere an identifier is expected: expressions, aggregate arguments, WHERE -clause category names and filter values. Double-quoted strings (`"..."`) -remain `Token::Str` and are used only for WHERE filter values in the -`split_where` pre-parse step. +`identifier` is `pipe_quoted | bare_ident`, so a pipe-quoted name works +everywhere a bare one does: expressions, aggregate arguments, and a `WHERE` +clause's category name. A double-quoted string is a `filter_value` only — the +right-hand side of a `WHERE` or an `IS`, never an expression. --- diff --git a/context/repo-map.md b/context/repo-map.md index 2a35525f..f296de78 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -183,7 +183,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 22. **The import wizard's back path is a history stack, not a reverse table.** `advance` pushes the step it leaves onto `ImportWizard::history`; `previous` pops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live in `validation_error()`, checked on the step where the user can still fix them — never deferred to `build_model`. `NameModel` is terminal for `advance`: Enter there calls `build()`, which parks the workbook in `built` on `Done`, and `AcceptWizardModel` takes it from there. Going back from `Done` discards `built`. 23. **`app.layout` goes stale mid-batch.** `apply_effects` rebuilds only *after* the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs a `RebuildLayout` effect in front of it — the command decides the order (see `EnterEditAtCursorCmd`, `CommitAndAdvance`). Read the cursor's cell text via `App::display_value_at_cursor()`; don't build a `CmdContext` inside `apply` (it recomputes column widths). -24. **The cursor is per-client and keyed by view.** `ViewState::cursors: HashMap` holds `selected`/`row_offset`/`col_offset`, keyed by view name; an absent key means the origin. Reach it through `App::cursor()` (a `Copy` snapshot), `App::cursor_mut()`, and `App::reset_cursor()` — never off `View`, which no longer has the fields (improvise-8uz). Two consequences. **Changing the grid's shape must reset the cursor explicitly**: `View::transpose_axes`/`cycle_axis` used to do it themselves, and now `TransposeAxes`/`CycleAxis` call `app.reset_cursor()` — a new geometry effect that renumbers rows or columns has to do the same. **Replacing the document must clear the map**: `LoadModel` and `AcceptWizardModel` call `cursors.clear()`, because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replaces `Workbook::normalize_view_state`, which is gone — a `View` can no longer carry a stale offset. The same clearing owes the **session mirrors**: `install_workbook` bumps `App::document_generation`, and `ClientSession::project` reseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reach `Sessions`, so the generation is the mechanism and `Sessions::rebase` is only the client-facing half. The same is true of a **geometry** change, and it owes the mirrors *both halves*: `App::cursor_epoch` is bumped by `reset_cursor` and watched by `Sessions::adopt_cursor_epoch`, which resets every mirror **and** returns `Broadcast::ToAll(Command::ResetCursor)` (improvise-gvnb). Doing only the first is **strictly worse than doing nothing** — the diff then believes every client already holds origin-positioned content and sends nothing, so they stay stale *permanently* rather than until their next scroll. The trigger is the **epoch moving**, not the command variant, for the reason gotcha 39 watches `document_generation` instead of matching on `LoadModel`: which effects renumber the grid is a per-effect decision, so a table of variants would be a second description of it, free to disagree. What is broadcast is an **invalidation, never a position** — another client's transpose must not move where I am looking (that is UI and it is mine, improvise-g0f), only declare that where I was looking has stopped meaning anything. **And the observation is only as complete as the `reset_cursor` calls.** Nothing declares which effects renumber the grid — deliberately — so a geometry effect that forgets `reset_cursor` is invisible in *both* directions: the host's own cursor stays stale *and* no client is told. Today only `TransposeAxes` and `CycleAxis` call it; `SetAxis`, `HideItem`/`ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`/`RemoveCategory` and `AddItem`/`RemoveItem` all renumber and do not (improvise-9mo9). `SetAxis` is the sharpest — `CycleAxis` *is* `SetAxis` to `Axis::next()`, and one resets while the other does not. The negative arm is real and must stay tested (`SetNumberFormat`, a `LayoutGeometry` effect that must *not* reset): without it, "reset on every model command" would pass the positive test. +24. **The cursor is per-client and keyed by view.** `ViewState::cursors: HashMap` holds `selected`/`row_offset`/`col_offset`, keyed by view name; an absent key means the origin. Reach it through `App::cursor()` (a `Copy` snapshot), `App::cursor_mut()`, and `App::reset_cursor()` — never off `View`, which no longer has the fields (improvise-8uz). Two consequences. **Changing the grid's shape must reset the cursor explicitly**: `View::transpose_axes`/`cycle_axis` used to do it themselves, and now `TransposeAxes`/`CycleAxis` call `app.reset_cursor()` — a new geometry effect that renumbers rows or columns has to do the same. **Replacing the document must clear the map**: `LoadModel` and `AcceptWizardModel` call `cursors.clear()`, because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replaces `Workbook::normalize_view_state`, which is gone — a `View` can no longer carry a stale offset. The same clearing owes the **session mirrors**: `install_workbook` bumps `App::document_generation`, and `ClientSession::project` reseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reach `Sessions`, so the generation is the mechanism and `Sessions::rebase` is only the client-facing half. The same is true of a **geometry** change, and it owes the mirrors *both halves*: `App::cursor_epoch` is bumped by `reset_cursor` and watched by `Sessions::adopt_cursor_epoch`, which resets every mirror **and** returns `Broadcast::ToAll(Command::ResetCursor)` (improvise-gvnb). Doing only the first is **strictly worse than doing nothing** — the diff then believes every client already holds origin-positioned content and sends nothing, so they stay stale *permanently* rather than until their next scroll. The trigger is the **epoch moving**, not the command variant, for the reason gotcha 39 watches `document_generation` instead of matching on `LoadModel`: which effects renumber the grid is a per-effect decision, so a table of variants would be a second description of it, free to disagree. What is broadcast is an **invalidation, never a position** — another client's transpose must not move where I am looking (that is UI and it is mine, improvise-g0f), only declare that where I was looking has stopped meaning anything. **And the observation is only as complete as the `reset_cursor` calls.** Nothing declares which effects renumber the grid — deliberately — so a geometry effect that forgets `reset_cursor` is invisible in *both* directions: the host's own cursor stays stale *and* no client is told. improvise-9mo9 closed that gap: **thirteen** effects call it — `AddCategory`, `AddItem`, `AddItemInGroup`, `RemoveItem`, `RemoveCategory`, `SetAxis`, `SetPageSelection` (conditionally, only when the page actually moved *and* pruning renumbered the grid — improvise-7kvw), `ToggleGroup`, `HideItem`, `ShowItem`, `TogglePruneEmpty`, `TransposeAxes`, `CycleAxis`. Do not trust that list; **derive it** — `awk '/^impl Effect for /{n=$4} /reset_cursor\(\)/{print n}' crates/improvise-command/src/ui/effect.rs` — because this paragraph said "only `TransposeAxes` and `CycleAxis`" for a day after every other entry had been added, naming as *absent* the exact set that was by then *present*. A stale list here is worse than none: it reads as a survey of what still needs doing. **Do not reconcile it with the thirteen effects that declare `LayoutGeometry`** — the two counts are equal, come from different derivations, and the sets differ in **both** directions, three each way. `CreateView`, `DeleteView` and `SetNumberFormat` declare the kind and never reset; `AddItem`, `AddItemInGroup` and `RemoveItem` reset while declaring `CellData`, because adding or removing an item renumbers the grid even though the change is data rather than geometry. Equal length invites reconciling one list to the other, which silently changes what one of them means — the same way the wasm bundle is seven crates and its gate is eight, and neither number is the other one typo. A warning about the derivation, learned by falling into it while writing this paragraph: the naive `awk`/`grep` over `LayoutGeometry` reports **fourteen**, picking up `HelpPageSet`, which is `ViewOnly` — it is the last `pub struct` before `#[cfg(test)]` and the test module mentions `ModelChange::`. Brace-match the `impl Effect` blocks (`doctrine(derive_then_verify)`). The negative arm is real and must stay tested (`SetNumberFormat`, a `LayoutGeometry` effect that must *not* reset): without it, "reset on every model command" would pass the positive test. 25. **`ViewState` and its field types are `Serialize`/`Deserialize`.** It is the wire payload of the client Snapshot (improvise-q08), so `AppMode`, `MinibufferConfig`, `MinibufColor`, `ViewFrame`, `KeyCode` and `KeyModifiers` all derive both (improvise-a2x), and `improvise-command` depends on `serde` for it. The constraint this puts on new state: **no borrowed-`'static` fields anywhere ViewState can reach** — `Deserialize` cannot produce one, and the error surfaces on every enclosing variant rather than at the offending field. `view_state_survives_a_json_round_trip` guards it. `ViewState` and `Snapshot` also derive `Clone` and `PartialEq`, so the bootstrap message can be compared in a round-trip test. @@ -230,7 +230,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 43. **A batch carries its basis, and the server had to publish it first.** `Command::Model { basis, cmd }` is a **struct variant** so the basis cannot be forgotten, and `Sessions::apply_model` checks it *before* applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know. **`Basis` has no `Default` and no public constructor**: a zero-valued basis is not "no claim", it is the claim that *the document is fresh*, which in a new `App` is true — so a default would be a forgeable bypass rather than an absence. `BasisCache::claim` is the only door a client has and it returns `None` until the server has published one. Note what that does and does not buy: `Basis` derives `Deserialize` because it must cross the wire, and its fields are public, so **a hand-rolled client can state any tiers it likes**. This is an *anti-staleness coordination check, not a security boundary* — `RecordUnstated` closes **omission**, not **invention**, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having: it makes the honest path the easy one. **Which records are checked is the server's decision** — `reduce.rs::records_named`, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the *client's* claim would be a **force by omission**: the record tier skipped, with no flag to remove and nothing in the message saying so. A claim that omits a record the command writes gets `Rejection::RecordUnstated`, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user. **`Rejection` keeps structural and data staleness apart**, which is the whole reason ADR 002 put a maintained `RecordHash` index in `DataStore`: `RecordGone` means nothing resolves and there is nothing to force (**force never creates structure**), `RecordMoved` means it resolves at another version and force is one keystroke — which is also why the wasm client stores the `Rejection` itself rather than collapsing it into `status_msg`, since a string cannot carry which record or which kind. **And the ADR omitted the publication.** It said a batch carries the basis it was composed against and never said how a client learns one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. `Projection::Basis` + `BasisCache` close that — the server publishes the two document tiers plus the counters for the records *this client's grid names*, diffed against what that client was last told and seeded in the bootstrap — and it is emitted **after** the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown, the mirror image of gotcha 33. That ordering was documented as load-bearing in three places and **held up only by the order of two statements** until `a_published_basis_follows_the_grid_updates_it_describes` pinned it (proved by injecting the swap) — the same job `a_cell_update_that_overtakes_its_labels_is_lost` does for the mirror-image rule. Two things are unchecked and deliberately so: `Command::SwitchView` carries no basis (it sits outside `Command::Model`; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), and `apply_command`/`reduce_full` do not check at all — **the check is a property of the session door, not of the mutation path**, which is right because a host holding the `App` is the truth rather than a claimant. That second one **was** a door rather than an exemption, and needed a type to stop being one (improvise-ondj): both took a wire `Command` and their `Command::Model { cmd, .. }` arm **discarded the basis with `..`**, while `ui/reduce.rs`'s own module doc pointed every future host — the browser worker included — at exactly those two names. **The struct variant closed omission *from the message*; it did nothing to close ignoring *on arrival*, which is the half a struct variant is usually reached for.** So both now take `Checked<'_>`, a private-field newtype over a `&Command` whose only constructor is `Checked::against(&App, &Command) -> Result<_, Rejection>` — also the only caller of `stale_basis`. `Sessions::apply` answers **once, before the dispatch**, so `apply_switch`/`apply_model` receive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answer `prune_axis_items` and `gp-tree`'s `Row` got. Visibility was the weaker option and was rejected for a concrete reason — `pub(crate)` closes only the out-of-crate path, and `reduce_full` has **no production caller anywhere in the workspace**, so it would have become dead code. **The regression guard is therefore the signature**: the test that reproduced the bug no longer compiles, and what remains asserts the constructor refuses and that refusing leaves the document untouched. One residual hole, documented on the type and filed: a `Checked` does not name *which* `App` it was checked against, so a multi-document host could in principle check one and apply to another. That is also why `ui::reduce::caught_up(&App, cmd)` is not a bypass: it takes `&App`, so nothing reachable from a wire can call it. **Since improvise-955n the frame, not the command, is what is answered — and only two of the three tiers hoist.** `CheckedBatch::against` runs before anything in a `ClientFrame::Commands` is applied. `document_generation` and `layout_version` describe the *document*, so answering them once for the frame is exactly right, and a first version hoisted all three — which was a lost update: the record tier asks "is the client's picture of **this record** current", and which records are at stake is a property of the *command* (`records_named`). Skipping it for later commands let `[SetCell(North), SetCell(South)]` overwrite a South somebody else had moved, silently, with the frame reported accepted, and invisible to the other two tiers because a `SetCell` is `CellData` and moves neither. So every mutation answers for its own records **against the frame's basis** — the one the first basis-carrying command stated, never each command's own field, or a later command could claim a fresher picture than the frame was composed under. The one exemption is stated over a *property* rather than a list of commands: a record that **neither resolves in the document nor is claimed by the basis** is one the frame is about to create (`[AddItem(East), SetCell(East)]`, which `BasisCache::claim` cannot possibly claim for), and only that combination is exempt; a record that does not resolve but *is* claimed is still `RecordGone`. That the `Basis` inside a *later* `Command::Model` is ignored rather than compared is an honest interim: clause 1 says the **frame** states one basis, and moving the field from `Command::Model` to `ClientFrame::Commands` would make that unrepresentable-otherwise instead of a convention — a protocol change that belongs with clause 4. Two things worth keeping from the rejected design: **arity is not a property of a variant** (a future `Command::RunScript` is one variant performing unboundedly many mutations), so no count over the vocabulary can enforce "one mutation per frame"; and **the transaction boundary is not the frame**, because one command may already be a chain, which is why clause 4's mechanism belongs at `App::apply_effects`. -44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers with `Item(Vec)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers "nearest preceding header", which is what the TUI has always painted rather than membership; changing that means changing `AxisEntry` in `improvise-core` and is filed, not smuggled in. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. +44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers with `Item(Vec)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**: the nearest preceding header *unless* an `AxisEntry::GroupEnd` closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. `GroupEnd` draws nothing and exists only so membership is not inherited. Still open beside it: `group_runs` coalesces by name where `FrameGroup` carries a `cat`, so two runs under different headers merge (improvise-h0iv), and `AxisFrame::first` is `serde(default)`, so a frame that loses the field claims it leads the axis (improvise-1wl0). And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. 45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8ab53306..cce8fcc0 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -2062,3 +2062,244 @@ done The other two losses are deliberate: `pointer.spec.js`'s sidebar test was superseded by the open-at-launch ruling, and the widgets file was merged into this one. + +#### "a click moves the selection from an editing state too" + +improvise-0x9u, and the fault was injected by running this assertion against the +bundle as it stood: `cells.nth(4).locator("input")` came back **0**, with the +selection still on cell 0, exactly as the bug report measured in Chromium. + +Two things about it are worth more than the assertion. First, **what it does not +do**: it never presses Escape between the two clicks. The sibling test does, and +that single line is why the property it names ("a click moves the selection") was +asserted for a whole wave while the neighbouring state was broken — the click +path from an *editing* page was never exercised. + +Second, **the fix's own first attempt failed this same test**, which is the part +to remember. Moving the handler to `mousedown` is necessary and not sufficient: +`close` still repainted, and removing a focused `` fires `blur` +**synchronously**, so `close` rebuilt the table from inside itself and detached +the `` the handler was about to open an editor on. The assertion could not +tell that apart from the original bug — same symptom, different cause — so it is +the debug run rather than the test that separated them. `close` therefore no +longer repaints, and the `blur` arm checks `closed` before dismissing. + +Third — and this is the edit the repair nearly shipped unguarded — **the fix is +three edits, not two**, and the third is the one with the most careful comment: +`close` restores `td.textContent` (the *drawn* value) instead of `seed` (the +*stored* one), because `close` no longer repaints and `repaint()` early-returns +for as long as `editing` is set. Reverting that one line to +`td.replaceChildren(original)` left the whole suite green, and the defect was on +the front page: `web/demo.improv` carries `format: ,.0` over `18400`, and this +test's own two clicks — no Escape between them — leave cell 0 reading `18400` +where the page had drawn `18,400`, for as long as the second editor stays open. +The assertion added for it is `toHaveText("18,400")` after the second click, and +the injection is exactly that revert: it fails with `unexpected value "18400"`, +naming this test and no other. + +The pattern is worth carrying past this entry. A repair of N edits whose N−1 +guarded ones pass is not a guarded repair, and the unguarded edit is reliably +the one the author understood best — which is why it carries the clearest +comment and why it felt covered. For each distinct edit, name the test that +fails when only that edit is reverted; a line with a comment and no answer to +that question is the line to inject. + +#### "the widgets the demo document fills are drawn, in the state that fills them" + +The `#doc-name` row is improvise-5jna, and the reason it belongs in a **browser** +test rather than in `render_surface.test.js` is that the latter is a substring +search over the page's source: `doc_name` mentioned by a function nothing calls +reads as painted. That is how `model.minibuffer` once shipped dead, and how +z754's three title-bar fields sat projected and unpainted for a wave while the +static check was green. Proved by adding the assertion first: `element(s) not +found` for `#doc-name`. + +The `[+]` marker is deliberately **not** asserted here. The standalone deployment +persists on every accepted batch and `effect::Save` clears the flag in the same +round trip (gotcha 45), so the marker is transient in a browser and any timing +assertion over it would be flaky rather than discriminating. Its spelling — the +order `name (stored_as) [+]`, the `null` `stored_as` writing no empty parens — is +`web/tests/shell.test.js`, over `documentTitle` directly. + +#### "a selection driven below the fold scrolls itself into view" + +improvise-jq8l, and the expensive half was the **fixture**, not the assertion. +Neither shipped document can reach the state: `web/demo.improv` is four rows and +`harness/fixtures/grouped.improv` is eight, and a probe measured both at +`scrollHeight === clientHeight` in a 484px box — nothing to scroll, so the +assertion would have passed against a page that never scrolls. That is the +vacuity this entry exists to record, and it is why the spec asserts +`scrollHeight - clientHeight > 0` **before** it presses a key. + +`harness/fixtures/tall.improv` is thirty rows in three groups, which overflows for +a reason worth stating: `ROW_PX` is 26 and a real row measures 28.6px, and inline +group rows are not charged for at all, so the page renders more rows than its own +box can show. Measured against the unfixed page: 105px of overflow, the selected +cell 96px below the visible bottom, `scrollTop` still 0 after twenty-five +`ArrowDown`s. + +Both directions are asserted, and neither alone is enough: the cell being inside +`#grid`'s rect would also hold if the selection had never moved, and `scrollTop > +0` would also hold if the page scrolled to the wrong place. + +#### "the casts are legible on a phone, and the page still does not scroll sideways" + +improvise-o31u, and **the instrument is the finding**. `getBoundingClientRect` +reports a height for a `content-visibility`-hidden child, and heading text +overflows its box without widening it, so neither "the element is present" nor a +box measurement can see this defect at all. What a reader sees is the **computed +font size**, and that is what is asserted. + +Measured against the unfixed page, and it reproduces the ticket's figure to five +digits: `pivot.cast` renders its terminal at **4.83258px** on a 390px viewport. +The arithmetic is not subtle — the player is told `cols: 120`, the container is +~356px inside the page's 16px gutters, and the player DERIVES its font size from +the width it is given. After the fix: **11.9457px**. + +Two assertions guard the trade this fix must not make. The page's own +`scrollWidth - clientWidth` must stay **0** — a document that scrolls sideways +on a phone is a worse bug than one unreadable section — and `#cast-pivot` must +be the thing that scrolls, which is what separates "contained the overflow" from +"removed the overflow by shrinking something else". + +#### "a desktop viewport is unchanged by the phone fix" + +The negative arm, and **it is forward-looking: no fault available today makes it +fail.** The entry previously named two mechanisms and both were measured, at a +1280px viewport, against the shipped page: + +- `@media (max-width: 900px)` → `@media all`: this test **passes** (13.1131px, + `scrollWidth === clientWidth`). +- `min-width: 880px` moved from `.player > *` onto `.player`: this test + **passes**; the *phone* arm fails instead, on `scrolls.page === 0`. + +The arithmetic is why, and it is worth stating rather than re-deriving: at +1280px `#cast-pivot` measures `clientWidth === scrollWidth === 966`, so an 880px +floor is inert at desktop wherever it is placed. The floor would have to exceed +the ~966px the player actually gets — or the content column would have to +narrow — before either assertion here could move. That is a real future fault +and the reason to keep the test: it pins 13.11px and "nothing to scroll" against +a floor raised past the player's own width. It is not a reason to claim the arm +currently discriminates between the two placements, because it does not. + +Note also that the **phone** arm is stronger than this entry used to credit it +with: the misplaced-`min-width` fault, attributed here to the desktop arm, is +caught there. `scrolls.page === 0` is the assertion that separates "contained +the overflow" from "moved it onto the document". + +#### "no cast carries the recording machine's shell, clock or geometry" + +improvise-2oxt, and **both clauses were injected separately**, which matters +because the first would otherwise mask the second. Serving the pre-2oxt +`pivot.cast` fails on `env` — `Received: {"SHELL": "/bin/zsh", "TERM": +"screen-256color"}` — and stops there. Stripping `env` and `timestamp` from that +same header and serving the body unchanged then fails on `pivot.cast sets a +window title`, which is the clause that actually watches for the leak: an OSC +window-title sequence, emitted by a shell once per prompt and carrying the cwd, +and emitted by `improvise` never. + +Asserted over the cast **file** rather than over the rendering, and that is the +finding. A player draws a shell prompt as ordinary terminal text, so a +screenshot or a `toContainText` assertion would have to know what the hostname +and home directory had been — a check that can only be written by someone who +has already seen the leak. The file-level signature can be written by anyone. + +What no assertion here reaches, and how it was checked instead: whether each +cast SHOWS what its section claims. That is not a property of the bytes. It was +verified by replaying each recording through a small ANSI reconstruction and +reading the frames as text, which found two scenarios that recorded cleanly and +demonstrated nothing — a drill into a cell with no records behind it, and a `c` +pressed on a category already on the column axis. Both are true frames about the +wrong thing, which is the failure mode this repo keeps meeting, and neither was +visible from the keystroke list. + +--- + +## `improvise-ws-server` — the `Origin` check (improvise-kc7n) + +The bug: `grep -rni origin crates/improvise-ws-server/src/` returned nothing. +The server inspected no `Origin` header and accepted every handshake, and a +websocket handshake is exempt from the same-origin policy — no CORS preflight — +so any page a user visited could open a socket from that user's own allowed +address and read and edit every document served. An address check cannot see +it; the argument is `context/code/clients.md` §Origin. + +Five edits, five guards. Each was reverted alone and the named test failed; +nothing here is covered only by a comment. Review added a sixth — see +`two_origins_state_none_this_policy_can_answer_for`. + +### a_handshake_from_a_foreign_origin_is_refused + +The reproduction, written first and watched fail on `2afbf37`: a handshake +carrying `Origin: https://evil.example` connected, and the test's +`connect_async(...).err()` was `None`. After the fix the upgrade is refused with +a 403 and the assertion is on the status, not merely on "an error happened" — +a refused upgrade and a dropped connection are different outcomes and only one +of them is the fix. + +**Injection:** replacing the handshake callback's `admitted(&origins, req)` with +`Ok(())` — the transport collecting a policy it never asks — fails this and +`a_named_origin_reaches_the_document`, and nothing else in the workspace. + +### a_handshake_with_no_origin_is_accepted + +The case most likely to be wrong later, so it is named rather than left to a +match arm. Non-browser clients send no `Origin`: curl, a native client, this +file's own other tests. **Injection:** flipping `OriginPolicy::permits`'s `None` +arm to `false` fails this, `an_absent_origin_is_allowed`, +`the_handshake_refuses_what_the_policy_refuses`, and *both pre-existing socket +tests* — which is the point: the repo's own instrument is the non-browser client +this arm is about. + +### a_handshake_from_a_loopback_page_is_accepted + +The shipped local recipe — the page served by `python3 -m http.server 8080`, +the socket on another loopback port — must keep working with no flag, or the +default is a different bug from the one being fixed. **Injection:** making +`is_loopback` return `false` fails this and +`a_loopback_page_is_allowed_by_default` and leaves the refusal tests green, +which separates "allows loopback" from "allows everything". + +### a_named_origin_reaches_the_document + +`--allow-origin` end to end over a real socket, plus the negative arm in the +same test: widening for one page does not widen for its neighbours. Without +that second assertion the test passes against a policy that permits everything, +which is the fault E1a injects. + +### allow_origin_widens_the_policy_and_nothing_else_does + +The wiring, which no socket test can reach: the other tests build an +`OriginPolicy` directly, so a flag collected by clap and dropped on the floor is +invisible to every one of them. This drives `Args::parse_from` and asserts both +directions — bare refuses the deployed https origin and still allows loopback; +widened allows the named origin and not its neighbours. **Injection:** making +`Args::origin_policy` return `OriginPolicy::default()` fails this test alone. + +### two_origins_state_none_this_policy_can_answer_for + +Found by review rather than by the ticket. `HeaderMap::get` is **first-wins**, +and the deployment this README recommends puts a reverse proxy in front of the +socket — a proxy that *appends* `Origin` rather than setting it leaves two, and +reading the first lets the second be anything at all. No browser sends two, so +two is not a state any policy can answer for and the handshake is refused. +**Injection:** restoring the first-wins read (`(Some(value), Some(_))` answering +from `value`) fails this test alone, across the whole workspace. The test also +asserts the allowed origin *by itself* is still allowed, so the refusal is +demonstrably the second header rather than the first. + +### a_refusal_is_a_forbidden_response + +The refusal has to be a refused *upgrade*, and the socket test asserts the +`Error::Http` **kind** and the status rather than matching "403" in a +`Display` string: a dropped connection and a refused handshake are different +outcomes and only one of them is this fix. **Injection:** changing `forbidden()` +to `UNAUTHORIZED` fails this and `a_handshake_from_a_foreign_origin_is_refused`, +which is what shows the socket test reads the status rather than merely +observing that connecting failed. + +What the signature covers instead of a test: `transport::serve` **takes** the +policy, so a host cannot forget to pass one — `doctrine(an_invariant_is_made_ +unrepresentable_not_validated)`. What remains uncovered and is stated rather +than guarded: a proxy that strips `Origin` turns the check off, which is why the +proxy's own Origin allow-list stays in `README.md` as defence in depth. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5d469e4e..ec26b005 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -403,6 +403,133 @@ than `effect.apply(&mut app)`. That is deliberate: the dirty flag is raised off the `EffectKind` now (improvise-gv9r, gotcha 45), so driving the effect directly would assert a flag nothing in production sets. +### every_file_writing_effect_aborts_the_batch_on_failure + +improvise-z05r, the **third** instance of one rule: improvise-8tk fixed `Save` and +not `SaveAs`; PR #27 found `SaveAs`; `ExportCsv` was the arm still missing, so a +`Binding::Sequence` or a `:` line composing an export with anything after it +continued past a failed write. + +The fix is structural rather than a third patch: all three now report through one +`report_file_write(app, outcome)`, which writes the status message either way and +sets `abort_effects` on `Err`. That is not sufficient by itself — a fourth writer +could still be written without calling it — so the test names the three, and a +fourth has to come here and be named. Naming is the mechanism; the shared function +is what makes the naming cheap to honour. + +Each case drives `App::apply_effects` with `[writer, ChangeMode(Quit)]` against a +path under a directory that does not exist, and asserts both `last_batch_aborted()` +and that the trailing mode change never landed. + +Injected fault: drop `app.abort_effects = true` from `report_file_write`. Fails by +name, along with the four older abort tests — which is the point: they and this one +now share one mechanism. + +Injected fault, narrower: route `ExportCsv` around the reporter and write its status +directly. Only this test fails, naming `ExportCsv`, with 460 passing — the exact +state the ticket describes. + +### a_batch_that_writes_nothing_leaves_the_document_clean + +improvise-748f. `ViewNavigate { forward: false }` emits `ApplyAndClearDrill` +unconditionally, and that effect returns immediately when `drill_state` is `None`. +It declares `Model(CellData)` regardless — `Effect::kind` takes no `App`, so the +classification cannot be state-dependent — so `apply_effects` raised +`model_state.dirty` on a document nothing had touched. Press view-back, press `:q`, +and the quit is refused; autosave arms thirty seconds later. + +The older test here, `start_drill_and_apply_clear_drill_with_no_edits`, pinned the +wrong behaviour in an assertion message arguing it was the same over-approximation a +failed `LoadModel` gets. It is not: a succeeding `LoadModel` leaves a document that +really does differ from the file its path names, and that remains declared. This was +a wrong answer to no divergence at all. + +### a_cell_data_effect_no_raw_cell_records_still_dirties_the_document + +The guard against the cure improvise-748f **proposed**, which is unsound. The ticket +suggested raising `dirty` off `bump_changed_records`' per-record fingerprint of the +raw cells, on the reading that `CellData` means cells. Five of the ten `CellData` +effects change the document without writing a raw cell: `AddItem`, `AddItemInGroup` +and `SortData` (which reorders — and `.improv` carries the order), and `AddFormula` +and `RemoveFormula`, whose results live in `Model::formula_cache` and not in the +store. Observing raw cells would have left every one of them reading clean, which is +a *lost edit* — the opposite direction from the spurious prompt it was fixing. + +So the observation is `document_content` — `document_config` plus the cells in +iteration order and the formulas — and **only the `CellData` arm reads it**. The +`LayoutGeometry` arm keeps the narrow `document_config`. A first version pointed both +arms at the wide fingerprint, and review benchmarked what that costs at 18,000 cells +in release over 200 batches after warm-up: one `SetCell` batch 8.07 ms → 10.21 ms +(+27%, a constant factor on a path that was already O(cells) through +`record_fingerprints`), but one `SetNumberFormat` batch **78 µs → 2.63 ms, 33×** — +an *asymptotic* change from O(config) to O(cells + config) on the held-key path that +`[`/`]`, axis cycle, hide/show, group toggle, transpose and prune toggle all take. +~380/sec where they had been ~12,800/sec. The split costs no correctness because all +five blind effects are `CellData`; that every `LayoutGeometry` effect really is +visible to the narrow fingerprint is asserted separately, and not left as a reading. + +`WholeWorkbook` stays *declared*, and that is the remaining over-report: a `LoadModel` +whose incoming content happens to be identical still marks the document dirty, and a +failed one does too. Both cost one redundant write. + +The `SortData` case needs two cells inserted out of sorted order, or it reorders +nothing and its case asserts the opposite of what it names. The `AddFormula` case +removes the fixture's formula first, or adding it is a no-op. + +Injected fault: narrow `document_content` back to the configuration it hashed before. +Fails naming `SortData`, with five other dirty-flag tests. + +### every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint + +The guard that makes the two-fingerprint split safe rather than a trap. Splitting +`CellData` (wide) from `LayoutGeometry` (narrow) is only sound while every effect +wearing `LayoutGeometry` writes something `document_config` hashes; an effect it +cannot see would leave the document reading clean **forever**, which is the same +lost-edit failure the wide fingerprint exists to prevent, arriving by the other door. + +The thirteen are enumerated by brace-matching the `impl Effect` blocks, not by +grepping — the naive grep reports fourteen, picking up `HelpPageSet`, which is +`ViewOnly` and is simply the last `pub struct` before `#[cfg(test)]` (gotcha 38 names +this exact artefact). Each case asserts its effect really declares +`Model(LayoutGeometry)` before asserting anything else, so a reclassified effect fails +here rather than silently leaving the list unrepresentative. + +**Four** cases carry a `prepare` step run **directly against the workbook** and not +through an effect, because the interesting state is the precondition. What makes such +a case honest is the **no-op-fails** property: without its precondition the effect +writes nothing, an unwritten document leaves the fingerprint unmoved, and the case +therefore *fails* rather than passing vacuously. + +Measured, by deleting each `prepare` in turn, **two** of the four are load-bearing in +exactly that way and two are not: + +- `DeleteView` needs a second view; without it, `dirty` reads false and the case fails. +- `ShowItem` needs a hidden item; without it, `dirty` reads false and the case fails. +- `ToggleGroup` is **not** load-bearing here. `ToggleGroup::apply` calls + `toggle_group_collapse` unconditionally — its membership check gates only + `reset_cursor` — so `collapsed_groups` moves whether or not the group is real, and + the case passes with the `prepare` deleted. The step documents the realistic input; + it is the *cursor* arm, tested elsewhere, that needs a real group. +- `TransposeAxes` is **not** load-bearing either, and its step is literally a no-op: + `View::on_category_added` already assigns the first regular category `Row` and the + second `Column`, so the fixture arrives with `Type`/`Month` differing. The step + states the precondition rather than establishing it, and the case passes without it. + +`dirty` is cleared **after** `prepare` and before the effect. That clear is defensive +hygiene for a *future* `prepare` that goes through `apply_effects`, and it is not what +makes any case honest today: `dirty` is raised **only** inside `App::apply_effects`, +and no `prepare` in this test runs an effect, so no preparation can set it. Demonstrated +by removing the clear **and** blinding `document_config` to `0` together: all thirteen +cases still read `dirty == false`. An earlier version of this entry claimed the +opposite — that without the clear "all four cases pass against any fingerprint" — and +measurement contradicts it. + +Injected fault: `document_config` returns `0`. Fails, with five other dirty-flag tests +across three files. Injected fault, narrower and the one that proves the test is +pointed at the right thing: drop `view.number_format` from `document_config`. Fails +naming **`SetNumberFormat`**, with only one other test failing anywhere. + + --- ## `ui/app.rs` @@ -862,6 +989,119 @@ The counters are shared document state on `App`, never on the portable session slice (gotcha 30): a client inheriting another's idea of how far the document has moved is exactly the stale basis this machinery exists to catch. +### a_page_flip_under_prune_empty_invalidates_the_cursor + +improvise-7kvw, the case improvise-9mo9 decided the other nine and deliberately left +open. `SetPageSelection` changes which records are visible and not which categories +frame them, so a grid position normally keeps naming the same cell. Under +`View::prune_empty` that is false: which rows survive pruning is a function of the +page selection, so a page flip renumbers the grid while neither `reset_cursor` nor +`cursor_epoch` moved — gotcha 24's failure in both directions at once, the host's own +cursor stale and no client told. + +9mo9 did not patch it because `[`/`]` paging is constant navigation and sending the +cursor home on every flip is worse than the hole when `prune_empty` is off, which is +the default. So the reset is conditional on `prune_empty`, and on the selection +actually moving. + +The fixture has three regions and two quarters whose non-empty rows differ, so +position 0 names `North` on Q1 and `South` on Q2. The test asserts the row labels +really differ before asserting the epoch moved; the negative arm asserts they really +*do not* differ with pruning off, so neither case can pass vacuously. + +Injected fault: drop the reset. The prune arm fails. Injected fault: make the reset +unconditional. The no-prune arm fails. + +### a_page_flip_without_prune_empty_leaves_the_cursor_alone + +The negative arm of the above, and it is load-bearing for the same reason gotcha 24 +gives for `SetNumberFormat`: without it, "reset on every page flip" passes the +positive test and costs the user their position on every `[`/`]`. + +### selecting_the_page_already_selected_invalidates_no_cursor + +The third arm, and the one the user actually hits. `SetPageSelection` resets the +cursor only when the selection **moved**, and that guard shipped untested: deleting +`moved &&` left the whole workspace green at 1416, which review caught. + +It is not an edge case. `PageNext`/`PagePrev` are odometer-style and emit a +`SetPageSelection` for **every** page category on every keypress, so with more than +one page category the non-carrying categories are always no-ops, and a single +one-item category re-selects the same item every time. Unguarded, each such keystroke +bumps `cursor_epoch`, sends the host's cursor to the origin and broadcasts +`Command::ResetCursor` to every client on that view — improvise-75ld's "a no-op that +refused everybody else", one tier down and aimed at the cursor instead of the basis. + +The test asserts both preconditions **before** applying the effect — that the fixture +really is on `Q1`, and that the grid really does prune — and that the row labels did +not move before asserting the epoch stood still. So it cannot pass by re-selecting a +page that was never selected, or by testing a grid that does not prune. + +Both `before` placements are load-bearing, and both were wrong once. The page check +used to run **after** the effect, which sets the page to `Q1`, so it could not see a +fixture that started elsewhere: with the fixture moved to `Q2` the test did fail, but +at the *row-labels* assertion, under a message about renumbering. The sentence naming +the property was not the assertion guarding it. The `prune_empty` check did not exist +at all, and with pruning off the reset never fires whatever `moved` says — so +`paged_model(false)` plus the deleted guard left the test **green**, proving nothing, +with nothing saying so. + +Both preconditions read `App::view()`, the view the effect writes through +`app.view_mut()`, and not `Workbook::active_view()`, which is the document's +*default* and is deliberately allowed to differ from it (gotcha 29). They agree in +this single-client fixture, so the distinction is latent rather than a live bug — +but an assertion about what an effect did must read the view that effect wrote. + +Injected fault: delete `moved &&`. Fails by name, alone. +Injected fault: `paged_model(false)` **and** delete `moved &&`. Fails on the +`prune_empty` assertion; before that assertion existed, this combination passed. +Injected fault: `paged_model`'s fixture starts on `Q2`. Fails on the page-selection +assertion, which now names it; before the move it failed on the row labels instead. + +### a_foreign_layout_is_not_shown_the_hosts_staged_drill_text + +improvise-pubt, filed with an argument for unreachability that the test disproves. +`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`, +and since improvise-jytm that layout may be another client's. +`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the +**host's** drill layout (gotcha 1), and a row index in one layout names nothing in +another. + +The ticket read the arm as unreachable because "a foreign layout built by +`GridLayout::new` carries no frozen records, so it should not produce a synthetic +key". That is wrong: records mode **auto-detects** from the axes (gotcha 1), so an +ordinary saved view with `_Index` on Row and `_Dim` on Column gives `GridLayout::new` +a records layout whose `cell_key` returns exactly the synthetic `(_Index, _Dim)` key +the drill arm matches on. The test fails on the original code, naming the host's +staged text read out of a layout it was never staged against. + +The fix is `App::staged_drill_edit`, which consults `drill_state` only when +`std::ptr::eq(layout, &self.layout)`. A pointer comparison rather than a name +comparison because `grid_in` reuses `&app.layout` by reference where the view names +agree and derives a fresh one otherwise, so identity *is* the question being asked. +Whether the host's pending drill text should reach any other client at all is +improvise-tom1 and is untouched here. + +The positive arm — the host's own layout still shows `STAGED` — is in the same test, +or "never consult the drill" passes, which would blank the TUI's own drill editor. +The drill fixture needs a real `RecordRow`, not an empty `Vec`: the host's layout +iterates the frozen snapshot, so an empty one gives a zero-row grid and the positive +arm cannot be built. + +### a_default_that_names_nothing_says_which_invariant_broke + +improvise-h074. `App::resolve_view` falls back to `workbook.active_view` for a name +the document has not got and then indexes `workbook.views[default]`. Several of these +call sites previously went through `Workbook::active_view()`, which carries +`.expect("active_view always names an existing view")`; `IndexMap`'s `Index` impl +panics with no message at all, so a workbook whose default names nothing — reachable +only by constructing the struct directly, since `create_view`/`delete_view` maintain +it — failed with a bare index panic where it used to name the broken invariant. + +Injected fault: restore `&workbook.views[default]`. The test fails on the expected +substring. + + --- ## `ui/project.rs` @@ -2180,6 +2420,21 @@ An empty prompt must not reach `SaveAs`: `SaveAs(PathBuf::from(""))` asks the backend to write to `""`, and the user then reads whatever I/O error that produces instead of "you typed nothing". +### commit_export_bails_on_an_empty_path + +improvise-z05r's first arm. `CommitExport` did not bail on an empty path where +`CommitBuffer`'s three kinds all do (gotcha 20: buffer to trim to bail-on-empty to +dirty to status is the shared spine), so Enter on an empty export prompt asked the +backend to write to `""` and the user read whatever I/O error that produced. + +The same change reorders the batch: the mode change now comes **first**, because +`ExportCsv` aborts on a failed write as of z05r's other arm, and an abort at the tail +would strand the user in `ExportPrompt` — where `draw_bottom_bar` renders the +minibuffer *instead of* the status line, so the export error is never drawn at all +and the operation appears to do nothing (gotcha 21). That ordering is asserted in +`commit_export_produces_export_and_normal_mode`, by index and not by presence. + + --- ## `command/cmd/fit.rs` @@ -2362,6 +2617,52 @@ second entry under "view-back" and removes "view-forward" from the registry entirely; `parse` takes the first match, so every surviving entry still agrees with its own name. Uniqueness is what catches it. +### the_context_reports_each_panels_own_open_flag + +improvise-y4xg and improvise-qsmp, two shapes of one defect. `registry.rs` mapped +`Panel` to a `CmdContext` field in two places, and `App::cmd_context` copies the +three flags out of `ViewState` in a third. **Both were demonstrated unguarded**: +swapping Formula and Category at the two registry sites left the workspace at 1400 +passing, and hardcoding the `category_panel_open` propagator to `true` left it at +1394. + +Nothing exercised either, because `cmd/mod.rs::test_helpers` sets the panel flags +explicitly on the context it builds — the command tests construct the value they then +assert on, so no test ever drove the real propagator. + +The registry mapping is gone: both sites call `CmdContext::panel_open(panel)`, the +one mapping on this side of the wall. That leaves two permutable sites, and the test +is aimed at both from a path neither controls — it writes `view_state`'s three fields +**by name** and reads back through the enum accessor on a context built by the real +`App::cmd_context`. PR #70's review caught the weaker shape of this: a panel test that +zeroed and read through the same four accessors passed a consistent permutation. + +Injected fault: swap `Panel::Formula` and `Panel::Category` in `panel_open`. Fails. +Injected fault: hardcode the `category_panel_open` propagator to `true`. Fails. + +### toggle_panel_visibility_reads_the_panel_it_names + +The behavioural half, and the one that reaches the registry. Its interactive arm +passes `currently_open` straight through, so with exactly one panel open the command +built for each of the three names carries its own panel's flag. Both injections above +fail here too. + +### a_focused_open_panel_closes_and_a_closed_one_opens + +improvise-qsmp's own scenario: `toggle-panel-and-focus` decides close only when the +panel is *both* open and focused, so the case that separates a right propagator from +a stuck-true one needs the mode set to the panel's own. With the flag stuck true, +pressing a panel's key on a closed panel decides to *close* it and the panel becomes +unopenable by its own key — which is what this test's second arm names. + +### Helper — `open_only` + +Not a test. It spells `formula_panel_open`, `category_panel_open` and +`view_panel_open` as fields rather than looping over `Panel::ALL` through an +accessor, and that is the whole evidentiary value: a permutation in any mapping +between the `Panel` enum and those three booleans has to disagree with this helper. + + --- ## `command/cmd/text_buffer.rs` diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index d203bb31..8785a653 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -12,6 +12,20 @@ none. ## `model/types.rs` +### a_hidden_dimension_list_reduces_to_one_of_two_states + +improvise-5jm5. `evaluate_aggregated` took `none_cats: &[String]` and consulted +it once, for emptiness — so two lists with different contents and the same +emptiness gave the same answer, while the name promised a filter. Nothing +misbehaved; the signature described something the body never did. + +This test is the *smaller* half of the guard and says so. The real guard is the +signature: the defect the ticket describes is no longer constructible, exactly +as `Checked`'s constructor is the guard for the basis check rather than a test +that can fail. What this test does add is that `Aggregation::of` is the only +place the list is read, and that a list of two hidden dimensions is not somehow +a third state — the reduction is emptiness, and the enum is not a count. + ### apply_agg_over_nothing_is_absent_for_every_function An empty cell must read as *absent*, not as zero — otherwise every blank @@ -543,3 +557,57 @@ uses. Fault injected into that arm alone: it fails, `Number(0.0)` against The general shape: an injected fault proves coverage only of the code path it is injected into. Two arms of the same rule need two injections, and if one of them turns out to be unreachable, that is a fact about the design worth writing down. + + +### a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular + +improvise-bidq. `View::on_category_added`'s early return read +`cat_name.starts_with('_') || self.is_records_layout()` — the **same +expression** mixing two derivations one operator apart, the left a name prefix +and the right `CategoryKind`. Four lines up, `is_records_layout` asks the enum. + +`Model::add_category` does not validate names, so a user category literally +named `_Foo` was `CategoryKind::Regular` to `records_columns`, `cell_id` and +`is_empty_model`, and "virtual" to these two lines — seated on `Axis::None`, +so it never appeared in a pivot while every other consumer treated it as +ordinary. The second site (`holds_a_regular`) had the same split, which is why +the test asserts a *second* category's seat too: fixing only the first leaves +`_Foo` on Row while `Region` also takes Row. + +Decided explicitly, per the ticket: `add_category` is **not** made to refuse a +leading underscore. The three reserved names are the ones `CategoryKind::of_name` +knows, `_Foo` is a legal document, and a user-visible restriction is a larger +change than making one answer out of two. The fix is that both sites now ask +`CategoryKind::of_name(..).is_virtual()`. + +Fault injected by restoring either `starts_with('_')`: the test fails, `None` +against `Row` on the first site and `Row` against `Column` on the second. + +### an_ungrouped_item_after_a_grouped_run_belongs_to_no_group + +### a_grouped_run_does_not_leak_into_the_next_prefix + +improvise-gd83, the two arms of one defect. `expand_category` emitted a +`GroupHeader` on **entering** a group and no boundary on leaving one, so +`row_group_for`/`col_group_for`'s nearest-preceding walk answered with a group +that had already ended. + +The arms are separate because the two escapes are different and a fix can close +one and miss the other. The first is *within* one expansion — `Jan[Q1]`, +`Feb[Q1]`, `Sep` — closed by emitting `AxisEntry::GroupEnd` on a +`Some -> None` transition. The second is *across* expansions: `cross_product` +expands the inner category once per prefix, each call starting with +`last_group = None`, so `Food/Bob[Family]` is immediately followed by +`Clothing/Carol`, which is ungrouped and inherited Family with no transition +anywhere to hook onto. That one needs a `GroupEnd` at the **end** of an +expansion whose last item was grouped. + +`GroupEnd` is deliberately NOT emitted on `Some(A) -> Some(B)`: B's own header +already ends A's run, and emitting one there moved +`data_row_to_visual_skips_headers` and `data_col_to_visual_skips_headers` for +no gain. Those two are untouched, which is the evidence the boundary is as +narrow as it claims. + +Fault injected by deleting either emission: the corresponding test fails with +`Some(("Month", "Q1"))` / `Some(("Recipient", "Family"))` against `None`, and +`the_two_front_ends_draw_the_same_column_header` fails with it. diff --git a/context/test-evidence/formula-and-io.md b/context/test-evidence/formula-and-io.md index d16a5963..cff43e6a 100644 --- a/context/test-evidence/formula-and-io.md +++ b/context/test-evidence/formula-and-io.md @@ -203,6 +203,33 @@ backend from quietly normalising or re-encoding what it is handed. ## `improvise-io/src/persistence/parse.rs` +### two_sections_naming_one_category_round_trip_to_a_stable_group_order + +improvise-349m: `format_md(parse_md(text))` was not idempotent when a file +carried two `## Category:` sections with the same name — the two outputs +differed only in the order of the `>` group lines. It looked like a flake +because only `grammar_prop_tests::generated_file_roundtrips` saw it, and only +when the generator happened to emit a duplicate header; taking proptest's +minimal input and running it deterministically is what turned it into a bug. + +The fixture is the smallest shape that shows it: a first section defining a +group by a `>` line only, and a second whose *item* names a different group that +also gets a `>` line. Both groups must have parents, or `format_md` writes only +one `>` line and the differing internal order is invisible in the text — which +is the trap when shrinking this fixture further. Fault injected: reverting +`ParsedFile::push_category` to a plain `Vec::push` fails it, and the ticket's own +minimal input was checked against the fix as well. + +**Stability is only half of what the merge owes, and the first version of this +test could see only that half.** Adversarial review injected the other fault — +`Some(_existing) => {}`, which *drops* the duplicate section outright — and the +whole `improvise-io` suite stayed green at 2000 proptest cases while the +fixture's `- X[G2]` and `> G2[P2]` vanished from the document without a word. +Main's behaviour was unstable-but-lossless; a test that compares two printed +forms cannot tell that from stable-and-lossy, because both sides of the equality +lose the same thing. The three `contains` assertions run *before* the equality +for exactly that reason, and they fail under the drop. + ### parse_md_formula_category_suffix_without_space Bug improvise-zgb (b). The formulas walker located the `[Category]` suffix with @@ -444,9 +471,10 @@ can override it. The same argument as the ambiguity below. nothing in the data can tell them apart. The heuristic is first-match over `DATE_FORMATS`, so the American reading wins — a European CSV imports with its days and months swapped, silently, and the derived `_Month`/`_Quarter` -categories inherit the error. Filed as **improvise-l8rh**; pinned here so that -changing the answer is a decision somebody makes rather than a side effect of -reordering the table. +categories inherit the error. **improvise-l8rh ruled this a known limit** rather +than a defect, so this test is what makes changing the answer a decision +somebody makes rather than a side effect of reordering the table. The argument +is in `context/code/formula-and-io.md`. The loop at the end is the precondition that makes this a statement about *ambiguity* rather than about one format merely fitting: the other reading has @@ -646,7 +674,28 @@ reopens the hole; that substitution is what this test was finally proved against. The negative arm is paired with a positive one, or "reject everything" would pass. -`^"WHERE"` has the identical hole and was deliberately **not** fixed here: -pre-existing, far less exposed (it sits in one fixed position after an -expression, where `IS` follows any identifier in expression position), and a -grammar change with its own round-trip risk. Filed as improvise-r08u. +`^"WHERE"` had the identical hole. It is closed now — see below. + +### where_does_not_swallow_the_start_of_a_longer_word + +improvise-r08u, the `WHERE` half of the same defect. `where_clause` and +`inline_where` were ordinary rules, so pest auto-inserted `WHITESPACE` after +`^"WHERE"` and `SUM(Rev WHEREAS x = y)` parsed as a `WHERE` on a category `AS`: +a silently wrong filter where it should have been a syntax error. + +The test carries **two** negative shapes because the repair has two independent +halves, and this was found by injecting each separately. Making the rules +compound-atomic alone already refuses `WHEREAS x = y` — once implicit whitespace +is gone, `AS x` cannot be one identifier — so a test built only on the ticket's +reproducer passes over a rule still spelled `sp*`, and `WHEREAS=y` is what that +rule still reads as a filter. Faults injected: the original non-atomic rule +(fails on the first assertion) and `sp+` relaxed to `sp*` (fails on the second). +The positive arms are there so "refuse every WHERE" cannot pass. + +### where_requires_a_space_after_the_keyword + +The one spelling `sp+` costs, stated rather than discovered later: +`WHERE|Region Name|=|East Coast|` used to parse and now does not. `IS` has +required its space since improvise-4zl7, so this is the two keywords agreeing. +The paired positive arm — the same text with the space — is what stops it +degrading into "any WHERE with a quoted category fails". diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index dcc5a092..f4e8df67 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -414,3 +414,131 @@ a word pool instead of random letters, `pipe_inner` is never empty, `rest_of_line` always produces at least one character, and repetitions (`*`) produce 1–4 items rather than 0. Each invocation is seeded from the current time plus the PID. + + +### a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open + +### the_minibuffer_prompt_is_not_displaced_by_the_message + +### an_empty_message_never_pushes_a_row_that_fits_past_its_width + +improvise-mai9. `draw_bottom_bar` rendered the minibuffer **instead of** the +status line, so a message written while a text-entry mode was open was never +drawn — not truncated, not overwritten. A test asserting `app.status_msg` was +set passed in every one of those cases, which is how this survived +improvise-6t3's careful effect ordering. + +Of the ticket's two candidate fixes, the row is **shared** rather than the +callers being made structurally unable to report: giving the status a second +row changes frame geometry (improvise-q0vb's cost), and "always leave the mode +before reporting" is what `CommitSave` does by hand and would have to be +repeated at every text-entry commit — the thing the ticket calls a workaround. + +**The first version of these tests could not fail for the bug, and that is the +entry worth reading.** They called the composing helper `minibuffer_line` +directly. The defect was in the *wiring* — `draw_bottom_bar` building its own +`format!` and never consulting `status_msg` — so reverting that one line +restored the bug in full with all 1411 tests green. The helper did not even go +dead: the test module's `use` kept it alive while it asserted nothing about the +product. Found by review (PR #77), reproduced here before rewriting. + +So the guard is now `Buffer`-backed: a real `Terminal` renders +`draw_bottom_bar` and the assertion reads the row the terminal received. That +is the only assertion that can fail for mai9's actual defect. Fault injected by +restoring `format!("{}{}", mb.prompt, buf)` plus the cursor glyph — both wiring +tests fail, the first naming the missing message and printing the row it did +compose. + +The second test exists because a "fix" that dropped to `draw_status` would +satisfy the first on the message alone; it pins that the prompt still leads. + +**The third test is the negative arm, and its first version was unfalsifiable +for arithmetic reasons.** `fill_line` computes `pad = width - left.len() - +right.len()`, so with an empty status an unconditional `format!(" {msg} ")` +produces a byte-identical row at any width where the line fits: `left` plus +`width - left.len()` spaces, either way. The `match status_msg.is_empty()` is +observable **only** in the band where the prompt fits but `left.len() + 4` +does not — widths 16..=19 for this fixture, where the unguarded arm returns a +row wider than the terminal. + +That test then failed a second time in a more interesting way: it derived its +lower bound by *calling `minibuffer_line` itself*, so the injected fault moved +the bound with it and the loop skipped the failing band entirely. The bound is +now spelled locally. **A bound read off the function under test is not a +bound** — it is the fault grading its own exam. + +Known and not fixed here: `fill_line` pads on `left.len()`, which is bytes, so +the right-hand message sits two cells further right than intended whenever the +left contains the multi-byte cursor glyph. `draw_status` has always done this +with its own multi-byte content; it is pre-existing and out of scope. It is +also what makes the band above four columns wide rather than two. + +### a_group_header_first_on_its_axis_reaches_both_front_ends + +improvise-0o6x. `AxisFrame::body_from(idx)` started at the first **item**, so at +the top of the axis a leading `FrameEntry::Group` was skipped on the way to the +screen — in both front ends. A collapsed group first on its axis therefore had +its rows hidden and its header nowhere, so there was no affordance to get them +back. The frame carried the header correctly; `body_from` dropped it. + +It is a boundary condition, as the ticket suspected, but the boundary is not +`idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled +window for `group_of` and `body_from` discards it — correctly. At the top of +the axis there is no "above", and a frame cannot tell the two apart from `idx` +alone, because the browser's frame is windowed and always passes `0`. So +`AxisFrame` gained `first`, the absolute data index of its first item, and the +rule is `first + idx == 0`. Without it the browser would have drawn an extra +header row at every scrolled position that the TUI does not. + +This test is separate from `the_two_front_ends_draw_the_same_row_header` +because that fixture orders `Carol` before `Family` to dodge this case **on +purpose**, and says so. Both collapse states are driven: the uncollapsed arm +alone passes against a frame that leads with a header only when it has items +under it, which is the shape the windowing bug below produced. + +The windowing half was found by this test and not predicted by the ticket. A +collapsed leading group has NO item to carry its header into the window, so it +reaches the frame only through `axis_frame`'s `pending` queue — and a first +version of the fix had `GroupEnd` **clear** `pending` above the window, which +deleted exactly that header. `GroupEnd` is now queued like a header and clears +nothing. + +Fault injected by forcing `body_from`'s match to `false`: this test fails and +`the_whole_frame_is_unchanged` fails with it. + +### the_whole_frame_is_unchanged + +Re-pinned for improvise-0o6x. `GROUPED_FRAME` moved by exactly one line: the +leading `▼ Co…` header now draws above `East`, and everything below shifts +down one. Nothing else in the buffer changed. That the group line, the column +headers, the separator, the data rows and the totals strip are byte-identical +is the evidence the change is the boundary condition it claims to be and not a +reshuffle of the draw loop. + +### the_two_front_ends_draw_the_same_column_header + +Two changes, from two tickets. + +improvise-gd83: `THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are +gone, and the value they predicted — `[(Q1,2), (None,1), (Q1,2), (None,1)]` — +is now what the test asserts, element for element. `Sep` is ungrouped, so the +run ends before it and resumes under the second measure's own `Q1` header. A +precondition asserts the fixture still contains an ungrouped column, because +without one the test cannot see a leaked group at all. + +improvise-8qlr: the TUI arm was `group_line.contains("▼ Q1")`, which is +one-directional and passes while the TUI labels **every** column. The run rule +now lives once, as `AxisFrame::group_runs`/`group_run_heads`, and both ends +call it; the TUI's group line is compared cell for cell against spans derived +from the browser's own runs. + +Fault injected exactly as the ticket specifies — forcing the TUI to emit the +group label on every column (`head = col_groups.get(ci)`, dropping the run-head +lookup). Before: `cargo test -p improvise` stayed green. After: this test fails +with `["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""]` against +`["▼ Q1","","","▼ Q1","",""]`. + +One thing the shared rule does **not** flatten: the TUI labels its first +*visible* column even mid-run, because a scrolled grid would otherwise draw an +unlabelled group line. That is a scrolling concession, kept explicitly, and it +is why the comparison is run at `col_offset == 0`. diff --git a/crates/improvise-command/src/command/cmd/cell.rs b/crates/improvise-command/src/command/cmd/cell.rs index e70af751..3f50d8c8 100644 --- a/crates/improvise-command/src/command/cmd/cell.rs +++ b/crates/improvise-command/src/command/cmd/cell.rs @@ -1,3 +1,4 @@ +use crate::model::Aggregation; use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; @@ -140,7 +141,9 @@ impl Cmd for YankCell { "yank" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats()); + let value = ctx + .model + .evaluate_aggregated(&self.key, Aggregation::of(ctx.none_cats())); vec![ Box::new(effect::SetYanked(value)), effect::set_status("Yanked"), diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index c3be37c7..803d3f13 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -195,6 +195,32 @@ mod tests { let dbg = effects_debug(&effects); assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + assert!( + dbg.find("Normal").unwrap() < dbg.find("ExportCsv").unwrap(), + "the mode change must precede the write an abort can reach: {dbg}" + ); + } + + /// BUG (improvise-z05r): the fourth commit kind did not bail on an empty + /// path, so Enter on an empty prompt exported to "". + #[test] + fn commit_export_bails_on_an_empty_path() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + for buffered in ["", " "] { + let mut bufs = HashMap::new(); + bufs.insert("export".to_string(), buffered.to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitExport.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + !dbg.contains("ExportCsv"), + "an empty export path must write nothing, got: {dbg}" + ); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + } } /// Naming a new measure in the trailing `+` column at the bottom-right sent the @@ -696,10 +722,18 @@ impl Cmd for CommitExport { "commit-export" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let buf = ctx.buffers.get("export").cloned().unwrap_or_default(); + let path = read_buffer(ctx, "export").trim().to_string(); + if path.is_empty() { + return vec![ + effect::set_status("No path given — not exported"), + effect::change_mode(AppMode::Normal), + ]; + } + // The mode change comes **first**, or a failed export aborts with the + // minibuffer still drawn over its error (gotcha 21). vec![ - Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))), effect::change_mode(AppMode::Normal), + Box::new(effect::ExportCsv(std::path::PathBuf::from(path))), ] } } diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index 56a5d897..04024746 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -81,6 +81,16 @@ impl<'a> CmdContext<'a> { .and_then(crate::view::synthetic_record_info) } + /// Whether `panel` is open. The one `Panel` → flag mapping on this side of + /// the wall, mirroring `ViewState::panel_open` (improvise-y4xg). + pub fn panel_open(&self, panel: Panel) -> bool { + match panel { + Panel::Formula => self.formula_panel_open, + Panel::Category => self.category_panel_open, + Panel::View => self.view_panel_open, + } + } + pub fn row_count(&self) -> usize { self.layout.row_count() } diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index f47b7d35..d288c0bc 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -355,11 +355,7 @@ pub fn default_registry() -> CmdRegistry { require_args("toggle-panel-and-focus", args, 1)?; let panel = parse_panel(&args[0])?; // Default interactive: if already open+focused -> close, else open+focus - let currently_open = match panel { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; + let currently_open = ctx.panel_open(panel); let currently_focused = ctx.mode.panel() == Some(panel); let (open, focused) = if currently_open && currently_focused { (false, false) // close @@ -389,11 +385,7 @@ pub fn default_registry() -> CmdRegistry { |args, ctx| { require_args("toggle-panel-visibility", args, 1)?; let panel = parse_panel(&args[0])?; - let currently_open = match panel { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; + let currently_open = ctx.panel_open(panel); Ok(Box::new(TogglePanelVisibility { panel, currently_open, @@ -725,3 +717,106 @@ mod tests { } } } + +#[cfg(test)] +mod panel_open_tests { + use super::*; + use crate::command::cmd::test_helpers::two_cat_model; + use crate::command::key::{KeyCode, KeyModifiers}; + use crate::ui::app::{App, AppMode}; + + /// Write the raw field each `Panel` names. Spelling the fields here is what + /// gives the read side a second path — context/test-evidence/command.md. + fn open_only(app: &mut App, panel: Panel) { + app.view_state.formula_panel_open = panel == Panel::Formula; + app.view_state.category_panel_open = panel == Panel::Category; + app.view_state.view_panel_open = panel == Panel::View; + } + + fn panel_mode(panel: Panel) -> AppMode { + match panel { + Panel::Formula => AppMode::FormulaPanel, + Panel::Category => AppMode::CategoryPanel, + Panel::View => AppMode::ViewPanel, + } + } + + #[test] + fn the_context_reports_each_panels_own_open_flag() { + for opened in Panel::ALL { + let mut app = App::new(two_cat_model(), None); + open_only(&mut app, opened); + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + for asked in Panel::ALL { + assert_eq!( + ctx.panel_open(asked), + asked == opened, + "with only {opened:?} open, the context answers wrongly for {asked:?}" + ); + } + } + } + + #[test] + fn toggle_panel_visibility_reads_the_panel_it_names() { + for opened in Panel::ALL { + let mut app = App::new(two_cat_model(), None); + open_only(&mut app, opened); + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + for asked in Panel::ALL { + let args = [asked.name().to_string()]; + let cmd = ctx + .registry + .interactive("toggle-panel-visibility", &args, &ctx) + .expect("toggle-panel-visibility resolves"); + let dbg = format!("{cmd:?}"); + let expected = format!("currently_open: {}", asked == opened); + assert!( + dbg.contains(&expected), + "with only {opened:?} open, toggling {asked:?} read another \ + panel's flag: {dbg}" + ); + } + } + } + + #[test] + fn a_focused_open_panel_closes_and_a_closed_one_opens() { + for panel in Panel::ALL { + let mut app = App::new(two_cat_model(), None); + open_only(&mut app, panel); + app.view_state.mode = panel_mode(panel); + let args = [panel.name().to_string()]; + + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + let dbg = format!( + "{:?}", + ctx.registry + .interactive("toggle-panel-and-focus", &args, &ctx) + .expect("toggle-panel-and-focus resolves") + ); + assert!( + dbg.contains("open: false"), + "{panel:?} is open and focused, so its key must close it: {dbg}" + ); + + let mut app = App::new(two_cat_model(), None); + app.view_state.formula_panel_open = false; + app.view_state.category_panel_open = false; + app.view_state.view_panel_open = false; + app.view_state.mode = panel_mode(panel); + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + let dbg = format!( + "{:?}", + ctx.registry + .interactive("toggle-panel-and-focus", &args, &ctx) + .expect("toggle-panel-and-focus resolves") + ); + assert!( + dbg.contains("open: true"), + "{panel:?} is closed, so its own key must open it rather than \ + leave it unopenable: {dbg}" + ); + } + } +} diff --git a/crates/improvise-command/src/command/cmd/search.rs b/crates/improvise-command/src/command/cmd/search.rs index 278a6448..d2fb9371 100644 --- a/crates/improvise-command/src/command/cmd/search.rs +++ b/crates/improvise-command/src/command/cmd/search.rs @@ -1,3 +1,4 @@ +use crate::model::Aggregation; use crate::model::cell::CellValue; use crate::ui::app::AppMode; use crate::ui::effect::{self, Effect, Panel}; @@ -112,7 +113,10 @@ impl Cmd for SearchNavigate { Some(k) => k, None => return false, }; - let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { + let s = match ctx + .model + .evaluate_aggregated(&key, Aggregation::of(ctx.none_cats())) + { Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Text(t)) => t, Some(CellValue::Error(e)) => format!("ERR:{e}"), diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index a7e16c2d..2305f92b 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -80,8 +80,26 @@ fn view_shapes(workbook: &Workbook) -> std::collections::BTreeMap { .collect() } -/// Everything a `LayoutGeometry` effect can write that the file would carry. Not the -/// cell data — `record_fingerprints` answers for that — so it is cheap per batch. +/// `document_config` plus the cells and the formulas. `CellData` is wider than its +/// name (improvise-748f), and only that arm pays the walk — context/code/command.md. +fn document_content(workbook: &Workbook) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (key, value) in workbook.model.data.iter_cells() { + key.hash(&mut h); + hash_value(value, &mut h); + } + for formula in workbook.model.formulas() { + formula.raw.hash(&mut h); + formula.target.hash(&mut h); + formula.target_category.hash(&mut h); + } + document_config(workbook).hash(&mut h); + h.finish() +} + +/// Everything a `LayoutGeometry` effect can write that the file would carry. Not +/// the cells, so it stays O(config) on the held-key path (improvise-748f). fn document_config(workbook: &Workbook) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -131,6 +149,26 @@ fn sorted_pairs( out } +/// Exhaustive, and by bits rather than by `Debug`: two values that print alike +/// must not fingerprint alike. +fn hash_value(value: &CellValue, hasher: &mut impl std::hash::Hasher) { + use std::hash::Hash; + match value { + CellValue::Number(n) => { + 0u8.hash(hasher); + n.to_bits().hash(hasher); + } + CellValue::Text(t) => { + 1u8.hash(hasher); + t.hash(hasher); + } + CellValue::Error(e) => { + 2u8.hash(hasher); + e.hash(hasher); + } + } +} + /// A per-record XOR fingerprint of the **raw** cells it holds. Process-local and /// never on a wire, unlike [`RecordHash`] — see `context/code/command.md`. fn record_fingerprints(data: &DataStore) -> std::collections::HashMap { @@ -139,22 +177,7 @@ fn record_fingerprints(data: &DataStore) -> std::collections::HashMap { - 0u8.hash(&mut hasher); - n.to_bits().hash(&mut hasher); - } - CellValue::Text(t) => { - 1u8.hash(&mut hasher); - t.hash(&mut hasher); - } - CellValue::Error(e) => { - 2u8.hash(&mut hasher); - e.hash(&mut hasher); - } - } + hash_value(value, &mut hasher); *out.entry(RecordHash::of(&key)).or_insert(0) ^= hasher.finish(); } out @@ -413,7 +436,13 @@ impl App { Some((key, view)) => (key.as_str(), view), None => { let default = workbook.active_view.as_str(); - (default, &workbook.views[default]) + // The message `Workbook::active_view()` carried: `IndexMap`'s + // `Index` panics bare, naming no invariant (improvise-h074). + let view = workbook + .views + .get(default) + .expect("active_view always names an existing view"); + (default, view) } } } @@ -488,13 +517,8 @@ impl App { /// The same text against a layout that may be another client's (improvise-jytm). pub(crate) fn display_value_in(&self, layout: &GridLayout, key: &CellKey) -> String { match crate::view::synthetic_record_info(key) { - Some((idx, col)) => layout - .record_col(col) - .and_then(|rc| { - self.drill_state - .as_ref() - .and_then(|s| s.pending_edits.get(&(idx, rc.clone())).cloned()) - }) + Some((idx, col)) => self + .staged_drill_edit(layout, idx, col) .or_else(|| layout.resolve_display(key)) .unwrap_or_default(), None => self @@ -507,6 +531,18 @@ impl App { } } + /// The host's own staged drill text at a records position, and only for the + /// host's own layout — a row index names nothing in another (improvise-pubt). + fn staged_drill_edit(&self, layout: &GridLayout, idx: usize, col: usize) -> Option { + let own = std::ptr::eq(layout, &self.layout); + let col = layout.record_col(col).filter(|_| own)?; + self.drill_state + .as_ref()? + .pending_edits + .get(&(idx, col.clone())) + .cloned() + } + pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.view(); let layout = &self.layout; @@ -570,12 +606,19 @@ impl App { let touches_model = effects .iter() .any(|e| matches!(e.kind(), EffectKind::Model(_))); - let records_before = effects + let writes_cells = effects .iter() - .any(|e| matches!(e.kind(), EffectKind::Model(ModelChange::CellData))) - .then(|| record_fingerprints(&self.model_state.workbook.model.data)); + .any(|e| matches!(e.kind(), EffectKind::Model(ModelChange::CellData))); + let moves_geometry = effects + .iter() + .any(|e| matches!(e.kind(), EffectKind::Model(ModelChange::LayoutGeometry))); + let records_before = + writes_cells.then(|| record_fingerprints(&self.model_state.workbook.model.data)); let shapes_before = touches_model.then(|| view_shapes(&self.model_state.workbook)); - let mut config_basis = touches_model.then(|| document_config(&self.model_state.workbook)); + // Two bases, because the cheap one is the held-key path: `[`/`]`, axis + // cycle and hide/show must not walk 18,000 cells (improvise-748f). + let mut content_basis = writes_cells.then(|| document_content(&self.model_state.workbook)); + let mut config_basis = moves_geometry.then(|| document_config(&self.model_state.workbook)); let mut dirty = Dirty::NOTHING; for effect in effects { let kind = effect.kind(); @@ -585,11 +628,11 @@ impl App { // rather than defaulting into the observed half by omission. let declared_dirty = match kind { EffectKind::ViewOnly => false, - // Things no structural fingerprint sees: a formula, a sort order, - // a replaced document. - EffectKind::Model(ModelChange::CellData) => true, + // A replaced document differs from the file its path names even + // where the content matches, so it is declared (improvise-748f). EffectKind::Model(ModelChange::WholeWorkbook) => true, - // Observed instead, in `observe_reshape` (improvise-75ld). + // Observed below, each against the fingerprint its tier needs. + EffectKind::Model(ModelChange::CellData) => false, EffectKind::Model(ModelChange::LayoutGeometry) => false, }; if declared_dirty { @@ -597,6 +640,13 @@ impl App { } // The observed half, read **inside the loop** so `Save` still clears it // in loop order (gotcha 45) — the position is the property. + if matches!(kind, EffectKind::Model(ModelChange::CellData)) + && let Some(basis) = content_basis + { + let now = document_content(&self.model_state.workbook); + self.model_state.dirty |= now != basis; + content_basis = Some(now); + } if matches!(kind, EffectKind::Model(ModelChange::LayoutGeometry)) && let Some(basis) = config_basis { @@ -939,6 +989,252 @@ mod tests { ); } + /// BUG (improvise-pubt): `pending_edits` are keyed by a row index into the + /// host's drill, and a position in one layout names nothing in another. + #[test] + fn a_foreign_layout_is_not_shown_the_hosts_staged_drill_text() { + use crate::model::cell::{CellKey, CellValue}; + use crate::view::{Axis, GridLayout, RecordCol}; + use std::rc::Rc; + + let mut wb = Workbook::new("Drill"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "East").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.model.set_cell( + CellKey::new(vec![ + ("Region".into(), "East".into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(7.0), + ); + wb.create_view("Records"); + { + let v = &mut wb.views["Records"]; + v.set_axis("_Index", Axis::Row); + v.set_axis("_Dim", Axis::Column); + v.set_axis("Region", Axis::None); + v.set_axis("_Measure", Axis::None); + } + let mut app = App::new(wb, None); + + let foreign = GridLayout::new( + &app.model_state.workbook.model, + &app.model_state.workbook.views["Records"], + ); + assert!( + foreign.is_records_mode(), + "the other client's view is not records mode, so no synthetic key exists" + ); + let col = (0..foreign.col_count()) + .find( + |c| matches!(foreign.record_col(*c), Some(RecordCol::Measure(m)) if m == "Revenue"), + ) + .expect("the records grid has a Revenue column"); + let key = foreign + .cell_key(0, col) + .expect("the records grid names a cell at (0, Revenue)"); + assert!( + crate::view::synthetic_record_info(&key).is_some(), + "the foreign layout produced no synthetic key, so the drill arm is unreachable" + ); + + let mut pending = std::collections::HashMap::new(); + pending.insert( + (0, RecordCol::Measure("Revenue".into())), + "STAGED".to_string(), + ); + let record = crate::view::RecordRow { + coords: CellKey::new(vec![("Region".into(), "East".into())]), + values: vec![("Revenue".to_string(), CellValue::Number(7.0))], + }; + app.drill_state = Some(DrillState { + records: Rc::new(vec![record]), + pending_edits: pending, + }); + + assert_ne!( + app.display_value_in(&foreign, &key), + "STAGED", + "another client was shown the host's uncommitted drill text against \ + coordinates it was never staged for" + ); + + // The positive arm: on the host's own layout the staged text is exactly + // what the editor must open with, or "never consult the drill" passes. + app.switch_to_view("Records"); + app.rebuild_layout(); + let own_key = app + .layout + .cell_key(0, col) + .expect("the host's own records grid names a cell at (0, Revenue)"); + assert_eq!( + app.display_value_of(&own_key), + "STAGED", + "the host's own staged drill text was dropped" + ); + } + + /// Two pages whose non-empty rows differ, so pruning renumbers the grid when + /// the page flips — improvise-7kvw. `prune_empty` is the caller's choice. + fn paged_model(prune: bool) -> App { + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + let mut wb = Workbook::new("Paged"); + wb.add_category("Region").unwrap(); + wb.add_category("Quarter").unwrap(); + for item in ["North", "South", "East"] { + wb.add_item("Region", item).unwrap(); + } + for item in ["Q1", "Q2"] { + wb.add_item("Quarter", item).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + for (quarter, region) in [ + ("Q1", "North"), + ("Q1", "South"), + ("Q2", "South"), + ("Q2", "East"), + ] { + wb.model.set_cell( + CellKey::new(vec![ + ("Quarter".into(), quarter.into()), + ("Region".into(), region.into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(1.0), + ); + } + { + let v = wb.active_view_mut(); + v.set_axis("Region", Axis::Row); + v.set_axis("_Measure", Axis::Column); + v.set_axis("Quarter", Axis::Page); + v.set_page_selection("Quarter", "Q1"); + v.prune_empty = prune; + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app + } + + fn row_labels(app: &App) -> Vec { + (0..app.layout.row_count()) + .map(|r| app.layout.row_label(r)) + .collect() + } + + fn flip_to_q2(app: &mut App) { + app.apply_effects(vec![Box::new(super::super::effect::SetPageSelection { + category: "Quarter".to_string(), + item: "Q2".to_string(), + })]); + } + + /// BUG (improvise-7kvw): pruning depends on the page, so a page flip renumbers + /// the grid and left both the host's cursor and every client's stale. + #[test] + fn a_page_flip_under_prune_empty_invalidates_the_cursor() { + let mut app = paged_model(true); + let before_labels = row_labels(&app); + let epoch = app.cursor_epoch(); + app.cursor_mut().selected = (1, 0); + + flip_to_q2(&mut app); + + assert_ne!( + before_labels, + row_labels(&app), + "the two pages prune the same rows, so this case proves nothing" + ); + assert_ne!( + app.cursor_epoch(), + epoch, + "the grid renumbered and no cursor was invalidated" + ); + assert_eq!( + app.cursor().selected, + (0, 0), + "the host's own cursor is stale" + ); + } + + /// The third arm, and the one `[`/`]` hits constantly: odometer paging emits a + /// `SetPageSelection` per page category per keypress, most of them no-ops. + #[test] + fn selecting_the_page_already_selected_invalidates_no_cursor() { + let mut app = paged_model(true); + // Both preconditions, asserted BEFORE the effect: the effect itself sets + // the page to Q1, so a check after it cannot see a fixture elsewhere. + assert!( + app.view().prune_empty, + "with pruning off the reset never fires whatever `moved` says, so \ + this case would pass against the guard it exists to hold" + ); + assert_eq!( + app.view().page_selection("Quarter"), + Some("Q1"), + "the fixture was not on Q1, so re-selecting it is not the no-op case" + ); + let before_labels = row_labels(&app); + app.cursor_mut().selected = (1, 0); + let epoch = app.cursor_epoch(); + + app.apply_effects(vec![Box::new(super::super::effect::SetPageSelection { + category: "Quarter".to_string(), + item: "Q1".to_string(), + })]); + + assert_eq!( + before_labels, + row_labels(&app), + "re-selecting the same page renumbered the grid, so this proves nothing" + ); + assert_eq!( + app.cursor_epoch(), + epoch, + "a page that did not move threw every client's cursor away, and \ + odometer paging emits one of these per page category per keypress" + ); + assert_eq!(app.cursor().selected, (1, 0)); + } + + /// The other arm: with pruning off the page names the same rows, so paging + /// must not throw the user's position away on an ordinary `[`/`]`. + #[test] + fn a_page_flip_without_prune_empty_leaves_the_cursor_alone() { + let mut app = paged_model(false); + let before_labels = row_labels(&app); + let epoch = app.cursor_epoch(); + app.cursor_mut().selected = (1, 0); + + flip_to_q2(&mut app); + + assert_eq!( + before_labels, + row_labels(&app), + "the page flip renumbered the grid, so this is not the no-reshape arm" + ); + assert_eq!( + app.cursor_epoch(), + epoch, + "paging with nothing pruned threw a cursor away" + ); + assert_eq!(app.cursor().selected, (1, 0)); + } + + /// improvise-h074: the fallback indexes `views` by the document's default, and + /// a bare `IndexMap` panic names no invariant where `active_view()` did. + #[test] + #[should_panic(expected = "active_view always names an existing view")] + fn a_default_that_names_nothing_says_which_invariant_broke() { + let mut app = two_col_model(); + // Only a hand-built or foreign-deserialized workbook can be here: + // `create_view`/`delete_view` both maintain the invariant. + app.model_state.workbook.active_view = "NoSuchView".to_string(); + let _ = app.resolve_view("AlsoNoSuchView"); + } + /// improvise-8uz: moving the cursor must leave the shared document untouched, /// so one client scrolling cannot move another's viewport. #[test] diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 3ba27903..fdc1d804 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -440,8 +440,14 @@ pub struct SetPageSelection { } impl Effect for SetPageSelection { fn apply(&self, app: &mut App) { + let moved = app.view().page_selection(&self.category) != Some(self.item.as_str()); app.view_mut() .set_page_selection(&self.category, &self.item); + // Which rows survive pruning is a function of the page, so under + // `prune_empty` a page flip renumbers the grid (improvise-7kvw). + if moved && app.view().prune_empty { + app.reset_cursor(); + } } fn kind(&self) -> EffectKind { EffectKind::Model(ModelChange::LayoutGeometry) @@ -744,24 +750,29 @@ impl Effect for SetDrillPendingEdit { pub struct Save; impl Effect for Save { fn apply(&self, app: &mut App) { - match save_to_current_path(&*app.storage, &app.model_state) { - Ok(msg) => { - app.model_state.dirty = false; - app.view_state.status_msg = msg; - } - // A failed save aborts the rest of the batch, so the `Quit` that - // `wq`/`ZZ` chains never lands (improvise-8tk, gotcha 21). - Err(msg) => { - app.view_state.status_msg = msg; - app.abort_effects = true; - } + let outcome = save_to_current_path(&*app.storage, &app.model_state); + if outcome.is_ok() { + app.model_state.dirty = false; } + report_file_write(app, outcome); } fn kind(&self) -> EffectKind { EffectKind::ViewOnly } } +/// Every effect that writes a file reports through here: the status message either +/// way, and a failure aborts the rest of the batch (improvise-z05r, gotcha 21). +fn report_file_write(app: &mut App, outcome: Result) { + match outcome { + Ok(msg) => app.view_state.status_msg = msg, + Err(msg) => { + app.view_state.status_msg = msg; + app.abort_effects = true; + } + } +} + /// Write the workbook to its current file path, returning the status /// message either way. Missing path and I/O failure are both errors. fn save_to_current_path( @@ -782,19 +793,15 @@ fn save_to_current_path( pub struct SaveAs(pub PathBuf); impl Effect for SaveAs { fn apply(&self, app: &mut App) { - match crate::persistence::save_to(&*app.storage, &app.model_state.workbook, &self.0) { - Ok(()) => { - app.model_state.file_path = Some(self.0.clone()); - app.model_state.dirty = false; - app.view_state.status_msg = format!("Saved to {}", self.0.display()); - } - // The same abort `Save` owes: `SaveAs` is the arm improvise-8tk - // left out, so `:w /bad/path . q` quit on a failed write. - Err(e) => { - app.view_state.status_msg = format!("Save error: {e}"); - app.abort_effects = true; - } + let outcome = + crate::persistence::save_to(&*app.storage, &app.model_state.workbook, &self.0) + .map(|()| format!("Saved to {}", self.0.display())) + .map_err(|e| format!("Save error: {e}")); + if outcome.is_ok() { + app.model_state.file_path = Some(self.0.clone()); + app.model_state.dirty = false; } + report_file_write(app, outcome); } fn kind(&self) -> EffectKind { EffectKind::ViewOnly @@ -930,19 +937,15 @@ pub struct ExportCsv(pub PathBuf); impl Effect for ExportCsv { fn apply(&self, app: &mut App) { let view_name = app.view_name().to_string(); - match crate::persistence::export_csv_to( + let outcome = crate::persistence::export_csv_to( &*app.storage, &app.model_state.workbook, &view_name, &self.0, - ) { - Ok(()) => { - app.view_state.status_msg = format!("Exported to {}", self.0.display()); - } - Err(e) => { - app.view_state.status_msg = format!("Export error: {e}"); - } - } + ) + .map(|()| format!("Exported to {}", self.0.display())) + .map_err(|e| format!("Export error: {e}")); + report_file_write(app, outcome); } fn kind(&self) -> EffectKind { EffectKind::ViewOnly @@ -2004,6 +2007,33 @@ mod tests { ); } + /// BUG (improvise-z05r): the third arm of a rule with three. Named, so a + /// fourth file-writing effect has to come here — see the evidence. + #[test] + fn every_file_writing_effect_aborts_the_batch_on_failure() { + let bad = PathBuf::from("/nonexistent-dir-improvise-z05r/out"); + type Writer = fn(&PathBuf) -> Box; + let writers: [(&str, Writer); 3] = [ + ("Save", |_| Box::new(Save)), + ("SaveAs", |p| Box::new(SaveAs(p.clone()))), + ("ExportCsv", |p| Box::new(ExportCsv(p.clone()))), + ]; + for (label, make) in writers { + let mut app = test_app(); + app.model_state.file_path = Some(bad.clone()); + app.apply_effects(vec![make(&bad), Box::new(ChangeMode(AppMode::Quit))]); + assert!( + app.last_batch_aborted(), + "{label} failed to write and did not stop the batch" + ); + assert_ne!( + app.view_state.mode, + AppMode::Quit, + "{label} failed to write and the trailing effect applied anyway" + ); + } + } + /// The positive arm: a `SaveAs` that succeeds must not abort. Without it, /// "always abort" passes the test above. #[test] @@ -2327,19 +2357,198 @@ mod tests { app.apply_effects(vec![Box::new(ApplyAndClearDrill)]); assert!(app.drill_state.is_none()); assert!( - app.model_state.dirty, - "a drill exited with no edits still marks the document dirty, \ - because `apply_effects` raises the flag off the declared \ - `EffectKind` and this effect declares `Model(CellData)` whether \ - or not it wrote anything (improvise-gv9r). That is the same \ - over-approximation a failed `LoadModel` gets, in the same \ - direction: one redundant write rather than a lost edit. It has \ - been true since the flag moved; this test could not see it \ - because it drove `apply` directly and so measured the effect in \ - isolation rather than the path production takes." + !app.model_state.dirty, + "a drill exited with no edits wrote nothing, so the document is \ + unchanged and `:q` must not be refused (improvise-748f)" ); } + /// improvise-748f: `ApplyAndClearDrill` on a document with no drill is what + /// view-back emits unconditionally, and it writes nothing at all. + #[test] + fn a_batch_that_writes_nothing_leaves_the_document_clean() { + let mut app = test_app(); + assert!(app.drill_state.is_none()); + app.apply_effects(vec![Box::new(ApplyAndClearDrill)]); + assert!( + !app.model_state.dirty, + "view-back on a document with no drill open refused the next `:q`" + ); + } + + /// `CellData` is wider than its name, and a raw-cell fingerprint sees none of + /// these — the cheap cure improvise-748f proposed would have lost them. + #[test] + fn a_cell_data_effect_no_raw_cell_records_still_dirties_the_document() { + type Writer = fn() -> Box; + let writers: [(&str, Writer); 5] = [ + ("AddItem", || { + Box::new(AddItem { + category: "Type".into(), + item: "Toys".into(), + }) + }), + ("AddItemInGroup", || { + Box::new(AddItemInGroup { + category: "Type".into(), + item: "Toys".into(), + group: "Leisure".into(), + }) + }), + ("SortData", || Box::new(SortData)), + ("AddFormula", || { + Box::new(AddFormula { + raw: "Profit = Revenue - Cost".into(), + target_category: "_Measure".into(), + }) + }), + ("RemoveFormula", || { + Box::new(RemoveFormula { + target: "Profit".into(), + target_category: "_Measure".into(), + }) + }), + ]; + for (label, make) in writers { + let mut app = test_app(); + app.apply_effects(vec![Box::new(AddFormula { + raw: "Profit = Revenue - Cost".into(), + target_category: "_Measure".into(), + })]); + // Inserted out of sorted order, or `SortData` reorders nothing and + // its case proves the opposite of what it names. + for (n, (ty, month)) in [("Food", "Jan"), ("Clothing", "Feb")].iter().enumerate() { + app.model_state.workbook.model.set_cell( + CellKey::new(vec![ + ("Type".into(), (*ty).into()), + ("Month".into(), (*month).into()), + ]), + CellValue::Number(n as f64), + ); + } + if label == "AddFormula" { + app.apply_effects(vec![Box::new(RemoveFormula { + target: "Profit".into(), + target_category: "_Measure".into(), + })]); + } + app.model_state.dirty = false; + + app.apply_effects(vec![make()]); + assert!( + app.model_state.dirty, + "{label} changed the document and left it reading clean, so the \ + edit is lost on quit and the worker never writes it back" + ); + } + } + + /// improvise-748f: the `LayoutGeometry` arm is observed against the **narrow** + /// `document_config`, so an effect it cannot see would read clean forever. + #[test] + fn every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint() { + type Prepare = fn(&mut App); + type Make = fn() -> Box; + let nothing: Prepare = |_| {}; + let cases: [(&str, Prepare, Make); 13] = [ + ("AddCategory", nothing, || { + Box::new(AddCategory("Region".into())) + }), + ("RemoveCategory", nothing, || { + Box::new(RemoveCategory("Month".into())) + }), + ("CreateView", nothing, || { + Box::new(CreateView("Second".into())) + }), + ( + "DeleteView", + |app| { + app.model_state.workbook.create_view("Second"); + }, + || Box::new(DeleteView("Second".into())), + ), + ("SetAxis", nothing, || { + Box::new(SetAxis { + category: "Type".into(), + axis: Axis::Column, + }) + }), + ("SetPageSelection", nothing, || { + Box::new(SetPageSelection { + category: "Type".into(), + item: "Food".into(), + }) + }), + ( + "ToggleGroup", + |app| { + app.model_state + .workbook + .add_item_in_group("Type", "Cake", "Baked") + .unwrap(); + }, + || { + Box::new(ToggleGroup { + category: "Type".into(), + group: "Baked".into(), + }) + }, + ), + ("HideItem", nothing, || { + Box::new(HideItem { + category: "Type".into(), + item: "Food".into(), + }) + }), + ( + "ShowItem", + |app| { + app.view_mut().hide_item("Type", "Food"); + }, + || { + Box::new(ShowItem { + category: "Type".into(), + item: "Food".into(), + }) + }, + ), + ( + "TransposeAxes", + |app| { + app.view_mut().set_axis("Type", Axis::Row); + app.view_mut().set_axis("Month", Axis::Column); + }, + || Box::new(TransposeAxes), + ), + ("CycleAxis", nothing, || Box::new(CycleAxis("Type".into()))), + ("SetNumberFormat", nothing, || { + Box::new(SetNumberFormat(",.4f".into())) + }), + ("TogglePruneEmpty", nothing, || Box::new(TogglePruneEmpty)), + ]; + + for (label, prepare, make) in cases { + let mut app = test_app(); + prepare(&mut app); + app.model_state.dirty = false; + + let effect = make(); + assert!( + matches!( + effect.kind(), + EffectKind::Model(ModelChange::LayoutGeometry) + ), + "{label} is not a LayoutGeometry effect, so this case belongs elsewhere" + ); + app.apply_effects(vec![effect]); + assert!( + app.model_state.dirty, + "{label} changed the document and `document_config` could not see it, \ + so the document reads clean forever" + ); + } + } + /// A staged edit to a measure column writes that one cell (improvise-c5v). #[test] fn apply_and_clear_drill_with_measure_edit() { diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 021a2061..844fd542 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -4,10 +4,10 @@ use std::collections::BTreeMap; use improvise_core::format::{format_f64, format_value, parse_number_format}; -use improvise_core::model::Model; use improvise_core::model::category::MEASURE_CATEGORY; use improvise_core::model::cell::CellValue; use improvise_core::model::record::{AggregateHash, RecordHash}; +use improvise_core::model::{Aggregation, Model}; use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol}; use improvise_core::view::{Axis, View}; use improvise_core::workbook::Workbook; @@ -197,6 +197,9 @@ fn axis_frame( collapsed: view.is_group_collapsed(cat_name, group_name), })); } + // Queued like a header, never clearing one: a COLLAPSED group has + // no item to carry it and reaches the frame only through `pending`. + AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd), AxisEntry::DataItem(tuple) => { let idx = data_idx; data_idx += 1; @@ -212,6 +215,7 @@ fn axis_frame( } AxisFrame { cats: cats.to_vec(), + first: window.start, entries, } } @@ -233,7 +237,9 @@ fn column_totals( .map(|c| { let total: f64 = (0..layout.row_count()) .filter_map(|r| layout.cell_key(r, c)) - .map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats)) + .map(|key| { + model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats)) + }) .sum(); CellDisplay::number(format_f64(total, comma, decimals)) }) @@ -268,7 +274,7 @@ fn cell_display( app.model_state .workbook .model - .evaluate_aggregated(&key, &layout.none_cats) + .evaluate_aggregated(&key, Aggregation::of(&layout.none_cats)) }); CellDisplay { text: format_value(value.as_ref(), comma, decimals), @@ -1234,6 +1240,7 @@ mod tests { .iter() .map(|e| match e { FrameEntry::Group(g) => format!("[{}]", g.name), + FrameEntry::GroupEnd => "[/]".to_string(), FrameEntry::Item(v) => v.join("+"), }) .collect() @@ -1755,6 +1762,7 @@ mod tests { let cell = |n: &str| CellDisplay::number(n); let mirror = GridCache { row_frame: AxisFrame { + first: 0, cats: vec!["Region".into()], entries: vec![FrameEntry::Item(vec!["North".into()])], }, @@ -1766,6 +1774,7 @@ mod tests { }; let desired = GridCache { row_frame: AxisFrame { + first: 0, cats: vec!["Department".into()], entries: mirror.row_frame.entries.clone(), }, diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index c95f3f38..7a0e19b6 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -6,7 +6,7 @@ use unicode_width::UnicodeWidthStr; use improvise_protocol::header_level_widths; use crate::format::format_f64; -use crate::model::Model; +use crate::model::{Aggregation, Model}; use crate::view::{AxisEntry, GridLayout}; /// Minimum column width — enough for short numbers/labels + 1 char gap. @@ -62,7 +62,7 @@ pub fn compute_col_widths( for (ci, wref) in widths.iter_mut().enumerate().take(n) { let total: f64 = (0..layout.row_count()) .filter_map(|ri| layout.cell_key(ri, ci)) - .map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats)) + .map(|key| model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats))) .sum(); let s = format_f64(total, fmt_comma, fmt_decimals); let w = s.width() as u16; @@ -82,7 +82,7 @@ pub fn compute_col_widths( pub fn compute_row_header_width(layout: &GridLayout) -> u16 { let items = layout.row_items.iter().filter_map(|e| match e { AxisEntry::DataItem(v) => Some(v), - AxisEntry::GroupHeader { .. } => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, }); header_level_widths(items, layout.row_cats.len().max(1)) .iter() diff --git a/crates/improvise-core/src/model/mod.rs b/crates/improvise-core/src/model/mod.rs index ee89f873..03dc766e 100644 --- a/crates/improvise-core/src/model/mod.rs +++ b/crates/improvise-core/src/model/mod.rs @@ -4,4 +4,4 @@ pub mod record; pub mod symbol; pub mod types; -pub use types::Model; +pub use types::{Aggregation, Model}; diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index f9ad18c3..723b07a5 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -200,6 +200,25 @@ fn eval_is(context: &CellKey, filter: &Filter) -> f64 { .is_some_and(|v| v == filter.item.as_str()) as i32 as f64 } +/// Whether a grid hides dimensions a cell key does not name. The emptiness of +/// the view's `Axis::None` list is the whole question — `context/code/core.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Aggregation { + /// The key names every dimension, so the cell is read as stored. + Exact, + /// Fold every cell the key does not name. + OverHidden, +} + +impl Aggregation { + pub fn of(none_cats: &[String]) -> Self { + match none_cats.is_empty() { + true => Aggregation::Exact, + false => Aggregation::OverHidden, + } + } +} + /// Pure-data document model: categories, cells, formulas. It knows nothing /// about views — see [`crate::workbook::Workbook`] and core.md §10. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -476,8 +495,8 @@ impl Model { }) } - pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option { - if none_cats.is_empty() { + pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option { + if agg == Aggregation::Exact { return self.evaluate(key); } @@ -491,8 +510,8 @@ impl Model { } /// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell. - pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 { - self.evaluate_aggregated(key, none_cats) + pub fn evaluate_aggregated_f64(&self, key: &CellKey, agg: Aggregation) -> f64 { + self.evaluate_aggregated(key, agg) .and_then(|v| v.as_f64()) .unwrap_or(0.0) } @@ -815,7 +834,7 @@ mod agg_helper_tests { #[cfg(test)] mod model_tests { - use super::Model; + use super::{Aggregation, Model}; use crate::model::cell::{CellKey, CellValue}; fn coord(pairs: &[(&str, &str)]) -> CellKey { @@ -994,7 +1013,7 @@ mod model_tests { // With Date hidden, it aggregates to SUM. let none_cats = vec!["Date".to_string()]; - let result = m.evaluate_aggregated(&partial_key, &none_cats); + let result = m.evaluate_aggregated(&partial_key, Aggregation::of(&none_cats)); assert_eq!(result, Some(CellValue::Number(150.0))); } @@ -1031,6 +1050,19 @@ mod model_tests { ); } + #[test] + fn a_hidden_dimension_list_reduces_to_one_of_two_states() { + assert_eq!(Aggregation::of(&[]), Aggregation::Exact); + assert_eq!( + Aggregation::of(&["Date".to_string()]), + Aggregation::OverHidden + ); + assert_eq!( + Aggregation::of(&["Date".to_string(), "Payee".to_string()]), + Aggregation::OverHidden + ); + } + #[test] fn evaluate_aggregated_no_hidden_delegates_to_evaluate() { let mut m = Model::new("Test"); @@ -1039,7 +1071,7 @@ mod model_tests { m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0)); let key = coord(&[("Region", "East")]); assert_eq!( - m.evaluate_aggregated(&key, &[]), + m.evaluate_aggregated(&key, Aggregation::Exact), Some(CellValue::Number(42.0)) ); } @@ -1065,7 +1097,7 @@ mod model_tests { fn status_of(m: &Model) -> Option { m.evaluate_aggregated( &coord(&[("Payee", "Acme"), ("_Measure", "Status")]), - &["Date".to_string()], + Aggregation::of(&["Date".to_string()]), ) } @@ -1156,14 +1188,14 @@ mod model_tests { let key = coord(&[("Payee", "Acme"), ("_Measure", "Price")]); let none_cats = vec!["Date".to_string()]; - let result = m.evaluate_aggregated(&key, &none_cats); + let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats)); assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20 } } #[cfg(test)] mod formula_tests { - use super::Model; + use super::{Aggregation, Model}; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; @@ -1715,12 +1747,18 @@ mod formula_tests { m.recompute_formulas(&none_cats); // Equality says X is zero… assert_eq!( - m.evaluate_aggregated(&coord(&[("_Measure", "IsZero")]), &none_cats), + m.evaluate_aggregated( + &coord(&[("_Measure", "IsZero")]), + Aggregation::of(&none_cats) + ), Some(CellValue::Number(1.0)) ); // …so dividing by X must be div/0 in the cached path too. assert_eq!( - m.evaluate_aggregated(&coord(&[("_Measure", "Ratio")]), &none_cats), + m.evaluate_aggregated( + &coord(&[("_Measure", "Ratio")]), + Aggregation::of(&none_cats) + ), Some(CellValue::Error("div/0".into())) ); } @@ -2382,7 +2420,7 @@ mod route_then_aggregate_tests { let uncached = m.evaluate(&north); m.recompute_formulas(&none_cats); - let cached = m.evaluate_aggregated(&north, &none_cats); + let cached = m.evaluate_aggregated(&north, Aggregation::of(&none_cats)); assert_eq!(uncached, Some(CellValue::Number(11.0))); assert_eq!(cached, uncached, "the two evaluators must not diverge"); @@ -2397,7 +2435,10 @@ mod route_then_aggregate_tests { m.recompute_formulas(&none_cats); for region in ["North", "South"] { assert_eq!( - m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats), + m.evaluate_aggregated( + &key(&[("Region", region), (MEASURE, "Pred")]), + Aggregation::of(&none_cats) + ), Some(CellValue::Number(11.0)), "{region}" ); @@ -2428,7 +2469,10 @@ mod route_then_aggregate_tests { m.recompute_formulas(&none_cats); for (region, expected) in [("North", 1.0), ("South", 0.0)] { assert_eq!( - m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats), + m.evaluate_aggregated( + &key(&[("Region", region), (MEASURE, "Pred")]), + Aggregation::of(&none_cats) + ), Some(CellValue::Number(expected)), "{region}" ); @@ -2444,7 +2488,10 @@ mod route_then_aggregate_tests { m.recompute_formulas(&none_cats); for (region, expected) in [("North", 14.0), ("South", 200.0)] { assert_eq!( - m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats), + m.evaluate_aggregated( + &key(&[("Region", region), (MEASURE, "Pred")]), + Aggregation::of(&none_cats) + ), Some(CellValue::Number(expected)), "{region}" ); diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index cffd5543..4cdd314c 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -1,8 +1,8 @@ use std::rc::Rc; -use crate::model::Model; use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY}; use crate::model::cell::{CellKey, CellValue}; +use crate::model::{Aggregation, Model}; use crate::view::{Axis, View}; /// The trailing column that creates a new measure: typing a name into it @@ -93,6 +93,9 @@ pub enum AxisEntry { cat_name: String, group_name: String, }, + /// The run opened by the last `GroupHeader` ends here (improvise-gd83). + /// Nothing is drawn for it; it exists so membership is not inherited. + GroupEnd, DataItem(Vec), } @@ -101,7 +104,7 @@ pub enum AxisEntry { fn data_items(items: &[AxisEntry]) -> impl Iterator> { items.iter().filter_map(|e| match e { AxisEntry::DataItem(v) => Some(v), - AxisEntry::GroupHeader { .. } => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, }) } @@ -134,6 +137,12 @@ fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { AxisEntry::GroupHeader { .. } => { pending_header = Some(entry); } + // The run it closes may have been pruned away entirely, in which + // case the header is still pending and both are dropped together. + AxisEntry::GroupEnd => match pending_header.take() { + Some(_) => {} + None => kept.push(entry), + }, AxisEntry::DataItem(_) => { if keep(data_idx) { if let Some(h) = pending_header.take() { @@ -427,7 +436,7 @@ impl GridLayout { for (ci, cell) in row.iter_mut().enumerate() { *cell = self .cell_key(ri, ci) - .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats)) + .and_then(|k| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats))) .is_some(); } } @@ -485,7 +494,7 @@ impl GridLayout { self.records_display(row, col).unwrap_or_default() } else { self.cell_key(row, col) - .and_then(|key| model.evaluate_aggregated(&key, &self.none_cats)) + .and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))) .map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals)) .unwrap_or_default() } @@ -529,34 +538,36 @@ impl GridLayout { /// the Nth data row. pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> { let vi = self.data_row_to_visual(data_row)?; - self.row_items[..vi].iter().rev().find_map(|e| { - if let AxisEntry::GroupHeader { - cat_name, - group_name, - } = e - { - Some((cat_name.clone(), group_name.clone())) - } else { - None - } - }) + self.row_items[..vi] + .iter() + .rev() + .find_map(|e| match e { + AxisEntry::GroupHeader { + cat_name, + group_name, + } => Some(Some((cat_name.clone(), group_name.clone()))), + AxisEntry::GroupEnd => Some(None), + AxisEntry::DataItem(_) => None, + }) + .flatten() } /// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding /// the Nth data column. pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> { let vi = self.data_col_to_visual(data_col)?; - self.col_items[..vi].iter().rev().find_map(|e| { - if let AxisEntry::GroupHeader { - cat_name, - group_name, - } = e - { - Some((cat_name.clone(), group_name.clone())) - } else { - None - } - }) + self.col_items[..vi] + .iter() + .rev() + .find_map(|e| match e { + AxisEntry::GroupHeader { + cat_name, + group_name, + } => Some(Some((cat_name.clone(), group_name.clone()))), + AxisEntry::GroupEnd => Some(None), + AxisEntry::DataItem(_) => None, + }) + .flatten() } } @@ -585,8 +596,12 @@ fn expand_category( .get(item_name.as_str()) .and_then(|i| i.group.as_deref()); - // Emit a group header at each group boundary. + // A header on entering a group; a `GroupEnd` only on leaving one for + // NO group — another group's header already ends the run. if item_group != last_group { + if last_group.is_some() && item_group.is_none() { + result.push(AxisEntry::GroupEnd); + } if let Some(g) = item_group { result.push(AxisEntry::GroupHeader { cat_name: cat_name.to_string(), @@ -605,6 +620,11 @@ fn expand_category( row.push(item_name.to_string()); result.push(AxisEntry::DataItem(row)); } + // The expansion is the end of the run too: the next prefix's items follow + // immediately and must not inherit it (improvise-gd83). + if last_group.is_some() { + result.push(AxisEntry::GroupEnd); + } result } @@ -620,10 +640,14 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec .into_iter() .flat_map(|entry| match entry { AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix), - header @ AxisEntry::GroupHeader { .. } => vec![header], + marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd) => vec![marker], }) .collect(); } + // The axis itself ends the last run; a trailing marker names nothing. + while matches!(result.last(), Some(AxisEntry::GroupEnd)) { + result.pop(); + } result } @@ -1114,6 +1138,59 @@ mod tests { assert!(jan.is_none()); } + /// improvise-gd83: two arms — an ungrouped item AFTER a grouped run, and + /// the same item under a later prefix. context/test-evidence/core.md. + #[test] + fn an_ungrouped_item_after_a_grouped_run_belongs_to_no_group() { + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); + wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.add_item("Month", "Sep").unwrap(); + wb.add_item("Type", "Food").unwrap(); + let layout = GridLayout::new(&wb.model, wb.active_view()); + + assert_eq!( + layout.row_group_for(0), + Some(("Month".into(), "Q1".into())), + "precondition: Jan really is in Q1" + ); + assert_eq!( + layout.row_group_for(2), + None, + "Sep is in no group — the run ended before it" + ); + } + + /// The second arm: the run ends at the end of ONE expansion, so the next + /// prefix's first item does not inherit it. context/test-evidence/core.md. + #[test] + fn a_grouped_run_does_not_leak_into_the_next_prefix() { + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Recipient").unwrap(); + wb.add_item("Type", "Food").unwrap(); + wb.add_item("Type", "Clothing").unwrap(); + wb.add_item("Recipient", "Carol").unwrap(); + wb.add_item_in_group("Recipient", "Alice", "Family") + .unwrap(); + wb.add_item_in_group("Recipient", "Bob", "Family").unwrap(); + wb.active_view_mut().set_axis("Recipient", Axis::Row); + let layout = GridLayout::new(&wb.model, wb.active_view()); + + assert_eq!( + layout.row_group_for(2), + Some(("Recipient".into(), "Family".into())), + "precondition: Food/Bob is in Family" + ); + assert_eq!( + layout.row_group_for(3), + None, + "Clothing/Carol opens a new prefix and is in no group" + ); + } + #[test] fn ungrouped_items_produce_no_headers() { let wb = two_cat_workbook(); diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index a3d824c6..eace86e6 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -70,13 +70,15 @@ impl View { /// first slot no regular holds. A virtual is **never** bumped (§7). pub fn on_category_added(&mut self, cat_name: &str) { if !self.category_axes.contains_key(cat_name) { - let axis = if cat_name.starts_with('_') || self.is_records_layout() { + let virtual_or_records = + CategoryKind::of_name(cat_name).is_virtual() || self.is_records_layout(); + let axis = if virtual_or_records { Axis::None } else { let holds_a_regular = |axis: Axis| { self.categories_on(axis) .into_iter() - .any(|c| !c.starts_with('_')) + .any(|c| !CategoryKind::of_name(c).is_virtual()) }; match (holds_a_regular(Axis::Row), holds_a_regular(Axis::Column)) { (false, _) => Axis::Row, @@ -292,6 +294,25 @@ mod tests { ); } + /// improvise-bidq: `_Foo` is `CategoryKind::Regular` to every other + /// consumer — see context/test-evidence/core.md. + #[test] + fn a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular() { + let v = view_with_cats(&["_Foo"]); + assert_eq!( + v.axis_of("_Foo"), + Axis::Row, + "of_name says Regular, so the first slot is Row — not None" + ); + + let v = view_with_cats(&["_Foo", "Region"]); + assert_eq!( + v.axis_of("Region"), + Axis::Column, + "_Foo holds Row as a regular category, so Region takes Column" + ); + } + #[test] fn first_category_assigned_to_row() { let v = view_with_cats(&["Region"]); diff --git a/crates/improvise-formula/src/formula.pest b/crates/improvise-formula/src/formula.pest index cffcdecc..38d11d81 100644 --- a/crates/improvise-formula/src/formula.pest +++ b/crates/improvise-formula/src/formula.pest @@ -20,7 +20,9 @@ formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI } // to `|Total Revenue|` name the same item. target = { identifier } -where_clause = { ^"WHERE" ~ identifier ~ "=" ~ filter_value } +// Compound-atomic like `is_expr`, so `sp+` is the real separator: relax it and +// `WHEREAS x = y` reads as a WHERE on a category `AS`. +where_clause = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value } // ---- expressions -------------------------------------------------------- @@ -64,7 +66,7 @@ paren_expr = { "(" ~ expr ~ ")" } // Aggregates with optional inline WHERE filter inside the parens. agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" } agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" } -inline_where = { ^"WHERE" ~ identifier ~ "=" ~ filter_value } +inline_where = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value } // IF(cond, then, else). Comparison is a standalone rule because comparison // operators are not valid in general expressions — only inside an IF condition. diff --git a/crates/improvise-formula/src/parser.rs b/crates/improvise-formula/src/parser.rs index 1f56e2b4..29497863 100644 --- a/crates/improvise-formula/src/parser.rs +++ b/crates/improvise-formula/src/parser.rs @@ -463,6 +463,35 @@ mod tests { assert_eq!(filter.item, "East"); } + #[test] + fn where_does_not_swallow_the_start_of_a_longer_word() { + assert!(parse_formula("X = SUM(Rev WHEREAS x = y)", "Cat").is_err()); + assert!(parse_formula("X = Revenue WHEREAS x = y", "Cat").is_err()); + assert!(parse_formula("X = Revenue WHEREAS=y", "Cat").is_err()); + assert!(parse_formula("X = SUM(Rev WHEREAS=y)", "Cat").is_err()); + let inline = parse_formula("X = SUM(Rev WHERE Region = East)", "Cat").unwrap(); + assert!( + matches!(&inline.expr, Expr::Agg(_, _, Some(_))), + "{:?}", + inline.expr + ); + let trailing = parse_formula("X = Revenue WHERE Region = East", "Cat").unwrap(); + assert_eq!(trailing.filter.as_ref().unwrap().category, "Region"); + } + + /// `sp+` is the boundary, so the keyword needs a space after it exactly as + /// `IS` does — a quoted category no longer abuts it. + #[test] + fn where_requires_a_space_after_the_keyword() { + assert!(parse_formula("X = Revenue WHERE|Region Name|=|East Coast|", "Cat").is_err()); + let f = parse_formula("X = Revenue WHERE |Region Name|=|East Coast|", "Cat").unwrap(); + let filter = f.filter.as_ref().unwrap(); + assert_eq!( + (filter.category.as_str(), filter.item.as_str()), + ("Region Name", "East Coast") + ); + } + // ── IS, the coordinate predicate (improvise-4zl7) ─────────────────── #[test] diff --git a/crates/improvise-io/src/persistence/parse.rs b/crates/improvise-io/src/persistence/parse.rs index 37746df1..e35a02a7 100644 --- a/crates/improvise-io/src/persistence/parse.rs +++ b/crates/improvise-io/src/persistence/parse.rs @@ -50,6 +50,29 @@ struct ParsedFile { views: Vec, } +impl ParsedFile { + /// Two `## Category:` sections naming one category are one category. The + /// merge concatenates, so the built order is the order a writer emits. + fn push_category(&mut self, pc: PCategory) { + let PCategory { + name, + items, + group_parents, + } = pc; + match self.categories.iter_mut().find(|e| e.name == name) { + Some(existing) => { + existing.items.extend(items); + existing.group_parents.extend(group_parents); + } + None => self.categories.push(PCategory { + name, + items, + group_parents, + }), + } + } +} + // ── Helpers for walking the pest parse tree ────────────────────────────────── /// Advance an iterator, returning an error if empty. @@ -285,7 +308,7 @@ fn walk_file(file: Pair<'_, Rule>) -> Result { Rule::initial_view => { parsed.initial_view = Some(section_title(&mut pair.into_inner(), "initial_view")?); } - Rule::category_section => parsed.categories.push(walk_category_section(pair)?), + Rule::category_section => parsed.push_category(walk_category_section(pair)?), Rule::formulas_section => parsed.formulas.extend(walk_formulas_section(pair)?), Rule::data_section => parsed.data.extend(walk_data_section(pair)?), Rule::view_section => parsed.views.push(walk_view_section(pair)?), @@ -1078,6 +1101,21 @@ mod parser_edge_cases { assert!(!item_names.is_empty()); } + #[test] + fn two_sections_naming_one_category_round_trip_to_a_stable_group_order() { + let text = "v2025-04-09\n# Test\n## Category: C\n> G1[P1]\n\ + ## Category: C\n- X[G2]\n> G2[P2]\n"; + let fmt = crate::persistence::format_md; + let printed = fmt(&parse_md(text).unwrap()); + for owed in ["> G1[P1]", "> G2[P2]", "- X[G2]"] { + assert!( + printed.contains(owed), + "the merge dropped {owed}:\n{printed}" + ); + } + assert_eq!(printed, fmt(&parse_md(&printed).unwrap())); + } + #[test] fn parse_category_with_no_items() { let text = "v2025-04-09\n# Test\n## Category: Empty\n## Category: Full\n- A\n"; diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index ebbf8f38..e766339d 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -163,6 +163,9 @@ pub struct FrameGroup { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FrameEntry { Group(FrameGroup), + /// The run opened by the last `Group` ends here — `AxisEntry::GroupEnd` + /// carried onto the wire (improvise-gd83). Nothing is drawn for it. + GroupEnd, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. Item(Vec), @@ -175,6 +178,10 @@ pub struct AxisFrame { /// The categories on this axis, outermost first. Empty is a real layout /// (one nameless row or column), not a gap — hence [`AxisFrame::levels`]. pub cats: Vec, + /// The **absolute** data index of this frame's first item. Zero means the + /// frame leads the axis, which is what [`AxisFrame::body_from`] asks. + #[serde(default)] + pub first: usize, pub entries: Vec, } @@ -184,6 +191,7 @@ impl AxisFrame { pub fn flat>(labels: impl IntoIterator) -> Self { Self { cats: Vec::new(), + first: 0, entries: labels .into_iter() .map(|l| FrameEntry::Item(vec![l.into()])) @@ -196,7 +204,7 @@ impl AxisFrame { pub fn items(&self) -> impl Iterator> { self.entries.iter().filter_map(|e| match e { FrameEntry::Item(v) => Some(v), - FrameEntry::Group(_) => None, + FrameEntry::Group(_) | FrameEntry::GroupEnd => None, }) } @@ -293,20 +301,51 @@ impl AxisFrame { for entry in &self.entries { match entry { FrameEntry::Group(g) => current = Some(g), + FrameEntry::GroupEnd => current = None, FrameEntry::Item(_) => out.push(current), } } out } - /// The **nearest preceding** group header — not the item's membership, - /// and deliberately so. **Not for a draw loop**; both points in §3. + /// The item's group **membership** — the nearest preceding header, unless + /// a `GroupEnd` closed it first (improvise-gd83). Not for a draw loop; §3. pub fn group_of(&self, idx: usize) -> Option<&FrameGroup> { let visual = self.visual_of(idx)?; - self.entries[..visual].iter().rev().find_map(|e| match e { - FrameEntry::Group(g) => Some(g), - FrameEntry::Item(_) => None, - }) + self.entries[..visual] + .iter() + .rev() + .find_map(|e| match e { + FrameEntry::Group(g) => Some(Some(g)), + FrameEntry::GroupEnd => Some(None), + FrameEntry::Item(_) => None, + }) + .flatten() + } + + /// The runs of adjacent positions sharing a group, coalesced by NAME — + /// the coalescing rule's **one home**, both front ends (improvise-8qlr). + pub fn group_runs(&self) -> Vec<(Option<&FrameGroup>, usize)> { + let mut runs: Vec<(Option<&FrameGroup>, usize)> = Vec::new(); + for group in self.groups() { + let name = group.map(|g| g.name.as_str()); + match runs.last_mut() { + Some((prev, span)) if prev.map(|p| p.name.as_str()) == name => *span += 1, + _ => runs.push((group, 1)), + } + } + runs + } + + /// The group label to paint above each position, `None` where the run + /// continues. Derived from [`AxisFrame::group_runs`], never beside it. + pub fn group_run_heads(&self) -> Vec> { + self.group_runs() + .into_iter() + .flat_map(|(group, span)| { + std::iter::once(group).chain(std::iter::repeat_n(None, span - 1)) + }) + .collect() } /// Position of the data item `idx` within `entries`, group headers @@ -320,11 +359,16 @@ impl AxisFrame { .nth(idx) } - /// The entries a renderer walks from data item `idx`, dropping the group - /// header above it. The argument is scrolling, not the rule — see §3. + /// The entries a renderer walks from data item `idx`. The header above it + /// is dropped, EXCEPT at the top of the axis — §3a, improvise-0o6x. pub fn body_from(&self, idx: usize) -> &[FrameEntry] { - let start = self.visual_of(idx).unwrap_or(self.entries.len()); - &self.entries[start..] + match self.first + idx == 0 { + true => &self.entries[..], + false => { + let start = self.visual_of(idx).unwrap_or(self.entries.len()); + &self.entries[start..] + } + } } /// The width of each header level, in display cells. Derived rather than @@ -921,6 +965,7 @@ mod tests { CellDisplay::number("3"), ])); grid.apply(&GridUpdate::RowFrame(AxisFrame { + first: 0, cats: vec!["Zone".into()], entries: grid.row_frame.entries.clone(), })); @@ -953,6 +998,7 @@ mod tests { /// A two-level axis: [A,x], [A,y], [B,z], with `B` in a collapsed group. fn two_level_frame() -> AxisFrame { AxisFrame { + first: 0, cats: vec!["Type".into(), "Who".into()], entries: vec![ FrameEntry::Item(vec!["A".into(), "x".into()]), @@ -1034,6 +1080,7 @@ mod tests { #[test] fn header_widths_are_per_level_and_measured_in_display_cells() { let frame = AxisFrame { + first: 0, cats: vec!["A".into(), "B".into()], entries: vec![FrameEntry::Item(vec!["Clothing".into(), "日本".into()])], }; @@ -1071,6 +1118,7 @@ mod tests { #[test] fn an_axis_with_no_categories_still_has_one_level() { let frame = AxisFrame { + first: 0, cats: Vec::new(), entries: vec![FrameEntry::Item(Vec::new())], }; @@ -1200,6 +1248,7 @@ mod tests { })) }; let axis = |cat: &str, labels: [&str; 2]| AxisFrame { + first: 0, cats: vec![cat.into()], entries: labels .iter() @@ -1225,6 +1274,7 @@ mod tests { fn renaming_a_row_category_invalidates_every_cell_under_it() { let mut grid = identified_grid(); let renamed = AxisFrame { + first: 0, cats: vec!["Department".into()], entries: grid.row_frame.entries.clone(), }; @@ -1264,6 +1314,7 @@ mod tests { fn renaming_a_column_category_invalidates_every_cell_under_it() { let mut grid = identified_grid(); let renamed = AxisFrame { + first: 0, cats: vec!["Metric".into()], entries: grid.col_frame.entries.clone(), }; diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index c42644d4..cb894cde 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -236,6 +236,8 @@ fn declined(name: &str, args: &[String]) -> Option { "toggle-records-mode" => "records mode", "add-record-row" => "adding a record row", "delete-formula-at-cursor" => "deleting a formula", + // NOT "not built": it commits a drill first, so composing it would + // commit another client's typing. Needs addressed commands — clients.md. "view-back" => "stepping back to the previous view", // A door this client will not open still has to say so: entering is // resolvable and the commit behind it is not. diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index fe4601c8..31f654ce 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -19,9 +19,8 @@ pub struct Client { state: ClientState, } -/// Allocate a client: an empty view slice and one empty cache per widget. A -/// client that draws before its bootstrap draws an empty grid, correctly. -#[wasm_bindgen] +/// An empty view slice and one empty cache per widget. Not exported: the +/// page's door is `new Client()` (improvise-seik). pub fn init() -> Client { Client::default() } @@ -55,14 +54,8 @@ impl Client { serde_json::to_string(&message).ok() } - /// The whole client state as JSON: the view slice, every widget cache, and - /// the revision it corresponds to. - #[wasm_bindgen] - pub fn render_state(&self) -> Result { - self.state_json().map_err(|e| JsError::new(&e.to_string())) - } - - /// The cheap half of [`Client::render_state`]: has anything moved? + /// Has anything moved? The cheap question, and the one `on_frame` already + /// answers by returning it. #[wasm_bindgen] pub fn revision(&self) -> u64 { self.state.revision @@ -71,24 +64,17 @@ impl Client { // The transport surface: a handful of string calls and the whole harness a // page needs. See `context/code/clients.md`. - /// The handshake frame, associated rather than a method. **Prefer - /// [`Client::set_viewport`]** — this one forgets the window (clients.md). + /// The handshake frame, *and* the record of what this client can show. + /// The **only** door, and why that matters: `context/code/clients.md`. #[wasm_bindgen] - pub fn viewport(rows: usize, cols: usize) -> String { + pub fn set_viewport(&mut self, rows: usize, cols: usize) -> String { + self.state.window = (rows, cols); serde_json::to_string(&Self::viewport_frame(rows, cols)) // Two `usize` in a unit variant; unreachable, and an empty frame // is skipped by the server rather than crashing the tab. .unwrap_or_default() } - /// The handshake frame, *and* the record of what this client can show — - /// which is not derivable from anything the host sends (clients.md). - #[wasm_bindgen] - pub fn set_viewport(&mut self, rows: usize, cols: usize) -> String { - self.state.window = (rows, cols); - Self::viewport(rows, cols) - } - /// Fold one frame, in arrival order, one at a time; returns the new /// revision. Never reorder or coalesce — a split batch loses cells. #[wasm_bindgen] @@ -316,12 +302,15 @@ mod tests { ); } - /// The handshake frame is the same either way; `set_viewport` also keeps - /// the window, which is what lets a cursor key resolve at all. + /// The handshake frame, and the window it records — one call, because a + /// second door that sent the frame and forgot the window was silent. #[test] - fn set_viewport_sends_the_same_frame_and_keeps_the_window() { + fn set_viewport_sends_the_handshake_frame_and_keeps_the_window() { let mut client = init(); - assert_eq!(client.set_viewport(24, 8), Client::viewport(24, 8)); + assert_eq!( + serde_json::from_str::(&client.set_viewport(24, 8)).unwrap(), + Client::viewport_frame(24, 8) + ); assert_eq!(client.state().window, (24, 8)); } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 30a20693..24f7202b 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -216,7 +216,10 @@ pub fn render_model(state: &ClientState) -> RenderModel { .body_from(0) .iter() .scan(0usize, |data_idx, entry| { + // `GroupEnd` is a boundary, not a row: it draws nothing and + // consumes no data index (improvise-gd83). let row = match entry { + FrameEntry::GroupEnd => return Some(None), FrameEntry::Group(group) => RenderRow::Group { label: group.name.clone(), collapsed: group.collapsed, @@ -244,8 +247,9 @@ pub fn render_model(state: &ClientState) -> RenderModel { } } }; - Some(row) + Some(Some(row)) }) + .flatten() .collect(), categories: entries .iter() @@ -443,26 +447,20 @@ fn col_levels(cols: &AxisFrame) -> Vec> { /// The column groups as runs, coalescing equal answers from /// `AxisFrame::group_of`. Nothing at all when no column is in a group. fn col_groups(cols: &AxisFrame) -> Vec { - let mut runs: Vec = Vec::new(); - let mut any = false; - for group in cols.groups() { - any |= group.is_some(); - let label = group.map(|g| g.name.clone()); - let collapsed = group.is_some_and(|g| g.collapsed); - // Coalesced by NAME alone, as `src/ui/grid.rs` does, and the run's - // indicator is its FIRST group's. See `context/code/clients.md`. - match runs.last_mut() { - Some(run) if run.label == label => run.span += 1, - _ => runs.push(RenderColGroup { - label, - collapsed, - span: 1, - }), - } - } + // The coalescing rule is `AxisFrame::group_runs`, which the TUI draws + // from too — one home, not two agreeing copies (improvise-8qlr). + let runs = cols.group_runs(); + let any = runs.iter().any(|(group, _)| group.is_some()); match any { - true => runs, false => Vec::new(), + true => runs + .into_iter() + .map(|(group, span)| RenderColGroup { + label: group.map(|g| g.name.clone()), + collapsed: group.is_some_and(|g| g.collapsed), + span, + }) + .collect(), } } @@ -520,6 +518,7 @@ mod tests { #[test] fn column_groups_coalesce_by_name_as_the_tui_does() { let frame = AxisFrame { + first: 0, cats: vec!["Month".into()], entries: vec![ group("Month", "Q1", false), diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 0fb65264..776f2501 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -98,10 +98,10 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() { // The handshake. The extent comes first because the snapshot seeds the // *visible* cells: one built at extent zero is an empty grid. - let handshake: ClientFrame = serde_json::from_str(&Client::viewport(10, 10)) + let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10)) .expect("the viewport frame must be one the server reads"); let ClientFrame::Viewport { rows, cols } = handshake else { - panic!("Client::viewport must produce a Viewport frame"); + panic!("set_viewport must produce a Viewport frame"); }; let (id, snapshot) = host.connect(rows, cols); deliver(&mut client, &[snapshot]); @@ -305,7 +305,7 @@ fn a_resize_reprojects_the_grid_at_the_new_extent() { "a one-row viewport is seeded with one row" ); - let frames = send(&mut host, id, &Client::viewport(10, 10)); + let frames = send(&mut host, id, &client.set_viewport(10, 10)); deliver(&mut client, &frames); assert_eq!( client.model().rows.len(), diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs index 36a25b66..10c05907 100644 --- a/crates/improvise-worker-server/src/worker.rs +++ b/crates/improvise-worker-server/src/worker.rs @@ -297,7 +297,10 @@ mod tests { fn value_at(wb: &Workbook, row: usize, col: usize) -> Option { let layout = improvise_core::view::layout::GridLayout::new(&wb.model, wb.active_view()); let key = layout.cell_key(row, col)?; - wb.model.evaluate_aggregated(&key, &layout.none_cats) + wb.model.evaluate_aggregated( + &key, + improvise_core::model::Aggregation::of(&layout.none_cats), + ) } /// `Storage::list` is how a browser offers a document picker, and it must diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index 53999dbd..0503d527 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use improvise_command::ui::app::App; use improvise_command::ui::reduce; +use improvise_core::model::Aggregation; use improvise_core::model::cell::{CellKey, CellValue}; use improvise_core::view::Axis; use improvise_core::view::layout::GridLayout; @@ -190,7 +191,8 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() { fn value_at(wb: &Workbook, row: usize, col: usize) -> Option { let layout = GridLayout::new(&wb.model, wb.active_view()); let key = layout.cell_key(row, col)?; - wb.model.evaluate_aggregated(&key, &layout.none_cats) + wb.model + .evaluate_aggregated(&key, Aggregation::of(&layout.none_cats)) } /// A second worker over the same backend sees what the first one saved: what a diff --git a/crates/improvise-ws-server/src/lib.rs b/crates/improvise-ws-server/src/lib.rs index 747c0b73..68947fea 100644 --- a/crates/improvise-ws-server/src/lib.rs +++ b/crates/improvise-ws-server/src/lib.rs @@ -2,9 +2,11 @@ //! Native only; no auth, no TLS, loopback only. `context/code/clients.md`. pub mod host; +pub mod origin; pub mod registry; pub mod transport; pub use host::{Addressed, Host}; pub use improvise_protocol::{ClientFrame, ServerFrame}; +pub use origin::OriginPolicy; pub use registry::{ClientId, DocumentId, Registry, Routed}; diff --git a/crates/improvise-ws-server/src/main.rs b/crates/improvise-ws-server/src/main.rs index d79cf508..e0dde48f 100644 --- a/crates/improvise-ws-server/src/main.rs +++ b/crates/improvise-ws-server/src/main.rs @@ -10,6 +10,7 @@ use tokio::net::TcpListener; use improvise_command::ui::app::App; use improvise_core::workbook::Workbook; use improvise_io::persistence; +use improvise_ws_server::origin::OriginPolicy; use improvise_ws_server::registry::{DocumentId, Registry}; use improvise_ws_server::transport; @@ -30,10 +31,22 @@ struct Args { /// unauthenticated, unencrypted, fully writable documents. #[arg(long, default_value = "127.0.0.1")] bind: String, + + /// An origin a browser page may open a socket from, e.g. + /// `https://example.github.io`. Repeatable; loopback is always allowed. + #[arg(long = "allow-origin", value_name = "ORIGIN")] + allow_origin: Vec, +} + +impl Args { + fn origin_policy(&self) -> OriginPolicy { + OriginPolicy::new(self.allow_origin.iter().cloned()) + } } fn main() -> Result<()> { let args = Args::parse(); + let origins = args.origin_policy(); let registry = open_all(&args.files)?; let names: Vec = registry.ids().iter().map(DocumentId::to_string).collect(); @@ -47,15 +60,25 @@ fn main() -> Result<()> { .await .with_context(|| format!("failed to bind {}:{}", args.bind, args.port))?; eprintln!( - "improvise-ws-server listening on ws://{}:{} (no auth, no TLS)\n documents: {}", + "improvise-ws-server listening on ws://{}:{} (no auth, no TLS)\n documents: {}\n \ + origins: loopback pages{}", args.bind, args.port, - names.join(", ") + names.join(", "), + allowed_suffix(&args.allow_origin) ); - transport::serve(registry, listener).await + transport::serve(registry, listener, origins).await })) } +/// What the startup banner adds after "loopback pages". +fn allowed_suffix(named: &[String]) -> String { + match named.is_empty() { + true => String::new(), + false => format!(", {}", named.join(", ")), + } +} + /// Open every named document, or the scratch document if none was named. A /// stem collision is fatal: a client could not tell the two documents apart. fn open_all(files: &[PathBuf]) -> Result { @@ -140,6 +163,24 @@ mod tests { ); } + /// The flag is the only way to widen the policy, and with none given the + /// deployed https page is refused while the local recipe still works. + #[test] + fn allow_origin_widens_the_policy_and_nothing_else_does() { + let bare = Args::parse_from(["improvise-ws-server"]).origin_policy(); + assert!(bare.permits(Some("http://localhost:8080"))); + assert!(!bare.permits(Some("https://fiddlerwoaroof.github.io"))); + + let widened = Args::parse_from([ + "improvise-ws-server", + "--allow-origin", + "https://fiddlerwoaroof.github.io", + ]) + .origin_policy(); + assert!(widened.permits(Some("https://fiddlerwoaroof.github.io"))); + assert!(!widened.permits(Some("https://evil.example"))); + } + #[test] fn no_files_serves_one_scratch_document() { let registry = open_all(&[]).unwrap(); diff --git a/crates/improvise-ws-server/src/origin.rs b/crates/improvise-ws-server/src/origin.rs new file mode 100644 index 00000000..c1019216 --- /dev/null +++ b/crates/improvise-ws-server/src/origin.rs @@ -0,0 +1,132 @@ +//! Which origins may open a socket. A handshake is exempt from the same-origin +//! policy, so this is the only thing that can see a hijack: improvise-kc7n. + +use std::net::Ipv4Addr; + +/// The origins a browser page may open a socket from. Loopback pages are +/// always allowed; anywhere else has to be named. +#[derive(Debug, Clone, Default)] +pub struct OriginPolicy { + named: Vec, +} + +impl OriginPolicy { + /// `named` is what `--allow-origin` collected: a scheme and an authority, + /// with no path, exactly as a browser spells it. + pub fn new(named: impl IntoIterator) -> Self { + Self { + named: named + .into_iter() + .map(|origin| origin.trim_end_matches('/').to_ascii_lowercase()) + .collect(), + } + } + + /// Whether a handshake carrying `origin` may proceed. Absent is a decision + /// and not a fall-through: `context/code/clients.md` §Origin argues it. + pub fn permits(&self, origin: Option<&str>) -> bool { + match origin { + // A client that sends no `Origin` is not a browser page: curl, a + // native client, this crate's own tests. + None => true, + Some(origin) => self.permits_present(&origin.to_ascii_lowercase()), + } + } + + fn permits_present(&self, origin: &str) -> bool { + is_loopback(origin) || self.named.iter().any(|allowed| allowed == origin) + } +} + +/// Whether `origin` names a page served from this machine. `null` — a +/// `file://` page or a sandboxed frame — is not one, and that is the point. +fn is_loopback(origin: &str) -> bool { + match origin.split_once("://") { + Some(("http" | "https", authority)) => is_loopback_host(host_of(authority)), + _ => false, + } +} + +/// The host an authority names, without its port. An IPv6 literal keeps its +/// brackets, which is how a browser spells it anyway. +fn host_of(authority: &str) -> &str { + match authority.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => authority, + } +} + +fn is_loopback_host(host: &str) -> bool { + host == "localhost" + || host == "[::1]" + || host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowing(origins: &[&str]) -> OriginPolicy { + OriginPolicy::new(origins.iter().map(|o| o.to_string())) + } + + /// The hole itself: a page the user did not open, driving the socket from + /// the user's own allowed address. + #[test] + fn a_foreign_origin_is_refused() { + let policy = OriginPolicy::default(); + assert!(!policy.permits(Some("https://evil.example"))); + assert!(!policy.permits(Some("http://improvise.example.com"))); + } + + /// The shipped local recipe. Every spelling of loopback a browser can put + /// in the address bar, on any port, over either scheme. + #[test] + fn a_loopback_page_is_allowed_by_default() { + let policy = OriginPolicy::default(); + for origin in [ + "http://localhost:8080", + "http://127.0.0.1:8080", + "https://localhost", + "http://[::1]:8080", + "http://127.0.0.2:3000", + ] { + assert!(policy.permits(Some(origin)), "{origin} must be allowed"); + } + } + + /// `null` is what a `file://` page and a sandboxed frame send, and both of + /// those are exactly what the check exists to keep out. + #[test] + fn the_null_origin_is_not_loopback() { + assert!(!OriginPolicy::default().permits(Some("null"))); + } + + /// A host that merely *contains* a loopback spelling is a different host. + #[test] + fn a_hostname_that_looks_like_loopback_is_not() { + let policy = OriginPolicy::default(); + assert!(!policy.permits(Some("http://localhost.evil.example"))); + assert!(!policy.permits(Some("http://127.0.0.1.evil.example"))); + assert!(!policy.permits(Some("ws://localhost:8080"))); + } + + /// The deployed page reaches the server through a TLS proxy, so its origin + /// is named rather than guessed. + #[test] + fn a_named_origin_is_allowed_and_its_neighbours_are_not() { + let policy = allowing(&["https://fiddlerwoaroof.github.io"]); + assert!(policy.permits(Some("https://fiddlerwoaroof.github.io"))); + assert!(policy.permits(Some("https://FiddlerWoaroof.GitHub.io"))); + assert!(!policy.permits(Some("http://fiddlerwoaroof.github.io"))); + assert!(!policy.permits(Some("https://fiddlerwoaroof.github.io.evil.example"))); + } + + /// Decided rather than defaulted: no browser omits `Origin` on a handshake, + /// so an absent one is a client an address check already answers. + #[test] + fn an_absent_origin_is_allowed() { + assert!(OriginPolicy::default().permits(None)); + assert!(allowing(&["https://example.com"]).permits(None)); + } +} diff --git a/crates/improvise-ws-server/src/transport.rs b/crates/improvise-ws-server/src/transport.rs index adcda39b..628ad415 100644 --- a/crates/improvise-ws-server/src/transport.rs +++ b/crates/improvise-ws-server/src/transport.rs @@ -10,12 +10,14 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, oneshot}; use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; +use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; +use tokio_tungstenite::tungstenite::http::{StatusCode, header}; use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; #[cfg(doc)] use crate::host::Host; +use crate::origin::OriginPolicy; use crate::registry::{ClientId, ConnectError, Registry, Routed}; use improvise_protocol::{ClientFrame, ServerFrame}; @@ -36,15 +38,17 @@ enum Inbound { /// Accept connections on `listener` until it fails, serving every document in /// `registry`, on the current thread in a `LocalSet` the caller supplies. -pub async fn serve(registry: Registry, listener: TcpListener) -> Result<()> { +pub async fn serve(registry: Registry, listener: TcpListener, origins: OriginPolicy) -> Result<()> { let (tx, rx) = mpsc::unbounded_channel(); let documents = tokio::task::spawn_local(registry_loop(registry, rx)); + let origins = Rc::new(origins); while let Ok((stream, _peer)) = listener.accept().await { let tx = tx.clone(); + let origins = Rc::clone(&origins); tokio::task::spawn_local(async move { // A connection that goes wrong is that connection's problem. - let _ = connection(stream, tx).await; + let _ = connection(stream, tx, origins).await; }); } drop(tx); @@ -114,14 +118,23 @@ fn attach( /// One websocket connection, from handshake to close. The large error type is /// tungstenite's and is never returned: refusal is the registry's answer. #[allow(clippy::result_large_err)] -async fn connection(stream: TcpStream, tx: mpsc::UnboundedSender) -> Result<()> { +async fn connection( + stream: TcpStream, + tx: mpsc::UnboundedSender, + origins: Rc, +) -> Result<()> { // The document is named in the URL, which is readable only during the // handshake, so it is captured out of the callback. let requested: Rc>> = Rc::new(RefCell::new(None)); let seen = Rc::clone(&requested); let ws = tokio_tungstenite::accept_hdr_async(stream, move |req: &Request, res: Response| { - *seen.borrow_mut() = document_of(req.uri().path()); - Ok(res) + match admitted(&origins, req) { + true => { + *seen.borrow_mut() = document_of(req.uri().path()); + Ok(res) + } + false => Err(forbidden()), + } }) .await .context("websocket handshake failed")?; @@ -185,6 +198,29 @@ async fn connection(stream: TcpStream, tx: mpsc::UnboundedSender) -> Re Ok(()) } +/// Whether `origins` permits this handshake. Two `Origin` headers, or one that +/// is not text, name no origin a policy could answer for and are refused. +fn admitted(origins: &OriginPolicy, req: &Request) -> bool { + let mut stated = req.headers().get_all(header::ORIGIN).iter(); + match (stated.next(), stated.next()) { + (None, _) => origins.permits(None), + (Some(value), None) => value + .to_str() + .is_ok_and(|origin| origins.permits(Some(origin))), + (Some(_), Some(_)) => false, + } +} + +/// What a refused handshake is answered with. A browser surfaces none of it to +/// the page, so the wording is for whoever is reading a proxy log. +fn forbidden() -> ErrorResponse { + let mut response = ErrorResponse::new(Some( + "origin not allowed; name it with --allow-origin".to_string(), + )); + *response.status_mut() = StatusCode::FORBIDDEN; + response +} + /// The document a request path names; `/` and `` are "whichever one there is". /// No unescaping: a `DocumentId` is a name a host chose, not a path. fn document_of(path: &str) -> Option { @@ -230,4 +266,59 @@ mod tests { // name, so a nested-looking path is simply a name nothing serves. assert_eq!(document_of("/a/b"), Some("a/b".to_string())); } + + fn handshake(origin: Option<&[u8]>) -> Request { + let mut req = Request::builder().uri("/").body(()).unwrap(); + if let Some(origin) = origin { + req.headers_mut().insert( + header::ORIGIN, + header::HeaderValue::from_bytes(origin).unwrap(), + ); + } + req + } + + /// A proxy that appends rather than sets `Origin` leaves two, and reading + /// the first would let the second be anything at all. + #[test] + fn two_origins_state_none_this_policy_can_answer_for() { + let mut req = handshake(Some(b"http://localhost:8080")); + req.headers_mut().append( + header::ORIGIN, + header::HeaderValue::from_static("https://evil.example"), + ); + assert_eq!(req.headers().get_all(header::ORIGIN).iter().count(), 2); + assert!(!admitted(&OriginPolicy::default(), &req)); + + // And the allowed one alone is still allowed, so the refusal above is + // the second header and not the first. + assert!(admitted( + &OriginPolicy::default(), + &handshake(Some(b"http://localhost:8080")) + )); + } + + /// The seam between the policy and the handshake, including the one case + /// the policy cannot be asked about: an `Origin` that is not text. + #[test] + fn the_handshake_refuses_what_the_policy_refuses() { + let origins = OriginPolicy::default(); + assert!(admitted( + &origins, + &handshake(Some(b"http://localhost:8080")) + )); + assert!(admitted(&origins, &handshake(None))); + assert!(!admitted( + &origins, + &handshake(Some(b"https://evil.example")) + )); + assert!(!admitted(&origins, &handshake(Some(b"http://\xffevil")))); + } + + /// A refusal has to be a refused upgrade rather than an accepted socket + /// the server then ignores. + #[test] + fn a_refusal_is_a_forbidden_response() { + assert_eq!(forbidden().status(), StatusCode::FORBIDDEN); + } } diff --git a/crates/improvise-ws-server/tests/socket.rs b/crates/improvise-ws-server/tests/socket.rs index 5f628d48..a3339d03 100644 --- a/crates/improvise-ws-server/tests/socket.rs +++ b/crates/improvise-ws-server/tests/socket.rs @@ -4,6 +4,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::http::StatusCode; use improvise_command::ui::app::App; use improvise_core::model::cell::CellValue; @@ -13,6 +14,7 @@ use improvise_protocol::{ BasisCache, CellId, Command, GridCache, GridUpdate, ModelCommand, Projection, WidgetCache, }; use improvise_protocol::{ClientFrame, ServerFrame}; +use improvise_ws_server::origin::OriginPolicy; use improvise_ws_server::registry::{DocumentId, Registry}; /// One document, served as `quarterly`. @@ -50,7 +52,12 @@ fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::task::spawn_local(async move { - let _ = improvise_ws_server::transport::serve(registry(), listener).await; + let _ = improvise_ws_server::transport::serve( + registry(), + listener, + OriginPolicy::default(), + ) + .await; }); // No path: with one document served, `/` reaches it, exactly as it @@ -198,7 +205,9 @@ fn the_url_path_picks_the_document() { registry .open(DocumentId::new("beta"), named_app("Beta", "Bison")) .unwrap(); - let _ = improvise_ws_server::transport::serve(registry, listener).await; + let _ = + improvise_ws_server::transport::serve(registry, listener, OriginPolicy::default()) + .await; }); for (path, row) in [("alpha", "Aardvark"), ("beta", "Bison")] { @@ -262,3 +271,111 @@ fn named_app(name: &str, row: &str) -> App { app.rebuild_layout(); app } + +/// A hostile page an allowed user visits opens a socket from that user's own +/// allowed address. Only `Origin` separates it from the real page. +#[test] +fn a_handshake_from_a_foreign_origin_is_refused() { + with_server(OriginPolicy::default(), |addr| async move { + let Err(refused) = + tokio_tungstenite::connect_async(request(addr, Some("https://evil.example"))).await + else { + panic!("a foreign origin must not reach the document"); + }; + // The kind, not the wording: a refused upgrade and a dropped socket + // are different outcomes and only one of them is this fix. + let tokio_tungstenite::tungstenite::Error::Http(answer) = refused else { + panic!("the handshake failed for some other reason: {refused}"); + }; + assert_eq!(answer.status(), StatusCode::FORBIDDEN); + }); +} + +/// The shipped local recipe: the page is served by `python3 -m http.server` +/// on loopback and opens a socket to the server on another loopback port. +#[test] +fn a_handshake_from_a_loopback_page_is_accepted() { + with_server(OriginPolicy::default(), |addr| async move { + let (mut ws, _) = + tokio_tungstenite::connect_async(request(addr, Some("http://localhost:8080"))) + .await + .expect("the local pairing recipe must keep working"); + send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await; + assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_))); + }); +} + +/// Non-browser clients — curl, a native client, this file's other tests — +/// send no `Origin` at all, and absent is decided rather than defaulted. +#[test] +fn a_handshake_with_no_origin_is_accepted() { + with_server(OriginPolicy::default(), |addr| async move { + let (mut ws, _) = tokio_tungstenite::connect_async(request(addr, None)) + .await + .expect("a client that sends no Origin is not a browser page"); + send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await; + assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_))); + }); +} + +/// The deployed page reaches the server through a TLS proxy, and `Origin` is +/// the one thing that survives the proxy to say which page it was. +#[test] +fn a_named_origin_reaches_the_document() { + let named = OriginPolicy::new(["https://fiddlerwoaroof.github.io".to_string()]); + with_server(named, |addr| async move { + let (mut ws, _) = tokio_tungstenite::connect_async(request( + addr, + Some("https://fiddlerwoaroof.github.io"), + )) + .await + .expect("--allow-origin must actually widen the policy"); + send(&mut ws, ClientFrame::Viewport { rows: 10, cols: 10 }).await; + assert!(matches!(recv(&mut ws).await, ServerFrame::Snapshot(_))); + + // Widening for one page does not widen for its neighbours. + assert!( + tokio_tungstenite::connect_async(request(addr, Some("https://evil.example"))) + .await + .is_err() + ); + }); +} + +/// A handshake request for `addr`, carrying `origin` when there is one. +fn request( + addr: std::net::SocketAddr, + origin: Option<&str>, +) -> tokio_tungstenite::tungstenite::http::Request<()> { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut request = format!("ws://{addr}/") + .into_client_request() + .expect("a loopback address is a valid websocket url"); + if let Some(origin) = origin { + request + .headers_mut() + .insert("Origin", origin.parse().expect("a header value")); + } + request +} + +/// Serve the one-document fixture on an ephemeral port and run `body`. +fn with_server(origins: OriginPolicy, body: F) +where + F: FnOnce(std::net::SocketAddr) -> Fut, + Fut: std::future::Future, +{ + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let local = tokio::task::LocalSet::new(); + runtime.block_on(local.run_until(async move { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::task::spawn_local(async move { + let _ = improvise_ws_server::transport::serve(registry(), listener, origins).await; + }); + body(addr).await; + })); +} diff --git a/docs/casts/drill.cast b/docs/casts/drill.cast index 91496034..07efc865 100644 --- a/docs/casts/drill.cast +++ b/docs/casts/drill.cast @@ -1,655 +1,175 @@ -{"version": 2, "width": 120, "height": 37, "timestamp": 1775772329, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}} -[0.255122, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[0.26349, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[0.389731, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:05:29\r\n16025:% \u001b[K"] -[0.389839, "o", "\u001b[?2004h"] -[3.260628, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"] -[4.100025, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l"] -[4.10015, "o", "\r\r\n"] -[4.101098, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"] -[4.137227, "o", "\u001b[?1049h"] -[4.139474, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"] -[4.139536, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "] -[4.139643, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"] -[4.139729, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"] -[4.139761, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.242195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.344691, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.447079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.549689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.652179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.754674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.856161, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.958622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.060824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.163294, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.265591, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.367483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.389269, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"] -[5.389472, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "] -[5.389587, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.491141, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.592963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.69456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.797252, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.899269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.001855, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.102804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.204488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.306416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.408174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.515144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.614322, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.715087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.816824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.919456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.023808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.126353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.227706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.329812, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.431498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.533584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.635475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.738315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.840254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.942468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.044291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.049255, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.151473, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.252708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.354391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.456511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.558371, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.66005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.761856, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.839104, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.941054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.043964, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.146777, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.24872, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.29841, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.399634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.500481, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.602021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.704492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.76467, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.866679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.96862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.070632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.168973, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.271432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.373277, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.475256, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.54583, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.647847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.750326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.851916, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.904656, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.007234, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.108949, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.210797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.305394, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.40791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.509599, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.612098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.714734, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.817078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.837581, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;20H\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;19H\u001b[3m2,940 4,200 \u001b[8;34H1,260\u001b[23m \u001b[9;2H\u001b[38;5;8;49m────────────"] -[11.837774, "o", "──────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[22m\u001b[39;49m \u001b[11;2H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HN\u001b[37;25Hne \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.939188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.040703, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.142444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.243643, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.344543, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.44606, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.546854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.648382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.749506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.85104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.952602, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.037101, "o", "\u001b[36;103H\u001b[38;5;8;49m [Date_Month ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.1401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.241851, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.343997, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.445753, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.547755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.648763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.750013, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.851891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.953275, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.055126, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.15682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.258532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.359739, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.46102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.562788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.664633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.766446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.868114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.969328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.070964, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.172609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.274437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.376279, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.477908, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.579437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.624853, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HCost | Customer = Stark Enterprises | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[4;25H\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-03-25 Gadgets East 2025-03 5670\u001b[7;2H\u001b[39;49m1 Stark En\u001b[7;16Herprises 2025-01-31 Gadgets\u001b[7;46HEast\u001b[7;54H2025-01\u001b[7;63H5180\u001b[8;2H \u001b[8;12H \u001b[8;17H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;12H\u001b[38;5;0;48;5;8mrilled into\u001b[37;24Hcell:\u001b[37;30H2\u001b[37;32Hrows\u001b[37;110H _Drill\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.726365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.827956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.929775, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.031367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.132934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.234738, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.335777, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.436366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.53795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.639488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.74103, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.843208, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.944693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.046398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.148618, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.250351, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.351896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.453688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.555582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.656954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.758333, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.859977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.961612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.062956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.164668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.26625, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.367619, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.46896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.570702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.672674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.774225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.875264, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.976852, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.077943, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.179556, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.280996, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.382478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.422995, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises] \u001b[39;49m \u001b[4;6H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[4;25H\u001b[24mRevenue Profit\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets Eas\u001b[7;16H \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[7;46H \u001b[7;54H \u001b[7;63H \u001b[8;2HSprockets\u001b[8;12HEast\u001b[8;17H\u001b[3m 2,940 4,200 1,260\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[36;10H\u001b[22m\u001b[38;5;8"] -[19.42323, "o", ";49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.52534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.627683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.729242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.83102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.932257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.033895, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.135888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.237369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.288716, "o", "\u001b[4;17H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;17H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.390112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.491486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.593008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.693621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.795131, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.896129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.96272, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HRevenue | Customer\u001b[3;34H= Stark Enterprises | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-01-31 Gadgets East 2025-01 7400\u001b[7;2H\u001b[39;49m1 Stark En\u001b[7;16Herprises 2025-03-25 Gadgets\u001b[7;46HEast\u001b[7;54H2025-03\u001b[7;63H8100\u001b[8;2H \u001b[8;12H \u001b[8;17H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;112H\u001b[38;5;0;48;5;8m _Dri\u001b[37;118Hl\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.064326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.165838, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.26736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.369255, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.470736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.540131, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-01-31\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.641991, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.743614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.844468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.894452, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01-31\u001b[1m\u001b[38;5;0;48;5;6m Gadgets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.996018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.097619, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.188952, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Gadgets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.290866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.392364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.483448, "o", "\u001b[4;43H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;43H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-01\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.585324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.686841, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.788996, "o", "\u001b[4;50H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;50H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01\u001b[1m\u001b[38;5;0;48;5;6m 7400\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.890257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.99175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.093257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.193931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.294717, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.36863, "o", "\u001b[6;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m7400 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 7400▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.470497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.571891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.673583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.775236, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.877092, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.978622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.080177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.182019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.283573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.385055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.48658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.587937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.617837, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.719109, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.820616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.922015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.023349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.12483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.141478, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.243544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.344981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.445341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.499384, "o", "\u001b[6;62H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.601098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.702607, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.804164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.905108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.953026, "o", "\u001b[6;62H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.053947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.118277, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.218892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.293284, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.394821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.496143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.597957, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.69913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.800668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.834513, "o", "\u001b[6;2H0 Stark Enterprises 2025-01-31 Gadgets East 2025-01 7500 \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237m1 \u001b[22m\u001b[38;5;15;48;5;237m Stark Enterprises 2025-03-25 Gadgets East 2025-03\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[22m\u001b[24m\u001b[39;48;5;237m \u001b[37;7H\u001b[1m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.935864, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.037078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.140586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.241716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.342374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.44323, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.544218, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.645819, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.747314, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.848602, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.950053, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.051416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.153119, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.158637, "o", "\u001b[7;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m<\u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m<▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.260014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.360707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.462516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.563782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.665631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.767546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.869222, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.970857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.072476, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.173862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.275017, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.37639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.477963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.579372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.681267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.782127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.791533, "o", "\u001b[7;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.893075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.994708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.09607, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.197242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.297699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.383851, "o", "\u001b[7;61H\u001b[1m\u001b[38;5;0;48;5;6m 8100\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Drilled into cell: 2 rows _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.485658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.587581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.688828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.790416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.892205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.993716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.095342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.196814, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.235231, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises]\u001b[3;34H\u001b[39;49m \u001b[4;6H \u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[24m Profit\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,600\u001b[22m\u001b[38;5;15;48;5;237m 4,750\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;12HEast\u001b[8;17H\u001b[3m 2,940 4,200 1,260\u001b[9;2H\u001b[23m\u001b[38;5;8;49m─────────────────────────────────────────────────────────────────────────────────────────────────────────"] -[31.23528, "o", "─────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,100 15,730\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.33715, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.438167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.539656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.641338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.742963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.844469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.945935, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.04757, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.148996, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.250673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.352507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.454665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.556091, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.611975, "o", "\u001b[6;2HGadgets East \u001b[3m 10,850 15,600 4,750\u001b[23m \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mWidgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 14,580\u001b[1m\u001b[38;5;0;48;5;6m 24,300\u001b[22m\u001b[38;5;15;48;5;237m 9,720\u001b[23m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.714152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.816243, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.91789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.02012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.121571, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.223001, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.324736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.426455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.527682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.629295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.730933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.832574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.934164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.009081, "o", "\u001b[7;2HWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2H\u001b[1m\u001b[38;5;6;48;5;237mSprockets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 2,940\u001b[1m\u001b[38;5;0;48;5;6m 4,200\u001b[22m\u001b[38;5;15;48;5;237m 1,260\u001b[23m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.111055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.21937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.321025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.422024, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.523707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.625411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.726664, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.828292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.929129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.030341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.13176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.23305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.334358, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.435652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.518974, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HRevenue | Customer\u001b[3;34H= Stark Enterprises | Product = Sprockets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-02-20 Sprockets East 2025-02 4200\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m \u001b[7;12H \u001b[7;17H \u001b[8;2H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;30H\u001b[38;5;0;48;5;8m1\u001b[37;112H _Dri\u001b[3"] -[35.519, "o", "7;118Hl\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.620559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.722169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.823642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.924946, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.02612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.127752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.228896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.330381, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.432324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.534209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.635534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.726876, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.828227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.9292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.02562, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[1m\u001b[38;5;0;48;5;6m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.126867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.227776, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.243248, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Sprockets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.34447, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.437218, "o", "\u001b[4;45H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;45H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.538942, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.640498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.717318, "o", "\u001b[4;52H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;52H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.818793, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.920394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.021784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.122547, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.22418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.325665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.427127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.528763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.630238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.685239, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4200 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 4200▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.786586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.888352, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.989767, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.091211, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.19311, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.294318, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.395029, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.49683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.59827, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.699745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.801287, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.902386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.003913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.105799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.206374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.307735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.409433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.510898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.612475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.713387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.815573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.824365, "o", "\u001b[6;63H\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.926091, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.027453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.129047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.229876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.33153, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.4335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.535434, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.637062, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.738676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.840484, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.943155, "o", "\u001b[4;52H\u001b[1m\u001b[4m\u001b[38;5;3;49m Date_Month\u001b[24m Value\u001b[6;52H\u001b[38;5;0;48;5;6m 2025-02\u001b[22m\u001b[38;5;15;48;5;237m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.04455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.147113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.248907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.350271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.452008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.454049, "o", "\u001b[4;45H\u001b[1m\u001b[4m\u001b[38;5;3;49m Region\u001b[24m Date_Month\u001b[6;45H\u001b[38;5;0;48;5;6m East\u001b[22m\u001b[38;5;15;48;5;237m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.555938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.656672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.694792, "o", "\u001b[4;35H\u001b[1m\u001b[4m\u001b[38;5;3;49m Product\u001b[24m Region\u001b[6;35H\u001b[38;5;0;48;5;6m Sprockets\u001b[22m\u001b[38;5;15;48;5;237m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.796342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.89699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.962391, "o", "\u001b[4;24H\u001b[1m\u001b[4m\u001b[38;5;3;49m Date\u001b[24m Product\u001b[6;24H\u001b[38;5;0;48;5;6m 2025-02-20\u001b[22m\u001b[38;5;15;48;5;237m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.06307, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.164443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.207856, "o", "\u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date\u001b[6;6H\u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.308944, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.4106, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.512276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.613521, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.715215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.734956, "o", "\u001b[6;6H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6mStark Enterprises \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: Stark Enterprises▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.836886, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.938724, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.040338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.142082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.243479, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.345158, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.446823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.548419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.650021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.751645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.853167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.954401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.056002, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.157732, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.258617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.306905, "o", "\u001b[6;6H\u001b[1m\u001b[38;5;0;48;5;6m Stark Enterprises\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.40892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.510835, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.612239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.71417, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.816108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.917769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.019473, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.120249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.221745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.32364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.425537, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.528656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.630009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.730899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.831981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.933554, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.035044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.136541, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.238468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.340173, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.442019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.54362, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.617582, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.719176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.819708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.901714, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[1m\u001b[38;5;0;48;5;6m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.003506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.106764, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Sprockets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.208137, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.309835, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.344493, "o", "\u001b[4;45H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;45H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.445881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.547204, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.582215, "o", "\u001b[4;52H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;52H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.683651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.785171, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.88631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.987214, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.088752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.19032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.2922, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.393169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.494592, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.551364, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4200 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 4200▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.653106, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.75426, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.855143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.956612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.057339, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.158822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.260463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.362035, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.463667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.565019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.658576, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.75945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.861196, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.962228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.06407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.088362, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.189174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.290284, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.392056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.49337, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.595101, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.659247, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.760985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.862189, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.96398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.977937, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.079343, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.146926, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.248174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.315613, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.417369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.518587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.620531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.639076, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.740677, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.841462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.9429, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.044326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.145842, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.247478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.347982, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.449704, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.551334, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.653012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.754874, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.855501, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.957138, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.057576, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.159355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.26075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.361942, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.4634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.564497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.666245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.7679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.868507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.969901, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.071612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.172648, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.200869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.302398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.403782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.505197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.606417, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.708289, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.809292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.910682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.011963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.113483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.214542, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.31553, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.377483, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4\u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m4▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.479033, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.580766, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.63521, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.736712, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.80774, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.909121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.000933, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.10274, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.203875, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.305319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.406083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.471834, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.57245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.673931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.775416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.876873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.978402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.079364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.181199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.28257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.323021, "o", "\u001b[6;63H\u001b[1m\u001b[38;5;0;48;5;6m 4500\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.425018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.526725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.62833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.729186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.830402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.931931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.033936, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.135577, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.236664, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.33801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.363396, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises]\u001b[3;34H\u001b[39;49m \u001b[4;6H \u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[24m Profit\u001b[22m\u001b[39;49m \u001b[6;2HGadgets East \u001b[3m 10,850 15,600 4,750\u001b[23m \u001b[7;2HWidgets\u001b[7;12HEast\u001b[7;17H\u001b[3m 14,580 24,300 9,720\u001b[8;2H\u001b[23m\u001b[1m\u001b[38;5;6;48;5;237mSprockets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 2,940\u001b[1m\u001b[38;5;0;48;5;6m 4,500\u001b[22m\u001b[38;5;15;48;5;237m 1,560\u001b[23m\u001b[39;48;5;237m \u001b[9;2H\u001b[38;5;8;49m───────────────────────────────────────────────────────────────────────────────────────────────────────────"] -[59.363433, "o", "───────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,400 16,030\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.46552, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.567009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.668443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.770756, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.872967, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.974045, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.075696, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.177744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.279764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.380471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.482824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.584652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.686336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.787808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.888707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.990743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.092529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.193873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.295612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.397566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.498529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.600461, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.70169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.803645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.905272, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.007052, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.108297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.209571, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.311535, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.413278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.514977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.616556, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.718025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.819687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.920927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.022617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.039683, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.140302, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.242332, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.344261, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.352965, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.454163, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.55584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.656611, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.758111, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.859338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.955664, "o", "\u001b[?1049l"] -[63.955716, "o", "\u001b[?25h"] -[63.957068, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[63.999517, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[64.14143, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:06:33\r\n16026:% \u001b[K"] -[64.141514, "o", "\u001b[?2004h"] -[64.491198, "o", "e"] -[64.739691, "o", "\bex"] -[64.9105, "o", "i"] -[65.087513, "o", "t"] -[65.267946, "o", "\u001b[?2004l\r\r\n"] -[65.269911, "o", "\u001b]0;exit\u0007\u001b[2 q"] +{"version": 2, "width": 120, "height": 37, "idle_time_limit": 2.0, "title": "improvise \u2014 drill"} +[0.004285, "o", "\u001b[?1049h"] +[0.005635, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise \u00b7 Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m\u250c\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[38;5;8;49m\u250c Categories \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;1H\u001b[39;49m\u2502\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[3;92H_Index\u001b[3;99H(0)\u001b[3;104H\u001b[38;5;8;49m [None \u2205]\u001b[3;120H\u2502\u001b[4;1H\u001b[39;49m\u2502\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[4;92H_Dim\u001b[4;97H(0)\u001b[4;102H\u001b[38;5;8;49m [None \u2205]\u001b[4;120H\u2502\u001b[5;1H\u001b[39;49m\u2502\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02\u001b[5;88H\u001b[22m\u001b[39"] +[0.005646, "o", ";49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[5;92H_Measure\u001b[5;101H(3)\u001b[5;106H\u001b[38;5;4;49m [Col \u2194]\u001b[5;120H\u001b[38;5;8;49m\u2502\u001b[6;1H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[6;92HCustomer\u001b[6;101H(9)\u001b[6;106H\u001b[38;5;5;49m [Page \u2630\u001b[6;115H]\u001b[6;120H\u001b[38;5;8;49m\u2502\u001b[7;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[7;92HDate\u001b[7;97H(40)\u001b[7;103H\u001b[38;5;8;49m [None \u2205]\u001b[7;120H\u2502\u001b[8;1H\u001b[39;49m\u2502\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[8;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[8;92HP"] +[0.005672, "o", "roduct\u001b[8;100H(3)\u001b[8;105H\u001b[38;5;2;49m [Row \u2195]\u001b[8;120H\u001b[38;5;8;49m\u2502\u001b[9;1H\u001b[39;49m\u2502\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[9;92HRegion\u001b[9;99H(4)\u001b[9;104H\u001b[38;5;2;49m [Row \u2195]\u001b[9;120H\u001b[38;5;8;49m\u2502\u001b[10;1H\u001b[39;49m\u2502\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[10;92HDate_Month\u001b[10;103H(3)\u001b[10;108H\u001b[38;5;4;49m [Col \u2194]\u001b[10;120H\u001b[38;5;8;49m\u2502\u001b[11;1H\u001b[39;49m\u2502Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m \u001b[11;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[11;120H\u2502\u001b[12;1H\u001b[39;49m\u2502\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[12;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[12;120H\u2502\u001b[13;1H\u001b[39;49m\u2502\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m "] +[0.005686, "o", " \u001b[13;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[13;120H\u2502\u001b[14;1H\u001b[39;49m\u2502\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[14;120H\u2502\u001b[15;1H\u001b[39;49m\u2502Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[15;120H\u2502\u001b[16;1H\u001b[39;49m\u2502\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[16;88H\u001b[23m\u2502\u001b[38;5;8;49m\u2502\u001b[16;120H\u2502\u001b[17;1H\u001b[39;49m\u2502\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[17;120H\u2502\u001b[18;1H\u001b[39;49m\u2502\u001b[18;12HWest\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[18;120H\u2502\u001b[19;1H\u001b[39;49m\u2502\u001b[38;5;8;49m"] +[0.005705, "o", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[19;120H\u2502\u001b[20;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260\u001b[20;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[20;120H\u2502\u001b[21;1H\u001b[39;49m\u2502\u001b[21;88H\u2502\u001b[38;5;8;49m\u2502\u001b[21;120H\u2502\u001b[22;1H\u001b[39;49m\u2502\u001b[22;88H\u2502\u001b[38;5;8;49m\u2502\u001b[22;120H\u2502\u001b[23;1H\u001b[39;49m\u2502\u001b[23;88H\u2502\u001b[38;5;8;49m\u2502\u001b[23;120H\u2502\u001b[24;1H\u001b[39;49m\u2502\u001b[24;88H\u2502\u001b[38;5;8;49m\u2502\u001b[24;120H\u2502\u001b[25;1H\u001b[39;49m\u2502\u001b[25;88H\u2502\u001b[38;5;8;49m\u2502\u001b[25;120H\u2502\u001b[26;1H\u001b[39;49m\u2502\u001b[26;88H\u2502\u001b[38;5;8;49m\u2502\u001b[26;120H\u2502\u001b[27;1H\u001b[39;49m\u2502\u001b[27;88H\u2502\u001b[38;5;8;49m\u2502\u001b[27;120H\u2502\u001b[28;1H\u001b[39;49m\u2502\u001b[28;88H\u2502\u001b[38;5;8;49m\u2502\u001b[28;120H\u2502\u001b[29;1H\u001b[39;49m\u2502\u001b[29;88H\u2502\u001b[38;5;8;49m\u2502\u001b[29;120H\u2502\u001b[30;1H\u001b[39;49m\u2502\u001b[30;88H\u2502\u001b[38;5;8;49m\u2502\u001b[30;120H\u2502\u001b["] +[0.005729, "o", "31;1H\u001b[39;49m\u2502\u001b[31;88H\u2502\u001b[38;5;8;49m\u2502\u001b[31;120H\u2502\u001b[32;1H\u001b[39;49m\u2502\u001b[32;88H\u2502\u001b[38;5;8;49m\u2502\u001b[32;120H\u2502\u001b[33;1H\u001b[39;49m\u2502\u001b[33;88H\u2502\u001b[38;5;8;49m\u2502\u001b[33;120H\u2502\u001b[34;1H\u001b[39;49m\u2502\u001b[34;88H\u2502\u001b[38;5;8;49m\u2502\u001b[34;120H\u2502\u001b[35;1H\u001b[39;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[38;5;8;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] [_Dim \u00b7] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date \u00b7] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl\u2190\u2191\u2192\u2193:nav ai:edit R:records P:prune F:formulas C:categories V:views T:tiles [:prev-page ]:next-pag\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.108486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.211251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.314095, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.416843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.519561, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.621198, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.723934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.82714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.929945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.032686, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.135402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.238119, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.340849, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.443552, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.546299, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.608193, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index \u00b7] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES hl\u2190\u2192:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.710971, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.813682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.916404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.019111, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.12183, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.224531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.327257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.430035, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.532783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.614933, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim \u00b7] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.717672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.820416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.923164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.974926, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.077689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.180422, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.283161, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.329807, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.432537, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.534125, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.636837, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.6862, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[3;2H\u001b[22m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;19H\u001b[4m2025-01\u001b[24m 2025-02 2025-03\u001b[4;43H2025-01\u001b[4;51H2025-02\u001b[4;59H2025-03\u001b[4;67H2025-01\u001b[4;75H2025-02\u001b[5;2H\u001b[22m\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m 7,210\u001b[22m\u001b[38;5;15;48;5;237m 5,110 4,060 10,300 7,300 5,800 3,090 2,190\u001b[23m\u001b[39;48;5;237m \u001b[6;106H\u001b[38;5;8;49m [None \u2205]\u001b[39;49m \u001b[7;2H East \u001b[3m 11,410 6,440 5,670 16,300 9,200 8,100 4,890 2,760\u001b[23m \u001b[8;12HSou\u001b[8;16Hh\u001b[8;21H\u001b[3m3\u001b[8;23H85\u001b[8;26H 4,270\u001b[8;37H4\u001b[8;39H76\u001b[8;45H5\u001b[8;47H5\u001b[8;50H 6,100\u001b[8;61H6\u001b[8;63H8\u001b[8;69H1\u001b[8;71H65\u001b[8;74H 1,830\u001b[9;12H\u001b[23m"] +[3.68622, "o", "Wes\u001b[9;16H \u001b[9;18H\u001b[3m 4,690 4,970 5,250 6,700 7,100 7,500 2,010 2,130\u001b[10;2H\u001b[23mWidgets\u001b[10;12HNor\u001b[10;16Hh\u001b[10;18H\u001b[3m 12,300 6,600 8,100 20,500 11,000 13,500 8,200 4,400\u001b[11;2H\u001b[23m \u001b[11;12HEas\u001b[11;16H \u001b[11;18H\u001b[3m 15,600 9,000 7,500 26,000 15,000 12,500 10,400 6,000\u001b[12;12H\u001b[23mSou\u001b[12;16Hh\u001b[12;20H\u001b[3m10\u001b[12;23H20\u001b[12;26H 6,120\u001b[12;37H5\u001b[12;39H34\u001b[12;45H7\u001b[12;47H0\u001b[12;50H 10,200\u001b[12;60H 8\u001b[12;63H9\u001b[12;69H6\u001b[12;71H80\u001b[12;74H 4,080\u001b[13;12H\u001b[23mWes\u001b[13;16H \u001b[13;18H\u001b[3m 6,300 6,720 13,560 10,500 11,200 22,600 4,200 4,480\u001b[14;2H\u001b[23mSprockets\u001b[14;12HNor\u001b[14;16Hh\u001b[14;18H\u001b[3m 2,380 2,030\u001b[14;42H 3,400 2,900\u001b[14;66H 1,020 870\u001b[15;2H\u001b[23m \u001b[15;12HEas\u001b[15;16H \u001b[15;26H\u001b[3m 2,940 3,360\u001b[15;50H 4,200 4,800\u001b[15;74H 1,260\u001b[16;12H\u001b[23mSou\u001b[16;16Hh\u001b[16;18H\u001b[3m 1,960\u001b[16;31H17\u001b[16;34H 2,450 2,800\u001b[16;53H3\u001b[16;55H1\u001b[16;58H 3,500 840\u001b[16;77H 93\u001b[17;12H\u001b[23mWes\u001b[17;16H \u001b[17;18H\u001b[3m 2,240 2,520 2,800 3,200 3,60"] +[3.686263, "o", "0 4,000 960 1,080\u001b[18;2H\u001b[23m\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[19;2H\u001b[1m\u001b[38;5;3;49mTotal 78,140 58,890 62,850 122,200 90,900 98,000 44,060 32,010\u001b[22m\u001b[39;49m \u001b[20;2H \u001b[36;59H\u001b[1m\u001b[38;5;0;48;5;6m\u00b7] \u001b[22m\u001b[38;5;8;49m [Date \u00b7] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mCustom\u001b[37;17Hr \u2192 None \u001b[37;42H \u001b[37;49H \u001b[37;56H \u001b[37;64H \u001b[37;108H Default\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.789137, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.891999, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.994907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.097786, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.198656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.301502, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.404386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.507282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.610223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.712281, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.815179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.918075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.020969, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.123902, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.226792, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.329688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.432581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.494585, "o", "\u001b[36;48H\u001b[38;5;8;49m [Customer \u00b7] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Customer \u2192 None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.5975, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.700438, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.803306, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.906223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.009094, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.112021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.214122, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.315364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.418259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.521129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.622197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.699834, "o", "\u001b[6;2HGadgets North \u001b[3m 7,210 5,110 4,060 10,300 7,300 5,800 3,090 2,190\u001b[23m \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237m East \u001b[3m\u001b[38;5;0;48;5;6m 11,410\u001b[22m\u001b[38;5;15;48;5;237m 6,440 5,670 16,300 9,200 8,100 4,890 2,760\u001b[23m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.802772, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.904516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.007571, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.110627, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.213753, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.316634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.419878, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.521306, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.625285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.710332, "o", "\u001b[2;9H_Dril\u001b[2;15H \u2500\u001b[3;2H\u001b[38;5;5;49m [_Measure = Cost | Product = Gadgets | Region = East | Date_Month = 2025-01] \u001b[39;49m \u001b[3;104H\u001b[38;5;2;49m [Row \u2195]\u001b[39;49m \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m \u001b[4;27H Date Product Region\u001b[4;51HDate_Month +cat\u001b[4;67HCost Revenue +\u001b[4;102H\u001b[22m\u001b[38;5;4;49m [Col \u2194]\u001b[39;49m \u001b[5;106H\u001b[38;5;3;49m [Fix \u26b2]\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-01-31 Gadgets East 2025-01\u001b[38;5;8;48;5;237m \u001b[38;5;15;48;5;237m 5180\u001b[38;5;8;48;5;237m \u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m1 Wonka Industries 2025-01-23 Gadgets East 2025-01\u001b[38;5;8;49m \u001b[39;49m 6230\u001b[38;5;8;49m \u001b[39;49m \u001b[8;12H \u001b[8;18H \u001b[8;105H\u001b[38;5;3;49m [Fix \u26b2]\u001b[9;12H\u001b[39;49m \u001b[9;18H \u001b[9;104H\u001b[38;5;3;49m [Fix \u26b2]\u001b[10;2H\u001b[39;49m \u001b[10;12H \u001b[10;18H "] +[7.710352, "o", " \u001b[10;108H\u001b[38;5;3;49m [Fix \u26b2]\u001b[11;12H\u001b[39;49m \u001b[11;18H \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;2H \u001b[14;12H \u001b[14;18H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;2H \u001b[19;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;8;49m [Customer \u00b7] [Date \u00b7] Ctrl+\u2191\u2193\u2190\u2192 to move tiles\u001b[39;49m "] +[7.710465, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mDrilled into c\u001b[37;26Hll:\u001b[37;30H2\u001b[37;32Hrows\u001b[37;110H _Drill\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.812945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.915072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.017259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.119553, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.221744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.32391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.426082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.528236, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.630426, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.732596, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.834774, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.936958, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.039128, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.141296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.243466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.345629, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.447771, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.549931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.652084, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.754228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.855757, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.917157, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-01-31\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.01932, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.120658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.221855, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.324001, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.371232, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01-31\u001b[1m\u001b[38;5;0;48;5;6m Gadgets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.473507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.576085, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.676895, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.779318, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.827297, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Gadgets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.929457, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.031648, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.133996, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.23653, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.284843, "o", "\u001b[4;43H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;43H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-01\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.386221, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.488609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.591232, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.693773, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.744869, "o", "\u001b[4;50H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m +cat\u001b[6;50H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01\u001b[1m\u001b[38;5;0;48;5;6m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.847416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.950034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.052515, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.154354, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.201487, "o", "\u001b[4;61H\u001b[1m\u001b[38;5;3;49m +cat\u001b[4m Cost\u001b[6;61H\u001b[22m\u001b[24m\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m 5180\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.303477, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.406072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.508279, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.608689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.710832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.813003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.913226, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.013574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.115748, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.217931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.320089, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.421722, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.523898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.626086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.728251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.830429, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.932603, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.034762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.065883, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.167595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.269758, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.371928, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.474085, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.574606, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.67679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.778963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.881118, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[14.98327, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.084772, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.185296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.287456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.389618, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.491797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.593978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.696247, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.798955, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[15.901301, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[16.002321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] diff --git a/docs/casts/formulas.cast b/docs/casts/formulas.cast index f1a153bf..8b16dba6 100644 --- a/docs/casts/formulas.cast +++ b/docs/casts/formulas.cast @@ -1,825 +1,149 @@ -{"version": 2, "width": 120, "height": 37, "timestamp": 1775772426, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}} -[0.22204, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[0.229576, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[0.348862, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:07:06\r\n16026:% \u001b[K"] -[0.348973, "o", "\u001b[?2004h"] -[4.839346, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"] -[5.712315, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l\r\r\n"] -[5.713215, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"] -[5.754065, "o", "\u001b[?1049h"] -[5.756407, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"] -[5.756495, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "] -[5.756562, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"] -[5.756628, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"] -[5.756698, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.859411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.963254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.067489, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.170023, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.27251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.375969, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.479677, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.582112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.685348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.787542, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.866052, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"] -[6.866248, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "] -[6.866345, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.967808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.07036, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.171986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.273477, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.37608, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.477589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.579865, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.681762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.784444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.887075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.989043, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.091087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.192621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.295349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.396347, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.499325, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.601635, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.703762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.805045, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.907044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.008857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.110585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.212366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.315082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.417176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.518617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.620581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.723247, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.825828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.927309, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.029287, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.131114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.232595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.334463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.436432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.537342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.586615, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.688844, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.79055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.891459, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.992994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.094817, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.196506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.208979, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.310676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.412609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.472655, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.57519, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.621489, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.723727, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.810324, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.912742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.992715, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.095391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.197498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.225504, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.327415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.429585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.534313, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.636065, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.738586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.840575, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.942807, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.045034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.147374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.248519, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.350293, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.411399, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;20H\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;19H\u001b[3m2,940 4,200 \u001b[8;34H1,260\u001b[23m \u001b[9;2H\u001b[38;5;8;49m────────────"] -[13.411561, "o", "──────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[22m\u001b[39;49m \u001b[11;2H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HN\u001b[37;25Hne \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.513136, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.61531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.717724, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.819203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.920313, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.021863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.122842, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.224088, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.325723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.426976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.527862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.629536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.731181, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.777459, "o", "\u001b[36;103H\u001b[38;5;8;49m [Date_Month ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.879201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.980529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.081904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.182728, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.284144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.386367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.488405, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.590322, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.691881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.771463, "o", "\u001b[2;88H┐\u001b[38;5;3;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[1m\u001b[38;5;0;48;5;3m Profit = Revenue - Cost\u001b[3;120H\u001b[22m\u001b[38;5;3;49m│\u001b[4;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[4;120H│\u001b[5;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[6;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[7;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[7;120H│\u001b[8;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[8;120H│\u001b[9;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[10;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[10;120H│\u001b[11;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[11;120H│\u001b[12;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[12;120H│\u001b[13;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[13;120H│\u001b[14;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[14;120H│\u001b[15;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[15;120H│\u001b[16;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[16;120H│\u001b[17;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[17;120H│\u001b[18;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[18;120H│\u001b[19;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[19;120H│"] -[15.771523, "o", "\u001b[20;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[20;120H│\u001b[21;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[21;120H│\u001b[22;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[22;120H│\u001b[23;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[23;120H│\u001b[24;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[24;120H│\u001b[25;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[25;120H│\u001b[26;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[26;120H│\u001b[27;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[27;120H│\u001b[28;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[28;120H│\u001b[29;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[29;120H│\u001b[30;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[30;120H│\u001b[31;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[31;120H│\u001b[32;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[32;120H│\u001b[33;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[33;120H│\u001b[34;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[34;120H│\u001b[35;88H\u001b[39;49m┘\u001b[38;5;3;49m└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mF\u001b[37;7HU\u001b[37;9HAS Date_Month\u001b[37;24H→ None\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.873015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.974298, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.075518, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.17702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.278763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.380421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.482397, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.584035, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.68617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.788334, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.890119, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.992122, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.09391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.195828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.297212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.399072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.500797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.602599, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.704051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.806336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.90867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.01054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.112401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.134611, "o", "\u001b[34;90H\u001b[38;5;3;49m┄ Enter formula (Name = expr)\u001b[37;1H\u001b[1m\u001b[38;5;6;48;5;235mformula: ▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.236595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.338804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.440583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.541464, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.642436, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.656428, "o", "\u001b[37;10H\u001b[1m\u001b[38;5;6;48;5;235mM▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.757466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.858857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.957266, "o", "\u001b[37;11H\u001b[1m\u001b[38;5;6;48;5;235ma▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.058764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.104126, "o", "\u001b[37;12H\u001b[1m\u001b[38;5;6;48;5;235mr▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.206143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.254003, "o", "\u001b[37;13H\u001b[1m\u001b[38;5;6;48;5;235mg▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.355945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.40299, "o", "\u001b[37;14H\u001b[1m\u001b[38;5;6;48;5;235mi▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.505037, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.550273, "o", "\u001b[37;15H\u001b[1m\u001b[38;5;6;48;5;235mn▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.651795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.731805, "o", "\u001b[37;16H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.833103, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.88046, "o", "\u001b[37;17H\u001b[1m\u001b[38;5;6;48;5;235m=▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.982487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.0286, "o", "\u001b[37;18H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.129755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.180513, "o", "\u001b[37;19H\u001b[1m\u001b[38;5;6;48;5;235m1▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.282654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.334596, "o", "\u001b[37;20H\u001b[1m\u001b[38;5;6;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.436242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.484785, "o", "\u001b[37;21H\u001b[1m\u001b[38;5;6;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.58654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.664649, "o", "\u001b[37;22H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.765442, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.867084, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.96863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.991979, "o", "\u001b[37;23H\u001b[1m\u001b[38;5;6;48;5;235m*▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.09353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.161257, "o", "\u001b[37;24H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.263347, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.364397, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.465102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.566609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.636594, "o", "\u001b[37;25H\u001b[1m\u001b[38;5;6;48;5;235mP▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.73766, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.839314, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.930228, "o", "\u001b[37;26H\u001b[1m\u001b[38;5;6;48;5;235mr▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.030863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.080686, "o", "\u001b[37;27H\u001b[1m\u001b[38;5;6;48;5;235mo▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.1822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.232387, "o", "\u001b[37;28H\u001b[1m\u001b[38;5;6;48;5;235mf▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.334144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.432432, "o", "\u001b[37;29H\u001b[1m\u001b[38;5;6;48;5;235mi▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.534534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.602656, "o", "\u001b[37;30H\u001b[1m\u001b[38;5;6;48;5;235mt▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.704246, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.749303, "o", "\u001b[37;31H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.850361, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.900622, "o", "\u001b[37;32H\u001b[1m\u001b[38;5;6;48;5;235m/▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.001894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.071145, "o", "\u001b[37;33H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.172849, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.273367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.374896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.379148, "o", "\u001b[37;34H\u001b[1m\u001b[38;5;6;48;5;235mR▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.480866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.582066, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.684013, "o", "\u001b[37;35H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.784848, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.834019, "o", "\u001b[37;36H\u001b[1m\u001b[38;5;6;48;5;235mv▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.935533, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.000423, "o", "\u001b[37;37H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.101663, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.187318, "o", "\u001b[37;38H\u001b[1m\u001b[38;5;6;48;5;235mn▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.288471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.374963, "o", "\u001b[37;39H\u001b[1m\u001b[38;5;6;48;5;235mu▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.47588, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.578051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.679745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.781518, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.883965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.985622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.087356, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.189121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.290385, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.392349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.493825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.595221, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.696678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.798409, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.900396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.001989, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.103513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.205321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.280519, "o", "\u001b[37;40H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.382821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.48443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.586182, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.68818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.790294, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.892088, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.958354, "o", "\u001b[4;39H\u001b[1m\u001b[38;5;3;49m Margin\u001b[4;90H\u001b[22m\u001b[38;5;2;49m Margin = 100 * Profit / Rev…\u001b[6;39H\u001b[3m\u001b[38;5;15;48;5;237m 30\u001b[7;39H\u001b[39;49m 40\u001b[8;39H 30\u001b[10;39H\u001b[23m\u001b[1m\u001b[38;5;3;49m 100\u001b[34;90H\u001b[22m\u001b[39;49m \u001b[37;1H\u001b[38;5;0;48;5;8m FORMULAS Formula added Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.060639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.162564, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.264269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.36574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.467341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.568744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.670688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.7723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.873949, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.975642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.077335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.178827, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.280529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.382127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.48383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.586228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.687929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.790028, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.823665, "o", "\u001b[2;89H\u001b[38;5;8;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;89H│\u001b[38;5;2;49m Profit = Revenue - Cost\u001b[3;120H\u001b[38;5;8;49m│\u001b[4;89H│\u001b[4;120H│\u001b[5;89H│\u001b[5;120H│\u001b[6;89H│\u001b[6;120H│\u001b[7;89H│\u001b[7;120H│\u001b[8;89H│\u001b[8;120H│\u001b[9;89H│\u001b[9;120H│\u001b[10;89H│\u001b[10;120H│\u001b[11;89H│\u001b[11;120H│\u001b[12;89H│\u001b[12;120H│\u001b[13;89H│\u001b[13;120H│\u001b[14;89H│\u001b[14;120H│\u001b[15;89H│\u001b[15;120H│\u001b[16;89H│\u001b[16;120H│\u001b[17;89H│\u001b[17;120H│\u001b[18;89H│\u001b[18;120H│\u001b[19;89H│\u001b[19;120H│\u001b[20;89H│\u001b[20;120H│\u001b[21;89H│\u001b[21;120H│\u001b[22;89H│\u001b[22;120H│\u001b[23;89H│\u001b[23;120H│\u001b[24;89H│\u001b[24;120H│\u001b[25;89H│\u001b[25;120H│\u001b[26;89H│\u001b[26;120H│\u001b[27;89H│\u001b[27;120H│\u001b[28;89H│\u001b[28;120H│\u001b[29;89H│\u001b[29;120H│\u001b[30;89H│\u001b[30;120H│\u001b[31;89H│\u001b[31;120H│\u001b[32;89H│\u001b[32;120H│\u001b[33;89H│\u001b[33;120H│\u001b[34;89H│\u001b[34;120H│\u001b[35;89H└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mN\u001b[37;7HA\u001b[37;9H Formula \u001b[37;20Hdde\u001b[37;24H \u001b[39m\u001b[49m\u001b[59"] -[28.82381, "o", "m\u001b[0m\u001b[?25l"] -[28.925823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.028152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.129687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.23137, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.333203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.434603, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.536387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.638191, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.74009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.841736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.943467, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.045355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.146794, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.248304, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.350033, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.452178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.553839, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.655545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.757685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.859652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.961651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.063178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.164853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.26659, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.368454, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.469947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.571386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.673047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.774632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.83504, "o", "\u001b[2;89H\u001b[38;5;3;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;89H│\u001b[1m\u001b[38;5;0;48;5;3m Profit = Revenue - Cost\u001b[3;120H\u001b[22m\u001b[38;5;3;49m│\u001b[4;89H│\u001b[4;120H│\u001b[5;89H│\u001b[5;120H│\u001b[6;89H│\u001b[6;120H│\u001b[7;89H│\u001b[7;120H│\u001b[8;89H│\u001b[8;120H│\u001b[9;89H│\u001b[9;120H│\u001b[10;89H│\u001b[10;120H│\u001b[11;89H│\u001b[11;120H│\u001b[12;89H│\u001b[12;120H│\u001b[13;89H│\u001b[13;120H│\u001b[14;89H│\u001b[14;120H│\u001b[15;89H│\u001b[15;120H│\u001b[16;89H│\u001b[16;120H│\u001b[17;89H│\u001b[17;120H│\u001b[18;89H│\u001b[18;120H│\u001b[19;89H│\u001b[19;120H│\u001b[20;89H│\u001b[20;120H│\u001b[21;89H│\u001b[21;120H│\u001b[22;89H│\u001b[22;120H│\u001b[23;89H│\u001b[23;120H│\u001b[24;89H│\u001b[24;120H│\u001b[25;89H│\u001b[25;120H│\u001b[26;89H│\u001b[26;120H│\u001b[27;89H│\u001b[27;120H│\u001b[28;89H│\u001b[28;120H│\u001b[29;89H│\u001b[29;120H│\u001b[30;89H│\u001b[30;120H│\u001b[31;89H│\u001b[31;120H│\u001b[32;89H│\u001b[32;120H│\u001b[33;89H│\u001b[33;120H│\u001b[34;89H│\u001b[34;120H│\u001b[35;89H└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mF\u001b[37;7HU\u001b[37;9HAS Formul\u001b[37;20H ad\u001b[37;24Hed\u001b"] -[31.83521, "o", "[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.93635, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.038453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.140305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.24179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.343021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.444953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.546884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.648532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.750338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.759627, "o", "\u001b[2;88H────────────────────────────────┐\u001b[3;88H \u001b[3;120H│\u001b[4;88H │\u001b[5;88H\u001b[38;5;8;49m────────────────────────────────\u001b[39;49m│\u001b[6;88H\u001b[39;48;5;237m \u001b[39;49m│\u001b[7;88H \u001b[7;120H│\u001b[8;88H \u001b[8;120H│\u001b[9;88H\u001b[38;5;8;49m────────────────────────────────\u001b[39;49m│\u001b[10;88H \u001b[10;120H│\u001b[11;88H \u001b[11;120H│\u001b[12;88H \u001b[12;120H│\u001b[13;88H \u001b[13;120H│\u001b[14;88H \u001b[14;120H│\u001b[15;88H \u001b[15;120H│\u001b[16;88H \u001b[16;120H│\u001b[17;88H \u001b[17;120H│\u001b[18;88H \u001b[18;120H│\u001b[19;88H \u001b[19;120H│\u001b[20;88H \u001b[20;120H│\u001b[21;88H \u001b[21;120H│\u001b[22;88H \u001b[22;120H│\u001b[23;88H \u001b[23;120H│\u001b[24;88H \u001b[24;120H│\u001b[25;88H \u001b[25;120H│\u001b[26;88H \u001b[26;120H│\u001b[27;88H \u001b[27;120H│\u001b[28;88H \u001b[28;120H│\u001b[29;88H \u001b[29;120H│\u001b[30;88H \u001b[30;120H│\u001b[31;88H \u001b[3"] -[32.759661, "o", "1;120H│\u001b[32;88H \u001b[32;120H│\u001b[33;88H \u001b[33;120H│\u001b[34;88H \u001b[34;120H│\u001b[35;88H────────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mN\u001b[37;7HA\u001b[37;9H Formula \u001b[37;20Hdde\u001b[37;24H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.861687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.963943, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.065622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.167382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.269343, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.370929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.472205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.574072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.675496, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.77751, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.878963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.981217, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.082469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.18416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.285853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.387215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.488822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.590148, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.691868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.793678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.894883, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.996624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.098854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.199728, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.30097, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.402643, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.504418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.605968, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.707659, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.813626, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.915915, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.017878, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.119781, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.221525, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.32263, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.424295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.525566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.627568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.729398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.830794, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.9326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.956636, "o", "\u001b[4;17H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;17H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.057713, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.159175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.260616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.362003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.45116, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Revenue\u001b[4m Profit\u001b[6;24H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 15,500\u001b[1m\u001b[38;5;0;48;5;6m 4,650\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.553269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.654984, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.756544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.855164, "o", "\u001b[4;32H\u001b[1m\u001b[38;5;3;49m Profit\u001b[4m Margin\u001b[6;32H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 4,650\u001b[1m\u001b[38;5;0;48;5;6m 30\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.95702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.05867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.16023, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.261882, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.364382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.465665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.568143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.66929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.770404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.872114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.9737, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.075442, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.177113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.279174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.381483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.483532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.585266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.68695, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.788157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.890074, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.991552, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.092834, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.194587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.283998, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Formula added Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.386092, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.487642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.5894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.691098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.792446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.894254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.995483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.048638, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.150203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.252282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.354288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.363494, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.465135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.567014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.652538, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.753272, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.854376, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.886425, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.988321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.089981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.191526, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.210178, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.312329, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.414458, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.516377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.523473, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.624892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.726668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.828869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.929605, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.969045, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.071061, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.172731, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.274463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.375888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.47684, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.578927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.680089, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.759548, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;8;49m [Date_Month ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.86162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.963419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.064814, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.165578, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.266915, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.368077, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.467449, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.569373, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.6706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.772271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.873952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.975198, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.067862, "o", "\u001b[4;17H \u001b[1m\u001b[38;5;3;49m Cost Revenue Profit\u001b[4m Margin\u001b[6;2H\u001b[24m\u001b[38;5;6;48;5;237m2025-03-25 Gadgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 5,670 8,100 2,430\u001b[1m\u001b[38;5;0;48;5;6m 30\u001b[7;2H\u001b[22m\u001b[23m\u001b[39;49m2025-03-05 Widgets East \u001b[3m 7,500 12,500\u001b[7;44H 5,000 40\u001b[8;2H\u001b[23m2025-01-31 Gadgets East \u001b[3m 5,180 7,400\u001b[8;44H 2,220 30\u001b[9;2H\u001b[23m2025-02-20 Sprockets East \u001b[3m 2,940 4,200 1,260 30\u001b[23m \u001b[10;2H2025-01-17 Widgets East \u001b[3m 7,080 11,800 4,720 40\u001b[11;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630 170"] -[45.067912, "o", "\u001b[36;71H\u001b[38;5;0;48;5;6mRow] \u001b[22m\u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;10H\u001b[38;5;0;48;5;5mDate → Row \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.169513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.271027, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.372769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.47433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.575726, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.677188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.779086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.880875, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.982323, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.084014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.185947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.287438, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.388634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.490413, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.59213, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.693451, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.795674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.897368, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.999478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.100406, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.202354, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.303499, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.405377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.507349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.608859, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.710466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.769032, "o", "\u001b[36;64H\u001b[38;5;2;49m [Date Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date → Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.870773, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.973227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.074952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.176627, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.278078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.379913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.48167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.583195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.68488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.786081, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.888008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.989799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.090852, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.192203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.293577, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.395558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.497376, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.598701, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.700034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.800738, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.902188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.004731, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.10652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.208391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.310331, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.412538, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.514326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.616773, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.717698, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.820295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.921874, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.023865, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.125694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.227335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.328825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.430927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.479132, "o", "\u001b[4;43H\u001b[1m\u001b[4m\u001b[38;5;3;49m Profit\u001b[24m Margin\u001b[6;43H\u001b[3m\u001b[38;5;0;48;5;6m 2,430\u001b[22m\u001b[38;5;15;48;5;237m 30\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.581056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.682894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.784319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.886355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.988029, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.09004, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.191833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.294121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.396186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.447223, "o", "\u001b[4;35H\u001b[1m\u001b[4m\u001b[38;5;3;49m Revenue\u001b[24m Profit\u001b[6;35H\u001b[3m\u001b[38;5;0;48;5;6m 8,100\u001b[22m\u001b[38;5;15;48;5;237m 2,430\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.548933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.650639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.753129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.855032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.956843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.992371, "o", "\u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue\u001b[6;28H\u001b[3m\u001b[38;5;0;48;5;6m 5,670\u001b[22m\u001b[38;5;15;48;5;237m 8,100\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.093934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.195476, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.297821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.39909, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.5012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.603706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.705669, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.807421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[53.909072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.010788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.113124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.214937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.315779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.401838, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HCost | Customer = Stark Enterprises | Date = 2025-03-25 | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date\u001b[4;36HProduct\u001b[4;44HRegion\u001b[4;51HD\u001b[4;53Hte_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-03-25 Gadgets East 2025-03 5670\u001b[7;2H\u001b[39;49m \u001b[7;13H \u001b[7;23H \u001b[7;28H \u001b[8;2H \u001b[8;13H \u001b[8;23H \u001b[8;28H \u001b[9;2H \u001b[9;13H \u001b[9;23H \u001b[9;28H \u001b[10;2H \u001b[10;13H \u001b[10;23H \u001b[10;28H \u001b[11;2H \u001b[12;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] [Date Pag] [Produc"] -[54.402009, "o", "t Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;12H\u001b[38;5;0;48;5;8mrilled into\u001b[37;24Hcell:\u001b[37;30H1\u001b[37;32Hrows\u001b[37;110H _Drill\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.503698, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.605285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.706675, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.807631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[54.909386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.011169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.113111, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.214654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.316369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.417789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.51931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.620988, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.722632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.823944, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[55.925315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.027107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.128956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.230678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.332344, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.433936, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.535729, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.637293, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.738954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.840527, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[56.942072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.043456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.150712, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises] \u001b[39;49m \u001b[4;6H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[4;36H\u001b[24mRevenue\u001b[4;44HProfit\u001b[4;51HM\u001b[4;53Hrgin\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237m2025-03-25 Gadgets East \u001b[3m\u001b[38;5;0;48;5;6m 5,670\u001b[22m\u001b[38;5;15;48;5;237m 8,100 2,430 30\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m2025-03-05\u001b[7;13HWidgets\u001b[7;23HEast\u001b[7;28H\u001b[3m 7,500 12,500 5,000 40\u001b[8;2H\u001b[23m2025-01-31\u001b[8;13HGadgets\u001b[8;23HEast\u001b[8;28H\u001b[3m 5,180 7,400 2,220 30\u001b[9;2H\u001b[23m2025-02-20\u001b[9;13HSprockets\u001b[9;23HEast\u001b[9;28H\u001b[3m 2,940 4,200 1,260 30\u001b[10;2H\u001b[23m2025-01-17\u001b[10;13HWidgets\u001b[10;23HEast\u001b[10;28H\u001b[3m 7,080 11,800 4,720 40\u001b[11;2H\u001b[23m\u001b[38;5;8;49m─────────────────────────────────────────────────────────────────────"] -[57.150756, "o", "─────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630 170\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;2;49m [Date Row] [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.252651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.354764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.455662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.557383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.658952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.760706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.862365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[57.964025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.066018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.117152, "o", "\u001b[3;16H\u001b[38;5;5;49moylent Ltd] \u001b[39;49m \u001b[4;28H \u001b[4;31H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit Margin\u001b[6;8H\u001b[38;5;6;48;5;237m2\u001b[6;11H2\u001b[6;13HSprockets\u001b[6;23HSou\u001b[6;27Hh \u001b[6;30H\u001b[3m\u001b[38;5;0;48;5;6m 2,170\u001b[6;38H\u001b[22m\u001b[38;5;15;48;5;237m 3,1\u001b[6;43H0\u001b[6;45H 930\u001b[6;55H 30\u001b[7;10H\u001b[23m\u001b[39;49m18\u001b[7;13HGa\u001b[7;23HSou\u001b[7;27Hh \u001b[7;30H\u001b[3m 4,760\u001b[7;37H 6,8\u001b[7;43H0\u001b[7;45H 2,\u001b[7;49H40\u001b[7;55H 30\u001b[8;10H\u001b[23m14\u001b[8;23HSou\u001b[8;27Hh \u001b[8;30H\u001b[3m 3,\u001b[8;34H50\u001b[8;38H 5,5\u001b[8;43H0\u001b[8;45H 1,650\u001b[8;55H 30\u001b[9;10H\u001b[23m03\u001b[9;13HWidgets \u001b[9;23HSou\u001b[9;27Hh \u001b[9;30H\u001b[3m 6,120\u001b[9;38H10,2\u001b[9;43H0\u001b[9;45H 4,080\u001b[9;55H 40\u001b[10;10H\u001b[23m09\u001b[10;23HSou\u001b[10;27Hh \u001b[10;30H\u001b[3m 5,\u001b[10;34H80\u001b[10;37H 9,8\u001b[10;43H0\u001b[10;45H 3,920\u001b[10;55H 40\u001b[12;29H\u001b[23m\u001b[1m\u001b[38;5;3;49m 22,\u001b[12;34H80\u001b[12;37H 35,4\u001b[12;43H0 12,620\u001b[12;54H 170\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.218907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.320446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.422194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.523868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.625832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.727316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.828823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.871774, "o", "\u001b[3;15H\u001b[38;5;5;49mW\u001b[3;17Hnka Industries] \u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;31HCost\u001b[24m Revenue Profit Margin\u001b[22m\u001b[39;49m \u001b[6;8H\u001b[1m\u001b[38;5;6;48;5;237m1\u001b[6;11H3\u001b[6;13HGadgets \u001b[6;23HEas\u001b[6;27H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[6;30H6,230\u001b[22m\u001b[38;5;15;48;5;237m \u001b[6;38H8,90\u001b[6;43H \u001b[6;45H2,670 \u001b[6;55H30\u001b[23m\u001b[39;48;5;237m \u001b[7;8H\u001b[39;49m1\u001b[7;10H06\u001b[7;13HWi\u001b[7;23HEas\u001b[7;27H \u001b[3m \u001b[7;30H8,520 \u001b[7;37H14,20\u001b[7;43H \u001b[7;45H5,680 \u001b[7;55H40\u001b[23m \u001b[8;8H2\u001b[8;11H0\u001b[8;13HWi\u001b[8;23HEas\u001b[8;27H \u001b[3m \u001b[8;30H9,000 \u001b[8;37H15,00\u001b[8;43H \u001b[8;45H6,000 \u001b[8;55H40\u001b[23m \u001b[9;8H3\u001b[9;10H14\u001b[9;13HSprockets\u001b[9;23HEas\u001b[9;27H \u001b[3m \u001b[9;30H3,360 \u001b[9;38H4,80\u001b[9;43H \u001b[9;45H1,440 \u001b[9;55H30\u001b[23m \u001b[10;8H2\u001b[10;10H28\u001b[10;13HGa\u001b[10;23HEas\u001b[10;27H \u001b[3m \u001b[10;30H6,440 \u001b[10;38H9,20\u001b[10;43H \u001b[10;45H2,760 \u001b[10;55H30\u001b[23m \u001b[12;29H\u001b[1m\u001b[38;5;3;49m33,550 \u001b[12;37H52,10\u001b[12;43H 18,550 \u001b[12;54H170\u001b[22m\u001b[39;49m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[58.973652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.07548, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.177244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.278352, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.380072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.481511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.596012, "o", "\u001b[3;15H\u001b[38;5;5;49mCyberdy\u001b[3;23He Syst\u001b[3;30Hms] \u001b[6;8H\u001b[1m\u001b[38;5;6;48;5;237m3\u001b[6;10H12\u001b[6;23HWe\u001b[6;30H\u001b[3m\u001b[38;5;0;48;5;6m5\u001b[6;33H5\u001b[6;38H\u001b[22m\u001b[38;5;15;48;5;237m7\u001b[6;40H5\u001b[6;47H25\u001b[7;8H\u001b[23m\u001b[39;49m3\u001b[7;10H30\u001b[7;23HWe\u001b[7;30H\u001b[3m5\u001b[7;32H88\u001b[7;37H 9\u001b[7;40H8\u001b[7;45H3\u001b[7;47H92\u001b[8;8H\u001b[23m1\u001b[8;11H9\u001b[8;13HGa\u001b[8;23HWe\u001b[8;30H\u001b[3m4\u001b[8;32H69\u001b[8;37H 6\u001b[8;40H7\u001b[8;45H2\u001b[8;48H1\u001b[8;55H3\u001b[9;8H\u001b[23m2\u001b[9;10H2\u001b[9;23HWe\u001b[9;30H\u001b[3m2\u001b[9;32H52\u001b[9;38H3\u001b[9;40H6\u001b[9;47H08\u001b[10;10H\u001b[23m06\u001b[10;13HWi\u001b[10;23HWe\u001b[10;32H\u001b[3m72\u001b[10;37H11\u001b[10;45H4\u001b[10;47H48\u001b[10;55H4\u001b[12;29H\u001b[23m\u001b[1m\u001b[38;5;3;49m25\u001b[12;32H06\u001b[12;37H38\u001b[12;40H8\u001b[12;45H3\u001b[12;47H74\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.697594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.798754, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[59.901337, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.002239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.104595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.206801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.308609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.324926, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.42746, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.529292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.631466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.733806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.835985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[60.937904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.039574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.141223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.158547, "o", "\u001b[4;28H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;28H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 5,250\u001b[1m\u001b[38;5;0;48;5;6m 7,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.260014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.361614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.463241, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.565458, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.66735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.769039, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.870889, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[61.972547, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.073955, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.174929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.276844, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.378546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.480073, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.581655, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.683563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.710581, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Drilled into cell: 1 rows Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.812801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[62.914448, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.016597, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.118395, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.141392, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.243176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.344884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.447866, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.548899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.650249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.717023, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.818713, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[63.919536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.02132, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.122911, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.184957, "o", "\u001b[3;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;28H\u001b[22m\u001b[24m\u001b[39;49m \u001b[4;30H\u001b[1m\u001b[38;5;3;49mStark Enterprises\u001b[4m Soylent Ltd\u001b[24m Wonka Industries Cyberdyne Systems Acme Corp\u001b[6;8H\u001b[38;5;6;48;5;237m1\u001b[6;10H23\u001b[6;23HEa\u001b[6;28H \u001b[22m\u001b[3m\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;15;48;5;237m 6,230\u001b[38;5;8;48;5;237m \u001b[7;10H\u001b[23m\u001b[39;49m25\u001b[7;13HGa\u001b[7;23HEa\u001b[7;28H \u001b[7;30H\u001b[3m \u001b[7;38H 5,670\u001b[38;5;8;49m \u001b[8;11H\u001b[23m\u001b[39;49m1\u001b[8;13HWi\u001b[8;28H \u001b[3m\u001b[38;5;8;49m \u001b[9;8H\u001b[23m\u001b[39;49m3\u001b[9;10H12\u001b[9;13HGadgets \u001b[9;28H \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,250\u001b[38;5;8;49m \u001b[10;8H\u001b[23m\u001b[39;49m1\u001b[10;23HEa\u001b[10;28H \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8"] -[64.185061, "o", ",520\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m2025-01-15 Widgets North \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 7,200\u001b[23m \u001b[12;2H2025-03-30 Widgets West \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[13;2H\u001b[23m\u001b[39;49m2025-03-10\u001b[13;13HWidgets\u001b[13;23HNorth\u001b[13;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,100\u001b[14;2H\u001b[23m2025-03-05\u001b[14;13HWidgets\u001b[14;23HEast\u001b[14;29H\u001b[3m 7,500\u001b[38;5;8;49m \u001b[15;2H\u001b[23m\u001b[39;49m2025-01-19\u001b[15;13HGadgets\u001b[15;23HWest\u001b[15;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,690\u001b[38;5;8;49m \u001b[16;2H\u001b[23m\u001b[39;49m2025-03-28\u001b[16;13HSprockets\u001b[16;23HSouth\u001b[16;29H\u001b[3m\u001b[38;5;8;49m \u001b[17;2H\u001b[23m\u001b[39;49m"] -[64.185124, "o", "2025-01-22\u001b[17;13HWidgets\u001b[17;23HNorth\u001b[17;29H\u001b[3m\u001b[38;5;8;49m \u001b[18;2H\u001b[23m\u001b[39;49m2025-01-30\u001b[18;13HGadgets\u001b[18;23HNorth\u001b[18;29H\u001b[3m\u001b[38;5;8;49m \u001b[19;2H\u001b[23m\u001b[39;49m2025-01-12\u001b[19;13HSprockets\u001b[19;23HNorth\u001b[19;29H\u001b[3m\u001b[38;5;8;49m \u001b[20;2H\u001b[23m\u001b[39;49m2025-02-15\u001b[20;13HGadgets\u001b[20;23HSouth\u001b[20;29H\u001b[3m\u001b[38;5;8;49m \u001b[21;2H\u001b[23m\u001b[39;49m2025-03-07\u001b[21;13HWidgets\u001b[21;23HSouth\u001b[21;29H\u001b[3m\u001b[38;5;8;49m \u001b[22;2H\u001b[23m\u001b[39;49m2025-01-31\u001b[22;13HGadgets\u001b[22;23HEast\u001b[22;29H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[23;2H\u001b[23m\u001b[39;49m2025-01-27\u001b[23;13HSprockets\u001b[23;23HWest\u001b[23;29H\u001b[3m\u001b[38;5;8;49m "] -[64.185176, "o", " \u001b[24;2H\u001b[23m\u001b[39;49m2025-01-28\u001b[24;13HSprockets\u001b[24;23HSouth\u001b[24;29H\u001b[3m\u001b[38;5;8;49m \u001b[25;2H\u001b[23m\u001b[39;49m2025-03-03\u001b[25;13HWidgets\u001b[25;23HWest\u001b[25;29H\u001b[3m\u001b[38;5;8;49m \u001b[26;2H\u001b[23m\u001b[39;49m2025-03-19\u001b[26;13HGadgets\u001b[26;23HNorth\u001b[26;29H\u001b[3m\u001b[38;5;8;49m \u001b[27;2H\u001b[23m\u001b[39;49m2025-02-05\u001b[27;13HWidgets\u001b[27;23HNorth\u001b[27;29H\u001b[3m\u001b[38;5;8;49m \u001b[28;2H\u001b[23m\u001b[39;49m2025-02-22\u001b[28;13HSprockets\u001b[28;23HSouth\u001b[28;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,170\u001b[38;5;8;49m \u001b[29;2H\u001b[23m\u001b[39;49m2025-02-25\u001b[29;13HSprockets\u001b[29;23HNorth\u001b[29;29H\u001b[3m\u001b[38;5;8;49m "] -[64.185201, "o", "\u001b[30;2H\u001b[23m\u001b[39;49m2025-03-18\u001b[30;13HGadgets\u001b[30;23HSouth\u001b[30;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,760\u001b[38;5;8;49m \u001b[31;2H\u001b[23m\u001b[39;49m2025-02-20\u001b[31;13HSprockets\u001b[31;23HEast\u001b[31;29H\u001b[3m 2,940\u001b[38;5;8;49m \u001b[32;2H\u001b[23m\u001b[39;49m2025-02-24\u001b[32;13HSprockets\u001b[32;23HWest\u001b[32;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,520\u001b[38;5;8;49m \u001b[33;2H\u001b[23m\u001b[39;49m2025-01-14\u001b[33;13HGadgets\u001b[33;23HSouth\u001b[33;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,850\u001b[38;5;8;49m \u001b[34;2H\u001b[23m\u001b[39;49m2025-03-21\u001b[34;13HSprockets\u001b[34;23HWest\u001b[34;29H\u001b[3m\u001b[38;5;8;49m \u001b[36;59H\u001b[23m\u001b[1m\u001b[38;5;0;48;5;6mCol\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mCustomer → Col \u001b[37;29H \u001b[37;31H \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.293903, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.403428, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.513866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.623157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.734355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.844972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[64.953157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.062288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.171044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.277506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.385493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.492312, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.604282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.71478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.827714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[65.934953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.041651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.148548, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.256987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.364621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.474381, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.58384, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.710711, "o", "\u001b[36;48H\u001b[38;5;4;49m [Customer Col] \u001b[1m\u001b[38;5;0;48;5;6m [Date Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.818615, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[66.925978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.034783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.141116, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.247723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.356797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.464647, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.575, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.682954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.789934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.86314, "o", "\u001b[3;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[3;32HCost\u001b[3;43H \u001b[3;104H \u001b[4;18H\u001b[24m Stark Enterprises\u001b[4m Soylent Ltd\u001b[24m Wonka Industries\u001b[4;66HCyberdyn\u001b[4;75H Syst\u001b[4;81Hms Acme Corp Oceanic Airlines\u001b[6;2H\u001b[38;5;6;48;5;237mGadgets North \u001b[22m\u001b[3m\u001b[38;5;8;48;5;237m \u001b[6;36H\u001b[1m\u001b[38;5;0;48;5;6m \u001b[6;48H\u001b[22m\u001b[38;5;8;48;5;237m \u001b[6;83H\u001b[38;5;15;48;5;237m 9,450\u001b[6;104H\u001b[38;5;8;48;5;237m \u001b[7;2H\u001b[23m\u001b[39;49m East \u001b[3m \u001b[7;30H10,850\u001b[38;5;8;49m \u001b[7;48H\u001b[39;49m 12,670\u001b[7;104H\u001b[38;5;8;49m \u001b[8;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[8;36H\u001b[39;49m 8,610\u001b[8;104H\u001b[38;5;8;49m \u001b[9;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[9;65H\u001b[39;49m \u001b[9;78H9,940\u001b[38;5;8;49m \u001b[39;49m 4,970\u001b[10;2H\u001b[23mWidgets North \u001b[3m\u001b[38;5;8;49m \u001b[10;59H \u001b[10;83H\u001b[39;49m 15,300\u001b[10;104H\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m East \u001b["] -[67.863188, "o", "3m 14,580\u001b[11;48H 17,520\u001b[11;94H\u001b[38;5;8;49m \u001b[12;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[12;36H\u001b[39;49m 12,000\u001b[12;76H\u001b[38;5;8;49m \u001b[12;104H \u001b[13;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[13;65H\u001b[39;49m 12,600\u001b[13;93H \u001b[13;99H 13,980\u001b[14;2H\u001b[23mSprockets North \u001b[3m\u001b[38;5;8;49m \u001b[14;104H \u001b[15;2H\u001b[23m\u001b[39;49m East \u001b[3m 2,940\u001b[15;48H 3,360\u001b[15;76H\u001b[38;5;8;49m \u001b[15;104H \u001b[16;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[16;36H\u001b[39;49m 2,170\u001b[16;104H\u001b[38;5;8;49m \u001b[17;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[17;65H\u001b[39;49m 2,520\u001b[17;93H 5,040\u001b[18;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────"] -[67.863311, "o", "────────────────────────────────────────────────────────\u001b[19;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990\u001b[20;2H\u001b[22m\u001b[39;49m \u001b[20;13H \u001b[20;23H \u001b[20;29H \u001b[21;2H \u001b[21;13H \u001b[21;23H \u001b[21;29H \u001b[22;2H \u001b[22;13H \u001b[22;23H \u001b[22;29H \u001b[23;2H \u001b[23;13H \u001b[23;23H \u001b[23;29H \u001b[24;2H \u001b[24;13H \u001b[24;23H \u001b[24;29H \u001b[25;2H \u001b[25;13H \u001b[25;23H \u001b[25;29H "] -[67.863477, "o", " \u001b[26;2H \u001b[26;13H \u001b[26;23H \u001b[26;29H \u001b[27;2H \u001b[27;13H \u001b[27;23H \u001b[27;29H \u001b[28;2H \u001b[28;13H \u001b[28;23H \u001b[28;29H \u001b[29;2H \u001b[29;13H \u001b[29;23H \u001b[29;29H \u001b[30;2H \u001b[30;13H \u001b[30;23H \u001b[30;29H \u001b[31;2H \u001b[31;13H \u001b[31;23H \u001b[31;29H \u001b[32;2H \u001b[32;13H \u001b[32;23H \u001b[32;29H \u001b[33;2H \u001b[33;13H \u001b[33;23H \u001b[33;29H "] -[67.86354, "o", " \u001b[34;2H \u001b[34;13H \u001b[34;23H \u001b[34;29H \u001b[36;71H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate → None \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[67.967252, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.071254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.175017, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.280402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.384869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.490994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.594645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.698504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.802472, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[68.906976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.010324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.114855, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.218682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.321925, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.425338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.530655, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.635327, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.739063, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.835646, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[69.939257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.042787, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.146403, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.221065, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.325462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.429923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.53483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.63921, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.699314, "o", "\u001b[3;12H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[3;26HCost\u001b[3;32H \u001b[3;110H \u001b[4;12H\u001b[24m Stark\u001b[4;19HEnterpris\u001b[4;29Hs\u001b[4m Soylent Ltd\u001b[24m Wonka\u001b[4;49HIndustries Cyb\u001b[4;64Hrdyne Syst\u001b[4;75Hms Acme Corp Oceanic Airlines Umbrella Co\u001b[6;12H\u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m \u001b[6;37H8,610\u001b[22m\u001b[38;5;15;48;5;237m 12,670 9,940 9,450\u001b[6;88H 4,970 4,270\u001b[7;2H\u001b[23m\u001b[39;49mWidgets\u001b[7;12H\u001b[3m \u001b[7;24H14,580 12,000 \u001b[7;53H17,520 12,600 15,300 13,980 9,660\u001b[8;2H\u001b[23mSprockets\u001b[8;12H\u001b[3m 2,940 \u001b[8;37H2,170\u001b[8;43H 3,360 2,520\u001b[8;87H 5,040 4,410\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────"] -[70.699374, "o", "────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990 18,340\u001b[11;12H\u001b[22m\u001b[39;49m \u001b[11;18H \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;2H \u001b[14;12H \u001b[14;18H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;2H "] -[70.699547, "o", " \u001b[19;2H \u001b[36;98H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[38;5;8;49m [Date_Month ·] \u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mRegion\u001b[37;17H→ None\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.801355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[70.903566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.006414, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.109107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.211226, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.312839, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.415164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.517285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.618708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.720755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.82319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[71.926319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.028504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.1316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.234303, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.33685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.438765, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.540175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.635683, "o", "\u001b[36;89H\u001b[38;5;8;49m [Region ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Region → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.737283, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.839113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[72.941124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.044152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.146225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.248372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.350589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.452793, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.555584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.658217, "o", "\u001b[3;12H \u001b[1m\u001b[4m\u001b[38;5;3;49m Gadgets\u001b[24m Widgets Sprockets\u001b[22m\u001b[39;49m \u001b[4;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[5;2H\u001b[1m\u001b[38;5;6;48;5;237mCost Stark Enterprises \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 14,580 2,940\u001b[23m\u001b[39;48;5;237m \u001b[6;2H\u001b[39;49m Soylent Ltd \u001b[3m 8,610 12,000 2,170\u001b[23m \u001b[7;2H \u001b[7;10HWonka Industries \u001b[3m 12,670 17,520\u001b[7;49H3,360\u001b[23m \u001b[8;2H Cy"] -[73.658358, "o", "berdyne Systems \u001b[3m \u001b[8;31H9,940\u001b[8;37H 12,600\u001b[8;49H2,520\u001b[23m \u001b[9;2H Acme Corp \u001b[3m 9,450 15,300\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[10;2H Oceanic Airlines \u001b[3m 4,970 13,980 5,040\u001b[23m \u001b[11;10HUmbrella\u001b[11;19HCo\u001b[11;28H\u001b[3m 4,270 9,660 4,410\u001b[12;10H\u001b[23mGlobex\u001b[12;17HInc\u001b[12;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,100 4,410\u001b[13;10H\u001b[23mInitech\u001b[13;28H\u001b[3m 6,930 6,600\u001b[38;5;8;49m \u001b[14;2H\u001b[23m\u001b[39;49mRevenue\u001b[14;10HStark\u001b[14;16HEnterprises\u001b[14;28H\u001b[3m 15,500 24,300 4,200\u001b[15;10H\u001b[23mSoylent\u001b[15;18HLtd\u001b[15;28H\u001b[3m 12,300 20,000 3,100\u001b[16;10H\u001b[23mWonka\u001b[16;16HIndustries\u001b[16;28H\u001b[3m 18,100 29,200 4,800\u001b[17;10H\u001b[23mCyberdyne\u001b[17;20HSystems\u001b[17;28H\u001b[3m 14,200 21,000 3,600\u001b[18;10H\u001b[23mAcme\u001b[18;15HCorp\u001b[18;28H\u001b[3m 13,500 25,500\u001b[38;5;8;49m "] -[73.658473, "o", " \u001b[19;10H\u001b[23m\u001b[39;49mOceanic\u001b[19;18HAirlines\u001b[19;28H\u001b[3m 7,100 23,300 7,200\u001b[20;10H\u001b[23mUmbrella\u001b[20;19HCo\u001b[20;28H\u001b[3m 6,100 16,100 6,300\u001b[21;10H\u001b[23mGlobex\u001b[21;17HInc\u001b[21;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,500 6,300\u001b[22;10H\u001b[23mInitech\u001b[22;28H\u001b[3m 9,900 11,000\u001b[38;5;8;49m \u001b[23;2H\u001b[23m\u001b[39;49mProfit\u001b[23;10HStark\u001b[23;16HEnterprises\u001b[23;28H\u001b[3m 4,650 9,720 1,260\u001b[24;10H\u001b[23mSoylent\u001b[24;18HLtd\u001b[24;28H\u001b[3m 3,690 8,000 930\u001b[25;10H\u001b[23mWonka\u001b[25;16HIndustries\u001b[25;28H\u001b[3m 5,430 11,680 1,440\u001b[26;10H\u001b[23mCyberdyne\u001b[26;20HSystems\u001b[26;28H\u001b[3m 4,260 8,400 1,080\u001b[27;10H\u001b[23mAcme\u001b[27;15HCorp\u001b[27;28H\u001b[3m 4,050 10,200\u001b[38;5;8;49m \u001b[28;10H\u001b[23m\u001b[39;49mOceanic\u001b[28;18HAirlines\u001b[28;28H\u001b[3m 2,130 9,320 2,160\u001b[29;10H\u001b[23mUmbrella\u001b[29;19HCo\u001b[29;28H\u001b[3m 1,830 6,440 1,890\u001b[30;10H\u001b[23mGlobex\u001b[30;17HInc\u001b[30;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,400 1,890\u001b[31;10H\u001b[23mInitech\u001b[31;28H\u001b[3m 2,970 4,400\u001b[38;5;8;49m \u001b[32;2H\u001b[23"] -[73.658556, "o", "m\u001b[39;49mMargin\u001b[32;10HStark\u001b[32;16HEnterprises\u001b[32;28H\u001b[3m 30 40 30\u001b[33;10H\u001b[23mSoylent\u001b[33;18HLtd\u001b[33;28H\u001b[3m 30 40 30\u001b[34;10H\u001b[23mWonka\u001b[34;16HIndustries\u001b[34;28H\u001b[3m 30 40 30\u001b[36;32H\u001b[23m\u001b[38;5;2;49m [_Measure Row] [Customer Row] \u001b[36;74H\u001b[38;5;4;49m [Product Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.759967, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.862561, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[73.965616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.068047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.170187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.272638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.374756, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.4771, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.579809, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.682278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.784151, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.885701, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[74.988411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.090938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.193462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.296878, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.398736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.501676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.604535, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.70834, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.811134, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[75.913363, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.015107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.11667, "o", "\u001b[3;12H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;2H\u001b[22m\u001b[24m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m Stark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines Umbrella Co\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 8,610 12,670 9,940 9,450 4,970 4,270\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets\u001b[7;10H \u001b[3m 14,580 12,000 \u001b[7;49H 17,520 12,600 15,300 13,980 "] -[76.116905, "o", " 9,660\u001b[8;2H\u001b[23mSprockets \u001b[3m 2,940\u001b[8;31H \u001b[8;37H2,170 \u001b[8;49H 3,360 2,520\u001b[38;5;8;49m \u001b[39;49m 5,040 4,410\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990 18,340\u001b[11;10H\u001b[22m\u001b[39;49m \u001b[11;19H \u001b[11;28H \u001b[12;10H \u001b[12;17H \u001b[12;28H \u001b[13;10H \u001b[13;28H \u001b[14;2H \u001b[14;10H \u001b[14;16H \u001b[14;28H \u001b[15;10H \u001b[15;18H \u001b[15;28H \u001b[16;10H \u001b[16;"] -[76.117215, "o", "16H \u001b[16;28H \u001b[17;10H \u001b[17;20H \u001b[17;28H \u001b[18;10H \u001b[18;15H \u001b[18;28H \u001b[19;10H \u001b[19;18H \u001b[19;28H \u001b[20;10H \u001b[20;19H \u001b[20;28H \u001b[21;10H \u001b[21;17H \u001b[21;28H \u001b[22;10H \u001b[22;28H \u001b[23;2H \u001b[23;10H \u001b[23;16H \u001b[23;28H \u001b[24;10H \u001b[24;18H \u001b[24;28H \u001b[25;10H \u001b[25;16H \u001b[25;28H \u001b[26;10H \u001b[26;20H \u001b[26;28H \u001b[27;10H \u001b[27;15H \u001b[27;28H \u001b[28;10H \u001b[28;18H \u001b[28;28H \u001b[29;10H \u001b[29;19H \u001b[29;28H \u001b[30;10H \u001b[30;17H \u001b[30;28H \u001b[31;10H \u001b[31;28H \u001b[32;2H \u001b[32;10H \u001b[32;16H \u001b[32;28H "] -[76.117371, "o", " \u001b[33;10H \u001b[33;18H \u001b[33;28H \u001b[34;10H \u001b[34;16H \u001b[34;28H \u001b[36;32H\u001b[38;5;4;49m [_Measure Col] [Customer Col] \u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.220162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.322015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.423613, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.525244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.627242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.728873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.831933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[76.934917, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.036315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.13847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.242733, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.345432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.448448, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.550404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.653764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.756099, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.859408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[77.961709, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.063238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.165254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.267493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.369832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.472128, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.574391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.676144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.777862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.880914, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[78.983394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.085396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.188456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.290135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.391212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.492775, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.595048, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.697259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.800184, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[79.902419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.005145, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.107077, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.209791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.311753, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.348684, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.450215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.553719, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.656004, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.757524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.8535, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[80.955594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.057237, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.159725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.261826, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.363603, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.406014, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.507994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.609703, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.712177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[81.798741, "o", "\u001b[?1049l\u001b[?25h"] -[81.80075, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[81.842957, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[81.985112, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:08:28\r\n16027:% \u001b[K"] -[81.985205, "o", "\u001b[?2004h"] -[82.780045, "o", "\u001b[?2004l\r\r\n"] +{"version": 2, "width": 120, "height": 37, "idle_time_limit": 2.0, "title": "improvise \u2014 formulas"} +[0.014548, "o", "\u001b[?1049h"] +[0.017833, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise \u00b7 Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m\u250c\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[38;5;8;49m\u250c Categories \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;1H\u001b[39;49m\u2502\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[3;92H_Index\u001b[3;99H(0)\u001b[3;104H\u001b[38;5;8;49m [None \u2205]\u001b[3;120H\u2502\u001b[4;1H\u001b[39;49m\u2502\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[4;92H_Dim\u001b[4;97H(0)\u001b[4;102H\u001b[38;5;8;49m [None \u2205]\u001b[4;120H\u2502\u001b[5;1H\u001b[39;49m\u2502\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02\u001b[5;88H\u001b[22m\u001b[39"] +[0.017866, "o", ";49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[5;92H_Measure\u001b[5;101H(3)\u001b[5;106H\u001b[38;5;4;49m [Col \u2194]\u001b[5;120H\u001b[38;5;8;49m\u2502\u001b[6;1H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[6;92HCustomer\u001b[6;101H(9)\u001b[6;106H\u001b[38;5;5;49m [Page \u2630\u001b[6;115H]\u001b[6;120H\u001b[38;5;8;49m\u2502\u001b[7;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[7;92HDate\u001b[7;97H(40)\u001b[7;103H\u001b[38;5;8;49m [None \u2205]\u001b[7;120H\u2502\u001b[8;1H\u001b[39;49m\u2502\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[8;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[8;92HP"] +[0.017873, "o", "roduct\u001b[8;100H(3)\u001b[8;105H\u001b[38;5;2;49m [Row \u2195]\u001b[8;120H\u001b[38;5;8;49m\u2502\u001b[9;1H\u001b[39;49m\u2502\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[9;92HRegion\u001b[9;99H(4)\u001b[9;104H\u001b[38;5;2;49m [Row \u2195]\u001b[9;120H\u001b[38;5;8;49m\u2502\u001b[10;1H\u001b[39;49m\u2502\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[10;92HDate_Month\u001b[10;103H(3)\u001b[10;108H\u001b[38;5;4;49m [Col \u2194]\u001b[10;120H\u001b[38;5;8;49m\u2502\u001b[11;1H\u001b[39;49m\u2502Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m \u001b[11;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[11;120H\u2502\u001b[12;1H\u001b[39;49m\u2502\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[12;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[12;120H\u2502\u001b[13;1H\u001b[39;49m\u2502\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m "] +[0.017922, "o", " \u001b[13;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[13;120H\u2502\u001b[14;1H\u001b[39;49m\u2502\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[14;120H\u2502\u001b[15;1H\u001b[39;49m\u2502Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[15;120H\u2502\u001b[16;1H\u001b[39;49m\u2502\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[16;88H\u001b[23m\u2502\u001b[38;5;8;49m\u2502\u001b[16;120H\u2502\u001b[17;1H\u001b[39;49m\u2502\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[17;120H\u2502\u001b[18;1H\u001b[39;49m\u2502\u001b[18;12HWest\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[18;120H\u2502\u001b[19;1H\u001b[39;49m\u2502\u001b[38;5;8;49m"] +[0.017953, "o", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[19;120H\u2502\u001b[20;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260\u001b[20;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[20;120H\u2502\u001b[21;1H\u001b[39;49m\u2502\u001b[21;88H\u2502\u001b[38;5;8;49m\u2502\u001b[21;120H\u2502\u001b[22;1H\u001b[39;49m\u2502\u001b[22;88H\u2502\u001b[38;5;8;49m\u2502\u001b[22;120H\u2502\u001b[23;1H\u001b[39;49m\u2502\u001b[23;88H\u2502\u001b[38;5;8;49m\u2502\u001b[23;120H\u2502\u001b[24;1H\u001b[39;49m\u2502\u001b[24;88H\u2502\u001b[38;5;8;49m\u2502\u001b[24;120H\u2502\u001b[25;1H\u001b[39;49m\u2502\u001b[25;88H\u2502\u001b[38;5;8;49m\u2502\u001b[25;120H\u2502\u001b[26;1H\u001b[39;49m\u2502\u001b[26;88H\u2502\u001b[38;5;8;49m\u2502\u001b[26;120H\u2502\u001b[27;1H\u001b[39;49m\u2502\u001b[27;88H\u2502\u001b[38;5;8;49m\u2502\u001b[27;120H\u2502\u001b[28;1H\u001b[39;49m\u2502\u001b[28;88H\u2502\u001b[38;5;8;49m\u2502\u001b[28;120H\u2502\u001b[29;1H\u001b[39;49m\u2502\u001b[29;88H\u2502\u001b[38;5;8;49m\u2502\u001b[29;120H\u2502\u001b[30;1H\u001b[39;49m\u2502\u001b[30;88H\u2502\u001b[38;5;8;49m\u2502\u001b[30;120H\u2502\u001b["] +[0.017989, "o", "31;1H\u001b[39;49m\u2502\u001b[31;88H\u2502\u001b[38;5;8;49m\u2502\u001b[31;120H\u2502\u001b[32;1H\u001b[39;49m\u2502\u001b[32;88H\u2502\u001b[38;5;8;49m\u2502\u001b[32;120H\u2502\u001b[33;1H\u001b[39;49m\u2502\u001b[33;88H\u2502\u001b[38;5;8;49m\u2502\u001b[33;120H\u2502\u001b[34;1H\u001b[39;49m\u2502\u001b[34;88H\u2502\u001b[38;5;8;49m\u2502\u001b[34;120H\u2502\u001b[35;1H\u001b[39;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[38;5;8;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] [_Dim \u00b7] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date \u00b7] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl\u2190\u2191\u2192\u2193:nav ai:edit R:records P:prune F:formulas C:categories V:views T:tiles [:prev-page ]:next-pag\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.120799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.223951, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.326843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.429589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.532664, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.63654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.738908, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.842847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.94676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.050559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.15455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.258358, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.362249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.466074, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.569866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.607399, "o", "\u001b[2;89H\u001b[38;5;3;49m\u250c Formulas [n]ew [d]elete \u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;89H\u2502\u001b[1m\u001b[38;5;0;48;5;3m Profit = Revenue - Cost\u001b[3;120H\u001b[22m\u001b[38;5;3;49m\u2502\u001b[4;89H\u2502\u001b[39;49m \u001b[4;92H \u001b[4;97H \u001b[4;102H \u001b[4;120H\u001b[38;5;3;49m\u2502\u001b[5;89H\u2502\u001b[39;49m \u001b[5;92H \u001b[5;101H \u001b[5;106H \u001b[5;120H\u001b[38;5;3;49m\u2502\u001b[6;89H\u2502\u001b[39;49m \u001b[6;92H \u001b[6;101H \u001b[6;106H \u001b[6;115H \u001b[6;120H\u001b[38;5;3;49m\u2502\u001b[7;89H\u2502\u001b[39;49m \u001b[7;92H \u001b[7;97H \u001b[7;103H \u001b[7;120H\u001b[38;5;3;49m\u2502\u001b[8;89H\u2502\u001b[39;49m \u001b[8;92H \u001b[8;100H \u001b[8;105H \u001b[8;120H\u001b[38;5;3;49m\u2502\u001b[9;89H\u2502\u001b[39;49m \u001b[9;92H \u001b[9;99H \u001b[9;104H \u001b[9;120H\u001b[38;5;3;49m\u2502\u001b[10;89H\u2502\u001b[39;49m \u001b[10;92H \u001b[10;103H \u001b[10;108H \u001b[10;120H\u001b[38;5;3;49m\u2502\u001b[11;89H\u2502\u001b[11;120H\u2502\u001b[12;89H\u2502\u001b[12;120H\u2502\u001b[13;89H\u2502\u001b[13;120H\u2502\u001b[14;89H\u2502\u001b[14;120H\u2502\u001b[15;89H\u2502\u001b[15;120H\u2502\u001b[16;89H\u2502\u001b[16;120H\u2502\u001b[17;89H\u2502\u001b[17;120H\u2502\u001b[18;89H\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[19;89H\u001b[38;5;8;"] +[1.607453, "o", "49m\u250c Categories \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[20;90H\u001b[39;49m\u25b6\u001b[20;92H_Index\u001b[20;99H(0)\u001b[20;104H\u001b[38;5;8;49m [None \u2205]\u001b[21;90H\u001b[39;49m\u25b6\u001b[21;92H_Dim\u001b[21;97H(0)\u001b[21;102H\u001b[38;5;8;49m [None \u2205]\u001b[22;90H\u001b[39;49m\u25b6\u001b[22;92H_Measure\u001b[22;101H(3)\u001b[22;106H\u001b[38;5;4;49m [Col \u2194]\u001b[23;90H\u001b[39;49m\u25b6\u001b[23;92HCustomer\u001b[23;101H(9)\u001b[23;106H\u001b[38;5;5;49m [Page \u2630\u001b[23;115H]\u001b[24;90H\u001b[39;49m\u25b6\u001b[24;92HDate\u001b[24;97H(40)\u001b[24;103H\u001b[38;5;8;49m [None \u2205]\u001b[25;90H\u001b[39;49m\u25b6\u001b[25;92HProduct\u001b[25;100H(3)\u001b[25;105H\u001b[38;5;2;49m [Row \u2195]\u001b[26;90H\u001b[39;49m\u25b6\u001b[26;92HRegion\u001b[26;99H(4)\u001b[26;104H\u001b[38;5;2;49m [Row \u2195]\u001b[27;90H\u001b[39;49m\u25b6\u001b[27;92HDate_Month\u001b[27;103H(3)\u001b[27;108H\u001b[38;5;4;49m [Col \u2194]\u001b[37;3H\u001b[38;5;0;48;5;8mF\u001b[37;7HU\u001b[37;9HAS ano:new Del/d\u001b[37;28Hdelete jk\u2191\u2193:nav\u001b[37;45H Esc/Tab:back \u001b[37;66H \u001b[37;80H \u001b[37;89H \u001b[37;98H Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.709336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.81078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.913634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.017181, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.120812, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.224672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.328579, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.432541, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.536434, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.638767, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.742846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.846891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.950429, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.054209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.158105, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.262011, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.365848, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.469167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.573205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.677078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.780906, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.884564, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.988459, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.014648, "o", "\u001b[2;89H\u001b[38;5;8;49m\u250c Formulas [n]ew [d]elete \u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;89H\u2502\u001b[38;5;2;49m Profit = Revenue - Cost\u001b[3;120H\u001b[38;5;8;49m\u2502\u001b[4;89H\u2502\u001b[4;120H\u2502\u001b[5;89H\u2502\u001b[5;120H\u2502\u001b[6;89H\u2502\u001b[6;120H\u2502\u001b[7;89H\u2502\u001b[7;120H\u2502\u001b[8;89H\u2502\u001b[8;120H\u2502\u001b[9;89H\u2502\u001b[9;120H\u2502\u001b[10;89H\u2502\u001b[10;120H\u2502\u001b[11;89H\u2502\u001b[11;120H\u2502\u001b[12;89H\u2502\u001b[12;120H\u2502\u001b[13;89H\u2502\u001b[13;120H\u2502\u001b[14;89H\u2502\u001b[14;120H\u2502\u001b[15;89H\u2502\u001b[15;120H\u2502\u001b[16;89H\u2502\u001b[16;120H\u2502\u001b[17;89H\u2502\u001b[17;120H\u2502\u001b[18;89H\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[37;3H\u001b[38;5;0;48;5;8mN\u001b[37;7HA\u001b[37;9H hjkl\u2190\u2191\u2192\u2193:nav ai\u001b[37;28Hedit R:records \u001b[37;45HP:prune F:formulas\u001b[37;66HC:categories\u001b[37;80HV:views\u001b[37;89HT:tiles\u001b[37;98H[:prev-page ]:next-pag\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.117617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.22097, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.323673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.424523, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.527237, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.629958, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.732688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.835783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.936791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.013824, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index \u00b7] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES hl\u2190\u2192:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.116492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.219167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.321881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.424669, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.52738, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.63015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.732965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.835763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.938557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.023889, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim \u00b7] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.126643, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.229444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.332227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.384077, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.486869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.589656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.692469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.74409, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.846867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.949624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.05235, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.103954, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date \u00b7] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.206645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.309316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.411998, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.464009, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.566754, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.669433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.772238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.824496, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.928486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.032516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.136284, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.186709, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.290586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.393388, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.497385, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.545994, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;18H\u001b[22m\u001b[39;49m \u001b[4;29H\u001b[1m\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets North 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[8;12H \u001b[8;18H2025-03 \u001b[8;34H\u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[9;12HEas\u001b[9;16H \u001b[9;18H2025-01 \u001b[3m 5,180 7,400 2,220\u001b[23m \u001b[10;12H \u001b[10;18H2025-02 \u001b[10;48H \u001b[11;2H "] +[8.546048, "o", " \u001b[11;12H \u001b[11;18H2025-03 \u001b[3m 5,670 8,100 2,430\u001b[23m \u001b[12;12HSou\u001b[12;16Hh\u001b[12;18H2025-01 \u001b[12;34H\u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[13;12H \u001b[13;18H2025-02 \u001b[13;48H \u001b[14;12H \u001b[14;18H2025-03 \u001b[14;48H \u001b[15;2H \u001b[15;12HWes\u001b[15;16H \u001b[15;18H2025-01 \u001b[15;48H \u001b[16;12H \u001b[16;18H2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[16;48H\u001b[23m\u001b[39;49m \u001b[17;12H \u001b[17;18H2025-03 \u001b[17;48H \u001b[18;2HWidgets\u001b[18;12HNor\u001b[18;16Hh\u001b[18;18H2025-01 \u001b[18;48H \u001b[19;2H 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[20;2H 2025-03 \u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[21;12HEast\u001b[21;18H2025-01\u001b[21;26H\u001b[3m 7,080 11,800 4,720"] +[8.546111, "o", "\u001b[22;18H\u001b[23m2025-02\u001b[22;26H\u001b[3m\u001b[38;5;8;49m \u001b[23;18H\u001b[23m\u001b[39;49m2025-03\u001b[23;26H\u001b[3m 7,500 12,500 5,000\u001b[24;12H\u001b[23mSouth\u001b[24;18H2025-01\u001b[24;26H\u001b[3m\u001b[38;5;8;49m \u001b[25;18H\u001b[23m\u001b[39;49m2025-02\u001b[25;26H\u001b[3m\u001b[38;5;8;49m \u001b[26;18H\u001b[23m\u001b[39;49m2025-03\u001b[26;26H\u001b[3m\u001b[38;5;8;49m \u001b[27;12H\u001b[23m\u001b[39;49mWest\u001b[27;18H2025-01\u001b[27;26H\u001b[3m\u001b[38;5;8;49m \u001b[27;108H\u001b[23m\u001b[38;5;2;49m [Row \u2195]\u001b[28;18H\u001b[39;49m2025-02\u001b[28;26H\u001b[3m\u001b[38;5;8;49m \u001b[29;18H\u001b[23m\u001b[39;49m2025-03\u001b[29;26H\u001b[3m\u001b[38;5;8;49m \u001b[30;2H\u001b[23m\u001b[39;49mSprockets\u001b[30;12HNorth\u001b[30;18H2025-01\u001b[30;26H\u001b[3m\u001b[38;5;8;49m \u001b[31;18H\u001b[23m\u001b[39;49m2025-02\u001b[31;26H\u001b[3m\u001b[38;5;8;49m \u001b[32;18H\u001b[23m\u001b[39;49m2025-03\u001b[32;26H\u001b[3m\u001b[38;5;8;49m \u001b[33;12H\u001b[23m\u001b[39;49mEast\u001b[33;18H2025-01\u001b[33;26H\u001b[3m\u001b[38;5;8;49m \u001b[34;18H\u001b[23m\u001b[39;49m2025-02\u001b[34;26H\u001b[3m 2,940 4,200 1,260\u001b[36;"] +[8.546122, "o", "116H\u001b[23m\u001b[1m\u001b[38;5;0;48;5;6mR\u001b[36;118Hw\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month \u2192\u001b[37;23HRow \u001b[37;42H \u001b[37;49H \u001b[37;56H \u001b[37;64H \u001b[37;108H Default\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.650023, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.75393, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.85762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.961581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.064973, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.168668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.27269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.375364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.478076, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.581333, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.684849, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.788637, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.892466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.99636, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.099546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.202229, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.304929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.407612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.5103, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.613091, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.715814, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.818501, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.921187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.023874, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.126548, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.145769, "o", "\u001b[36;103H\u001b[38;5;2;49m [Date_Month Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month \u2192 Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.248551, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.351332, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.454116, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.556881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.659673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.762462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.865224, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[11.967972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.070761, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.173486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.276277, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.37898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.481068, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.583793, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.686495, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.7892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.891901, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[12.994604, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.0958, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.198485, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.30126, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.403943, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[13.506636, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] diff --git a/docs/casts/import.cast b/docs/casts/import.cast index b35d4ed6..7e6dcf93 100644 --- a/docs/casts/import.cast +++ b/docs/casts/import.cast @@ -1,210 +1,99 @@ -{"version": 2, "width": 120, "height": 37, "timestamp": 1775770896, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}} -[0.236073, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[0.243595, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[0.36932, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 14:41:37\r\n16023:% \u001b[K"] -[0.369422, "o", "\u001b[?2004h"] -[6.04003, "o", "\u001b[3m./target/release/improvise import examples/demo.csv\u001b[23m"] -[6.780344, "o", "\u001b[51D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mo\u001b[23mr\u001b[23mt\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mc\u001b[23ms\u001b[23mv"] -[6.780389, "o", "\u001b[?2004l\r\r\n"] -[6.781567, "o", "\u001b]0;./target/release/improvise import examples/demo.csv\u0007\u001b[2 q"] -[6.824759, "o", "\u001b[?1049h"] -[6.825908, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · New Model ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[3;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Value\u001b[3;120H\u001b[22m\u001b[24m\u001b[39;49m│\u001b[4;1H│\u001b[38;5;8;49m───────────────────\u001b[38;5;5;49m┌ Import Wizard — Review Fields ───────────────────────────────────────────────┐\u001b[38;5;8;49m───────────────────\u001b[39;49m│\u001b[5;1H│\u001b[5;21H\u001b[38;5;5;49m│\u001b[38;5;3;49mReview field proposals (Space toggle, c cycle kin"] -[6.825948, "o", "d):\u001b[5;100H\u001b[38;5;5;49m│\u001b[5;120H\u001b[39;49m│\u001b[6;1H│\u001b[6;21H\u001b[38;5;5;49m│\u001b[4m\u001b[38;5;7;49m Field Kind Accept\u001b[6;100H\u001b[24m\u001b[38;5;5;49m│\u001b[6;120H\u001b[39;49m│\u001b[7;1H│\u001b[7;21H\u001b[38;5;5;49m│\u001b[1m\u001b[38;5;0;48;5;6m Cost Measure (numeric) [✓]\u001b[7;100H\u001b[22m\u001b[38;5;5;49m│\u001b[7;120H\u001b[39;49m│\u001b[8;1H│\u001b[8;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Customer Category (dimension) [✓]\u001b[8;100H\u001b[38;5;5;49m│\u001b[8;120H\u001b[39;49m│\u001b[9;1H│\u001b[9;21H\u001b[38;5;5;49m│ Date Time Category [✓]\u001b[9;100H│\u001b[9;120H\u001b[39;49m│\u001b[10;1H│\u001b[10;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Product Category (dimension) [✓]\u001b[10;100H\u001b[38;5;5;49m│\u001b[10;120H\u001b[39;49m│\u001b[11;1H│\u001b[11;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Region Category (dimension) [✓]\u001b[11;100H\u001b[38;5;5;49m│\u001b[11;120H\u001b[39;49m│\u001b[12;1H│\u001b[12;21H\u001b[38;5;5;49m│\u001b[38;5;6;49m Revenue Measure (numeric) [✓]\u001b[12;100H\u001b[38;5;5;49m│\u001b[12;120H\u001b[39;49m│\u001b[13;1H│\u001b[13;2"] -[6.826095, "o", "1H\u001b[38;5;5;49m│\u001b[13;100H│\u001b[13;120H\u001b[39;49m│\u001b[14;1H│\u001b[14;21H\u001b[38;5;5;49m│\u001b[14;100H│\u001b[14;120H\u001b[39;49m│\u001b[15;1H│\u001b[15;21H\u001b[38;5;5;49m│\u001b[15;100H│\u001b[15;120H\u001b[39;49m│\u001b[16;1H│\u001b[16;21H\u001b[38;5;5;49m│\u001b[16;100H│\u001b[16;120H\u001b[39;49m│\u001b[17;1H│\u001b[17;21H\u001b[38;5;5;49m│\u001b[17;100H│\u001b[17;120H\u001b[39;49m│\u001b[18;1H│\u001b[18;21H\u001b[38;5;5;49m│\u001b[18;100H│\u001b[18;120H\u001b[39;49m│\u001b[19;1H│\u001b[19;21H\u001b[38;5;5;49m│\u001b[19;100H│\u001b[19;120H\u001b[39;49m│\u001b[20;1H│\u001b[20;21H\u001b[38;5;5;49m│\u001b[20;100H│\u001b[20;120H\u001b[39;49m│\u001b[21;1H│\u001b[21;21H\u001b[38;5;5;49m│\u001b[21;100H│\u001b[21;120H\u001b[39;49m│\u001b[22;1H│\u001b[22;21H\u001b[38;5;5;49m│\u001b[22;100H│\u001b[22;120H\u001b[39;49m│\u001b[23;1H│\u001b[23;21H\u001b[38;5;5;49m│\u001b[23;100H│\u001b[23;120H\u001b[39;49m│\u001b[24;1H│\u001b[24;21H\u001b[38;5;5;49m│\u001b[24;100H│\u001b[24;120H\u001b[39;49m│\u001b[25;1H│\u001b[25;21H\u001b[38;5;5;49m│\u001b[25;100H│\u001b[25;120H\u001b[39;49m│\u001b[26;1H│\u001b[26;21H\u001b[38;5;5;49m│\u001b[26;100H│\u001b[26;120H\u001b[39;49m│\u001b[27;1H│\u001b[27;21H\u001b[38;5;5;49m│\u001b[27;100H│\u001b[27;120H\u001b[39;49m│\u001b[28;1H│\u001b[28;21H\u001b[38;5;5;49m│\u001b[28;100H│\u001b[28;120H\u001b[39;49m│"] -[6.826171, "o", "\u001b[29;1H│\u001b[29;21H\u001b[38;5;5;49m│\u001b[29;100H│\u001b[29;120H\u001b[39;49m│\u001b[30;1H│\u001b[30;21H\u001b[38;5;5;49m│\u001b[30;100H│\u001b[30;120H\u001b[39;49m│\u001b[31;1H│\u001b[31;21H\u001b[38;5;5;49m│\u001b[31;100H│\u001b[31;120H\u001b[39;49m│\u001b[32;1H│\u001b[32;21H\u001b[38;5;5;49m│\u001b[38;5;8;49mEnter: next Space: toggle c: cycle kind Esc: cancel\u001b[32;100H\u001b[38;5;5;49m│\u001b[32;120H\u001b[39;49m│\u001b[33;1H│\u001b[33;21H\u001b[38;5;5;49m└──────────────────────────────────────────────────────────────────────────────┘\u001b[33;120H\u001b[39;49m│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H"] -[6.826265, "o", "\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;8;49m [_Measure ·] Ctrl+↑↓←→ to move tiles\u001b[37;1H\u001b[38;5;0;48;5;8m IMPORT Space:toggle c:cycle Enter:next Esc:cancel Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.927868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.028723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.130263, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.231693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.333159, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.433716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.535238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.636386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.73786, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.839624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.941139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.04274, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.144036, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.245449, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.346985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.448858, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.550336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.576166, "o", "\u001b[4;39H\u001b[38;5;5;49mDate Components \u001b[5;22H\u001b[38;5;3;49mS\u001b[5;24Hlect\u001b[5;29Hdate component\u001b[5;45Hto extract (Spa\u001b[5;61He toggle):\u001b[39;49m \u001b[6;22H\u001b[1m\u001b[38;5;5;49m Date (format: %Y-%m-%d)\u001b[22m\u001b[39;49m \u001b[7;24H\u001b[1m\u001b[38;5;0;48;5;6m [ ]\u001b[7;30HYear\u001b[22m\u001b[39;49m \u001b[8;22H\u001b[38;5;8;49m [ ] Month\u001b[39;49m \u001b[9;22H\u001b[38;5;8;49m [ ] Quarter\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[12;22H \u001b[32;22H\u001b[38;5;8;49mSpace\u001b[32;29Htoggle Enter: next\u001b[32;50HEsc: \u001b[32;56Hancel\u001b[39;49m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.677413, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.778735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.880265, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.981784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.083632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.185292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.286565, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.388026, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.489709, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.591377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.692806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.794433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.895905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.948598, "o", "\u001b[4;39H\u001b[38;5;5;49mFormulas ───────\u001b[5;22H\u001b[38;5;3;49mD\u001b[5;24Hfine\u001b[5;29Hformulas (optional):\u001b[39;49m \u001b[6;22H\u001b[38;5;8;49m (no formulas yet)\u001b[39;49m \u001b[7;22H \u001b[8;22H\u001b[38;5;8;49mExamples:\u001b[39;49m \u001b[9;24H\u001b[38;5;8;49mDiff = Cos\u001b[9;35H - Revenue\u001b[10;22H Total = SUM(Cost)\u001b[11;22H Ratio = Cost / Revenue\u001b[32;22Hn: n\u001b[32;27Hw\u001b[32;29Hf\u001b[32;31Hrmula\u001b[32;37H d: delete \u001b[32;49HEnter: next Esc: cancel\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.050037, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.151224, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.252897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.354645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.456403, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.558415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.659907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.761363, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.862866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.964162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.06573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.166744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.267259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.368721, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.382289, "o", "\u001b[4;39H\u001b[38;5;5;49mName Model \u001b[5;22H\u001b[38;5;3;49mModel name:\u001b[39;49m \u001b[6;22H\u001b[38;5;2;49m> Imported Model█\u001b[39;49m \u001b[8;23H\u001b[38;5;8;49mnter to import, Esc to cancel\u001b[9;22H\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[32;22H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.482638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.584593, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.686176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.788053, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.889981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.991675, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.093291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.194383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.295384, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.395968, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.497456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.599038, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.700412, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.801355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.902735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.922022, "o", "\u001b[1;17H\u001b[1m\u001b[38;5;0;48;5;4mImported Model\u001b[1;32H[+]\u001b[3;2H\u001b[22m\u001b[38;5;5;49m [Product = Gadgets | Region = South] \u001b[4;2H\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-02-03\u001b[24m 2025-02-25 2025-01-22 2025-03-28 2025-01-31 2025-01-27 2025-01-11 2025-02-10 2025-03-14\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mAcme Corp \u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWonka\u001b[7;8HIndustries\u001b[7;20H\u001b[38;5;8;49m \u001b[8;2H\u001b[39;49mInit"] -[12.922264, "o", "ech\u001b[8;20H\u001b[38;5;8;49m \u001b[8;28H \u001b[8;31H \u001b[8;39H \u001b[8;43H \u001b[8;46H \u001b[9;2H\u001b[39;49mUmbrella\u001b[9;11HCo\u001b[9;20H\u001b[38;5;8;49m \u001b[10;2H\u001b[39;49mGlobex\u001b[10;9HInc\u001b[10;20H\u001b[38;5;8;49m \u001b[11;2H\u001b[39;49mCyberdyne\u001b[11;12HSystems\u001b[11;20H\u001b[38;5;8;49m \u001b[12;2H\u001b[39;49mSoylent\u001b[12;10HLtd\u001b[12;20H\u001b[38;5;8;49m \u001b[13;2H\u001b[39;49mStark\u001b[13;8HEnterprises\u001b[13;20H\u001b[38;5;8;49m \u001b[14;2H\u001b[39;49mOceanic\u001b[14;10HAirlines\u001b[14;20H\u001b[38;5;8;49m "] -[12.922541, "o", " \u001b[15;2H──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[16;2H\u001b[1m\u001b[38;5;3;49mTotal 0 0 0 0 0 0 0 0 0\u001b[17;21H\u001b[22m\u001b[39;49m \u001b[17;100H \u001b[18;21H \u001b[18;100H \u001b[19;21H \u001b[19;100H \u001b[20;21H \u001b[20;100H \u001b[21;21H \u001b[21;100H \u001b[22;21H \u001b[22;100H \u001b[23;21H \u001b[23;100H \u001b[24;21H \u001b[24;100H \u001b[25;21H \u001b[25;100H \u001b[26;21H \u001b[26;100H \u001b[27;21H \u001b[27;100H \u001b[28;21H \u001b[28;100H \u001b[29;21H \u001b[29;100H \u001b[30;21H \u001b[30;100H \u001b[31;21H \u001b[31;100H \u001b[32;21H \u001b[32;100H \u001b[33;21H \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] [_Measure ·] \u001b[38;5;2;"] -[12.922705, "o", "49m [Customer Row] \u001b[38;5;4;49m [Date Col] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[37;3H\u001b[38;5;0;48;5;8mNORMAL\u001b[37;11HImport succ\u001b[37;23Hssful! Press :w \u001b[37;46Hto save. \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.025884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.129779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.233754, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.336214, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.440232, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.543166, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.645506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.748407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.851524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.954923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.058244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.160825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.264044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.366997, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.471142, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.573197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.676755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.77958, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.883251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.987297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.090783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.194845, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.298104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.40055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.469084, "o", "\u001b[4;14H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01-14\u001b[24m 2025-0\u001b[4;33H-1\u001b[4;36H 2025-03-18\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mUmbrella Co\u001b[6;14H\u001b[38;5;0;48;5;6m \u001b[6;25H\u001b[22m\u001b[38;5;15;48;5;237m 10,370\u001b[6;47H\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mS\u001b[7;4Hylent Ltd \u001b[7;20H9,350\u001b[7;36H 11,560 \u001b[8;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[9;2H\u001b[1m\u001b[38;5;3;49mTotal 9,350 10,370 11,560\u001b[22m\u001b[39;49m "] -[15.469306, "o", " \u001b[10;2H \u001b[10;9H \u001b[10;20H \u001b[11;2H \u001b[11;12H \u001b[11;20H \u001b[12;2H \u001b[12;10H \u001b[12;20H \u001b[13;2H \u001b[13;8H \u001b[13;20H \u001b[14;2H \u001b[14;10H \u001b[14;20H \u001b[15;2H \u001b[16;2H \u001b[37;11H\u001b[38;5;0;48;5;8mHiding\u001b[37;18Hempty rows/columns \u001b[37;39H \u001b[37;46H \u001b[37;49H \u001b[39"] -[15.469451, "o", "m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.570197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.671986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.780803, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.875273, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.976917, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.078891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.179977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.281679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.383194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.483934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.585778, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.686585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.78829, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.890249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.992328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.093415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.19456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.296044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.398176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.500056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.600932, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.701717, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.8031, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.904288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.005978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.107622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.209146, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.310776, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.412516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.51388, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.615399, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.716846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.817638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.91938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.020843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.122743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.150145, "o", "\u001b[4;14H\u001b[1m\u001b[38;5;3;49m 2025-01-14\u001b[4m 2025-02-15\u001b[6;14H\u001b[22m\u001b[24m\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m 10,370\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.252006, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.353965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.456545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.658912, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.742183, "o", "\u001b[4;25H\u001b[1m\u001b[38;5;3;49m 2025-02-15\u001b[4m 2025-03-18\u001b[6;25H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 10,370\u001b[1m\u001b[38;5;0;48;5;6m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.843853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.945171, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.046281, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.147441, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.18382, "o", "\u001b[6;2HUmbrella Co \u001b[38;5;8;49m \u001b[39;49m 10,370\u001b[38;5;8;49m \u001b[39;49m \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mSoylent Ltd \u001b[22m\u001b[38;5;15;48;5;237m 9,350\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m 11,560\u001b[22m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.285624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.386859, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.488387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.590209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.651419, "o", "\u001b[4;25H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-02-15\u001b[24m 2025-03-18\u001b[7;25H\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;15;48;5;237m 11,560\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.753642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.855676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.957502, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.059422, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.160676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.192868, "o", "\u001b[4;14H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01-14\u001b[24m 2025-02-15\u001b[7;14H\u001b[38;5;0;48;5;6m 9,350\u001b[22m\u001b[38;5;8;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.294455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.396139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.497157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.598115, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.700028, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.800846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.902346, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.004411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.085017, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.186447, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.288585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.390454, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.49188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.533815, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.635568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.73671, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.838266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.921803, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.023789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.125305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.226879, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.329421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.430789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.531493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.633286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.73513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.836317, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.937902, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.039833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.141634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.242633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.303273, "o", "\u001b[?1049l"] -[24.303377, "o", "\u001b[?25h"] -[24.304571, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[24.338067, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[24.468723, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 14:42:01\r\n16024:% \u001b[K"] -[24.468837, "o", "\u001b[?2004h"] -[25.113653, "o", "e"] -[25.344547, "o", "\bex"] -[25.514123, "o", "i"] -[25.686254, "o", "t"] -[25.857353, "o", "\u001b[?2004l\r\r\n"] -[25.858441, "o", "\u001b]0;exit\u0007\u001b[2 q"] +{"version": 2, "width": 120, "height": 37, "idle_time_limit": 2.0, "title": "improvise \u2014 import"} +[0.004821, "o", "\u001b[?1049h"] +[0.005717, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise \u00b7 New Model ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m\u250c\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[38;5;8;49m\u250c Categories \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;1H\u001b[39;49m\u2502\u001b[3;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[3;88H\u001b[22m\u001b[24m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[3;92H_Index\u001b[3;99H(0)\u001b[3;104H\u001b[38;5;8;49m [None \u2205]\u001b[3;120H\u2502\u001b[4;1H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[38;5;5;49m\u250c Import Wizard \u2014 Review Fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[4;102H\u001b[38;5;8;49m [None \u2205]\u001b[4;120H\u2502\u001b[5;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;6;48;5;"] +[0.00574, "o", "237m \u001b[38;5;0;48;5;6m \u001b[22m\u001b[39;48;5;237m \u001b[38;5;5;49m\u2502\u001b[38;5;3;49mReview field proposals (Space toggle, c cycle kind):\u001b[5;100H\u001b[38;5;5;49m\u2502\u001b[39;49m(0)\u001b[5;106H\u001b[38;5;5;49m [Page \u2630\u001b[5;115H]\u001b[5;120H\u001b[38;5;8;49m\u2502\u001b[6;1H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[38;5;5;49m\u2502\u001b[4m\u001b[38;5;7;49m Field Kind Accept\u001b[6;100H\u001b[24m\u001b[38;5;5;49m\u2502\u001b[6;120H\u001b[38;5;8;49m\u2502\u001b[7;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;3;49mTota 0\u001b[7;21H\u001b[22m\u001b[38;5;5;49m\u2502\u001b[1m\u001b[38;5;0;48;5;6m Cost Measure (numeric) [\u2713]\u001b[7;100H\u001b[22m\u001b[38;5;5;49m\u2502\u001b[7;120H\u001b[38;5;8;49m\u2502\u001b[8;1H\u001b[39;49m\u2502\u001b[8;21H\u001b[38;5;5;49m\u2502\u001b[38;5;2;49m Customer Category (dimension) [\u2713]\u001b[8;100H\u001b[38;5;5;49m\u2502\u001b[8;120H\u001b[38;5;8;49m\u2502\u001b[9;1H\u001b[39;49m\u2502\u001b[9;21H\u001b[38;5;5;49m\u2502 Date Time Category [\u2713]\u001b[9;100H\u2502\u001b[9;120H\u001b[38;5;8;49m\u2502\u001b[10;1H\u001b[39;49m\u2502\u001b[10;21H\u001b[38;5;5;49m\u2502\u001b[38;5;2;49m Product Category (dimension) [\u2713]\u001b[10"] +[0.005763, "o", ";100H\u001b[38;5;5;49m\u2502\u001b[10;120H\u001b[38;5;8;49m\u2502\u001b[11;1H\u001b[39;49m\u2502\u001b[11;21H\u001b[38;5;5;49m\u2502\u001b[38;5;2;49m Region Category (dimension) [\u2713]\u001b[11;100H\u001b[38;5;5;49m\u2502\u001b[11;120H\u001b[38;5;8;49m\u2502\u001b[12;1H\u001b[39;49m\u2502\u001b[12;21H\u001b[38;5;5;49m\u2502\u001b[38;5;6;49m Revenue Measure (numeric) [\u2713]\u001b[12;100H\u001b[38;5;5;49m\u2502\u001b[12;120H\u001b[38;5;8;49m\u2502\u001b[13;1H\u001b[39;49m\u2502\u001b[13;21H\u001b[38;5;5;49m\u2502\u001b[13;100H\u2502\u001b[13;120H\u001b[38;5;8;49m\u2502\u001b[14;1H\u001b[39;49m\u2502\u001b[14;21H\u001b[38;5;5;49m\u2502\u001b[14;100H\u2502\u001b[14;120H\u001b[38;5;8;49m\u2502\u001b[15;1H\u001b[39;49m\u2502\u001b[15;21H\u001b[38;5;5;49m\u2502\u001b[15;100H\u2502\u001b[15;120H\u001b[38;5;8;49m\u2502\u001b[16;1H\u001b[39;49m\u2502\u001b[16;21H\u001b[38;5;5;49m\u2502\u001b[16;100H\u2502\u001b[16;120H\u001b[38;5;8;49m\u2502\u001b[17;1H\u001b[39;49m\u2502\u001b[17;21H\u001b[38;5;5;49m\u2502\u001b[17;100H\u2502\u001b[17;120H\u001b[38;5;8;49m\u2502\u001b[18;1H\u001b[39;49m\u2502\u001b[18;21H\u001b[38;5;5;49m\u2502\u001b[18;100H\u2502\u001b[18;120H\u001b[38;5;8;49m\u2502\u001b[19;1H\u001b[39;49m\u2502\u001b[19;21H\u001b[38;5;5;49m\u2502\u001b[19;100H\u2502\u001b[19;120H\u001b[38;5;8;49m\u2502\u001b[20;1H\u001b[39;49m\u2502\u001b[20;21H\u001b[38;5;5;49m\u2502\u001b[20;100H\u2502\u001b[20;120H\u001b[38;5;8;49m\u2502\u001b[21;1H\u001b[39;49m\u2502\u001b[21;21H\u001b[38;5;5;49m\u2502\u001b[21;100H\u2502\u001b[21;"] +[0.005784, "o", "120H\u001b[38;5;8;49m\u2502\u001b[22;1H\u001b[39;49m\u2502\u001b[22;21H\u001b[38;5;5;49m\u2502\u001b[22;100H\u2502\u001b[22;120H\u001b[38;5;8;49m\u2502\u001b[23;1H\u001b[39;49m\u2502\u001b[23;21H\u001b[38;5;5;49m\u2502\u001b[23;100H\u2502\u001b[23;120H\u001b[38;5;8;49m\u2502\u001b[24;1H\u001b[39;49m\u2502\u001b[24;21H\u001b[38;5;5;49m\u2502\u001b[24;100H\u2502\u001b[24;120H\u001b[38;5;8;49m\u2502\u001b[25;1H\u001b[39;49m\u2502\u001b[25;21H\u001b[38;5;5;49m\u2502\u001b[25;100H\u2502\u001b[25;120H\u001b[38;5;8;49m\u2502\u001b[26;1H\u001b[39;49m\u2502\u001b[26;21H\u001b[38;5;5;49m\u2502\u001b[26;100H\u2502\u001b[26;120H\u001b[38;5;8;49m\u2502\u001b[27;1H\u001b[39;49m\u2502\u001b[27;21H\u001b[38;5;5;49m\u2502\u001b[27;100H\u2502\u001b[27;120H\u001b[38;5;8;49m\u2502\u001b[28;1H\u001b[39;49m\u2502\u001b[28;21H\u001b[38;5;5;49m\u2502\u001b[28;100H\u2502\u001b[28;120H\u001b[38;5;8;49m\u2502\u001b[29;1H\u001b[39;49m\u2502\u001b[29;21H\u001b[38;5;5;49m\u2502\u001b[29;100H\u2502\u001b[29;120H\u001b[38;5;8;49m\u2502\u001b[30;1H\u001b[39;49m\u2502\u001b[30;21H\u001b[38;5;5;49m\u2502\u001b[30;100H\u2502\u001b[30;120H\u001b[38;5;8;49m\u2502\u001b[31;1H\u001b[39;49m\u2502\u001b[31;21H\u001b[38;5;5;49m\u2502\u001b[31;100H\u2502\u001b[31;120H\u001b[38;5;8;49m\u2502\u001b[32;1H\u001b[39;49m\u2502\u001b[32;21H\u001b[38;5;5;49m\u2502\u001b[38;5;8;49mEnter: next Space: toggle c: cycle kind \u2190 back Esc: cancel \u001b[38;5;5;49m\u2502\u001b[32;120H\u001b[38;5;8;49m\u2502\u001b[33;1H\u001b[39;49m\u2502\u001b[33;21H\u001b[38;5;5;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500"] +[0.005803, "o", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[33;120H\u001b[38;5;8;49m\u2502\u001b[34;1H\u001b[39;49m\u2502\u001b[34;88H\u2502\u001b[38;5;8;49m\u2502\u001b[34;120H\u2502\u001b[35;1H\u001b[39;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[38;5;8;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] [_Dim \u00b7] \u001b[38;5;5;49m [_Measure Pag] \u001b[38;5;8;49m Ctrl+\u2191\u2193\u2190\u2192 to move tiles\u001b[37;1H\u001b[38;5;0;48;5;8m IMPORT Space:toggle c:cycle Enter:next Esc:cancel Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.108052, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.210206, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.312358, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.414536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.516694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.618854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.721003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.823147, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.925321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.027474, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.129638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.231794, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.333954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.403261, "o", "\u001b[4;39H\u001b[38;5;5;49mDate Components \u001b[5;22H\u001b[38;5;3;49mS\u001b[5;24Hlect\u001b[5;29Hdate component\u001b[5;45Hto extract (Spa\u001b[5;61He toggle):\u001b[39;49m \u001b[6;22H\u001b[1m\u001b[38;5;5;49m Date (format: %Y-%m-%d)\u001b[22m\u001b[39;49m \u001b[7;24H\u001b[1m\u001b[38;5;0;48;5;6m [ ]\u001b[7;30HYear\u001b[22m\u001b[39;49m \u001b[8;22H\u001b[38;5;8;49m [ ] Month\u001b[39;49m \u001b[9;22H\u001b[38;5;8;49m [ ] Quarter\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[12;22H \u001b[32;22H\u001b[38;5;8;49mSpace\u001b[32;29Htoggle Enter: next\u001b[32;50H\u2190 back Esc: cancel \u001b[32;73H \u001b[32;78H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.505408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.607546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.708543, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.810673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.912815, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.014961, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.117138, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.219285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.321465, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.423646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.526098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.613326, "o", "\u001b[4;39H\u001b[38;5;5;49mFormulas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[5;22H\u001b[38;5;3;49mD\u001b[5;24Hfine\u001b[5;29Hformulas (optional):\u001b[39;49m \u001b[6;22H\u001b[38;5;8;49m (no formulas yet)\u001b[39;49m \u001b[7;22H \u001b[8;22H\u001b[38;5;8;49mExamples:\u001b[39;49m \u001b[9;24H\u001b[38;5;8;49mDiff = Cos\u001b[9;35H - Revenue\u001b[10;22H Total = SUM(Cost)\u001b[11;22H Ratio = Cost / Revenue\u001b[32;22Hn: n\u001b[32;27Hw\u001b[32;29Hf\u001b[32;31Hrmula\u001b[32;37H d: delete \u001b[32;49HEnter: next \u2190 ba\u001b[32;67Hk \u001b[32;70HEsc:\u001b[32;75Hcancel\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.715504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.817699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.920007, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.022151, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.12483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.225962, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.328229, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.432693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.533282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.635462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.7377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.822632, "o", "\u001b[4;39H\u001b[38;5;5;49mName Model \u001b[5;22H\u001b[38;5;3;49mModel name:\u001b[39;49m \u001b[6;22H\u001b[38;5;2;49m> Imported Model\u2588\u001b[39;49m \u001b[8;23H\u001b[38;5;8;49mnter: build \u2190 back Esc: cancel\u001b[9;22H\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[32;22H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.92477, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.02702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.129142, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.230551, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.331505, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.433656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.535824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.637975, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.740139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.842279, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.944433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.023575, "o", "\u001b[4;39H\u001b[38;5;5;49mConfirm \u2500\u2500\u2500\u001b[5;22H\u001b[38;5;2;49mImported Model \u2014 4 categories, 2 measures\u001b[6;22H\u001b[39;49m \u001b[8;22H \u001b[32;22H\u001b[38;5;8;49mEnter: accept \u2190/Esc: back \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.125727, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.227889, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.330049, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.430444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.532612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.634784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.736931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.839078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.94124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.04341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.14557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.233632, "o", "\u001b[1;17H\u001b[1m\u001b[38;5;0;48;5;4mImported Model\u001b[1;32H[+]\u001b[3;2H\u001b[22m\u001b[38;5;5;49m [_Measure = Cost | Product = Widgets | Region = North] \u001b[4;2H\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01-15\u001b[24m 2025-01-22 2025-01-08 2025-01-30 2025-01-12 2025-02-05\u001b[22m\u001b[39;49m \u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6 _Dim (0) \u001b[5;2H\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[5;92H_Measure \u001b[5;102H2\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mAcme Corp \u001b[38;5;0;48;5;6m 7,200\u001b[22m\u001b[38;5;8;48;5;237m \u001b[39;48;5;237m \u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[6;92HCustomer (9)\u001b[6;106H\u001b[38;5;2;49m [Row \u2195]\u001b[7;2H\u001b[39;49mGlobex Inc\u001b[7;20H\u001b[38;5;8;49m \u001b[7;37H\u001b[39;49m5,100\u001b[38;5;8;49m "] +[6.23365, "o", " \u001b[7;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[7;92HDate\u001b[7;97H(40)\u001b[7;103H\u001b[38;5;4;49m [Col \u2194]\u001b[8;2H\u001b[39;49mInitech\u001b[8;20H\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m6,600\u001b[8;88H\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[8;92HProduct\u001b[8;100H(3)\u001b[8;105H\u001b[38;5;5;49m [Page \u2630\u001b[8;114H]\u001b[9;2H\u001b[39;49mSoylent\u001b[9;10HLtd\u001b[9;20H\u001b[38;5;8;49m \u001b[9;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[9;92HRegion\u001b[9;99H(4)\u001b[9;104H\u001b[38;5;5;49m [Page \u2630\u001b[9;113H]\u001b[10;2H\u001b[39;49mUmbrella\u001b[10;11HCo\u001b[10;20H\u001b[38;5;8;49m \u001b[10;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[10;100H\u001b[39;49m \u001b[11;2HWonka\u001b[11;8HIndustries\u001b[11;20H\u001b[38;5;8;49m \u001b[11;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[11;100H\u001b[39;49m \u001b[12;2HStark\u001b[12;8HEnterprises\u001b[12;20H\u001b[38;5;8;49m \u001b[12;88H\u001b[39;49m\u2502\u001b[38;5;8;49m"] +[6.233671, "o", "\u2502\u001b[12;100H\u001b[39;49m \u001b[13;2HOceanic\u001b[13;10HAirlines\u001b[13;20H\u001b[38;5;8;49m \u001b[13;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[13;100H\u001b[39;49m \u001b[14;2HCyberdyne\u001b[14;12HSystems\u001b[14;20H\u001b[38;5;8;49m \u001b[14;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[14;100H\u001b[39;49m \u001b[15;2H\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[15;100H\u001b[39;49m \u001b[16;2H\u001b[1m\u001b[38;5;3;49mTotal 7,200 5,100 0 0 0 6,600\u001b[16;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[16;100H\u001b[39;49m \u001b[17;21H \u001b[17;88H\u2502\u001b[38;5;8;49m\u2502\u001b[17;100H\u001b[39;49m \u001b[18;21H \u001b[18;88H\u2502\u001b[38;5;8;49m\u2502\u001b[18;100H\u001b[39;49m \u001b[19;21H \u001b[19;88H\u2502\u001b[38;5;8;49m\u2502\u001b[19;100H\u001b[39;49m \u001b[20;21H \u001b[20;88H\u2502\u001b["] +[6.233703, "o", "38;5;8;49m\u2502\u001b[20;100H\u001b[39;49m \u001b[21;21H \u001b[21;88H\u2502\u001b[38;5;8;49m\u2502\u001b[21;100H\u001b[39;49m \u001b[22;21H \u001b[22;88H\u2502\u001b[38;5;8;49m\u2502\u001b[22;100H\u001b[39;49m \u001b[23;21H \u001b[23;88H\u2502\u001b[38;5;8;49m\u2502\u001b[23;100H\u001b[39;49m \u001b[24;21H \u001b[24;88H\u2502\u001b[38;5;8;49m\u2502\u001b[24;100H\u001b[39;49m \u001b[25;21H \u001b[25;88H\u2502\u001b[38;5;8;49m\u2502\u001b[25;100H\u001b[39;49m \u001b[26;21H \u001b[26;88H\u2502\u001b[38;5;8;49m\u2502\u001b[26;100H\u001b[39;49m \u001b[27;21H \u001b[27;88H\u2502\u001b[38;5;8;49m\u2502\u001b[27;100H\u001b[39;49m \u001b[28;21H \u001b[28;88H\u2502\u001b[38;5;8;49m\u2502\u001b[28;100H\u001b[39;49m \u001b[29;21H \u001b[29;88H\u2502\u001b[38;5;8;49m\u2502\u001b[29;100H\u001b[39;49m \u001b[30;21H \u001b[30;88H\u2502\u001b[38;5;8;49m\u2502\u001b[30;100H\u001b[39;49m \u001b[31;21H \u001b[31;88H\u2502\u001b[38;5;8;49m\u2502\u001b[31;100H\u001b[39;49m \u001b[32;21H \u2502\u001b[38;5;8;49m\u2502\u001b[39;49m \u001b[33;21H \u2502\u001b[38;5;8;49m\u2502\u001b[39;49m \u001b[36;48H\u001b[38;5;2;49m [Customer Row] \u001b[38;5;4;49m [Date Col] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[37;3H\u001b[38;5;0;48;5;8mNORMAL\u001b[37;11HImport succ\u001b[37;23Hssful! Press :w \u001b[37;46"] +[6.233714, "o", "Hto save. \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.335974, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.439534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.542714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.64428, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.746394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.849834, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.953166, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.056487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.159873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.263221, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.365492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.468818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.570782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.634521, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.737821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.841115, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.945818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.050845, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.158455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.260439, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.362595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.466533, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.570302, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.681278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.778235, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.882984, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] diff --git a/docs/casts/pivot.cast b/docs/casts/pivot.cast index b0ea24f4..339e4fcb 100644 --- a/docs/casts/pivot.cast +++ b/docs/casts/pivot.cast @@ -1,522 +1,123 @@ -{"version": 2, "width": 120, "height": 37, "timestamp": 1775772168, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}} -[0.195175, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[0.202866, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[0.324014, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:02:48\r\n16023:% \u001b[K"] -[0.324144, "o", "\u001b[?2004h"] -[2.844656, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"] -[4.032794, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l"] -[4.03284, "o", "\r\r\n"] -[4.03374, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"] -[4.07901, "o", "\u001b[?1049h"] -[4.081296, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"] -[4.081395, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "] -[4.081493, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"] -[4.081554, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"] -[4.081585, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.183606, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.286393, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.390472, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.492202, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.594054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.696758, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.799994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[4.902051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.005122, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.108799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.212099, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.314466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.417267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.518871, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.621814, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.711323, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"] -[5.711359, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "] -[5.711509, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.813121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[5.91487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.017513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.118507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.220366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.322685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.424938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.526253, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.628282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.730046, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.831779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[6.933073, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.034386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.136138, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.237624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.339346, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.44047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.54239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.569716, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.670732, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.772512, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.873528, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[7.974952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.076658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.178544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.279559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.381257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.482995, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.585266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.686385, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.788117, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.890743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[8.988024, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.089784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.192696, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.295117, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.397096, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.448442, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.550536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.652481, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.754194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.778141, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.880846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[9.983079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.084998, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.110623, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.212706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.314212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.415972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.517497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.62025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.721782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.797927, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[10.90034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.00188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.1041, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.20582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.278559, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.38008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.481759, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.583563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.627199, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.729661, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.831725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[11.933955, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.034905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.137145, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.238572, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.34034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.441291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.543123, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.644683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.746513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.847672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[12.949646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.051292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.153276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.256497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.358742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.46022, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.56178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.663495, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.764432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.86667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[13.968591, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.069929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.172681, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.274414, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.377178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.478959, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.58086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.682785, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.769142, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;17H\u001b[22m\u001b[39;49m \u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[23m \u001b[8;17H2025-01 \u001b[3m 7,080 \u001b[8;34H11,80\u001b[8;40H \u001b[8;42H4,720\u001b[23m \u001b[9;2H \u001b[9;12H \u001b[9;17"] -[14.769179, "o", "H2025-03 \u001b[9;27H\u001b[3m7,500 12,500 5,000\u001b[23m \u001b[10;2HSprockets East 2025-02 \u001b[3m 2,940 4,200 1,260\u001b[23m \u001b[11;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[36;116H\u001b[38;5;0;48;5;6mR\u001b[36;118Hw\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HR\u001b[37;26H \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.870531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[14.972288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.074003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.175981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.277102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.378888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.480112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.581487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.681928, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.784289, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.886179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[15.988225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.08923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.191357, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.293039, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.395185, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.496391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.598014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.699411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.800941, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[16.902697, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.004348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.105735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.207365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.30897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.410872, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.511981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.613786, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.715365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.817026, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[17.918686, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.020006, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.121899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.223486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.324746, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.426154, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.527582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.629511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.731055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.832593, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[18.934443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.036075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.137379, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.239173, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.310363, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;2;49m [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.412423, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.514646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.6164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.718133, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.770074, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.872124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[19.973223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.07488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.129894, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.231558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.333386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.434821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.536254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.545792, "o", "\u001b[36;48H\u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[22m\u001b[38;5;8;49m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.647361, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.74837, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.849862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[20.912245, "o", "\u001b[3;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;25H\u001b[22m\u001b[24m\u001b[39;49m \u001b[4;27H\u001b[1m\u001b[4m\u001b[38;5;3;49mStark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines\u001b[6;12H\u001b[38;5;6;48;5;237mNor\u001b[6;16Hh 2025-01 \u001b[6;27H\u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[38;5;15;48;5;237m 4,340\u001b[38;5;8;48;5;237m \u001b[7;17H\u001b[23m\u001b[39;49m 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,110\u001b[38;5;8;49m \u001b[8;2H\u001b[23m\u001b[39;49m \u001b[8;12H \u001b[8;17H 2025-03 \u001b[3m\u001b[38;5;8;49m \u001b[9;12H\u001b[23m\u001b[39;49mEast\u001b[9;17H 2025-01 \u001b[9;27H\u001b[3m \u001b[9;34H 5,180\u001b[38;5;8;49m \u001b[39;49m 6,230\u001b[38;5;8;49m \u001b[10;2H\u001b[23m"] -[20.912321, "o", "\u001b[39;49m \u001b[10;12H \u001b[10;17H 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,440\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m 2025-03 \u001b[3m 5,670\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[12;2H South 2025-01 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,850\u001b[38;5;8;49m \u001b[13;18H\u001b[23m\u001b[39;49m2025-02\u001b[13;26H\u001b[3m\u001b[38;5;8;49m \u001b[14;18H\u001b[23m\u001b[39;49m2025-03\u001b[14;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,760\u001b[38;5;8;49m \u001b[15;12H\u001b[23m\u001b[39;49mWest\u001b[15;18H2025-01\u001b[15;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,690\u001b[38;5;8;49m \u001b[16;18H\u001b[23m\u001b[39;49m2025-02\u001b[16;26H\u001b[3m"] -[20.912448, "o", "\u001b[38;5;8;49m \u001b[39;49m 4,970\u001b[17;18H\u001b[23m2025-03\u001b[17;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,250\u001b[38;5;8;49m \u001b[18;2H\u001b[23m\u001b[39;49mWidgets\u001b[18;12HNorth\u001b[18;18H2025-01\u001b[18;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 7,200\u001b[38;5;8;49m \u001b[19;18H\u001b[23m\u001b[39;49m2025-02\u001b[19;26H\u001b[3m\u001b[38;5;8;49m \u001b[20;18H\u001b[23m\u001b[39;49m2025-03\u001b[20;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,100\u001b[38;5;8;49m \u001b[21;12H\u001b[23m\u001b[39;49mEast\u001b[21;18H2025-01\u001b[21;26H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 8,520\u001b[38;5;8;49m \u001b[22;18H\u001b[23m\u001b[39;49m2025-02\u001b[22;26H\u001b[3m\u001b[38;5;8;49m "] -[20.912533, "o", " \u001b[39;49m 9,000\u001b[38;5;8;49m \u001b[23;18H\u001b[23m\u001b[39;49m2025-03\u001b[23;26H\u001b[3m 7,500\u001b[38;5;8;49m \u001b[24;12H\u001b[23m\u001b[39;49mSouth\u001b[24;18H2025-01\u001b[24;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[25;18H\u001b[23m\u001b[39;49m2025-02\u001b[25;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,120\u001b[38;5;8;49m \u001b[26;18H\u001b[23m\u001b[39;49m2025-03\u001b[26;26H\u001b[3m\u001b[38;5;8;49m \u001b[27;12H\u001b[23m\u001b[39;49mWest\u001b[27;18H2025-01\u001b[27;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,300\u001b[28;18H\u001b[23m2025-02\u001b[28;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,720\u001b[38;5;8;49m "] -[20.912627, "o", " \u001b[29;18H\u001b[23m\u001b[39;49m2025-03\u001b[29;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[39;49m 7,680\u001b[30;2H\u001b[23mSprockets\u001b[30;12HNorth\u001b[30;18H2025-01\u001b[30;26H\u001b[3m\u001b[38;5;8;49m \u001b[31;18H\u001b[23m\u001b[39;49m2025-02\u001b[31;26H\u001b[3m\u001b[38;5;8;49m \u001b[32;12H\u001b[23m\u001b[39;49mEast\u001b[32;18H2025-02\u001b[32;26H\u001b[3m 2,940\u001b[38;5;8;49m \u001b[33;18H\u001b[23m\u001b[39;49m2025-03\u001b[33;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,360\u001b[38;5;8;49m \u001b[34;12H\u001b[23m\u001b[39;49mSouth\u001b[34;18H2025-01\u001b[34;26H\u001b[3m\u001b[38;5;8;49m \u001b[36;59H\u001b[23m\u001b[1m\u001b[38;5;0;48;5;6mCol\u001b[37;10H\u001b[22m\u001b[38;5;0;"] -[20.912712, "o", "48;5;5mCustomer →\u001b[37;21HCol \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.021241, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.130594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.237986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.347183, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.455154, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.566032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.672926, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.780079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.888829, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[21.996791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.105822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.213368, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.320348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.426688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.535526, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.643373, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.749869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.858388, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[22.965471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.075854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.185921, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.294418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.402404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.510325, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.620269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.732998, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.844083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[23.949977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.059804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.167351, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.273888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.38234, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.490268, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.598348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.707559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.815903, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[24.925818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.031393, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.140847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.247853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.356972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.468231, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.578125, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.685815, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.793907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[25.906736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.01559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.123245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.231876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.339574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.446666, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.554584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.612204, "o", "\u001b[36;48H\u001b[38;5;4;49m [Customer Col] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.721803, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.82881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[26.937051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.045557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.101995, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.209792, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.318377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.426665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.487816, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.595002, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.703005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.811798, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[27.894092, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.002212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.111067, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.21876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.327108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.435327, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.544297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.588175, "o", "\u001b[3;2H\u001b[38;5;5;49m [Date_Month = 2025-01] \u001b[39;49m \u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;27H Cos\u001b[4;36H \u001b[22m\u001b[24m\u001b[39;49m \u001b[5;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Stark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[38;5;15;48;5;237m 4,340\u001b[38;5;8;"] -[28.588511, "o", "48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[8;12H\u001b[39;49mEast\u001b[8;18H\u001b[3m 5,180\u001b[8;48H 6,230\u001b[8;110H\u001b[23m \u001b[9;12HSou\u001b[9;16Hh\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;39H\u001b[39;49m 3,850\u001b[9;56H\u001b[38;5;8;49m \u001b[9;110H\u001b[23m\u001b[39;49m \u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;56H \u001b[10;68H\u001b[39;49m 4,690\u001b[10;110H\u001b[23m \u001b[11;2HWidgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m \u001b[11;83H\u001b[39;49m 7,200\u001b[11;110H\u001b[23m \u001b[12;12HEas\u001b[12;16H \u001b[12;18H\u001b[3m 7,080\u001b[12;44H\u001b[38;5;8;49m \u001b[12;51H\u001b[39;49m 8,520\u001b[12;110H\u001b[23m \u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;36H\u001b[39;49m 5,880\u001b[13;110H\u001b[23m \u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;44H \u001b[14;93H\u001b[39;49m 6,300\u001b[23m \u001b[15;2HSprockets\u001b[15;12HNor\u001b[15;16Hh\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;73H \u001b[15;110H\u001b[23m\u001b[39;49m \u001b[16;12HSouth\u001b[16;18H\u001b[3m\u001b[38;5;8;"] -[28.588661, "o", "49m \u001b[16;101H \u001b[23m\u001b[39;49m \u001b[17;12HWest\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;73H \u001b[17;93H\u001b[39;49m 2,240\u001b[23m \u001b[18;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[19;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 9,730 14,750 4,690 11,540 8,540\u001b[22m\u001b[39;49m \u001b[20;18H \u001b[20;26H \u001b[21;12H \u001b[21;18H \u001b[21;26H \u001b[22;18H \u001b[22;26H "] -[28.588738, "o", " \u001b[23;18H \u001b[23;26H \u001b[24;12H \u001b[24;18H \u001b[24;26H \u001b[25;18H \u001b[25;26H \u001b[26;18H \u001b[26;26H \u001b[27;12H \u001b[27;18H \u001b[27;26H \u001b[28;18H \u001b[28;26H \u001b[29;18H \u001b[29;26H \u001b[30;2H \u001b[30;12H \u001b[30;18H \u001b[30;26H \u001b[31;18H \u001b[31;26H "] -[28.588853, "o", " \u001b[32;12H \u001b[32;18H \u001b[32;26H \u001b[33;18H \u001b[33;26H \u001b[34;12H \u001b[34;18H \u001b[34;26H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6mPag\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month\u001b[37;21H→ Page\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.691453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.796795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[28.902199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.006987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.111806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.215479, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.319908, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.423309, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.528042, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.632673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.737576, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.841402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[29.945086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.048563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.152632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.257558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.361587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.466201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.569187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.673297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.776706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.880876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[30.984693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.088614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.192586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.29718, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.325158, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;5;49m [Date_Month Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.43008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.533714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.640572, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.745475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.848524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[31.930201, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.033502, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.136248, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.239752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.342356, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.429596, "o", "\u001b[36;48H\u001b[1m\u001b[38;5;0;48;5;6m [Customer Col] \u001b[22m\u001b[38;5;8;49m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.532883, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.635586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.738898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.84424, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.947995, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[32.984771, "o", "\u001b[3;4H\u001b[38;5;5;49mCustomer =\u001b[3;15HStark Enterprises | Date_Month = 2025-01] \u001b[4;15H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[8;2H\u001b[38;5;8;49m──────────────────────"] -[32.984836, "o", "────────────────────────────────────────────────────────────────────────────────────────────────\u001b[9;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 19,200 6,940\u001b[22m\u001b[39;49m \u001b[10;12H \u001b[10;18H \u001b[11;2H \u001b[11;12H \u001b[11;18H \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b"] -[32.984988, "o", "[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;2H \u001b[19;2H \u001b[36;59H\u001b[1m\u001b[38;5;0;48;5;6mPag\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mCustomer →\u001b[37;21HPage \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.086487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.18779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.289838, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.391186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.492681, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.593254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.69439, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.795177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.896392, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[33.998164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.103769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.204562, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.305805, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.407428, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.50897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.610366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.71215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.813785, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[34.915156, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.017084, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.118645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.220083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.321694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.414691, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.515987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.617711, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.719335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.820861, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[35.922469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.02437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.126192, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.14992, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.251377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.352991, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.455271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.556932, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.658353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.759434, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.860885, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[36.961396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.063528, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.165156, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.2662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.367788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.469285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.571052, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.672135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.774993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.876832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[37.978463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.079993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.181443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.282509, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.38423, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.485301, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.586649, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.689199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.791226, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.892952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[38.993685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.095155, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.19662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.298101, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.310417, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.41267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.514667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.617075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.718774, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.820583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[39.922075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.023689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.125316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.226916, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.230596, "o", "\u001b[3;32H\u001b[38;5;5;49m] \u001b[39;49m \u001b[4;15H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit\u001b[6;10H\u001b[38;5;6;48;5;237m East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[7;2H\u001b[23m\u001b[39;49m \u001b[7;10H \u001b[7;15H 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[8;2H\u001b[23mWidgets East 2025-01 \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[9;2H 2025-03 \u001b[3m 7,500 12,500 5,000\u001b[10;2H\u001b[23mSprockets\u001b[10;12HEast\u001b[10;17H2025-02\u001b[10;25H\u001b[3m 2,940 4,200 1,260\u001b[11;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b"] -[40.230708, "o", "[36;116H\u001b[38;5;0;48;5;6mRow\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month\u001b[37;21H→ Row\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.332315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.433228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.535032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.636694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.737953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.838891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[40.940545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.042589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.144286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.246074, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.347233, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.448212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.549993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.651976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.753813, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.854633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[41.957087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.059085, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.16124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.262945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.365201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.467407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.482247, "o", "\u001b[36;103H\u001b[38;5;2;49m [Date_Month Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.583904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.685078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.78682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.888734, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[42.98978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.091781, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.193421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.294857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.395621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.498973, "o", "\u001b[4;10H\u001b[1m\u001b[4m\u001b[38;5;3;49m Gadgets \u001b[4;26H\u001b[24m Widgets \u001b[4;41H Sprockets\u001b[5;2H\u001b[22m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m East \u001b[24m East East\u001b[22m\u001b[39;49m \u001b[6;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-03 2025-01 2025-03 2025-02\u001b[22m\u001b[39;49m \u001b[7;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[8;2H\u001b[1m\u001b[38;5;6;48;5;237mCost \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 5,670 7,080 7,500 2,940\u001b[23m\u001b[39;48;5;237m \u001b[9;2H\u001b[39;49mRevenu"] -[43.499016, "o", "e\u001b[9;10H\u001b[3m 7,400 8,100\u001b[9;27H 11,800 12,500 4,200\u001b[10;2H\u001b[23mProfit \u001b[3m 2,220 2,430\u001b[10;27H 4,720\u001b[10;35H 5,\u001b[10;40H00 1,260\u001b[12;12H\u001b[23m\u001b[1m\u001b[38;5;3;49m14,800\u001b[12;20H16,200 2\u001b[12;30H,600 25,\u001b[12;40H00 8,400\u001b[36;32H\u001b[22m\u001b[38;5;2;49m [_Measure Row] \u001b[36;74H\u001b[38;5;4;49m [Product Col] [Region Col] [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.600963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.703421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.804551, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[43.907097, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.008979, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.110755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.212471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.314251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.415568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.517273, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.618742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.705815, "o", "\u001b[4;10H \u001b[4;26H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue\u001b[4;41HProfit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[23m \u001b[8;2HWidgets East 2025-01 \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[9;2H \u001b[9;10H 2025-03 \u001b[3m \u001b[9;27H7,500 12,500 5,000\u001b[2"] -[44.705935, "o", "3m \u001b[10;2HSprockets East 2025-02 \u001b[3m \u001b[10;27H2,940 \u001b[10;35H4,20\u001b[10;40H 1,260\u001b[23m \u001b[12;12H\u001b[1m\u001b[38;5;3;49m \u001b[12;20H 28,\u001b[12;30H70 44,00\u001b[12;40H 15,630\u001b[22m\u001b[39;49m \u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[36;74H\u001b[38;5;2;49m [Product Row] [Region Row] [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.807627, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[44.90931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.010483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.11209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.214054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.315824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.417061, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.518689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.62005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.72157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.82307, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[45.924911, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.025947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.128324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.229411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.331387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.432521, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.534601, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.635595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.737399, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.838296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[46.939905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.041873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.143353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.245237, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.346678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.448408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.550153, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.651708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.753436, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.854847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[47.956529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.05797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.15988, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.261094, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.362924, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.464278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.566104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.667592, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.769687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.871205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[48.972328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.07408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.175891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.277195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.3788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.442395, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.544319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.645499, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.747199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.848828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.863148, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[49.964715, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.066286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.167945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.269492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.350163, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.452616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.554188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.655924, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.757431, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.859183, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[50.960929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.062657, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.163616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.265278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.367394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.468649, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.570276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.671308, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.773372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.874565, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[51.97578, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.07771, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.179348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] -[52.18419, "o", "\u001b[?1049l"] -[52.18437, "o", "\u001b[?25h"] -[52.186019, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"] -[52.233127, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"] -[52.387142, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:03:40\r\n16024:% \u001b[K"] -[52.387248, "o", "\u001b[?2004h"] -[53.608892, "o", "\u001b[?2004l\r\r\n"] +{"version": 2, "width": 120, "height": 37, "idle_time_limit": 2.0, "title": "improvise \u2014 pivot"} +[0.00824, "o", "\u001b[?1049h"] +[0.010718, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise \u00b7 Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m\u250c\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[38;5;8;49m\u250c Categories \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001b[3;1H\u001b[39;49m\u2502\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;88H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[3;92H_Index\u001b[3;99H(0)\u001b[3;104H\u001b[38;5;8;49m [None \u2205]\u001b[3;120H\u2502\u001b[4;1H\u001b[39;49m\u2502\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[4;92H_Dim\u001b[4;97H(0)\u001b[4;102H\u001b[38;5;8;49m [None \u2205]\u001b[4;120H\u2502\u001b[5;1H\u001b[39;49m\u2502\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02\u001b[5;88H\u001b[22m\u001b[39"] +[0.010728, "o", ";49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[5;92H_Measure\u001b[5;101H(3)\u001b[5;106H\u001b[38;5;4;49m [Col \u2194]\u001b[5;120H\u001b[38;5;8;49m\u2502\u001b[6;1H\u001b[39;49m\u2502\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[6;92HCustomer\u001b[6;101H(9)\u001b[6;106H\u001b[38;5;5;49m [Page \u2630\u001b[6;115H]\u001b[6;120H\u001b[38;5;8;49m\u2502\u001b[7;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[7;92HDate\u001b[7;97H(40)\u001b[7;103H\u001b[38;5;8;49m [None \u2205]\u001b[7;120H\u2502\u001b[8;1H\u001b[39;49m\u2502\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[8;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[8;92HP"] +[0.010744, "o", "roduct\u001b[8;100H(3)\u001b[8;105H\u001b[38;5;2;49m [Row \u2195]\u001b[8;120H\u001b[38;5;8;49m\u2502\u001b[9;1H\u001b[39;49m\u2502\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[9;92HRegion\u001b[9;99H(4)\u001b[9;104H\u001b[38;5;2;49m [Row \u2195]\u001b[9;120H\u001b[38;5;8;49m\u2502\u001b[10;1H\u001b[39;49m\u2502\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[39;49m\u25b6\u001b[10;92HDate_Month\u001b[10;103H(3)\u001b[10;108H\u001b[38;5;4;49m [Col \u2194]\u001b[10;120H\u001b[38;5;8;49m\u2502\u001b[11;1H\u001b[39;49m\u2502Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m \u001b[11;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[11;120H\u2502\u001b[12;1H\u001b[39;49m\u2502\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[12;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[12;120H\u2502\u001b[13;1H\u001b[39;49m\u2502\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m "] +[0.010763, "o", " \u001b[13;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[13;120H\u2502\u001b[14;1H\u001b[39;49m\u2502\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[14;120H\u2502\u001b[15;1H\u001b[39;49m\u2502Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[15;120H\u2502\u001b[16;1H\u001b[39;49m\u2502\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[16;88H\u001b[23m\u2502\u001b[38;5;8;49m\u2502\u001b[16;120H\u2502\u001b[17;1H\u001b[39;49m\u2502\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[17;120H\u2502\u001b[18;1H\u001b[39;49m\u2502\u001b[18;12HWest\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;88H\u001b[23m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[18;120H\u2502\u001b[19;1H\u001b[39;49m\u2502\u001b[38;5;8;49m"] +[0.010784, "o", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[19;120H\u2502\u001b[20;1H\u001b[39;49m\u2502\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260\u001b[20;88H\u001b[22m\u001b[39;49m\u2502\u001b[38;5;8;49m\u2502\u001b[20;120H\u2502\u001b[21;1H\u001b[39;49m\u2502\u001b[21;88H\u2502\u001b[38;5;8;49m\u2502\u001b[21;120H\u2502\u001b[22;1H\u001b[39;49m\u2502\u001b[22;88H\u2502\u001b[38;5;8;49m\u2502\u001b[22;120H\u2502\u001b[23;1H\u001b[39;49m\u2502\u001b[23;88H\u2502\u001b[38;5;8;49m\u2502\u001b[23;120H\u2502\u001b[24;1H\u001b[39;49m\u2502\u001b[24;88H\u2502\u001b[38;5;8;49m\u2502\u001b[24;120H\u2502\u001b[25;1H\u001b[39;49m\u2502\u001b[25;88H\u2502\u001b[38;5;8;49m\u2502\u001b[25;120H\u2502\u001b[26;1H\u001b[39;49m\u2502\u001b[26;88H\u2502\u001b[38;5;8;49m\u2502\u001b[26;120H\u2502\u001b[27;1H\u001b[39;49m\u2502\u001b[27;88H\u2502\u001b[38;5;8;49m\u2502\u001b[27;120H\u2502\u001b[28;1H\u001b[39;49m\u2502\u001b[28;88H\u2502\u001b[38;5;8;49m\u2502\u001b[28;120H\u2502\u001b[29;1H\u001b[39;49m\u2502\u001b[29;88H\u2502\u001b[38;5;8;49m\u2502\u001b[29;120H\u2502\u001b[30;1H\u001b[39;49m\u2502\u001b[30;88H\u2502\u001b[38;5;8;49m\u2502\u001b[30;120H\u2502\u001b["] +[0.010803, "o", "31;1H\u001b[39;49m\u2502\u001b[31;88H\u2502\u001b[38;5;8;49m\u2502\u001b[31;120H\u2502\u001b[32;1H\u001b[39;49m\u2502\u001b[32;88H\u2502\u001b[38;5;8;49m\u2502\u001b[32;120H\u2502\u001b[33;1H\u001b[39;49m\u2502\u001b[33;88H\u2502\u001b[38;5;8;49m\u2502\u001b[33;120H\u2502\u001b[34;1H\u001b[39;49m\u2502\u001b[34;88H\u2502\u001b[38;5;8;49m\u2502\u001b[34;120H\u2502\u001b[35;1H\u001b[39;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[38;5;8;49m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] [_Dim \u00b7] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date \u00b7] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl\u2190\u2191\u2192\u2193:nav ai:edit R:records P:prune F:formulas C:categories V:views T:tiles [:prev-page ]:next-pag\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.113594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.216309, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.319069, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.421861, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.524574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.627301, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.730025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.832574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[0.934646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.03742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.140227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.242954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.345694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.448439, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.551164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.60679, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index \u00b7] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES hl\u2190\u2192:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.709471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.812277, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[1.915005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.017728, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.120436, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.223184, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.32591, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.42863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.531316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.634027, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.736677, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.810908, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim \u00b7] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[2.913611, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.016275, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.118914, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.17099, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.274291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.377983, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.48162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.531951, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.635296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.738948, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.842562, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.882724, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date \u00b7] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[3.985398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.089296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.192493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.242332, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date \u00b7] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.346109, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.448809, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.552424, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.599678, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;8H\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;22H \u001b[4;42H \u001b[5;8H Gadgets \u001b[5;19H \u001b[24m Widgets \u001b[5;43H \u001b[5;51H Sprockets\u001b[5;67H \u001b[5;75H \u001b[6;2H\u001b[22m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;2H\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[8;2H\u001b[1m\u001b[38;5;6;48;5;237mNorth \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[8;105H\u001b[38;5;4;49m [Col \u2194]\u001b[9;2H\u001b[39;49mEast\u001b[9;8H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[9;24H\u001b[39;49m 5,670 7,080\u001b[9;48H 7,500\u001b[9;66H 2,940\u001b[9;82H\u001b[38;5;8;49m \u001b[10;2H\u001b[23m\u001b[39;49m"] +[4.59972, "o", "South\u001b[10;8H\u001b[3m\u001b[38;5;8;49m \u001b[10;82H \u001b[11;3H\u001b[23m\u001b[39;49mest \u001b[3m\u001b[38;5;8;49m \u001b[11;82H \u001b[12;2H\u001b[23m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[13;2H\u001b[1m\u001b[38;5;3;49mTotal 5,180 0 5,670 7,080 0 7,500 0 2,940 0\u001b[14;12H\u001b[22m\u001b[39;49m \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H "] +[4.599766, "o", " \u001b[20;2H \u001b[36;84H\u001b[1m\u001b[38;5;0;48;5;6mC\u001b[36;86Hl\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mProduct \u2192 Col \u001b[37;42H \u001b[37;49H \u001b[37;56H \u001b[37;64H \u001b[37;108H Default\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.70338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.805285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[4.908241, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.011837, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.114534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.217208, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.320236, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.423831, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.527439, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.630676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.734425, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.838082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[5.940768, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.043457, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.146139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.248828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.351066, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.453816, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.556508, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.659257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.761965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.800917, "o", "\u001b[4;8H \u001b[4;22H\u001b[1m\u001b[4m\u001b[38;5;3;49mCost\u001b[4;42H\u001b[24m Revenue Profit \u001b[22m\u001b[39;49m \u001b[5;8H \u001b[5;19H\u001b[1m\u001b[4m\u001b[38;5;3;49m2025-01\u001b[24m 2025-02 2025-03\u001b[5;43H2025-01\u001b[5;51H2025-02 2025-03\u001b[5;67H2025-01\u001b[5;75H2025-02\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[38;5;8;49m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49m East \u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[8;105H\u001b[38;5;2;49m [Row \u2195]\u001b[9;2H\u001b[39;49m \u001b[9;8H South \u001b[9;24H\u001b[3m\u001b[38;5;8;49m \u001b[9;48H \u001b[9;66H "] +[6.800947, "o", "\u001b[9;82H\u001b[23m\u001b[39;49m \u001b[10;2H \u001b[10;8H West \u001b[10;82H \u001b[11;3Hidgets North \u001b[11;82H \u001b[12;2H East \u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[13;2H South \u001b[3m\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;2H\u001b[23m\u001b[39;49mSprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[16;12H\u001b[23m\u001b[39;49mEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[17;12H\u001b[23mSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;12H\u001b[23m\u001b[39;49mWest\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[19;2H\u001b[23m\u2500\u2500\u2500\u2500\u2500\u2500\u2500"] +[6.800971, "o", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[20;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260\u001b[36;84H\u001b[38;5;0;48;5;6mR\u001b[36;86Hw\u001b[37;20H\u001b[22m\u001b[38;5;0;48;5;5mR\u001b[37;22Hw\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[6.903724, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.006438, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.107813, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.21049, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.313243, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.415929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.518618, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.62134, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.72403, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.826802, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[7.929516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.03224, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.134921, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.237609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.340279, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.442985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.54568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.648387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.74982, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.852504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[8.955191, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.003008, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Product \u2192 Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.105732, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.208518, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.311187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.413891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.516585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.619324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.721926, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.824649, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[9.927433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.030203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.132942, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.235699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.338384, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.441134, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.543863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.646616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.748527, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.851224, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] +[10.953268, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"] diff --git a/docs/index.html b/docs/index.html index ebcfc879..f2427fae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -9,8 +9,8 @@ content="Terminal pivot-table modeling in the spirit of Lotus Improv: multidimensional cells, formulas over dimensions instead of cell addresses, vim-style keybindings." /> + 120x37 is an INPUT now, not whatever the recorder had: the casts are + made by docs/record-casts.py, which sets it (improvise-2oxt). --> * { + min-width: 880px; + } + } pre { background: var(--panel); border: 1px solid var(--rule); @@ -156,11 +159,6 @@

An aggregated cell opens onto the records behind it, editable in place.

-

- Recorded 2026-04-09. The records grid has changed since: it now has a - column per measure plus columns for adding a category and a measure, - where this recording shows a single Value column. -

Formulas over dimensions

diff --git a/docs/record-casts.py b/docs/record-casts.py new file mode 100755 index 00000000..8b1cad71 --- /dev/null +++ b/docs/record-casts.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Record the four asciicasts under docs/casts/ from a driven improvise session. + +Not `asciinema rec`: that records a SHELL, and the shell it records is the +recorder's own — every previous cast opens and closes on a real hostname and +home-directory path on what is now the site root (improvise-2oxt). Here there is +no shell at all. `improvise` is the pty's only child, so there is nothing for a +prompt to leak into, and the geometry, the pacing and the keys are inputs rather +than whatever the recording machine happened to have. + +Usage: python3 docs/record-casts.py [--out docs/casts] [--bin target/release/improvise] + +The output is asciicast v2, which is what `asciinema-player` on docs/index.html +reads: one JSON header line, then `[time, "o", data]` per chunk. +""" + +import argparse +import fcntl +import json +import os +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time + +COLS, ROWS = 120, 37 + +# Long enough that a reader can follow, short enough that a loop is bearable. +BEAT = 0.9 +SETTLE = 0.35 + + +def keys(*items): + """A step list from (text, pause) pairs, with the pause defaulted.""" + return [(k, BEAT) if isinstance(k, str) else k for k in items] + + +ESC = "\x1b" + + +def scenarios(root): + """The four the landing page embeds, each named for the section over it.""" + demo = os.path.join(root, "examples/demo.improv") + csv = os.path.join(root, "examples/demo.csv") + return { + # "Reassign an axis": the tile bar, a category moved, the grid redrawn. + "pivot": ( + [demo], + keys( + ("", 1.6), + ("T", 1.2), + *[("\x1b[C", 0.35)] * 5, + ("c", 2.2), + ("r", 2.2), + (ESC, 1.6), + ), + ), + # "Drill into an aggregate": the records grid behind one cell, which is + # the cast 2oxt says is materially wrong — it predates the +cat and + + # columns entirely. + "drill": ( + [demo], + keys( + ("", 1.6), + # Customer off the page axis first, so a cell aggregates over + # all nine of them and the drill has more than one record to + # show. Drilling the shipped layout shows exactly one row, + # which is true and demonstrates nothing. + ("T", 1.0), + *[("\x1b[C", 0.35)] * 3, + (" ", 1.8), + (ESC, 1.2), + # Gadgets/East at Cost 2025-01, which has records behind it. + # The origin cell is blank, and a drill into nothing draws an + # empty records grid — a true frame about the wrong thing. + ("j", 1.0), + (">", 2.2), + *[("\x1b[C", 0.45)] * 6, + ("", 1.4), + (ESC, 1.6), + ), + ), + # "Formulas over dimensions": the panel, then the formula that is + # already in the document, read as text rather than as a cell address. + "formulas": ( + [demo], + keys( + ("", 1.6), + # The panel first: the formula is TEXT over categories and + # items, which is the claim the section makes. + ("F", 2.4), + (ESC, 1.0), + # Then move a category and let the Profit column follow. A + # formula naming `B3` could not survive this, which is the + # whole point and is only visible if the grid really moves. + ("T", 1.0), + *[("\x1b[C", 0.35)] * 7, + ("r", 2.6), + (ESC, 2.0), + ), + ), + # "Import a CSV": the wizard's own steps, stepped through. + "import": ( + ["import", csv, "-o", "/dev/null"], + keys( + ("", 1.4), + ("\r", 1.2), + ("\r", 1.2), + ("\r", 1.2), + ("\r", 1.2), + ("\r", 1.4), + (ESC, 1.0), + ), + ), + } + + +def record(binary, argv, steps, cwd): + """Drive one session on a pty and return its asciicast event list.""" + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) + env = { + "TERM": "xterm-256color", + "PATH": "/usr/bin:/bin", + "LANG": "en_US.UTF-8", + "HOME": "/tmp", + # `improvise` is the only child, so nothing else can print a path. + "COLUMNS": str(COLS), + "LINES": str(ROWS), + } + child = subprocess.Popen( + [binary, *argv], + stdin=slave, + stdout=slave, + stderr=slave, + env=env, + cwd=cwd, + close_fds=True, + ) + os.close(slave) + + started = time.time() + events = [] + + def drain(until): + """Everything the child says before `until`, timestamped as it arrives.""" + while True: + budget = until - time.time() + if budget <= 0: + return + ready, _, _ = select.select([master], [], [], min(budget, 0.05)) + if not ready: + continue + try: + chunk = os.read(master, 65536) + except OSError: + return + if not chunk: + return + events.append( + [round(time.time() - started, 6), "o", chunk.decode("utf-8", "replace")] + ) + + for text, pause in steps: + if text: + os.write(master, text.encode()) + drain(time.time() + pause) + + # No `:q` typed at the end: the player loops, so the last frame is also the + # frame after the last one, and a minibuffer reading `:q` is what it would + # show. The process is signalled instead and the recording ends on the UI. + drain(time.time() + SETTLE) + if child.poll() is None: + child.send_signal(signal.SIGTERM) + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill() + os.close(master) + return events + + +def main(): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=os.path.join(root, "docs/casts")) + ap.add_argument("--bin", default=os.path.join(root, "target/release/improvise")) + ap.add_argument("--only", default=None) + args = ap.parse_args() + + if not os.path.exists(args.bin): + sys.exit(f"no binary at {args.bin} — cargo build --release --bin improvise") + + for name, (argv, steps) in scenarios(root).items(): + if args.only and name != args.only: + continue + events = record(args.bin, argv, steps, root) + header = { + "version": 2, + "width": COLS, + "height": ROWS, + # No `timestamp` and no `env`: the first carries the recording + # machine's clock and the second carried $SHELL (improvise-2oxt). + "idle_time_limit": 2.0, + "title": f"improvise — {name}", + } + path = os.path.join(args.out, f"{name}.cast") + with open(path, "w") as out: + out.write(json.dumps(header) + "\n") + for event in events: + out.write(json.dumps(event) + "\n") + print(f"{path}: {len(events)} chunks, {events[-1][0] if events else 0:.1f}s") + + +if __name__ == "__main__": + main() diff --git a/harness/fixtures/tall.improv b/harness/fixtures/tall.improv new file mode 100644 index 00000000..5daa32d2 --- /dev/null +++ b/harness/fixtures/tall.improv @@ -0,0 +1,114 @@ +v2025-04-09 +# Harness tall fixture +Initial View: Tall + +## View: Default +_Index: none +_Dim: none +_Measure: page +Region: row +format: ,.0 + +## View: Tall +_Index: none +_Dim: none +_Measure: column +Region: row +format: ,.0 + +## Category: _Measure +- Revenue, Cost + +## Category: Region +- R01[Alpha] +- R02[Alpha] +- R03[Alpha] +- R04[Alpha] +- R05[Alpha] +- R06[Alpha] +- R07[Alpha] +- R08[Alpha] +- R09[Alpha] +- R10[Alpha] +- R11[Beta] +- R12[Beta] +- R13[Beta] +- R14[Beta] +- R15[Beta] +- R16[Beta] +- R17[Beta] +- R18[Beta] +- R19[Beta] +- R20[Beta] +- R21[Gamma] +- R22[Gamma] +- R23[Gamma] +- R24[Gamma] +- R25[Gamma] +- R26[Gamma] +- R27[Gamma] +- R28[Gamma] +- R29[Gamma] +- R30[Gamma] + +## Data +Region=R01, _Measure=Cost = 50 +Region=R01, _Measure=Revenue = 100 +Region=R02, _Measure=Cost = 51 +Region=R02, _Measure=Revenue = 101 +Region=R03, _Measure=Cost = 52 +Region=R03, _Measure=Revenue = 102 +Region=R04, _Measure=Cost = 53 +Region=R04, _Measure=Revenue = 103 +Region=R05, _Measure=Cost = 54 +Region=R05, _Measure=Revenue = 104 +Region=R06, _Measure=Cost = 55 +Region=R06, _Measure=Revenue = 105 +Region=R07, _Measure=Cost = 56 +Region=R07, _Measure=Revenue = 106 +Region=R08, _Measure=Cost = 57 +Region=R08, _Measure=Revenue = 107 +Region=R09, _Measure=Cost = 58 +Region=R09, _Measure=Revenue = 108 +Region=R10, _Measure=Cost = 59 +Region=R10, _Measure=Revenue = 109 +Region=R11, _Measure=Cost = 60 +Region=R11, _Measure=Revenue = 110 +Region=R12, _Measure=Cost = 61 +Region=R12, _Measure=Revenue = 111 +Region=R13, _Measure=Cost = 62 +Region=R13, _Measure=Revenue = 112 +Region=R14, _Measure=Cost = 63 +Region=R14, _Measure=Revenue = 113 +Region=R15, _Measure=Cost = 64 +Region=R15, _Measure=Revenue = 114 +Region=R16, _Measure=Cost = 65 +Region=R16, _Measure=Revenue = 115 +Region=R17, _Measure=Cost = 66 +Region=R17, _Measure=Revenue = 116 +Region=R18, _Measure=Cost = 67 +Region=R18, _Measure=Revenue = 117 +Region=R19, _Measure=Cost = 68 +Region=R19, _Measure=Revenue = 118 +Region=R20, _Measure=Cost = 69 +Region=R20, _Measure=Revenue = 119 +Region=R21, _Measure=Cost = 70 +Region=R21, _Measure=Revenue = 120 +Region=R22, _Measure=Cost = 71 +Region=R22, _Measure=Revenue = 121 +Region=R23, _Measure=Cost = 72 +Region=R23, _Measure=Revenue = 122 +Region=R24, _Measure=Cost = 73 +Region=R24, _Measure=Revenue = 123 +Region=R25, _Measure=Cost = 74 +Region=R25, _Measure=Revenue = 124 +Region=R26, _Measure=Cost = 75 +Region=R26, _Measure=Revenue = 125 +Region=R27, _Measure=Cost = 76 +Region=R27, _Measure=Revenue = 126 +Region=R28, _Measure=Cost = 77 +Region=R28, _Measure=Revenue = 127 +Region=R29, _Measure=Cost = 78 +Region=R29, _Measure=Revenue = 128 +Region=R30, _Measure=Cost = 79 +Region=R30, _Measure=Revenue = 129 diff --git a/harness/tests/contract.spec.js b/harness/tests/contract.spec.js index 74093787..06c87b03 100644 --- a/harness/tests/contract.spec.js +++ b/harness/tests/contract.spec.js @@ -26,6 +26,15 @@ test.describe("every field the render model projects reaches the screen", () => await expect(page.locator("#grid tbody th").first()).toHaveText("Espresso"); await expect(page.locator("#grid tbody td").first()).not.toHaveText(""); + // `doc_name`, `doc_stored_as`: the title bar. improvise-z754 projected + // these and nothing painted them for a wave, which `render_surface`'s + // substring search cannot see — only a loaded page can (improvise-5jna). + // The dirty marker's own spelling is `web/tests/shell.test.js`: this + // deployment persists on every accepted batch, so `[+]` is transient. + await expect(page.locator("#doc-name")).toHaveText( + "Corner Coffee — 2025 (demo.improv)", + ); + // `mode`, `hint`, `view_name`, `status`: the header's four facts. await expect(page.locator("#mode")).toHaveText("NORMAL"); await expect(page.locator("#hint")).not.toHaveText(""); @@ -212,4 +221,73 @@ test.describe("a pointer event reaches the client and its answer is painted", () await expect(page.locator("#mode")).toHaveText("FORMULAS"); await expect(page.locator("#formulas li.selected")).toHaveCount(1); }); + + // improvise-0x9u. The neighbouring state the assertion above cannot see: it + // presses Escape first, so it only ever clicks from a NON-editing page. + test("a click moves the selection from an editing state too", async ({ page }) => { + await openApp(page); + const cells = page.locator("#grid tbody td"); + + await cells.nth(0).click(); + await expect(cells.nth(0).locator("input")).toHaveCount(1); + + // No Escape between the two clicks — that is the whole test. The blur used + // to repaint and `replaceChildren` destroyed the the mousedown landed + // on, so no click event was ever dispatched and the pointer was dead. + await cells.nth(4).click(); + await expect(cells.nth(4).locator("input")).toHaveCount(1); + await expect(cells.nth(0).locator("input")).toHaveCount(0); + // The third edit of the same repair: `close` restores the DRAWN text, not + // the seed. `repaint` early-returns while `editing` is set, so a `18400` + // put back here stays on screen (improvise-0x9u). + await expect(cells.nth(0)).toHaveText("18,400"); + + await page.keyboard.press("Escape"); + await expect(cells.nth(4)).toHaveClass(/selected/); + await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); + }); +}); + +test.describe("the selection is on screen", () => { + // improvise-jq8l: `web/app.js` called `scrollIntoView` NOWHERE, so a selection + // moved past the bottom of `#grid` simply stayed there, invisible, with no way + // back but scrolling by hand. Only a browser can see this: it is a fact about + // `#grid`'s own scroll box, not about the projection, which had already sent + // the row — the page renders more rows than fit, for the reason below. + test("a selection driven below the fold scrolls itself into view", async ({ page }) => { + // `harness/fixtures/tall.improv`: 30 rows in three groups, which is more + // than the box can show because `ROW_PX` (26) under-estimates the real + // 28.6px row and inline group rows are deliberately not charged for. + await page.goto("/app/index.html?doc=tall-scroll.improv&seed=tall.improv"); + await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("#view-name")).toHaveText("Tall"); + await expect(page.locator("#grid tbody th[scope=row]").first()).toHaveText("R01"); + await page.locator("#grid").focus(); + + // Not vacuous by construction: if `#grid` does not overflow there is + // nothing to scroll and every assertion below passes on a broken page. + const overflow = await page + .locator("#grid") + .evaluate((g) => g.scrollHeight - g.clientHeight); + expect(overflow).toBeGreaterThan(0); + + for (let i = 0; i < 25; i++) await page.keyboard.press("ArrowDown"); + + // The selected cell must be inside the box the user can see. Measured + // against `#grid`'s own rect rather than the window's, because `#grid` is + // the scroll container — the page itself does not scroll. + await expect + .poll(async () => + page.locator("#grid").evaluate((g) => { + const sel = g.querySelector("td.selected"); + if (!sel) return "no selected cell"; + const cell = sel.getBoundingClientRect(); + const box = g.getBoundingClientRect(); + return cell.bottom <= box.bottom + 1 && cell.top >= box.top - 1; + }), + ) + .toBe(true); + // And it really did have to move: a page that never scrolls leaves this 0. + expect(await page.locator("#grid").evaluate((g) => g.scrollTop)).toBeGreaterThan(0); + }); }); diff --git a/harness/tests/grouped.spec.js b/harness/tests/grouped.spec.js index 3da05d91..a5fbd8df 100644 --- a/harness/tests/grouped.spec.js +++ b/harness/tests/grouped.spec.js @@ -10,6 +10,15 @@ import { test, expect } from "@playwright/test"; /// answers a missing seed with an EMPTY model -- so everything below asserts /// labels the fixture really carries. "A grid is drawn" would pass on the /// empty document a typo in the filename produces. +/// The first row that carries DATA. Not `tbody tr` first: since improvise-0o6x +/// a group header first on its axis is drawn, and `Coastal` is exactly that in +/// `grouped.improv` -- so the first row is a header alone, with no `td` at all. +const firstDataRow = (page) => + page + .locator("#grid tbody tr") + .filter({ hasNot: page.locator("th.group") }) + .first(); + async function openGrouped(page, doc) { await page.goto(`/app/index.html?doc=${doc}&seed=grouped.improv`); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); @@ -20,9 +29,7 @@ async function openGrouped(page, doc) { // half-drawn, and a one-shot read cannot wait. await expect(page.locator("#grid thead tr")).toHaveCount(3); await expect(page.locator("#grid thead th.group").first()).toHaveText("▼ Q1"); - await expect(page.locator("#grid tbody tr").first().locator("th").last()).toHaveText( - "Main", - ); + await expect(firstDataRow(page).locator("th").last()).toHaveText("Main"); await page.locator("#grid").focus(); } @@ -58,7 +65,7 @@ test.describe("a grouped, multi-level document", () => { // Two row categories, so the corner spans two — and the row headers below // must agree with it, or the table is ragged. expect(lines.every((l) => l.cornerSpan === 2)).toBe(true); - await expect(page.locator("#grid tbody tr").first().locator("th")).toHaveCount(2); + await expect(firstDataRow(page).locator("th")).toHaveCount(2); // The level lines. The blanks are repeat suppression, decided in Rust by // `AxisFrame::header_labels` — NOT an absent label, and not the page's @@ -111,7 +118,7 @@ test.describe("a grouped, multi-level document", () => { // that were merely "big enough" passes the pair above and fails here. const columns = lines[2].cells.length; expect(groups.reduce((n, c) => n + c.span, 0)).toBe(columns); - await expect(page.locator("#grid tbody tr").first().locator("td")).toHaveCount(columns); + await expect(firstDataRow(page).locator("td")).toHaveCount(columns); }); // `RenderRow::Group` carries no cells, so the page CANNOT draw one with @@ -125,12 +132,15 @@ test.describe("a grouped, multi-level document", () => { const groupRows = page.locator("#grid tbody tr", { has: page.locator("th.group"), }); - // Two, not three: `Coastal` is the frame's FIRST entry and a leading group - // header is dropped before it reaches the page — improvise-0o6x, open. If - // this count becomes 3, that bug is fixed and this line is the record of - // when. It is not an endorsement. - await expect(groupRows).toHaveCount(2); - await expect(groupRows).toHaveText(["▼ Inland", "▶ Upland"]); + // Three, not two: this WAS two, because `Coastal` is the frame's FIRST + // entry and a leading group header was dropped on the way to the page. + // improvise-0o6x fixed it, and this line is the record of when. + await expect(groupRows).toHaveCount(3); + await expect(groupRows).toHaveText([ + "▼ Coastal", + "▼ Inland", + "▶ Upland", + ]); // The glyph is `indicator(collapsed)`: a page that hard-coded either one // passes on one of these rows and fails on the other. expect(await groupRows.locator("td").count()).toBe(0); diff --git a/harness/tests/landing.spec.js b/harness/tests/landing.spec.js index 74d13bf7..b3f5d5b3 100644 --- a/harness/tests/landing.spec.js +++ b/harness/tests/landing.spec.js @@ -65,4 +65,100 @@ test.describe("the landing page", () => { await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#status")).toContainText("in-page worker"); }); + + // improvise-o31u. Since improvise-kh8 this page is the SITE ROOT, so the + // first thing a phone visitor sees is four panels of terminal. The player + // derives its font size from the container width, and 120 columns into the + // ~356px inside the page's gutters gave 4.83px — measured, not eyeballed. + // + // The instrument matters: `getBoundingClientRect` reports a height for a + // `content-visibility`-hidden child and heading text overflows its box + // without widening it, so neither "the element is there" nor a box + // measurement can see this. The COMPUTED FONT SIZE is what a reader sees. + test("the casts are legible on a phone, and the page still does not scroll sideways", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/index.html"); + await expect(page.locator(".ap-player")).toHaveCount(CASTS.length); + + for (const name of CASTS) { + const player = page.locator(`#cast-${name}`); + await player.locator(".ap-play-button").click(); + await expect(player.locator(".ap-line").first()).toBeVisible({ timeout: 20_000 }); + const px = await player + .locator(".ap-terminal") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(px, `${name}.cast renders at ${px}px on a 390px viewport`).toBeGreaterThanOrEqual( + 10, + ); + } + + // The trade this fix must NOT make: a page that scrolls sideways breaks + // the whole document on a phone rather than one section of it. The + // overflow has to be contained inside `.player`. + const scrolls = await page.evaluate(() => ({ + page: document.documentElement.scrollWidth - document.documentElement.clientWidth, + player: document.querySelector("#cast-pivot").scrollWidth > + document.querySelector("#cast-pivot").clientWidth, + })); + expect(scrolls.page).toBe(0); + expect(scrolls.player, "the cast must be the thing that scrolls").toBe(true); + }); + + // The other half: desktop is not collateral. 13.11px at 1280 was fine and + // nothing about this fix is allowed to touch it. + test("a desktop viewport is unchanged by the phone fix", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/index.html"); + const player = page.locator("#cast-pivot"); + await player.locator(".ap-play-button").click(); + await expect(player.locator(".ap-line").first()).toBeVisible({ timeout: 20_000 }); + const px = await player + .locator(".ap-terminal") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(px).toBeGreaterThan(12); + expect( + await page.evaluate( + () => + document.querySelector("#cast-pivot").scrollWidth <= + document.querySelector("#cast-pivot").clientWidth, + ), + "a desktop player has nothing to scroll", + ).toBe(true); + }); + + // improvise-2oxt. The four casts used to be `asciinema rec` of a SHELL, and + // the shell was the recorder's own: every one of them opened and closed on + // `(0) Mac: ~gf/u/edwlan/improvise`, on what improvise-kh8 made the + // site root. `docs/record-casts.py` drives `improvise` as the pty's only + // child, so there is no shell for a prompt to leak from. + // + // Asserted over the FILE rather than the rendering, because that is where the + // leak lives: a player draws a prompt as ordinary text and a screenshot + // assertion would have to know what the hostname was. + test("no cast carries the recording machine's shell, clock or geometry", async ({ + request, + }) => { + for (const name of CASTS) { + const lines = (await (await request.get(`/casts/${name}.cast`)).text()).trim().split("\n"); + const header = JSON.parse(lines[0]); + + // `env` carried $SHELL and $TERM off the recording machine; `timestamp` + // carried its clock. Neither is anything a visitor is owed. + expect(header.env, `${name}.cast carries the recorder's environment`).toBeUndefined(); + expect(header.timestamp, `${name}.cast carries a recording clock`).toBeUndefined(); + // The geometry is an INPUT now, and docs/index.html spells the same + // numbers: a 30-row player over a 37-row cast clips the tile bar and the + // status line, which is exactly where the interesting output is. + expect([header.width, header.height], `${name}.cast geometry`).toEqual([120, 37]); + + // The OSC window-title sequence is the leak's signature: `improvise` + // emits none and a shell emits one per prompt, carrying the cwd. Checked + // over EVERY chunk, not just the first — the old casts leaked at the end + // as well, when the session exited back to the prompt. + const body = lines.slice(1).join("\n"); + expect(body.includes("\\u001b]"), `${name}.cast sets a window title`).toBe(false); + } + }); }); diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 3d148bba..fcd6578b 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -231,6 +231,11 @@ doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.'). doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.'). doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body,'check-evidence validates that a heading names a test that exists. It says nothing about the PROSE under the heading, which is what a human actually reads. Demonstrated on this repo 2026-09-20: renaming a retired entry to the test that had been restored made the checker green while the body three paragraphs down still said \"The test no longer exists\" and \"nothing replaced the assertion\" -- one entry making mutually exclusive claims, and the gate could not see it because the gate was never about that. Found by review, not by the checker. The general form is that a mechanical check licenses exactly the claim it makes and no neighbouring one, so a rename is a read of the whole entry rather than an edit of its first line -- a_fact_stated_twice_is_changed_by_grep applied WITHIN a document rather than across them.'). +doctrine(a_cost_note_names_the_half_the_author_was_thinking_about,'Twice now a reviewer has been asked to check a claim of the form \"X is now O(n) where it was O(1)\", and both times the interesting answer was not whether the claim was true but WHICH SIDE OF IT A READER WOULD ACT ON. On PR #76 the author wrote that the cost is \"O(cells) per CellData OR LayoutGeometry effect, where it was per LayoutGeometry effect\" -- which reads as though CellData is the newly burdened path. Measured at 18,000 cells it is the reverse: CellData was ALREADY O(cells) through record_fingerprints, so widening it is a constant factor (+27%), while LayoutGeometry went from O(config) to O(cells + config) and got 33x slower, 78us to 2.63ms, on the held-key-repeat operations. Not dishonest -- the author was thinking about WHICH EFFECTS NEWLY QUALIFY rather than WHICH PATH NEWLY CHANGED COMPLEXITY CLASS, and those are different questions with different answers. A cost note is worth a measurement precisely when the author can say confidently which half is worse, because that confidence is what stops anyone measuring.'). +doctrine(name_the_test_that_fails_when_only_this_edit_is_reverted,'A repair is usually N edits, and the failure mode is that N-1 are guarded while the last is explained in a COMMENT instead. Three independent reviews in one wave found the same shape: improvise-mai9 (the helper was tested, the caller that actually rendered was not), improvise-0x9u (preventDefault and the close/dismiss split were both tested, the drawn-vs-seed restore was not, and reverting it shows 18400 where 18,400 belongs on the shipped demo), improvise-o31u (the phone arm was measured, the desktop arm was justified by a mechanism measurement contradicts). The unguarded edit is reliably the one the author understood BEST -- which is why it carries the most careful comment, and why it felt covered: the comment is doing the work the test should, and it reads as evidence because it is the most thoughtful thing on the screen. The check is per-edit, not per-fix: for each distinct edit, name the test that fails when ONLY that edit is reverted. A line with a comment and no answer is the line to inject.'). +doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list,'repo-map gotcha 24 named TransposeAxes and CycleAxis as the only effects calling reset_cursor, and named SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem as renumbering the grid WITHOUT resetting. improvise-9mo9 had closed that the day before: all of them reset, thirteen in total. So the paragraph named as ABSENT the exact set that was by then PRESENT, and read as a survey of outstanding work rather than as history -- strictly worse than saying nothing, because it invites an agent to re-fix what is fixed. A list of what is missing decays in the one direction that looks like a to-do. Where the set is derivable, publish the DERIVATION beside it: awk over impl Effect blocks for reset_cursor, brace-matched rather than grepped.'). +doctrine(two_counts_from_two_derivations_are_not_one_fact,'Thirteen effects call reset_cursor and thirteen declare Model(LayoutGeometry). The counts are equal and the SETS ARE NOT: CreateView, DeleteView and SetNumberFormat declare the kind and never reset; SetPageSelection resets only conditionally. Equal length invites reconciling one list to the other, which silently changes what one of them means. Same shape as the wasm bundle being seven crates while its CI gate covers eight -- neither number is the other one typo.'). +doctrine(verify_the_claim_you_are_about_to_publish_as_the_correction,'While correcting repo-map gotcha 24 for being stale, the orchestrator asserted from a reviewer report that CreateView, DeleteView and SetNumberFormat declare LayoutGeometry and never reset the cursor. Measuring it before merging found the claim TRUE but UNDERSTATED: the sets differ in BOTH directions, three each way -- AddItem, AddItemInGroup and RemoveItem reset while declaring CellData. And the orchestrator naive awk reported fourteen LayoutGeometry effects, picking up HelpPageSet, which is the exact artefact the same document already warns about two paragraphs away. A correction is a claim like any other and decays the same way; the person writing the fix is the one least likely to check it, because they have just read the evidence and feel current.'). work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result; three tests in ui/effect.rs.'). work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.'). work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.'). diff --git a/src/draw.rs b/src/draw.rs index 664ccdaa..fb46dfff 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -193,17 +193,28 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color inner } -/// Terminal styling for the mode indicator. Its wildcard arm is a known -/// defect (improvise-zx8t) — see `context/code/tui.md`. +/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new +/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`. fn mode_style(mode: &AppMode) -> Style { - match mode { - AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => { - Style::default().fg(Color::Black).bg(Color::Green) - } - AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow), - AppMode::TileSelect => Style::default().fg(Color::Black).bg(Color::Magenta), - _ => Style::default().fg(Color::Black).bg(Color::DarkGray), - } + let bg = match mode { + AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green, + AppMode::CommandMode { .. } => Color::Yellow, + AppMode::TileSelect => Color::Magenta, + AppMode::Normal + | AppMode::RecordsNormal + | AppMode::FormulaEdit { .. } + | AppMode::FormulaPanel + | AppMode::CategoryPanel + | AppMode::CategoryAdd { .. } + | AppMode::ItemAdd { .. } + | AppMode::ViewPanel + | AppMode::ImportWizard + | AppMode::ExportPrompt { .. } + | AppMode::SavePrompt { .. } + | AppMode::Help + | AppMode::Quit => Color::DarkGray, + }; + Style::default().fg(Color::Black).bg(bg) } fn draw(f: &mut Frame, app: &App) { @@ -377,6 +388,17 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { ); } +/// The minibuffer row, with any pending status message riding on its right. +/// The minibuffer owns the bottom row and used to hide it — tui.md §3. +fn minibuffer_line(prompt: &str, buf: &str, status_msg: &str, width: u16) -> String { + let left = format!("{prompt}{buf}▌"); + let right = match status_msg.is_empty() { + true => String::new(), + false => format!(" {status_msg} "), + }; + fill_line(left, &right, width) +} + fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { if let Some(mb) = app.view_state.mode.minibuffer() { let buf = app @@ -385,7 +407,7 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { .get(&mb.buffer_key) .map(|s| s.as_str()) .unwrap_or(""); - let text = format!("{}{}▌", mb.prompt, buf); + let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width); f.render_widget( Paragraph::new(text).style( Style::default() @@ -516,6 +538,97 @@ fn draw_welcome(f: &mut Frame, area: Rect) { } } +#[cfg(test)] +mod bottom_bar_tests { + use super::{draw_bottom_bar, minibuffer_line}; + use crate::ui::app::{App, AppMode}; + use crate::workbook::Workbook; + use ratatui::{Terminal, backend::TestBackend, layout::Rect}; + + /// The bottom row as the terminal really receives it, so the assertion is + /// about `draw_bottom_bar`'s wiring and not about a helper it may not call. + fn bottom_row(app: &App, width: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("test backend"); + terminal + .draw(|f| draw_bottom_bar(f, Rect::new(0, 0, width, 1), app)) + .expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect() + } + + fn app_with_minibuffer_and_message() -> App { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.mode = AppMode::category_add(); + let key = app + .view_state + .mode + .minibuffer() + .expect("CategoryAdd carries a MinibufferConfig") + .buffer_key + .clone(); + app.view_state.buffers.insert(key, "North".to_string()); + app.view_state.status_msg = "category already exists".to_string(); + app + } + + /// improvise-mai9, and the ONE assertion that can fail for the real defect + /// — see context/test-evidence/tui.md. + #[test] + fn a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open() { + let app = app_with_minibuffer_and_message(); + let row = bottom_row(&app, 80); + + assert!( + row.contains("North"), + "the minibuffer must still own the left of the row: {row:?}" + ); + assert!( + row.contains("category already exists"), + "the status message is never drawn — the bug is in draw_bottom_bar, \ + which used to render the minibuffer INSTEAD of the status line, so \ + a test that calls the composing helper directly cannot see it: {row:?}" + ); + } + + /// The minibuffer is still the thing that owns the row: a fix that dropped + /// to `draw_status` would pass the assertion above on the message alone. + #[test] + fn the_minibuffer_prompt_is_not_displaced_by_the_message() { + let app = app_with_minibuffer_and_message(); + let row = bottom_row(&app, 80); + let prompt_at = row.find("North").expect("the buffer is drawn"); + let message_at = row.find("category already exists").expect("the message"); + assert!( + prompt_at < message_at, + "the prompt leads and the message follows it: {row:?}" + ); + } + + /// The guard's ONLY observable effect, in the narrow band where the two + /// arms differ at all. Why the obvious negative arm cannot: tui.md. + #[test] + fn an_empty_message_never_pushes_a_row_that_fits_past_its_width() { + // Spelled here, NOT taken from `minibuffer_line`: a bound read off the + // function under test moves with the fault and skips the failing band. + let fits_from = "Category: North\u{258c}".chars().count() as u16; + for width in fits_from..fits_from + 24 { + let line = minibuffer_line("Category: ", "North", "", width); + assert!( + line.chars().count() <= width as usize, + "width {width}: an empty status still composed {} columns \ + from {line:?} — an unconditional append pads for a message \ + that is not there", + line.chars().count() + ); + } + } +} + #[cfg(test)] mod key_conversion_tests { use super::*; diff --git a/src/ui/grid.rs b/src/ui/grid.rs index fa86dd8e..fb844f3c 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -140,20 +140,23 @@ impl<'a> GridWidget<'a> { format!("{: = None; + // The run rule's one home, plus the TUI's one divergence from + // it at a scrolled offset — `context/code/protocol.md` §3a. + let run_heads = cols.group_run_heads(); for ci in visible_col_range.clone() { let x = col_x_at(ci); if x >= area.x + area.width { break; } let cw = col_w_at(ci) as usize; - let col_group = col_groups.get(ci).copied().flatten(); - let group_name = col_group.map(|g| g.name.clone()); - let label = match (group_name != prev_group, col_group) { - (true, Some(g)) => format!("{} {}", indicator(g.collapsed), g.name), - _ => String::new(), + let head = match ci == visible_col_range.start { + true => col_groups.get(ci).copied().flatten(), + false => run_heads.get(ci).copied().flatten(), + }; + let label = match head { + Some(g) => format!("{} {}", indicator(g.collapsed), g.name), + None => String::new(), }; - prev_group = group_name; buf.set_string( x, y, @@ -231,6 +234,9 @@ impl<'a> GridWidget<'a> { break; } match entry { + // A boundary, not a row: nothing is painted and no line is + // consumed (improvise-gd83). + FrameEntry::GroupEnd => continue, FrameEntry::Group(group) => { let label = format!("{} {}", indicator(group.collapsed), group.name); buf.set_string( @@ -895,12 +901,15 @@ mod tests { assert_eq!(text, GROUPED_FRAME, "\n--- got ---\n{text}\n--- end ---"); } + /// Re-pinned for improvise-0o6x: the leading `▼ Co…` is the ONE line that + /// moved, and its absence was the bug. context/test-evidence/tui.md. const GROUPED_FRAME: &str = concat!( "┌ View: Default ───────────────────────────┐\n", "│ [Payer = Alice] │\n", "│ ▼ Q1 ▼ Q2 │\n", "│ Jan Apr │\n", "│──────────────────────────────────────────│\n", + "│▼ Co…────────── │\n", "│East 100 100 │\n", "│▼ In…────────── │\n", "│West 50 50 │\n", @@ -910,7 +919,6 @@ mod tests { "│ │\n", "│ │\n", "│ │\n", - "│ │\n", "└──────────────────────────────────────────┘", ); } diff --git a/tests/grid_frame_agreement.rs b/tests/grid_frame_agreement.rs index b5160e2c..28334701 100644 --- a/tests/grid_frame_agreement.rs +++ b/tests/grid_frame_agreement.rs @@ -7,7 +7,7 @@ use improvise::ui::grid::GridWidget; use improvise::view::{Axis, GridLayout}; use improvise::workbook::Workbook; use improvise_command::ui::project; -use improvise_protocol::{GridCache, Snapshot, ViewState}; +use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState}; use improvise_wasm_client::render::{RenderModel, RenderRow}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; @@ -278,37 +278,96 @@ fn the_two_front_ends_draw_the_same_column_header() { .iter() .map(|g| (g.label.as_deref(), g.span)) .collect(); + // improvise-gd83: MEMBERSHIP, not nearest-preceding. Sep is ungrouped, so + // the run ends before it and resumes under the next measure's Q1 header. assert_eq!( named, - vec![(Some("Q1"), 6)], - "Q1 covers all six columns, INCLUDING the two under the ungrouped \ - Sep — `expand_category` emits a header on entering a group and no \ - boundary on leaving one, so the nearest-preceding walk keeps \ - answering Q1 (see `AxisFrame::group_of`). That is `GridLayout`'s \ - rule and the TUI paints the same span; it is reproduced here rather \ - than corrected, because correcting it changes what the TUI draws. \ - See `THE_MEMBERSHIP_CORRECT_SPANS` for what improvise-gd83 makes \ - this line say." + vec![(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)], + "the two Q1 runs are separated by the ungrouped Sep; a run that \ + swallowed it would be `expand_category` leaking a group across its \ + own boundary (see `AxisFrame::group_of`)" ); - // A DELIBERATELY WRONG constant and a live tripwire — do not remove it. - // Derived, not guessed: see `context/test-evidence/tui.md`. - const THE_MEMBERSHIP_CORRECT_SPANS: [(Option<&str>, usize); 4] = - [(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)]; - assert_ne!( - named.as_slice(), - THE_MEMBERSHIP_CORRECT_SPANS.as_slice(), - "when this stops differing, improvise-gd83 is fixed and the \ - assertion above should become the constant" + assert!( + named.iter().any(|(label, _)| label.is_none()), + "precondition: the fixture must contain an UNGROUPED column, or this \ + test cannot see a leaked group at all" ); - // And the group line the TUI paints names the same group. + // improvise-8qlr: the TUI's group line, compared EXACTLY. A `contains` + // passes while the TUI labels every column, which is a different grid. let group_line = &lines[rule - levels - 1]; - assert!( - group_line.contains("▼ Q1"), - "the TUI paints the same group above the same columns: {group_line:?}" + let expected: Vec = model + .col_groups + .iter() + .flat_map(|g| { + let head = match &g.label { + Some(name) => format!("{} {name}", indicator(g.collapsed)), + None => String::new(), + }; + std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) + }) + .collect(); + assert_eq!( + &tui_header_cells(group_line, &cache), + &expected, + "the TUI's group line and the browser's runs disagree — one of them \ + coalesces the runs itself instead of asking `group_runs`" ); } +/// improvise-0o6x: a group FIRST on its axis. Separate from `two_level_rows`, +/// which orders Carol first to dodge this case on purpose. +fn leading_group_rows() -> Workbook { + let mut m = Workbook::new("Test"); + m.add_category("Recipient").unwrap(); + m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); + m.add_item_in_group("Recipient", "Bob", "Fam").unwrap(); + m.add_item("Recipient", "Carol").unwrap(); + m.add_item("_Measure", "Revenue").unwrap(); + m.active_view_mut().set_axis("Recipient", Axis::Row); + m.active_view_mut().set_axis("_Measure", Axis::Column); + for r in ["Alice", "Bob", "Carol"] { + m.model.set_cell( + coord(&[("Recipient", r), ("_Measure", "Revenue")]), + CellValue::Number(1.0), + ); + } + m +} + +/// A collapsed group at position zero is otherwise a trap: its rows are hidden +/// and its header is the only affordance. context/test-evidence/tui.md. +#[test] +fn a_group_header_first_on_its_axis_reaches_both_front_ends() { + for collapse in [false, true] { + let mut wb = leading_group_rows(); + if collapse { + wb.active_view_mut() + .toggle_group_collapse("Recipient", "Fam"); + } + let (_app, cache) = whole_grid(&mut wb); + + assert!( + matches!(cache.row_frame.entries.first(), Some(FrameEntry::Group(g)) if g.name == "Fam"), + "precondition (collapse={collapse}): the frame must LEAD with the \ + header, or body_from has nothing at position zero to drop" + ); + + let browser = browser_rows(&cache); + assert!( + matches!(browser.first(), Some(RenderRow::Group { label, .. }) if label == "Fam"), + "the browser dropped the leading group header: {browser:?}" + ); + + let painted = tui_row_headers(&wb, &cache); + assert_eq!( + painted.first().map(|r| r.join("").trim().to_string()), + Some(format!("{} Fam", indicator(collapse))), + "the TUI dropped the leading group header: {painted:?}" + ); + } +} + /// The bug's own words, separate because the agreement above would also hold /// if *both* ends started slash-joining. #[test] diff --git a/web/app.js b/web/app.js index 9c496209..41ded498 100644 --- a/web/app.js +++ b/web/app.js @@ -7,6 +7,7 @@ import { HANDSHAKE_TIMEOUT_MS, ROW_PX, createShell, + documentTitle, extentOf, frameCostOf, gridPlan, @@ -35,6 +36,7 @@ const el = { help: document.getElementById("help"), status: document.getElementById("status"), viewName: document.getElementById("view-name"), + docName: document.getElementById("doc-name"), }; await init(); @@ -60,8 +62,8 @@ function sendViewport() { const box = el.grid.getBoundingClientRect(); const metrics = { rowPx: ROW_PX, colPx: COL_PX }; const { rows, cols } = extentOf(box, frameCost, metrics); - // `client.set_viewport`, NOT the associated `Client.viewport`: the same - // frame, but it also RECORDS the window. Why that matters: clients.md. + // `set_viewport` RECORDS the window as well as framing it; the frame-only + // twin beside it is deleted, so there is nothing to call by mistake (seik). transport.send(client.set_viewport(rows, cols)); } @@ -162,6 +164,13 @@ function repaint() { paintMinibuffer(model); shell.setModel(model.status); el.viewName.textContent = model.view_name; + // The title bar, off the cache `src/draw.rs::draw_title` folds — one + // derivation, both front ends (improvise-z754, improvise-5jna). + el.docName.textContent = documentTitle( + model.doc_name, + model.doc_dirty, + model.doc_stored_as, + ); el.grid.replaceChildren(gridTable(model)); // The flag is the client's answer, not the page's: `C` toggles it and the // TUI draws the panel on exactly the same condition (improvise-wsaw). @@ -173,6 +182,13 @@ function repaint() { client.click_formula(i), ); + // The selection must be on screen. `#grid` scrolls on its own — ROW_PX and + // COL_PX under-estimate and group rows are uncharged — so a selection the + // projection did send can still sit below the fold (improvise-jq8l). + el.grid + .querySelector("td.selected") + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + // What the frame costs, so the NEXT viewport announcement is honest. // Inline group ROWS are deliberately NOT counted — context/code/clients.md. const next = frameCostOf(model); @@ -299,7 +315,12 @@ function gridTable(model) { // Every cell moves the selection; an editable one also opens its editor, // which is the pointer's `i`. Not repainted in that branch: `edit` has // set `editing`, and a repaint would take the away. - td.addEventListener("click", () => { + // On MOUSEDOWN, and defaulted away: from an editing state the input's + // blur repainted first and this was gone before `click` could fire + // (improvise-0x9u). See context/code/clients.md. + td.addEventListener("mousedown", (event) => { + event.preventDefault(); + if (editing) editing.close(false); const frame = client.click_cell(cell.row, cell.col); if (frame) transport.send(frame); if (cell.editable) edit(td, cell.row, cell.col, cell.editText); @@ -314,10 +335,12 @@ function gridTable(model) { * back is the server's, never the string typed here. */ function edit(td, row, col, seed) { if (editing) return; - editing = { row, col }; // `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn one: // `td.textContent` has been through the number format. const original = seed; + // What was on the screen, kept so closing can put it back: `close` no longer + // always repaints, so restoring `seed` would leave `1200` where `1,200` was. + const drawn = td.textContent; const input = document.createElement("input"); input.value = original; td.replaceChildren(input); @@ -327,6 +350,8 @@ function edit(td, row, col, seed) { // Enter removes the focused , which fires `blur`, which calls // `close` again. Harmless before, but only by accident. let closed = false; + // `close` does NOT repaint: a caller about to open another editor on a live + // must not have the table rebuilt under it (improvise-0x9u). const close = (commit) => { if (closed) return; closed = true; @@ -337,19 +362,28 @@ function edit(td, row, col, seed) { // follows restores whatever the server now says is there. if (frame) transport.send(frame); } - td.replaceChildren(original); + td.replaceChildren(drawn); painted = -1; + }; + const dismiss = (commit) => { + close(commit); repaint(); }; + editing = { row, col, close }; input.addEventListener("keydown", (e) => { - if (e.key === "Enter") close(true); - else if (e.key === "Escape") close(false); + if (e.key === "Enter") dismiss(true); + else if (e.key === "Escape") dismiss(false); // The editor owns the keyboard while it is open; the page-level handler // below must not also see these. e.stopPropagation(); }); - input.addEventListener("blur", () => close(false)); + // Removing the focused fires `blur` SYNCHRONOUSLY, so a caller that + // already closed this editor must not have the table rebuilt from inside its + // own `close` — that is what detached the in improvise-0x9u's repair. + input.addEventListener("blur", () => { + if (!closed) dismiss(false); + }); } /** A side panel. An empty row list is what says it is closed — the page asks diff --git a/web/index.html b/web/index.html index 8e3dd905..7fd56994 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,7 @@ -->

improvise

+ diff --git a/web/shell.js b/web/shell.js index 3bbf7508..7100f234 100644 --- a/web/shell.js +++ b/web/shell.js @@ -16,6 +16,12 @@ export function composeStatus(shell, model) { return [shell, model].filter(Boolean).join(" · "); } +/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title` + * uses. The three facts are the host's; only the joining is here (clients.md). */ +export function documentTitle(name, dirty, storedAs) { + return `${name}${storedAs ? ` (${storedAs})` : ""}${dirty ? " [+]" : ""}`; +} + /** The collapse glyph. `collapsed` is the fact, decided by the view and * carried on the frame; only this spelling is the page's (gotcha 26). */ export function indicator(collapsed) { diff --git a/web/style.css b/web/style.css index 03908006..c2055238 100644 --- a/web/style.css +++ b/web/style.css @@ -49,6 +49,10 @@ header h1 { color: var(--accent); } +#doc-name { + opacity: 0.85; +} + #mode { color: var(--accent); } diff --git a/web/tests/bundle/wasm_surface.test.js b/web/tests/bundle/wasm_surface.test.js index bde6bdef..b959d77d 100644 --- a/web/tests/bundle/wasm_surface.test.js +++ b/web/tests/bundle/wasm_surface.test.js @@ -86,6 +86,31 @@ test("the page still reaches the client through a handful of calls", () => { ); }); +/// Methods on `Client` the page does not call, each with the reason it is +/// still exported. A door with no caller and no entry here is improvise-seik: +/// the dangerous kind, because calling the wrong one of a pair is silent. +// What this CANNOT see, exactly as UNPAINTED cannot: `callsOn` is a source +// scan, so a `client.foo()` inside a function nothing calls reads as called. +const UNCALLED = { + free: "wasm-bindgen's own destructor, not ours to remove", + initSync: "wasm-bindgen's own module loader, beside the default export", + revision: "on_frame returns it; a page that stores it can skip a repaint", +}; + +test("the bundle exports no client door the page has no reason to hold", () => { + const declared = declarations("pkg/improvise_wasm_client.d.ts"); + const called = new Set(callsOn("app.js", "client").map((c) => c.name)); + // `constructor` is `new Client()`, which `callsOn` cannot see as a call. + const spare = [...declared.keys()].filter( + (name) => name !== "constructor" && !called.has(name), + ); + assert.deepEqual( + spare.sort(), + Object.keys(UNCALLED).sort(), + "an exported method nothing calls — delete it, or add it to UNCALLED with the reason", + ); +}); + test("every worker call web/worker.js makes is declared, with that arity", () => { const declared = declarations("worker-pkg/improvise_worker_server.d.ts"); const calls = callsOn("worker.js", "server"); diff --git a/web/tests/render_surface.test.js b/web/tests/render_surface.test.js index aa0766aa..ee9b3d6d 100644 --- a/web/tests/render_surface.test.js +++ b/web/tests/render_surface.test.js @@ -17,14 +17,10 @@ const code = (rel) => /// Fields the page deliberately does not read, each with the reason. A field /// belongs here only with an argument; the list is the debt, written down. -// improvise-z754 put the title bar's facts in one shared cache and taught -// `src/draw.rs::draw_title` to fold it; this page never learned to. That is -// improvise-5jna, open, and this list is what stops it being forgotten. -const UNPAINTED = { - doc_name: "improvise-5jna: the title bar is drawn by the TUI and not here", - doc_dirty: "improvise-5jna", - doc_stored_as: "improvise-5jna", -}; +// Empty since improvise-5jna painted the title bar. Note what this check CANNOT +// see, and why the harness asserts the painted text too: it is a substring +// search, so a field named by a function nothing calls reads as painted. +const UNPAINTED = {}; /// The `pub` fields of `RenderModel`, read off the Rust that defines it. function renderModelFields() { diff --git a/web/tests/shell.test.js b/web/tests/shell.test.js index 8d229d4a..8cf36b80 100644 --- a/web/tests/shell.test.js +++ b/web/tests/shell.test.js @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { composeStatus, createShell, + documentTitle, extentOf, frameCostOf, indicator, @@ -160,3 +161,17 @@ test("the collapse glyph points right when collapsed", () => { assert.equal(indicator(true), "▶"); assert.equal(indicator(false), "▼"); }); + +test("the title bar spells the document the way the TUI's own title does", () => { + // `src/draw.rs::draw_title`: name, then what it is stored as in parens, then + // the dirty marker. The harness cannot pin `[+]` — the standalone deployment + // persists on every accepted batch, so the marker is transient there. + assert.equal( + documentTitle("Corner Coffee", true, "demo.improv"), + "Corner Coffee (demo.improv) [+]", + ); + assert.equal(documentTitle("Corner Coffee", false, "demo.improv"), "Corner Coffee (demo.improv)"); + // A host with nowhere to write it: `stored_as` is `None` over the wire, which + // arrives as `null`, and an empty pair of parens would be a lie about storage. + assert.equal(documentTitle("Untitled", true, null), "Untitled [+]"); +});