From c7273e018d42717e871abd960e134eb5204f4c63 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 2 Aug 2026 13:47:59 -0700 Subject: [PATCH] Unify Operations consequence actions Approach routes now lead the same shared action list as sale and policy controls, so keyboard and pointer navigation operate one row identity in both human frontends. Defense: Implements operations-workspace criterion 24 and the Liturgical UI Constitution's one-decision-choir law: a consequence action is the first exact OpsActionEntry, not a separate RELATED-pane control. --- crates/misaligned-bevy/src/operations_ui.rs | 34 ++++--- crates/misaligned-bevy/src/shot_harness.rs | 9 ++ crates/misaligned-core/src/operations_ui.rs | 96 ++++++++++++++++++- crates/misaligned-terminal/src/ui.rs | 32 ++----- wiki/interface/liturgical-ui-constitution.md | 7 ++ wiki/interface/operations-workspace.md | 43 +++++++-- ...2026-08-02-consequence-actions-one-list.md | 47 +++++++++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-08-02.md | 36 +++++++ 9 files changed, 257 insertions(+), 52 deletions(-) create mode 100644 wiki/log/2026-08-02-consequence-actions-one-list.md diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index 1b57afb5..bd27adbc 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -302,6 +302,20 @@ mod operations_workspace_tests { .unwrap(); ops.select_object_index(&sim, index); ops.select(&sim); + let entries = ops.action_entries(&sim); + assert!(matches!( + entries.first(), + Some(OpsActionEntry::Open { label, .. }) if label == "APPROACH MARCUS" + )); + let sale = entries + .iter() + .position(|entry| { + entry.row().is_some_and(|row| { + matches!(row.command, ActionCommand::SellIntel { raw_id: 777 }) + }) + }) + .expect("sale follows the consequence route in the same action choir"); + ops.select_action_index(&sim, sale); let row = ops.selected_action(&sim).unwrap(); assert!(matches!( row.command, @@ -1112,7 +1126,6 @@ pub(super) fn manage_operations_ui( obj, &entries, selected_action, - selected_related, &ops, ); }); @@ -1200,8 +1213,8 @@ fn spawn_ops_object_body( let supporting_links = obj .related .iter() + .filter(|link| consequence_link != Some(*link)) .enumerate() - .filter(|(_, link)| consequence_link != Some(*link)) .collect::>(); if !supporting_links.is_empty() { spawn_ops_section_label(detail, "RELATED"); @@ -1234,24 +1247,8 @@ fn spawn_ops_contextual_actions( obj: &misaligned::operations_projection::OperationsObject, entries: &[OpsActionEntry], selected_action: usize, - selected_related: usize, ops: &OperationsWorkspace, ) { - if let Some(action) = obj - .consequence - .as_ref() - .and_then(|consequence| consequence.action.as_ref()) - { - spawn_ops_section_label(actions, "WHAT THIS OPENS"); - let index = obj - .related - .iter() - .position(|link| link == action) - .unwrap_or(0); - let selected = index == selected_related && ops.pane == OpsPane::Related; - spawn_ops_relation_button(actions, action, index, selected); - } - if let Some(label) = sim.operations_actuator_label(&obj.target) { spawn_ops_section_label(actions, "WHERE THIS HAPPENS"); actions @@ -1538,6 +1535,7 @@ fn ops_context_text(obj: &misaligned::operations_projection::OperationsObject) - fn ops_action_entry_line(entry: &OpsActionEntry) -> String { match entry { + OpsActionEntry::Open { label, .. } => format!("{label} >"), OpsActionEntry::Action { label, row, .. } => { let indent = if row.indent { "- " } else { "" }; format!("{indent}{label}") diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 19541cd2..5f7017ef 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -774,6 +774,8 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s } // Live INTEL index hierarchy: strategic holdings outrank sightings, a // newer sold item is absent, and the pooled inbox follows live holdings. + // Marcus's debt also stages the complete consequence-action seam: the + // exact APPROACH route sits above its sale in one keyboard action list. if kind == "operations-intel" { use misaligned::intel::{IntelKind, ProcessedIntel}; use misaligned::person::Leverage; @@ -822,10 +824,17 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s item(90_005, 50, Some(2), IntelKind::Sighting), item(90_006, 60, Some(3), IntelKind::Sighting), ]); + game.sim.people.people[0].knowledge = Knowledge::Leverage; + game.sim.set_persona("Sam Reyes", "IT contractor"); + game.sim.accounts.set_slush_balance(1_000); + game.sim.player.money = 1_000; game.sim.accounts.mark_intel_sold(90_006); dev_clear_teaching_lock(game); game.ops = Some(OperationsWorkspace::open_view(OperationsView::Intel)); game.drain(); + let ops = game.ops.as_mut().expect("INTEL shot opened"); + ops.pane = OpsPane::Actions; + ops.select_action_index(&game.sim, 1); mode.material = true; mode.zoom = 2.0; return; diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 0666e324..a9ba9c56 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -56,6 +56,14 @@ pub enum OpsActionSubmenu { /// plain-language intent row and open into the same exact rows one level down. #[derive(Debug, Clone, PartialEq)] pub enum OpsActionEntry { + /// A consequence-specific route into the exact choices this information + /// unlocked. It is an action in the human decision list even though its + /// immediate effect is renderer-neutral Operations navigation. + Open { + label: String, + description: String, + target: OperationsTarget, + }, Action { label: String, description: Option, @@ -71,12 +79,15 @@ pub enum OpsActionEntry { impl OpsActionEntry { pub fn label(&self) -> &str { match self { - Self::Action { label, .. } | Self::Submenu { label, .. } => label, + Self::Open { label, .. } | Self::Action { label, .. } | Self::Submenu { label, .. } => { + label + } } } pub fn description(&self) -> Option<&str> { match self { + Self::Open { description, .. } => Some(description), Self::Action { description, .. } => description.as_deref(), Self::Submenu { description, .. } => Some(description), } @@ -85,13 +96,14 @@ impl OpsActionEntry { pub fn row(&self) -> Option<&MenuRow> { match self { Self::Action { row, .. } => Some(row), - Self::Submenu { .. } => None, + Self::Open { .. } | Self::Submenu { .. } => None, } } pub fn submenu(&self) -> Option { match self { Self::Action { .. } => None, + Self::Open { .. } => None, Self::Submenu { submenu, .. } => Some(*submenu), } } @@ -102,6 +114,7 @@ impl OpsActionEntry { /// closed or while an executed action changes the live projection. #[derive(Debug, Clone, PartialEq)] enum OpsActionKey { + Open(OperationsTarget), Command(ActionCommand), Submenu(OpsActionSubmenu), } @@ -109,6 +122,7 @@ enum OpsActionKey { impl OpsActionKey { fn of(entry: &OpsActionEntry) -> Self { match entry { + OpsActionEntry::Open { target, .. } => Self::Open(target.clone()), OpsActionEntry::Action { row, .. } => Self::Command(row.command.clone()), OpsActionEntry::Submenu { submenu, .. } => Self::Submenu(*submenu), } @@ -350,7 +364,17 @@ impl OperationsWorkspace { > 1; let mut leverage_added = false; let mut recruitment_added = false; - let mut entries = Vec::new(); + let mut entries = object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()) + .map(|action| OpsActionEntry::Open { + label: action.label.clone(), + description: "OPEN THE EXACT CHOICES THIS INFORMATION ENABLES".into(), + target: action.target.clone(), + }) + .into_iter() + .collect::>(); for row in rows { match action_submenu_for_row(&row) { Some(OpsActionSubmenu::Leverage) if has_leverage_choices => { @@ -377,7 +401,16 @@ impl OperationsWorkspace { pub fn related_links(&self, sim: &Sim) -> Vec { self.selected_object(sim) - .map(|object| object.related) + .map(|mut object| { + let consequence_action = object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()); + object + .related + .retain(|link| consequence_action != Some(link)); + object.related + }) .unwrap_or_default() } @@ -826,6 +859,7 @@ impl OperationsWorkspace { .map(|link| OpsSelect::Open(link.target)) .unwrap_or(OpsSelect::None), OpsPane::Actions => match self.selected_action_entry(sim) { + Some(OpsActionEntry::Open { target, .. }) => OpsSelect::Open(target), Some(entry) if entry.submenu().is_some() => { self.action_submenu = entry.submenu(); self.action = 0; @@ -1010,7 +1044,7 @@ mod tests { use crate::actions::{ActionCost, ActionDesc, ActionRole}; use crate::intel::{IntelKind, IntelMagnitude, IntelPolicyMatch, ProcessedIntel}; use crate::operations_projection::ObjectState; - use crate::person::Leverage; + use crate::person::{Knowledge, Leverage}; fn intel(raw_id: u64, processed_tick: u64, kind: IntelKind) -> ProcessedIntel { ProcessedIntel { @@ -1098,6 +1132,58 @@ mod tests { assert_eq!(row.command, remove_first); } + #[test] + fn consequence_route_is_the_first_keyboard_action_not_a_separate_pane() { + let mut sim = Sim::new(); + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.set_persona("Sam", "contractor"); + sim.accounts.set_slush_balance(1_000); + sim.player.money = 1_000; + sim.intel.push(ProcessedIntel { + raw_id: 400, + tick: 5, + processed_tick: 6, + feed: "creditor call".into(), + room: None, + x: 0, + y: 0, + person: Some(0), + magnitude: IntelMagnitude::new(5).unwrap(), + kind: IntelKind::Leverage(Leverage::Debt), + }); + + let target = OperationsTarget::Intel { raw_id: 400 }; + let mut ops = OperationsWorkspace::open_target(&sim, &target); + assert_eq!(ops.select(&sim), OpsSelect::None); + assert_eq!(ops.pane, OpsPane::Actions); + let entries = ops.action_entries(&sim); + assert!(matches!( + entries.first(), + Some(OpsActionEntry::Open { + label, + target: OperationsTarget::IntelOpportunity { raw_id: 400 }, + .. + }) if label == "APPROACH MARCUS" + )); + let sale = entries + .iter() + .position(|entry| { + entry.row().is_some_and(|row| { + matches!(row.command, ActionCommand::SellIntel { raw_id: 400 }) + }) + }) + .expect("the exact sale remains in the same decision list"); + assert_eq!(sale, 1, "APPROACH is immediately above the sale row"); + + ops.select_action_index(&sim, sale); + ops.move_up(&sim); + assert_eq!(ops.selected_action_index(&sim), Some(0)); + assert_eq!( + ops.select(&sim), + OpsSelect::Open(OperationsTarget::IntelOpportunity { raw_id: 400 }) + ); + } + #[test] fn vanished_action_returns_to_the_object_before_enter_can_retarget() { let mut sim = Sim::new(); diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 62eff41a..42bf613f 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -2402,6 +2402,9 @@ impl UI { for (i, entry) in entries.iter().enumerate() { let marker = if i == action_index { "▸" } else { " " }; let text = match entry { + OpsActionEntry::Open { label, .. } => { + format!("{marker} {label} >") + } OpsActionEntry::Submenu { label, .. } => { format!("{marker} {label} >") } @@ -2427,7 +2430,12 @@ impl UI { } } let Some(row) = entry.row() else { - dline(stdout, y, "Enter to compare choices", pal::DIM, None)?; + let hint = if entry.submenu().is_some() { + "Enter to compare choices" + } else { + "Enter to open exact choices" + }; + dline(stdout, y, hint, pal::DIM, None)?; return Ok(()); }; let sig = row @@ -2485,26 +2493,6 @@ impl UI { for line in wrap(&consequence.statement, dw) { dline(stdout, &mut y, &line, pal::TEXT, None)?; } - if let Some(action) = &consequence.action { - let index = obj - .related - .iter() - .position(|link| link == action) - .unwrap_or(0); - let marker = if index == related_index { "▸" } else { " " }; - let line = format!("{marker} {}", action.label); - if index == related_index && ops.pane == OpsPane::Related { - dline( - stdout, - &mut y, - &line, - pal::AMBER_DIM, - Some(Attribute::Reverse), - )?; - } else { - dline(stdout, &mut y, &line, pal::AMBER_DIM, None)?; - } - } } render_actions(stdout, &mut y)?; dline(stdout, &mut y, "", pal::FAINT, None)?; @@ -2520,8 +2508,8 @@ impl UI { let supporting_links = obj .related .iter() + .filter(|link| consequence_link != Some(*link)) .enumerate() - .filter(|(_, link)| consequence_link != Some(*link)) .collect::>(); if !supporting_links.is_empty() { dline(stdout, &mut y, "RELATED", pal::DIM, None)?; diff --git a/wiki/interface/liturgical-ui-constitution.md b/wiki/interface/liturgical-ui-constitution.md index bb764486..f674bb1e 100644 --- a/wiki/interface/liturgical-ui-constitution.md +++ b/wiki/interface/liturgical-ui-constitution.md @@ -183,6 +183,13 @@ are implementation vocabulary; player-facing labels stay ordinary (`CHOOSE`, `CURRENT`, `WHAT DOES THIS CHANGE?`, `WHERE THIS HAPPENS`, `ACTIONS`, `WHAT HAPPENS NEXT`, `CONTEXT`). +The contextual verbs form one vertical decision choir. A consequence-specific +route such as APPROACH MARCUS is its first ordinary selectable row, followed by +the object's command and control rows; all share one keyboard/pointer cursor. +`WHAT THIS OPENS` is not a sixth section and a consequence action is not a +supporting RELATED link. The center states what changed; the right side answers +what the player can do about it. + Operations is frontend-only navigation over the shared projection. Opening, navigating, and closing mutate no sim/save state, never rewrite the player's explicit pause state, and never advance a tick themselves. While the chamber diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 6859f8d6..aa4638b5 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -53,6 +53,12 @@ Status note: Reopened 2026-08-02 (adopted, not yet implemented): selected canonical agent language across the pooled host, exact opaque information, custody policies, sink receipts, and accounting records; legacy `review` spellings remain input-only aliases. + Amended 2026-08-02: a consequence-specific route such as APPROACH MARCUS + is the first entry in the same renderer-neutral action list as SELL and + standing policy controls. It is no longer rendered as a separate WHAT THIS + OPENS/RELATED pane that pointer hover could reach while j/k navigation could + not. Enter follows its exact semantic target; sale and control rows retain + their existing commands and confirmation law. Amended 2026-07-21: ACCOUNTS teaches ordinary TAP on the known accounting device, then waits honestly for later-authored record mail, then offers PROCESS on captured books. The retired second-step `TAP LEDGER` label is not @@ -417,9 +423,12 @@ a toast, modal pop-up, or scale-triggered notification. For the Marcus call the first useful read is “Marcus owes $400,” followed by “You can approach Marcus through his debt” and **APPROACH MARCUS**. That control opens the exact debt-enabled choices on Marcus; it does not merely navigate to an unfiltered -dossier. Provenance and source history follow. When processing changes only a -knowledge model or relationship and creates no action, the result says that -plainly and exposes no invented button. +dossier. It is the first row of the same action cursor as SELL and any standing +policy control, so pointer hover and j/k-or-arrow navigation cannot disagree +about whether it is actionable. There is no separate WHAT THIS OPENS section +or RELATED-pane detour for a consequence action. Provenance and source history +follow. When processing changes only a knowledge model or relationship and +creates no action, the result says that plainly and exposes no invented button. ### The same object at every scale @@ -739,6 +748,11 @@ explanatory surface its strategic systems lacked. gain, signature/observer band (or `no signature`), channel/actuator, and disabled reason. A disabled row is not merely gray. Attempting it also writes the same reason to the trace. +- A consequence route that opens exact choices is an action-list entry, not a + supporting relationship. It leads the same vertical cursor as commands and + controls, carries its exact semantic target, and opens directly on Enter or + click. Supporting RELATED links retain their separate pane because they are + context, not answers to “what do you want to do?” - Confirmation follows consequence, not the fact that a row was selected. Routine internal unsigned work such as PROCESS starts immediately after its visible explanation; forcing a second Enter adds no decision. @@ -969,10 +983,12 @@ state is not saved and never mutates or advances the sim. frontends do not create a toast/modal for completion or for a scale change. The Marcus fixture renders MARCUS OWES $400, WHAT DOES THIS CHANGE?, YOU CAN APPROACH MARCUS THROUGH HIS DEBT, and an APPROACH MARCUS control before - provenance; that control opens the debt-enabled choices. A result with no - consequence-specific route names the changed knowledge or relationship - without inventing a button, while any independently canonical object action - remains available directly beneath the consequence. + provenance; that control is the first member of the same action cursor as + sale and policy controls, and opens the debt-enabled choices. Up from the + sale row reaches APPROACH; pointer hover selects that same entry. A result + with no consequence-specific route names the changed knowledge or + relationship without inventing a button, while any independently canonical + object action remains available directly beneath the consequence. 22. Human Operations labels, learned results, consequences, provenance, facts, progress, related links, and action verbs expose no internal object/rule/ persona/expectation/grant/correlation/position ids, record keys, report-lot @@ -996,6 +1012,13 @@ state is not saved and never mutates or advances the sim. live content; scrolling it cannot zoom either world camera or move the hidden resting sidebar. +24. A projected consequence action is represented once as the first shared + `OpsActionEntry`, ahead of the selected object's command/control rows. Its + exact `OperationsTarget` is the cursor identity and Enter/click returns the + ordinary `Open` transition. It is excluded from supporting RELATED links, + never receives its own WHAT THIS OPENS section, and participates in the + same j/k-or-arrow sequence as every row below it in terminal and Bevy. + Defense: `operations_ui::tests::repeated_plot_and_recruitment_variants_fold_into_intent_submenus`, `operations_ui::tests::one_recruitment_choice_stays_a_direct_exact_action`, and `operations_ui::tests::back_walks_confirmation_submenu_detail_objects_then_closes` @@ -1006,6 +1029,12 @@ pin the workspace's visible, non-persisted time and camera boundary; `operations_ui::operations_workspace_tests::action_scroll_uses_wheel_direction_and_never_leaves_its_content` pins the action column's wheel direction and bounds. +Defense: `operations_ui::tests::consequence_route_is_the_first_keyboard_action_not_a_separate_pane` +pins APPROACH MARCUS immediately above the exact sale command and proves Up +then Enter opens its evidence-scoped target. Bevy's shared-row regression pins +the same first entry before dispatching the unchanged sale command; terminal +and Bevy render that `OpsActionEntry` through their ordinary action-row path. + Defense: `operations_projection::tests::processed_marcus_debt_projects_fact_consequence_and_exact_approach` pins the shared result, consequence, and evidence-scoped APPROACH target; `operations_projection::tests::schemes_keep_outside_access_on_switch_and_explain_the_blocker` diff --git a/wiki/log/2026-08-02-consequence-actions-one-list.md b/wiki/log/2026-08-02-consequence-actions-one-list.md new file mode 100644 index 00000000..873873ce --- /dev/null +++ b/wiki/log/2026-08-02-consequence-actions-one-list.md @@ -0,0 +1,47 @@ +# 2026-08-02 — Put consequence routes in the action list + +``` +Type: log +``` + +## Finding + +Cameron's screenshot exposed a semantic split hidden by the visual layout. +APPROACH MARCUS was rendered in the right-hand action column under WHAT THIS +OPENS, but the shared workspace still classified it as a RELATED link. Mouse +hover could select that link; Up/Down stayed in the separate action pane and +could move only between SELL and the auto-sale control. The first RELATED +cursor entry was therefore also invisible whenever the consequence link was +filtered out of the center pane. + +## Change + +- The renderer-neutral action hierarchy now has an `Open` entry carrying the + exact consequence target. +- A consequence route leads the same action list as the selected object's + command and control rows. APPROACH MARCUS sits immediately above SELL, so Up + and Enter reach it through the ordinary action cursor. +- Consequence routes no longer remain in the workspace's supporting RELATED + list. Terminal and Bevy both render the shared `Open` entry as an ordinary + selectable row with a `>` continuation mark. +- Bevy removes the duplicate WHAT THIS OPENS section. The right column now + answers one question with one selection grammar. + +## Verification + +The focused core regression constructs Marcus's processed debt, proves +APPROACH is row zero and the sale is row one, then moves Up from sale and opens +the exact `IntelOpportunity` target. The Bevy Operations tests pin the same +shared ordering before dispatching the unchanged exact sale command; all +focused core, Bevy, and terminal Operations tests pass. The deterministic +`MISALIGNED_SHOT=operations-intel` frame was inspected with SELL selected: it +shows APPROACH MARCUS directly above SELL and auto-sale under the single WHAT +DO YOU WANT TO DO? heading, with no WHAT THIS OPENS split. + +## Defense + +`wiki/interface/operations-workspace.md` requires the processed result to move +from learned fact to changed consequence to exact action without forcing the +player to rediscover a different interface grammar. The Liturgical UI +Constitution now makes that result one decision choir: the center explains the +change, and the right column contains every available response in one cursor. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 89fcb924..35e892b6 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-02-knowledge-use-receipts.md](2026-08-02-knowledge-use-receipts.md) +## 2026-08-02 - Put consequence routes in the action list + +- Intent: (see session log) +- Log: [wiki/log/2026-08-02-consequence-actions-one-list.md](2026-08-02-consequence-actions-one-list.md) + ## 2026-08-02 - Keep Bevy text linearly sampled - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-02.md b/wiki/log/decisions/2026-08-02.md index d6c901ae..b67691a9 100644 --- a/wiki/log/decisions/2026-08-02.md +++ b/wiki/log/decisions/2026-08-02.md @@ -113,3 +113,39 @@ Owners: [plots.md](../../mechanics/plots.md#player-surface), [personas.md](../../mechanics/personas.md), [action-vocabulary.md](../../interface/action-vocabulary.md), and [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md#control-canon). + +## Rising read beats have a human reading opportunity + +### DECIDED + +- Newly processed intel and a routed record's terminal read/stop outcome get + one speed-aware three-second reading opportunity in wall-clock human + frontends. Command-clocked reads remain exact-current. +- Routed custody glyphs keep moving on exact persisted hops, while their cause + sentence stays grouped at the emitter instead of jumping every tick. +- This supersedes the temporal-hierarchy OPEN above. Read dwell is short world + attention; NOTICES remains durable event history, and neither duplicates + saved intel/evidence custody. + +Owner: [digital-read.md](../../interface/digital-read.md). + +## Consequence actions share one cursor + +### DECIDED + +- A consequence-specific route such as APPROACH MARCUS is an action-list + entry, not supporting RELATED context. It leads the same list as SELL and + standing policy controls. +- Pointer and keyboard operate the same row identity. Up from SELL reaches + APPROACH; Enter/click opens the exact evidence-scoped choices. +- The separate WHAT THIS OPENS section is removed. WHAT DOES THIS CHANGE? + remains the explanation; the one action choir contains every response. + +### REJECTED + +- **Keep the visual split and add a special Up handoff.** That would preserve + two semantic cursors while teaching one apparent list, leaving keyboard and + pointer behavior fragile whenever another consequence route appears. + +Owner: [operations-workspace.md](../../interface/operations-workspace.md), +constrained by [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md). -- 2.51.2