diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 2ae32106..660f3425 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -105,9 +105,9 @@ use world_annotations::{ CursorMarker, DigitalFocusLabel, HoverVerbBar, HoverVerbFamily, HoverVerbGroup, HoverVerbReceipt, HoverVerbReceiptText, HoverVerbWord, OperatorCue, PersonEvidenceMarker, PersonMarker, PersonWorkMarker, READ_SENTENCE_FONT_PX, ReadSentenceMarker, TokenMarker, - hover_verb_bar_anchor, render_cursor, render_digital_focus_label, render_hover_verb_bar, - render_operator_cue, render_people, render_person_evidence_markers, render_person_work_markers, - render_read_sentences, render_tokens, + hover_verb_bar_anchor, render_cursor, render_digital_focus_label, render_hover_identity, + render_hover_verb_bar, render_operator_cue, render_people, render_person_evidence_markers, + render_person_work_markers, render_read_sentences, render_tokens, setup_hover_identity, }; const TILE_SIZE: f32 = 16.0; @@ -1571,7 +1571,10 @@ fn main() { .init_resource::() .init_resource::() .init_gizmo_group::() - .add_systems(Startup, (setup, setup_ui, setup_3d).chain()) + .add_systems( + Startup, + (setup, setup_ui, setup_3d, setup_hover_identity).chain(), + ) .add_systems( Update, ( @@ -1622,7 +1625,12 @@ fn main() { scroll_sidebar, apply_rail_detail, (render_ui, render_opening_overlay).chain(), - (render_operator_cue, render_hover_verb_bar).chain(), + ( + render_operator_cue, + render_hover_verb_bar, + render_hover_identity, + ) + .chain(), (held_choice_pointer, menu_pointer), ops_pointer, log_row_pointer, diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index 5433ccbb..c37b73b2 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -1028,6 +1028,165 @@ 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. +#[derive(Component)] +pub(super) struct HoverIdentityLabel; + +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. +pub(super) fn hover_identity_target( + game: &Game, + pointer: Option<(i32, i32)>, +) -> Option<((i32, i32), String)> { + if game.screen != Screen::Playing + || game.menu.is_some() + || game.ops.is_some() + || game.marquee.is_some() + || game.sim.opening_choices_visible() + { + return None; + } + let (x, y) = pointer?; + 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)) +} + +pub(super) fn setup_hover_identity(mut commands: Commands) { + commands.spawn(( + Text::new(""), + TextFont { + font_size: HOVER_IDENTITY_FONT_PX, + ..default() + }, + TextColor(scaled(BONE, 0.72)), + BackgroundColor(Color::srgba(0.006, 0.007, 0.011, 0.88)), + Node { + position_type: PositionType::Absolute, + padding: UiRect::axes(Val::Px(6.0), Val::Px(3.0)), + ..default() + }, + GlobalZIndex(40), + Visibility::Hidden, + HoverIdentityLabel, + )); +} + +#[allow(clippy::type_complexity)] +pub(super) fn render_hover_identity( + game: Res, + mode: Res, + harness: Option>, + windows: Query<&Window, With>, + camera_q: Query<(&Camera, &GlobalTransform), With>, + camera3_q: RealCameraQuery, + mut label: Query< + (&mut Text, &mut Node, &mut Visibility), + (With, Without), + >, +) { + let Ok((mut text, mut node, mut visibility)) = label.single_mut() else { + return; + }; + // Evidence frames must not change with the operator's resting mouse: + // a staged screenshot never reads the live pointer. + let pointer = if harness.is_some() { + None + } else if mode.material { + mouse_grid_real(&windows, &camera3_q) + } else { + mouse_grid(&windows, &camera_q) + }; + let Some(((x, y), line)) = hover_identity_target(&game, pointer) else { + *visibility = Visibility::Hidden; + return; + }; + let Some(anchor) = hover_verb_bar_anchor(mode.material, x, y, &camera_q, &camera3_q) else { + *visibility = Visibility::Hidden; + return; + }; + let Ok(window) = windows.single() else { + *visibility = Visibility::Hidden; + return; + }; + if text.0 != line { + text.0.clone_from(&line); + } + // Above the tile, clamped inside the canvas by a rough glyph estimate; + // exact centering is not worth a layout read-back for a quiet label. + let estimated_width = line.len() as f32 * HOVER_IDENTITY_FONT_PX * 0.62 + 12.0; + let left = (anchor.x - estimated_width * 0.5).clamp( + 4.0, + (window.width() - SIDEBAR_WIDTH - estimated_width - 4.0).max(4.0), + ); + let top = (anchor.y - TILE_SIZE * 1.6 - 18.0).max(4.0); + node.left = Val::Px(left); + node.top = Val::Px(top); + *visibility = Visibility::Visible; +} + +#[cfg(test)] +mod hover_identity_tests { + use super::{Game, hover_identity_target}; + + #[test] + fn identity_stays_silent_outside_earned_quiet_hover() { + let mut game = Game::new(); + game.start_run(); + let (x, y) = game.sim.core_position(); + + // The opening boundary suppresses the label even over the host. + assert_eq!(hover_identity_target(&game, Some((x, y))), None); + assert_eq!(hover_identity_target(&game, None), None); + } + + #[test] + fn identity_names_the_host_after_the_boundary_and_yields_to_selection() { + let mut game = Game::new(); + game.start_run(); + game.sim.opening_stage = misaligned::sim::OpeningStage::World; + let (x, y) = game.sim.core_position(); + + let named = hover_identity_target(&game, Some((x, y))); + assert!( + named + .as_ref() + .is_some_and(|((tx, ty), line)| { (*tx, *ty) == (x, y) && !line.is_empty() }), + "the earned host answers with its identity line, got {named:?}" + ); + + // The machine selection's own label owns the spot once selected. + let id = game + .sim + .compute + .machines + .iter() + .find(|m| (m.x, m.y) == (x, y)) + .map(|m| m.id) + .expect("host machine exists"); + game.selected_machines.insert(id); + assert_eq!(hover_identity_target(&game, Some((x, y))), None); + } +} + #[cfg(test)] mod hover_verb_bar_tests { use super::{ diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 6474475a..43463ff6 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -167,7 +167,16 @@ so no heard-set map walk or room-scale geometry exists at display rate. toggles the dev work light (a flat neutral flood, not a player surface). F3 flips back to the default DIGITAL flat sensorium; the mode is not saved and changes no sim state. -- **Overlays** — title card and the RUN ENDED card (reason, day / tick). +- **Pointer identity** (2026-07-21) — a quiet one-line label above the + hovered tile rendering `InspectCard::identity_line` verbatim (cursor.md + criterion 5: the one shared earned name for the most specific thing + 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 + screenshots never read the live pointer, so harness evidence frames + render without it. Text remains ASCII-only because Bevy's embedded default font carries a limited glyph set, but the sidebar's key meters are Bevy UI rectangles/cells rather diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 1c6321a6..e9ee1145 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -265,6 +265,14 @@ into one world annotation. An explicit machine selection shows only device may show its attached explanation. 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 +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 diff --git a/wiki/log/2026-07-21-hover-identity-label.md b/wiki/log/2026-07-21-hover-identity-label.md new file mode 100644 index 00000000..74605720 --- /dev/null +++ b/wiki/log/2026-07-21-hover-identity-label.md @@ -0,0 +1,35 @@ +# 2026-07-21 — The pointer names what it rests on + +``` +Type: log +``` + +## Intent + +Frontend half of today's surface-identity work, answering the GUI +playtest's "glyphs are never named" finding: resting the pointer on any +world tile should say what the thing is, in the game's own earned voice, +without opening anything. + +## Changed + +- `world_annotations.rs` gains the hover identity label: a quiet dim + chip above the hovered tile rendering `InspectCard::identity_line` + verbatim (the shared core condensation landed earlier today). The + decision function `hover_identity_target` keeps it silent outside + play, while a menu / Operations / marquee owns input, before the + opening boundary retires, over the machine selection, over a device + the hover verb bar already answers for, and where nothing is earned. + Staged screenshots never read the live pointer (shot-harness rule), so + evidence frames render without it. +- [bevy.md](../interface/bevy.md) "What it renders" gains the pointer + identity bullet; the label is never amber and never carries verbs. +- Stacked on the seam-extraction branch under review; lands with it. + +## Verification + +`cargo test -p misaligned-bevy` (2 new decision tests: boundary/none +silence; earned host naming and selection yield), `./tools/check.sh +--frontend`, and an observed run in the real window: a hovered floor +tile answers `Floor`, the foreign rack answers `Foundation Rack - +foreign, powered`, and unknown space stays silent. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index e3c8b6ca..518e0347 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -31,6 +31,11 @@ add or amend a session log, then re-run the generator. - Intent: Audit the never-audited material-dark-frame contract against its owning opening law, the current runtime, and dependent current mirrors. Repair one live contradiction and promote the recurring mechanical class into the corpus gate rather than leave it for a third semantic tick. - Log: [wiki/log/2026-07-21-material-opening-honesty.md](2026-07-21-material-opening-honesty.md) +## 2026-07-21 - The pointer names what it rests on + +- Intent: Frontend half of today's surface-identity work, answering the GUI playtest's "glyphs are never named" finding: resting the pointer on any world tile should say what the thing is, in the game's own earned voice, without opening anything. +- Log: [wiki/log/2026-07-21-hover-identity-label.md](2026-07-21-hover-identity-label.md) + ## 2026-07-21 - GUI human-legibility playtest filed - Intent: Cameron asked for the Bevy frontend to be played as a human would meet it — real window, mouse, keyboard, screenshots — after finding it "incomprehensible as a human." No prior playtest had exercised the GUI this way; all previous reports drove `--agent` frames or the terminal.