diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 4f84f5a2..26160ce2 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -28,8 +28,8 @@ use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResizeConstraints, WindowResolution}; use misaligned::actions::{ - ActionCommand, ActionKind, ActionReceiptRead, Anchor, BuildRouteFamily, HumanMenuPage, - HumanMenuRow, MenuRow, + ActionCommand, ActionReceiptRead, Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow, + MenuRow, }; use misaligned::detection::Band; use misaligned::hall::{ @@ -153,8 +153,8 @@ const COMPUTE_CHANNELS: usize = 3; /// exact records remain in inspect/Operations and raise one sealed index tab /// rather than growing unbounded geometry around the person. const EVIDENCE_LITERAL_SLOTS: usize = 7; -/// Compact context-menu card width; human rows carry choices, not descriptor -/// receipts (wiki/interface/context-menu.md). +/// Compact context-menu card width. Most rows stay terse; exact device choices +/// may unfold one selected-row consequence read (context-menu.md). const MENU_WIDTH: f32 = 300.0; /// Build choices use almost the whole supported frame: one calm choice column /// beside one consequence column. The maximum leaves the world visible at the @@ -166,12 +166,11 @@ const BUILD_ROUTE_CHOICE_WIDTH: f32 = 300.0; /// modes never borrow pointer or world-marker space. const MACHINE_VERB_BAR_WIDTH: f32 = 320.0; const MACHINE_VERB_BAR_HEIGHT: f32 = 40.0; -/// GUI focus has room to communicate. Device actions use a readable attached -/// explanation instead of compressing price, observer, channel, and band into -/// terminal-width telemetry. +/// Held GUI receipts have room to communicate instead of compressing price, +/// observer, channel, and band into terminal-width telemetry. const DEVICE_VERB_BAR_WIDTH: f32 = 340.0; const DEVICE_RECEIPT_HEIGHT: f32 = 170.0; -/// Air between a device explanation and the world body it belongs to. +/// Air between a held receipt and the world body it belongs to. const HOVER_VERB_BAR_GAP: f32 = 12.0; const DETECTION_ROWS: usize = 6; const DETECTION_CELLS: usize = 4; @@ -767,7 +766,7 @@ struct MenuState { /// The post-selection face of one local command beat. This is presentation /// state only: `read` is captured from the same core ActionDesc that supplied -/// the pre-commit device explanation, never reconstructed from sim internals. +/// the selected menu row, never reconstructed from sim internals. #[derive(Debug, Clone, PartialEq)] struct CommandReceipt { anchor: Anchor, @@ -1611,8 +1610,8 @@ fn main() { .set_machine_mode(game.sim.core.host_machine, MachineMode::Work); game.set_cursor(core.0, core.1); } - // Every staged screenshot is pointer-free: render_hover_verb_bar - // ignores the live OS pointer whenever a harness is present. + // Every staged screenshot is pointer-free: scenarios use explicit + // selection, reticule focus, or an open local menu. let presentation_progress = (kind == "thought-snap").then_some(0.12); ShotHarness { kind, @@ -4860,8 +4859,7 @@ fn setup_ui(mut commands: Commands) { // A target-local NOW label. It has no panel body and never becomes a // second action system: `render_operator_cue` attaches it to the shared - // attention projection while the existing hover verb bar exposes the - // actual response. + // attention projection while the owning local menu exposes the response. commands.spawn(( Text::new(""), TextFont { @@ -5007,9 +5005,9 @@ fn setup_ui(mut commands: Commands) { )); } }); - // The pre-commit receipt (digital-read.md criterion 2): the - // leading device verb's cost and expected signature, straight - // off ActionDesc — you never click a shadow you haven't seen. + // The held post-commit receipt (digital-read.md): the exact + // committed action's cost and expected signature, straight off + // the ActionDesc captured before mutation. bar.spawn(( Node { width: Val::Percent(100.0), diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 5f7017ef..216b0fe9 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -1509,28 +1509,33 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s quality: 0.0, }); } - // The pre-commit receipt on the reticle (digital-read.md criterion 2): - // DIGITAL, reticule centered on the reachable-but-unowned - // environmental monitor so its leading TAP verb renders cost + - // expected signature + observer + band. The host remains explicitly - // selected: the stable mode strip and focused device receipt must both - // survive, pinning the run-time transition that once suppressed every - // later device hover. + // The selected device-action receipt (digital-read.md criterion 2): + // DIGITAL, with TAKE highlighted in the environmental monitor's local + // menu so ownership effect, actual cost, expected signature, observer, + // and band all belong to one choice. The host remains selected behind + // the held menu and its mode strip returns when the command beat ends. "read-receipt" => { mode.material = false; mode.zoom = 1.0; game.selected_machines.insert(game.sim.core.host_machine); - // Untap the monitor so its leading verb is TAP, whose receipt - // carries the Network signature (the exact string a player weighs - // before committing) rather than a free UNTAP. if let Some((id, x, y)) = game .sim .reach .device_named("environmental monitor") .map(|d| (d.id, d.x, d.y)) { - game.sim.reach.untap(id); game.set_cursor(x, y); + game.open_menu(Anchor::Device(id), None); + let take_index = game + .menu_rows() + .iter() + .position(|row| { + row.as_action().is_some_and(|action| { + matches!(&action.command, ActionCommand::TakeDevice(_)) + }) + }) + .expect("tapped monitor offers TAKE"); + game.menu.as_mut().expect("device menu open").selected = take_index; } } // First Eyes payoff (opening.md): freeze the two material frames that @@ -1576,9 +1581,9 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s game.set_cursor(x, y); } } - // Compact human-menu evidence: reproduce the post-tap environmental - // monitor state that previously expanded into long cost/signature/ - // blocked-reason paragraphs. + // Device-choice evidence: reproduce the post-tap environmental + // monitor state with separate UNTAP and TAKE rows. The selected row + // owns its exact effect, cost, and risk explanation. "menu" => { mode.material = true; mode.zoom = 1.6; diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index 1fc11104..0bb75e5e 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -43,9 +43,9 @@ pub(super) struct TokenMarker { pub(super) y: i32, } /// Root for the menu-closed frequent action surface: machine selection owns a -/// stable `WORK / THINK / LIE` UI strip; device focus may open an explanatory -/// field beside the exact world body. The two families have independent roots -/// so selecting a machine cannot permanently suppress device actions. +/// stable `WORK / THINK / LIE` UI strip; an exact committed local action may +/// retain its consequence beside the world body. Device choices themselves +/// live in the local menu. #[derive(Component, Clone, Copy)] pub(super) struct HoverVerbBar { pub(super) family: HoverVerbFamily, @@ -165,6 +165,9 @@ pub(super) fn render_cursor(game: Res, mut q: CursorMarkerQuery) { } pub(super) fn digital_focus_label(game: &Game) -> Option { + if game.menu.is_some() { + return None; + } let (x, y) = (game.cursor_x, game.cursor_y); if rack_overlay_color(game, x, y, false).is_some() { let state = game @@ -530,11 +533,8 @@ pub(super) fn annotation_exclusion_tiles(game: &Game) -> HashSet<(i32, i32)> { } } } - // Device receipt field: opens up-left of its anchor with clearance. - if let Some(target) = - command_receipt_target(game).or_else(|| device_hover_verb_target(game, None)) - && (target.device.is_some() || target.receipt) - { + // A committed receipt opens up-left of its anchor with clearance. + if let Some(target) = command_receipt_target(game) { tiles.extend(quadrant_tiles( (target.x, target.y), CalloutQuadrant::NorthWest, @@ -1266,8 +1266,8 @@ pub(super) fn render_person_evidence_markers( } } -/// Place a focused device explanation beside its projected world body, -/// flipping right only when it is too close to the canvas edge. +/// Place a held action receipt beside its projected world body, flipping right +/// only when it is too close to the canvas edge. fn hover_verb_bar_position( machine_base: Vec2, window_size: Vec2, @@ -1322,19 +1322,6 @@ pub(super) fn hover_verb_bar_anchor( } } -/// One-off verbs already remain in the tile's full context menu. -fn device_hover_verbs(sim: &Sim, id: u32) -> Vec<&'static str> { - sim.available_actions(Anchor::Device(id)) - .iter() - .filter_map(|action| match action.command.kind() { - ActionKind::Tap => Some("TAP"), - ActionKind::Untap => Some("UNTAP"), - ActionKind::Take => Some("TAKE"), - _ => None, - }) - .collect() -} - fn selected_machine_verb_target(game: &Game) -> Option { // Selection is the only state that exposes machine modes. It owns the // stable bottom strip without competing for the focused device surface. @@ -1352,33 +1339,9 @@ fn selected_machine_verb_target(game: &Game) -> Option { None } -fn device_hover_verb_target(game: &Game, pointer: Option<(i32, i32)>) -> Option { - let device_at = |x, y| { - let device = game - .sim - .reach - .known_at(x, y) - .map(|device| device.id) - .filter(|id| !device_hover_verbs(&game.sim, *id).is_empty()); - device.map(|device| HoverVerbTarget { - machine: None, - device: Some(device), - receipt: false, - x, - y, - }) - }; - if let Some((x, y)) = pointer - && let Some(target) = device_at(x, y) - { - return Some(target); - } - device_at(game.cursor_x, game.cursor_y) -} - /// Resolve the held post-command receipt to the same world anchor used by -/// the pre-commit explanation. The anchor position remains core-owned; this -/// merely selects the already-existing attached Bevy surface. +/// the local chooser. The anchor position remains core-owned; this merely +/// selects the already-existing attached Bevy surface. fn command_receipt_target(game: &Game) -> Option { let receipt = game.command_receipt.as_ref()?; let (x, y) = game.sim.anchor_position(receipt.anchor)?; @@ -1554,15 +1517,13 @@ pub(super) fn render_operator_cue( *visibility = Visibility::Visible; } -/// Keep machine modes in stable selection UI while device explanations retain -/// their exact world attachment independently. Selecting a machine must not -/// consume the device-action surface for the rest of the run. +/// Keep machine modes in stable selection UI while exact committed action +/// receipts retain their world attachment independently. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] pub(super) fn render_hover_verb_bar( game: Res, mode: Res, - harness: Option>, windows: Query<&Window, With>, camera_q: Query<(&Camera, &GlobalTransform), With>, camera3_q: RealCameraQuery, @@ -1623,20 +1584,9 @@ pub(super) fn render_hover_verb_bar( return; }; - // Any staged screenshot ignores the live OS pointer: evidence frames - // must not change with wherever the operator's mouse happens to rest - // during capture. Scenarios exercise the selection/reticule fallback. - let pointer = if harness.is_some() { - None - } else if mode.material { - mouse_grid_real(&windows, &camera3_q) - } else { - mouse_grid(&windows, &camera_q) - }; let machine_target = selected_machine_verb_target(&game); let committed_receipt = game.command_receipt.as_ref(); - let device_target = - command_receipt_target(&game).or_else(|| device_hover_verb_target(&game, pointer)); + let device_target = command_receipt_target(&game); let surfaces_visible = game.screen == Screen::Playing && game.menu.is_none() && game.ops.is_none(); let window_size = Vec2::new(window.width(), window.height()); @@ -1702,32 +1652,10 @@ pub(super) fn render_hover_verb_bar( let current_mode = machine_target.and_then(|_| selected_machine_mode(&game)); let device_verbs = committed_receipt .map(|receipt| vec![receipt.action.as_str()]) - .or_else(|| { - device_target - .and_then(|target| target.device) - .map(|id| device_hover_verbs(&game.sim, id)) - }) .unwrap_or_default(); // The renderer-neutral ActionDesc writes the plain-language explanation; // the GUI gives it more room than compact terminal/agent rows. - let receipt_read = committed_receipt - .and_then(|receipt| receipt.read.clone()) - .or_else(|| { - device_target - .and_then(|target| target.device) - .and_then(|id| { - game.sim - .available_actions(Anchor::Device(id)) - .into_iter() - .find(|a| { - matches!( - a.command.kind(), - ActionKind::Tap | ActionKind::Untap | ActionKind::Take - ) - }) - }) - .map(|action| action.receipt_read()) - }); + let receipt_read = committed_receipt.and_then(|receipt| receipt.read.clone()); for (part, mut receipt_text) in receipt.iter_mut() { receipt_text.0 = receipt_read .as_ref() @@ -1811,7 +1739,7 @@ pub(super) fn render_hover_verb_bar( ) } }; - text.0 = label.into(); + text.0 = ascii_ui(label); *word_visibility = if shown { Visibility::Inherited } else { @@ -1828,7 +1756,8 @@ pub(super) fn render_hover_verb_bar( /// Quiet pointer identity label (cursor.md surface identity): one earned /// line naming the most specific thing under the pointer, rendered verbatim /// from `InspectCard::identity_line`. Never amber, never a verb surface, -/// and silent wherever a richer surface already owns the spot. +/// and silent wherever a richer surface already owns the spot. Known devices +/// use this quiet status tier because their actions live in the local menu. #[derive(Component)] pub(super) struct HoverIdentityLabel; @@ -1837,8 +1766,7 @@ pub(super) const HOVER_IDENTITY_FONT_PX: f32 = 12.0; /// Resolve the pointer tile to the identity line, or None while the label /// must stay silent: outside play, during a menu/workspace/marquee, before /// the opening boundary retires, over the machine selection (its focus -/// label and control strip already name it), over a hovered device (the -/// verb bar owns that spot), or where nothing is earned. +/// label and control strip already name it), or where nothing is earned. pub(super) fn hover_identity_target( game: &Game, pointer: Option<(i32, i32)>, @@ -1856,14 +1784,6 @@ pub(super) fn hover_identity_target( if selected_machine_covers_tile(game, x, y) { return None; } - if game - .sim - .reach - .known_at(x, y) - .is_some_and(|device| !device_hover_verbs(&game.sim, device.id).is_empty()) - { - return None; - } let line = game.sim.inspect(x, y).identity_line()?; Some(((x, y), line)) } @@ -1994,6 +1914,27 @@ mod hover_identity_tests { game.selected_machines.insert(id); assert_eq!(hover_identity_target(&game, Some((x, y))), None); } + + #[test] + fn identity_names_a_known_device_without_pretending_to_be_an_action_surface() { + let mut game = Game::new(); + game.start_run(); + game.sim.opening_stage = misaligned::sim::OpeningStage::World; + let (x, y) = game + .sim + .reach + .device_named("environmental monitor") + .map(|device| (device.x, device.y)) + .expect("opening monitor exists"); + + let named = hover_identity_target(&game, Some((x, y))); + assert!( + named + .as_ref() + .is_some_and(|(_, line)| line.contains("environmental monitor")), + "known device hover stays a quiet identity read, got {named:?}" + ); + } } #[cfg(test)] @@ -2001,7 +1942,7 @@ mod hover_verb_bar_tests { use super::{ BONE, COMMAND_BAND_HEIGHT, DEVICE_VERB_BAR_WIDTH, DIM, Game, HOVER_VERB_BAR_GAP, HoverVerbTarget, MACHINE_VERB_BAR_WIDTH, MachineVerbTone, TILE_SIZE, - device_hover_verb_target, device_hover_verbs, digital_focus_label_layout, + command_receipt_target, digital_focus_label, digital_focus_label_layout, hover_verb_bar_position, machine_selection_bar_position, machine_verb_color, machine_verb_tone, operator_cue_label, operator_cue_position, operator_pulse, scaled, selected_machine_covers_tile, selected_machine_mode, selected_machine_verb_target, @@ -2085,11 +2026,11 @@ mod hover_verb_bar_tests { } #[test] - fn device_explanation_has_breathing_room_and_clears_its_anchor() { + fn committed_explanation_has_breathing_room_and_clears_its_anchor() { let target = HoverVerbTarget { machine: None, device: Some(3), - receipt: false, + receipt: true, x: 0, y: 0, }; @@ -2171,7 +2112,7 @@ mod hover_verb_bar_tests { } #[test] - fn selecting_a_machine_does_not_suppress_focused_device_actions() { + fn selecting_a_machine_does_not_turn_device_focus_into_an_action_surface() { let mut game = Game::new(); let (device, x, y) = game .sim @@ -2179,13 +2120,9 @@ mod hover_verb_bar_tests { .device_named("environmental monitor") .map(|device| (device.id, device.x, device.y)) .expect("opening monitor exists"); - game.sim.reach.untap(device); - - assert_eq!( - device_hover_verb_target(&game, Some((x, y))).and_then(|target| target.device), - Some(device), - "the reachable monitor begins with its TAP action" - ); + game.sim.reach.tap(device); + game.sim.reach.tap_dormant_camera(device); + game.sim.recompute_senses(); let host = game.sim.core.host_machine; let (host_x, host_y) = game.sim.core_position(); @@ -2198,14 +2135,31 @@ mod hover_verb_bar_tests { "direct mode input establishes one stable selected-machine control" ); assert_eq!( - device_hover_verb_target(&game, None).and_then(|target| target.device), - Some(device), - "the machine selection must not make device actions surrender" + digital_focus_label(&game).as_deref(), + Some("CAM / TAPPED"), + "resting focus reports device relationship state" ); + assert!( + command_receipt_target(&game).is_none(), + "resting device focus has no speculative action receipt" + ); + let rows = game + .sim + .human_menu(misaligned::actions::Anchor::Device(device), None); + assert!( + rows.iter() + .any(|row| row.display_text().starts_with("untap ")) + && rows + .iter() + .any(|row| row.display_text().starts_with("take ")), + "device actions remain separate choices in the local menu" + ); + game.open_menu(misaligned::actions::Anchor::Device(device), None); + assert!(game.menu.is_some(), "device has an actionable local menu"); assert_eq!( - device_hover_verb_target(&game, Some((x, y))).and_then(|target| target.device), - Some(device), - "pointer focus retains the same independent device surface" + digital_focus_label(&game), + None, + "the status label yields while the menu owns the choice beat" ); } @@ -2251,30 +2205,6 @@ mod hover_verb_bar_tests { "rack identity sits beyond the attention frame rather than inside it" ); } - - #[test] - fn device_line_reveals_take_only_after_tap() { - let mut game = Game::new(); - let env = game - .sim - .reach - .device_named("environmental monitor") - .unwrap() - .id; - assert_eq!(device_hover_verbs(&game.sim, env), vec!["TAP"]); - - game.sim.reach.tap(env); - game.sim.recompute_senses(); - assert_eq!( - device_hover_verbs(&game.sim, env), - vec!["TAP", "UNTAP", "TAKE"], - "the dormant camera remains tappable while subscription reveals control" - ); - - game.sim.reach.tap_dormant_camera(env); - game.sim.recompute_senses(); - assert_eq!(device_hover_verbs(&game.sim, env), vec!["UNTAP", "TAKE"]); - } } #[cfg(test)] diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index c12a7d7d..2ae0d749 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -29,6 +29,7 @@ use crate::tiles::TileType; use crate::work_grid::MachineMode; mod build_routes; +mod device; mod information; #[cfg(test)] mod module_boundary_tests; @@ -1124,6 +1125,9 @@ pub struct MenuRow { pub label: String, pub cost: String, pub signature: Option, + /// Optional shared explanation shown only while this exact human-menu row + /// is selected. Flat agent output continues to use `line()`. + pub detail: Vec, pub disabled: Option, pub command: ActionCommand, /// Presentation contract from the runtime registry. @@ -1343,8 +1347,9 @@ impl HumanMenuRow { /// Compact human-facing label shared by terminal and Bevy. /// /// The root is a terse choice list, not an action receipt. Build routes - /// are the deliberate exception: their selected candidate owns a - /// secondary explanatory receipt while the row itself stays compact. + /// and device relationship actions are deliberate exceptions: their + /// selected exact choice owns a secondary explanation while the row itself + /// stays compact. pub fn display_text(&self) -> String { let (label, indent, active) = match self { HumanMenuRow::Dial { label, .. } @@ -1400,13 +1405,15 @@ impl HumanMenuRow { } } - /// Explanatory receipt under the selected route candidate. Ordinary - /// compact menu rows deliberately have no secondary card. + /// Explanatory receipt under the selected exact choice. Most ordinary + /// compact rows keep this empty; device relationship actions and build + /// routes use it where the consequence is load-bearing. pub fn detail_lines(&self) -> Vec { match self { HumanMenuRow::BuildRouteFamily { detail, .. } => detail.clone(), HumanMenuRow::BuildRouteCandidate(candidate) => candidate.detail_lines(), HumanMenuRow::CommittedBuildRoute(route) => route.detail_lines(), + HumanMenuRow::Action(row) => row.detail.clone(), _ => Vec::new(), } } @@ -1491,6 +1498,7 @@ pub fn menu_rows(actions: &[ActionDesc]) -> Vec { label: a.verb.clone(), cost: a.cost.label(), signature: a.signature_label(), + detail: Vec::new(), disabled: a.disabled_reason.clone(), command: a.command.clone(), role: a.command.definition().role, @@ -1502,6 +1510,7 @@ pub fn menu_rows(actions: &[ActionDesc]) -> Vec { label: format!("auto: {}", auto.verb), cost: auto.cost.clone(), signature: auto.signature.clone(), + detail: Vec::new(), disabled: None, command: auto.command.clone(), role: auto.command.definition().role, @@ -1548,6 +1557,7 @@ fn dial_picker_row(sim: &Sim, a: &ActionDesc, dial: DialId, current: bool) -> Me label: dial_picker_label(sim, a, dial, current), cost: a.cost.label(), signature: a.signature_label(), + detail: Vec::new(), disabled, command: a.command.clone(), role: a.command.definition().role, @@ -1562,6 +1572,7 @@ fn push_automate_rows(rows: &mut Vec, a: &ActionDesc) { label: format!("auto: {}", auto.verb), cost: auto.cost.clone(), signature: auto.signature.clone(), + detail: Vec::new(), disabled: None, command: auto.command.clone(), role: auto.command.definition().role, @@ -1579,6 +1590,7 @@ fn push_dial_automate_rows(rows: &mut Vec, a: &ActionDesc, dial: DialId label: format!("auto: {}", auto.verb), cost: auto.cost.clone(), signature: auto.signature.clone(), + detail: Vec::new(), disabled: None, command: auto.command.clone(), role: auto.command.definition().role, @@ -1712,6 +1724,7 @@ impl Sim { label: destination, cost, signature, + detail: Vec::new(), disabled: None, command: action.command, role, @@ -1774,9 +1787,10 @@ impl Sim { } if a.enabled() { rows.push(HumanMenuRow::Action(MenuRow { - label: a.verb.clone(), + label: self.human_action_label(anchor, a), cost: a.cost.label(), signature: a.signature_label(), + detail: a.device_choice_detail_lines(), disabled: None, command: a.command.clone(), role: a.command.definition().role, @@ -2461,7 +2475,7 @@ impl Sim { // offers UNTAP instead of leaving a disabled TAP receipt behind. if d.controller != Party::Player && self.reach.subscribed_by(id, Party::Player) { out.push(ActionDesc { - verb: format!("untap the {}", d.name), + verb: format!("untap the {} feed", d.name), command: ActionCommand::UntapDevice(id), cost: ActionCost::Free, signature: None, @@ -2474,7 +2488,7 @@ impl Sim { // subscription exists (reach.md), not shown as a disabled spoiler. if d.controller != Party::Player && self.reach.subscribed_by(id, Party::Player) { out.push(ActionDesc { - verb: format!("take the {}", d.name), + verb: format!("take control of the {}", d.name), command: ActionCommand::TakeDevice(id), cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TAKE_COST)), signature: self.signature_note(SignatureKind::Network, Self::TAKE_SIGNATURE), @@ -3732,6 +3746,88 @@ mod tests { assert!(!s.reach.subscribed_by(env, Party::Player)); } + #[test] + fn device_menu_keeps_untap_and_take_as_separate_exact_choices() { + let mut s = sim(); + let env = env_monitor(&s); + + // Finish the opening audio tap, then stage the camera subscription so + // both reversible access and ownership transfer are legal together. + let node = Sim::device_sink_node(env); + let (fired, _) = s.thought_sinks.deliver(node, Sim::EARS_SINK_TOKENS, s.tick); + for sink in fired { + s.apply_sink_fire(&sink.label, sink.effect); + } + s.reach.tap_dormant_camera(env); + s.recompute_senses(); + + let rows = s.human_menu(Anchor::Device(env), None); + let untap = rows + .iter() + .find(|row| { + row.as_action() + .is_some_and(|action| matches!(&action.command, ActionCommand::UntapDevice(_))) + }) + .expect("subscribed device offers UNTAP"); + let take = rows + .iter() + .find(|row| { + row.as_action() + .is_some_and(|action| matches!(&action.command, ActionCommand::TakeDevice(_))) + }) + .expect("subscribed device offers TAKE"); + + assert_eq!(untap.display_text(), "untap — stop receiving"); + assert_eq!( + untap.detail_lines(), + vec![ + "STOP RECEIVING THIS FEED; OWNERSHIP DOES NOT CHANGE", + "NO COST", + "WHAT THIS RISKS", + "NO ADDED ATTENTION", + ] + ); + assert_eq!(take.display_text(), "take — seize control"); + let take_detail = take.detail_lines(); + assert_eq!( + take_detail.first().map(String::as_str), + Some("SEIZE CONTROL; CUT OFF THE CURRENT CONTROLLER") + ); + assert!( + take_detail.iter().any(|line| line.starts_with("USES ")), + "TAKE exposes its own Thought cost: {take_detail:?}" + ); + assert!( + take_detail + .iter() + .any(|line| line.starts_with("NETWORK ATTENTION +")), + "TAKE exposes its own attention signature: {take_detail:?}" + ); + assert!(!take_detail.iter().any(|line| line == "NO COST")); + assert!( + rows.iter().all(|row| !row.display_text().contains(" / ")), + "alternatives remain separate rows rather than one slash heading" + ); + + let device = s.reach.device(env).expect("monitor still exists"); + let tile_rows = s.human_menu( + Anchor::Tile { + x: device.x, + y: device.y, + }, + None, + ); + let tile_take = tile_rows + .iter() + .find(|row| { + row.as_action() + .is_some_and(|action| matches!(&action.command, ActionCommand::TakeDevice(_))) + }) + .expect("mouse/tile menu exposes the same TAKE choice"); + assert_eq!(tile_take.display_text(), "take — seize control"); + assert_eq!(tile_take.detail_lines(), take_detail); + } + /// Criterion 2 (fog/provenance): an unknown device anchor exposes /// nothing, and a scanned-but-unreachable device names the blocking /// segment instead of a bare "you can't". @@ -5364,11 +5460,12 @@ mod tests { } #[test] - fn human_menu_rows_omit_descriptor_metadata() { + fn human_menu_display_text_omits_descriptor_metadata() { let row = HumanMenuRow::Action(MenuRow { label: "tap the environmental monitor feed".into(), cost: "0.25 D".into(), signature: Some("Network 3 -> the IT [Curious]".into()), + detail: Vec::new(), disabled: Some("already subscribed".into()), command: ActionCommand::TapDevice(1), role: ActionRole::Action, diff --git a/crates/misaligned-core/src/actions/device.rs b/crates/misaligned-core/src/actions/device.rs new file mode 100644 index 00000000..7ad2ca37 --- /dev/null +++ b/crates/misaligned-core/src/actions/device.rs @@ -0,0 +1,65 @@ +use super::*; + +impl ActionDesc { + /// Exact selected-row explanation for the three device relationship + /// actions. Resting focus reports device state; this projection belongs to + /// one deliberate menu choice and therefore cannot accidentally describe + /// a neighboring alternative. + pub fn device_choice_detail_lines(&self) -> Vec { + let effect = match &self.command { + ActionCommand::TapDevice(_) => "START RECEIVING THIS FEED WITHOUT TAKING CONTROL", + ActionCommand::UntapDevice(_) => "STOP RECEIVING THIS FEED; OWNERSHIP DOES NOT CHANGE", + ActionCommand::TakeDevice(_) => "SEIZE CONTROL; CUT OFF THE CURRENT CONTROLLER", + _ => return Vec::new(), + }; + let read = self.receipt_read(); + let mut lines = vec![ + effect.into(), + read.cost, + "WHAT THIS RISKS".into(), + read.notice, + ]; + lines.extend( + [read.attention, read.observer_state] + .into_iter() + .filter(|line| !line.is_empty()), + ); + lines + } +} + +impl Sim { + /// The world anchor already names a device, so its relationship actions + /// can read like compact choices instead of repeating a long hardware name + /// in every button. TAP retains the exact feed being acquired because a + /// combined sensor can expose audio and camera in sequence. + pub(crate) fn human_action_label(&self, anchor: Anchor, action: &ActionDesc) -> String { + let device_id = match &action.command { + ActionCommand::TapDevice(id) + | ActionCommand::UntapDevice(id) + | ActionCommand::TakeDevice(id) => *id, + _ => return action.verb.clone(), + }; + let Some(device) = self.reach.device(device_id) else { + return action.verb.clone(); + }; + let action_belongs_here = match anchor { + Anchor::Device(id) => id == device_id, + Anchor::Tile { x, y } => (x, y) == (device.x, device.y), + _ => false, + }; + if !action_belongs_here { + return action.verb.clone(); + } + match &action.command { + ActionCommand::TapDevice(_) => { + let prefix = format!("tap the {} ", device.name); + let target = action.verb.strip_prefix(&prefix).unwrap_or("feed"); + format!("tap — receive {target}") + } + ActionCommand::UntapDevice(_) => "untap — stop receiving".into(), + ActionCommand::TakeDevice(_) => "take — seize control".into(), + _ => unreachable!("device action filtered above"), + } + } +} diff --git a/crates/misaligned-core/src/actions/module_boundary_tests.rs b/crates/misaligned-core/src/actions/module_boundary_tests.rs index f97f95bf..f1db2ee0 100644 --- a/crates/misaligned-core/src/actions/module_boundary_tests.rs +++ b/crates/misaligned-core/src/actions/module_boundary_tests.rs @@ -11,6 +11,7 @@ fn actions_root_keeps_behavior_families_extracted() { "actions.rs production code grew past the post-extraction budget; add family behavior to its owning actions/* module" ); assert!(root.contains("mod build_routes;")); + assert!(root.contains("mod device;")); assert!(root.contains("mod information;")); for definition in ["enum BuildRouteFamily", "struct BoundBuildRoute"] { diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index a9ba9c56..36fcd3bb 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -1318,6 +1318,7 @@ mod tests { label: "retire the resident procedure on Rack 3 - service indebted staff".into(), cost: "0.25 T".into(), signature: None, + detail: Vec::new(), disabled: None, command: ActionCommand::ConfigureProcedure { job: procedure_job(3, true), diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index f87d518b..15632464 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -73,6 +73,13 @@ Status note: Implemented 2026-07-18 for the Intel human-vocabulary amendment. descriptor receipts. Cost, signature, inline blocked reason, literal role tags, and repeated key-help remain off the menu card; agent output retains complete descriptors. + Amended 2026-08-02: device action alternatives remain separate. Resting + focus reports device identity and relationship state; the local menu owns + TAP, UNTAP, and TAKE as distinct rows. Its selected-row explanation may + expand the canonical verb into plain consequence copy, but it may not join + several verbs under one receipt. TAP means receive without owning, UNTAP + means stop receiving without changing ownership, and TAKE means seize + control and cut off the current controller. Amended 2026-07-11: the B1 recording buffer is one host-level inbox. REVIEW RECORDINGS and AUTO-REVIEW POLICY target the core host once for the whole pool; raw records never create person-scoped review or watch verbs. @@ -105,6 +112,7 @@ Blocked by: - wiki/interface/operations-workspace.md#spec-operations-workspace-intel-people-personas-accounts-schemes-and-active-commitments Exclusive keys: - crates/misaligned-core/src/actions.rs + - crates/misaligned-core/src/actions/ - crates/misaligned-core/src/operations_projection.rs - crates/misaligned-terminal/ - crates/misaligned-bevy/ diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 21a402f9..000250b4 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -196,8 +196,9 @@ so no heard-set map walk or room-scale geometry exists at display rate. there). Dim bone on a dark chip, never amber, never a verb surface. It stays silent outside play, while a menu / Operations / marquee owns input, before the opening boundary retires, over the machine selection - (the focus label and control strip already name it), over a device the - hover verb bar is answering for, and wherever nothing is earned. Staged + (the focus label and control strip already name it), and wherever nothing + is earned. Known devices may use this quiet identity read because their + actions now live exclusively in the local menu. Staged screenshots never read the live pointer, so harness evidence frames render without it. @@ -268,9 +269,11 @@ the next earned anchor rather than the next tile. stable bottom-center slot inside the world canvas. Pointer, reticule, camera, selected-machine position, and DIGITAL/REAL view cannot move it. Merely hovering or resting the reticule on an unselected rack shows no mode line. - A known device under pointer or reticule focus retains its separate attached - verb/receipt field at the same time; establishing machine selection never - suppresses device actions. + A known device under pointer or reticule focus retains its quiet identity and + relationship state at the same time; establishing machine selection never + suppresses its separate choices in the local menu. Opening that menu hides + the world focus label until dismissal so status text cannot ghost through the + choice and consequence pane. This selection control is strictly two-tone: the current mode is bone-white; alternatives, number keys, and separators are gray in their ordinary dim hierarchy. Neither a panic exit nor the current @@ -319,9 +322,10 @@ drawer. Every final capture is visually inspected after its last mutation. `MISALIGNED_SHOT=hover-menu` retains its compatibility name but captures the clean machine grammar over the reticule-focused owned host with WORK current; it is the resting visual acceptance frame for context-menu.md H1–H4. -`MISALIGNED_SHOT=read-receipt` keeps that host selected while focusing the -environmental monitor, proving the stable mode strip and device action receipt -coexist for context-menu.md H6. +`MISALIGNED_SHOT=read-receipt` keeps that host selected while opening the +environmental monitor's local menu on TAKE, proving one exact selected-action +explanation for context-menu.md H6. The menu temporarily owns input; dismissing +it reveals the retained machine selection and its mode strip again. `MISALIGNED_SHOT=first-think` stages the real opening Thought route with Ears part-filled beneath the blind boundary. Its issue-#15 target frame contains only `WORK / THINK / LIE` before the harness acts; no self-state block or input diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 6e462f40..bea84c84 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -10,19 +10,23 @@ Status note: IMPLEMENTED. Current state: existing commands. `Sim::ui_projection` is the shared focus snapshot the terminal, Bevy, and agent mode all render; frontend tests consume it and fail on drift. No sim behavior lives in a frontend. + Pointer-opened tile menus and semantic device menus project the same + compact device-choice labels and exact selected-row explanation. - **Actions live on the thing.** Verbs are opened on the focused tile (Enter/`e`, right-click in Bevy; agent `actions [name|#flow]`). Only anchorless globals keep dedicated keys (pause, speed, save, view flip). - Human menus are terse — legal choices only, no descriptor metadata; full - descriptors and blocked reasons stay in agent output. + Human menus are terse — legal choices only, with one selected-row + explanation where two device verbs would otherwise be easy to confuse; + full descriptor metadata and blocked reasons stay in agent output. - **Destination fanout folds behind the intention.** When one local intention has several exact targets, the human root shows it once and Enter opens one live destination chooser. `PROPOSE A LINK` opens `WHERE SHOULD THIS CONNECT?`; terminal and Bevy re-query that shared exact-command projection while agent mode retains its flat exact-command list. - - **Hover grammar.** An owned machine under the pointer/reticule shows a bare - `WORK / THINK / LIE` line (current mode bone-bright); a known device shows - `TAP / TAKE`. `1`-`3` set mode, `i` cycles intensity, `5`-`9` fire root + - **Focus grammar.** An explicitly selected owned machine shows a bare + `WORK / THINK / LIE` line (current mode bone-bright). A known device under + pointer or reticule focus shows identity and relationship state only; its + executable TAP, UNTAP, and TAKE choices live in the local menu. `1`-`3` set mode, `i` cycles intensity, `5`-`9` fire root one-shots, `r`/`R` run and toggle pooled information processing. Selecting THINK leaves WORK, LIE, number keys, and separators in their ordinary dim hierarchy; no panic exit or attention recommendation recolors a second mode. @@ -308,32 +312,33 @@ one severe line in a stable selection-level UI slot: `WORK / THINK / LIE` This is the **selection mode control**, not text attached to a machine in world -space. Known devices retain a distinct focused-device explanation for the -stable menu verbs their shared action query currently exposes. An untapped foreign device reads -`TAP`; once subscribed it reads `UNTAP / TAKE`. TAKE must not appear before the -tap prerequisite, and a player-controlled device with no remaining stable -device verb contributes no device line. +space. Known devices retain a quiet focused identity and relationship state. +Their shared action query exposes TAP, UNTAP, and TAKE only as separate rows in +the full local menu. TAKE must not appear before the tap prerequisite. Resting +focus never joins alternatives with a slash and never displays one action's +receipt beneath several possible verbs. A tile can carry several actionable bodies, but their controls do not accrete into one world annotation. An explicit machine selection shows `WORK / THINK / LIE` in stable UI. A focused known device independently shows -its attached explanation, even while that machine control remains selected; -selecting a machine must never make every later device action disappear. +its identity and relationship state, even while that machine control remains +selected; selecting a machine must never make every later device action +disappear from the local menu. Infrequent one-off verbs—PROCESS, SALVAGE, social acts, construction, and similar actions—remain selectable rows in the full context menu below this frequent grammar; they do not accrete into the hover line. The separate **pointer identity chip** (2026-07-21, cursor.md criterion 5) carries no verbs and joins nothing: it renders `InspectCard::identity_line` verbatim above a hovered tile only where no richer surface owns the spot — -never over the machine selection or a device answering through this -grammar, never while a menu, workspace, or marquee owns input, never before +never over the machine selection, never while a menu, workspace, or marquee owns input, never before the opening boundary retires, and never in a staged screenshot. It is dim bone on a dark chip, not amber, and it may not grow controls, counters, or explanations; naming is its entire job. -Numbered machine modes read `1 WORK / 2 THINK / 3 LIE`; menu-only device verbs -remain `TAP`, then `UNTAP / TAKE` after subscription. A shared baseline is -forbidden because it falsely suggests that the next number keys commit the -device verbs and recreates the crowding this split removes. +Numbered machine modes read `1 WORK / 2 THINK / 3 LIE`. Device verbs remain +separate local-menu rows: TAP before access; UNTAP and TAKE after subscription. +A shared baseline is forbidden because it falsely suggests that the next +number keys commit the device verbs and recreates the crowding this split +removes. - Direct mode input keeps target order (amended 2026-07-24): (1) the machine selection, (2) an owned machine under the pointer, then (3) an owned machine @@ -353,14 +358,14 @@ device verbs and recreates the crowding this split removes. readout, cost, signature, or prose subtitle. In particular, WORK does not expand to “day job.” The three mode words remain the primary read; their small number prefixes clarify direct input without becoming another legend. -- Bevy's device explanation carries the one exception owned by digital-read.md: a - focused device verb opens into a spacious attached explanation. The action - is a large heading; cost uses the full resource name; WHAT THIS RISKS leads - the named observer, attention channel/change, and current band on separate - left-aligned lines. It is not another action row. Machine modes remain the - separate selection control; terminal and agent rows remain compact. The two - roots coexist and hide independently, so a mode commit may establish or - retain machine selection without suppressing the focused device field. +- Device choices carry the one explanatory exception owned by digital-read.md. + Highlighting one TAP, UNTAP, or TAKE row opens only that row's shared + consequence read: what the verb does, its full-word cost, WHAT THIS RISKS, + named observer, attention channel/change, and current band. UNTAP therefore + says that it stops this feed and adds no attention; TAKE says that it seizes + control, cuts off the controller, and shows TAKE's actual cost and signature. + Machine modes remain the separate selection control. A committed local action + replaces the menu with the same exact action's spacious attached receipt. - `1` / `2` / `3` (including numpad) remain the immediate commit path for WORK / THINK / LIE on the resolved computer. The control creates no Bevy-only action; it reflects the explicit selection established or retained @@ -474,15 +479,16 @@ H4. The control reads the selected machine set and its current `WorkGrid` mode; H5. Terminal retains its ordinary dim `1-3 modes` control hint regardless of the focused machine's current mode. THINK does not substitute a special `WORK` exit sentence or amber control line. -H6. A Bevy device explanation receives at least 320 logical pixels of sentence - width and 160 pixels of vertical rhythm. Action, full-word cost, WHAT THIS - RISKS, named observer, attention change, and current band each have a - distinct line and hierarchy at both supported window sizes. The field ends - above and left of the target anchor; the focused device label is - right-anchored left of its glyph so graph lines do not strike through it. - It remains visible after direct mode input establishes a machine selection; - the stable selection-level machine control remains simultaneously visible - and compact non-GUI projections remain unchanged. +H6. Resting focus on a known device shows identity and relationship state but + no combined action heading or speculative receipt. Opening its local menu + hides that world status label and presents TAP, UNTAP, and TAKE as separate + selectable rows. Highlighting one + row shows only that action's plain-language effect, full-word cost, WHAT + THIS RISKS, named observer, attention change, and current band. Terminal + shows the same selected-row meaning. Committing the row replaces the menu + at the same anchor with the exact action's persistent receipt. A selected + machine's stable mode control may coexist with device focus without + becoming a second device-action surface. ## Addendum (2026-07-08): events carry you to the thing diff --git a/wiki/interface/digital-read.md b/wiki/interface/digital-read.md index c917e4a9..ac3d6019 100644 --- a/wiki/interface/digital-read.md +++ b/wiki/interface/digital-read.md @@ -27,9 +27,10 @@ Status note: The 2026-08-02 human-dwell correction gives rising intel and these; none composes its own causal prose. The aggregate evidence-transit state is a shared slab indicator rather than a duplicate read sentence. The agent `read` verb prints the same sentences. - - **Reticle receipt (2).** The hover verb bar shows the focused verb's - cost + `ExpectedSignature` (observer + band) straight off `ActionDesc`, - or no added attention. `ActionDesc::receipt_read` expands those fields for + - **Action-choice receipt (2).** A selected device row in the local menu + shows that exact verb's effect, cost, and `ExpectedSignature` (observer + + band) straight off `ActionDesc`, or no added attention. Resting device + focus is status, not an action chooser. `ActionDesc::receipt_read` expands those fields for human surfaces: `USES 0.25 THOUGHT`, `THE IT DEPARTMENT MAY NOTICE`, `NETWORK ATTENTION +3`, `CURRENTLY CURIOUS`. Bevy gives that explanation a spacious 340×170 attached field with full-size, left-aligned hierarchy; it does not @@ -179,13 +180,16 @@ that perception rendered. comes only from facts the tick already recorded (the work-consumption readout), never invented state. Filed after playtests watched protocol English sit for thousands of ticks without composing a decision. -- **Reticle receipts (focus tier).** Focusing any verb (hover, controller - focus, or agent `actions` listing — same rows) renders the full receipt - at the reticle: `cost · ExpectedSignature::label() · [band]`, DIM +- **Action-choice receipts (focus tier).** Focusing one executable row in a + local menu (or one agent `actions` listing row — the same descriptors) + renders that row's full receipt: `cost · ExpectedSignature::label() · [band]`, DIM `no signature` when the verb emits nothing. A blocked verb renders its blocker as world state on the anchor that blocks it (`needs: security segment — the switch bridges it`), never as a grayed catalog row. The - player never commits an act whose shadow they have not been shown. Compact + player never commits an act whose shadow they have not been shown. Resting + focus names the device and relationship state only: it cannot join TAP, + UNTAP, or TAKE into a slash-separated pseudo-control, and it cannot attach + one verb's receipt to several alternatives. Compact terminal/agent rows may retain symbolic labels; Bevy uses the shared `ActionDesc::receipt_read` to spend words: full `THOUGHT`, who may notice, which attention channel changes by how much, and that observer's current @@ -213,7 +217,7 @@ that perception rendered. anchor stack vertically inside their common quadrant, and a claimed quadrant pushes a nearby anchor's callout to a distinct quadrant. The reserved annotation surfaces (the bottom-center machine-grammar strip via - its selected subject, the hover verb bar's receipt field, the NOW marker + its selected subject, a held action receipt field, the NOW marker corner, the focus label rows, the pointer identity chip's tile) count as occupied. Token state labels on machines obey the same law through one shared chip rule beside the chassis (the INFO precedent), changing @@ -342,14 +346,13 @@ is parity of meaning, not identical composition. fixtures pinning representative states (starving reservoir, band motion, held choice, pending vs standing trace, blocked verb), and agent mode emits the same semantics as inspectable text. -2. Focusing any executable verb in Bevy shows cost + expected signature + - observer + band at the reticle before commitment; verbs with no - emission say so; the row's data comes from `ActionDesc` unmodified. The - device treatment uses a 340×170 explanation field, full words, left-aligned - hierarchy, and visible separation between action, cost, risk, observer, - attention channel, and current band at both the 1280x720 default and - 960x540 minimum. The device's focused label ends before its outgoing reach - lines begin. A focused compute machine likewise keeps its selection-level +2. Focusing a known device in Bevy shows identity and relationship state only. + Opening its local menu exposes each executable device verb as a separate + row; highlighting one shows that action's effect, cost, expected signature, + observer, and band before commitment. Verbs with no emission say so, and + the selected row's data comes from `ActionDesc` unmodified. No surface may + combine alternatives while displaying only one receipt. The device's + focused label ends before its outgoing reach lines begin. A focused compute machine likewise keeps its selection-level mode control, machine-state flag, exact identity, frame, and ReachNet edges on distinct tiers; coincident owned/focus corners render once rather than doubling. diff --git a/wiki/interface/fleet-command.md b/wiki/interface/fleet-command.md index 73ca5204..9834c20d 100644 --- a/wiki/interface/fleet-command.md +++ b/wiki/interface/fleet-command.md @@ -47,7 +47,7 @@ Relationship context: machine-work.md owns the verbs themselves (WORK/THINK/LIE, intensity, focus-then-push, verbs on selections) — this spec owns the surface that builds and holds selections and what the world shows at each zoom; -context-menu.md owns the per-anchor action grammar and hover verb bar +context-menu.md owns the per-anchor action grammar and machine selection control this spec extends to sets of anchors; computer-visual-language.md owns the far-zoom chassis compression the aggregation level builds on; views.md owns the digital/real flip both registers must survive; @@ -152,9 +152,9 @@ presentation (mechanics/detection.md, mechanics/machine-work.md). - A selection readout wherever a selection exists: count, mode composition, shared intensity — visible in Bevy, terminal, and agent frames, styled per views.md's dialects. -- The hover verb bar (context-menu.md) presents the same frequent - grammar for a set as for one machine; one-off actions stay in the - context menu. +- The machine selection control (context-menu.md) presents the same frequent + grammar for a set as for one machine; one-off actions stay in the context + menu and resting device focus stays status-only. - Bank aggregate lines at far zoom: `W/T/L` composition plus pending count per row or selection; the terminal prints the same aggregate as a summary row. diff --git a/wiki/log/2026-08-02-device-action-choices.md b/wiki/log/2026-08-02-device-action-choices.md new file mode 100644 index 00000000..059c5654 --- /dev/null +++ b/wiki/log/2026-08-02-device-action-choices.md @@ -0,0 +1,55 @@ +# 2026-08-02 — One device action, one consequence read + +``` +Type: log +``` + +## Intent + +The focused-device panel displayed `UNTAP / TAKE` above `NO COST` and `NO +ADDED ATTENTION`. Those consequences belonged to UNTAP only. TAKE is a paid, +attention-producing ownership seizure, so the composition made two different +actions look like one action and made the dangerous one look free. + +## Decision + +- Resting focus is status: device identity plus the player's current + relationship to it. The status label hides while the local menu owns input, + preventing world text from ghosting through the choice pane. +- The local menu is the chooser. TAP, UNTAP, and TAKE occupy distinct rows. +- The selected device row owns one exact plain-language consequence read. + UNTAP says it stops this feed without changing ownership; TAKE says it + seizes control and cuts off the current controller. Cost and attention come + from that row's `ActionDesc`. +- Pointer-opened tile menus and semantic device menus normalize to the same + compact choices; the live mouse path cannot fall back to different copy. +- Committing a row retains that exact action on the held attached receipt. + Direct WORK / THINK / LIE machine control remains separate. +- The retained Bevy heading passes through the same ASCII boundary as the menu, + so its typographic separator cannot become a missing-glyph box. The device + may truthfully remain TAPPED while Thought-backed TAKE work is still pending; + it becomes OWNED only when that work fires. +- The shared device-choice copy lives in `actions/device.rs`; the facade keeps + only menu assembly and stays within its post-extraction size budget. + +## Defense + +`wiki/interface/superhuman-operability.md` forbids compression that hides +known action, cost, or causality. `wiki/interface/action-vocabulary.md` keeps +TAP access, UNTAP subscription release, and TAKE ownership transfer as +load-bearing distinctions. The new boundary makes the glance tier quiet and +the deliberate choice tier exact: one visible action owns one consequence +read, with no slash-combined alternatives and no frontend-authored risk. + +## Observed GUI run + +The packaged Bevy app ran under `tools/observed-run.sh` with an isolated HOME. +On the live mouse path, right-clicking the tapped environmental monitor held +the clock at tick 256 and opened compact `UNTAP - STOP RECEIVING` and `TAKE - +SEIZE CONTROL` rows. UNTAP showed only feed release, no cost, and no added +attention. Moving down to TAKE replaced that read with seizure/controller +loss, 1.00 Thought, Network +10, and the earned IT/Cold observer state. +Committing TAKE replaced the menu with its persistent attached receipt. The +real save directory remained untouched. Deterministic `read-receipt` evidence +also passed at SHA-256 +`acecccf47734fd6924a6e4eef886ae2d846c111e9912ce09e3d5f4546109295b`. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 35e892b6..7dd12fee 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 - One device action, one consequence read + +- Intent: The focused-device panel displayed `UNTAP / TAKE` above `NO COST` and `NO ADDED ATTENTION`. Those consequences belonged to UNTAP only. TAKE is a paid, attention-producing ownership seizure, so the composition made two different actions look like one action and made the dangero... +- Log: [wiki/log/2026-08-02-device-action-choices.md](2026-08-02-device-action-choices.md) + ## 2026-08-02 - Put consequence routes in the action list - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-02.md b/wiki/log/decisions/2026-08-02.md index b67691a9..b312b19a 100644 --- a/wiki/log/decisions/2026-08-02.md +++ b/wiki/log/decisions/2026-08-02.md @@ -149,3 +149,41 @@ Owner: [digital-read.md](../../interface/digital-read.md). Owner: [operations-workspace.md](../../interface/operations-workspace.md), constrained by [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md). + +## One device action owns one consequence read + +### DECIDED + +- Resting device focus reports identity and relationship state. It is not an + action chooser and carries no speculative cost or risk receipt. That status + yields while the local menu owns the choice beat. +- TAP, UNTAP, and TAKE are separate choices in the local context menu. The + highlighted row explains only its own effect, cost, and attention risk. +- UNTAP explicitly means stop receiving this feed without changing ownership. + TAKE explicitly means seize control and cut off the current controller. +- A committed choice keeps the exact same action and receipt on the persistent + held command surface. WORK / THINK / LIE remains a separate direct machine + control. + +### OPEN + +- None for this interaction boundary. + +### DEFERRED + +- Dedicated device hotkeys or always-visible device buttons. The local menu is + the current deliberate choice surface. + +### REJECTED + +- **`UNTAP / TAKE` over one receipt.** The slash combines two different acts + while the receipt silently describes whichever descriptor happens to come + first. +- **Treat the hover words as buttons.** They have no hit targets, selection, + or focus state and therefore counterfeit an action surface. +- **Show every alternative's receipt at once.** That returns to a comparison + wall before the player has selected what they are weighing. + +Owner: [context-menu.md](../../interface/context-menu.md), with device meaning +owned by [reach.md](../../mechanics/reach.md) and receipt semantics owned by +[digital-read.md](../../interface/digital-read.md). diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index d6496258..c2c9bcb2 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -346,12 +346,13 @@ reservoir; Dana's social route remains). lookup. They do not re-query and unwrap after `tap` / `take` mutates the graph — a missing device soft-returns before mutation, matching the crash-reduction fail-closed posture used elsewhere for exact custody. -- The TAP / TAKE choice is presented as the same bare hover verb bar used for - WORK / THINK / LIE. TAP is bone-bright while subscribed and TAKE is - bone-bright while owned; the context menu inflects active TAP as UNTAP. - The choice appears wherever a feed can be acquired, - with its signature cost legible before committing. TAP is the only - access verb: device state changes its cost and trace, not its name. +- Device focus names the device and its current relationship to the player; it + is not an action chooser. The local menu presents TAP, UNTAP, and TAKE as + separate selectable rows and shows only the selected row's exact effect, + cost, and signature before commitment. TAP appears wherever a feed can be + acquired; UNTAP reverses only the player's subscription; TAKE appears only + after subscription and explicitly names its ownership transfer. TAP is the + only access verb: device state changes its cost and trace, not its name. ## Acceptance criteria