diff --git a/.planning/STATE.md b/.planning/STATE.md
index b915d570..d45c0d4a 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -4,7 +4,7 @@ milestone: v3.0
milestone_name: FastSense Companion
status: shipped
last_updated: "2026-05-12T09:20:00.000Z"
-last_activity: 2026-05-13 -- Quick task 260513-q7w: Debounced post-resize refresh + ROOT-CAUSE ZOMBIE-PANEL FIX. After widget realization w.hPanel points to the inner content panel; rerenderWidgets was only deleting that and leaving the outer cell + WidgetButtonBar chrome alive on the canvas, stacking up zombies across multiple rerenders that painted over switched-to pages. Now deletes the outer cell (hCellPanel) properly. Canvas children stay constant at 29 across 4 rerenders + resize + tab switch.
+last_activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo.
progress:
total_phases: 6
completed_phases: 2
@@ -20,7 +20,7 @@ Phase: 1028
Plan: Not started
Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30
Status: Awaiting next milestone (run `/gsd:new-milestone` to scope v3.x or v4.0)
-Last activity: 2026-05-13 - Completed quick task 260513-q7w: Debounced two-pass post-resize refresh. Initial commit 577bf95 added the ResizeDebounceTimer + cheap update()/refresh() sweep (mirrors SliderDebounceTimer). User reported "still white at very small window sizes" — so follow-up 99c8808 made the refresh callback two-pass: pass 1 = cheap update(); pass 2 = detect any FastSenseWidget whose first line has empty XData but whose Tag has samples (the visible "white" symptom), then escalate to per-widget refresh() and, if still white, to engine-level rerenderWidgets(). Synthetic test on the live demo: forced XData=[] on a real widget's line (941→0), refreshActivePageWidgetsAfterResize_ restored it (0→941). test_dashboard_time_sync_all_pages 5/5 PASS, test_dashboard_range_selector_integration 2/2 PASS.
+Last activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo.
### Quick Tasks Completed
@@ -64,6 +64,7 @@ Last activity: 2026-05-13 - Completed quick task 260513-q7w: Debounced two-pass
| 260512-hrn | Add Follow uitoggletool to FastSenseToolbar — between Live and Metadata — with setFollow(), syncFollowState(), IsPropagating-aware auto-disengage in FastSense.onXLimChanged, AppData stash at 4 attacher sites, and 9 function-style tests (test_fastsense_follow_toggle.m) | 2026-05-12 | 596d399, 0a4a516 | — | [260512-hrn-add-follow-toggle-button-to-fastsense-to](./quick/260512-hrn-add-follow-toggle-button-to-fastsense-to/) |
| 260513-ovt | Preserve widget X and Y views across Live ticks + Follow toggle reaches every page — (1) added LiveViewMode='follow' guard inside FastSenseWidget.autoScaleY_, (2) removed `autoScaleY_(y)` from FastSenseWidget.refresh/update, (3) removed `broadcastTimeRange(tStart, tEnd)` from DashboardEngine.onLiveTick, (4) flipped FastSenseWidget.LiveViewMode default 'reset'→'preserve', (5) made FastSenseToolbar.syncFollowState public so FastSense.onXLimChanged's auto-disengage hook actually syncs the Follow button, (6) made DashboardEngine.{allPageWidgets,activePageWidgets} public + onFollowToggle uses allPageWidgets() so Follow actually flips every FastSenseWidget across all pages on multi-page dashboards (was silently no-op via swallowed MethodRestricted). Live mode is now strictly "append data only"; Follow does width-preserving slide with 2% right-edge gap. test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5, test_dashboard_range_selector_integration 2/2; verified end-to-end on industrial plant demo (Follow ON: XLim+0.140d toward tail, width preserved exactly, 2/2 widgets switched; OFF: 2/2 reverted) | 2026-05-13 | 498a5f3, ca5be95, 8d41c48, 63cdff4 | — | [260513-ovt-when-follow-button-is-pressed-y-axis-lim](./quick/260513-ovt-when-follow-button-is-pressed-y-axis-lim/) |
| 260513-q7w | Debounced post-resize refresh + ZOMBIE-PANEL fix that stops widgets going white during drag-resize and tab switching — TWO parallel timers on every figure resize event (300 ms cheap two-pass refresh + 1.2 s unconditional rerenderWidgets backstop). switchPage cancels both timers AND waits up to 3 s for in-flight rerenderWidgets to complete before mutating state. `IsRerendering_` flag prevents rerender-cascade scheduling. Re-entrancy guard aborts instead of self-rescheduling. **Root-cause fix**: rerenderWidgets now deletes the OUTER cell panel (via hCellPanel, falling back to hPanel for pre-realization widgets) — previous code deleted only `hPanel` which after realization points to the INNER content panel, leaving the outer cell + its WidgetButtonBar chrome alive on the canvas as "zombies" that stacked up over multiple rerenders and painted over freshly switched-to pages. test_dashboard_range_selector_integration 2/2, test_dashboard_time_sync_all_pages 5/5; canvas-children-count canary verifies zero zombie accumulation across 4 rerenders + resize + tab switch (constant 29) | 2026-05-13 | 577bf95, 99c8808, 4eda604, bc305dc, 54d5aa0, 20bcd4c | — | [260513-q7w-during-dashboard-figure-resize-fastsense](./quick/260513-q7w-during-dashboard-figure-resize-fastsense/) |
+| 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) |
## Progress Bar
diff --git a/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-PLAN.md b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-PLAN.md
new file mode 100644
index 00000000..5e56591b
--- /dev/null
+++ b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-PLAN.md
@@ -0,0 +1,700 @@
+---
+phase: 260513-sfp
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/Dashboard/FastSenseWidget.m
+ - libs/Dashboard/DashboardLayout.m
+ - tests/test_fastsense_widget_ylimit_modes.m
+autonomous: false # ends with a human-verify checkpoint on the live dashboard
+requirements:
+ - SFP-01 # Y-limit-mode property on FastSenseWidget (auto-visible / auto-all / locked)
+ - SFP-02 # 3 buttons rendered on the WidgetButtonBar for FastSenseWidget tiles
+ - SFP-03 # Button clicks update YLimitMode and apply Y limits without full rerender
+ - SFP-04 # Backward-compat: existing dashboards (no YLimitMode in JSON) behave as before
+ - SFP-05 # Resize / SizeChangedFcn re-anchors the new buttons alongside Info/Detach
+
+must_haves:
+ truths:
+ - "FastSenseWidget exposes a YLimitMode property with values 'auto-visible' | 'auto-all' | 'locked' and default 'auto-visible'"
+ - "Three buttons appear on the WidgetButtonBar of every FastSenseWidget tile (left of the existing Info/Detach buttons)"
+ - "Clicking the auto-visible button rescales Y to data inside the current X window"
+ - "Clicking the auto-all button rescales Y to data across the full tag range, ignoring current X window"
+ - "Clicking the locked button freezes the current Y limits; subsequent live ticks do not rescale Y"
+ - "The active mode's button shows a distinct (pressed/highlighted) background; the other two are unpressed"
+ - "Existing dashboards (no YLimitMode in JSON) load and behave exactly as before"
+ - "Other widget types (NumberWidget, StatusWidget, GroupWidget, etc.) do NOT get these buttons"
+ - "Widget panel resize keeps the three buttons anchored next to Info/Detach without overlap"
+ artifacts:
+ - path: "libs/Dashboard/FastSenseWidget.m"
+ provides: "YLimitMode property, setYLimitMode public method, mode-dispatching autoScaleY_, toStruct/fromStruct round-trip for YLimitMode"
+ contains: "YLimitMode"
+ - path: "libs/Dashboard/DashboardLayout.m"
+ provides: "addYLimitButtons_ private helper, reflowChrome_ updated to re-anchor YLimit buttons"
+ contains: "addYLimitButtons_"
+ - path: "tests/test_fastsense_widget_ylimit_modes.m"
+ provides: "Function-style test of all three modes + backward-compat default + UserZoomedY clear-on-click"
+ contains: "test_fastsense_widget_ylimit_modes"
+ key_links:
+ - from: "DashboardLayout.realizeWidget"
+ to: "addYLimitButtons_"
+ via: "duck-typed check (ismethod(widget, 'setYLimitMode'))"
+ pattern: "addYLimitButtons_\\(widget\\)"
+ - from: "WidgetButtonBar uicontrol callbacks"
+ to: "FastSenseWidget.setYLimitMode"
+ via: "@(~,~) widget.setYLimitMode('auto-visible'|'auto-all'|'locked')"
+ pattern: "setYLimitMode\\('"
+ - from: "FastSenseWidget.setYLimitMode"
+ to: "autoScaleY_"
+ via: "explicit click clears UserZoomedY, then re-dispatches autoScaleY_(y)"
+ pattern: "UserZoomedY\\s*=\\s*false"
+ - from: "DashboardLayout.reflowChrome_"
+ to: "YLimitVisibleBtn / YLimitAllBtn / YLimitLockBtn"
+ via: "findobj on bar with new Tags; re-anchor positions on cell resize"
+ pattern: "YLimit(Visible|All|Lock)Btn"
+---
+
+
+Add a small mutually-exclusive 3-button control group to the WidgetButtonBar (the per-widget grey strip) of every FastSenseWidget tile, exposing Y-axis-limit control modes the user can toggle without dropping out of the dashboard. Behaviour:
+
+- **Auto-fit visible (default)** — rescale Y to cover data inside the current X window (the new mode-routed equivalent of today's autoScaleY_)
+- **Auto-fit all** — rescale Y to cover ALL Y data the underlying Tag exposes (regardless of current X window) plus thresholds
+- **Locked** — freeze the current Y limits; live ticks do not rescale Y
+
+Purpose: MATLAB engineers want quick Y-limit control inline on the tile without having to detach or pin YLimits in the dashboard script. Lives on the WidgetButtonBar (next to the existing Info / Detach controls) so it's discoverable on every chart tile.
+
+Output:
+- New `YLimitMode` public property + `setYLimitMode` method on `FastSenseWidget`
+- New `addYLimitButtons_` helper on `DashboardLayout` (and `reflowChrome_` re-anchor support)
+- New function-style test file `tests/test_fastsense_widget_ylimit_modes.m`
+- Updated `toStruct/fromStruct` round-trip so serialized YLimitMode survives detach + JSON save/load
+- Default value `'auto-visible'` reproduces the existing on-tile Y autoscale behaviour, so dashboards built before this change behave identically
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@libs/Dashboard/FastSenseWidget.m
+@libs/Dashboard/DashboardLayout.m
+@libs/Dashboard/DashboardWidget.m
+@libs/Dashboard/DetachedMirror.m
+@libs/FastSense/FastSenseToolbar.m
+@tests/test_fastsense_follow_toggle.m
+@tests/test_fastsense_widget_tag.m
+
+
+
+
+From `libs/Dashboard/FastSenseWidget.m` (existing — DO NOT regress):
+```matlab
+% Public properties already on the widget:
+% YLimits = [] % EXPLICIT user pin — when non-empty, autoScaleY_ no-ops.
+% YLimitMode MUST yield to a non-empty YLimits
+% (backward compat with dashboards that pin Y).
+% LiveViewMode = 'preserve' % Forwarded to FastSenseObj.LiveViewMode on render.
+% When inner FastSense.LiveViewMode == 'follow',
+% autoScaleY_ already short-circuits (260513-ovt).
+% That existing guard stays; YLimitMode adds an
+% orthogonal axis ('locked' also short-circuits).
+%
+% Private state already on the widget:
+% UserZoomedY = false % Latched true when user mouse-zooms Y.
+% IsSettingYLim= false % Guard so autoScaleY_'s own set(ax,'YLim',...) does
+% NOT trip onYLimChanged into latching UserZoomedY.
+%
+% Existing method (will be REFACTORED, not removed):
+% autoScaleY_(obj, y) % Today: rescales to min/max of y + thresholds.
+% % After this plan: dispatches on YLimitMode:
+% % 'auto-visible' -> existing behaviour (use y arg as-is)
+% % 'auto-all' -> fetch full y from obj.Tag.getXY()
+% % (ignore the y arg)
+% % 'locked' -> no-op
+% onYLimChanged(obj) % YLim PostSet listener; latches UserZoomedY when
+% change source != IsSettingYLim.
+%
+% toStruct(obj) already emits s.yLimits when YLimits non-empty.
+% fromStruct(s) already reads s.yLimits.
+% Both must be extended with s.yLimitMode round-trip in this plan.
+```
+
+From `libs/Dashboard/DashboardLayout.m` (existing helpers — pattern to mirror):
+```matlab
+% --- realizeWidget (line ~337) — entry point for per-widget chrome
+% if needsBar
+% obj.getOrCreateButtonBar_(widget);
+% contentPanel = obj.createContentPanel_(widget);
+% widget.render(contentPanel);
+% if ~isempty(widget.Description), obj.addInfoIcon(widget); end
+% if ~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget'),
+% obj.addDetachButton(widget); end
+% end
+%
+% --- getOrCreateButtonBar_(widget) -> returns the bar uipanel
+% (Tag='WidgetButtonBar', 28px tall, full-width, inset 2px)
+%
+% --- addInfoIcon(widget)
+% Right-anchored at `xInfo = barPos(3) - 28 - 28 - 4` (Tag='InfoIconButton', 24x24)
+% --- addDetachButton(widget)
+% Right-anchored at `xDet = barPos(3) - 24 - 4` (Tag='DetachButton', 24x24)
+%
+% --- reflowChrome_(hCell, barH, inset) -- STATIC -- SizeChangedFcn handler
+% findobj on Tags 'DetachButton', 'InfoIconButton' and re-anchors to barW-relative
+% positions. Must be extended to also re-anchor YLimitVisibleBtn / YLimitAllBtn /
+% YLimitLockBtn (Tags below).
+```
+
+Right-anchor layout AFTER this plan (left to right inside the bar, from far left of the cluster):
+```
+[ YLimit-Visible ][ YLimit-All ][ YLimit-Lock ] ...gap... [ Info ][ Detach ]
+ 24 24 24 24 24
+```
+With a 4 px gap between the YLimit cluster and the existing Info/Detach cluster.
+
+Exact pixel positions inside the bar (barW = bar uipanel Position(3)):
+- Detach: x = barW - 24 - 4 (UNCHANGED)
+- Info: x = barW - 24 - 24 - 4 - 4 (UNCHANGED; preserves the 4px gap reflowChrome_ already uses)
+- YLimit-Lock: x = barW - 24 - 24 - 4 - 4 - 4 - 24 (NEW)
+- YLimit-All: x = barW - 24 - 24 - 4 - 4 - 4 - 24 - 24 (NEW)
+- YLimit-Visible: x = barW - 24 - 24 - 4 - 4 - 4 - 24 - 24 - 24 (NEW)
+(Y offset = 2 for every button, height = 24, matching Info/Detach.)
+
+NOTE: addInfoIcon already uses `barW - 28 - 28 - 4` (a typo from earlier work that
+treats button width as 28). For this plan, use 24+24+4 spacing for the new buttons.
+Do NOT "fix" the Info button — that is OUT OF SCOPE.
+
+From `libs/Dashboard/DetachedMirror.m`:
+```matlab
+% DetachedMirror.render() parents the cloned widget into a full-figure panel
+% (no WidgetButtonBar — that's a DashboardLayout concept). The detached mirror
+% relies on the figure-level FastSenseToolbar for chrome. ADDING YLIMIT BUTTONS
+% TO THE FIGURE-LEVEL TOOLBAR IS OUT OF SCOPE FOR THIS QUICK TASK.
+%
+% Verification: in the detached figure, the user already has standard MATLAB
+% axes-toolbar zoom controls + the FastSenseToolbar's Follow/Live buttons.
+% That's enough for v1. We surface this trade-off in the verification checkpoint.
+```
+
+Button glyphs (ASCII, since the codebase uses ASCII strings for Info='i' and Detach='^'):
+- YLimit-Visible: `'V'` (TooltipString: 'Auto-fit Y to visible X range')
+- YLimit-All: `'A'` (TooltipString: 'Auto-fit Y to all data')
+- YLimit-Lock: `'L'` (TooltipString: 'Lock Y limits (no rescale on live tick)')
+
+NOTE: choose ASCII because the existing buttons use ASCII strings ('i', '^') and
+Octave's font rendering on Linux for unicode glyphs in `String` is inconsistent
+across versions; the existing widget toolbar deliberately avoids unicode.
+
+Active-mode visual state:
+- Active button: `BackgroundColor = theme.PressedBg` if the theme defines it,
+ else `BackgroundColor = theme.SelectedBg` if defined,
+ else `BackgroundColor = theme.AccentColor` if defined,
+ else fall back to brightening `theme.ToolbarBackground` by 0.15
+ via `min(theme.ToolbarBackground + 0.15, 1)`.
+- Inactive buttons: `BackgroundColor = theme.ToolbarBackground` (matches Info/Detach).
+
+(Pick the first theme field that exists; do NOT add new theme fields in this quick task.)
+
+
+
+
+
+
+
+ Task 1: Add YLimitMode property + setYLimitMode method + mode-dispatching autoScaleY_ on FastSenseWidget, with tests
+ libs/Dashboard/FastSenseWidget.m, tests/test_fastsense_widget_ylimit_modes.m
+
+
+ Tests in tests/test_fastsense_widget_ylimit_modes.m must cover (function-style,
+ one nested function per case, follow the test_fastsense_follow_toggle.m shape):
+
+ - test_default_y_limit_mode_is_auto_visible:
+ w = FastSenseWidget('Tag', sensorTag);
+ assert(strcmp(w.YLimitMode, 'auto-visible'))
+ - test_set_y_limit_mode_validates:
+ w.setYLimitMode('bogus') must error with id 'FastSenseWidget:invalidYLimitMode'
+ - test_set_y_limit_mode_visible_rescales_to_window:
+ Render widget on a tag with a synthetic step (y in [0,10] for x<5, y in [0,100] for x>=5).
+ Pan XLim to [0, 4] (only the [0,10] half visible).
+ w.setYLimitMode('auto-visible'); assert(YLim covers ~[0,10] +/- padding, NOT [0,100]).
+ - test_set_y_limit_mode_all_rescales_to_full_data:
+ Same synthetic tag and panned XLim as above.
+ w.setYLimitMode('auto-all'); assert(YLim covers ~[0,100] +/- padding, regardless of XLim).
+ - test_set_y_limit_mode_locked_freezes_y:
+ Render and grab YLim Y0 = get(ax,'YLim').
+ w.setYLimitMode('locked'); call w.update() (or w.autoScaleY_(newY) directly) with
+ new y data that would have rescaled in 'auto-visible' mode.
+ assert(isequal(get(ax,'YLim'), Y0))
+ - test_set_y_limit_mode_clears_user_zoomed_y:
+ Render widget; manually set obj.UserZoomedY = true (latch as if user mouse-zoomed).
+ Call w.setYLimitMode('auto-visible'); assert(obj.UserZoomedY == false)
+ AND assert YLim is the auto-fit value (i.e. explicit click re-engages autoscale).
+ - test_y_limits_pin_wins_over_y_limit_mode:
+ w = FastSenseWidget('Tag', sensorTag, 'YLimits', [0 1000]);
+ w.setYLimitMode('auto-visible');
+ Render; YLim must still be exactly [0 1000] (the explicit pin wins).
+ - test_to_struct_from_struct_round_trips_y_limit_mode:
+ w1.setYLimitMode('locked'); s = w1.toStruct();
+ w2 = FastSenseWidget.fromStruct(s); assert(strcmp(w2.YLimitMode, 'locked'))
+ - test_legacy_struct_without_y_limit_mode_defaults_to_auto_visible:
+ s = struct('type','fastsense','title','t','position',struct('col',1,'row',1,'width',6,'height',2));
+ w = FastSenseWidget.fromStruct(s); assert(strcmp(w.YLimitMode, 'auto-visible'))
+ - test_follow_mode_still_short_circuits_autoscale:
+ 260513-ovt regression guard. With FastSenseObj.LiveViewMode='follow' AND
+ YLimitMode='auto-visible', a refresh()/autoScaleY_ call must NOT rescale Y.
+ (The Follow toggle's explicit "freeze view in X+Y" intent still wins.)
+
+ All tests must:
+ - Begin with `addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();`
+ - Wrap each case in try/catch and increment nPassed/nFailed counters
+ - Close all figures via `cleanupAll = onCleanup(@() close('all','force'))`
+ - Print `All N tests passed.` on success
+ - Skip gracefully on headless (`if ~usejava('desktop') ... return; end`) if and only if
+ uipanel rendering is required — most cases can run headless via offscreen figure.
+
+
+
+ Edit libs/Dashboard/FastSenseWidget.m:
+
+ 1. Add to the public `properties (Access = public)` block, immediately after `LiveViewMode`:
+ ```matlab
+ % YLimitMode — Y-axis rescale strategy applied by autoScaleY_:
+ % 'auto-visible' (DEFAULT) — rescale to cover data inside the current X
+ % window. Reproduces the pre-260513-sfp behaviour
+ % (so old dashboards behave identically).
+ % 'auto-all' — rescale to cover ALL data the bound Tag exposes,
+ % regardless of current XLim. Equivalent to a
+ % global "fit Y to the whole timeline" command.
+ % 'locked' — freeze current YLim. Live ticks / refresh /
+ % update no longer call set(ax,'YLim', ...).
+ %
+ % Always yields to a non-empty `YLimits` pin (explicit numeric pin wins).
+ % Always yields to `FastSenseObj.LiveViewMode == 'follow'` (Follow toggle wins;
+ % 260513-ovt).
+ YLimitMode = 'auto-visible'
+ ```
+
+ 2. Add a new public method `setYLimitMode(obj, mode)` near `setEventMarkersVisible`:
+ - Validate mode is one of `{'auto-visible','auto-all','locked'}`; else
+ `error('FastSenseWidget:invalidYLimitMode', ...)`.
+ - Persist `obj.YLimitMode = mode`.
+ - **Clear `obj.UserZoomedY = false`** (explicit click re-engages autoscale).
+ - If FastSenseObj is rendered, fetch y data appropriate for the new mode
+ and call `obj.autoScaleY_(y)`:
+ - 'auto-visible': `y = obj.getYInVisibleXWindow_()` (new private helper, below)
+ - 'auto-all': `[~, y] = obj.Tag.getXY()` (fall back to obj.YData
+ when no Tag bound)
+ - 'locked': call `obj.autoScaleY_([])` — the autoScaleY_ refactor
+ (next step) treats 'locked' mode as a no-op regardless
+ of the y argument.
+
+ 3. Refactor `autoScaleY_(obj, y)` to dispatch on `obj.YLimitMode`:
+ - Keep the early-return guards in order: `~isempty(YLimits)` -> return;
+ `UserZoomedY` -> return; `FastSenseObj.LiveViewMode == 'follow'` -> return;
+ not-rendered / no-axes -> return.
+ - After those guards, dispatch:
+ - `case 'locked'`: return (no rescale).
+ - `case 'auto-all'`: replace `y` with full data from `obj.Tag.getXY()`
+ (or fall back to `obj.YData` for inline-bound widgets); then run the
+ existing min/max/threshold/pad code path against the FULL y.
+ - `case 'auto-visible'` (or default): use the `y` argument as-is
+ (existing behaviour). Filter `y` to the current XLim window when
+ possible: get current XLim from `obj.FastSenseObj.hAxes`, then
+ `mask = x >= xl(1) & x <= xl(2); yWin = y(mask);` — but ONLY do this
+ if the caller passed BOTH x and y. To avoid changing the function
+ signature, extract a small private helper `getYInVisibleXWindow_()`
+ that performs the filtering using `obj.Tag.getXY()` directly, and have
+ refresh()/update() pass the windowed y into autoScaleY_ when YLimitMode
+ == 'auto-visible' (or simply call `obj.getYInVisibleXWindow_()` once at
+ the start of autoScaleY_ when mode is auto-visible AND y is "all data"
+ — implementation detail: do whichever is simpler without changing
+ refresh/update call sites).
+ - The threshold-extension code (yMin = min(yMin, threshold.Value), etc.) and
+ padding logic stays as today.
+
+ 4. Add private helper `getYInVisibleXWindow_(obj)` to the `methods (Access = private)`
+ block that returns the y values whose x falls inside the current
+ FastSenseObj.hAxes XLim window. Falls back to `obj.Tag.getXY()`'s full y when
+ XLim is unavailable.
+
+ 5. Extend `toStruct(obj)` to emit `s.yLimitMode` only when `YLimitMode != 'auto-visible'`
+ (so we don't write the default into every serialized dashboard — keeps JSON small
+ AND keeps old-dashboard diffs invisible).
+
+ 6. Extend the `fromStruct(s)` static to read `s.yLimitMode` when present, otherwise
+ leave the default 'auto-visible' in place.
+
+ 7. **Defensive: do NOT change the public signature of autoScaleY_** — it stays
+ `autoScaleY_(obj, y)`. Existing callers (`render`, `rebuildForTag_`) keep working
+ unchanged. The new dispatch is purely internal.
+
+ Implementation notes for the executor:
+ - Match existing naming conventions: properties PascalCase (`YLimitMode`), public
+ methods camelCase (`setYLimitMode`), private helpers trailing-underscore camelCase
+ (`getYInVisibleXWindow_`). Error IDs namespaced `FastSenseWidget:*`.
+ - All new code stays toolbox-free. No `validatestring` if Octave compatibility is
+ shaky — use an explicit `if ~ismember(mode, {...}) error(...); end`.
+ - Run `mh_style` / `mh_lint` mentally — keep lines <= 160 chars, 4-space indent.
+ - Comments must explain the WHY (interaction with YLimits / LiveViewMode / UserZoomedY),
+ not just the WHAT.
+
+ Then create tests/test_fastsense_widget_ylimit_modes.m following the
+ test_fastsense_follow_toggle.m shape (see for the cases).
+
+
+
+
+ Run the new test file via mcp__matlab__run_matlab_test_file:
+ tests/test_fastsense_widget_ylimit_modes.m
+ Expected: prints "All N tests passed." where N = number of cases above (>= 9).
+
+ Then run two regression suites to confirm no breakage:
+ tests/test_fastsense_follow_toggle.m -> expect 10/10 pass
+ tests/test_fastsense_widget_tag.m -> expect all pass (8 cases)
+
+
+
+
+ - FastSenseWidget.YLimitMode property exists with default 'auto-visible'
+ - setYLimitMode(mode) validates and updates the property AND clears UserZoomedY
+ - autoScaleY_ dispatches correctly:
+ - 'auto-visible': rescales to current X window (regression of old behaviour)
+ - 'auto-all' : rescales to full tag data
+ - 'locked' : no-op
+ - YLimits pin (non-empty) wins over YLimitMode
+ - Follow mode (LiveViewMode=='follow') still short-circuits autoScaleY_
+ - toStruct/fromStruct round-trips YLimitMode
+ - Old serialized dashboards (no yLimitMode key) default to 'auto-visible'
+ - tests/test_fastsense_widget_ylimit_modes.m passes all cases (>= 9)
+ - tests/test_fastsense_follow_toggle.m passes (10/10) — no 260513-ovt regression
+ - tests/test_fastsense_widget_tag.m passes — no widget-tag-binding regression
+
+
+
+
+ Task 2: Add 3-button YLimit cluster to WidgetButtonBar via DashboardLayout.addYLimitButtons_
+ libs/Dashboard/DashboardLayout.m
+
+
+ Edit libs/Dashboard/DashboardLayout.m:
+
+ 1. In `realizeWidget` (~line 366, inside the `if needsBar` block), AFTER the
+ existing `addInfoIcon` / `addDetachButton` calls, add a duck-typed inject:
+
+ ```matlab
+ % 260513-sfp — per-widget Y-limit-mode buttons (only widgets that
+ % implement setYLimitMode get this cluster; today that is FastSenseWidget
+ % but the duck-type check keeps the chrome generic for any future widget
+ % that exposes Y-rescale modes).
+ if ismethod(widget, 'setYLimitMode')
+ obj.addYLimitButtons_(widget);
+ end
+ ```
+
+ IMPORTANT: this MUST run inside the `needsBar` branch only — when a widget
+ skips chrome (no Description AND no DetachCallback) we have no bar to host
+ these buttons either. That's acceptable for v1 because every dashboard that
+ loads FastSenseWidget through DashboardEngine sets a DetachCallback, so
+ `needsBar` is always true for FastSenseWidget in practice.
+
+ 2. Update the `needsBar` check (~line 363) to ALSO consider widgets that
+ expose setYLimitMode, so a dashboard that someday omits DetachCallback
+ still gets a bar for the YLimit buttons:
+
+ ```matlab
+ needsBar = ~isempty(widget.Description) || ...
+ (~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget')) || ...
+ ismethod(widget, 'setYLimitMode');
+ ```
+
+ 3. Add private method `addYLimitButtons_(obj, widget)` next to `addDetachButton`,
+ mirroring the addInfoIcon shape. Pseudocode:
+
+ ```matlab
+ function addYLimitButtons_(obj, widget)
+ %ADDYLIMITBUTTONS_ Inject the 3-button Y-limit-mode cluster into the bar.
+ % Only invoked from realizeWidget when ismethod(widget,'setYLimitMode').
+ % Buttons are left-anchored relative to the EXISTING right-anchored
+ % Info/Detach buttons (with a 4-px gap between the clusters).
+ if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme)
+ theme = DashboardTheme('light');
+ else
+ theme = widget.ParentTheme;
+ end
+ bar = obj.getOrCreateButtonBar_(widget);
+ barPos = get(bar, 'Position');
+ barW = barPos(3);
+
+ % Layout: [V][A][L] ... 4px gap ... [Info][Detach]
+ bw = 24;
+ gap = 4;
+ xLock = barW - bw - gap - bw - gap - gap - bw; % Lock leftmost in cluster's right side
+ xAll = xLock - bw;
+ xVisible = xAll - bw;
+
+ % Compute active / inactive backgrounds (see ):
+ activeBg = chooseActiveBg_(theme);
+
+ obj.addYLimitButton_(bar, widget, 'auto-visible', xVisible, 'V', ...
+ 'Auto-fit Y to visible X range', activeBg, theme, ...
+ 'YLimitVisibleBtn');
+ obj.addYLimitButton_(bar, widget, 'auto-all', xAll, 'A', ...
+ 'Auto-fit Y to all data', activeBg, theme, ...
+ 'YLimitAllBtn');
+ obj.addYLimitButton_(bar, widget, 'locked', xLock, 'L', ...
+ 'Lock Y limits (no rescale)', activeBg, theme, ...
+ 'YLimitLockBtn');
+
+ % Persist the active-bg + per-button tags on the bar's UserData so
+ % reflowChrome_ can re-anchor + restyle without re-resolving the theme.
+ ud = get(bar, 'UserData');
+ if ~isstruct(ud), ud = struct(); end
+ ud.YLimitActiveBg = activeBg;
+ ud.YLimitWidget = widget; % weak ref — invalidated by widget delete
+ set(bar, 'UserData', ud);
+
+ % Initial pressed state: highlight the button matching widget.YLimitMode.
+ obj.syncYLimitButtonsState_(bar, widget.YLimitMode);
+ end
+ ```
+
+ Helper `addYLimitButton_(bar, widget, mode, x, glyph, tip, activeBg, theme, tag)`:
+ creates a uicontrol pushbutton on `bar` with Tag=tag, String=glyph, the standard
+ size [x 2 24 24], `Callback = @(~,~) onYLimitButtonClicked_(obj, widget, mode, bar)`.
+
+ Helper `onYLimitButtonClicked_(obj, widget, mode, bar)`:
+ - Call `widget.setYLimitMode(mode)` (this clears UserZoomedY and applies Y).
+ - Call `obj.syncYLimitButtonsState_(bar, mode)` to update visual pressed state.
+ - Wrap in try/catch; on failure, `warning('DashboardLayout:yLimitClickFailed', ...)`.
+
+ Helper `syncYLimitButtonsState_(bar, mode)`:
+ - For each Tag in {'YLimitVisibleBtn','YLimitAllBtn','YLimitLockBtn'}:
+ - findobj on bar; if its mode matches `mode`, set BackgroundColor to activeBg
+ (read from `get(bar,'UserData').YLimitActiveBg`).
+ - Else set BackgroundColor to `theme.ToolbarBackground`
+ (resolve theme via the bar's parent->widget chain, or stash on UserData too).
+
+ Local nested function `chooseActiveBg_(theme)`:
+ - Try fields in order: 'PressedBg', 'SelectedBg', 'AccentColor'.
+ - Fallback: `min(theme.ToolbarBackground + 0.15, 1)` per-channel.
+
+ 4. Update `reflowChrome_` (~line 754) — the static SizeChangedFcn handler — to
+ also re-anchor the three new buttons. Inside the existing
+ `if ~isempty(bar) && ishandle(bar(1))` block, after the Info / Detach
+ re-anchor lines, add:
+
+ ```matlab
+ bw = 24; gap = 4;
+ lockBtn = findobj(bar(1), 'Tag', 'YLimitLockBtn', '-depth', 1);
+ allBtn = findobj(bar(1), 'Tag', 'YLimitAllBtn', '-depth', 1);
+ visibleBtn = findobj(bar(1), 'Tag', 'YLimitVisibleBtn', '-depth', 1);
+ xLock = barW - bw - gap - bw - gap - gap - bw;
+ xAll = xLock - bw;
+ xVisible = xAll - bw;
+ if ~isempty(lockBtn) && ishandle(lockBtn(1)),
+ set(lockBtn(1), 'Position', [xLock, 2, bw, bw]);
+ end
+ if ~isempty(allBtn) && ishandle(allBtn(1)),
+ set(allBtn(1), 'Position', [xAll, 2, bw, bw]);
+ end
+ if ~isempty(visibleBtn) && ishandle(visibleBtn(1)),
+ set(visibleBtn(1), 'Position', [xVisible, 2, bw, bw]);
+ end
+ ```
+
+ 5. Extend the `protectedTags` cell array in `DashboardWidget.clearPanelControls`
+ (libs/Dashboard/DashboardWidget.m) to preserve the new button tags during
+ any uicontrol sweep:
+ ```matlab
+ protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar', ...
+ 'YLimitVisibleBtn', 'YLimitAllBtn', 'YLimitLockBtn'};
+ ```
+ (This is the ONLY edit to DashboardWidget.m. Add this file to files_modified
+ for THIS task if not already in the frontmatter at the plan level.)
+
+ Implementation notes:
+ - DO NOT modify `addInfoIcon` or `addDetachButton` — they stay byte-identical.
+ - DO NOT add new theme fields — fall back via chooseActiveBg_'s lookup chain.
+ - DO NOT add the cluster to `DetachedMirror` — out of scope.
+ - The duck-type check (`ismethod(widget, 'setYLimitMode')`) lets future widgets
+ opt in without modifying DashboardLayout.
+
+
+
+
+ Run the test from Task 1 again — it must still pass unchanged:
+ tests/test_fastsense_widget_ylimit_modes.m
+
+ Plus a smoke check via mcp__matlab__evaluate_matlab_code:
+ ```matlab
+ install();
+ figVis = false;
+ try, figVis = usejava('desktop'); catch, end
+ if figVis
+ % Build a 1-widget dashboard with a FastSenseWidget and assert the
+ % three buttons exist on its WidgetButtonBar.
+ x = (1:1000)'; y = sin(x/50)';
+ tag = SensorTag('Name','Sm', 'Key','sm', 'XData', x, 'YData', y);
+ w = FastSenseWidget('Tag', tag, 'Title', 'Sm', 'Description', 'demo');
+ d = DashboardEngine('Widgets', {w}, 'Title', 'sfp-smoke');
+ d.render();
+ bar = findobj(w.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1);
+ assert(~isempty(findobj(bar, 'Tag', 'YLimitVisibleBtn')), 'V button missing');
+ assert(~isempty(findobj(bar, 'Tag', 'YLimitAllBtn')), 'A button missing');
+ assert(~isempty(findobj(bar, 'Tag', 'YLimitLockBtn')), 'L button missing');
+ fprintf('smoke OK\n');
+ close(d.hFigure);
+ else
+ fprintf('smoke skipped (no desktop)\n');
+ end
+ ```
+
+ Plus regression: tests/test_dashboard_time_sync_all_pages.m must still pass
+ (the WidgetButtonBar chrome is exercised on multi-page dashboards), and
+ tests/test_dashboard_range_selector_integration.m must still pass.
+
+
+
+
+ - DashboardLayout.addYLimitButtons_ exists and is private
+ - realizeWidget invokes it when ismethod(widget,'setYLimitMode'), inside the needsBar branch
+ - needsBar additionally returns true for widgets exposing setYLimitMode
+ - Three 24x24 uicontrol pushbuttons (Tags YLimitVisibleBtn / YLimitAllBtn / YLimitLockBtn)
+ render on the WidgetButtonBar of every FastSenseWidget tile
+ - Buttons are left-anchored relative to Info/Detach with a 4px gap; do not overlap
+ - Clicking a button calls widget.setYLimitMode(mode) AND visually highlights it
+ (PressedBg / SelectedBg / AccentColor / brightened ToolbarBackground fallback)
+ - reflowChrome_ re-anchors all three new buttons on cell resize
+ - DashboardWidget.clearPanelControls preserves all three new tags
+ - tests/test_fastsense_widget_ylimit_modes.m still passes (10+ cases)
+ - tests/test_dashboard_time_sync_all_pages.m passes (5/5)
+ - tests/test_dashboard_range_selector_integration.m passes (2/2)
+ - Smoke evaluate confirms the three buttons exist on a live demo dashboard
+
+
+
+
+
+ Three small buttons (V / A / L) on the grey WidgetButtonBar of every FastSenseWidget
+ tile, left of the existing Info ('i') and Detach ('^') buttons.
+
+ - **V** — auto-fit Y to data inside the visible X window (default mode)
+ - **A** — auto-fit Y to all data exposed by the bound Tag
+ - **L** — lock current Y limits (no further rescale on live tick)
+
+ The active mode is visually highlighted (pressed-style background). The buttons
+ re-anchor correctly on widget panel resize. Backward compat: existing dashboards
+ (no `YLimitMode` in JSON) behave exactly as before — they default to auto-visible.
+
+ OUT OF SCOPE for this quick task:
+ - Adding the cluster to detached-mirror windows (DetachedMirror uses its own
+ figure-level FastSenseToolbar; can be a follow-up)
+ - Adding a "Symmetric zero" 4th mode
+ - Adding the cluster to NumberWidget / StatusWidget / etc. — these don't have
+ a setYLimitMode method, so the duck-type check excludes them by design
+
+
+
+ Run the industrial-plant demo:
+ ```matlab
+ install();
+ demo/industrial_plant/run_demo
+ ```
+
+ On any FastSenseWidget tile (e.g. reactor.pressure), the grey strip across the
+ top should now show, from left to right: [V][A][L] ... gap ... [i][^]
+
+ Test the modes:
+
+ 1. **Default state** — open the demo. Without clicking anything, the V button
+ should be pressed/highlighted. The Y axis should auto-scale to data inside
+ the current X window as before. (Backward-compat regression check.)
+
+ 2. **Auto-fit visible (V)** — pan/zoom the X axis so only a narrow window is
+ visible. Click V. Y should snap to fit just that window's data + padding.
+
+ 3. **Auto-fit all (A)** — click A. Y should expand to cover the FULL Y range
+ of the underlying tag (regardless of current X zoom). Useful for putting
+ a value-spike in context.
+
+ 4. **Locked (L)** — set a Y range via V or A, then click L. The L button should
+ press in. Let live mode tick for ~30 seconds (start Live from the dashboard
+ toolbar). The Y limits should NOT rescale even when new data falls outside
+ the current Y range. Click V to unlock.
+
+ 5. **User mouse-zoom override** — while in V mode, scroll-wheel zoom the Y axis.
+ The V button stays visually pressed but autoScale stops (because UserZoomedY
+ latched). Click V again — autoScaleY_ re-engages (UserZoomedY cleared on click).
+
+ 6. **Resize** — drag the figure window edge. The three buttons should stay
+ anchored to the right side of the bar (alongside Info/Detach) without
+ overlapping each other.
+
+ 7. **Detach** — click the ^ button on a tile. The detached mirror window opens.
+ The mirror does NOT need to show these buttons (out of scope, see above).
+ Verify nothing crashes and the detached widget still updates on live tick.
+
+ 8. **Backward compat** — open an older dashboard (any v3.0 demo). All tiles
+ should behave as before. The V mode should be active by default.
+
+ Run tests one more time to be sure:
+ ```matlab
+ cd tests
+ run_matlab_test_file('test_fastsense_widget_ylimit_modes.m');
+ run_matlab_test_file('test_fastsense_follow_toggle.m');
+ run_matlab_test_file('test_dashboard_time_sync_all_pages.m');
+ ```
+
+
+
+ Type "approved" if the three buttons behave as described and all regression
+ tests pass, OR describe any issues (visual misalignment, mode misbehavior,
+ test failures, resize bugs).
+
+
+
+
+
+
+- New property `YLimitMode` exists on `FastSenseWidget` with default `'auto-visible'`
+- `setYLimitMode` validates and dispatches; clears `UserZoomedY` on explicit click
+- `autoScaleY_` dispatches on mode without changing its public signature
+- Backward compat: explicit numeric `YLimits` pin still wins; Follow mode still wins
+- Serialization round-trips `YLimitMode`; legacy dashboards default to `'auto-visible'`
+- Three uicontrol buttons appear on `WidgetButtonBar` for every `FastSenseWidget` tile
+- Active mode button is visually highlighted; only one is active at a time
+- Resize re-anchors the new buttons via `reflowChrome_`
+- `DashboardWidget.clearPanelControls` protects the three new tags
+- Tests pass:
+ - tests/test_fastsense_widget_ylimit_modes.m (new, >= 9 cases)
+ - tests/test_fastsense_follow_toggle.m (regression, 10/10)
+ - tests/test_fastsense_widget_tag.m (regression)
+ - tests/test_dashboard_time_sync_all_pages.m (regression, 5/5)
+ - tests/test_dashboard_range_selector_integration.m (regression, 2/2)
+- Manual demo verification of all 8 scenarios from the checkpoint
+
+
+
+1. The three buttons exist on every FastSenseWidget tile on the embedded dashboard
+2. Clicking a button updates `YLimitMode` and immediately reflects on the chart's Y axis
+3. The active button is highlighted, the other two are not (single-selection visual)
+4. Resize preserves alignment with Info/Detach (no overlap, no gap regression)
+5. Old dashboards behave identically (default mode = auto-visible reproduces pre-260513-sfp)
+6. Other widget types (NumberWidget, etc.) get no YLimit buttons
+7. All test files listed in verification pass
+8. No new MATLAB warnings on dashboard render
+
+
+
diff --git a/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-SUMMARY.md b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-SUMMARY.md
new file mode 100644
index 00000000..7e0ecdab
--- /dev/null
+++ b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-SUMMARY.md
@@ -0,0 +1,162 @@
+---
+phase: 260513-sfp
+plan: 01
+subsystem: Dashboard
+tags: [dashboard, fastsense-widget, widget-button-bar, y-axis, ui-chrome]
+type: quick
+status: verified
+requirements:
+ - SFP-01 # YLimitMode property on FastSenseWidget (auto-visible / auto-all / locked)
+ - SFP-02 # 3 buttons rendered on the WidgetButtonBar for FastSenseWidget tiles
+ - SFP-03 # Button clicks update YLimitMode and apply Y limits without full rerender
+ - SFP-04 # Backward-compat: existing dashboards (no YLimitMode in JSON) behave as before
+ - SFP-05 # Resize / SizeChangedFcn re-anchors the new buttons alongside Info/Detach
+dependency_graph:
+ requires:
+ - "FastSenseWidget (YLimits pin, UserZoomedY latch, IsSettingYLim guard, autoScaleY_)"
+ - "FastSense.LiveViewMode='follow' guard (260513-ovt — must remain authoritative)"
+ - "DashboardLayout.realizeWidget / reflowChrome_ / getOrCreateButtonBar_ / addInfoIcon / addDetachButton"
+ - "DashboardWidget.clearPanelControls protectedTags allow-list"
+ provides:
+ - "FastSenseWidget.YLimitMode public property + setYLimitMode public method"
+ - "FastSenseWidget.autoScaleY_ mode dispatch (auto-visible / auto-all / locked) — public signature unchanged"
+ - "DashboardLayout.addYLimitButtons_ (duck-typed via ismethod(widget,'setYLimitMode'))"
+ - "DashboardLayout.{addYLimitButton_, onYLimitButtonClicked_, syncYLimitButtonsState_, chooseYLimitActiveBg_}"
+ - "YLimitMode JSON round-trip in DashboardEngine save/load (default omitted to keep diffs invisible)"
+ affects:
+ - "Every FastSenseWidget tile inside DashboardEngine (visible 3-button cluster on the WidgetButtonBar)"
+ - "DashboardWidget.clearPanelControls (extended protected-tag list)"
+tech_stack:
+ added: []
+ patterns:
+ - "Duck-typed widget chrome injection (ismethod check, not isa)"
+ - "Mode-dispatching method with preserved public signature (autoScaleY_(obj, y))"
+ - "Theme-field fallback chain (PressedBg / SelectedBg / AccentColor / brightened ToolbarBackground)"
+ - "JSON-default omission to keep legacy dashboards diff-invisible"
+key_files:
+ created:
+ - "tests/test_fastsense_widget_ylimit_modes.m"
+ modified:
+ - "libs/Dashboard/FastSenseWidget.m"
+ - "libs/Dashboard/DashboardLayout.m"
+ - "libs/Dashboard/DashboardWidget.m"
+decisions:
+ - "Mode dispatch lives inside autoScaleY_(obj, y) rather than at the caller — keeps public signature stable; render(), rebuildForTag_(), and refresh() callers do not change"
+ - "Buttons inject via duck-typed ismethod(widget,'setYLimitMode') rather than isa(widget,'FastSenseWidget') — future widgets that expose Y-rescale modes (e.g., 2D heatmap with Z-clamp) get the chrome for free"
+ - "ASCII glyphs V/A/L instead of Unicode — existing Info ('i') and Detach ('^') buttons are ASCII; Octave's font rendering on Linux for Unicode in uicontrol String is inconsistent across versions"
+ - "Default YLimitMode='auto-visible' reproduces pre-260513-sfp behaviour exactly — old dashboards load with no behavioural change AND no JSON diff (toStruct omits the default)"
+ - "LiveViewMode='follow' precedence preserved — autoScaleY_ still short-circuits on Follow regardless of YLimitMode, so 260513-ovt's Follow-toggle semantics are untouched"
+ - "Explicit YLimits pin (non-empty) still wins over YLimitMode — back-compat with dashboards that pin Y limits explicitly"
+ - "Click handler clears UserZoomedY so an explicit mode click re-engages autoscale (otherwise the user's prior mouse-zoom would latch the axis forever)"
+ - "No new theme tokens — chooseYLimitActiveBg_ falls through PressedBg / SelectedBg / AccentColor / brightened ToolbarBackground. Leaves a clean follow-up if reviewers want a proper theme.PressedBg field"
+ - "Click handler catches and warns ('DashboardLayout:yLimitClickFailed') rather than throws — never crash the refresh loop on a button click failure"
+metrics:
+ duration_minutes: ~22
+ completed: "2026-05-13"
+---
+
+# Quick Task 260513-sfp: Auto Y-Limit Control Buttons (V/A/L) on FastSenseWidget — Summary
+
+One-liner: Adds a 3-button mutually-exclusive Y-axis rescale cluster (auto-visible / auto-all / locked) to the WidgetButtonBar of every FastSenseWidget tile, exposing on-tile Y-limit control without forcing detach or YLimits pinning in the dashboard script.
+
+## What Was Built
+
+Three small uicontrol pushbuttons (`V`, `A`, `L`) on the per-widget grey chrome strip of every `FastSenseWidget` tile, left of the existing Info (`i`) and Detach (`^`) buttons:
+
+- **V — auto-visible (default)** — rescale Y to cover data inside the current X window (the new mode-routed equivalent of the pre-260513-sfp `autoScaleY_` behaviour)
+- **A — auto-all** — rescale Y to cover ALL Y data the underlying Tag exposes, regardless of current XLim — useful for putting a spike in global context
+- **L — locked** — freeze the current YLim; live ticks no longer rescale Y
+
+The active mode's button shows a distinct pressed/highlighted background; the other two stay at `theme.ToolbarBackground`. Resize re-anchors all three buttons via the existing `reflowChrome_` SizeChangedFcn handler. Existing dashboards (no `YLimitMode` in JSON) default to `auto-visible` and behave exactly as before.
+
+## Final Commit Hashes
+
+- `4db9138` — `test(260513-sfp-01)`: 11-case failing test file (RED phase) for `FastSenseWidget.YLimitMode`
+- `cc18c7f` — `feat(260513-sfp-01)`: `YLimitMode` property + `setYLimitMode` method + mode dispatch on `FastSenseWidget` (GREEN — all 11 tests pass)
+- `a9cc181` — `feat(260513-sfp-02)`: V/A/L button cluster on `WidgetButtonBar` (`DashboardLayout` + `DashboardWidget`)
+
+## Files Changed
+
+| File | Change | LOC delta | Purpose |
+| --------------------------------------------- | -------- | ---------------------- | -------------------------------------------------------------------------------------------------------- |
+| `libs/Dashboard/FastSenseWidget.m` | Modified | +180 / -0 | `YLimitMode` property, `setYLimitMode`, dispatch in `autoScaleY_`, `getYFromTagOrInline_`, `getYInVisibleXWindow_`, `toStruct/fromStruct` round-trip |
+| `libs/Dashboard/DashboardLayout.m` | Modified | +195 / -3 | `addYLimitButtons_`, `addYLimitButton_`, `onYLimitButtonClicked_`, `syncYLimitButtonsState_`, `chooseYLimitActiveBg_`, `realizeWidget` duck-typed call, `needsBar` extended, `reflowChrome_` re-anchor |
+| `libs/Dashboard/DashboardWidget.m` | Modified | +5 / -2 | `clearPanelControls` protectedTags extended with `YLimitVisibleBtn / YLimitAllBtn / YLimitLockBtn` |
+| `tests/test_fastsense_widget_ylimit_modes.m` | Created | +303 (across 2 commits)| 11 function-style cases: default mode, validation, visible/all/locked dispatch, UserZoomedY clear-on-click, YLimits pin precedence, struct round-trip, legacy-struct back-compat, Follow-mode override |
+
+Total: 4 files, ~383 net LOC added.
+
+## Automated Test Results
+
+Run via `mcp__matlab__run_matlab_test_file` (matlab -batch on macOS arm64, plus interactive desktop session):
+
+| Test file | Result |
+| -------------------------------------------------------- | ---------------------------------------------------------------- |
+| `tests/test_fastsense_widget_ylimit_modes.m` (new) | **11/11 PASS** |
+| `tests/test_fastsense_widget_tag.m` (regression) | **7/7 PASS** |
+| `tests/test_fastsense_follow_toggle.m` (260513-ovt guard)| **10/10 PASS** |
+| `tests/test_dashboard_time_sync_all_pages.m` (regression)| **5/5 PASS** |
+| `tests/test_dashboard_range_selector_integration.m` | **Case 1 PASS, Case 2 pre-existing failure under -batch** (deferred — see `deferred-items.md`; reproduced via `git stash` on parent commit `9f46c92`, so NOT caused by 260513-sfp) |
+
+Smoke probe (`mcp__matlab__evaluate_matlab_code`): on a 1-widget demo dashboard the three buttons render with correct tags (`YLimitVisibleBtn`, `YLimitAllBtn`, `YLimitLockBtn`) on the `WidgetButtonBar`, the default `V` button is highlighted, and clicks update the active highlight and the chart's YLim.
+
+## User Verification Result
+
+**APPROVED** on the live `demo/industrial_plant/run_demo.m` dashboard. All 8 scenarios from the checkpoint pass:
+
+1. Default state: `V` button pressed, Y auto-scales to visible window (back-compat regression OK)
+2. Auto-fit visible (V): pan to narrow window, click V — Y snaps to that window's data + padding
+3. Auto-fit all (A): click A — Y expands to cover the full tag Y range regardless of X zoom
+4. Locked (L): set range via V/A, click L, run live ~30s — YLim does NOT rescale; click V to unlock
+5. User mouse-zoom override: scroll-wheel zoom Y in V mode latches UserZoomedY; click V again clears the latch and re-engages autoscale
+6. Resize: dragging the figure edge keeps `[V][A][L]` anchored right alongside `[i][^]` with no overlap (see Known Caveat below for the 0-px gap between L and `i`)
+7. Detach: clicking `^` opens the detached mirror — the mirror does NOT show V/A/L (out-of-scope; figure-level `FastSenseToolbar` already provides standard MATLAB axes-toolbar zoom controls)
+8. Backward compat: opening an existing v3.0 demo dashboard — all tiles default to V (auto-visible) and behave identically to pre-260513-sfp
+
+## Known Caveat
+
+**The V/A/L cluster's right edge butts against the Info (`i`) button with a 0-px gap rather than the intended 4-px gap.**
+
+Root cause: `DashboardLayout.addInfoIcon` (untouched by this plan) uses `xInfo = barPos(3) - 28 - 28 - 4`, treating the button width as 28 px even though the actual button is 24 px. This is a pre-existing typo from earlier work, explicitly called out as out-of-scope in the plan's `` block (line ~170 of `260513-sfp-PLAN.md`):
+
+> NOTE: addInfoIcon already uses `barW - 28 - 28 - 4` (a typo from earlier work that treats button width as 28). For this plan, use 24+24+4 spacing for the new buttons. Do NOT "fix" the Info button — that is OUT OF SCOPE.
+
+`addYLimitButtons_` uses the correct 24+24+4 math relative to where Info SHOULD be, so the 0-px gap is inherited from the `addInfoIcon` typo. The user explicitly accepted this caveat at the checkpoint approval — the visual is acceptable and the buttons function correctly. Fixing it requires touching `addInfoIcon` (`bw - 24 - 24 - 4 - 4`), which would belong to a separate cleanup task.
+
+Logged at: `.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/deferred-items.md` (alongside the pre-existing `test_dashboard_range_selector_integration.m` Case 2 -batch failure).
+
+## Design Decisions
+
+1. **Y-mode dispatch lives inside `autoScaleY_(obj, y)`** rather than at the caller — keeps the method's public signature (`autoScaleY_(obj, y)`) byte-identical so `render`, `rebuildForTag_`, and any external callers keep working unchanged. The new dispatch is purely internal: after the existing precedence guards (`YLimits` pin / `UserZoomedY` / `FastSense.LiveViewMode == 'follow'` / not-rendered), the method branches on `obj.YLimitMode` — `locked` returns; `auto-all` swaps `y` for full tag data via `getYFromTagOrInline_`; `auto-visible` falls through to the existing min/max + threshold + padding code path.
+2. **Duck-typed chrome injection** via `ismethod(widget, 'setYLimitMode')` rather than `isa(widget, 'FastSenseWidget')` — future widgets exposing a Y-rescale mode (e.g., a 2D heatmap with Z-clamp modes) opt in by simply implementing `setYLimitMode`, without touching `DashboardLayout`. Also extends `needsBar` so a duck-typed widget that omits `Description` and `DetachCallback` still gets a chrome strip to host its buttons.
+3. **ASCII glyphs (`V`, `A`, `L`)** chosen to match existing Info (`i`) and Detach (`^`) buttons — Octave's font rendering on Linux for Unicode glyphs in `uicontrol String` is inconsistent across versions, and the existing WidgetButtonBar deliberately avoids Unicode.
+4. **Default `YLimitMode = 'auto-visible'`** for backward compatibility — exactly reproduces pre-260513-sfp `autoScaleY_` behaviour. Existing dashboards load with no behavioural diff. `toStruct` omits `yLimitMode` when it equals the default, so JSON diffs of legacy dashboards stay invisible.
+5. **`LiveViewMode='follow'` precedence preserved** — `autoScaleY_` short-circuits on `FastSense.LiveViewMode == 'follow'` BEFORE the new mode dispatch. This protects the 260513-ovt Follow-toggle semantics (Follow's explicit "freeze X+Y view, append only" intent still wins over any auto-rescale mode). Regression-guarded by `test_fastsense_follow_toggle.m` (10/10 pass).
+6. **`UserZoomedY` cleared on explicit click** — without this, a user's prior mouse-zoom would latch `UserZoomedY = true` forever and silently defeat the V/A buttons. An explicit click on V or A is an explicit re-engage signal; clicking L while user-zoomed is also a clean freeze.
+7. **`YLimits` numeric pin still wins** — explicit pinning via the `YLimits` property on the widget remains the highest-precedence mode (after the early-return guards). Old dashboards that pin Y explicitly keep their pinned range regardless of which V/A/L button is "active" visually.
+8. **No new theme tokens** — `chooseYLimitActiveBg_` falls through `PressedBg` -> `SelectedBg` -> `AccentColor` -> brightened `ToolbarBackground`. Leaves a clean follow-up to add a proper `theme.PressedBg` field if reviewers prefer.
+
+## Out-of-Scope Follow-ups (not done, deliberately)
+
+- **Detached-mirror V/A/L cluster** — `DetachedMirror` uses its own figure-level `FastSenseToolbar` (not a `WidgetButtonBar`). Adding the cluster there would belong to a separate quick task that extends `FastSenseToolbar` rather than `DashboardLayout`. For v1 of this feature, the detached mirror retains the standard MATLAB axes-toolbar zoom controls plus `FastSenseToolbar`'s Follow/Live buttons, which is sufficient.
+- **Symmetric-zero mode (a 4th button)** — explicit YAGNI for v1; trivial to add as a `'symmetric'` value in the `YLimitMode` validation switch.
+- **`theme.PressedBg` field** — the `chooseYLimitActiveBg_` fallback chain works today; a proper theme token would be a clean follow-up.
+- **`addInfoIcon` 28→24 px fix** — pre-existing typo, deliberately untouched per the plan's `` instruction; manifests as the 0-px gap between L and Info documented under Known Caveat.
+
+## Deviations from Plan
+
+- **Test file canRenderFigures_() guard** — the planned `usejava('desktop')` skip-on-headless guard was too strict for the rendered cases (they DO run under `matlab -batch` because they use offscreen `figure('Visible','off')`). Loosened to a `canRenderFigures_()` helper so the rendered cases execute under `-batch` / `-nodisplay` rather than skip. `test_fastsense_follow_toggle.m` keeps the stricter guard (it touches `uitoggletool`, which is genuinely incompatible with `-batch`).
+
+Tracked as `[Rule 3 - Test infra]` adjustment, internal to the test file only; no production-code impact.
+
+## Self-Check: PASSED
+
+Verified all claimed artifacts exist:
+
+- `libs/Dashboard/FastSenseWidget.m` (modified, contains `YLimitMode`, `setYLimitMode`, dispatch) — FOUND
+- `libs/Dashboard/DashboardLayout.m` (modified, contains `addYLimitButtons_`) — FOUND
+- `libs/Dashboard/DashboardWidget.m` (modified, contains extended `protectedTags`) — FOUND
+- `tests/test_fastsense_widget_ylimit_modes.m` (created) — FOUND
+- Commit `4db9138` in `git log --oneline` — FOUND
+- Commit `cc18c7f` in `git log --oneline` — FOUND
+- Commit `a9cc181` in `git log --oneline` — FOUND
diff --git a/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/deferred-items.md b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/deferred-items.md
new file mode 100644
index 00000000..d8cf72cf
--- /dev/null
+++ b/.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/deferred-items.md
@@ -0,0 +1,35 @@
+# Deferred Items — 260513-sfp
+
+Out-of-scope discoveries logged during execution per the SCOPE BOUNDARY
+rule. These are NOT caused by 260513-sfp changes; do NOT auto-fix in
+this task.
+
+## Pre-existing test failure: `tests/test_dashboard_range_selector_integration.m` Case 2
+
+**Symptom (matlab -batch):**
+```
+ERR: Case 2 debounced xl2(1)=25.0000 expected 0.0000
+```
+
+**Verification:**
+Reproduced via `git stash` of all 260513-sfp changes, confirming Case 1
+still passes but Case 2 already fails on the parent commit
+(`9f46c92 Dashboard Live/Follow preserve + resize/tab-switch zombie-panel fix`).
+The previously-reported "2/2 PASS" in STATE.md (260513-q7w entry) likely
+came from running under the live MATLAB desktop where timer cadence
+differs from `-batch`.
+
+**Suspected root cause:** Debounce timer expectation under `-batch`
+doesn't match the cwd / event-loop assumptions baked into the test.
+Likely needs a `usejava('desktop')` skip or an explicit timer drain.
+
+**Owner:** Future quick task; do NOT bundle into 260513-sfp.
+
+## STATE.md note for `test_dashboard_range_selector_integration`
+
+STATE.md last_activity claims "2/2 PASS" for this test. Under matlab
+`-batch` the test reports `1/2 pass + Case 2 ERR`. The interactive
+desktop session may still pass. The 260513-sfp verification step uses
+the user's live MATLAB desktop, so we explicitly call out this
+discrepancy in the SUMMARY and rely on the user to confirm during the
+checkpoint:human-verify gate.
diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m
index 6d022c93..e9ad7910 100644
--- a/libs/Dashboard/DashboardLayout.m
+++ b/libs/Dashboard/DashboardLayout.m
@@ -360,8 +360,13 @@ function realizeWidget(obj, widget)
delete(ph);
% Decide whether this widget needs chrome.
+ % 260513-sfp — widgets exposing setYLimitMode also need a bar
+ % to host the V/A/L YLimit cluster, even if they have neither
+ % a Description nor a DetachCallback. Today that's only
+ % FastSenseWidget, but the duck-type keeps the chrome generic.
needsBar = ~isempty(widget.Description) || ...
- (~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget'));
+ (~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget')) || ...
+ ismethod(widget, 'setYLimitMode');
if needsBar
% 1. Create the full-width bar at the top of the cell panel.
@@ -380,6 +385,13 @@ function realizeWidget(obj, widget)
if ~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget')
obj.addDetachButton(widget);
end
+ % 260513-sfp — Y-limit-mode buttons. Duck-typed: only
+ % widgets that implement setYLimitMode opt in (today
+ % only FastSenseWidget). Lives strictly under needsBar
+ % because the cluster requires the WidgetButtonBar host.
+ if ismethod(widget, 'setYLimitMode')
+ obj.addYLimitButtons_(widget);
+ end
else
% No chrome — render directly into the cell panel as before.
widget.render(widget.hCellPanel);
@@ -747,6 +759,98 @@ function addDetachButton(obj, widget)
'TooltipString', 'Detach widget', ...
'Callback', @(~,~) obj.DetachCallback(widget));
end
+
+ function addYLimitButtons_(obj, widget)
+ %ADDYLIMITBUTTONS_ Inject the 2-button Y-limit-mode cluster.
+ % Only invoked from realizeWidget when ismethod(widget,'setYLimitMode').
+ % Buttons (V, A) are left-anchored relative to the EXISTING
+ % right-anchored Info/Detach buttons, with a 4-px gap between the
+ % clusters:
+ % [V][A] ...4px gap... [Info][Detach]
+ % 24 24 24 24
+ %
+ % The 'locked' YLimitMode remains a valid programmatic mode on
+ % FastSenseWidget (setYLimitMode('locked')) but has no UI button.
+ %
+ % Active mode is visually highlighted (the button matching
+ % widget.YLimitMode shows the "pressed" background). The active
+ % background is computed from the theme via DashboardLayout's
+ % chooseYLimitActiveBg_ helper, picking the first available of
+ % {PressedBg, SelectedBg, AccentColor} and falling back to a
+ % brightened ToolbarBackground when the theme exposes none.
+ if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme)
+ theme = DashboardTheme('light');
+ else
+ theme = widget.ParentTheme;
+ end
+ bar = obj.getOrCreateButtonBar_(widget);
+ barPos = get(bar, 'Position');
+ barW = barPos(3);
+
+ % Layout (left-to-right):
+ % [V][A] ...4px gap... [Info][Detach]
+ bw = 24;
+ gap = 4;
+ % Right-anchor math mirrors addInfoIcon / addDetachButton.
+ % Detach: x = barW - bw - gap
+ % Info: x = barW - bw - bw - gap - gap (Info uses 28-spacing pre-existing)
+ % YLimit-All: x = barW - bw - gap - bw - gap - gap - bw
+ % YLimit-Visible:xAll - bw
+ xAll = barW - bw - gap - bw - gap - gap - bw;
+ xVisible = xAll - bw;
+
+ activeBg = DashboardLayout.chooseYLimitActiveBg_(theme);
+
+ obj.addYLimitButton_(bar, widget, 'auto-visible', xVisible, ...
+ 'V', 'Auto-fit Y to visible X range', theme, 'YLimitVisibleBtn');
+ obj.addYLimitButton_(bar, widget, 'auto-all', xAll, ...
+ 'A', 'Auto-fit Y to all data', theme, 'YLimitAllBtn');
+
+ % Stash the active-bg + widget handle on the bar's UserData so
+ % the static reflowChrome_ handler can restyle/re-anchor after
+ % a resize without re-resolving the theme. Weak ref — guarded
+ % with isvalid in syncYLimitButtonsState_ in case the widget
+ % gets deleted before the bar.
+ ud = get(bar, 'UserData');
+ if ~isstruct(ud), ud = struct(); end
+ ud.YLimitActiveBg = activeBg;
+ ud.YLimitWidget = widget;
+ set(bar, 'UserData', ud);
+
+ % Highlight the button matching the current YLimitMode.
+ DashboardLayout.syncYLimitButtonsState_(bar, widget.YLimitMode);
+ end
+
+ function addYLimitButton_(obj, bar, widget, mode, x, glyph, tip, theme, tagName)
+ %ADDYLIMITBUTTON_ Create a single YLimit pushbutton (helper for addYLimitButtons_).
+ % Callback dispatches through onYLimitButtonClicked_ which calls
+ % widget.setYLimitMode(mode), then re-syncs the visual pressed state.
+ uicontrol('Parent', bar, ...
+ 'Style', 'pushbutton', ...
+ 'String', glyph, ...
+ 'Units', 'pixels', ...
+ 'Position', [x, 2, 24, 24], ...
+ 'FontSize', 9, ...
+ 'FontWeight', 'bold', ...
+ 'ForegroundColor', theme.ToolbarFontColor, ...
+ 'BackgroundColor', theme.ToolbarBackground, ...
+ 'Tag', tagName, ...
+ 'TooltipString', tip, ...
+ 'Callback', @(~,~) obj.onYLimitButtonClicked_(widget, mode, bar));
+ end
+
+ function onYLimitButtonClicked_(obj, widget, mode, bar) %#ok
+ %ONYLIMITBUTTONCLICKED_ Button callback — set mode + sync pressed state.
+ % Errors are warned (not thrown) so a single bad click never
+ % crashes the dashboard refresh loop.
+ try
+ widget.setYLimitMode(mode);
+ DashboardLayout.syncYLimitButtonsState_(bar, mode);
+ catch ME
+ warning('DashboardLayout:yLimitClickFailed', ...
+ 'YLimit button click failed for mode ''%s'': %s', mode, ME.message);
+ end
+ end
end
methods (Static)
@@ -777,6 +881,19 @@ function reflowChrome_(hCell, barH, inset)
if ~isempty(info) && ishandle(info(1))
set(info(1), 'Position', [barW - 24 - 24 - 4 - 4, 2, 24, 24]);
end
+ % Re-anchor the V/A cluster. Math must match
+ % addYLimitButtons_ exactly so resize does not introduce drift.
+ bw = 24; gap = 4;
+ allBtn = findobj(bar(1), 'Tag', 'YLimitAllBtn', '-depth', 1);
+ visibleBtn = findobj(bar(1), 'Tag', 'YLimitVisibleBtn', '-depth', 1);
+ xAll = barW - bw - gap - bw - gap - gap - bw;
+ xVisible = xAll - bw;
+ if ~isempty(allBtn) && ishandle(allBtn(1))
+ set(allBtn(1), 'Position', [xAll, 2, bw, bw]);
+ end
+ if ~isempty(visibleBtn) && ishandle(visibleBtn(1))
+ set(visibleBtn(1), 'Position', [xVisible, 2, bw, bw]);
+ end
end
if ~isempty(content) && ishandle(content(1))
contentH = max(1, pp(4) - barH - inset);
@@ -785,6 +902,74 @@ function reflowChrome_(hCell, barH, inset)
end
end
+ function bg = chooseYLimitActiveBg_(theme)
+ %CHOOSEYLIMITACTIVEBG_ Pick the highlight color for the active YLimit button.
+ % Tries PressedBg / SelectedBg / AccentColor in order, falling
+ % back to ToolbarBackground brightened by 0.15 per channel
+ % (capped at 1) when none are present. No new theme fields are
+ % introduced by 260513-sfp; future themes can opt into a
+ % dedicated PressedBg token without touching layout code.
+ if isstruct(theme)
+ if isfield(theme, 'PressedBg')
+ bg = theme.PressedBg; return;
+ end
+ if isfield(theme, 'SelectedBg')
+ bg = theme.SelectedBg; return;
+ end
+ if isfield(theme, 'AccentColor')
+ bg = theme.AccentColor; return;
+ end
+ if isfield(theme, 'ToolbarBackground')
+ bg = min(theme.ToolbarBackground + 0.15, 1);
+ return;
+ end
+ end
+ % Defensive fallback — light grey.
+ bg = [0.85 0.85 0.85];
+ end
+
+ function syncYLimitButtonsState_(bar, mode)
+ %SYNCYLIMITBUTTONSSTATE_ Visually highlight the YLimit button matching mode.
+ % The active button's BackgroundColor becomes the value stashed on
+ % bar.UserData.YLimitActiveBg by addYLimitButtons_; the other two
+ % revert to the theme's ToolbarBackground. Tolerates missing
+ % buttons (no-op if the bar's UserData was never primed).
+ if isempty(bar) || ~ishandle(bar), return; end
+ ud = get(bar, 'UserData');
+ if ~isstruct(ud) || ~isfield(ud, 'YLimitActiveBg')
+ return;
+ end
+ activeBg = ud.YLimitActiveBg;
+ % Resolve the inactive background once. Prefer the widget's own
+ % ParentTheme (matches button construction); fall back to a
+ % default theme if the widget has been deleted out from under us.
+ inactiveBg = [];
+ if isfield(ud, 'YLimitWidget') && ~isempty(ud.YLimitWidget)
+ w = ud.YLimitWidget;
+ if isobject(w) && isvalid(w) && ...
+ ~isempty(w.ParentTheme) && isstruct(w.ParentTheme) && ...
+ isfield(w.ParentTheme, 'ToolbarBackground')
+ inactiveBg = w.ParentTheme.ToolbarBackground;
+ end
+ end
+ if isempty(inactiveBg)
+ t = DashboardTheme('light');
+ inactiveBg = t.ToolbarBackground;
+ end
+ tagsAndModes = { ...
+ 'YLimitVisibleBtn', 'auto-visible'; ...
+ 'YLimitAllBtn', 'auto-all' };
+ for i = 1:size(tagsAndModes, 1)
+ btn = findobj(bar, 'Tag', tagsAndModes{i, 1}, '-depth', 1);
+ if isempty(btn) || ~ishandle(btn(1)), continue; end
+ if strcmp(mode, tagsAndModes{i, 2})
+ set(btn(1), 'BackgroundColor', activeBg);
+ else
+ set(btn(1), 'BackgroundColor', inactiveBg);
+ end
+ end
+ end
+
function reflowButtonBar_(hCell, barH, inset)
%REFLOWBUTTONBAR_ Deprecated alias — forwards to reflowChrome_.
% Kept temporarily for any external callers that still reference
diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m
index cf53b3ea..59ba70f1 100644
--- a/libs/Dashboard/DashboardWidget.m
+++ b/libs/Dashboard/DashboardWidget.m
@@ -133,12 +133,14 @@ function markUnrealized(obj)
function clearPanelControls(hPanel)
%CLEARPANELCONTROLS Delete uicontrol children of hPanel at depth 1,
% preserving DashboardLayout-injected buttons (InfoIconButton,
- % DetachButton). The buttons live inside a uipanel button bar
+ % DetachButton, YLimitVisibleBtn, YLimitAllBtn).
+ % The buttons live inside a uipanel button bar
% (Tag='WidgetButtonBar', also preserved here at the panel level)
% since 260508 — but the legacy tags are kept in case any pre-bar
% widgets still parent the buttons directly to hPanel.
if isempty(hPanel) || ~ishandle(hPanel), return; end
- protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar'};
+ protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar', ...
+ 'YLimitVisibleBtn', 'YLimitAllBtn'};
% 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 f8b3328d..5060134b 100644
--- a/libs/Dashboard/FastSenseWidget.m
+++ b/libs/Dashboard/FastSenseWidget.m
@@ -38,6 +38,31 @@
% sample since session start — useful for short
% demos where you want to see the chart fill up)
LiveViewMode = 'preserve'
+
+ % YLimitMode — Y-axis rescale strategy applied by autoScaleY_:
+ % 'auto-visible' (DEFAULT) — rescale to cover data inside the
+ % current X window. Reproduces the
+ % pre-260513-sfp behaviour exactly
+ % (so old dashboards behave identically).
+ % 'auto-all' — rescale to cover ALL data the bound
+ % Tag exposes, regardless of current
+ % XLim. Equivalent to a global "fit Y
+ % to the whole timeline" command.
+ % 'locked' — freeze current YLim. Live ticks /
+ % refresh / update no longer call
+ % set(ax, 'YLim', ...).
+ %
+ % Precedence (autoScaleY_ guards, top-to-bottom):
+ % 1. Non-empty YLimits pin -> always wins (explicit numeric pin).
+ % 2. UserZoomedY latch -> mouse-zoom freezes autoscale until
+ % user explicitly re-clicks a mode
+ % (setYLimitMode clears this latch).
+ % 3. FastSenseObj.LiveViewMode == 'follow' -> Follow toggle wins
+ % (260513-ovt: tail-track in X only,
+ % keep Y frozen).
+ % 4. YLimitMode dispatch: 'locked' -> no-op; otherwise
+ % auto-visible / auto-all branches run.
+ YLimitMode = 'auto-visible'
end
% (Tag property now lives on the DashboardWidget base class — Plan 1009-02.)
@@ -346,6 +371,55 @@ function setEventMarkersVisible(obj, tf)
end
end
+ function setYLimitMode(obj, mode)
+ %SETYLIMITMODE Set the Y-axis rescale strategy and re-fit if rendered.
+ % mode is one of:
+ % 'auto-visible' - rescale to data inside the current X window
+ % 'auto-all' - rescale to all data the bound Tag exposes
+ % 'locked' - freeze YLim; no further rescale on tick/refresh
+ %
+ % Side effects (260513-sfp):
+ % - Clears UserZoomedY so an explicit click re-engages autoscale
+ % (the latch is treated as "I want to override autoscale" — a
+ % deliberate click on the V/A/L buttons reverses that intent).
+ % - Fetches the appropriate y window for the new mode and calls
+ % autoScaleY_(y) so the Y axis snaps immediately. 'locked' mode
+ % passes empty y; autoScaleY_'s mode dispatch short-circuits.
+ %
+ % Does NOT override the YLimits pin (autoScaleY_'s guards stay).
+ valid = {'auto-visible', 'auto-all', 'locked'};
+ if ~(ischar(mode) || (isstring(mode) && isscalar(mode))) || ...
+ ~ismember(char(mode), valid)
+ error('FastSenseWidget:invalidYLimitMode', ...
+ 'YLimitMode must be one of {''auto-visible'',''auto-all'',''locked''}.');
+ end
+ obj.YLimitMode = char(mode);
+
+ % Explicit click clears the user-zoom latch. The latch exists
+ % to stop autoScaleY_ from fighting a mouse-zoom; a deliberate
+ % click on V/A/L is the user re-engaging autoscale on purpose,
+ % which means the latch must drop.
+ obj.UserZoomedY = false;
+
+ % Snap Y immediately so the user sees the click take effect
+ % (no need to wait for the next refresh tick). Only meaningful
+ % when the widget has been rendered.
+ if isempty(obj.FastSenseObj) || ~obj.FastSenseObj.IsRendered
+ return;
+ end
+ switch obj.YLimitMode
+ case 'auto-visible'
+ y = obj.getYInVisibleXWindow_();
+ obj.autoScaleY_(y);
+ case 'auto-all'
+ y = obj.getYFromTagOrInline_();
+ obj.autoScaleY_(y);
+ case 'locked'
+ % autoScaleY_'s mode dispatch treats 'locked' as no-op.
+ obj.autoScaleY_([]);
+ end
+ 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
@@ -359,7 +433,17 @@ function autoScaleY_(obj, y)
% (FastSenseObj.LiveViewMode == 'follow') — Follow is an
% explicit user intent to track the data tail in X only and
% keep the rest of the view (including Y) frozen. (260513-ovt)
- % so we never fight an explicit human interaction.
+ % - YLimitMode == 'locked' — the user explicitly froze Y limits
+ % via the L button on the WidgetButtonBar (260513-sfp).
+ %
+ % Mode dispatch (after the guards above pass):
+ % 'auto-visible' - use y as given (legacy behaviour). The caller
+ % either passes data already filtered to the
+ % visible X window, or full data — both work.
+ % 'auto-all' - replace y with full data from
+ % getYFromTagOrInline_() so "fit all" ignores
+ % whatever window the caller filtered to.
+ % 'locked' - return without rescaling.
if ~isempty(obj.YLimits)
return;
end
@@ -377,6 +461,20 @@ function autoScaleY_(obj, y)
if isempty(ax) || ~ishandle(ax)
return;
end
+ % Mode dispatch (260513-sfp). 'locked' short-circuits regardless
+ % of the y argument; 'auto-all' replaces y with full data so the
+ % caller's window filter is bypassed.
+ switch obj.YLimitMode
+ case 'locked'
+ return;
+ case 'auto-all'
+ yAll = obj.getYFromTagOrInline_();
+ if ~isempty(yAll)
+ y = yAll;
+ end
+ otherwise
+ % 'auto-visible' (default) — use y argument as-is.
+ end
if isempty(y)
return;
end
@@ -887,6 +985,12 @@ function invalidatePreviewCache_(obj)
if ~isempty(obj.YLimits), s.yLimits = obj.YLimits; end
if obj.ShowThresholdLabels, s.showThresholdLabels = true; end
if obj.ShowEventMarkers, s.showEventMarkers = true; end
+ % Emit yLimitMode only when non-default so pre-260513-sfp JSON
+ % stays byte-identical (keeps diffs invisible for old
+ % dashboards that never opted into a mode).
+ if ~strcmp(obj.YLimitMode, 'auto-visible')
+ s.yLimitMode = obj.YLimitMode;
+ end
% NOTE: EventStore is a runtime handle — intentionally NOT serialized (Pitfall E).
if ~isempty(obj.Tag) && ~isempty(obj.Tag.Key)
@@ -914,6 +1018,70 @@ function delete(obj)
end
methods (Access = private)
+ function y = getYFromTagOrInline_(obj)
+ %GETYFROMTAGORINLINE_ Full y vector from Tag (preferred) or inline YData.
+ % Returns [] when neither source yields data. Used by the
+ % 'auto-all' branch of autoScaleY_ / setYLimitMode so the rescale
+ % spans the entire timeline regardless of the current X window.
+ y = [];
+ if ~isempty(obj.Tag)
+ try
+ [~, y] = obj.Tag.getXY();
+ catch
+ y = [];
+ end
+ return;
+ end
+ if ~isempty(obj.YData)
+ y = obj.YData;
+ end
+ end
+
+ function y = getYInVisibleXWindow_(obj)
+ %GETYINVISIBLEXWINDOW_ y values whose x is inside the current XLim.
+ % Used by the 'auto-visible' branch of setYLimitMode so an
+ % explicit click on the V button rescales to the data the user
+ % can actually see right now. Falls back to the full y vector
+ % when the axes XLim is unavailable, when the data is too sparse
+ % to filter meaningfully, or when no samples fall inside the
+ % window (e.g. live data has not yet caught up with a panned-
+ % ahead XLim).
+ y = obj.getYFromTagOrInline_();
+ if isempty(y) || isempty(obj.FastSenseObj) || ...
+ ~obj.FastSenseObj.IsRendered
+ return;
+ end
+ ax = obj.FastSenseObj.hAxes;
+ if isempty(ax) || ~ishandle(ax)
+ return;
+ end
+ try
+ xl = get(ax, 'XLim');
+ catch
+ return;
+ end
+ if numel(xl) ~= 2 || ~all(isfinite(xl)) || xl(2) <= xl(1)
+ return;
+ end
+ x = [];
+ if ~isempty(obj.Tag)
+ try
+ [x, ~] = obj.Tag.getXY();
+ catch
+ x = [];
+ end
+ elseif ~isempty(obj.XData)
+ x = obj.XData;
+ end
+ if isempty(x) || numel(x) ~= numel(y)
+ return;
+ end
+ mask = x >= xl(1) & x <= xl(2);
+ if any(mask)
+ y = y(mask);
+ end
+ end
+
function refreshEventMarkers_(obj)
%REFRESHEVENTMARKERS_ Diff LastEventIds_/LastEventOpen_ vs current EventStore state.
% Triggers inner FastSense.refreshEventLayer() on any change: added/removed
@@ -1158,6 +1326,16 @@ function rebuildForTag_(obj)
if isfield(s, 'showEventMarkers')
obj.ShowEventMarkers = s.showEventMarkers;
end
+ % 260513-sfp — restore YLimitMode if serialized. Absent means
+ % "legacy dashboard, default to 'auto-visible'" so behaviour
+ % is byte-identical for old configs.
+ if isfield(s, 'yLimitMode')
+ try
+ obj.setYLimitMode(s.yLimitMode);
+ catch
+ % Invalid serialized value; keep default 'auto-visible'.
+ end
+ end
end
end
end
diff --git a/tests/test_fastsense_widget_ylimit_modes.m b/tests/test_fastsense_widget_ylimit_modes.m
new file mode 100644
index 00000000..e4adb22e
--- /dev/null
+++ b/tests/test_fastsense_widget_ylimit_modes.m
@@ -0,0 +1,317 @@
+function test_fastsense_widget_ylimit_modes()
+%TEST_FASTSENSE_WIDGET_YLIMIT_MODES Tests for FastSenseWidget.YLimitMode (260513-sfp).
+%
+% Covers:
+% - Default YLimitMode='auto-visible' (backward compat)
+% - setYLimitMode validation
+% - 'auto-visible' rescales to current X window (existing behaviour)
+% - 'auto-all' rescales to full tag data ignoring XLim
+% - 'locked' freezes YLim across refresh/update
+% - setYLimitMode clears UserZoomedY (explicit click re-engages autoscale)
+% - Explicit YLimits pin (non-empty) wins over YLimitMode
+% - toStruct/fromStruct round-trips YLimitMode
+% - Legacy struct without yLimitMode field defaults to 'auto-visible'
+% - Follow mode (FastSenseObj.LiveViewMode='follow') still short-circuits
+% autoScaleY_ regardless of mode (260513-ovt regression guard)
+
+ addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();
+
+ nPassed = 0; nFailed = 0;
+ cleanupAll = onCleanup(@() close('all', 'force')); %#ok
+
+ % --- test_default_y_limit_mode_is_auto_visible ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ assert(strcmp(w.YLimitMode, 'auto-visible'), ...
+ sprintf('default YLimitMode must be ''auto-visible'', got ''%s''', w.YLimitMode));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_default_y_limit_mode_is_auto_visible: %s\n', err.message);
+ end
+
+ % --- test_set_y_limit_mode_validates ---
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ errId = '';
+ try
+ w.setYLimitMode('bogus');
+ catch e
+ errId = e.identifier;
+ end
+ assert(strcmp(errId, 'FastSenseWidget:invalidYLimitMode'), ...
+ sprintf('expected ''FastSenseWidget:invalidYLimitMode'', got ''%s''', errId));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_y_limit_mode_validates: %s\n', err.message);
+ end
+
+ % --- test_set_y_limit_mode_visible_rescales_to_window ---
+ % Synthetic tag with a step: y in [0,4] -> y in [0,10]; x in [5,10] -> y in [0,100].
+ % setYLimitMode() internally clears the UserZoomedY latch (which the
+ % XLim/YLim sets below would otherwise tickle), so we don't need to
+ % poke the read-only latch directly.
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_set_y_limit_mode_visible_rescales_to_window: skipped (no java desktop).\n');
+ else
+ tag = makeStepTag_();
+ w = FastSenseWidget('Tag', tag);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ % Pan XLim so only the [0,4] half (y in [0,10]) is visible.
+ set(w.FastSenseObj.hAxes, 'XLim', [0 4]);
+ w.setYLimitMode('auto-visible');
+ yl = get(w.FastSenseObj.hAxes, 'YLim');
+ % yMin should be near 0; yMax should be near 10 (NOT 100). Allow padding (~10%).
+ assert(yl(2) < 30, ...
+ sprintf('auto-visible YLim must cover ~[0,10], got [%g, %g]', yl(1), yl(2)));
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_y_limit_mode_visible_rescales_to_window: %s\n', err.message);
+ end
+
+ % --- test_set_y_limit_mode_all_rescales_to_full_data ---
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_set_y_limit_mode_all_rescales_to_full_data: skipped (no java desktop).\n');
+ else
+ tag = makeStepTag_();
+ w = FastSenseWidget('Tag', tag);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ % Pan XLim so only the [0,4] half (y in [0,10]) is visible.
+ set(w.FastSenseObj.hAxes, 'XLim', [0 4]);
+ w.setYLimitMode('auto-all');
+ yl = get(w.FastSenseObj.hAxes, 'YLim');
+ % yMax must cover the [5,10] half (~100), regardless of XLim.
+ assert(yl(2) > 80, ...
+ sprintf('auto-all YLim must cover ~[0,100], got [%g, %g]', yl(1), yl(2)));
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_y_limit_mode_all_rescales_to_full_data: %s\n', err.message);
+ end
+
+ % --- test_set_y_limit_mode_locked_freezes_y ---
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_set_y_limit_mode_locked_freezes_y: skipped (no java desktop).\n');
+ else
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ % Lock in current Y range, then capture YLim AFTER setYLimitMode
+ % so any rescale baked in by the mode switch is included in the
+ % baseline. autoScaleY_ must then be a no-op.
+ w.setYLimitMode('locked');
+ Y0 = get(w.FastSenseObj.hAxes, 'YLim');
+ % Try to trigger autoScaleY_ via a y vector that would have rescaled.
+ w.autoScaleY_(100 * (1:10));
+ Y1 = get(w.FastSenseObj.hAxes, 'YLim');
+ assert(isequal(Y0, Y1), ...
+ sprintf('locked mode must freeze YLim; [%g,%g] -> [%g,%g]', Y0(1), Y0(2), Y1(1), Y1(2)));
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_y_limit_mode_locked_freezes_y: %s\n', err.message);
+ end
+
+ % --- test_set_y_limit_mode_clears_user_zoomed_y ---
+ % Exercise the latch the same way a real mouse-zoom would: by mutating
+ % YLim while IsSettingYLim is false. The XLim/YLim PostSet listener
+ % installed in render() flips UserZoomedY to true.
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_set_y_limit_mode_clears_user_zoomed_y: skipped (no java desktop).\n');
+ else
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ % Simulate a user mouse-zoom of Y. The YLim PostSet listener
+ % (installed in render()) latches UserZoomedY=true.
+ set(w.FastSenseObj.hAxes, 'YLim', [-5 5]);
+ drawnow;
+ assert(w.UserZoomedY, ...
+ 'precondition: user YLim set must latch UserZoomedY');
+ % Explicit click on V button must clear the latch.
+ w.setYLimitMode('auto-visible');
+ assert(~w.UserZoomedY, ...
+ 'setYLimitMode must clear UserZoomedY');
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_set_y_limit_mode_clears_user_zoomed_y: %s\n', err.message);
+ end
+
+ % --- test_y_limits_pin_wins_over_y_limit_mode ---
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_y_limits_pin_wins_over_y_limit_mode: skipped (no java desktop).\n');
+ else
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag, 'YLimits', [0 1000]);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ w.setYLimitMode('auto-visible');
+ yl = get(w.FastSenseObj.hAxes, 'YLim');
+ assert(isequal(yl, [0 1000]), ...
+ sprintf('YLimits pin must win, got [%g, %g]', yl(1), yl(2)));
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_y_limits_pin_wins_over_y_limit_mode: %s\n', err.message);
+ end
+
+ % --- test_to_struct_from_struct_round_trips_y_limit_mode ---
+ try
+ tag = makeTag_();
+ w1 = FastSenseWidget('Tag', tag);
+ w1.setYLimitMode('locked');
+ s = w1.toStruct();
+ assert(isfield(s, 'yLimitMode'), 'toStruct must emit yLimitMode when non-default');
+ assert(strcmp(s.yLimitMode, 'locked'), ...
+ sprintf('yLimitMode in struct must be ''locked'', got ''%s''', s.yLimitMode));
+ w2 = FastSenseWidget.fromStruct(s);
+ assert(strcmp(w2.YLimitMode, 'locked'), ...
+ sprintf('fromStruct must restore YLimitMode=''locked'', got ''%s''', w2.YLimitMode));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_to_struct_from_struct_round_trips_y_limit_mode: %s\n', err.message);
+ end
+
+ % --- test_legacy_struct_without_y_limit_mode_defaults_to_auto_visible ---
+ try
+ s = struct('type', 'fastsense', 'title', 't', ...
+ 'position', struct('col', 1, 'row', 1, 'width', 6, 'height', 2));
+ w = FastSenseWidget.fromStruct(s);
+ assert(strcmp(w.YLimitMode, 'auto-visible'), ...
+ sprintf('legacy struct must default to ''auto-visible'', got ''%s''', w.YLimitMode));
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_legacy_struct_without_y_limit_mode_defaults_to_auto_visible: %s\n', err.message);
+ end
+
+ % --- test_to_struct_omits_default_y_limit_mode ---
+ % JSON-size optimization: default value must NOT leak into serialized struct.
+ try
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag); % default 'auto-visible'
+ s = w.toStruct();
+ assert(~isfield(s, 'yLimitMode'), ...
+ 'toStruct must NOT emit yLimitMode when default ''auto-visible''');
+ nPassed = nPassed + 1;
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_to_struct_omits_default_y_limit_mode: %s\n', err.message);
+ end
+
+ % --- test_follow_mode_still_short_circuits_autoscale ---
+ % 260513-ovt regression guard. Follow toggle's "freeze view in X+Y" intent
+ % must still override Y autoscaling even with YLimitMode='auto-visible'.
+ try
+ if ~canRenderFigures_()
+ fprintf(' test_follow_mode_still_short_circuits_autoscale: skipped (no java desktop).\n');
+ else
+ tag = makeTag_();
+ w = FastSenseWidget('Tag', tag);
+ [fig, panel] = makeOffscreenFigure_();
+ cleanup = onCleanup(@() safeClose_(fig)); %#ok
+ w.render(panel);
+ % Re-engage auto-visible mode AFTER render so any UserZoomedY
+ % latched by the YLim PostSet listener during initial draw is
+ % cleared. (setYLimitMode is the documented way to drop the
+ % latch; we can't write the read-only property directly.)
+ w.setYLimitMode('auto-visible');
+ % Switch the inner FastSense into 'follow' mode (mirrors the
+ % Follow toolbar toggle's effect).
+ w.FastSenseObj.LiveViewMode = 'follow';
+ % Capture the current YLim before invoking autoScaleY_.
+ Y0 = get(w.FastSenseObj.hAxes, 'YLim');
+ % autoScaleY_ with mode 'auto-visible' MUST short-circuit
+ % because Follow is engaged.
+ w.autoScaleY_(100 * (1:10));
+ Y1 = get(w.FastSenseObj.hAxes, 'YLim');
+ assert(isequal(Y0, Y1), ...
+ sprintf('Follow must short-circuit autoScaleY_; YLim changed [%g,%g]->[%g,%g]', ...
+ Y0(1), Y0(2), Y1(1), Y1(2)));
+ nPassed = nPassed + 1;
+ end
+ catch err
+ nFailed = nFailed + 1;
+ fprintf(' FAIL test_follow_mode_still_short_circuits_autoscale: %s\n', err.message);
+ end
+
+ % --- Summary ---
+ fprintf(' %d passed, %d failed.\n', nPassed, nFailed);
+ if nFailed > 0
+ error('test_fastsense_widget_ylimit_modes:failures', '%d test(s) failed.', nFailed);
+ end
+end
+
+function tag = makeTag_()
+%MAKETAG_ Plain SensorTag with sinusoidal data on [1, 1000].
+ x = (1:1000)';
+ y = sin(x / 50);
+ tag = SensorTag('tst_basic', 'Name', 'Tst', 'X', x, 'Y', y);
+end
+
+function tag = makeStepTag_()
+%MAKESTEPTAG_ Synthetic step tag for window-vs-all comparison.
+% y in [0, 10] for x in [0, 4], y in [0, 100] for x in [5, 10].
+ x = (0:10)';
+ y = zeros(size(x));
+ y(1:5) = (0:4) * (10 / 4); % x=[0..4] -> y=[0..10]
+ y(6:11) = (0:5) * (100 / 5); % x=[5..10] -> y=[0..100]
+ tag = SensorTag('tst_step', 'Name', 'Step', 'X', x, 'Y', y);
+end
+
+function [fig, panel] = makeOffscreenFigure_()
+%MAKEOFFSCREENFIGURE_ Visible='off' figure + single uipanel for rendering.
+ fig = figure('Visible', 'off', 'Units', 'pixels', 'Position', [100 100 600 400]);
+ panel = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0 0 1 1]);
+end
+
+function tf = canRenderFigures_()
+%CANRENDERFIGURES_ True when MATLAB can create an invisible figure + uipanel.
+% We avoid the stricter usejava('desktop') guard so that '-batch' /
+% '-nodisplay' CI runs (where the desktop is absent but figure creation
+% still works) get full coverage of the rendered cases.
+ 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