diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..f2636339 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Added pages keep the page colour (M1, #295): `page.add` gives the new page the current page's background, as the documentation says, instead of the interface theme's; the theme still decides a new file's page, and saved pages keep their colour when the theme changes. The same in the app, CLI, control channel and MCP. Custom colours, both themes, headless, undo/redo and save/reopen are covered in `theme_pages_tests`. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Added pages keep the page colour (M1, #295): `page.add` takes the current page's background instead of the theme's; the theme still decides a new file's page. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988..d7f630b9 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { - let background = s.new_page_background(); + // An added page takes the current page's colour; the theme only decides new files. + let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind { + NodeKind::Page { background, .. } => Some(background), + _ => None, + }); + let background = current.unwrap_or_else(|| s.new_page_background()); let doc = s.doc_mut()?; let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1)); let id = doc.add_page(&name); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..f4a8a5bf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -663,8 +663,8 @@ pub struct Session { pub prefs: Prefs, pub tool: tools::ToolState, pub ui_requests: Vec, - /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages - /// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no + /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the + /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, /// Command log (id + params) for actions/automation. @@ -721,7 +721,7 @@ impl Session { } } - /// The background new pages get: the dark page colour while the interface is dark. + /// The background a new file's page gets: the dark page colour while the interface is dark. pub fn new_page_background(&self) -> figcraft_color::Color { if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE } } diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071a..219f6120 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -1,7 +1,7 @@ -//! The page colour of new files and pages follows the interface's theme when they are made (dark: -//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface -//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is -//! white on a dark page and black on a light one. +//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E, +//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page +//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the +//! light default. Text drawn with the Text tool is white on a dark page and black on a light one. use figcraft_color::{Color, Paint}; use figcraft_doc::{NodeId, NodeKind}; @@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId { } #[test] -fn new_files_and_pages_take_the_theme_they_are_made_in() { - // (theme the interface reported, colour of a new file's page and of an added page) +fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() { + // (theme the interface reported, colour of a new file's page and of a page added to it) for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] { let mut s = Session::empty(); if let Some(d) = dark { @@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() { ex(&mut s, "file.new", json!({})); s.set_ui_dark(false); ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme"); + ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"})); s.set_ui_dark(true); - assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with"); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour"); // A copy of a page is that page's colour, whatever the theme. let first = s.doc().unwrap().pages[0]; s.set_ui_dark(false); @@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() { assert_eq!(background(&s, copy), Color::DARK_PAGE); } +#[test] +fn an_added_page_takes_the_current_pages_colour() { + // Whatever the theme, and headless too: the page it was added from decides. + for dark in [None, Some(false), Some(true)] { + let mut s = Session::empty(); + if let Some(d) = dark { + s.set_ui_dark(d); + } + ex(&mut s, "file.new", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}"); + // From the second page, now current, after giving it its own colour. + ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"})); + ex(&mut s, "page.add", json!({"name": "Third"})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}"); + // One undo step, the colour with it. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo"); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo"); + } + // From a page other than the first: go there, then add. + let mut s = Session::new(); + s.set_ui_dark(true); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"})); + ex(&mut s, "page.select", json!({"index": 0})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last"); +} + +#[test] +fn added_page_colours_survive_save_and_reopen() { + let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pages.figcraft"); + let mut s = Session::new(); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"]); + // And a page added to the reopened file in the dark theme follows the file, not the theme. + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() { // Made before the interface knew its theme, nothing done to it yet. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..3f698a0e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -892,7 +892,7 @@ impl FigApp { _ => false, }; self.ui.dark = dark; - // New files and pages take the theme's page colour (existing pages keep theirs). + // New files take the theme's page colour (existing pages keep theirs). self.session.set_ui_dark(dark); theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion()); if self.worker.is_none() { diff --git a/crates/ui-egui/src/text_enter_tests.rs b/crates/ui-egui/src/text_enter_tests.rs index 2a0f3d22..5de0522d 100644 --- a/crates/ui-egui/src/text_enter_tests.rs +++ b/crates/ui-egui/src/text_enter_tests.rs @@ -75,5 +75,7 @@ fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() { u.app.run("view.themeDark", json!({})); u.frame(vec![]); u.app.run("page.add", json!({})); - assert_eq!(page_colour(&u), Color::DARK_PAGE, "a page added in the dark theme"); + assert_eq!(page_colour(&u), Color::DEFAULT_PAGE, "a page added in the dark theme follows its light page"); + u.app.run("file.new", json!({})); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "a file made in the dark theme"); } diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..64982fd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -301,9 +301,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: given, are or are in; default the selection) out of the library the file publishes, or let them back in (`on: false`; without `on` it toggles). The sign of it is the name: hiding puts a `.` before it, showing takes the leading `.` and `_` away. Returns `{ids, hidden}`; one undo step. -- New files and pages: `file.new` and `page.add` give a page the colour `#F5F5F5`. Only the app - with its dark theme showing makes them `#1E1E1E`; headless sessions (the CLI, this server) have - no theme. Text made with `text.create` or `node.create` is black whatever the page here; in the +- New files and pages: `file.new` gives its page the colour `#F5F5F5`. Only the app with its dark + theme showing makes it `#1E1E1E`; headless sessions (the CLI, this server) have no theme. + `page.add` gives the new page the current page's colour, wherever it runs. Text made with `text.create` or `node.create` is black whatever the page here; in the app (the control channel, `--attach`) it is white on a dark page unless `props` name a `fill`, as the Text tool (`pointer` with `tool.text`) makes it everywhere. - `transform.rotationOrigin {on?}` (`Alt+R`): show or hide the selection's rotation origin handle