feat(wasm-client,web): the DOM renderer for the browser grid (improvise-cr3)

The last blocker of improvise-1ey. Mostly assembly: `Client` already had the
fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a
cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's
flattening. What was missing was the three joints between them.

`render.rs` is the one that matters. The DOM half is JavaScript and JavaScript
here is untested, so every question with a *silent* wrong answer is answered
in Rust under test and crosses as decided data: `editable` is a fact about the
`CellId` variant (Record only — an Aggregate is a drill target and `Drill` is
not on the wire yet, and `None` is "no projected cell", not "aggregate"), the
selection is placed by subtracting the scroll offset because `GridCache` is
viewport-indexed while the cursor is not, and the category panel is
`flatten_cat_tree` rather than a second walk of the cache.

`socket.rs` frames what `improvise-ws-server` actually speaks, which is not
what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at
extent zero is an empty grid), it batches `Commands`, and it has no `Key`
variant. The two enums are duplicated because a native crate cannot be a wasm
dependency; `improvise-ws-server` is a dev-dependency so the compatibility is
checked through its own types, and a companion test proves the check is
discriminating on the variant names that would drift.

`web/` is the shell, and it is thin on purpose: four string calls in and out,
no protocol knowledge, no framework.

Gates: 1047 passing (was 1031), clippy and fmt clean, and
`cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean.
Verified end to end headlessly — the wasm-pack artifact driven from node over
a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed,
"98,765" projected back. The DOM painting itself is unverified; there is no
headless browser here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 17:34:39 -07:00
parent f007be88af
commit c72832f542
11 changed files with 1432 additions and 4 deletions
Generated
+1
View File
@@ -946,6 +946,7 @@ dependencies = [
"improvise-command",
"improvise-core",
"improvise-protocol",
"improvise-ws-server",
"serde",
"serde_json",
"wasm-bindgen",
+13
View File
@@ -25,6 +25,19 @@ wasm-bindgen = "0.2"
# this client over the same keypress and asserts their `ViewState`s agree. Dev
# dependencies are not built for `--target wasm32-unknown-unknown`, so the
# browser bundle is unaffected.
#
# `improvise-ws-server` is here for the same reason and on the same terms:
# `socket.rs` mirrors its two wire enums, and mirroring them is only safe if
# a rename fails a test. It is a native crate — tokio, tungstenite, an `App`
# owning a `Box<dyn Storage>` — so it could never be a real dependency.
[dev-dependencies]
improvise-command = { path = "../improvise-command" }
improvise-core = { path = "../improvise-core" }
improvise-ws-server = { path = "../improvise-ws-server" }
# The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by
# default, while LLVM emits `memory.copy` regardless — so the validator
# rejects the module unless the feature is named. Naming it here rather than
# turning `wasm-opt` off keeps the optimizer in the loop.
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O", "--enable-bulk-memory"]
+70 -4
View File
@@ -53,14 +53,18 @@
//! [`Client::on_key_event`] returned them.
pub mod keys;
pub mod render;
pub mod socket;
pub mod state;
pub mod wire;
pub use keys::{BrowserKey, KeyOutcome, resolve};
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
pub use socket::{ClientFrame, ServerFrame};
pub use state::ClientState;
pub use wire::{ClientMessage, ServerMessage};
use improvise_protocol::{CellId, CellValue, Command, ModelCommand, Snapshot};
use improvise_protocol::{CellId, CellValue, Command, ModelCommand, RecordCell, Snapshot};
use wasm_bindgen::prelude::*;
/// The handle a JS bootstrap holds for the lifetime of the tab.
@@ -151,6 +155,52 @@ impl Client {
let message = self.set_cell(row, col, value)?;
serde_json::to_string(&message).ok()
}
// ── the websocket surface (improvise-cr3) ───────────────────────────
//
// Three string calls, and they are the whole harness a page needs: send
// `viewport`, feed every socket frame to `on_frame`, paint `render_model`
// when the revision moves. See [`socket`] for why these frames are not
// [`ClientMessage`]/[`ServerMessage`].
/// The handshake frame, and the one to resend on every resize.
///
/// Associated rather than a method: a page must send it *before* it has
/// anything to fold, and nothing about this client's state is involved.
#[wasm_bindgen]
pub fn viewport(rows: usize, cols: usize) -> String {
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()
}
/// Fold one frame off the websocket, in arrival order, one at a time.
///
/// Returns the new revision so the page can skip a repaint. Never
/// reorder or coalesce: a batch that is split or sorted loses cells
/// (gotcha 33).
#[wasm_bindgen]
pub fn on_frame(&mut self, json: &str) -> Result<u64, JsError> {
self.handle_frame(json)
.map_err(|e| JsError::new(&e.to_string()))
}
/// Everything the page draws, with every rendering decision already
/// made. See [`render`].
#[wasm_bindgen]
pub fn render_model(&self) -> Result<String, JsError> {
self.model_json().map_err(|e| JsError::new(&e.to_string()))
}
/// The frame for "the user typed `text` into the cell at this viewport
/// position", or `None` when that position is not one this client may
/// write to. The page shows an editor only where `editable` is true, so
/// `None` here is a race, not a normal path.
#[wasm_bindgen]
pub fn typed_cell(&self, row: usize, col: usize, text: &str) -> Option<String> {
serde_json::to_string(&self.typed_cell_frame(row, col, text)?).ok()
}
}
/// The same surface in plain Rust, for tests and for any host that is not JS.
@@ -165,6 +215,24 @@ impl Client {
&self.state
}
/// The fold's target. Crate-visible on purpose in spirit — it is `pub`
/// only because tests in sibling modules drive it, and nothing outside
/// this crate should be folding into a client behind its own methods.
pub fn state_mut(&mut self) -> &mut ClientState {
&mut self.state
}
/// The address of the stored cell at a viewport position, or `None` on
/// the three shapes that are not one: outside the grid, no projected
/// cell (`id: None`), or an aggregate. The last is not a check — the
/// pattern simply does not bind (ADR 002).
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
match self.state.grid.cell(row, col)?.id.clone()? {
CellId::Record(cell) => Some(cell),
CellId::Aggregate(_) => None,
}
}
/// Fold one server→client message given as JSON. A message that does not
/// parse folds nothing.
pub fn handle_message(&mut self, json: &str) -> Result<u64, serde_json::Error> {
@@ -210,9 +278,7 @@ impl Client {
/// which is the side that formats it; guessing it locally is how the two
/// ends drift apart.
pub fn set_cell(&self, row: usize, col: usize, value: CellValue) -> Option<ClientMessage> {
let CellId::Record(cell) = self.state.grid.cell(row, col)?.id.clone()? else {
return None;
};
let cell = self.record_cell_at(row, col)?;
Some(ClientMessage::Command(Command::Model(
ModelCommand::SetCell { cell, value },
)))
+388
View File
@@ -0,0 +1,388 @@
//! What the page paints: the client's state with every rendering decision
//! already made (improvise-cr3).
//!
//! The DOM half of this client is JavaScript, and JavaScript in this repo is
//! untested. So the split is drawn to make that acceptable rather than to
//! make it comfortable: **every question with a wrong answer is answered
//! here, in Rust, under test**, and what crosses to the page is a list of
//! rows of cells that each already know whether they are editable, numeric,
//! erroneous and selected. The page's job is `document.createElement`.
//!
//! Three decisions in particular are here because getting them wrong is
//! silent:
//!
//! - **Editable is a fact about the id, not about the text.** Only
//! `CellId::Record` may be written (ADR 002). `CellId::Aggregate` is a
//! *drill* target and `Drill` is not on the wire yet, so it renders as
//! non-editable rather than as a control that does nothing. And `None` is
//! **not** "aggregate" — it is "no projected cell here": a padded slot, a
//! blanked one, or a records-mode column that renames a coordinate across
//! the whole row (gotcha 1) rather than writing one.
//! - **The selection is absolute and the grid is not.** `GridCache` is
//! indexed by position *within the viewport*, while `ViewState`'s
//! `selected` and `row_offset`/`col_offset` are positions within the whole
//! grid. Subtracting the offset is the entire conversion, and the trap is
//! that forgetting it looks right at the top of a document and wrong after
//! the first scroll. A selection outside the window highlights nothing.
//! - **The category panel is [`flatten_cat_tree`], not a second walk of the
//! cache.** That function exists in `improvise-protocol` precisely so this
//! end and `move-panel-cursor` share one flattening (improvise-edp); the
//! page never sees `CategoryTreeCache` at all.
//!
//! Nothing here is on the wire. It is a *projection of a projection* — the
//! client's own caches shaped for one renderer — so it may change freely
//! without a protocol conversation.
use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
use improvise_protocol::{CellDisplay, CellId};
use serde::Serialize;
use crate::{Client, ClientState};
/// One frame's worth of everything the page draws.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderModel {
/// The revision this model was built at. A page that stores it can skip
/// a repaint without re-serializing.
pub revision: u64,
/// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String,
pub status: String,
/// The document's active view, as this client knows it (gotcha 29).
pub view_name: String,
pub col_labels: Vec<String>,
pub rows: Vec<RenderRow>,
/// The category panel, flattened by the one flattening.
pub categories: Vec<RenderCatRow>,
/// Whether the panel is open, so the page does not decide.
pub category_panel_open: bool,
}
/// A grid row: its header label and its cells.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderRow {
pub label: String,
pub cells: Vec<RenderCell>,
}
/// One cell, with every question already answered.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCell {
pub text: String,
/// Right-align. The server decided this; it is echoed, not re-derived.
pub numeric: bool,
pub error: bool,
/// May this client compose a write for it? True for `CellId::Record`
/// alone.
pub editable: bool,
pub selected: bool,
}
impl RenderCell {
fn of(cell: &CellDisplay, selected: bool) -> Self {
Self {
text: cell.text.clone(),
numeric: cell.numeric,
error: cell.error,
editable: matches!(cell.id, Some(CellId::Record(_))),
selected,
}
}
}
/// One row of the category panel.
///
/// `item_count` and `expanded` are meaningful on a category row and are the
/// header's own facts; an item row carries neither, which is why the two are
/// `Option` rather than sentinels.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCatRow {
pub label: String,
/// True for a category header, false for an item under one.
pub is_category: bool,
/// The axis initial for a category header (`""` for `Axis::None`, which
/// is a real answer and not a truncation — gotcha 26).
pub axis: String,
pub item_count: Option<usize>,
pub expanded: Option<bool>,
/// Under `ViewState::cat_panel_cursor`, which indexes this very list.
pub selected: bool,
}
impl ClientState {
/// Build the page's frame from the caches and the view slice.
pub fn render_model(&self) -> RenderModel {
let cursor = self.view.cursor();
let (sel_row, sel_col) = cursor.selected;
let entries = flatten_cat_tree(&self.categories, &self.view.expanded_cats);
RenderModel {
revision: self.revision,
mode: self.view.mode.label().to_string(),
status: self.view.status_msg.clone(),
view_name: self.view.active_view.clone(),
col_labels: self.grid.col_labels.clone(),
rows: self
.grid
.row_labels
.iter()
.enumerate()
.map(|(r, label)| RenderRow {
label: label.clone(),
cells: (0..self.grid.col_labels.len())
.map(|c| {
// The one conversion: viewport position + offset
// is the absolute position the cursor names.
let selected = r + cursor.row_offset == sel_row
&& c + cursor.col_offset == sel_col;
match self.grid.cell(r, c) {
Some(cell) => RenderCell::of(cell, selected),
None => RenderCell {
selected,
..RenderCell::default()
},
}
})
.collect(),
})
.collect(),
categories: entries
.iter()
.enumerate()
.map(|(i, entry)| RenderCatRow::of(entry, i == self.view.cat_panel_cursor))
.collect(),
category_panel_open: self.view.category_panel_open,
}
}
}
impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry {
CatTreeEntry::Category {
name,
item_count,
expanded,
axis,
} => Self {
label: name.clone(),
is_category: true,
axis: axis.initial().to_string(),
item_count: Some(*item_count),
expanded: Some(*expanded),
selected,
},
CatTreeEntry::Item { item_name, .. } => Self {
label: item_name.clone(),
is_category: false,
axis: String::new(),
item_count: None,
expanded: None,
selected,
},
}
}
}
impl Client {
pub fn model(&self) -> RenderModel {
self.state().render_model()
}
pub fn model_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.model())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init;
use improvise_core::view::Axis;
use improvise_protocol::{
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, Projection, RecordCell, Snapshot,
ViewState,
};
fn surrogate() -> improvise_protocol::RecordHash {
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("hex")
}
fn aggregate() -> improvise_protocol::AggregateHash {
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex")
}
fn record_id() -> CellId {
CellId::Record(RecordCell {
record: surrogate(),
measure: Some("Revenue".into()),
})
}
/// A 2x2 grid whose four cells cover the three id shapes plus a padded
/// slot, so one fixture answers every editability question.
fn client_with_grid(view: ViewState) -> Client {
let mut client = init();
client.bootstrap(Snapshot {
view,
seed: vec![
Projection::Grid(GridUpdate::RowLabels(vec!["North".into(), "South".into()])),
Projection::Grid(GridUpdate::ColLabels(vec![
"Revenue".into(),
"Total".into(),
])),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), CellDisplay::number("1200").with_id(record_id())),
(
(0, 1),
CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())),
),
((1, 0), CellDisplay::text("pending")),
])),
],
});
client
}
/// ADR 002's permission, as the page sees it. The three cases are three
/// different facts, and only the first is a cell a user may type into.
#[test]
fn only_a_record_cell_renders_as_editable() {
let model = client_with_grid(ViewState::default()).model();
assert!(model.rows[0].cells[0].editable, "a stored cell is editable");
assert!(
!model.rows[0].cells[1].editable,
"an aggregate is a drill target, and Drill is not on the wire yet"
);
assert!(
!model.rows[1].cells[0].editable,
"id: None is not 'aggregate' — it is 'no projected cell', and not writable either"
);
assert!(
!model.rows[1].cells[1].editable,
"and neither is a slot fit_to_labels padded"
);
}
/// The numeric/error flags are the server's answers, echoed. The client
/// must not re-derive them from the text (that is how the two ends
/// drift), so the test asserts the echo rather than a parse.
#[test]
fn the_servers_formatting_facts_are_echoed_not_re_derived() {
let model = client_with_grid(ViewState::default()).model();
assert!(model.rows[0].cells[0].numeric);
assert!(!model.rows[1].cells[0].numeric);
assert_eq!(model.rows[0].cells[0].text, "1200");
assert_eq!(model.col_labels, vec!["Revenue", "Total"]);
assert_eq!(model.rows[1].label, "South");
}
/// The conversion that looks right until the first scroll: the grid is
/// viewport-indexed, the cursor is not. With `row_offset = 4`, absolute
/// row 5 is *render* row 1.
#[test]
fn the_selection_is_placed_by_subtracting_the_scroll_offset() {
let mut view = ViewState::default();
view.cursor_mut().selected = (5, 1);
view.cursor_mut().row_offset = 4;
view.cursor_mut().col_offset = 1;
let model = client_with_grid(view).model();
assert!(
model.rows[1].cells[0].selected,
"absolute (5,1) under offsets (4,1) is render position (1,0)"
);
let elsewhere: usize = model
.rows
.iter()
.flat_map(|r| &r.cells)
.filter(|c| c.selected)
.count();
assert_eq!(elsewhere, 1, "exactly one cell is selected");
}
/// A cursor scrolled off the projected window selects nothing, rather
/// than wrapping onto whatever cell happens to sit at that index.
#[test]
fn a_selection_outside_the_window_highlights_nothing() {
let mut view = ViewState::default();
view.cursor_mut().selected = (40, 0);
let model = client_with_grid(view).model();
assert!(
!model.rows.iter().flat_map(|r| &r.cells).any(|c| c.selected),
"a cursor the server has not projected a row for highlights nothing"
);
}
/// The panel comes from `flatten_cat_tree` and honours this client's own
/// `expanded_cats` — which is why the flattening is per-client and
/// derived rather than sent (improvise-edp).
#[test]
fn the_category_panel_is_the_shared_flattening() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![
CategoryRow {
name: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
CategoryRow {
name: "Measure".into(),
items: vec!["Revenue".into()],
axis: Axis::Column,
},
]),
)));
let collapsed = client.model().categories;
assert_eq!(collapsed.len(), 2, "collapsed categories show headers only");
assert_eq!(collapsed[0].axis, "R");
assert_eq!(collapsed[0].item_count, Some(2));
assert_eq!(collapsed[0].expanded, Some(false));
client
.state_mut()
.view
.expanded_cats
.insert("Region".into());
client.state_mut().view.cat_panel_cursor = 2;
let expanded = client.model().categories;
assert_eq!(expanded.len(), 4, "Region's two items appear under it");
assert!(!expanded[1].is_category);
assert_eq!(expanded[1].label, "North");
assert!(
expanded[2].selected,
"cat_panel_cursor indexes the flattened list, items included"
);
assert_eq!(
expanded[3].label, "Measure",
"and the next category follows its items"
);
}
/// The mode's label is `AppMode`'s own, so a page never spells one.
#[test]
fn the_mode_label_comes_off_the_mode() {
let mut client = init();
client.state_mut().fold(&Command::SetStatus("saved".into()));
let model = client.model();
assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label());
assert_eq!(model.status, "saved");
assert_eq!(model.revision, client.revision());
}
/// The whole model crosses as JSON, so the page needs no bindings.
#[test]
fn the_render_model_crosses_as_json() {
let json = client_with_grid(ViewState::default())
.model_json()
.expect("serializes");
let value: serde_json::Value = serde_json::from_str(&json).expect("is JSON");
assert_eq!(value["rows"][0]["cells"][0]["editable"], true);
assert_eq!(value["rows"][0]["cells"][1]["editable"], false);
assert_eq!(value["col_labels"][0], "Revenue");
}
}
+348
View File
@@ -0,0 +1,348 @@
//! The frames a browser tab exchanges with `improvise-ws-server`, and the
//! fold that consumes them in arrival order (improvise-cr3).
//!
//! # Why this is not [`crate::wire`]
//!
//! This crate already has an envelope pair, and it is a *different* pair.
//! [`ClientMessage`](crate::ClientMessage)/[`ServerMessage`](crate::ServerMessage)
//! frame the **worker** deployment (epic improvise-tm6): one command out,
//! one command back, and `ClientMessage::Key` for the keypresses a thin
//! client cannot resolve (gotcha 38). `improvise-ws-server`'s socket speaks
//! a different pair for reasons that are each load-bearing:
//!
//! - it adds `Viewport { rows, cols }`, which is **the handshake** — the
//! bootstrap `Snapshot` seeds the *visible* cells, so one built at extent
//! zero is an empty grid (`improvise-ws-server::wire`);
//! - it batches: `Commands(Vec<Command>)`, because a host's fan-out for one
//! client frame is several projections that must arrive whole and in
//! sequence (gotcha 33);
//! - it has **no `Key` variant** at all. Key forwarding is not on that
//! socket yet, which is why keyboard navigation is still improvise-bpq2.
//!
//! So the two are not the same enum wearing two names, and collapsing them
//! would mean deciding those three questions for the worker transport too.
//!
//! # The duplication, stated rather than hidden
//!
//! These two enums *mirror* `improvise_ws_server::wire::{ClientMsg, ServerMsg}`
//! and must stay JSON-compatible with them. They are duplicated because
//! `improvise-ws-server` is a native crate — tokio, tungstenite, a
//! `Box<dyn Storage>` — and cannot be a dependency of a wasm bundle. The
//! honest fix is for `improvise-protocol` to own the framing and both ends
//! to use it; that is a protocol change and deliberately not made here.
//!
//! What stands in for it until then is [`tests::the_socket_framing_matches_the_server`]:
//! `improvise-ws-server` is a **dev**-dependency (dev-dependencies are not
//! built for `--target wasm32-unknown-unknown`, the same argument
//! `improvise-command` already makes in this crate's `Cargo.toml`), so every
//! variant is round-tripped *through the other crate's type* in both
//! directions. A renamed variant on either side fails that test rather than
//! failing in a browser.
//!
//! # Ordering
//!
//! [`Client::fold_frame`] is a `for` over `Commands` in arrival order and
//! nothing else — no batching, sorting or coalescing — because a
//! `GridUpdate::Cells` that overtakes its framing labels is dropped, not
//! replayed (gotcha 33). A batch is one frame precisely so a transport
//! cannot split it.
use improvise_protocol::{CellValue, Command, ModelCommand, Snapshot};
use serde::{Deserialize, Serialize};
use crate::Client;
/// Browser → `improvise-ws-server`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClientFrame {
/// How many rows and columns of the grid this tab can display. The
/// connection's **first** frame, and sent again on every resize.
Viewport { rows: usize, cols: usize },
/// User intent, in the shared vocabulary.
Command(Command),
}
/// `improvise-ws-server` → browser.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerFrame {
/// Drop everything and start from this.
Snapshot(Box<Snapshot>),
/// Fold these, in order.
Commands(Vec<Command>),
}
/// How a typed string becomes a [`CellValue`].
///
/// The same three-way split `commit_regular_cell_value` makes in
/// `improvise-command` — empty clears, a parseable number is a number,
/// anything else is text. It is duplicated here for the reason the whole
/// client exists: the browser has no `App` to ask, and `ModelCommand::SetCell`
/// takes a `CellValue`, so *somebody* on this side must decide. Putting it in
/// Rust rather than in the page's JavaScript is what makes it testable and
/// keeps one spelling of the rule per language boundary instead of two.
///
/// `None` means "clear this cell", which is a different command, not a
/// different value — hence the `Option` rather than a `CellValue::Text("")`.
fn typed(text: &str) -> Option<CellValue> {
match text.is_empty() {
true => None,
false => Some(match text.parse::<f64>() {
Ok(n) => CellValue::Number(n),
Err(_) => CellValue::Text(text.to_string()),
}),
}
}
impl Client {
/// The handshake frame, and every resize after it.
///
/// Nothing is folded locally: the extent is not in `ViewState` and no
/// command carries it, so the grid this client draws next is whatever
/// the host projects in answer.
pub fn viewport_frame(rows: usize, cols: usize) -> ClientFrame {
ClientFrame::Viewport { rows, cols }
}
/// Fold one server frame, in arrival order.
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
match frame {
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
ServerFrame::Commands(commands) => {
for cmd in &commands {
self.state_mut().fold(cmd);
}
}
}
self.revision()
}
/// Parse and fold one server frame given as JSON.
pub fn handle_frame(&mut self, json: &str) -> Result<u64, serde_json::Error> {
let frame: ServerFrame = serde_json::from_str(json)?;
Ok(self.fold_frame(frame))
}
/// Compose the write a user made by typing `text` into the cell at a
/// viewport position.
///
/// `None` on exactly the shapes [`Client::set_cell`] refuses — outside
/// the grid, no projected cell, or an aggregate, which `SetCell` cannot
/// be composed for because it does not take that kind of id (ADR 002).
pub fn typed_cell_frame(&self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
let cell = self.record_cell_at(row, col)?;
Some(ClientFrame::Command(Command::Model(match typed(text) {
Some(value) => ModelCommand::SetCell { cell, value },
None => ModelCommand::ClearCell(cell),
})))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init;
use improvise_protocol::{CellDisplay, CellId, GridUpdate, Projection, RecordCell, ViewState};
fn surrogate(hex: &str) -> improvise_protocol::RecordHash {
serde_json::from_str(&format!("\"{hex}\"")).expect("a surrogate is hex")
}
fn record(measure: &str) -> RecordCell {
RecordCell {
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
measure: Some(measure.to_string()),
}
}
fn frames() -> (Vec<ClientFrame>, Vec<ServerFrame>) {
(
vec![
ClientFrame::Viewport { rows: 24, cols: 8 },
ClientFrame::Command(Command::SetSelected { row: 1, col: 2 }),
],
vec![
ServerFrame::Snapshot(Box::new(Snapshot {
view: ViewState::default(),
seed: vec![Projection::Formulas(vec!["Profit = Revenue".into()])],
})),
ServerFrame::Commands(vec![Command::SetStatus("saved".into())]),
],
)
}
/// The compatibility this module owes, checked through the other
/// crate's own types rather than asserted in prose. `improvise-ws-server`
/// is a dev-dependency, so this costs the browser bundle nothing.
#[test]
fn the_socket_framing_matches_the_server() {
use improvise_ws_server::wire::{ClientMsg, ServerMsg};
let (client_frames, server_frames) = frames();
for frame in &client_frames {
let json = serde_json::to_string(frame).expect("serializes");
let theirs: ClientMsg = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("the server cannot read {json}: {e}"));
assert_eq!(
serde_json::from_str::<ClientFrame>(
&serde_json::to_string(&theirs).expect("serializes")
)
.expect("and back"),
*frame,
);
}
for frame in &server_frames {
let json = serde_json::to_string(frame).expect("serializes");
let theirs: ServerMsg = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("the server cannot read {json}: {e}"));
assert_eq!(
serde_json::from_str::<ServerFrame>(
&serde_json::to_string(&theirs).expect("serializes")
)
.expect("and back"),
*frame,
);
}
}
/// `doctrine(check_the_checker)`: the test above is only worth anything
/// if a *renamed* variant fails it. The rename is not hypothetical —
/// this crate's other envelope spells the same payload `Bootstrap`
/// rather than `Snapshot`, and `Command` rather than `Commands`. Neither
/// name parses here, so the compatibility test above is discriminating
/// on exactly the thing that would drift.
#[test]
fn a_frame_named_the_other_envelopes_way_does_not_parse() {
let ours = serde_json::to_string(&ServerFrame::Commands(vec![Command::SetStatus(
"saved".into(),
)]))
.expect("serializes");
let other = ours.replacen("Commands\":[", "Command\":", 1);
assert!(
serde_json::from_str::<ServerFrame>(&other).is_err(),
"a renamed variant must fail to parse, or the compatibility test proves nothing"
);
assert!(
serde_json::from_str::<ServerFrame>(r#"{"Bootstrap":{"view":null,"seed":[]}}"#)
.is_err()
);
}
/// A batch is folded in arrival order and is not reordered, so gotcha 33
/// holds *inside* one frame exactly as it holds across frames: cells that
/// precede the labels framing them are dropped.
#[test]
fn a_batch_is_folded_in_arrival_order() {
let cells = Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
(0, 0),
CellDisplay::number("1200"),
)])));
let rows = Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![
"North".into(),
])));
let cols = Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![
"Revenue".into(),
])));
let mut early = init();
early.fold_frame(ServerFrame::Commands(vec![
cells.clone(),
rows.clone(),
cols.clone(),
]));
assert_eq!(
early.state().grid.cell(0, 0),
Some(&CellDisplay::default()),
"a batched Cells that overtakes its frame is still dropped"
);
let mut ordered = init();
ordered.fold_frame(ServerFrame::Commands(vec![rows, cols, cells]));
assert_eq!(
ordered.state().grid.cell(0, 0),
Some(&CellDisplay::number("1200")),
);
}
/// Folding a batch bumps the revision once per command, not once per
/// frame — a renderer polling `revision` must see a five-command batch
/// as movement even if it only repaints once.
#[test]
fn every_command_in_a_batch_bumps_the_revision() {
let mut client = init();
let after = client.fold_frame(ServerFrame::Commands(vec![
Command::SetStatus("a".into()),
Command::SetStatus("b".into()),
Command::SetStatus("c".into()),
]));
assert_eq!(after, 3);
assert_eq!(client.state().view.status_msg, "c");
}
fn client_showing(id: Option<CellId>) -> Client {
let mut cell = CellDisplay::number("1200");
cell.id = id;
let mut client = init();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])),
Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])),
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
],
});
client
}
/// The three-way split, matching `commit_regular_cell_value`: a number
/// is a number, anything else is text, and *empty clears* rather than
/// writing an empty string.
#[test]
fn a_typed_string_is_classified_as_the_host_classifies_it() {
let client = client_showing(Some(CellId::Record(record("Revenue"))));
assert_eq!(
client.typed_cell_frame(0, 0, "42.5"),
Some(ClientFrame::Command(Command::Model(
ModelCommand::SetCell {
cell: record("Revenue"),
value: CellValue::Number(42.5),
}
)))
);
assert_eq!(
client.typed_cell_frame(0, 0, "pending"),
Some(ClientFrame::Command(Command::Model(
ModelCommand::SetCell {
cell: record("Revenue"),
value: CellValue::Text("pending".into()),
}
)))
);
assert_eq!(
client.typed_cell_frame(0, 0, ""),
Some(ClientFrame::Command(Command::Model(
ModelCommand::ClearCell(record("Revenue"))
))),
"an empty string clears the cell; it is not a Text write"
);
}
/// Typing into a cell the client may not write is no frame at all, for
/// the same reason `set_cell` composes nothing: the id is of the wrong
/// kind, or there is no id. `Drill` is not on the wire yet, so an
/// aggregate is simply not editable here.
#[test]
fn typing_into_a_cell_that_is_not_a_record_composes_nothing() {
let hash: improvise_protocol::AggregateHash =
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex");
assert_eq!(
client_showing(Some(CellId::Aggregate(hash))).typed_cell_frame(0, 0, "42"),
None
);
assert_eq!(client_showing(None).typed_cell_frame(0, 0, "42"), None);
assert_eq!(
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42"),
None,
"and neither is a position outside the grid"
);
}
}
@@ -0,0 +1,238 @@
//! `improvise-1ey`'s stated scope, asserted end to end without a browser.
//!
//! > open an existing document server-side, view it in the browser, **type a
//! > number into a cell, see the server value update and the projection
//! > arrive with the new value.**
//!
//! Everything in that sentence except the DOM is Rust on both sides, so this
//! test drives the *whole* of it: a real [`Host`] over an authoritative
//! `App`, and a real [`Client`] reached only through the string calls the
//! page makes. The socket is the one thing left out, and it is covered by
//! `improvise-ws-server`'s own `tests/socket.rs`; what this adds is the half
//! that crate cannot reach — that the **browser client** seeds, renders,
//! composes and folds correctly against it.
//!
//! The discipline that makes it evidence rather than decoration: the client
//! is touched **only** through `Client::viewport`, `Client::on_frame`,
//! `Client::render_model` and `Client::typed_cell` — four strings in and out
//! — and it learns the cell to write from its own render model, never from
//! the fixture. A `CellKey` anywhere here would be the test knowing
//! something a browser does not (ADR 002).
use improvise_command::ui::app::App;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::workbook::Workbook;
use improvise_wasm_client::{Client, RenderModel};
use improvise_ws_server::host::{Addressed, Host};
use improvise_ws_server::wire::ClientMsg;
/// The document the server opens: two regions by two measures, one cell
/// already filled so the client has something to read before it writes.
fn host() -> Host {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1200.0),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
}
/// The page's own loop, minus the DOM: every frame the host addressed to
/// this session, in order, through the one call that folds them.
fn deliver(client: &mut Client, frames: &[Addressed]) {
for Addressed { msg, .. } in frames {
client
.on_frame(&serde_json::to_string(msg).expect("a server frame serializes"))
.expect("the client must fold every frame the host sends");
}
}
/// The one frame the page sends that is not a command: the handshake.
fn send(
host: &mut Host,
id: improvise_command::ui::project::SessionId,
json: &str,
) -> Vec<Addressed> {
let msg: ClientMsg =
serde_json::from_str(json).expect("the server must be able to read what the page sends");
host.handle(id, msg)
}
/// The position of the first cell the render model says may be typed into.
/// Read off the model rather than assumed, because "which cells are
/// editable" is precisely what the client is being trusted to say.
fn first_editable(model: &RenderModel) -> (usize, usize) {
model
.rows
.iter()
.enumerate()
.find_map(|(r, row)| {
row.cells
.iter()
.position(|cell| cell.editable)
.map(|c| (r, c))
})
.expect("a pivot of raw data must offer at least one settable cell")
}
#[test]
fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
let mut host = host();
let mut client = Client::new();
// ── the handshake ───────────────────────────────────────────────────
// The extent comes first because the snapshot seeds the *visible*
// cells: one built at extent zero is an empty grid.
let handshake: ClientMsg = serde_json::from_str(&Client::viewport(10, 10))
.expect("the viewport frame must be one the server reads");
let ClientMsg::Viewport { rows, cols } = handshake else {
panic!("Client::viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
// ── what the page draws ─────────────────────────────────────────────
let model = client.model();
assert_eq!(
model
.rows
.iter()
.map(|r| r.label.as_str())
.collect::<Vec<_>>(),
vec!["North", "South"],
"the seed must carry the row labels framing the cells"
);
assert_eq!(model.col_labels, vec!["Revenue", "Cost"]);
assert_eq!(
model.rows[0].cells[0].text, "1,200",
"the value arrives *formatted* — the separator is the server's view \
format, and a client that re-derived the text would not have it"
);
assert!(model.rows[0].cells[0].numeric);
// ── typing a number ─────────────────────────────────────────────────
let (row, col) = first_editable(&model);
assert_eq!(
(row, col),
(0, 0),
"with no category on the None axis every cell is a stored cell"
);
let frame = client
.typed_cell(row, col, "7500")
.expect("an editable cell must compose a write");
let frames = send(&mut host, id, &frame);
// ── the server value updated ────────────────────────────────────────
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(7500.0)),
"the authoritative document is the thing that must have moved"
);
// ── and the projection arrives with the new value ───────────────────
assert!(
!frames.is_empty(),
"a cell write owes this client a grid projection"
);
let before = client.revision();
deliver(&mut client, &frames);
assert!(
client.revision() > before,
"folding the fan-out must move the client"
);
assert_eq!(
client.model().rows[row].cells[col].text,
"7,500",
"the page redraws from the server's formatting, never from the \
\"7500\" the user typed"
);
}
/// The other half of ADR 002, end to end: a cell the server projected as an
/// **aggregate** is rendered non-editable, so the page never offers an
/// editor `SetCell` could not be composed for. `Drill` is not on the wire
/// yet (ADR 001 layer 2), which is why the answer is "not editable" rather
/// than "opens a drill".
#[test]
fn an_aggregated_cell_reaches_the_page_as_not_editable() {
let mut host = {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
// A regular category left on the None axis is what makes a cell an
// aggregate — the same question `EditOrDrill` asks in the TUI.
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.add_item("Product", "Hats").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().set_axis("Product", Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
};
let mut client = Client::new();
let (_id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let model = client.model();
assert!(
model
.rows
.iter()
.flat_map(|r| &r.cells)
.all(|c| !c.editable),
"every cell here aggregates over Product, so none may be written"
);
assert_eq!(
client.typed_cell(0, 0, "42"),
None,
"and typing into one composes nothing, because the id is of the wrong kind"
);
}
/// A resize is a fan-out with nothing dirty, and it is the page's job to
/// send one: the extent is not in `ViewState` and no command carries it, so
/// a client that never resends it keeps the grid it first asked for.
#[test]
fn a_resize_reprojects_the_grid_at_the_new_extent() {
let mut host = host();
let mut client = Client::new();
let (id, snapshot) = host.connect(1, 1);
deliver(&mut client, &[snapshot]);
assert_eq!(
client.model().rows.len(),
1,
"a one-row viewport is seeded with one row"
);
let frames = send(&mut host, id, &Client::viewport(10, 10));
deliver(&mut client, &frames);
assert_eq!(
client.model().rows.len(),
2,
"the resize must bring the rest of the grid"
);
assert_eq!(client.model().col_labels, vec!["Revenue", "Cost"]);
}
+2
View File
@@ -0,0 +1,2 @@
# wasm-pack output; built, never committed.
pkg/
+167
View File
@@ -0,0 +1,167 @@
// The browser half of improvise-cr3, kept as thin as it can be made.
//
// The boundary is JSON strings in and out, so this file holds no protocol
// knowledge at all: it never inspects a Command, never builds a CellId, never
// decides whether a cell is editable or whether a typed string is a number.
// Four calls are the whole harness —
//
// Client.viewport(rows, cols) -> the handshake frame, and every resize
// client.on_frame(json) -> fold one socket frame, in arrival order
// client.render_model() -> everything to paint, already decided
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
//
// — and the two rules this file must not break are ordering and framing:
// frames are folded one at a time in the order they arrive (a cell update
// that overtakes its labels is dropped, not replayed), and the *first* frame
// out must be the viewport, because the server builds the snapshot at it.
import init, { Client } from "./pkg/improvise_wasm_client.js";
const params = new URLSearchParams(location.search);
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`;
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
const ROW_PX = 26;
const COL_PX = 110;
const el = {
grid: document.getElementById("grid"),
categories: document.getElementById("categories"),
mode: document.getElementById("mode"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
await init();
const client = new Client();
const socket = new WebSocket(WS_URL);
/** The extent this window can show, in whole cells. */
function extent() {
const box = el.grid.getBoundingClientRect();
return {
rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1),
cols: Math.max(1, Math.floor(box.width / COL_PX) - 1),
};
}
function sendViewport() {
if (socket.readyState !== WebSocket.OPEN) return;
const { rows, cols } = extent();
socket.send(Client.viewport(rows, cols));
}
let painted = -1;
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
function repaint() {
const model = JSON.parse(client.render_model());
if (model.revision === painted || editing) return;
painted = model.revision;
el.mode.textContent = model.mode;
el.status.textContent = model.status;
el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model));
el.categories.replaceChildren(categoryList(model));
}
function gridTable(model) {
const table = document.createElement("table");
const head = table.createTHead().insertRow();
head.appendChild(document.createElement("th"));
for (const label of model.col_labels) {
const th = document.createElement("th");
th.textContent = label;
head.appendChild(th);
}
const body = table.createTBody();
model.rows.forEach((row, r) => {
const tr = body.insertRow();
const header = document.createElement("th");
header.scope = "row";
header.textContent = row.label;
tr.appendChild(header);
row.cells.forEach((cell, c) => {
const td = tr.insertCell();
td.textContent = cell.text;
td.classList.toggle("numeric", cell.numeric);
td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected);
// `editable` is the server's answer relayed through Rust: a stored
// cell. An aggregate is a drill target and drill is not on the wire
// yet, so it is simply not offered here.
td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c));
});
});
return table;
}
/** Open an inline editor. Committing sends a frame; the value that comes
* back is the server's, never the string typed here. */
function edit(td, row, col) {
if (editing) return;
editing = { row, col };
const original = td.textContent;
const input = document.createElement("input");
input.value = original;
td.replaceChildren(input);
input.focus();
input.select();
const close = (commit) => {
editing = null;
if (commit) {
const frame = client.typed_cell(row, col, input.value);
// null only if the grid moved under the editor; the repaint that
// follows restores whatever the server now says is there.
if (frame) socket.send(frame);
}
td.replaceChildren(original);
painted = -1;
repaint();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") close(true);
else if (e.key === "Escape") close(false);
e.stopPropagation();
});
input.addEventListener("blur", () => close(false));
}
function categoryList(model) {
const list = document.createElement("ul");
for (const row of model.categories) {
const li = document.createElement("li");
li.className = row.is_category ? "category" : "item";
li.classList.toggle("selected", row.selected);
li.textContent = row.is_category
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
: row.label;
list.appendChild(li);
}
return list;
}
socket.addEventListener("open", () => {
sendViewport(); // the handshake: the snapshot is built at this extent
el.status.textContent = "connected";
});
socket.addEventListener("message", (event) => {
// One frame, one fold, in arrival order. Never batch or reorder these.
client.on_frame(event.data);
repaint();
});
socket.addEventListener("close", () => {
el.status.textContent = "disconnected";
});
new ResizeObserver(sendViewport).observe(el.grid);
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Build the wasm bundle this page loads, then say how to run it.
#
# Needs wasm-pack (cargo install wasm-pack) and the wasm32-unknown-unknown
# target. Nothing else in the repo depends on either — the `wasm` CI job only
# runs `cargo check --target wasm32-unknown-unknown`, which does not need a
# bindgen step.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
wasm-pack build "$root/crates/improvise-wasm-client" \
--target web --out-dir "$root/web/pkg"
cat <<'MSG'
Built. To run it, in two terminals:
improvise-ws-server your-model.improv --port 8787
python3 -m http.server 8080 --directory web
then open http://127.0.0.1:8080/ (override the socket with ?ws=ws://host:port).
MSG
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<!--
The browser shell for improvise-ws-server (improvise-cr3).
Deliberately three elements and no framework. Everything that could be
*wrong* — what a cell is, whether it may be typed into, where the
selection sits, how the category tree flattens — is decided in Rust and
arrives already decided (see `render.rs`). This page paints it.
Build the wasm module into ./pkg first:
wasm-pack build crates/improvise-wasm-client --target web --out-dir ../../web/pkg
then serve this directory over http (a module script will not load from
file://) and run the server beside it:
improvise-ws-server bank-info.improv --port 8787
python3 -m http.server 8080 --directory web
The socket address can be overridden with ?ws=ws://host:port.
-->
<header>
<h1>improvise</h1>
<span id="view-name"></span>
<span id="mode"></span>
<span id="status" role="status"></span>
</header>
<main>
<aside id="categories" aria-label="Categories"></aside>
<div id="grid" tabindex="0" aria-label="Grid"></div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
/* Legibility only. Styling the browser client properly is not improvise-cr3. */
:root {
--bg: #fbfaf7;
--fg: #1c1b19;
--line: #d8d4cc;
--accent: #2f6f4f;
--selected: #e8e2d4;
--error: #a8322d;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17181a;
--fg: #e7e4de;
--line: #35373b;
--accent: #7fc4a0;
--selected: #2c2f33;
--error: #e2827c;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--fg);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
}
header {
display: flex;
gap: 1rem;
align-items: baseline;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
}
header h1 {
margin: 0;
font-size: 1rem;
color: var(--accent);
}
#mode {
color: var(--accent);
}
#status {
margin-left: auto;
opacity: 0.75;
}
main {
flex: 1;
display: flex;
min-height: 0;
}
#categories {
width: 15rem;
flex: none;
overflow: auto;
padding: 0.5rem;
border-right: 1px solid var(--line);
}
#categories ul {
margin: 0;
padding: 0;
list-style: none;
}
#categories .item {
padding-left: 1.5rem;
opacity: 0.85;
}
#categories .selected,
td.selected {
background: var(--selected);
outline: 1px solid var(--accent);
}
#grid {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--line);
padding: 0.25rem 0.5rem;
min-width: 6rem;
height: 1.75rem;
text-align: left;
font-weight: normal;
}
th {
background: color-mix(in srgb, var(--line) 35%, transparent);
font-weight: 600;
}
td.numeric {
text-align: right;
}
td.error {
color: var(--error);
}
/* The only affordance that matters: a cell the server said is settable. */
td.editable {
cursor: text;
}
td:not(.editable) {
opacity: 0.7;
}
td input {
width: 100%;
border: none;
padding: 0;
font: inherit;
color: inherit;
background: transparent;
}