From bb6d1c35ee78c1d470f7896973665d8d57e63fdf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 9 Nov 2026 14:02:07 -0500 Subject: [PATCH 1/2] M12: a board table's height change goes into its rows (#2172) Resizing a board table by its bottom edge, a corner or a height command left it at a fixed height, so its rows stayed at 40 with blank space below them (or were clipped when shrunk) and typing in a cell no longer grew the table. Measured in FigJam's web app (2026-11-09): a table has no lock-proportions control and never scales proportionally; a bottom edge grows only the last row, a corner drag shares the change equally over every row and column, text keeps its size, and a cell has a minimum size. board_table::settle_resize runs first in the normalize pass (so a drag, node.set, transform.resize, MCP and live-session peers all agree): a table left at a fixed height gets the change in its rows' minimum height (the last row, or every row when both axes changed), is kept at least columns x 40 wide, and hugs its rows again. New cells get a minimum width of 40. FigJam's floor is about 52 and FigCraft's is 40; there are no per-column widths yet. Docs: boards-parity Tables row, revision history and status line; the board_table module doc. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Fable 5.1 (work seat) scoped; Sonnet 5.5 measured in FigJam and wrote at medium --- crates/engine/src/board_tables_tests.rs | 181 ++++++++++++++++++++++ crates/engine/src/boards/board/derived.rs | 1 + crates/engine/src/boards/board_table.rs | 66 ++++++++ docs/boards-parity.md | 5 +- 4 files changed, 251 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/board_tables_tests.rs b/crates/engine/src/board_tables_tests.rs index 2c0d07bbc..7ae3a64ce 100644 --- a/crates/engine/src/board_tables_tests.rs +++ b/crates/engine/src/board_tables_tests.rs @@ -255,3 +255,184 @@ fn template_and_table_commands_survive_junk() { } } } + +// --------------------------------------------------------------------------------------------- +// Resizing a table (#2172): a height change goes into its rows, never into blank space. + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); +} + +/// A selected 3 x 3 table at 0,0 (480 x 122: a line under all but the last row adds 1 to each), with `texts` in the first row. +fn table_3x3(texts: [&str; 3]) -> (Session, NodeId) { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [texts, ["", "", ""], ["", "", ""]]})).unwrap(); + (s, id(&r, "id")) +} + +fn rows_of(s: &Session, t: NodeId) -> Vec { + let doc = s.doc().unwrap(); + doc.node(t).unwrap().children.iter().copied().filter(|c| !node(s, *c).child_layout.absolute).collect() +} + +fn row_heights(s: &Session, t: NodeId) -> Vec { + rows_of(s, t).into_iter().map(|r| node(s, r).height).collect() +} + +fn column_widths(s: &Session, t: NodeId) -> Vec { + let r = rows_of(s, t)[0]; + node(s, r).children.iter().map(|c| node(s, *c).width).collect() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.6 +} + +fn all_near(got: &[f64], want: &[f64]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(a, b)| near(*a, *b)) +} + +fn is_hug(s: &Session, t: NodeId) -> bool { + node(s, t).child_layout.sizing_v == figcraft_doc::Sizing::Hug +} + +#[test] +fn a_bottom_drag_grows_only_the_last_row() { + // The programmatic route. + let (mut s, t) = table_3x3(["Alpha", "", ""]); + s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap(); + assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t)); + let n = node(&s, t); + assert!(near(n.width, 480.0) && near(n.height, 272.0), "{}x{}", n.width, n.height); + assert!(is_hug(&s, t), "still hugging its rows"); + assert!(all_near(&column_widths(&s, t), &[160.0; 3])); + assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t)); + // The pointer route: the bottom edge dragged down by 150. + let (mut s, t) = table_3x3(["Alpha", "", ""]); + drag(&mut s, (240.0, 122.0), (240.0, 272.0)); + assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t)); + assert!(near(node(&s, t).height, 272.0) && near(node(&s, t).width, 480.0)); + assert!(is_hug(&s, t)); + assert!(all_near(&column_widths(&s, t), &[160.0; 3])); + assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t)); + // The text keeps its size. + let row0 = rows_of(&s, t)[0]; + let cell = node(&s, node(&s, row0).children[0]); + let NodeKind::Text(td) = &node(&s, cell.children[0]).kind else { panic!("text") }; + assert_eq!(td.style.font_size, 14.0); +} + +#[test] +fn a_corner_drag_shares_the_change_over_every_row_and_column() { + let (mut s, t) = table_3x3(["", "", ""]); + drag(&mut s, (480.0, 122.0), (570.0, 182.0)); + let n = node(&s, t); + assert!(near(n.width, 570.0) && near(n.height, 182.0), "{}x{}", n.width, n.height); + assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t)); + assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t)); + let xs: Vec = lines(&s, t).iter().map(|l| l.0).collect(); + assert!(all_near(&xs, &[189.5, 379.5]), "{xs:?}"); + assert!(is_hug(&s, t)); + // The same through `transform.resize`. + let (mut s, t) = table_3x3(["", "", ""]); + s.execute("transform.resize", &json!({"ids": [t], "width": 570, "height": 182})).unwrap(); + assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t)); + assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t)); +} + +#[test] +fn a_side_drag_changes_no_row() { + let (mut s, t) = table_3x3(["", "", ""]); + s.execute("node.set", &json!({"id": t, "props": {"width": 680}})).unwrap(); + assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t)); + assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0)); + assert!(is_hug(&s, t)); + // A pointer drag of the right edge. + let (mut s, t) = table_3x3(["", "", ""]); + drag(&mut s, (480.0, 60.0), (680.0, 60.0)); + assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t)); + assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0)); + assert!(is_hug(&s, t)); +} + +#[test] +fn a_table_never_shrinks_below_its_cells() { + let (mut s, t) = table_3x3(["Alpha", "Bravo longer text", "Charlie"]); + drag(&mut s, (480.0, 122.0), (50.0, 32.0)); + let n = node(&s, t); + assert!(near(n.width, 120.0), "width {}", n.width); + assert!(all_near(&column_widths(&s, t), &[40.0; 3]), "{:?}", column_widths(&s, t)); + let rows = row_heights(&s, t); + assert!(rows[0] > 40.0, "row 1 holds its wrapped text: {rows:?}"); + assert!(near(rows[1], 41.0) && near(rows[2], 40.0), "{rows:?}"); + assert!(near(n.height, rows.iter().sum::()), "{} vs {rows:?}", n.height); + assert!(is_hug(&s, t)); +} + +#[test] +fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() { + let (mut s, t) = table_3x3(["", "", ""]); + drag(&mut s, (240.0, 122.0), (240.0, 272.0)); + assert!(near(node(&s, t).height, 272.0)); + // Typing in a last-row cell: the table grows once the text needs more than 190. + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 200.0, 1.0)).unwrap(); + assert!(s.ds().unwrap().text_edit.is_some(), "typing in the last row's cell"); + let long = (0..12).map(|i| format!("line {i}")).collect::>().join("\n"); + crate::textedit::insert(&mut s, &long).unwrap(); + crate::textedit::end(&mut s).unwrap(); + assert!(node(&s, t).height > 272.0, "grew with its text: {}", node(&s, t).height); + assert!(is_hug(&s, t)); + // Undo: the typing, then the resize, to the table as made. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(node(&s, t).height, 272.0), "{}", node(&s, t).height); + s.execute("edit.undo", &json!({})).unwrap(); + let n = node(&s, t); + assert!(near(n.width, 480.0) && near(n.height, 122.0), "{}x{}", n.width, n.height); + assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t)); +} + +#[test] +fn table_resizes_sync_in_live_sessions() { + let s = board(); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 12); + let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id"); + room.settle(); + room.check(); + room.s(0).execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap(); + room.settle(); + room.check(); + let doc = room.server.document(); + let heights: Vec = + doc.node(t).unwrap().children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect(); + assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}"); + assert!(near(doc.node(t).unwrap().height, 272.0)); + assert_eq!(doc.node(t).unwrap().child_layout.sizing_v, figcraft_doc::Sizing::Hug); +} + +#[test] +fn resizing_a_table_survives_junk_and_saves_the_rows() { + for h in [json!(1e308), json!(-5), json!("NaN"), json!(null), json!(0)] { + let (mut s, t) = table_3x3(["", "", ""]); + if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": t, "props": {"height": h}})) { + panic!("{cmd} panicked on {h}: {msg}"); + } + for p in [json!({"ids": [t], "width": h, "height": h}), json!({"ids": [t], "height": h}), json!({"ids": [t], "width": h})] { + if let Err(EngineError::Internal { cmd, msg }) = s.execute("transform.resize", &p) { + panic!("{cmd} panicked on {p}: {msg}"); + } + } + let n = node(&s, t); + assert!(n.width.is_finite() && n.height.is_finite(), "{h}: {}x{}", n.width, n.height); + } + let (mut s, t) = table_3x3(["", "", ""]); + s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap(); + let doc = s.doc().unwrap(); + let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap(); + let heights: Vec = + back.node(t).unwrap().children.iter().map(|c| back.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect(); + assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}"); +} diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index 9fae17206..3db4d88de 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -15,6 +15,7 @@ pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTree if !is_board(doc) { return; } + crate::board_table::settle_resize(doc, before, touched); for &id in touched { let Some(n) = doc.node(id) else { continue }; if let Some(owner) = diff --git a/crates/engine/src/boards/board_table.rs b/crates/engine/src/boards/board_table.rs index aef7c8917..b77f343a0 100644 --- a/crates/engine/src/boards/board_table.rs +++ b/crates/engine/src/boards/board_table.rs @@ -3,6 +3,16 @@ //! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws //! a line under itself and the column lines are thin rectangles over the table that stretch with //! it (they are rebuilt after every edit, like the first row's header tint). +//! +//! Resizing (measured in FigJam's web app, 2026-11-09, #2172): a table never scales its text or +//! its rows in proportion. A height change goes into the rows ([`settle_resize`]): the bottom or +//! top edge grows the last row, a corner drag shares the change equally over every row (every +//! cell fills its row, so the width is already shared equally), and the table keeps hugging its +//! rows. A cell is at least [`MIN_CELL`] (40; FigJam's floor is about 52) wide and tall, and a row +//! is never shorter than its wrapped text. There are no per-column widths yet: a side drag of +//! the right edge splits the change equally where FigJam grows only the last column. + +use std::collections::BTreeSet; use figcraft_color::{Color, Paint}; use figcraft_doc::{ @@ -22,6 +32,10 @@ pub const MAX_COLUMNS: usize = 20; /// A new column's width, and a cell's height before its text is measured. const CELL_W: f64 = 160.0; const CELL_H: f64 = 40.0; +/// The least a cell is wide or tall (FigJam's is about 52). +pub(crate) const MIN_CELL: f64 = CELL_H; +/// The most a resize may ask one row to be tall. +const MAX_ROW_H: f64 = 100_000.0; /// Room around a cell's text: [top, right, bottom, left]. const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0]; const TEXT_SIZE: f64 = 14.0; @@ -125,6 +139,7 @@ fn make_cell(doc: &mut Document, row: NodeId, index: Option, width: f64, n.child_layout.sizing_h = Sizing::Fill; n.child_layout.sizing_v = Sizing::Hug; n.child_layout.min_h = Some(CELL_H); + n.child_layout.min_w = Some(MIN_CELL); })?; add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?; Ok(cell) @@ -259,6 +274,57 @@ fn table_cmd(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// A table resized by its height or a corner (the resize leaves it at a fixed height): the change +/// goes into its rows, as in FigJam, and the table hugs them again. A bottom or top edge (or a +/// height set by command) grows or shrinks the last row; a drag that changed both axes shares the +/// change equally over every row. A row never gets shorter than [`MIN_CELL`] or than its text (the +/// table hugs it), and the table is never narrower than its columns' minimum. A table already +/// hugging (a peer's synced edit, a second pass) is left alone, so the change is made once. +pub(crate) fn settle_resize(doc: &mut Document, before: &Document, touched: &BTreeSet) { + let mut tables = BTreeSet::new(); + for &id in touched { + if let Some(t) = table_of(doc, id) { + tables.insert(t); + } + } + for table in tables { + let (Some(now), Some(was)) = (doc.node(table), before.node(table)) else { continue }; + if now.child_layout.sizing_v != Sizing::Fixed { + continue; + } + let (w, h, bw, bh) = (now.width, now.height, was.width, was.height); + let (rows, _) = parts(doc, table); + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()); + let dh = h - bh; + if dh.is_finite() && dh.abs() > 1e-6 && !rows.is_empty() { + let both = w.is_finite() && bw.is_finite() && (w - bw).abs() > 1e-6; + let targets: Vec = if both { rows.clone() } else { rows.last().copied().into_iter().collect() }; + let share = dh / targets.len() as f64; + for row in targets { + let base = row_content(before, row).or_else(|| row_content(doc, row)).unwrap_or(CELL_H); + let min_h = (base + share).clamp(MIN_CELL, MAX_ROW_H); + for cell in cells(doc, row) { + if let Some(c) = doc.node_mut(cell) { + c.child_layout.min_h = Some(min_h); + } + } + } + } + if let Some(t) = doc.node_mut(table) { + let least = MIN_CELL * columns as f64; + if t.width.is_nan() || t.width < least { + t.width = least; + } + t.child_layout.sizing_v = Sizing::Hug; + } + } +} + +/// How tall a row's cells are: the row's height less the line under it. +fn row_content(doc: &Document, row: NodeId) -> Option { + cells(doc, row).into_iter().filter_map(|c| doc.node(c).map(|n| n.height)).reduce(f64::max) +} + /// The table `id` is or is in. fn table_of(doc: &Document, id: NodeId) -> Option { std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table))) diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 16456492c..a2c88a6eb 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** minor (resizing a board table by height changes its rows, not blank space below them: a bottom or top edge grows the last row, a corner shares the change over every row, and a table keeps at least 40 per cell, measured in FigJam's web app, 2026-11-09 (#2172); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -60,7 +60,7 @@ all end with the same geometry. | Voting results: who voted for what, earlier sessions' results | partial | once a vote ends, its results list who voted for each object and how many times (by name in a live session, kept with the results so someone who has left still shows by name), as the reference app shows how each participant voted; each ended vote's results are kept in the file (the last 20), listed newest first in the Results popover with arrows to earlier ones, and deleted one at a time (`board.vote {action: delete, index}`; Clear results only takes them off the board), as the reference app keeps each session's results until deleted; saved, synced in live sessions, and like the vote not undo steps. Anyone who can edit the board ends or clears a vote, as the reference app's help describes. A vote has no prompt (question) of its own | | Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards | | Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own | -| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text | +| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; resizing a table by its bottom edge or its height grows the last row and a corner drag shares the change equally over every row and column (a side drag changes no row), the table keeps hugging its rows and a cell keeps at least 40 each way (measured in FigJam's web app, 2026-11-09: its floor is about 52, FigCraft's is 40, and its right edge grows only the last column); a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No per-column widths yet (a width change splits equally), no column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text | | Mind maps | missing | | | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | @@ -72,6 +72,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | M12, #2172: a board table's height change goes into its rows. Measured in FigJam's web app, 2026-11-09 (a table has no lock-proportions control and never scales proportionally): a bottom-edge drag grows only the last row, a corner drag shares the change equally over every row and column, text keeps its size, and a table stops shrinking at about 52 per cell with a row never shorter than its wrapped text. FigCraft already split a width change equally (every cell fills); the bug was height: a bottom or corner resize turned the table from hugging to fixed height, so the rows stayed at 40 with blank space below them (or were clipped when shrunk) and typing no longer grew the table. `board_table::settle_resize`, called first in the normalize pass, finds a table left at a fixed height, puts the change into the rows' minimum height (the last row, or every row when both axes changed), raises a width that is too narrow to `columns x 40`, and returns the table to hugging, so every route (drag, `node.set`, `transform.resize`, MCP, live-session peers) gives the same result, in one undo step. FigCraft's floor is 40 (FigJam's is about 52) and there are no per-column widths yet: a side drag of the right edge still splits equally where FigJam grows only the last column. Numbers unchanged. Tests: `board_tables_tests` (the bottom, corner and side drags, the floor, typing after a resize and undo, live sessions, junk). | | 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | | 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | | 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). | From 1ac46ba0af3829d322d2adc09506363487fa9440 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 21:41:31 -0500 Subject: [PATCH 2/2] Board tables: the resize growth test scans system fonts; one Tables and one Mind maps row (#2172) The merge with the mind-maps main kept both sides' Tables and Mind maps rows in boards-parity.md (union): the stale "Mind maps | missing" and main's old Tables row are gone, keeping this PR's Tables row and main's Mind maps row. The new text-growth test scans system fonts itself, as its neighbour does, so it measures text when it runs first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/board_tables_tests.rs | 3 +++ docs/boards-parity.md | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/board_tables_tests.rs b/crates/engine/src/board_tables_tests.rs index d15aedc7b..3d4d8ce3a 100644 --- a/crates/engine/src/board_tables_tests.rs +++ b/crates/engine/src/board_tables_tests.rs @@ -377,6 +377,9 @@ fn a_table_never_shrinks_below_its_cells() { #[test] fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() { + // Text measures only once system fonts are scanned (without craft-fonts), so scan them here + // without depending on another test having scanned them first. + figcraft_text::scan_system_fonts(); let (mut s, t) = table_3x3(["", "", ""]); drag(&mut s, (240.0, 122.0), (240.0, 272.0)); assert!(near(node(&s, t).height, 272.0)); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index f5f7d17cf..30ad2da7e 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -61,8 +61,6 @@ all end with the same geometry. | Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards | | Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own | | Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; resizing a table by its bottom edge or its height grows the last row and a corner drag shares the change equally over every row and column (a side drag changes no row), the table keeps hugging its rows and a cell keeps at least 40 each way (measured in FigJam's web app, 2026-11-09: its floor is about 52, FigCraft's is 40, and its right edge grows only the last column); a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No per-column widths yet (a width change splits equally), no column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text | -| Mind maps | missing | | -| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text | | Mind maps | partial | from commands only so far (#2226): `board.mindMap {x?, y?, text?, children?}` places a first node (bold, no box) with three children on its right, joined by curved branches; `board.mindMapAdd {id?, relation?: child\|sibling}` adds a node. Nodes are text that grows wider as it is typed and taller with line breaks; a node's children stand in one column 88 units right of the widest node of its own column, 48 apart, centred on it, and the map lays itself out again in the normalize pass after every edit, undo and live-session change; moving the first node moves the map; a deleted node's children stay where they are without their branch. Branches are board connectors (`branch`: stroke 4, #757575, round caps), so their style, end points, weight and colour change like any connector's. No toolbar entry, shortcuts, "+" handles, dragging a node onto another, left side or `.jam` import yet | | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | |