diff --git a/.planning/STATE.md b/.planning/STATE.md
index 86248fde..de0fdb67 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -27,7 +27,7 @@ Phase: 1040
Plan: Not started
Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114.
Status: Phase complete — ready for verification
-Last activity: 2026-06-02
+Last activity: 2026-06-02 - Completed quick task 260602-mri: crosshair-link toggle on the FastSense widget bar (mirrors the hover crosshair across active-page FastSense widgets)
### Note on parallel v4.0 work (main branch state)
@@ -96,6 +96,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict
| 260526-pqz | Raise per-signal slider-preview cap from 400 → 1000 buckets in `DashboardEngine.computePreviewEnvelopeReturning_` — three textual edits (1 code clamp + 2 documenting comments) in `libs/Dashboard/DashboardEngine.m` plus one consistency comment in `tests/test_dashboard_preview_overlay.m` (no assertion change; `numel(xd) >= 4` is cap-independent). Edit sites: line 3524 doc-comment (`computePreviewEnvelope` range), line 3542 inline comment (clamp range), line 3555 actual clamp `max(50, min(1000, floor(axWpx / 2)))`. Out of scope per plan: cache invalidation of `PreviewNBuckets_` — running demos must restart (or trigger the existing resize-invalidation path at `DashboardEngine.m:2241`) for the new cap to take effect. Static analysis clean: `mh_lint` + `mh_style` on both edited files report "everything seems fine"; regression sweep `grep -rn "\b400\b" tests/ \| grep -iE "(preview\|bucket\|envelope)"` returns no matches. MATLAB R2025a: `test_dashboard_preview_envelope` 7/7, `test_dashboard_preview_overlay` 10/10. Octave 11.1.0: `test_dashboard_preview_envelope` 2/2 (5 skipped — pre-existing TimeRangeSelector guard for patch+FaceAlpha+NaN on xvfb), `test_dashboard_preview_overlay` skipped entirely (pre-existing). | 2026-05-26 | 834b43c | — | [260526-pqz-raise-preview-line-cap-per-signal-from-4](./quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/) |
| 260529-rxf | Real per-event email alerts for background monitoring — new `EmailTransport` (SMTP auth/STARTTLS:587 default, also `none`/`ssl`; Octave `exist('sendmail','file')` log-and-skip guard; pure static `buildMailProps` CI seam) that `NotificationService` now delegates to via an injectable `Transport` property; per-(sensor,threshold) email cooldown (default 5 min, 0 disables; dry-run honors it too) with public `SuppressedCount`; `LiveEventPipeline.processMonitorTag_`/`runCycle` now forward real per-event `sensorData` (X/Y/thresholdValue/thresholdDirection from the live tick) so `IncludeSnapshot` rules attach PNGs in live mode. MATLAB-only per user decision. **Backward-compat preserved**: pipeline still defaults to `NotificationService('DryRun', true)` and all prior tests stay green. Verified locally (R2025a, live MATLAB MCP): `test_email_transport` 5/5, `test_notification_service` 10/10 (7 original + 3 new: delegation / cooldown-suppress / cooldown-expiry-via-Hidden-DI-seam), `test_live_event_pipeline_tag` 3/3, plus class suites `TestEmailTransport` 5/5, `TestNotificationService` 7/7, `TestLiveEventPipelineTag` 3/3. MISS_HIT (`mh_style`+`mh_lint`) clean on all 8 files; MATLAB Code Analyzer clean on the 3 new/edited libs. Real SMTP delivery is the single manual step via `examples/05-events/smoke_email_send.m` (FASTSENSE_SMTP_* env vars, STARTTLS:587), out of CI. | 2026-05-29 | 203da7a, 2ac6887, 341bab2, cef1fc5 | Verified | [260529-rxf-real-per-event-email-alerts-for-backgrou](./quick/260529-rxf-real-per-event-email-alerts-for-backgrou/) |
| 260529-fnt | Add `FunctionTransport` adapter (`libs/EventDetection/FunctionTransport.m`) — wraps a user-supplied function handle as a `NotificationService` `Transport` so an existing site/company MATLAB mailer can be reused for alerts with **no SMTP config** (no server/port/creds, no Gmail App Password). Drop-in duck-typed `send(recipients,subject,body,attachments)` (same as EmailTransport), normalizes recipients to a flat cellstr, defaults attachments to `{}`, Octave-safe (only calls user code). Purely additive — EmailTransport/NotificationService behavior unchanged. Built via **/gsd:fast** (inline, no subagents). Verified (R2025a): `test_function_transport` 5/5 (forwarding / recipients-normalization / attachments-default / invalid-handle / NotificationService integration), `test_notification_service` 10/10 (no regression); MISS_HIT + Code Analyzer clean on all touched files. `example_live_pipeline.m` gains a commented FunctionTransport option. Follow-up to 260529-rxf after the user opted to reuse their company mailer instead of configuring Gmail SMTP. | 2026-05-29 | 706e9d5 | Verified | (inline) |
+| 260602-mri | Add a crosshair-link toggle ('X') to the FastSenseWidget grey WidgetButtonBar that mirrors the hover crosshair across all FastSense widgets on the **active dashboard page**. New `CrosshairLinked` public property + `setCrosshairLink(tf)` on `FastSenseWidget` (default OFF; `toStruct` omits when false so legacy serialized dashboards are byte-identical; `fromStruct` restores). `HoverCrosshair` gains `setBroadcastFcn`/`onMoveExternal`/`onLeaveExternal` + a **deterministic `IsMirrored_` suppress flag** so a mirrored peer's same-dispatch self-leave is a no-op — **ZERO new figure-WBM closures**, respecting the 260512-egv/eu2 chained-WBM constraint; the broadcast rides on existing per-crosshair `onMove`/`onLeave` and each peer computes its OWN per-series datatip at the shared data-x via `computeYAtX_` (raw-x, no Y transmitted). `DashboardEngine` derives the active-page link set on demand (`collectLinkedCrosshairs_`, flattens GroupWidget children via `flattenWidgetsForPreview_`) through `broadcastCrosshairX_`/`broadcastCrosshairLeave_`/`onCrosshairLinkToggle`, and re-primes broadcast hooks via `rewireCrosshairLinks_` after `rerenderWidgets`/`switchPage`/`detachWidget`. Duck-typed 'X' button injected in `DashboardLayout.realizeWidget` via `ismethod(widget,'setCrosshairLink')` (leftmost chrome button, left of V/A), re-anchored by `reflowChrome_`, protected in `DashboardWidget.clearPanelControls`. **Orchestrator verification (R2025a, live MCP)** replaced the executor's flaky `tic`-window suppress (`SuppressLeaveUntil_`/`SuppressWindow_` — passed in isolation, failed in-suite) with the deterministic `IsMirrored_` flag, and fixed a **latent unbounded leave ping-pong** (`onLeaveExternal` now hides directly via a private `hideGraphics_` instead of re-entering broadcasting `onLeave`). Tests: new `test_fastsense_crosshair_link` **11/11**; regressions green (`test_hover_crosshair` 11/11, `test_fastsense_widget_ylimit_modes` 11/11, `test_time_range_selector_reinstall_after_rerender` pass, `test_dashboard_time_sync_all_pages` 5/5); MISS_HIT clean on all 6 files; Code Analyzer no new findings; live UI smoke on a rendered 2-widget dashboard (button renders + toggles, hovering one linked widget mirrors crosshair+datatip on the other, leave hides both, unlink stops mirroring). DetachedMirror crosshair-link parity OUT OF SCOPE (detached widgets use a figure-level `FastSenseToolbar`, not a WidgetButtonBar — matches 260513-sfp). | 2026-06-02 | a495cbc9, 635632e2, 8950abd5, 485154b7 | Verified | [260602-mri-add-crosshair-link-toggle-to-fastsense-w](./quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/) |
## Progress Bar
diff --git a/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md
new file mode 100644
index 00000000..a2454b0c
--- /dev/null
+++ b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md
@@ -0,0 +1,291 @@
+---
+phase: 260602-mri
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/FastSense/HoverCrosshair.m
+ - libs/Dashboard/FastSenseWidget.m
+ - libs/Dashboard/DashboardEngine.m
+ - libs/Dashboard/DashboardLayout.m
+ - libs/Dashboard/DashboardWidget.m
+ - tests/test_fastsense_crosshair_link.m
+autonomous: true
+requirements:
+ - MRI-01 # CrosshairLinked property + setCrosshairLink on FastSenseWidget (default false), toStruct/fromStruct round-trip (omit default)
+ - MRI-02 # HoverCrosshair gains BroadcastFcn_ broadcast hook + suppress-leave guard so a mirrored crosshair shows-instead-of-hides without new figure-WBM closures
+ - MRI-03 # DashboardEngine coordinates the active-page link set: hovered widget broadcasts its data-x; every OTHER linked FastSense widget mirrors crosshair + per-series datatip at that x
+ - MRI-04 # Crosshair-link toggle button (X glyph) on the FastSenseWidget WidgetButtonBar, duck-typed via ismethod(widget,'setCrosshairLink'); reflow re-anchors it; survives Reset/resize/page-switch
+ - MRI-05 # Backward compat: default OFF, legacy serialized dashboards load unchanged; unlink-on-detach; per-active-page scope
+
+must_haves:
+ truths:
+ - "A FastSenseWidget tile shows a crosshair-link toggle button (X) on its grey WidgetButtonBar, left of V/A and Info/Detach"
+ - "Toggling the button ON for >=2 FastSense widgets on the active page links them: hovering ANY linked widget mirrors the crosshair x onto all OTHER linked widgets, each showing its own per-series datatip at that x"
+ - "Toggling the button OFF removes that widget from the link set; it no longer mirrors or receives"
+ - "When the cursor leaves all linked axes, the mirrored crosshairs hide"
+ - "Existing single-widget hover (standalone FastSense, unlinked dashboard widgets) is unchanged"
+ - "A legacy serialized dashboard (no crosshairLinked field) loads with all widgets unlinked and identical behaviour + identical JSON"
+ - "Linking survives top-toolbar Reset (rerenderWidgets), figure resize, and page switch"
+ artifacts:
+ - path: "libs/FastSense/HoverCrosshair.m"
+ provides: "BroadcastFcn_ property + setBroadcastFcn(fn) + suppress-leave guard (SuppressLeaveUntil_/onMoveExternal)"
+ contains: "BroadcastFcn_"
+ - path: "libs/Dashboard/FastSenseWidget.m"
+ provides: "CrosshairLinked public property (default false) + setCrosshairLink(tf) + toStruct/fromStruct round-trip (omit when false)"
+ contains: "CrosshairLinked"
+ - path: "libs/Dashboard/DashboardEngine.m"
+ provides: "Active-page crosshair-link coordination: onCrosshairLinkToggle, broadcastCrosshairX_, broadcastCrosshairLeave_, collectLinkedCrosshairs_, rewireCrosshairLinks_"
+ contains: "collectLinkedCrosshairs_"
+ - path: "libs/Dashboard/DashboardLayout.m"
+ provides: "addCrosshairLinkToggle button (Tag CrosshairLinkButton) duck-typed via ismethod(widget,'setCrosshairLink'); reflowChrome_ re-anchors it"
+ contains: "CrosshairLinkButton"
+ - path: "tests/test_fastsense_crosshair_link.m"
+ provides: "Pure-logic + headless-render unit tests for property round-trip + collectLinkedCrosshairs_ + suppress-leave guard"
+ contains: "test_fastsense_crosshair_link"
+ key_links:
+ - from: "libs/Dashboard/DashboardLayout.m (CrosshairLinkButton callback)"
+ to: "DashboardEngine.onCrosshairLinkToggle (via EngineRef)"
+ via: "button callback -> widget.setCrosshairLink(tf) + engine.onCrosshairLinkToggle(widget)"
+ pattern: "onCrosshairLinkToggle"
+ - from: "libs/FastSense/HoverCrosshair.m (source onMove)"
+ to: "DashboardEngine.broadcastCrosshairX_"
+ via: "BroadcastFcn_ callback fired at end of onMove"
+ pattern: "BroadcastFcn_"
+ - from: "DashboardEngine.broadcastCrosshairX_"
+ to: "peer HoverCrosshair_.onMoveExternal(x)"
+ via: "loop over collectLinkedCrosshairs_(activePageWidgets) excluding source"
+ pattern: "onMoveExternal"
+ - from: "DashboardEngine.rerenderWidgets / switchPage"
+ to: "rewireCrosshairLinks_"
+ via: "re-prime BroadcastFcn_ on freshly-rebuilt HoverCrosshair_ handles"
+ pattern: "rewireCrosshairLinks_"
+---
+
+
+Add a crosshair-link toggle button to the FastSense dashboard widget's grey WidgetButtonBar. When toggled ON for a widget, that widget joins a crosshair-link set scoped to the CURRENTLY ACTIVE dashboard page. Moving the hover crosshair over ANY linked FastSense widget mirrors the crosshair's data-x onto all OTHER linked FastSense widgets on the same page; each mirrored widget shows its own per-series datatip at that same x — so the user compares values at the same x/time across multiple plots. Toggling OFF removes the widget from the set.
+
+Purpose: Cross-plot value comparison at a shared x/time without detaching widgets or losing dashboard context.
+Output: A new `CrosshairLinked` property + button, a broadcast hook on `HoverCrosshair`, and active-page link coordination on `DashboardEngine` — all backward-compatible, MATLAB+Octave-safe, toolbox-free.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@CLAUDE.md
+@.planning/STATE.md
+
+# Precedent — the EXACT pattern to mirror (V/A/L buttons): widget exposes a setter,
+# DashboardLayout duck-types via ismethod, toStruct omits the default.
+@.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-SUMMARY.md
+
+
+User chose plain /gsd:quick (no --discuss). The planner locked these defaults — record them in SUMMARY:
+
+- **D-01 Link model — SHARED SET.** Any widget with the toggle ON both broadcasts on hover AND receives mirrors. No master/source role. Matches "mirror across ALL other FastSense widgets".
+- **D-02 X mapping — RAW DATA-X.** Sensor dashboards share a time axis and the user said "same x/time position". `HoverCrosshair.onMove(xQuery)` already computes each line's Y at xQuery via `computeYAtX_` (binary_search), so each mirrored widget shows ITS OWN series values for free — no Y is transmitted. Log-x axes: the raw x VALUE is still correct (no pixel/normalized transform); a peer whose data does not span x shows em-dashes, which is the existing OOR behaviour. No special-casing needed.
+- **D-03 Coordination owner — DashboardEngine methods + per-widget flag (NO new class).** The link set is derived on demand from `FastSenseWidget.CrosshairLinked` over the flattened active-page widgets (`collectLinkedCrosshairs_`). Least-invasive; the enumeration logic is a pure-testable helper.
+- **D-04 Suppress-hide crux — broadcast hook + suppress-leave tic-guard on HoverCrosshair; NO new figure-WBM closures.** The single dashboard-figure WindowButtonMotionFcn already carries a chain of every widget's `HoverCrosshair.onFigureMove_` (newHcN -> ... -> newHc1 -> trs). When the cursor is over widget A, A's `onFigureMove_` resolves "inside" and calls `A.onMove(xQuery)`; B/C/D's `onFigureMove_` resolve "outside" and call `onLeave()` (hide). We make A's `onMove` ALSO drive peers, and make a recently-mirrored peer's `onLeave()` a no-op:
+ 1. New `BroadcastFcn_` callback on HoverCrosshair, fired at the END of `onMove(xQuery)` (only when set). On a dashboard, the engine sets this to `@(x) engine.broadcastCrosshairX_(thisCrosshair, x)`.
+ 2. `broadcastCrosshairX_(sourceHc, x)` loops the active-page linked crosshairs, and for each peer != source calls `peer.onMoveExternal(x)`.
+ 3. `onMoveExternal(x)` sets `SuppressLeaveUntil_ = tic` (window ~= 3*ThrottleSeconds) then calls `onMove(x)` with broadcasting DISABLED (re-entrancy: peers must not re-broadcast). `onLeave()` early-returns while `toc(SuppressLeaveUntil_) < window`. So when peer B's own `onFigureMove_` later fires `onLeave()` in the SAME motion dispatch (cursor not over B), the guard swallows it and B's mirrored crosshair stays visible.
+ 4. When the cursor leaves ALL linked axes: the source's `onFigureMove_` calls its own `onLeave()` (no suppress on the source) and, if `BroadcastFcn_` is set, the engine also broadcasts `onLeaveExternal()` to peers (clears their suppress + hides). Implement leave-broadcast by having `onLeave()` on a broadcasting crosshair invoke a paired `LeaveBroadcastFcn_` (or fold leave into `BroadcastFcn_` with a sentinel — simplest: a second callback `BroadcastLeaveFcn_`). Peers' `onLeaveExternal()` sets `SuppressLeaveUntil_ = []` then hides.
+ This adds ZERO new closures to the figure WBM — the broadcast rides on the EXISTING per-crosshair `onMove`/`onLeave` calls. This is the design constraint that prevents repeating the 260512-egv dangling-closure regression.
+- **D-05 Lifecycle — per-active-page set; unlink-on-detach; re-prime after rerender/switchPage.** Each `FastSenseWidget`'s `HoverCrosshair_` is recreated by `FastSense.render()` on every `rerenderWidgets` (Reset) and on page allocation. The engine must RE-PRIME `BroadcastFcn_`/`BroadcastLeaveFcn_` on the fresh crosshair handles after the realizeWidget loop and after switchPage (the 260512-eu2 reinstall-after-rerender lesson). Detaching a linked widget: drop it from the set by leaving `CrosshairLinked=true` but it no longer participates because `collectLinkedCrosshairs_` only walks active-page widgets — the detached widget is no longer in `activePageWidgets()`. Simpler still: clear its broadcast hook on detach. Mirrored crosshairs hide naturally on the next source `onLeave`.
+- **D-06 Backward compat — default OFF, toStruct omits, legacy loads unchanged.** Mirror the YLimitMode idiom byte-for-byte.
+- **D-07 Button glyph — ASCII 'X'.** Matches existing ASCII Info ('i') / Detach ('^') / V / A / L glyphs (Octave Unicode-in-uicontrol rendering is inconsistent; the WidgetButtonBar deliberately avoids Unicode — see 260513-sfp D-03). Active (linked) state highlighted via the existing `chooseYLimitActiveBg_` helper.
+
+
+
+
+
+From libs/FastSense/HoverCrosshair.m (verified):
+- `properties (SetAccess = private)`: Target, hFigure, hAxes, hLineV, hTipBox (GetAccess public — tests read fp.HoverCrosshair_)
+- `properties (Access = private)`: PrevWBMFcn_, LastUpdateTime, IsBusy, FigDeleteListener, AxDeleteListener, ThrottleSeconds=0.025
+- `onMove(obj, xQuery)` — PUBLIC. Updates+shows the vertical line + datatip at data-x xQuery. Computes per-line Y at xQuery internally (computeYAtX_). THIS is the external-drive entry point.
+- `onLeave(obj)` — PUBLIC. Hides line + datatip. Already guarded by `if ~isvalid(obj); return; end`.
+- `onFigureMove_(obj, src, evt)` — PRIVATE chained WBM handler. Lines 295-297: validity/handle guards FIRST. Line ~342-346: when cursor is OUTSIDE hAxes pixel bounds it calls `obj.onLeave()`. Line 366: when inside, `obj.onMove(xQuery)`.
+- `delete(obj)` restores PrevWBMFcn_ unconditionally.
+
+From libs/FastSense/FastSense.m (verified):
+- `HoverCrosshair` PUBLIC property (line 91, default true) — opt-out flag.
+- `HoverCrosshair_` property, `SetAccess = private` (line 174) -> GetAccess is PUBLIC. Created in render() at lines 1622-1631 (only when interactive desktop). So from a widget: `widget.FastSenseObj.HoverCrosshair_` is the reachable per-widget crosshair handle (may be [] under -batch/headless — guard with isempty + isvalid).
+
+From libs/Dashboard/FastSenseWidget.m (verified — mirror the YLimitMode pattern exactly):
+- `properties (Access = public)`: ... LiveViewMode='preserve', YLimitMode='auto-visible' (line 66). ADD `CrosshairLinked = false` here.
+- `FastSenseObj` (SetAccess=private, line 71): the FastSense handle (.hAxes, .IsRendered, .HoverCrosshair_).
+- `setYLimitMode(obj, mode)` (line 545) — public setter precedent.
+- toStruct (line 1194): `if ~strcmp(obj.YLimitMode,'auto-visible'); s.yLimitMode = obj.YLimitMode; end` (line 1205-1207). ADD `if obj.CrosshairLinked; s.crosshairLinked = true; end`.
+- fromStruct (line 1501): `if isfield(s,'yLimitMode'); try obj.setYLimitMode(s.yLimitMode); catch; end; end` (line 1566-1572). ADD analogous `if isfield(s,'crosshairLinked'); obj.CrosshairLinked = logical(s.crosshairLinked); end` (do NOT call a setter that touches graphics here — fromStruct runs pre-render; just set the flag, the engine wires broadcast at render/realize time).
+
+From libs/Dashboard/DashboardLayout.m (verified):
+- `EngineRef` property (line 28) — back-reference to DashboardEngine; set at DashboardEngine.m:164 `obj.Layout.EngineRef = obj;`.
+- `realizeWidget` (line ~352-428): chrome injected when `needsBar`. `needsBar` (line 370-373) already includes `ismethod(widget,'setYLimitMode')`. The crosshair-link button is FastSenseWidget-only and FastSenseWidget already triggers needsBar via setYLimitMode, so NO needsBar change is required — but the new button injection block must be added INSIDE the `if needsBar` body, after `addYLimitButtons_` (line 401-403) and after `addPlantLogToggle` (line 405-413), BEFORE the final `reflowChrome_(widget.hCellPanel, 28, 2)` (line 420).
+- Button precedent to copy: `addPlantLogToggle(obj, widget, engine)` (line 627) — reaches engine via EngineRef, idempotent (deletes prior by Tag), builds uicontrol with a Tag, callback toggles state + rebuilds + reflows. `onPlantLogTogglePressed_` (line 723) wraps in try/catch + non-blocking uialert.
+- `addYLimitButton_` (line 988) — the simplest uicontrol-button template (Position [x 2 24 24], FontSize 9, FontWeight bold, theme.ToolbarFontColor / theme.ToolbarBackground, Tag, TooltipString, Callback).
+- `reflowChrome_(hCell, barH, inset)` STATIC (line 1068) — re-anchors right cluster. Right-to-left order today: Detach (barW-24-4) ... Create ... Info ... PlantLog (xPl) ... then V/A cluster LEFT of PlantLog (xAll/xVisible at lines 1130-1146). ADD CrosshairLink as the LEFTMOST button: place it to the LEFT of the V/A cluster (i.e. `xLink = xVisible - gap - bw`). Find it via `findobj(bar(1),'Tag','CrosshairLinkButton','-depth',1)` and set its Position. Keep the same bw=24, gap=4 convention.
+- `chooseYLimitActiveBg_(theme)` STATIC (line 1155) — reuse for the linked (active) highlight.
+
+From libs/Dashboard/DashboardWidget.m (verified):
+- `clearPanelControls(hPanel)` (line 133): `protectedTags` (line 145-147) lists InfoIconButton, DetachButton, WidgetButtonBar, YLimitVisibleBtn, YLimitAllBtn, CreateEventButton, PlantLogToggleButton. ADD `'CrosshairLinkButton'` so the toggle survives re-render sweeps.
+
+From libs/Dashboard/DashboardEngine.m (verified):
+- `Layout.EngineRef = obj` set at line 164.
+- `activePageWidgets()` (line 2834) and `allPageWidgets()` (line 2845) — PUBLIC.
+- `flattenWidgetsForPreview_(obj, widgets, depth)` (line 3390) — depth-first flatten that unwraps GroupWidget children via getNestedWidgets(); depth cap 10. USE THIS to flatten active-page widgets so FastSense widgets nested in a GroupWidget participate. THIS feature is ACTIVE-PAGE scoped -> `flattenWidgetsForPreview_(activePageWidgets())`.
+- `rerenderWidgets` (lines 1742-1805): deletes each widget's outer cell panel (1742-1769), reinstalls TRS callbacks (1782-1790), re-allocates + `realizeWidget` loop (1794-1799 — this recreates each FastSense + a FRESH HoverCrosshair_), re-wires DetachCallback (1802) + CreateEventCallback (1804). ADD a `rewireCrosshairLinks_()` call at the END of this method (after line 1804) so the fresh HoverCrosshair_ handles get their BroadcastFcn_ re-primed.
+- `switchPage(obj, pageIdx)` (line 243) — after the page becomes active, call `rewireCrosshairLinks_()` so the now-active page's linked widgets are wired (and the previously-active page's are dropped, since collectLinkedCrosshairs_ only walks the active page).
+- `detachWidget(obj, widget)` (line 1535) — on detach, clear that widget's crosshair broadcast hook (best-effort) so a detached widget stops participating.
+
+
+
+
+
+
+ Task 1: CrosshairLinked property + HoverCrosshair broadcast hook (pure-testable core)
+ libs/Dashboard/FastSenseWidget.m, libs/FastSense/HoverCrosshair.m, tests/test_fastsense_crosshair_link.m
+
+ FastSenseWidget (per MRI-01, mirror YLimitMode idiom):
+ - New public property `CrosshairLinked = false` (declare in the `properties (Access = public)` block beside YLimitMode at line 66, with a header comment).
+ - New public method `setCrosshairLink(obj, tf)`: validate `logical scalar` (accept logical or 0/1 numeric scalar; else error 'FastSenseWidget:invalidCrosshairLink'); set `obj.CrosshairLinked = logical(tf)`. Do NOT touch graphics here — the engine owns broadcast wiring. (Setter is the duck-type hook DashboardLayout checks via ismethod.)
+ - toStruct (line ~1205): after the yLimitMode block, add `if obj.CrosshairLinked; s.crosshairLinked = true; end` (omit when false -> legacy JSON byte-identical).
+ - fromStruct (line ~1566): after the yLimitMode block, add `if isfield(s,'crosshairLinked'); obj.CrosshairLinked = logical(s.crosshairLinked); end`. Absent -> stays false.
+
+ HoverCrosshair (per MRI-02, the suppress-hide mechanism — D-04):
+ - Add `properties (Access = private)`: `BroadcastFcn_ = []`, `BroadcastLeaveFcn_ = []`, `SuppressLeaveUntil_ = []`, `SuppressWindow_ = 0.075` (~3*ThrottleSeconds).
+ - New public `setBroadcastFcn(obj, moveFn, leaveFn)`: store the two callbacks (each a function_handle or []). Tolerate nargin<3 (leaveFn defaults []).
+ - New public `onMoveExternal(obj, xQuery)`: guard `if ~isvalid(obj); return; end`; set `obj.SuppressLeaveUntil_ = tic`; then drive the crosshair WITHOUT re-broadcasting — call the existing `onMove` body but suppress the broadcast tail (simplest: set a private `InBroadcast_=true` flag, call `obj.onMove(xQuery)`, reset flag; `onMove`'s broadcast tail is gated on `~obj.InBroadcast_`). Add `InBroadcast_ = false` to the private props.
+ - New public `onLeaveExternal(obj)`: guard isvalid; set `obj.SuppressLeaveUntil_ = []`; call `obj.onLeave()` (which will now hide since the guard is cleared).
+ - Modify `onMove(obj, xQuery)`: at the very END (after the tip box is shown), add the broadcast tail:
+ `if ~obj.InBroadcast_ && isa(obj.BroadcastFcn_,'function_handle'); try obj.BroadcastFcn_(xQuery); catch; end; end`
+ - Modify `onLeave(obj)`: at the TOP, add the suppress guard:
+ `if ~isempty(obj.SuppressLeaveUntil_); try; if toc(obj.SuppressLeaveUntil_) < obj.SuppressWindow_; return; end; catch; obj.SuppressLeaveUntil_ = []; end; end`
+ AND at the END (after hiding), if this crosshair is a broadcaster (not currently being externally driven) and has a leave callback, broadcast leave:
+ `if ~obj.InBroadcast_ && isempty(obj.SuppressLeaveUntil_) && isa(obj.BroadcastLeaveFcn_,'function_handle'); try obj.BroadcastLeaveFcn_(); catch; end; end`
+ NOTE on ordering: the source crosshair has `SuppressLeaveUntil_` empty (it is the hovered one), so its onLeave runs the hide + leave-broadcast. A peer being mirrored has `SuppressLeaveUntil_` set, so its own onLeave (fired by its own onFigureMove_ later in the same dispatch) early-returns. This is the crux — verify carefully.
+ - delete(obj): also clear the callbacks (set BroadcastFcn_/BroadcastLeaveFcn_ to []) before/after restoring PrevWBMFcn_ (defensive — avoid a dangling engine ref firing post-delete; the existing isvalid guards already protect, but null the handles too).
+
+ Tests (tests/test_fastsense_crosshair_link.m — function-style, mirror test_fastsense_widget_ylimit_modes.m structure: addpath+install, nPassed/nFailed counters, per-case try/catch, em-dash-free messages, a canRenderFigures_() helper, final `assert(nFailed==0)` + `fprintf(' All %d tests passed.\n', nPassed)`):
+ - PURE (no figure): default `CrosshairLinked == false`.
+ - PURE: setCrosshairLink(true) -> true; setCrosshairLink(false) -> false; setCrosshairLink('bad') throws 'FastSenseWidget:invalidCrosshairLink'.
+ - PURE: toStruct omits crosshairLinked when false (`~isfield(toStruct,'crosshairLinked')`); emits `true` when set.
+ - PURE: fromStruct restores true when present; legacy struct (no field) -> false.
+ - RENDER-GUARDED (canRenderFigures_): build a FastSense on an offscreen axes, attach a HoverCrosshair, call `setBroadcastFcn`; assert `onMoveExternal(x)` makes the crosshair line Visible='on' AND that an immediately-following `onLeave()` is SUPPRESSED (line still Visible='on') because SuppressLeaveUntil_ is fresh; then `onLeaveExternal()` hides it (Visible='off'). This is the direct unit proof of the suppress-leave crux.
+ - RENDER-GUARDED: a broadcaster's `onMove(x)` invokes BroadcastFcn_ exactly once (capture via a counter closure); `onMoveExternal(x)` does NOT re-invoke it (InBroadcast_ gate).
+
+
+ Implement FastSenseWidget property/setter/round-trip first (RED: write the PURE test cases, watch them fail, then GREEN). Then implement the HoverCrosshair broadcast hook + suppress-leave guard (RED render-guarded cases, then GREEN). Keep all new error IDs namespaced. Octave-safe: HoverCrosshair already documents it is MATLAB-only for the render path (test_hover_crosshair skips on Octave) — gate the render cases behind canRenderFigures_() and an Octave skip exactly like test_hover_crosshair.m. The PURE FastSenseWidget cases MUST run on both MATLAB and Octave (no graphics).
+ Follow MISS_HIT style: 160-col lines, 4-space tabs, PascalCase props, camelCase methods, trailing-underscore private state.
+
+
+ MISSING — orchestrator runs live MATLAB. Executor cannot run MATLAB (no MCP). Static check: `grep -n "CrosshairLinked" libs/Dashboard/FastSenseWidget.m` shows property + toStruct + fromStruct; `grep -n "BroadcastFcn_\|onMoveExternal\|onLeaveExternal\|SuppressLeaveUntil_" libs/FastSense/HoverCrosshair.m` shows all four; `grep -c "nPassed = nPassed + 1" tests/test_fastsense_crosshair_link.m` >= 8. Orchestrator will run: `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` (expect all pass) + `tests/test_fastsense_widget_ylimit_modes.m` + `tests/test_hover_crosshair.m` as regression.
+
+ FastSenseWidget has CrosshairLinked (default false) + setCrosshairLink + JSON round-trip (omit default). HoverCrosshair has BroadcastFcn_/BroadcastLeaveFcn_ + setBroadcastFcn + onMoveExternal + onLeaveExternal + suppress-leave guard, with onMove broadcasting once at its tail and onLeave honoring the suppress window. test_fastsense_crosshair_link.m exists with >=8 cases (PURE + render-guarded suppress-leave proof). No new figure-WBM closures introduced.
+
+
+
+ Task 2: DashboardEngine active-page link coordination + lifecycle re-wiring
+ libs/Dashboard/DashboardEngine.m, tests/test_fastsense_crosshair_link.m
+
+ Implement the coordination layer (MRI-03, MRI-05) on DashboardEngine. Add a `methods` block (public where tests need it):
+
+ 1. `collectLinkedCrosshairs_(obj, widgets)` — PURE-ENUMERATION helper (make it public or Hidden so the test can drive it with a synthetic widget list). Flatten `widgets` via `obj.flattenWidgetsForPreview_(widgets)` (unwraps GroupWidget children). Return a cell array of structs `{struct('widget',w,'hc',hc), ...}` for every flattened widget that (a) `isa(w,'FastSenseWidget')`, (b) `w.CrosshairLinked` is true, (c) `~isempty(w.FastSenseObj)` and the FastSense is rendered, (d) `~isempty(w.FastSenseObj.HoverCrosshair_)` and `isvalid` (Octave: skip isvalid). Skip any widget failing a guard — never throw. KEEP THIS PURE (no side effects) so it is unit-testable against a hand-built widget list.
+
+ 2. `rewireCrosshairLinks_(obj)` — the wiring driver. Compute `linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets())`. FIRST clear BroadcastFcn_ on ALL active-page FastSense crosshairs (so a widget toggled OFF, or the previously-active page, stops broadcasting) — walk the flattened active page, and for each FastSenseWidget with a valid HoverCrosshair_ call `hc.setBroadcastFcn([], [])`. THEN, for each entry in `linked`, set `entry.hc.setBroadcastFcn(@(x) obj.broadcastCrosshairX_(entry.hc, x), @() obj.broadcastCrosshairLeave_(entry.hc))`. Wrap per-handle in try/catch. Only meaningful when >=1 linked widget, but clearing must always run. Guard: if `obj.HoverCrosshair_`-style headless (no crosshairs) it is a no-op.
+
+ 3. `broadcastCrosshairX_(obj, sourceHc, xQuery)` — re-collect `linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets())` (cheap; active page only; respects the 0.025s throttle upstream because this only fires from a source onMove which is itself throttled). For each entry where `entry.hc ~= sourceHc` (handle inequality), call `entry.hc.onMoveExternal(xQuery)` in try/catch. AVOID per-tick allocations beyond the small cell — acceptable per perf constraint (broadcast path runs at most ~40Hz, N widgets small).
+
+ 4. `broadcastCrosshairLeave_(obj, sourceHc)` — re-collect linked; for each peer `~= sourceHc` call `entry.hc.onLeaveExternal()` in try/catch.
+
+ 5. `onCrosshairLinkToggle(obj, widget)` — PUBLIC. Called by the DashboardLayout button callback AFTER `widget.setCrosshairLink(tf)` flips the flag. Just calls `obj.rewireCrosshairLinks_()` (re-derives the whole active-page set from current flags — idempotent, simplest correct behaviour). Wrap in try/catch + namespaced warning 'DashboardEngine:crosshairLinkToggleFailed'.
+
+ 6. Lifecycle hooks (the 260512-eu2 re-establishment lesson):
+ - In `rerenderWidgets`, add `obj.rewireCrosshairLinks_();` at the END (after the CreateEventCallback re-wire at line ~1804), wrapped in try/catch warning 'DashboardEngine:crosshairRewireFailed'. The fresh HoverCrosshair_ handles created by the realizeWidget loop get their broadcast hooks (re-)primed here.
+ - In `switchPage`, after the page is made active + widgets realized/refreshed, call `obj.rewireCrosshairLinks_();` (try/catch). This drops the previously-active page's wiring (those widgets are no longer in activePageWidgets) and primes the now-active page.
+ - In `detachWidget`, best-effort clear the detaching widget's broadcast hook before/while detaching: `if isa(widget,'FastSenseWidget') && ~isempty(widget.FastSenseObj) && ~isempty(widget.FastSenseObj.HoverCrosshair_); try widget.FastSenseObj.HoverCrosshair_.setBroadcastFcn([],[]); catch; end; end`. Then call `obj.rewireCrosshairLinks_()` after detach completes so the remaining active-page set is consistent. (The detached widget keeps CrosshairLinked=true in its serialized state but no longer participates because it left activePageWidgets.)
+
+ Tests — APPEND to tests/test_fastsense_crosshair_link.m:
+ - PURE: build a DashboardEngine with 3 FastSenseWidgets on one page (use the existing makeTag_/widget helpers from test_fastsense_widget_ylimit_modes.m as a model; render headless via canRenderFigures_ or, for the PURE enumeration case, hand-build a fake widget list of structs that duck-type .CrosshairLinked/.FastSenseObj so collectLinkedCrosshairs_ can be exercised WITHOUT a figure). Assert `collectLinkedCrosshairs_` returns only the widgets with CrosshairLinked==true and a valid rendered crosshair; flipping a flag changes the count; a GroupWidget-nested linked FastSenseWidget IS included (flatten works).
+ - RENDER-GUARDED end-to-end: render a 2-widget dashboard, setCrosshairLink(true) on both, call rewireCrosshairLinks_, then simulate hover by calling widgetA.FastSenseObj.HoverCrosshair_.onMove(xMid) and assert widgetB's crosshair line became Visible='on' at xMid (mirror works); then call onLeave on A and assert B hides. This is the integration proof.
+ Keep the render cases behind canRenderFigures_() + Octave skip; keep the collectLinkedCrosshairs_ enumeration case PURE (runs everywhere).
+
+
+ MISSING — orchestrator runs live MATLAB. Static check: `grep -n "collectLinkedCrosshairs_\|broadcastCrosshairX_\|broadcastCrosshairLeave_\|onCrosshairLinkToggle\|rewireCrosshairLinks_" libs/Dashboard/DashboardEngine.m` shows all five; `grep -n "rewireCrosshairLinks_" libs/Dashboard/DashboardEngine.m` appears in rerenderWidgets, switchPage, detachWidget AND the method def (>=4 hits). Orchestrator runs: `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` (expect all pass) + regression `tests/test_time_range_selector_reinstall_after_rerender.m` (the eu2/egv guard — must stay green) + `tests/test_dashboard_time_sync_all_pages.m`.
+
+ DashboardEngine derives the active-page link set on demand from CrosshairLinked flags (flattening GroupWidget children), broadcasts a hovered widget's data-x to all OTHER linked widgets' crosshairs via onMoveExternal, broadcasts leave via onLeaveExternal, and re-primes the broadcast hooks after rerenderWidgets, switchPage, and detachWidget. collectLinkedCrosshairs_ is pure and unit-tested. No new figure-WBM closures; the TRS reinstall regression test stays green.
+
+
+
+ Task 3: Crosshair-link toggle button on the WidgetButtonBar (duck-typed) + protect from re-render sweep
+ libs/Dashboard/DashboardLayout.m, libs/Dashboard/DashboardWidget.m
+
+ Add the toggle button (MRI-04), mirroring addPlantLogToggle/addYLimitButtons_ exactly.
+
+ 1. In `DashboardLayout.realizeWidget` (inside the `if needsBar` body): after the `addYLimitButtons_` block (line ~401-403) and after the `addPlantLogToggle` block (line ~405-413), and BEFORE the final `reflowChrome_(widget.hCellPanel, 28, 2)` (line ~420), add:
+ `if ismethod(widget, 'setCrosshairLink'); try obj.addCrosshairLinkToggle(widget); catch ME; warning('DashboardLayout:crosshairToggleFailed', 'addCrosshairLinkToggle failed during realizeWidget: %s', ME.message); end; end`
+ (No needsBar change needed — FastSenseWidget already triggers needsBar via setYLimitMode.)
+
+ 2. New method `addCrosshairLinkToggle(obj, widget)` (model on addPlantLogToggle):
+ - Resolve theme: `if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme); theme = DashboardTheme('light'); else; theme = widget.ParentTheme; end`.
+ - `bar = obj.getOrCreateButtonBar_(widget);`
+ - Idempotent: delete any prior `findobj(bar,'Tag','CrosshairLinkButton','-depth',1)`.
+ - Position: this is the LEFTMOST chrome button. Compute its x as LEFT of the V/A cluster. The initial x can be a best-effort (the final position is settled by reflowChrome_): mirror addYLimitButtons_'s left-anchored math and subtract one more `(bw+gap)`. Simplest robust approach: place it provisionally (e.g. `xLink = 2`) and rely on the realizeWidget-tail reflowChrome_ to anchor it correctly — but PREFER computing it consistently with reflowChrome_ (xVisible - gap - bw) using the same hasCreate/hasPlantLog detection so the very first paint is right. 24x24, FontSize 9, FontWeight bold.
+ - Glyph 'X', Tag 'CrosshairLinkButton', TooltipString: linked -> 'Unlink crosshair (stop mirroring)'; unlinked -> 'Link crosshair across page'. ForegroundColor theme.ToolbarFontColor. BackgroundColor: when `widget.CrosshairLinked` use `DashboardLayout.chooseYLimitActiveBg_(theme)` (highlighted), else `theme.ToolbarBackground`.
+ - Callback: `@(s,~) obj.onCrosshairLinkTogglePressed_(s, widget)`.
+ - After creating, call `DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2)` in try/catch (matches addPlantLogToggle's tail so callback-driven rebuilds re-anchor).
+
+ 3. New method `onCrosshairLinkTogglePressed_(obj, src, widget)` (model on onPlantLogTogglePressed_):
+ - try: `widget.setCrosshairLink(~widget.CrosshairLinked);` then notify the engine: `if ~isempty(obj.EngineRef) && isa(obj.EngineRef,'DashboardEngine'); obj.EngineRef.onCrosshairLinkToggle(widget); end`. Then rebuild the button look: `obj.addCrosshairLinkToggle(widget);`.
+ - catch ME: `warning('DashboardLayout:crosshairToggleFailed', 'Crosshair-link toggle callback failed: %s', ME.message);` + best-effort non-blocking uialert if a uifigure ancestor exists (copy the onPlantLogTogglePressed_ uialert block).
+
+ 4. In `reflowChrome_` (STATIC, line 1068): after the V/A cluster re-anchor block (lines ~1130-1146), add the CrosshairLink re-anchor as the LEFTMOST button:
+ `link = findobj(bar(1),'Tag','CrosshairLinkButton','-depth',1);`
+ `if ~isempty(link) && ishandle(link(1)); xLink = xVisible - gap - bw; set(link(1),'Position',[xLink,2,bw,bw]); end`
+ (xVisible is already computed for the V/A cluster just above; reuse it. If V/A absent for some future duck-typed widget, fall back to anchoring left of the right-cluster — but FastSenseWidget always has V/A, so the simple `xVisible - gap - bw` is correct for the only consumer today. Add a brief comment noting the leftmost-button assumption.)
+
+ 5. In `DashboardWidget.clearPanelControls` (line 145-147): append `'CrosshairLinkButton'` to `protectedTags` so the button survives the depth-1 uicontrol sweep on re-render.
+
+
+ MISSING — orchestrator runs live MATLAB. Static check: `grep -n "CrosshairLinkButton\|addCrosshairLinkToggle\|onCrosshairLinkTogglePressed_" libs/Dashboard/DashboardLayout.m` shows the button Tag + both methods + reflow re-anchor (>=5 hits); `grep -n "CrosshairLinkButton" libs/Dashboard/DashboardWidget.m` shows it in protectedTags; `grep -n "setCrosshairLink\|onCrosshairLinkToggle" libs/Dashboard/DashboardLayout.m` shows the realizeWidget duck-type + the EngineRef callback. Orchestrator will smoke-test on the live demo: button renders with Tag CrosshairLinkButton on the WidgetButtonBar left of V/A, clicking it toggles widget.CrosshairLinked + highlight, and after a top-toolbar Reset + figure resize the button stays anchored and linking still mirrors.
+
+ Every FastSenseWidget tile shows an 'X' crosshair-link toggle on its WidgetButtonBar (leftmost chrome button, left of V/A), duck-typed via ismethod(widget,'setCrosshairLink'). Clicking flips CrosshairLinked, highlights when linked, and calls EngineRef.onCrosshairLinkToggle to (re)wire the active-page link set. reflowChrome_ re-anchors it on resize; clearPanelControls protects it from re-render sweeps. The callback never throws into the refresh loop (try/catch + warning + non-blocking uialert).
+
+
+
+
+
+Overall checks (orchestrator runs these in live MATLAB — executor cannot):
+
+1. `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` — all cases pass (PURE on MATLAB+Octave; render cases on MATLAB desktop).
+2. Regression (MUST stay green):
+ - `tests/test_fastsense_widget_ylimit_modes.m` (V/A/L untouched)
+ - `tests/test_hover_crosshair.m` (standalone hover unchanged)
+ - `tests/test_time_range_selector_reinstall_after_rerender.m` (the 260512-egv/eu2 chained-WBM regression guard — proves no dangling-closure regression)
+ - `tests/test_dashboard_time_sync_all_pages.m` (multi-page sweep)
+3. MISS_HIT clean on all 5 edited files + the new test: `mh_style` + `mh_lint` report no new findings (`mcp__matlab__check_matlab_code` on each).
+4. Live demo smoke (orchestrator, on `demo/industrial_plant/run_demo.m`):
+ - Two FastSense widgets on the active page, click X on both -> hovering one mirrors the crosshair + per-series datatip onto the other at the same x; cursor leave hides both.
+ - Click X off on one -> it stops mirroring/receiving; the other keeps its own hover.
+ - Top-toolbar Reset, then re-hover -> mirroring still works (re-wire after rerender).
+ - Drag-resize the figure -> X stays anchored left of V/A; mirroring still works.
+ - Switch page (multi-page demo) -> link set scoped to the now-active page; previous page's widgets dropped.
+ - Open an existing v3.0 serialized dashboard -> all widgets unlinked, behaviour + JSON identical.
+
+
+
+- FastSenseWidget.CrosshairLinked (public, default false) + setCrosshairLink(tf) + toStruct (omit when false) + fromStruct (restore when present). Legacy dashboards load unchanged with identical JSON.
+- HoverCrosshair.onMove broadcasts data-x via BroadcastFcn_ exactly once; onMoveExternal mirrors without re-broadcasting; onLeave honors a suppress-leave tic-window so a mirrored peer is not hidden by its own same-dispatch onLeave; onLeaveExternal hides cleanly. ZERO new figure WindowButtonMotionFcn closures.
+- DashboardEngine derives the active-page link set from CrosshairLinked over flattened active-page widgets (GroupWidget children included), broadcasts hover-x to all OTHER linked widgets, and re-primes broadcast hooks after rerenderWidgets / switchPage / detachWidget.
+- An 'X' toggle on the WidgetButtonBar (leftmost chrome button) flips the flag, highlights when linked, re-anchors on resize, survives Reset, and is protected from clearPanelControls sweeps.
+- tests/test_fastsense_crosshair_link.m: >=8 cases incl. the suppress-leave unit proof + a 2-widget mirror integration proof + a pure collectLinkedCrosshairs_ enumeration test (with a GroupWidget-nested case). Existing hover + V/A/L + TRS-reinstall + multi-page tests stay green.
+- All errors namespaced (FastSenseWidget:* / DashboardLayout:* / DashboardEngine:*); MISS_HIT clean; MATLAB+Octave-safe (render paths gated, pure paths run everywhere).
+
+
+
diff --git a/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md
new file mode 100644
index 00000000..a89d88ee
--- /dev/null
+++ b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md
@@ -0,0 +1,139 @@
+---
+phase: 260602-mri
+plan: 01
+subsystem: Dashboard / FastSense
+tags: [crosshair, link, widget-button-bar, hover, broadcast]
+dependency_graph:
+ requires: []
+ provides:
+ - CrosshairLinked property on FastSenseWidget (MRI-01)
+ - HoverCrosshair broadcast hook + suppress-leave guard (MRI-02)
+ - DashboardEngine active-page link coordination (MRI-03)
+ - Crosshair-link 'X' toggle on WidgetButtonBar (MRI-04)
+ - Backward-compat: default OFF, legacy JSON unchanged (MRI-05)
+ affects:
+ - libs/FastSense/HoverCrosshair.m
+ - libs/Dashboard/FastSenseWidget.m
+ - libs/Dashboard/DashboardEngine.m
+ - libs/Dashboard/DashboardLayout.m
+ - libs/Dashboard/DashboardWidget.m
+tech_stack:
+ added: []
+ patterns:
+ - Suppress-leave via deterministic IsMirrored_ flag (one-shot, no wall-clock window); onLeaveExternal hides directly to avoid leave recursion
+ - InBroadcast_ re-entrancy flag prevents peer re-broadcasting
+ - collectLinkedCrosshairs_ pure enumeration helper (no side effects)
+ - Duck-type via ismethod(widget,'setCrosshairLink') for WidgetButtonBar injection
+ - Re-establish-after-rerender pattern (260512-eu2) for BroadcastFcn_ hooks
+key_files:
+ created:
+ - tests/test_fastsense_crosshair_link.m
+ modified:
+ - libs/FastSense/HoverCrosshair.m (+78 lines)
+ - libs/Dashboard/FastSenseWidget.m (+40 lines)
+ - libs/Dashboard/DashboardEngine.m (+152 lines)
+ - libs/Dashboard/DashboardLayout.m (+101 lines, -2 lines)
+ - libs/Dashboard/DashboardWidget.m (+3 lines, -1 line)
+decisions:
+ - D-01: Link model SHARED SET — any widget with toggle ON both broadcasts AND receives
+ - D-02: X mapping RAW DATA-X — data-x broadcast; each peer computes its own Y via computeYAtX_
+ - D-03: Coordination owner DashboardEngine + per-widget flag (no new class)
+ - D-04: Suppress-hide crux — broadcast hook + deterministic IsMirrored_ suppress flag; ZERO new figure WBM closures; onLeaveExternal hides directly (no leave recursion)
+ - D-05: Lifecycle — per-active-page scope; unlink-on-detach; re-prime after rerender/switchPage
+ - D-06: Backward compat — default OFF, toStruct omits field when false, legacy JSON byte-identical
+ - D-07: Button glyph ASCII 'X' (Octave-safe; matches existing V/A/L/i/^ ASCII glyphs)
+metrics:
+ duration: "~75 min"
+ completed: "2026-06-02"
+ tasks: 3
+ files: 6
+ loc_added: 748
+ loc_removed: 3
+---
+
+# Phase 260602-mri Plan 01: Add Crosshair-Link Toggle to FastSenseWidget Summary
+
+**One-liner:** Per-page crosshair-link broadcast with a deterministic IsMirrored_ suppress flag: hovering any linked FastSenseWidget mirrors its data-x to all OTHER linked widgets' crosshairs via BroadcastFcn_ without adding new figure WBM closures.
+
+## Tasks Completed
+
+| Task | Name | Commit |
+|------|------|--------|
+| 1 | CrosshairLinked property + HoverCrosshair broadcast hook | a495cbc9 |
+| 2 | DashboardEngine active-page link coordination | 635632e2 |
+| 3 | Crosshair-link toggle button on WidgetButtonBar | 8950abd5 |
+
+## Files Changed
+
+| File | LOC Delta | Summary |
+|------|-----------|---------|
+| `tests/test_fastsense_crosshair_link.m` | +377 | New test file: 11 cases (6 PURE + 1 pure-engine enumeration + 2 render-guarded suppress-leave/broadcast-reentry + 2 render-guarded 2-widget integration) |
+| `libs/Dashboard/DashboardEngine.m` | +152 | collectLinkedCrosshairs_, rewireCrosshairLinks_, broadcastCrosshairX_, broadcastCrosshairLeave_, onCrosshairLinkToggle; lifecycle hooks in rerenderWidgets/switchPage/detachWidget |
+| `libs/Dashboard/DashboardLayout.m` | +101, -2 | addCrosshairLinkToggle, onCrosshairLinkTogglePressed_, reflowChrome_ CrosshairLinkButton re-anchor, duck-type injection in realizeWidget |
+| `libs/FastSense/HoverCrosshair.m` | +78 | setBroadcastFcn, onMoveExternal, onLeaveExternal; suppress-leave guard in onLeave; BroadcastFcn_ tail in onMove; delete() nulls callbacks first |
+| `libs/Dashboard/FastSenseWidget.m` | +40 | CrosshairLinked=false public property; setCrosshairLink(tf) setter; toStruct omit-when-false; fromStruct pre-render restore |
+| `libs/Dashboard/DashboardWidget.m` | +3, -1 | CrosshairLinkButton added to clearPanelControls protectedTags |
+
+## Suppress-Leave Mechanism Explanation
+
+The core design challenge (D-04): the single dashboard-figure `WindowButtonMotionFcn` dispatches through a chain of every widget's `HoverCrosshair.onFigureMove_`. When the cursor is over widget A, A's handler resolves "inside" and fires `onMove(xQuery)`; B's handler resolves "outside" and fires `onLeave()` (hide). Without intervention, mirroring A's x onto B would be immediately undone by B's own `onLeave()` in the same dispatch cycle.
+
+**Solution:** Zero new figure-WBM closures. The broadcast rides on EXISTING per-crosshair `onMove`/`onLeave` calls, gated by a **deterministic `IsMirrored_` boolean** (NOT a wall-clock window — see "Orchestrator Verification & Fixes" for why the original tic-window was replaced):
+
+1. `BroadcastFcn_` callback on HoverCrosshair, fired at the END of `onMove(xQuery)` only when not in `InBroadcast_` mode. The engine sets this to `@(x) engine.broadcastCrosshairX_(thisHc, x)`.
+2. `broadcastCrosshairX_` loops active-page linked crosshairs and calls `peer.onMoveExternal(xQuery)` for each peer != source.
+3. `onMoveExternal(x)` sets `IsMirrored_ = true` then calls `onMove(x)` with `InBroadcast_=true`. `onLeave()` early-returns whenever `IsMirrored_` is true. So when peer B's own `onFigureMove_` fires `onLeave()` in the same motion dispatch (cursor not over B), the guard swallows it and B's mirrored crosshair stays visible — regardless of how long the dispatch takes.
+4. A REAL hover (`onMove` with `InBroadcast_=false`) clears `IsMirrored_` — the widget under the cursor becomes the link **source**.
+5. When the cursor leaves ALL linked axes: the source's `onFigureMove_` fires its own `onLeave()`. The source has `IsMirrored_=false`, so it hides and, via `BroadcastLeaveFcn_`, calls `broadcastCrosshairLeave_` → `peer.onLeaveExternal()` on all peers. `onLeaveExternal` sets `IsMirrored_=false` and hides **directly** (it does NOT call `onLeave`, so peers never re-broadcast — no leave ping-pong).
+
+**Key invariant:** there is always exactly one source (the last widget whose real `onMove` ran, `IsMirrored_=false`); on cursor-exit the source's `onLeave` always fires and broadcasts leave, clearing every peer. A mirrored peer (`IsMirrored_=true`) never hides on its own self-leave. This is timing-independent, so it cannot race the synchronous motion dispatch.
+
+## Design Decisions
+
+**D-01 — Link model SHARED SET.** Any widget with toggle ON both broadcasts on hover AND receives mirrors. No master/source role distinction.
+
+**D-02 — X mapping RAW DATA-X.** Sensor dashboards share a time axis. `HoverCrosshair.onMove(xQuery)` already computes each line's Y via `computeYAtX_` (binary_search), so each mirrored widget shows ITS OWN series values at the shared x for free. No Y is transmitted.
+
+**D-03 — Coordination owner DashboardEngine + per-widget flag (NO new class).** The link set is derived on demand from `FastSenseWidget.CrosshairLinked` over the flattened active-page widgets (`collectLinkedCrosshairs_`).
+
+**D-04 — Suppress-hide crux.** See "Suppress-Leave Mechanism Explanation" above. Zero new WBM closures — matches the 260512-egv/260512-eu2 design constraint.
+
+**D-05 — Lifecycle per-active-page.** `collectLinkedCrosshairs_` only walks `activePageWidgets()` so page-switch automatically scopes the link set. `rewireCrosshairLinks_` is called after `rerenderWidgets`, `switchPage`, and `detachWidget` to re-prime fresh HoverCrosshair_ handles (260512-eu2 lesson).
+
+**D-06 — Backward compat default OFF.** `CrosshairLinked = false` by default. `toStruct` omits `crosshairLinked` when false. `fromStruct` assigns the raw property (no setter call that would touch graphics) so pre-render load is safe. Legacy serialized dashboards load unchanged with identical JSON.
+
+**D-07 — Button glyph ASCII 'X'.** Octave-safe (no Unicode). Matches existing ASCII Info ('i') / Detach ('^') / V / A / L glyphs.
+
+## Deferred Items
+
+**DetachedMirror crosshair-link parity — OUT OF SCOPE.** A detached widget opens in its own standalone figure with a `FastSenseToolbar` (figure-level crosshair toggle), not a `WidgetButtonBar`. This matches the 260513-sfp precedent where V/A/L buttons are also absent on detached widgets. Implementing crosshair-link on DetachedMirror would require wiring the detached figure's toolbar to the engine's active-page link set, which is a separate effort.
+
+## Orchestrator Verification & Fixes
+
+Live MATLAB verification (R2025a) found three issues in the executor's commits, all fixed in commit `260602-mri-04` (HoverCrosshair.m + test):
+
+1. **Flaky time-window suppress (replaced).** The original `SuppressLeaveUntil_ = tic` / `toc < SuppressWindow_ (0.075s)` guard depends on wall-clock elapsed time *inside a synchronous motion dispatch*. The crux test failed in the full suite run (the window expired before the self-leave) yet passed in isolation (6.7ms) — i.e. genuinely flaky. Replaced with a deterministic `IsMirrored_` boolean (set by `onMoveExternal`, cleared when the widget becomes the hover source or via `onLeaveExternal`). Timing-independent; correct under every chained-dispatch ordering (verified by trace + the now-passing render-guarded tests).
+2. **Latent infinite-recursion in the leave path (fixed).** The original `onLeaveExternal` cleared the suppress then called `onLeave()`, which re-broadcast leave → peer `onLeaveExternal` → `onLeave` → re-broadcast … an unbounded ping-pong that would hang MATLAB with ≥2 linked widgets. (The committed tests never hit it: the crux test has no peers, and the integration test died earlier on bug #3.) Fixed by having `onLeaveExternal` hide **directly** via a new private `hideGraphics_` helper — never re-entering the broadcasting `onLeave`.
+3. **Two test-construction bugs (fixed).** `test_collect_linked_crosshairs_pure_enumeration` and `test_two_widget_mirror_integration` set `eng.Widgets = {...}`, but `DashboardEngine.Widgets` is `SetAccess=private`. Fixed: the enumeration test passes the cell list straight to `collectLinkedCrosshairs_` (it takes the list as a parameter); the integration test uses `eng.addWidget(w)` (which accepts pre-built widget handles).
+
+## Test Results — ALL GREEN (MATLAB R2025a, live MCP)
+
+| Test | Result |
+|------|--------|
+| `tests/test_fastsense_crosshair_link.m` (NEW) | **11/11 pass** (incl. deterministic suppress-leave crux + 2-widget mirror integration) |
+| `tests/test_hover_crosshair.m` (regression — standalone hover) | **11/11 pass** |
+| `tests/test_fastsense_widget_ylimit_modes.m` (regression — V/A/L) | **11/11 pass** |
+| `tests/test_time_range_selector_reinstall_after_rerender.m` (regression — chained-WBM/egv-eu2 guard) | **pass** |
+| `tests/test_dashboard_time_sync_all_pages.m` (regression — multi-page) | **5/5 pass** |
+| MISS_HIT `mh_style` + `mh_lint` (all 6 files) | **clean** (everything seems fine) |
+| MATLAB Code Analyzer | no new findings (5 pre-existing `%#ok` stale-pragma infos, present on `main` baseline) |
+
+**Live UI smoke (real rendered 2-widget dashboard, figure on screen):** `CrosshairLinkButton` ('X', 24×24) renders on each FastSenseWidget's grey WidgetButtonBar; clicking it flips `CrosshairLinked` and wires the engine link set; hovering one linked widget mirrors the crosshair + per-series datatip onto the other at the same x; cursor-leave hides both; unlinking a widget stops it broadcasting/receiving; a still-linked solo widget keeps its own hover crosshair. Octave: pure cases run; render-guarded cases skip (HoverCrosshair is MATLAB-only) — consistent with `test_hover_crosshair.m`.
+
+## Self-Check: PASSED
+
+All 6 files exist on disk. All 4 commits exist in git log:
+- a495cbc9 Task 1: CrosshairLinked property + HoverCrosshair broadcast hook
+- 635632e2 Task 2: DashboardEngine active-page link coordination
+- 8950abd5 Task 3: Crosshair-link toggle button on WidgetButtonBar
+- (260602-mri-04) Orchestrator fixes: deterministic IsMirrored_ suppress + leave-recursion fix + test-construction fixes
diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m
index bc92e892..0fa46bd7 100644
--- a/libs/Dashboard/DashboardEngine.m
+++ b/libs/Dashboard/DashboardEngine.m
@@ -371,6 +371,14 @@ function switchPage(obj, pageIdx)
end
% Phase 1039 — recompute the current-view box for the newly active page.
try obj.updateCurrentViewIndicator_(); catch, end
+ % 260602-mri — re-prime crosshair broadcast hooks for the now-active page.
+ % Previous-page crosshairs are dropped (they are no longer in activePageWidgets).
+ try
+ obj.rewireCrosshairLinks_();
+ catch ME
+ warning('DashboardEngine:crosshairRewireFailed', ...
+ 'rewireCrosshairLinks_ failed after switchPage: %s', ME.message);
+ end
end
function w = addWidget(obj, type, varargin)
@@ -1545,6 +1553,20 @@ function detachWidget(obj, widget)
% is a handle class, so mutations to it are visible through all
% references.
+ % 260602-mri — best-effort clear this widget's broadcast hook before
+ % detaching so it stops participating in the active-page link set.
+ % The widget keeps CrosshairLinked=true in serialized state but will
+ % no longer be in activePageWidgets() after detach, so
+ % collectLinkedCrosshairs_ naturally excludes it. The explicit clear
+ % here avoids a stale closure firing between the detach and the next
+ % rewireCrosshairLinks_ call.
+ try
+ if isa(widget, 'FastSenseWidget') && ~isempty(widget.FastSenseObj) && ...
+ ~isempty(widget.FastSenseObj.HoverCrosshair_)
+ widget.FastSenseObj.HoverCrosshair_.setBroadcastFcn([], []);
+ end
+ catch
+ end
themeStruct = obj.getCachedTheme();
% containers.Map is a handle object — mutations after closure creation
% are visible through the captured reference (unlike cells/structs).
@@ -1569,6 +1591,12 @@ function detachWidget(obj, widget)
warning('DashboardEngine:plantLogOverlayFailed', ...
'detachWidget plant-log re-attach failed: %s', err.message);
end
+ % 260602-mri — re-derive active-page link set after detach so
+ % remaining widgets' broadcast hooks reflect the updated set.
+ try
+ obj.rewireCrosshairLinks_();
+ catch
+ end
end
function removeDetached(obj)
@@ -1802,6 +1830,14 @@ function rerenderWidgets(obj)
obj.Layout.DetachCallback = @(w) obj.detachWidget(w);
% 260513-snt — re-wire Create-Event callback for the same reason.
obj.Layout.CreateEventCallback = @(w) obj.openCreateEventDialog_(w);
+ % 260602-mri — re-prime crosshair broadcast hooks on freshly-built
+ % HoverCrosshair_ handles (each realizeWidget creates a new HC).
+ try
+ obj.rewireCrosshairLinks_();
+ catch ME
+ warning('DashboardEngine:crosshairRewireFailed', ...
+ 'rewireCrosshairLinks_ failed after rerenderWidgets: %s', ME.message);
+ end
end
function updateGlobalTimeRange(obj)
@@ -2856,6 +2892,122 @@ function setTimeRangeSelectorForTest_(obj, sel)
end
end
+ % 260602-mri — crosshair-link coordination
+ function linked = collectLinkedCrosshairs_(obj, widgets)
+ %COLLECTLINKEDCROSSHAIRS_ Enumerate linked+rendered crosshairs on active page (260602-mri).
+ % linked = collectLinkedCrosshairs_(obj, widgets) flattens widgets via
+ % flattenWidgetsForPreview_ and returns a cell array of structs:
+ % {struct('widget', w, 'hc', hc), ...}
+ % for every flattened FastSenseWidget with CrosshairLinked=true AND a
+ % valid rendered HoverCrosshair_. Widgets failing any guard are silently
+ % skipped. PURE (no side effects) so it is unit-testable with a
+ % hand-built widget list.
+ % Made public (Access=public) so tests and DashboardLayout can call it.
+ linked = {};
+ if nargin < 2 || isempty(widgets); return; end
+ isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0;
+ try
+ flat = obj.flattenWidgetsForPreview_(widgets);
+ catch
+ flat = {};
+ end
+ for i = 1:numel(flat)
+ w = flat{i};
+ try
+ if ~isa(w, 'FastSenseWidget'); continue; end
+ if ~w.CrosshairLinked; continue; end
+ if isempty(w.FastSenseObj); continue; end
+ if ~isa(w.FastSenseObj, 'FastSense'); continue; end
+ if ~w.FastSenseObj.IsRendered; continue; end
+ hc = w.FastSenseObj.HoverCrosshair_;
+ if isempty(hc); continue; end
+ if ~isOctave && ~isvalid(hc); continue; end
+ linked{end + 1} = struct('widget', w, 'hc', hc); %#ok
+ catch
+ % skip any widget that errors during guard checks
+ end
+ end
+ end
+
+ function rewireCrosshairLinks_(obj)
+ %REWIRECEOSSHAIRLINKS_ Re-prime BroadcastFcn_ on active-page linked crosshairs (260602-mri).
+ % 1. Clear BroadcastFcn_/BroadcastLeaveFcn_ on ALL active-page FastSense
+ % crosshairs (handles toggled-OFF widgets + previous-page crosshairs).
+ % 2. For each currently-linked+rendered crosshair, install the engine
+ % broadcast callbacks. Must be called after rerenderWidgets (fresh
+ % HoverCrosshair_ handles), after switchPage, and after detachWidget.
+ % Wrapped in try/catch at call sites; inner per-handle errors are silently
+ % skipped so a single bad crosshair never breaks the whole sweep.
+ isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0;
+ try
+ flat = obj.flattenWidgetsForPreview_(obj.activePageWidgets());
+ catch
+ flat = {};
+ end
+ % Pass 1: clear all active-page crosshairs.
+ for i = 1:numel(flat)
+ w = flat{i};
+ try
+ if ~isa(w, 'FastSenseWidget'); continue; end
+ if isempty(w.FastSenseObj); continue; end
+ hc = w.FastSenseObj.HoverCrosshair_;
+ if isempty(hc); continue; end
+ if ~isOctave && ~isvalid(hc); continue; end
+ hc.setBroadcastFcn([], []);
+ catch
+ end
+ end
+ % Pass 2: wire the linked crosshairs.
+ linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets());
+ if numel(linked) < 1; return; end
+ for i = 1:numel(linked)
+ entry = linked{i};
+ hc = entry.hc;
+ try
+ hc.setBroadcastFcn( ...
+ @(x) obj.broadcastCrosshairX_(hc, x), ...
+ @() obj.broadcastCrosshairLeave_(hc));
+ catch
+ end
+ end
+ end
+
+ function broadcastCrosshairX_(obj, sourceHc, xQuery)
+ %BROADCASTCROSSHAIRX_ Mirror xQuery onto all OTHER linked crosshairs on active page (260602-mri).
+ % Fired at end of source crosshair's onMove (via BroadcastFcn_).
+ % Re-collects the linked set each call (cheap; active page only;
+ % upstream throttle limits call rate to ~40 Hz; N widgets small).
+ linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets());
+ for i = 1:numel(linked)
+ entry = linked{i};
+ if entry.hc == sourceHc; continue; end
+ try entry.hc.onMoveExternal(xQuery); catch; end
+ end
+ end
+
+ function broadcastCrosshairLeave_(obj, sourceHc)
+ %BROADCASTCROSSHAIRLEAVE_ Tell all OTHER linked crosshairs to hide (260602-mri).
+ % Fired at end of source crosshair's onLeave (via BroadcastLeaveFcn_).
+ linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets());
+ for i = 1:numel(linked)
+ entry = linked{i};
+ if entry.hc == sourceHc; continue; end
+ try entry.hc.onLeaveExternal(); catch; end
+ end
+ end
+
+ function onCrosshairLinkToggle(obj, widget)
+ %ONCROSSHAIRLINKTOGGLE Called by DashboardLayout after widget.setCrosshairLink(tf) (260602-mri).
+ % Re-derives the whole active-page link set from current flags — idempotent.
+ % Wrapped in try/catch so a single toggle failure never crashes the bar.
+ try
+ obj.rewireCrosshairLinks_();
+ catch ME
+ warning('DashboardEngine:crosshairLinkToggleFailed', ...
+ 'rewireCrosshairLinks_ failed during onCrosshairLinkToggle: %s', ME.message);
+ end
+ end
+
function notifyEventsChanged(obj)
%NOTIFYEVENTSCHANGED Refresh all event-aware widgets after store mutation (260513-snt).
% Called after CreateEventDialog persists a new event. Walks the
diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m
index a457cf93..0dbb65d6 100644
--- a/libs/Dashboard/DashboardLayout.m
+++ b/libs/Dashboard/DashboardLayout.m
@@ -411,12 +411,24 @@ function realizeWidget(obj, widget)
'addPlantLogToggle failed during realizeWidget: %s', ME.message);
end
end
+ % 260602-mri — crosshair-link toggle. Duck-typed via
+ % ismethod(widget,'setCrosshairLink') so only FastSenseWidget
+ % opts in today (setCrosshairLink is the duck-type hook).
+ % Lives inside needsBar because it requires the WidgetButtonBar.
+ if ismethod(widget, 'setCrosshairLink')
+ try
+ obj.addCrosshairLinkToggle(widget);
+ catch ME
+ warning('DashboardLayout:crosshairToggleFailed', ...
+ 'addCrosshairLinkToggle failed during realizeWidget: %s', ME.message);
+ end
+ end
% v4.0 260513-snt — settle final right-anchored button positions.
% addInfoIcon runs BEFORE addCreateEventButton, so Info's
% initial X collides with Create's slot. reflowChrome_ knows
% the full layout (V/A left cluster + Info/Create/Detach right
- % cluster + Plant Log toggle) and re-anchors everything in one
- % pass.
+ % cluster + Plant Log toggle + CrosshairLink leftmost) and
+ % re-anchors everything in one pass.
DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2);
else
% No chrome — render directly into the cell panel as before.
@@ -757,6 +769,81 @@ function onPlantLogTogglePressed_(obj, src, widget, engine)
end
end
+ function addCrosshairLinkToggle(obj, widget)
+ %ADDCROSSHAIRLINKTOGGLE Inject crosshair-link 'X' button into WidgetButtonBar (260602-mri).
+ % Duck-typed: only called for widgets where ismethod(widget,'setCrosshairLink').
+ % Idempotent: removes any prior CrosshairLinkButton before creating the new one.
+ % Glyph: 'X' (ASCII, Octave-safe — matches existing V/A/L/i/^ glyphs).
+ % Position: leftmost chrome button, placed to the LEFT of the V/A cluster;
+ % final position settled by reflowChrome_ (reflowChrome_ is called from
+ % both realizeWidget and from this method for callback-driven rebuilds).
+ % Active (linked) state highlighted via chooseYLimitActiveBg_ (same as V/A).
+ if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme)
+ theme = DashboardTheme('light');
+ else
+ theme = widget.ParentTheme;
+ end
+ bar = obj.getOrCreateButtonBar_(widget);
+ % Idempotent: delete any prior CrosshairLinkButton.
+ prior = findobj(bar, 'Tag', 'CrosshairLinkButton', '-depth', 1);
+ if ~isempty(prior)
+ try delete(prior); catch, end
+ end
+ % Provisional position (reflowChrome_ will re-anchor on resize).
+ % Use xLink = 2 as placeholder so the button is always inside
+ % the bar. reflowChrome_ computes the definitive left-of-V/A coord.
+ xLink = 2;
+ if widget.CrosshairLinked
+ tipStr = 'Unlink crosshair (stop mirroring)';
+ bgColor = DashboardLayout.chooseYLimitActiveBg_(theme);
+ else
+ tipStr = 'Link crosshair across page';
+ bgColor = theme.ToolbarBackground;
+ end
+ uicontrol('Parent', bar, ...
+ 'Style', 'pushbutton', ...
+ 'String', 'X', ...
+ 'Units', 'pixels', ...
+ 'Position', [xLink 2 24 24], ...
+ 'FontSize', 9, ...
+ 'FontWeight', 'bold', ...
+ 'ForegroundColor', theme.ToolbarFontColor, ...
+ 'BackgroundColor', bgColor, ...
+ 'Tag', 'CrosshairLinkButton', ...
+ 'TooltipString', tipStr, ...
+ 'Callback', @(s, ~) obj.onCrosshairLinkTogglePressed_(s, widget));
+ try
+ DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2);
+ catch
+ % Best-effort reflow — must not break the toggle.
+ end
+ end
+
+ function onCrosshairLinkTogglePressed_(obj, src, widget)
+ %ONCROSSHAIRLINKTOGLEPRESSED_ CrosshairLink toggle callback (260602-mri).
+ % Flips widget.CrosshairLinked, notifies the engine, and rebuilds the
+ % button visual. All errors are caught and surfaced as namespaced
+ % warnings so no toggle failure can crash the dashboard refresh loop.
+ try
+ widget.setCrosshairLink(~widget.CrosshairLinked);
+ if ~isempty(obj.EngineRef) && isa(obj.EngineRef, 'DashboardEngine')
+ obj.EngineRef.onCrosshairLinkToggle(widget);
+ end
+ % Rebuild button look (highlight / tooltip).
+ obj.addCrosshairLinkToggle(widget);
+ catch ME
+ warning('DashboardLayout:crosshairToggleFailed', ...
+ 'Crosshair-link toggle callback failed: %s', ME.message);
+ try
+ fig = ancestor(src, 'figure');
+ if ~isempty(fig) && ishandle(fig) && isa(fig, 'matlab.ui.Figure')
+ uialert(fig, ME.message, 'Crosshair link toggle failed', 'Icon', 'error');
+ end
+ catch
+ end
+ end
+ end
+
end
methods (Access = private)
@@ -1144,6 +1231,16 @@ function reflowChrome_(hCell, barH, inset)
if ~isempty(visibleBtn) && ishandle(visibleBtn(1))
set(visibleBtn(1), 'Position', [xVisible, 2, bw, bw]);
end
+ % 260602-mri — CrosshairLinkButton: LEFTMOST chrome button,
+ % sits to the LEFT of the V/A cluster.
+ % Assumption: FastSenseWidget always has V/A, so xVisible is
+ % always set when CrosshairLinkButton is present. Add a brief
+ % comment noting the leftmost-button assumption.
+ linkBtn = findobj(bar(1), 'Tag', 'CrosshairLinkButton', '-depth', 1);
+ if ~isempty(linkBtn) && ishandle(linkBtn(1))
+ xLink = xVisible - gap - bw;
+ set(linkBtn(1), 'Position', [xLink, 2, bw, bw]);
+ end
end
if ~isempty(content) && ishandle(content(1))
contentH = max(1, pp(4) - barH - inset);
diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m
index 999379a5..ef474dcc 100644
--- a/libs/Dashboard/DashboardWidget.m
+++ b/libs/Dashboard/DashboardWidget.m
@@ -142,9 +142,10 @@ function clearPanelControls(hPanel)
% v3.1 Phase 1032 PLOG-VIZ-05 — protect plant-log toggle from re-render sweeps.
% v4.0 — '+Event' button (Tag='CreateEventButton') + V/A Y-limit cluster
% (Tags 'YLimitVisibleBtn', 'YLimitAllBtn') also preserved.
+ % 260602-mri — protect crosshair-link toggle button from re-render sweeps.
protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar', ...
'YLimitVisibleBtn', 'YLimitAllBtn', 'CreateEventButton', ...
- 'PlantLogToggleButton'};
+ 'PlantLogToggleButton', 'CrosshairLinkButton'};
% Sweep depth-1 uicontrols (legacy-positioned buttons).
kids = findobj(hPanel, '-depth', 1, 'Type', 'uicontrol');
for i = 1:numel(kids)
diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m
index e5c30206..942f8c52 100644
--- a/libs/Dashboard/FastSenseWidget.m
+++ b/libs/Dashboard/FastSenseWidget.m
@@ -64,6 +64,15 @@
% 4. YLimitMode dispatch: 'locked' -> no-op; otherwise
% auto-visible / auto-all branches run.
YLimitMode = 'auto-visible'
+
+ % CrosshairLinked — when true this widget joins the active-page
+ % crosshair-link set (260602-mri). Moving the hover crosshair
+ % over any linked FastSenseWidget broadcasts the data-x to all
+ % OTHER linked widgets on the same page, so each mirrors the
+ % crosshair + per-series datatip at that x for cross-plot
+ % comparison. Default false -> backward-compatible (existing
+ % dashboards and serialized JSON are byte-identical).
+ CrosshairLinked = false
end
% (Tag property now lives on the DashboardWidget base class — Plan 1009-02.)
@@ -591,6 +600,25 @@ function setYLimitMode(obj, mode)
end
end
+ function setCrosshairLink(obj, tf)
+ %SETCROSSHAIRLINK Set the crosshair-link flag (260602-mri).
+ % setCrosshairLink(obj, tf) sets CrosshairLinked to logical(tf).
+ % tf must be a logical scalar or a numeric 0/1 scalar.
+ % Does NOT touch graphics — the engine owns broadcast wiring.
+ % Throws FastSenseWidget:invalidCrosshairLink for invalid input.
+ %
+ % This method is the duck-type hook: DashboardLayout calls
+ % ismethod(widget,'setCrosshairLink') to decide whether to render
+ % the crosshair-link toggle button on the WidgetButtonBar.
+ if ~((islogical(tf) || isnumeric(tf)) && isscalar(tf) && ...
+ (tf == 0 || tf == 1 || islogical(tf)))
+ error('FastSenseWidget:invalidCrosshairLink', ...
+ 'CrosshairLinked must be a logical scalar (or numeric 0/1); got %s.', ...
+ class(tf));
+ end
+ obj.CrosshairLinked = logical(tf);
+ end
+
function autoScaleY_(obj, y)
%AUTOSCALEY_ Rescale the Y axis to cover current data + thresholds.
% FastSense locks YLim to manual mode at first render, so new
@@ -1205,6 +1233,11 @@ function invalidatePreviewCache_(obj)
if ~strcmp(obj.YLimitMode, 'auto-visible')
s.yLimitMode = obj.YLimitMode;
end
+ % 260602-mri — emit crosshairLinked only when true so legacy
+ % JSON stays byte-identical for old dashboards (default false).
+ if obj.CrosshairLinked
+ s.crosshairLinked = true;
+ end
% NOTE: EventStore is a runtime handle — intentionally NOT serialized (Pitfall E).
if ~isempty(obj.Tag) && ~isempty(obj.Tag.Key)
@@ -1570,6 +1603,13 @@ function rebuildForTag_(obj)
% Invalid serialized value; keep default 'auto-visible'.
end
end
+ % 260602-mri — restore CrosshairLinked if serialized. Absent means
+ % "legacy dashboard, default false" so JSON round-trip is byte-identical.
+ % Do NOT call setCrosshairLink here (fromStruct runs pre-render;
+ % graphics wiring is the engine's responsibility at realize time).
+ if isfield(s, 'crosshairLinked')
+ obj.CrosshairLinked = logical(s.crosshairLinked);
+ end
end
end
end
diff --git a/libs/FastSense/HoverCrosshair.m b/libs/FastSense/HoverCrosshair.m
index 8f18f6aa..e2c9b27d 100644
--- a/libs/FastSense/HoverCrosshair.m
+++ b/libs/FastSense/HoverCrosshair.m
@@ -49,6 +49,11 @@
FigDeleteListener = [] % listener handle on figure ObjectBeingDestroyed
AxDeleteListener = [] % listener handle on axes ObjectBeingDestroyed
ThrottleSeconds = 0.025 % minimum interval between motion-driven updates
+ % 260602-mri — crosshair-link broadcast support
+ BroadcastFcn_ = [] % @(x) callback fired at end of onMove (link source->peers)
+ BroadcastLeaveFcn_ = [] % @() callback fired at end of onLeave (source hides->peers)
+ IsMirrored_ = false % driven by a peer via onMoveExternal; own self-leave then no-ops (deterministic, no wall-clock)
+ InBroadcast_ = false % re-entrancy guard: peers must not re-broadcast
end
methods (Access = public)
@@ -100,6 +105,54 @@
end
end
+ function setBroadcastFcn(obj, moveFn, leaveFn)
+ %SETBROADCASTFCN Set (or clear) crosshair-link broadcast callbacks (260602-mri).
+ % setBroadcastFcn(obj, moveFn, leaveFn)
+ % moveFn — @(x) callback, or [] to clear. Called at end of onMove.
+ % leaveFn — @() callback, or []. Called at end of onLeave on source.
+ % nargin < 3: leaveFn defaults to [].
+ if ~isvalid(obj); return; end
+ if nargin < 2
+ moveFn = [];
+ end
+ if nargin < 3
+ leaveFn = [];
+ end
+ obj.BroadcastFcn_ = moveFn;
+ obj.BroadcastLeaveFcn_ = leaveFn;
+ end
+
+ function onMoveExternal(obj, xQuery)
+ %ONMOVEEXTERNAL Mirror an external crosshair data-x without re-broadcasting.
+ % Called by DashboardEngine.broadcastCrosshairX_ to drive a peer
+ % crosshair at xQuery. Sets IsMirrored_ so the peer's own
+ % onFigureMove_->onLeave (fired in the same motion dispatch when
+ % the cursor is not over this widget) is swallowed and the
+ % mirrored crosshair stays visible. IsMirrored_ is cleared either
+ % when this widget becomes the hover source (its own real onMove)
+ % or by onLeaveExternal (the source's leave-broadcast).
+ if ~isvalid(obj); return; end
+ obj.IsMirrored_ = true;
+ obj.InBroadcast_ = true;
+ try
+ obj.onMove(xQuery);
+ catch
+ % swallow — never let mirroring errors surface
+ end
+ obj.InBroadcast_ = false;
+ end
+
+ function onLeaveExternal(obj)
+ %ONLEAVEEXTERNAL Clear mirror state and hide crosshair on command.
+ % Called by DashboardEngine.broadcastCrosshairLeave_ when the
+ % source crosshair's own onLeave fires (cursor left all linked axes).
+ % Hides DIRECTLY (does NOT call onLeave) so it never re-broadcasts
+ % leave — that would ping-pong between linked peers indefinitely.
+ if ~isvalid(obj); return; end
+ obj.IsMirrored_ = false;
+ obj.hideGraphics_();
+ end
+
function onMove(obj, xQuery)
%ONMOVE Update + show the crosshair at data x-coordinate xQuery.
% Public so tests can drive motion deterministically without
@@ -112,6 +165,13 @@ function onMove(obj, xQuery)
fp = obj.Target;
if isempty(fp) || ~isvalid(fp); return; end
+ % 260602-mri — a real (non-mirrored) hover makes this crosshair the
+ % link source. Clearing IsMirrored_ here means a later self-leave
+ % WILL hide it and broadcast leave to peers (see onLeave).
+ if ~obj.InBroadcast_
+ obj.IsMirrored_ = false;
+ end
+
yLim = get(obj.hAxes, 'YLim');
xLim = get(obj.hAxes, 'XLim');
@@ -187,11 +247,39 @@ function onMove(obj, xQuery)
'String', rows, ...
'Visible', 'on');
end
+ % 260602-mri — broadcast data-x to peer crosshairs (link source only).
+ % InBroadcast_ guard prevents re-entrancy when onMove is called
+ % via onMoveExternal (peers must not re-broadcast).
+ if ~obj.InBroadcast_ && isa(obj.BroadcastFcn_, 'function_handle')
+ try obj.BroadcastFcn_(xQuery); catch; end
+ end
end
function onLeave(obj)
%ONLEAVE Hide the crosshair line + datatip.
if ~isvalid(obj); return; end
+ % 260602-mri — suppress-leave: a crosshair currently mirrored by a
+ % peer (IsMirrored_ set in onMoveExternal) must NOT hide on its own
+ % same-dispatch self-leave (the cursor is over the source, not over
+ % this widget). It hides only via onLeaveExternal (the source's
+ % leave-broadcast). This is deterministic — no wall-clock window —
+ % so it cannot race the synchronous motion dispatch.
+ if obj.IsMirrored_
+ return;
+ end
+ obj.hideGraphics_();
+ % 260602-mri — this is the source crosshair (not externally driven):
+ % broadcast leave so peer crosshairs hide. onLeaveExternal hides
+ % peers DIRECTLY (it does not call onLeave), so there is no leave
+ % ping-pong / unbounded recursion between linked peers.
+ if ~obj.InBroadcast_ && isa(obj.BroadcastLeaveFcn_, 'function_handle')
+ try obj.BroadcastLeaveFcn_(); catch; end
+ end
+ end
+
+ function hideGraphics_(obj)
+ %HIDEGRAPHICS_ Hide the crosshair line + datatip (no broadcast).
+ % Shared by onLeave (source path) and onLeaveExternal (peer path).
if ~isempty(obj.hLineV) && ishandle(obj.hLineV)
set(obj.hLineV, 'Visible', 'off');
end
@@ -204,6 +292,10 @@ function delete(obj)
%DELETE Restore prior WindowButtonMotionFcn and remove graphics.
% Restore unconditionally — '' is a legal callback value, so
% we must NOT guard with ~isempty(PrevWBMFcn_).
+ % 260602-mri — null broadcast callbacks first (defensive: avoids a
+ % dangling engine ref firing after delete via stale closure).
+ obj.BroadcastFcn_ = [];
+ obj.BroadcastLeaveFcn_ = [];
if ~isempty(obj.hFigure) && ishandle(obj.hFigure)
try %#ok
set(obj.hFigure, 'WindowButtonMotionFcn', obj.PrevWBMFcn_);
diff --git a/tests/test_fastsense_crosshair_link.m b/tests/test_fastsense_crosshair_link.m
new file mode 100644
index 00000000..3642eeb4
--- /dev/null
+++ b/tests/test_fastsense_crosshair_link.m
@@ -0,0 +1,380 @@
+function test_fastsense_crosshair_link()
+%TEST_FASTSENSE_CROSSHAIR_LINK Tests for FastSenseWidget crosshair-link feature (260602-mri).
+%
+% Covers:
+% PURE (no graphics, run on MATLAB + Octave):
+% - Default CrosshairLinked == false
+% - setCrosshairLink(true/false) round-trips; 'bad' throws namespaced error
+% - toStruct omits crosshairLinked when false; emits true when set
+% - fromStruct restores true when present; legacy struct -> false
+% RENDER-GUARDED (MATLAB desktop only, skipped on Octave + headless):
+% - onMoveExternal shows crosshair; immediately following onLeave is
+% SUPPRESSED (suppress-leave crux); onLeaveExternal hides (clears)
+% - setBroadcastFcn fires BroadcastFcn_ exactly once from onMove;
+% onMoveExternal does NOT re-invoke it (InBroadcast_ gate)
+% ENGINE + PURE enumeration:
+% - collectLinkedCrosshairs_ returns only linked+rendered widgets
+% - flipping CrosshairLinked changes the count
+% - GroupWidget-nested FastSenseWidget IS included (flatten works)
+% RENDER-GUARDED integration:
+% - 2-widget dashboard: rewireCrosshairLinks_ + onMove on A mirrors B;
+% onLeave on A hides B
+
+ addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();
+
+ nPassed = 0; nFailed = 0;
+ cleanupAll = onCleanup(@() close('all', 'force')); %#ok
+
+ % =========================================================
+ % PURE TESTS (MATLAB + Octave)
+ % =========================================================
+
+ % --- test_default_crosshair_linked_is_false ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ assert(w.CrosshairLinked == false, ...
+ sprintf('default CrosshairLinked must be false, got %d', w.CrosshairLinked));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_default_crosshair_linked_is_false: %s\n', err.message);
+ end
+
+ % --- test_set_crosshair_link_true_and_false ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ w.setCrosshairLink(true);
+ assert(w.CrosshairLinked == true, 'setCrosshairLink(true) must set CrosshairLinked=true');
+ w.setCrosshairLink(false);
+ assert(w.CrosshairLinked == false, 'setCrosshairLink(false) must set CrosshairLinked=false');
+ % also accept numeric 0/1
+ w.setCrosshairLink(1);
+ assert(w.CrosshairLinked == true, 'setCrosshairLink(1) must set CrosshairLinked=true');
+ w.setCrosshairLink(0);
+ assert(w.CrosshairLinked == false, 'setCrosshairLink(0) must set CrosshairLinked=false');
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_crosshair_link_true_and_false: %s\n', err.message);
+ end
+
+ % --- test_set_crosshair_link_bad_throws ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ errId = '';
+ try
+ w.setCrosshairLink('bad');
+ catch e
+ errId = e.identifier;
+ end
+ assert(strcmp(errId, 'FastSenseWidget:invalidCrosshairLink'), ...
+ sprintf('expected FastSenseWidget:invalidCrosshairLink, got ''%s''', errId));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_crosshair_link_bad_throws: %s\n', err.message);
+ end
+
+ % --- test_to_struct_omits_when_false ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag); % default false
+ s = w.toStruct();
+ assert(~isfield(s, 'crosshairLinked'), ...
+ 'toStruct must NOT emit crosshairLinked when false (legacy JSON byte-identical)');
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_to_struct_omits_when_false: %s\n', err.message);
+ end
+
+ % --- test_to_struct_emits_true_when_set ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ w.setCrosshairLink(true);
+ s = w.toStruct();
+ assert(isfield(s, 'crosshairLinked'), ...
+ 'toStruct must emit crosshairLinked when true');
+ assert(s.crosshairLinked == true, ...
+ sprintf('crosshairLinked in struct must be true, got %d', s.crosshairLinked));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_to_struct_emits_true_when_set: %s\n', err.message);
+ end
+
+ % --- test_from_struct_restores_true ---
+ try
+ tag = makeTag_();
+ w1 = FastSenseWidget('Tag', tag);
+ w1.setCrosshairLink(true);
+ s = w1.toStruct();
+ w2 = FastSenseWidget.fromStruct(s);
+ assert(w2.CrosshairLinked == true, ...
+ sprintf('fromStruct must restore CrosshairLinked=true, got %d', w2.CrosshairLinked));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_from_struct_restores_true: %s\n', err.message);
+ end
+
+ % --- test_legacy_struct_without_field_defaults_false ---
+ try
+ s = struct('type', 'fastsense', 'title', 't', ...
+ 'position', struct('col', 1, 'row', 1, 'width', 6, 'height', 2));
+ w = FastSenseWidget.fromStruct(s);
+ assert(w.CrosshairLinked == false, ...
+ sprintf('legacy struct without crosshairLinked must default to false, got %d', ...
+ w.CrosshairLinked));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_legacy_struct_without_field_defaults_false: %s\n', err.message);
+ end
+
+ % =========================================================
+ % PURE ENGINE ENUMERATION TEST (MATLAB + Octave, no figures)
+ % =========================================================
+
+ % --- test_collect_linked_crosshairs_pure_enumeration ---
+ % Build a tiny DashboardEngine and a fake widget list to verify
+ % collectLinkedCrosshairs_ enumeration without real figures.
+ try
+ eng = DashboardEngine('TestLink');
+ % Build 3 widgets: 2 linked, 1 unlinked. None rendered (headless).
+ tag = makeTag_();
+ wA = FastSenseWidget('Tag', tag); wA.setCrosshairLink(true);
+ wB = FastSenseWidget('Tag', tag); wB.setCrosshairLink(true);
+ wC = FastSenseWidget('Tag', tag); % unlinked
+
+ % collectLinkedCrosshairs_ takes the widget list as a parameter
+ % (DashboardEngine.Widgets is SetAccess=private). With no FastSenseObj
+ % rendered it must return empty (guard: FastSenseObj must be rendered).
+ wlist = {wA, wB, wC};
+ linked = eng.collectLinkedCrosshairs_(wlist);
+ assert(isempty(linked), ...
+ sprintf('unrendered widgets must yield empty linked set; got %d', numel(linked)));
+
+ % Flip wA unlinked -> size still 0 (not rendered)
+ wA.setCrosshairLink(false);
+ linked2 = eng.collectLinkedCrosshairs_(wlist);
+ assert(isempty(linked2), ...
+ 'still empty with all unrendered widgets');
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_collect_linked_crosshairs_pure_enumeration: %s\n', err.message);
+ end
+
+ % =========================================================
+ % RENDER-GUARDED TESTS (MATLAB desktop only)
+ % =========================================================
+
+ % Skip render-guarded cases on Octave (HoverCrosshair is MATLAB-only)
+ if exist('OCTAVE_VERSION', 'builtin')
+ fprintf(' Render-guarded crosshair-link tests: SKIPPED (Octave - HoverCrosshair uses isvalid).\n');
+ fprintf(' %d passed, %d failed.\n', nPassed, nFailed);
+ if nFailed > 0
+ error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed);
+ end
+ fprintf(' All %d tests passed.\n', nPassed);
+ return;
+ end
+
+ if ~canRenderFigures_()
+ fprintf(' Render-guarded crosshair-link tests: SKIPPED (no java desktop).\n');
+ fprintf(' %d passed, %d failed.\n', nPassed, nFailed);
+ if nFailed > 0
+ error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed);
+ end
+ fprintf(' All %d tests passed.\n', nPassed);
+ return;
+ end
+
+ % --- test_suppress_leave_crux ---
+ % Core mechanism proof: onMoveExternal shows; next onLeave is suppressed;
+ % onLeaveExternal hides cleanly.
+ try
+ [fp, hFig] = makeFp_(1);
+ cleanup = onCleanup(@() safeClose_(hFig)); %#ok
+ hc = HoverCrosshair(fp);
+ t = linspace(0, 10, 500);
+ xMid = t(250);
+
+ % Drive crosshair externally (simulates being mirrored by another widget).
+ hc.onMoveExternal(xMid);
+ assert(strcmp(get(hc.hLineV, 'Visible'), 'on'), ...
+ 'onMoveExternal must show the crosshair line');
+
+ % An immediately following onLeave (same dispatch, cursor not over us)
+ % MUST be suppressed because IsMirrored_ was set by onMoveExternal.
+ hc.onLeave();
+ assert(strcmp(get(hc.hLineV, 'Visible'), 'on'), ...
+ 'onLeave immediately after onMoveExternal must be SUPPRESSED (crux)');
+
+ % onLeaveExternal clears suppress + hides.
+ hc.onLeaveExternal();
+ assert(strcmp(get(hc.hLineV, 'Visible'), 'off'), ...
+ 'onLeaveExternal must hide the crosshair');
+
+ delete(hc);
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_suppress_leave_crux: %s\n', err.message);
+ end
+
+ % --- test_broadcast_fires_once_no_reentry ---
+ % BroadcastFcn_ fires exactly once from onMove; onMoveExternal does NOT
+ % re-invoke it (InBroadcast_ gate).
+ try
+ [fp, hFig] = makeFp_(1);
+ cleanup = onCleanup(@() safeClose_(hFig)); %#ok
+ hc = HoverCrosshair(fp);
+ t = linspace(0, 10, 500);
+ xMid = t(250);
+
+ % Install a broadcast counter via setBroadcastFcn.
+ counterMap = containers.Map({'count'}, {0});
+ moveFn = @(x) incrementCounter_(counterMap);
+ leaveFn = @() [];
+ hc.setBroadcastFcn(moveFn, leaveFn);
+
+ % onMove must fire BroadcastFcn_ exactly once.
+ hc.onMove(xMid);
+ assert(counterMap('count') == 1, ...
+ sprintf('onMove must fire BroadcastFcn_ once; fired %d times', counterMap('count')));
+
+ % onMoveExternal must NOT re-invoke BroadcastFcn_ (InBroadcast_ gate).
+ hc.onMoveExternal(xMid);
+ assert(counterMap('count') == 1, ...
+ sprintf('onMoveExternal must not re-invoke BroadcastFcn_; count is %d', counterMap('count')));
+
+ delete(hc);
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_broadcast_fires_once_no_reentry: %s\n', err.message);
+ end
+
+ % --- test_two_widget_mirror_integration ---
+ % End-to-end: 2-widget dashboard, both linked, rewire, then simulate
+ % hover on A -> B mirrors; then A leaves -> B hides.
+ try
+ eng = DashboardEngine('TestLink2');
+ [fig, panA] = makeOffscreenFigure_();
+ cleanFig = onCleanup(@() safeClose_(fig)); %#ok
+
+ tag = makeTag_();
+ wA = FastSenseWidget('Tag', tag); wA.setCrosshairLink(true);
+ wB = FastSenseWidget('Tag', tag); wB.setCrosshairLink(true);
+
+ % Render wA and wB into sub-panels of our test figure.
+ panB = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0.5 0 0.5 1]);
+ panAp = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0 0 0.5 1]);
+ wA.render(panAp);
+ wB.render(panB);
+
+ % Register widgets with the engine (Widgets is SetAccess=private;
+ % addWidget accepts pre-constructed widget handles) + rewire links.
+ eng.addWidget(wA);
+ eng.addWidget(wB);
+ eng.rewireCrosshairLinks_();
+
+ % Simulate hover on A: onMove fires BroadcastFcn_ which calls
+ % broadcastCrosshairX_ -> B.onMoveExternal.
+ t = linspace(0, 10, 500);
+ xMid = t(250);
+ hcA = wA.FastSenseObj.HoverCrosshair_;
+ hcB = wB.FastSenseObj.HoverCrosshair_;
+ if ~isempty(hcA) && isvalid(hcA) && ~isempty(hcB) && isvalid(hcB)
+ hcA.onMove(xMid);
+ drawnow;
+ assert(strcmp(get(hcB.hLineV, 'Visible'), 'on'), ...
+ 'B crosshair must be visible after A onMove (mirror works)');
+
+ % A leaves -> broadcast leave -> B hides.
+ hcA.onLeave();
+ drawnow;
+ assert(strcmp(get(hcB.hLineV, 'Visible'), 'off'), ...
+ 'B crosshair must hide after A onLeave (leave-broadcast works)');
+ else
+ fprintf(' test_two_widget_mirror_integration: skipped (crosshairs not created in headless mode)\n');
+ end
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_two_widget_mirror_integration: %s\n', err.message);
+ end
+
+ % =========================================================
+ % Summary
+ % =========================================================
+ fprintf(' %d passed, %d failed.\n', nPassed, nFailed);
+ if nFailed > 0
+ error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed);
+ end
+ fprintf(' All %d tests passed.\n', nPassed);
+end
+
+% ============================ HELPERS ============================
+
+function tag = makeTag_()
+%MAKETAG_ Plain SensorTag with sinusoidal data on [0, 10].
+ t = linspace(0, 10, 500)';
+ y = sin(t);
+ tag = SensorTag('tst_xlink', 'Name', 'TstLink', 'X', t, 'Y', y);
+end
+
+function [fig, panel] = makeOffscreenFigure_()
+%MAKEOFFSCREENFIGURE_ Visible='off' figure + single uipanel for rendering.
+ fig = figure('Visible', 'off', 'Units', 'pixels', 'Position', [100 100 800 400]);
+ panel = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0 0 1 1]);
+end
+
+function [fp, hFig] = makeFp_(nLines)
+%MAKEFP_ Build a rendered FastSense in an offscreen figure.
+ hFig = figure('Visible', 'off');
+ ax = axes('Parent', hFig);
+ fp = FastSense('Parent', ax);
+ t = linspace(0, 10, 500);
+ if nLines >= 1
+ fp.addLine(t, sin(t), 'DisplayName', 'sine');
+ end
+ if nLines >= 2
+ fp.addLine(t, cos(t), 'DisplayName', 'cosine');
+ end
+ fp.render();
+end
+
+function tf = canRenderFigures_()
+%CANRENDERFIGURES_ True when MATLAB can create an invisible figure + uipanel.
+ tf = false;
+ try
+ h = figure('Visible', 'off');
+ if ishandle(h)
+ tf = true;
+ close(h, 'force');
+ end
+ catch
+ tf = false;
+ end
+end
+
+function safeClose_(fig)
+ try
+ if ~isempty(fig) && ishandle(fig)
+ close(fig, 'force');
+ end
+ catch
+ end
+end
+
+function incrementCounter_(m)
+%INCREMENTCOUNTER_ Increment the 'count' key in a containers.Map.
+ m('count') = m('count') + 1;
+end