Symbols: add right-click context menu to panel items

This commit is contained in:
ruchirguitar
2026-11-14 21:59:45 +05:30
parent 676f0524cc
commit 415783e926
3 changed files with 243 additions and 0 deletions
+239
View File
@@ -154,6 +154,69 @@ fn has_instances_selected(app: &VectorcraftApp) -> bool {
instance_selected(app, |_| true)
}
/// Collect the IDs of selected instances of symbol `name` (including inside groups).
fn selected_symbol_instances(app: &VectorcraftApp, name: &str) -> Vec<u64> {
let mut ids = vec![];
if let Some(st) = app.session.active() {
for id in &st.selection.objects {
if let Some(n) = st.doc.node(*id) {
n.walk(&mut |c| {
if matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == name) {
ids.push(c.id.0);
}
});
}
}
}
ids
}
/// A symbol item's context menu.
fn item_context_menu(app: &mut VectorcraftApp, ui: &mut Ui, name: &str) {
let center = app
.view()
.map(|v| v.center)
.or_else(|| app.session.active().and_then(|st| st.doc.artboards.first().map(|a| a.rect.center())))
.unwrap_or(vectorcraft_geom::Point::ZERO);
if menu_item(ui, tl!("Place Symbol Instance"), true, false) {
if let Err(e) = app.run("symbol.place", json!({ "name": name, "x": center.x, "y": center.y })) {
app.status(e);
}
ui.close();
}
if menu_item(ui, tl!("Edit Symbol"), true, false) {
if let Err(e) = app.run("symbol.edit", json!({ "name": name })) {
app.status(e);
}
ui.close();
}
if menu_item(ui, tl!("Duplicate Symbol"), true, false) {
if let Err(e) = app.run("symbol.duplicate", json!({ "name": name })) {
app.status(e);
}
ui.close();
}
if menu_item(ui, tl!("Delete Symbol"), true, false) {
delete(app, &[], Some(name), false);
ui.close();
}
let break_ids = selected_symbol_instances(app, name);
let break_enabled = !break_ids.is_empty();
if menu_item(ui, tl!("Break Link to Symbol"), break_enabled, false) {
if let Err(e) = app.run("symbol.breakLink", json!({ "ids": break_ids })) {
app.status(e);
}
ui.close();
}
if menu_item(ui, tl!("Select All Instances"), true, false) {
if let Err(e) = app.run("symbol.selectInstances", json!({ "name": name })) {
app.status(e);
}
ui.close();
}
}
/// Does the selection hold a symbol instance with appearance changes of its own (a dynamic
/// symbol's), which Reset drops?
pub(crate) fn changed_instances_selected(app: &VectorcraftApp) -> bool {
@@ -249,11 +312,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
chip(ui, &doc, &keys, egui::Rect::from_center_size(r.left_center() + vec2(3.0 + side / 2.0, 0.0), vec2(side, side)), n);
ui.painter().text(r.left_center() + vec2(side + 10.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(text), t.text);
let resp = resp.on_hover_text(n);
if resp.double_clicked() {
clicked = Some((n.clone(), true));
} else if resp.clicked() {
clicked = Some((n.clone(), false));
}
if resp.secondary_clicked() && (sel.as_deref() != Some(n) || !marked.is_empty()) {
set_pstate(ui.ctx(), MARKED, (uid, Vec::<String>::new()));
if let Err(e) = app.run("symbol.setCurrent", json!({ "name": n })) {
app.status(e);
}
}
resp.context_menu(|ui| item_context_menu(app, ui, n));
}
} else {
ui.horizontal_wrapped(|ui| {
@@ -274,6 +345,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if resp.clicked() {
clicked = Some((n.clone(), false));
}
if resp.secondary_clicked() && (sel.as_deref() != Some(n) || !marked.is_empty()) {
set_pstate(ui.ctx(), MARKED, (uid, Vec::<String>::new()));
if let Err(e) = app.run("symbol.setCurrent", json!({ "name": n })) {
app.status(e);
}
}
resp.context_menu(|ui| item_context_menu(app, ui, n));
}
});
}
@@ -1498,4 +1576,165 @@ mod tests {
bar(&mut app, vec![egui::Event::PointerMoved(at), button(at, true), button(at, false)]);
assert!(close(xf(&app), master), "the Control bar's Reset resets the instance: {:?} vs {master:?}", xf(&app));
}
/// Right-clicking a symbol in the Symbols panel selects it (if not already selected),
/// displays the context menu, and clicking an item dispatches the corresponding command.
#[test]
fn right_click_context_menu_in_symbols_panel_runs_actions() {
use egui_kittest::kittest::{NodeT, Queryable};
let has_instance = |doc: &Document, name: &str| {
let mut found = false;
for l in &doc.layers {
l.walk(&mut |c| found |= matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == name));
}
found
};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
// Create two symbols "A" and "B" (clearing the instances symbol.new leaves)
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let id_a = app.run("symbol.new", json!({"name": "A"})).unwrap()["id"].as_u64().unwrap();
app.run("edit.clear", json!({"ids": [id_a]})).unwrap();
app.run("shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 20})).unwrap();
let id_b = app.run("symbol.new", json!({"name": "B"})).unwrap()["id"].as_u64().unwrap();
app.run("edit.clear", json!({"ids": [id_b]})).unwrap();
// Currently "B" is the current symbol
assert_eq!(current(&mut app), Some("B".to_string()));
let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 400.0)).with_accessibility_check(false).build_ui_state(
|ui, app: &mut VectorcraftApp| {
show(app, ui);
},
app,
);
h.run_steps(2);
// In thumbnail view, get symbol A
let node_a = h.get_by_label("A");
let pos_a = node_a.rect().center();
// Right-click symbol A
h.event(egui::Event::PointerMoved(pos_a));
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// Right-clicking unselected "A" selected it
assert_eq!(current(h.state_mut()), Some("A".to_string()));
// Context menu items are displayed
let place = h.get_by_label(" Place Symbol Instance");
let _ = h.get_by_label(" Edit Symbol");
let _ = h.get_by_label(" Duplicate Symbol");
let _ = h.get_by_label(" Delete Symbol");
let break_link = h.get_by_label(" Break Link to Symbol");
let _ = h.get_by_label(" Select All Instances");
// Break Link to Symbol is disabled because no instances of A are selected
assert!(break_link.accesskit_node().is_disabled());
// 1. Click "Place Symbol Instance"
place.click();
h.run_steps(2);
// An instance of A is placed on the canvas and selected
let doc = &h.state().session.active().unwrap().doc;
assert!(has_instance(doc, "A"));
// 2. Right-click symbol A again now that its instance is selected
// Place an instance of B as well and select both to verify Break Link is scoped to A
let inst_b = h.state_mut().run("symbol.place", json!({ "name": "B" })).unwrap()["id"].as_u64().unwrap();
let inst_a = h.state().session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0;
h.state_mut().run("select.set", json!({ "ids": [inst_a, inst_b] })).unwrap();
h.run_steps(2);
h.event(egui::Event::PointerMoved(pos_a));
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// Now "Break Link to Symbol" is enabled
let break_link = h.get_by_label(" Break Link to Symbol");
assert!(!break_link.accesskit_node().is_disabled());
break_link.click();
h.run_steps(2);
// Link broken for A (no longer a SymbolInstance of A), while B remains an instance
let doc = &h.state().session.active().unwrap().doc;
assert!(!has_instance(doc, "A"));
assert!(has_instance(doc, "B"));
// 3. Right-click A and click "Duplicate Symbol"
let before_syms = symbols(h.state_mut());
h.event(egui::Event::PointerMoved(pos_a));
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label(" Duplicate Symbol").click();
h.run_steps(2);
let after_syms = symbols(h.state_mut());
assert_eq!(after_syms.len(), before_syms.len() + 1);
assert!(before_syms.iter().all(|s| after_syms.contains(s)));
assert!(after_syms.contains(&"A copy".to_string()));
// 4. Right-click B and click "Edit Symbol"
let node_b = h.get_by_label("B");
let pos_b = node_b.rect().center();
h.event(egui::Event::PointerMoved(pos_b));
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
assert_eq!(current(h.state_mut()), Some("B".to_string()));
h.get_by_label(" Edit Symbol").click();
h.run_steps(2);
assert!(h.state().session.active().unwrap().doc.symbol_edit.is_some());
// Exit symbol editing mode
h.state_mut().run("symbol.exitEditing", json!({})).unwrap();
h.run_steps(2);
// 5. Right-click B and click "Delete Symbol"
h.event(egui::Event::PointerMoved(pos_b));
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label(" Delete Symbol").click();
h.run_steps(2);
// Dialog opened; cancelling preserves B
assert!(h.state().ui.dialog.is_some());
h.state_mut().ui.dialog = None;
assert!(symbols(h.state_mut()).contains(&"B".to_string()));
// Right-click B again and confirm deletion
h.event(egui::Event::PointerMoved(pos_b));
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label(" Delete Symbol").click();
h.run_steps(2);
crate::dialogs::confirm(h.state_mut()).unwrap();
assert!(!symbols(h.state_mut()).contains(&"B".to_string()));
// Also test list view
set_pstate(&h.ctx, VIEW, View::SmallList);
h.run_steps(2);
let node_a = h.get_by_label("A");
let pos_a = node_a.rect().center();
h.event(egui::Event::PointerMoved(pos_a));
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
assert!(h.query_by_label(" Place Symbol Instance").is_some());
h.get_by_label(" Select All Instances").click();
h.run_steps(2);
}
}
+2
View File
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | G12: Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu (Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol, Select All Instances), selecting the symbol first if unselected; scores and estimates unchanged. |
| 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); estimate decremented (5–15 → 4–14 h). |
| 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. |
| 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups `<g>`, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. |
@@ -465,6 +466,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
## G12 Preferences and panels that don't act yet
- **Done (2026-11-14):** Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu with Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of the symbol), and Select All Instances, selecting the symbol if unselected. Estimate unchanged.
- **Done (2026-10-29, #4522):** the Pathfinder panel menu's Make, Release and Expand Compound Shape, which were dimmed placeholders, and the panel's Expand button act on live compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html)): Alt-clicking a Shape Mode makes one in that mode, a Shape Mode sets the mode of the members selected, and the compound shape draws, exports and saves as the area its members' modes make (`object.compoundShape.*`, `crates/engine/src/tests/compound_shape.rs`). Left: blends, envelopes and warps as members, question marks on the buttons for mixed member modes, clicks that hit the area rather than the members. Estimate unchanged.
- **Done (2026-10-25, #3426):** Pathfinder panel › Pathfinder Options… ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): Precision (0.001–10 pt; results are refit to within it and pieces thinner than it are dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (pieces of objects with no fill), kept in the preferences (`object.pathfinder.options`, group `pathfinder`) and used by every Shape Mode and Pathfinder button and Repeat; the page gives no defaults, so ours (0.01 pt, both off) keep the earlier results byte for byte. Trap is left as it was. Estimate unchanged.
- **Done (2026-10-25):** Artboards panel › Delete Empty Artboards ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html), [artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html)): `artboard.deleteEmpty` deletes every unlocked artboard with no artwork, with its guides, in one undo step, and keeps artboard 1 when every artboard is empty and none is locked; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). The Artboard tool's canvas context menu lists it too. Estimate unchanged.
+2
View File
@@ -968,6 +968,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it may pass the art's edges (see the next entry) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`.
- **9-slice guides outside the art** ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section): a guide may lie outside the symbol's art, set by dragging it past the art's edge in symbol editing mode, by `symbol.options`'s `nineSliceGuides` or by a Symbol Options field below 0% or above 100%, and a scaled instance still scales, by the cells its art falls in. VectorCraft's own rules: each guide lies within 16 times the art's width or height of its edge (`-16..16` as a fraction, `-1600..1600` in percent) and the two guides of an axis keep their order; a guide past an edge of the art cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an axis past opposite edges the art scales as a whole along it; with both past the same edge the art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, or shrinks to the instance's width or height when the instance is smaller than the art; Symbol Options shows and keeps the guides unrounded. Native files keep the guides exactly; SVG and `.ai` write such an instance as its placed art and keep no guides. Tests: `symbol_nine_slice::tests::guides_outside_the_art_are_accepted_in_order_and_within_the_limit`, `art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it`, `the_map_with_guides_outside_the_art_is_continuous_and_monotone` in `crates/doc`, `guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art`, `a_guide_dragged_past_the_arts_edge_lies_outside_it`, `guides_outside_the_art_survive_saving_and_reopening_a_native_file` in `crates/engine`, `nineslice::tests::a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge`, `a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn` (SVG and `.ai`).
- **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`.
- **Symbols panel context menu** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): right-clicking a symbol in thumbnail view or list view displays a context menu with Place Symbol Instance (passes view center or artboard center), Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of this symbol), and Select All Instances. Right-clicking an unselected symbol selects it first (`symbol.setCurrent`). Tests: `panels::symbols::tests::right_click_context_menu_in_symbols_panel_runs_actions`.
- **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`.
- **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`.
- **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged).
@@ -2452,6 +2453,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. |
| 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. |
| 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. |
| 2026-11-14 | minor | Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu (Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol, Select All Instances), selecting the symbol first if unselected; scores and estimates unchanged. |
| 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. |
| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). |
| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. |