Maturing the web frontend: spike -> product¶
Status: analysis / proposal. The web frontend (gridcalc.web) is an experimental spike (docs/gui.md); this doc lays out what "product" would actually require, the decisions that gate it, and a phased path. It is a plan to argue with, not a committed roadmap.
What has since landed¶
The body below is unchanged from when it was written; this section records where the work actually got to, so the two do not silently diverge.
-
P0 is done. The client is a real Vite/React/TypeScript app under a lint, type-check and test gate (
make web-qa, now part ofmake qa), not the inline_HTMLstring Section 5c describes. There is an error/notification channel (Section 5d), plus a React error boundary and anunhandledrejectionhandler. TheNamedRangestructural-edit bug is fixed and per-sheet view state is kept across a tab switch; constrainingsavepaths (Section 4) is still open. -
P1 is done and then some.
:opt/:goalreached the GUI, and beyond the original sketch: persisted named models (the:opt def/run/list/undefsurface, which Appendix A explicitly deferred as "not core P1"), a non-solvinginfer_model_specso a model can be read off a block and corrected before it runs, a parametric sweep plotted with breakpoints marked, and a grid annotation layer that paints binding/slack constraints and shadow prices onto the sheet itself. Charts use Recharts, as Section 5c anticipated the renderer-agnosticchart_datashape would allow. -
P2 is partial. Formula bar, per-cell and global number formats, styles, and a status bar with selection aggregates exist. Row/column insert-delete, sheet add/rename/delete/reorder, persisted per-column widths, find, named ranges, sort, formula-mode switching, freeze panes, and a Ctrl-K command palette now ship too -- the last several came free with the shared command registry (
gridcalc/commands.py), which both frontends dispatch by name. -
P3 is untouched. No frozen builds, no per-platform QA, no signing. The IME/CJK and accessibility claims from Section 5e remain unvalidated in a real webview; the grid still has no ARIA grid semantics.
-
Security now has the trust flow Section 5a called for. Formulas-only is still what an unanswered open loads; a workbook carrying code raises a dialog reporting what it would run, and its answer becomes the
LoadPolicythe load uses.inspect_filedisclosure, the blocked/I-O/unknown split, and the separate answer for unclassified modules all match the curses prompt.
1. The premise, challenged¶
"Polished product" is underspecified, and the ambiguity is not cosmetic -- it changes almost every downstream decision. Two forks dominate:
-
Audience / deployment. A single-user desktop app (pywebview shell, engine in-process) and a shareable, multi-user web app are different products with different security models, packaging, and persistence stories.
docs/gui.mdalready flagged this as an open question; it must be closed before "product" means anything. Recommendation: commit to single-user desktop. It is what the pywebview choice already implies (no server, no port, in-processjs_api), it sidesteps the multi-tenant security problem (Section 5a), and it matches a spreadsheet-optimization tool used by one analyst at a time. The multi-user web path is a different product; defer it explicitly rather than drift toward it. -
Scope: TUI parity vs deliberately narrower. The reflex is "make the web view do everything the TUI does." Challenge it. gridcalc's stated differentiator (
TODO.md) is optimization with spreadsheet semantics --:opt/:goalover a live grid -- not commodity spreadsheet editing that Excel already owns. The webApitoday exposes none of the optimization surface. So the highest-value gap is not prettier cell editing; it is bringing the differentiator to the GUI. A "polished" web gridcalc that can edit cells and draw bar charts but cannot run:optis a worse gridcalc than the terminal one.
Alternative framing (worth a real look): the web view does not have to be the product. It can be a deliberately-scoped visual companion -- open, browse, light-edit, and above all visualize and solve (charts + :opt results a terminal renders poorly) -- while the curses TUI stays the power-editing surface. Under this framing "polish" means depth on charts and optimization, not breadth across every : command. This is cheaper, ships sooner, and plays to the browser's actual strength (rendering) rather than re-implementing modal editing the TUI already does well.
2. Current state: what is solid, what is spike-grade¶
Solid, keep:
-
The
Apiboundary. All engine<->view logic is a plain-Python class with nowebviewimport (web/__init__.py), unit-tested without a display (tests/test_web.py). This is the right architecture and scales. -
Headless regression testing of the JS/DOM. The same page is driven in Chromium with the bridge mocked from real
Apioutput (tests/integration/test_web_playwright.py). Rare and valuable for a webview app. -
Shared formatting. Cells format identically to the TUI via
display.cell_text/cell_right_aligned. No divergence risk. -
Virtualized rendering. Only viewport cells enter the DOM, so the full 256x1024 sheet scrolls without 260k nodes.
Spike-grade, must change:
-
A ~500-line inline HTML/JS string (
_HTMLinweb/__init__.py). No lint, no type-check, no module boundaries, no source maps -- invisible to every quality gate that guards the Python. This is the single biggest maintainability liability (Section 5c). -
~~Security punted.
open_filehardcodesLoadPolicy.formulas_only()(loader.py); code blocks are never run, no trust UI, noinspect_filedisclosure. Fine for a spike, a product-defining hole otherwise (Section 5a).~~ Closed:open_filetakes a policy,inspect/pending_trustreport the decision, andTrustDialogasks it. -
No error surface.
Apimethods return{ok: false}shapes the client largely ignores; a failed save/open/recalc has no user-visible channel beyond an ad-hocflashSave. -
One hard-coded demo, no chrome. No menu bar, no About, no keyboard-help, no recent-files, no window-title lifecycle beyond a best-effort retitle.
3. Feature gap: TUI vs web Api¶
Engine support already exists for nearly all of this; the gap is Api surface
- client UI, not core work. Legend: [A] needs
Apimethod, [C] needs client UI, [E] needs engine/new work.
The shared-command rows are no longer maintained by hand. Commands in gridcalc/commands.py are dispatched by name from both frontends, and tests/test_docs_conformance.py fails if one of them is shadowed in the TUI or dropped by the web bridge. What remains below is the part that is not shared: per-frontend interaction, and capabilities one side simply does not have.
| TUI capability | Web today | Gap |
|---|---|---|
| Cell edit, nav, selection, copy/cut/paste, fill | Yes | -- |
| Undo/redo, save, open, paste-in | Yes | -- |
| Per-cell + global number format / style | Yes | -- |
| Row/column header selection | Yes | -- |
Search (/, n, N) |
Yes (find bar) | -- |
| Sheet add/del/rename/move | Yes | -- |
| Column width | Yes, per-column pixels | diverges from :width (uniform chars) by design |
:opt LP/MIP, :goal, sensitivity, sweep |
Yes | -- |
| Bar chart from range | Yes (inline SVG) | depth only |
Shared registry (:b :f :gf :ir :ic :dr :dc :name :names :unname :sort :mode :title :recalc) |
Yes, by name | enforced by test |
Replicate (replicatecell) beyond fill down/right |
Partial (fill only) | [A] [C] |
Move row/column (swaprow/swapcol) |
No | registry entry + drag gesture |
Object editor for Vec/ndarray/DataFrame cells (tui/objedit.py) |
No | [A] [C] larger |
| xlsx / csv / pandas import-export | save by ext; open JSON/xlsx/csv | [A] pandas, dialogs |
| Code block trust prompt | Yes (dialog) | -- |
Code block edit (:e) |
No | [A] |
Takeaway: two clusters carry most of the product value -- optimization (:opt/:goal) and format/structure editing (rows, cols, styles, named ranges). Both are almost entirely "wire an Api method to existing engine calls + build client UI." The engine is ready.
4. Correctness gaps found while scoping (fix regardless of roadmap)¶
These are latent bugs the spike has not tripped yet; a product will.
-
savetrusts a client-supplied path (Api.save, web/init.py). In the in-process desktop model the "client" is local so the blast radius is small, but any move toward a served frontend makes this arbitrary-path write. Constrain now while it is cheap. -
Shared, persistent eval globals (
_eval_globals, exec intogon PYTHON recalc). Single-process desktop is fine; it is a hard blocker for any multi-tenant path -- another reason to decide Section 1 early.
5. The hard problems (what actually separates spike from product)¶
5a. Security / trust model -- the decisive one¶
The spike is safe precisely because it does nothing: formulas_only() never stores or runs a workbook's code block, so cells depending on it show errors by design. A product has to choose, and the choice is structural:
-
Stay formulas-only forever. Simplest, honestly safe. Cost: PYTHON and HYBRID workbooks are second-class in the GUI -- their computed cells are dead. Defensible if the web view is the "safe viewer/solver" and code-block authoring stays in the TUI.
-
Full trust flow, ported. Reproduce
inspect_file->FileInfodisclosure (blocked / side-effect modules, code preview) -> a consent dialog equivalent totui/commands.py trust_prompt->LoadPolicy(load_code=True, ...). This is real product work and, critically,validate_code(sandbox.py) is a denylist, not a container -- once it passes, code runs with the process's full privileges. On the desktop that is the same trust the TUI already asks for, so it is acceptable with an honest consent UI. It is not acceptable on a served/multi-user deployment without OS-level isolation (subprocess, container, seccomp), which is out of scope for the recommended desktop product.
Recommendation: ship P0/P1 as formulas-only (option 1), then add option 2's consent flow as an explicit, well-labelled feature -- not silently. Never wire load_code=True without a trust dialog. This is the one area where "polish" means restraint, not features.
5b. Distribution / packaging¶
A pywebview app is not pip install for end users. Product means a double-clickable artifact:
-
Bundler: PyInstaller or Briefcase around pywebview + the C++ extensions (
_core, the HiGHS_opt). The native extensions and platform webview (WebKit / WebView2 / GTK-WebKit) are the risk; test the frozen build on all three OSes, not justuv run. -
Platform webview parity. macOS WKWebView, Windows WebView2 (Edge/Chromium runtime dependency), Linux WebKitGTK render and expose
navigator.clipboarddifferently. The Playwright suite is Chromium, a faithful proxy, not the production engine -- clipboard, IME, and CSS edge cases need a manual pass per platform. -
Signing / notarization (macOS Gatekeeper, Windows SmartScreen) if distributed beyond yourself. This alone can dominate the effort budget and is a reason the "personal tool" audience is materially cheaper.
5c. Client architecture¶
The inline _HTML string was right for a spike and is wrong for a product:
-
Extract the JS/CSS to real files (still framework-free, still no build step to start) so
eslint/prettier/type-checking (JSDoc or a light TS pass) can guard it the wayruff/mypyguard the Python. -
Decide on a build step deliberately. Framework-free hand-rolled DOM is fine and dependency-light today. It gets painful at "object editor, dialogs, chart library, i18n." A small bundler (esbuild) or a minimal reactive layer (Preact/lit) is a reversible bet; a full SPA framework is probably over-scoped for a single-window desktop tool.
-
A real charting library (Plotly/ECharts) replaces the inline SVG.
Api.chart_dataalready returns a renderer-agnostic{title, labels, series}shape, so this is a client-only swap -- the deliberate seam pays off here. -
A real data-grid only if the hand-rolled virtualized grid gets painful (frozen panes beyond row/col headers, cell merging, rich in-cell widgets). Do not adopt one preemptively.
5d. UX chrome and error handling¶
Product table stakes the spike lacks: a menu/command surface (open, save, save-as, recent files, About, keyboard-shortcut help), a real notification / error channel (not just the save flash), a formula bar showing the active cell's source, cell-format and named-range dialogs, a status line (mode, selection stats), and window-title/dirty-state lifecycle. A discoverable command palette (Ctrl-K) mapping the TUI's : commands is a natural bridge -- it reuses the mental model without forcing modal : typing into a GUI.
5e. Performance, accessibility, i18n¶
-
Perf at scale. Virtualized DOM is fine; the cost is
viewport()round-trips per scroll frame across the bridge. Profile with a full sheet and fast scroll; batch/debounce if the bridge is the bottleneck. -
The reason web was chosen was IME/CJK + accessibility (
docs/gui.md). That claim is still unvalidated in the actual pywebview window -- only asserted. A product must prove CJK/IME input and basic screen-reader/keyboard accessibility on each platform's real webview, or the central justification for the web bet is unconfirmed.
6. Recommended path¶
Assumes the two recommended decisions: single-user desktop, and lead with the differentiator (optimization + visualization), not blanket TUI parity.
-
P0 -- Harden the spike (no new features). Extract JS/CSS to files under a lint/format/type gate. Add an error/notification channel. Constrain
savepaths. Fix theNamedRangestructural-edit bug (Section 4). Keep formulas-only. Outcome: the current feature set becomes maintainable and honest. -
P1 -- The differentiator. Wire
Api.solve/Api.goaltoopt.solve/goalseek.seek; build the client UI to define a model from a selection (opt.infer_model), run it, and render results + sensitivity. Swap inline SVG for a real charting library. Outcome: the web view does the thing that makes gridcalc gridcalc, better than the terminal can. -
P2 -- Editing parity essentials. Row/col insert/delete/move, per-cell and global number format + styles, named-range management, search, sheet management, a formula bar, and a command palette over the
:set. Outcome: the web view is a credible primary editor, not just a viewer. -
P3 -- Product distribution. Frozen, signed, double-clickable builds on macOS/Windows/Linux; per-platform manual QA of clipboard/IME/accessibility; About/help/recent-files. Optionally, the gated code-block trust flow (Section 5a option 2). Outcome: shippable to someone who is not you.
Ordering rationale: P1 before P2 deliberately. Editing polish is commodity; optimization-in-a-GUI is the moat. If the budget runs out after P1, you still have something no other tool offers. If it runs out after P2-of-parity-first, you have a mediocre Excel.
7. Open decisions (need your call)¶
-
Audience: personal desktop tool, or eventually shareable/distributed? (Drives Section 5a and 5b, and how much P3 costs.)
-
Scope: TUI feature-parity, or the narrower "visualize + solve companion" framing from Section 1? (Drives whether P2 is breadth or depth.)
-
Code blocks in the GUI: never (formulas-only forever), or behind an explicit ported trust flow? (Security posture.)
-
Client stack: stay hand-rolled framework-free, or adopt a small build step / reactive layer at P2 when dialogs and the object editor arrive?
-
Does the web view replace the TUI as the default, or complement it? If complement, parity matters less and the companion framing wins.
8. Effort / risk summary¶
-
Lowest-risk, highest-value first move: P1 optimization wiring. Engine is ready (
opt.solve,goalseek.seek); it isApi+ client UI, and it is the differentiator. -
Most underestimated cost: P3 distribution (native extensions + three platform webviews + signing). Budget it as its own project.
-
Biggest latent trap: the security fork (5a). Cheap to keep closed (formulas-only), expensive and dangerous to open carelessly. Decide explicitly; never let
load_codecreep in silently. -
Cheapest thing that most improves maintainability: extracting
_HTMLand putting the client under a quality gate (P0).
Want more depth on any section -- a packaging spike plan or a security-consent UI design -- say which and I will expand it. The P1 optimization endpoints are sketched in Appendix A.
Appendix A -- P1 optimization Api endpoints (sketch)¶
The differentiator wiring from Section 6 (P1). The engine is ready (opt.solve, opt.sweep, opt.infer_model, goalseek.seek); this is Api surface + coordinate translation, plus one small shared refactor.
Design rules¶
-
A1 in, A1 out. The client already computes
selRef(); never push raw coords across the bridge.opt/goalseekuseCellKey = (col, row)while the webApiis(r, c)/A1 -- translate at the boundary, one place. -
Errors become data. Catch
OptError(withNotLinear/NotQuadraticsubclasses) andGoalSeekError->{ok: false, error}; the messages are written to be user-facing. -
Applied solves are undo-wrapped.
opt.solve(apply=True)overwrites decision cells and recalcs dependents;goalseek.seekoverwrites the var cell. Snapshot first; drop the snapshot when nothing was written. -
No
Infinityin JSON. Ranging bounds (obj_from/till,rhs_from/till) are legitimately+-inf; JSON has no such literal and the bridge chokes. Map non-finite ->null(client renders "inf"). -
Reuse the engine's spec parsers, not the TUI's -- the
Apimust not importtui.
Prerequisite refactor (same pattern as display/loader/undo)¶
Promote _parse_cells / _parse_bounds / _parse_bound_value from tui/solve.py down to opt.py as public parse_cells / parse_bounds (with _parse_bound_value a module helper); tui/solve.py re-imports them under the old private names so existing TUI callers and gridcalc.tui re-exports keep working. Add one helper to UndoManager:
def discard_last(self) -> None:
"""Drop the most recent snapshot when the mutation it guarded didn't happen."""
if self.undo_stack:
self.undo_stack.pop()
Endpoints¶
Coordinate/serialization helpers: _key(a1) -> (c, r) (rejecting trailing garbage), _a1((c, r)) -> "B4", _num(x) (non-finite -> None).
-
solve_selection(r0, c0, r1, c1, sense="max")--opt.infer_modelover the selection, then solve with sensitivity + diagnostics on. Mirrors:opt max/:opt minover a visual selection. -
solve_model(spec)-- explicit A1 model:{sense, objective:'B2', vars:'A2:A3', constraints:'C2:C4', bounds?, integers?, binaries?, sensitivity?, diagnose?, apply?}. -
goal_seek(formula_ref, target, var_ref, lo?, hi?, apply?)--goalseek.seek. -
opt_sweep(spec)-- parametric RHS sweep (opt.sweep); never mutates the sheet, so no undo snapshot.
All applied paths route through a shared _run_solve that snapshots, calls opt.solve, and on OptError or a non-applied result (INFEASIBLE/UNBOUNDED) calls discard_last. Results serialize through _solve_json (CellKeys -> A1 strings, ranging floats through _num).
Client-facing return contract¶
SolveResponse =
{ ok: true, status: "OPTIMAL"|"INFEASIBLE"|"UNBOUNDED"|"SUBOPTIMAL",
optimal: bool, objective: number|null,
values: {A1: number}, // already written to the sheet
applied: bool, quadratic: bool,
sensitivity?: { variables: [...], constraints: [...] }, // absent for MIPs
conflict?: [A1], unbounded?: [A1] }
| { ok: false, error: string }
Decisions carried into implementation¶
-
apply=Truemutates the sheet -- the UI must make a solve feel like an action, not a preview (apply:falsegives a dry run forsolve_model). -
Snapshot/redo caveat:
save_gridclears the redo stack, so a failed applied solve still wipes redo afterdiscard_last. Acceptable; tighten by saving/restoring the redo stack indiscard_lastif it matters. -
Sensitivity is
Nonefor MIPs by design (integer duals mislead) -- the client renders "no sensitivity for integer models," not an empty table. -
The web view renders the returned
sensitivityobject in a panel rather than writing it into cells (the TUI's:opt sens into <cell>); grid write-back can be an opt-in later. -
Persisted models (
grid.models: dict[str, OptModel], the:opt def/run/listpath) are a natural extension --list_models/run_model/save_modeloverOptModel.to_json/from_json-- but not core P1.