diff --git a/.planning/STATE.md b/.planning/STATE.md
index f9c18bbd..7147d20b 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -89,6 +89,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict
| 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/) |
| 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) |
| 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) |
+| 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/) |
## Progress Bar
diff --git a/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md
new file mode 100644
index 00000000..76203e32
--- /dev/null
+++ b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md
@@ -0,0 +1,296 @@
+---
+phase: quick-260526-pqz
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/Dashboard/DashboardEngine.m
+ - tests/test_dashboard_preview_overlay.m
+autonomous: true
+requirements: [PREVIEW-CAP-1000]
+must_haves:
+ truths:
+ - "computePreviewEnvelopeReturning_ clamps nBuckets to at most 1000 (was 400)"
+ - "The doc-comment on computePreviewEnvelope reports the new clamp range [50, 1000]"
+ - "The inline comment on the clamp expression reports the new clamp range [50, 1000]"
+ - "The slider preview lines still render correctly for short and long signals (no shape regression)"
+ - "No test hard-asserts the old 400 cap (regression sweep clean)"
+ artifacts:
+ - path: libs/Dashboard/DashboardEngine.m
+ provides: "computePreviewEnvelopeReturning_ with raised per-signal preview cap"
+ contains: "max(50, min(1000, floor(axWpx / 2)))"
+ - path: tests/test_dashboard_preview_overlay.m
+ provides: "Consistent doc-comment about adaptive bucket range"
+ contains: "[50, 1000]"
+ key_links:
+ - from: "libs/Dashboard/DashboardEngine.m::computePreviewEnvelopeReturning_"
+ to: "libs/Dashboard/FastSenseWidget.m::getPreviewSeries"
+ via: "ws{i}.getPreviewSeries(nBuckets) at line 3582"
+ pattern: "ws\\{i\\}\\.getPreviewSeries\\(nBuckets\\)"
+---
+
+
+Raise the per-signal slider-preview-line cap from 400 to 1000 datapoints in
+`DashboardEngine.computePreviewEnvelopeReturning_`. The cap is the upper
+bound of the `nBuckets` derived from the dashboard figure's pixel width;
+each bucket produces exactly one (x, yMid) point on the per-widget preview
+line drawn into the bottom `TimeRangeSelector` strip.
+
+Purpose: Give users denser preview detail per signal in wide windows. The
+old `min(400, floor(axWpx / 2))` clamp meant any window wider than ~800 px
+hit the 400-point ceiling. Raising the ceiling to 1000 lets very wide
+displays (1440 px / 1920 px / ultrawide) drive proportionally more
+pixel-aligned preview detail.
+
+Output:
+- `libs/Dashboard/DashboardEngine.m`: three edits in `computePreviewEnvelopeReturning_`
+ (the code clamp + two comments that document the clamp range).
+- `tests/test_dashboard_preview_overlay.m`: one comment update for consistency
+ (range mentioned in a regression test comment, not an assertion).
+
+Out of scope (per task specifics): cache-invalidation logic. The cached
+`PreviewNBuckets_` on `DashboardEngine` is intentionally left alone — a
+running demo will only pick up the new cap on the next `DashboardEngine`
+instantiation (e.g., demo restart, or a resize that triggers the existing
+invalidation at line 2241).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+
+
+
+@./CLAUDE.md
+@.planning/STATE.md
+
+
+
+
+From libs/Dashboard/DashboardEngine.m (current state, ~lines 3516-3560):
+```matlab
+function computePreviewEnvelope(obj, nBuckets)
+%COMPUTEPREVIEWENVELOPE Aggregate per-bucket min/max across the
+% currently active page's widgets (including nested GroupWidget
+% children) and push the result onto the selector's envelope
+% patch (D-07, D-08). Multi-page dashboards therefore reflect
+% only the active tab — switchPage() recomputes the envelope so
+% navigation stays in sync. nBuckets optional; when omitted,
+% defaults to ~200 based on panel axes pixel width, clamped to
+% [50, 400]. Silently no-ops when no selector is wired yet
+% (e.g. before render()).
+ if nargin < 2, nBuckets = []; end
+ obj.computePreviewEnvelopeReturning_(nBuckets);
+end
+
+function env = computePreviewEnvelopeReturning_(obj, nBuckets)
+%COMPUTEPREVIEWENVELOPERETURNING_ computePreviewEnvelope + return.
+% ...
+ env = [];
+ if isempty(obj.TimeRangeSelector_) || ...
+ ~isa(obj.TimeRangeSelector_, 'TimeRangeSelector')
+ return;
+ end
+ if isempty(nBuckets)
+ % Derive nBuckets from figure pixel width; clamp to [50, 400].
+ % Cache the computed value so we avoid get/set Units on every
+ % live tick (figure size rarely changes between ticks).
+ if obj.PreviewNBuckets_ > 0
+ nBuckets = obj.PreviewNBuckets_;
+ else
+ nBuckets = 200;
+ try
+ oldU = get(obj.hFigure, 'Units');
+ set(obj.hFigure, 'Units', 'pixels');
+ figPx = get(obj.hFigure, 'Position');
+ set(obj.hFigure, 'Units', oldU);
+ axWpx = figPx(3) * 0.94;
+ nBuckets = max(50, min(400, floor(axWpx / 2)));
+ catch
+ end
+ obj.PreviewNBuckets_ = nBuckets;
+ end
+ end
+ ...
+```
+
+From libs/Dashboard/FastSenseWidget.m::getPreviewSeries (around line 765-767),
+the per-widget downstream consumer — no change needed, included for context:
+```matlab
+nBucketsEff = max(1, min(nBuckets, floor(numel(x) / 2)));
+```
+This further clamps `nBuckets` to `floor(numel(x) / 2)` per widget — for
+short signals it correctly limits to half the sample count; for long
+signals it now allows up to 1000 buckets. No edit required here.
+
+
+
+The literal `400` appears in EXACTLY three places in DashboardEngine.m
+(all in the same method body), confirmed by `grep -n "\b400\b"`:
+
+```
+libs/Dashboard/DashboardEngine.m:3524 % [50, 400]. Silently no-ops...
+libs/Dashboard/DashboardEngine.m:3542 % Derive nBuckets from figure pixel width; clamp to [50, 400].
+libs/Dashboard/DashboardEngine.m:3555 nBuckets = max(50, min(400, floor(axWpx / 2)));
+```
+
+One additional occurrence in tests (comment only, not an assertion):
+```
+tests/test_dashboard_preview_overlay.m:95 % Default aggregator picks nBuckets in [50, 400]; with 50 samples,
+```
+
+No test file contains `assert(... == 400)` or similar value-pinning, so
+no test logic changes are required.
+
+
+
+
+
+
+ Task 1: Raise the cap (DashboardEngine + comment sync)
+ libs/Dashboard/DashboardEngine.m, tests/test_dashboard_preview_overlay.m
+
+ Make four single-character edits — three in `libs/Dashboard/DashboardEngine.m`
+ and one in `tests/test_dashboard_preview_overlay.m`. Change `400` → `1000` at
+ each site. Do NOT add cache-invalidation logic and do NOT change anything
+ else in `computePreviewEnvelopeReturning_`. Keep edits surgical.
+
+ Use the `Edit` tool four times with unique surrounding context:
+
+ 1) **DashboardEngine.m line ~3524 (doc comment in `computePreviewEnvelope`):**
+ - Find: `% [50, 400]. Silently no-ops when no selector is wired yet`
+ - Replace: `% [50, 1000]. Silently no-ops when no selector is wired yet`
+
+ 2) **DashboardEngine.m line ~3542 (inline comment in `computePreviewEnvelopeReturning_`):**
+ - Find: `% Derive nBuckets from figure pixel width; clamp to [50, 400].`
+ - Replace: `% Derive nBuckets from figure pixel width; clamp to [50, 1000].`
+
+ 3) **DashboardEngine.m line ~3555 (the actual clamp expression):**
+ - Find: `nBuckets = max(50, min(400, floor(axWpx / 2)));`
+ - Replace: `nBuckets = max(50, min(1000, floor(axWpx / 2)));`
+
+ 4) **tests/test_dashboard_preview_overlay.m line ~95 (test doc-comment for
+ consistency — comment text only, not test logic):**
+ - Find: `% Default aggregator picks nBuckets in [50, 400]; with 50 samples,`
+ - Replace: `% Default aggregator picks nBuckets in [50, 1000]; with 50 samples,`
+
+ Rationale for the test-file comment edit: the comment explains the
+ range the aggregator picks from — keeping it stale would mislead future
+ readers. The actual assertion below (line ~110: `numel(xd) >= 4`) does
+ not depend on the upper bound, so no test logic changes.
+
+ Per the task specifics: **do not** add invalidation of `PreviewNBuckets_`.
+ Users on a running demo session must restart the demo for the new cap
+ to take effect (or trigger the existing resize-based invalidation at
+ DashboardEngine.m line 2241). Note this in the commit message.
+
+
+ grep -nE "\\b400\\b" libs/Dashboard/DashboardEngine.m tests/test_dashboard_preview_overlay.m
+ Expected output: empty (no matches). If any `400` literal remains in
+ these two files, fix and re-grep.
+
+ Additionally, run `mcp__matlab__check_matlab_code` on
+ `libs/Dashboard/DashboardEngine.m` and confirm no new lint/syntax issues
+ were introduced compared to baseline. Three trivial textual edits in
+ constant literals and comments should produce identical diagnostics.
+
+
+ - `grep -n "\\b400\\b" libs/Dashboard/DashboardEngine.m` returns no matches.
+ - `grep -n "\\b400\\b" tests/test_dashboard_preview_overlay.m` returns no matches.
+ - `grep -n "\\b1000\\b" libs/Dashboard/DashboardEngine.m` returns at least 3 matches in `computePreviewEnvelope` / `computePreviewEnvelopeReturning_` (lines ~3524, ~3542, ~3555).
+ - `mcp__matlab__check_matlab_code` on the edited file reports no new errors/warnings vs. baseline.
+ - The `nBuckets = max(50, ...)` line in `computePreviewEnvelopeReturning_` now reads `min(1000, floor(axWpx / 2))`.
+
+
+
+
+ Task 2: Spot-check the preview test suite still passes
+ tests/test_dashboard_preview_envelope.m, tests/test_dashboard_preview_overlay.m
+
+ Run the two preview-focused test files via `mcp__matlab__run_matlab_test_file`
+ to confirm the cap change doesn't break preview rendering on either the
+ short-signal adaptive path or the legacy-shape aggregate path. No edits
+ are expected here — this is a verification sweep only.
+
+ Test files to run (in order):
+
+ 1. `tests/test_dashboard_preview_envelope.m` — exercises the
+ legacy aggregate envelope path; specifically asserts that
+ `nBuckets=200` (well below both the old 400 and new 1000 cap) still
+ produces shape-stable results. Cap change should be invisible here.
+
+ 2. `tests/test_dashboard_preview_overlay.m` — exercises the
+ per-widget preview-line path (post-260508-das + 260512-cxc).
+ Includes the 50-sample adaptive-buckets regression. Cap change
+ should be invisible here too because none of the test cases drive
+ the figure wider than ~800 px.
+
+ If either suite fails, capture the failing test name + assertion
+ message and stop — do NOT attempt to fix the failure inside this
+ quick task. Surface the failure to the user; we'll triage in a
+ follow-up. The cap-raise edit should be revertable in isolation.
+
+ Also worth a quick spot-glance (no need to run): `tests/suite/TestDashboardEngine.m`,
+ `tests/suite/TestDashboardEngineAttachPlantLog.m`,
+ `tests/suite/TestDashboardEngineEventMarkers.m` — grep for `400` in
+ these confirmed no matches in the pre-plan sweep, so no run needed.
+
+
+ Both `mcp__matlab__run_matlab_test_file` invocations return all-pass. Grep regression: `grep -rn "\\b400\\b" tests/ | grep -iE "(preview|bucket|envelope)"` returns no matches.
+
+
+ - `tests/test_dashboard_preview_envelope.m` runs to completion with 0 failures.
+ - `tests/test_dashboard_preview_overlay.m` runs to completion with 0 failures.
+ - No preview-related test file contains a stale `400` literal.
+
+
+
+
+
+
+End-to-end verification (manual, optional — not gating):
+
+The user's running industrial-plant demo will continue to display preview
+lines clamped to the OLD cap (`PreviewNBuckets_` is cached on the engine
+instance). To see the new cap in action the user must either:
+
+ (a) restart the demo (`run demo/industrial_plant/run_demo.m`), or
+ (b) resize the dashboard window (the existing path at
+ `DashboardEngine.m:2241` invalidates `PreviewNBuckets_` on resize),
+
+then visually confirm wide windows show denser preview detail (more points
+per signal in the bottom slider strip).
+
+This is intentional per task specifics — we deliberately did NOT add cache
+invalidation logic to keep the change minimal.
+
+
+
+- Three edits applied in `libs/Dashboard/DashboardEngine.m` (1 code line, 2 comments).
+- One comment edit applied in `tests/test_dashboard_preview_overlay.m` for consistency.
+- `grep "\\b400\\b" libs/Dashboard/DashboardEngine.m` returns no matches.
+- `grep "\\b400\\b" tests/test_dashboard_preview_overlay.m` returns no matches.
+- `mcp__matlab__check_matlab_code` on `DashboardEngine.m` reports no new diagnostics.
+- `tests/test_dashboard_preview_envelope.m` passes.
+- `tests/test_dashboard_preview_overlay.m` passes.
+- No cache-invalidation logic was added (running-demo users restart to pick up the new cap).
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md
new file mode 100644
index 00000000..fd5ad2bd
--- /dev/null
+++ b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md
@@ -0,0 +1,122 @@
+---
+phase: quick-260526-pqz
+plan: 01
+subsystem: Dashboard
+tags: [preview, slider, fastsense-widget, cap-raise]
+requires: []
+provides:
+ - "Raised per-signal slider-preview cap from 400 -> 1000 buckets"
+affects:
+ - "libs/Dashboard/DashboardEngine.m::computePreviewEnvelopeReturning_"
+ - "libs/Dashboard/DashboardEngine.m::computePreviewEnvelope (doc-comment)"
+ - "tests/test_dashboard_preview_overlay.m (doc-comment consistency)"
+tech-stack:
+ added: []
+ patterns:
+ - "Surgical 3-edit cap-raise in clamp expression + 2 documenting comments"
+ - "Test-side comment kept in sync (no assertion change)"
+key-files:
+ created: []
+ modified:
+ - libs/Dashboard/DashboardEngine.m
+ - tests/test_dashboard_preview_overlay.m
+decisions:
+ - "Deliberately did NOT invalidate the cached PreviewNBuckets_; running demos must restart (or resize) to pick up the new cap. This keeps the diff surgical and avoids touching the live-tick caching path."
+metrics:
+ duration: "~3m"
+ completed: "2026-05-26T16:39:29Z"
+---
+
+# Phase quick-260526-pqz Plan 01: Raise Preview Line Cap per Signal from 400 -> 1000 Summary
+
+Raised the per-signal slider-preview bucket cap in `DashboardEngine.computePreviewEnvelopeReturning_` from `min(400, ...)` to `min(1000, ...)` so wide windows (>=2000 px) drive proportionally more preview detail; three textual edits in `DashboardEngine.m` (1 code line + 2 comments) plus one consistency comment in the overlay test.
+
+## Changes
+
+### `libs/Dashboard/DashboardEngine.m` (3 edits — all within the same method body, lines ~3520-3560)
+
+1. **Line 3524 — doc-comment on `computePreviewEnvelope`:**
+ - `% [50, 400]. Silently no-ops...` -> `% [50, 1000]. Silently no-ops...`
+
+2. **Line 3542 — inline comment in `computePreviewEnvelopeReturning_`:**
+ - `% Derive nBuckets from figure pixel width; clamp to [50, 400].` -> `% Derive nBuckets from figure pixel width; clamp to [50, 1000].`
+
+3. **Line 3555 — the actual clamp expression:**
+ - `nBuckets = max(50, min(400, floor(axWpx / 2)));` -> `nBuckets = max(50, min(1000, floor(axWpx / 2)));`
+
+### `tests/test_dashboard_preview_overlay.m` (1 edit — comment-only, no assertion change)
+
+4. **Line 95 — test doc-comment in `case_small_dataset_adaptive_buckets`:**
+ - `% Default aggregator picks nBuckets in [50, 400]; with 50 samples,` -> `% Default aggregator picks nBuckets in [50, 1000]; with 50 samples,`
+
+The assertion below (`numel(xd) >= 4`) is independent of the cap, so no test logic was touched.
+
+## Verification
+
+### Static analysis
+
+- `grep -n "\b400\b" libs/Dashboard/DashboardEngine.m` -> **empty** (no matches)
+- `grep -n "\b400\b" tests/test_dashboard_preview_overlay.m` -> **empty** (no matches)
+- `grep -n "\b1000\b" libs/Dashboard/DashboardEngine.m` near preview function -> **3 matches at lines 3524, 3542, 3555**
+- `mh_lint libs/Dashboard/DashboardEngine.m` -> "everything seems fine" (no new diagnostics)
+- `mh_style libs/Dashboard/DashboardEngine.m` -> clean
+- `mh_style tests/test_dashboard_preview_overlay.m` -> clean
+
+### Test runs
+
+**MATLAB (R2025a on macOS ARM64):**
+
+| Test file | Result |
+|---|---|
+| `tests/test_dashboard_preview_envelope.m` | **7/7 passed** |
+| `tests/test_dashboard_preview_overlay.m` | **10/10 passed** |
+
+**Octave (11.1.0 on macOS ARM64):**
+
+| Test file | Result |
+|---|---|
+| `tests/test_dashboard_preview_envelope.m` | **2/2 passed, 5 skipped** (`TimeRangeSelector` guard — patch+FaceAlpha+NaN crashes Octave xvfb; pre-existing) |
+| `tests/test_dashboard_preview_overlay.m` | **skipped entirely** (TimeRangeSelector unavailable on Octave — pre-existing) |
+
+The Octave skips are pre-existing and not affected by this change.
+
+### Regression sweep
+
+`grep -rn "\b400\b" tests/ | grep -iE "(preview\|bucket\|envelope)"` -> empty. No stale `400` literal remains anywhere in the test suite that mentions the preview / bucket / envelope concept.
+
+## Deviations from Plan
+
+None — plan executed exactly as written. All four edit sites were applied at the line numbers documented in the plan's `` block.
+
+## Important: cache reminder
+
+The cached `PreviewNBuckets_` on `DashboardEngine` is **intentionally** left alone (out of scope per the plan's "Out of scope" note). For users with a running industrial-plant demo, the new cap will only take effect on:
+
+ (a) restart of the demo (`run demo/industrial_plant/run_demo.m`), or
+ (b) a dashboard window resize (the existing path at `DashboardEngine.m` line ~2241 invalidates `PreviewNBuckets_` on resize).
+
+After either event, wide windows (>800 px / ~94% = >752 px of axes width -> nBuckets > 376) will see denser preview detail per signal in the bottom slider strip, up to a new ceiling of 1000 points per signal (achievable at ~2128 px of figure width and above).
+
+## Forward-looking note
+
+If a future user requests "the slider should always reflect the configured cap immediately without restart", the fix is one of:
+
+ (a) call `obj.PreviewNBuckets_ = 0` at the end of `DashboardEngine` setup (or after `render()`), or
+ (b) extend the cache-invalidation path at `DashboardEngine.m` line ~2241 to also re-derive on construct / initial render.
+
+This was deliberately out of scope for this quick task.
+
+## Commits
+
+| Task | Commit | Files |
+|---|---|---|
+| 1 — Raise cap + sync comments | `834b43c` | `libs/Dashboard/DashboardEngine.m`, `tests/test_dashboard_preview_overlay.m` |
+| 2 — Verification only (no edits) | — | (no commit; verification confirmed Task 1 edits) |
+
+## Self-Check: PASSED
+
+- File `libs/Dashboard/DashboardEngine.m` exists and contains `min(1000, floor(axWpx / 2))` at line 3555.
+- File `tests/test_dashboard_preview_overlay.m` exists and contains `[50, 1000]` at line 95.
+- Commit `834b43c` exists in the worktree branch log.
+- `mh_lint` and `mh_style` both clean.
+- Both preview test files pass in MATLAB (7/7 and 10/10).
diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m
index 1086b497..b5f07cf4 100644
--- a/libs/Dashboard/DashboardEngine.m
+++ b/libs/Dashboard/DashboardEngine.m
@@ -3521,7 +3521,7 @@ function computePreviewEnvelope(obj, nBuckets)
% only the active tab — switchPage() recomputes the envelope so
% navigation stays in sync. nBuckets optional; when omitted,
% defaults to ~200 based on panel axes pixel width, clamped to
- % [50, 400]. Silently no-ops when no selector is wired yet
+ % [50, 1000]. Silently no-ops when no selector is wired yet
% (e.g. before render()).
if nargin < 2, nBuckets = []; end
obj.computePreviewEnvelopeReturning_(nBuckets);
@@ -3539,7 +3539,7 @@ function computePreviewEnvelope(obj, nBuckets)
return;
end
if isempty(nBuckets)
- % Derive nBuckets from figure pixel width; clamp to [50, 400].
+ % Derive nBuckets from figure pixel width; clamp to [50, 1000].
% Cache the computed value so we avoid get/set Units on every
% live tick (figure size rarely changes between ticks).
if obj.PreviewNBuckets_ > 0
@@ -3552,7 +3552,7 @@ function computePreviewEnvelope(obj, nBuckets)
figPx = get(obj.hFigure, 'Position');
set(obj.hFigure, 'Units', oldU);
axWpx = figPx(3) * 0.94;
- nBuckets = max(50, min(400, floor(axWpx / 2)));
+ nBuckets = max(50, min(1000, floor(axWpx / 2)));
catch
end
obj.PreviewNBuckets_ = nBuckets;
diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m
index d3e0bca9..a457cf93 100644
--- a/libs/Dashboard/DashboardLayout.m
+++ b/libs/Dashboard/DashboardLayout.m
@@ -702,6 +702,22 @@ function addPlantLogToggle(obj, widget, engine)
'Tag', 'PlantLogToggleButton', ...
'TooltipString', tipStr, ...
'Callback', @(s, ~) obj.onPlantLogTogglePressed_(s, widget, engine));
+ % 260526-info-icon-vanishes-after-plantlog-toggle:
+ % The xPL above is hardcoded for a 3-button right cluster
+ % (Detach + Info + PlantLog). For FastSenseWidgets with the
+ % v4.0 CreateEventButton, the canonical 4-button cluster is
+ % Detach@barW-28, Create@barW-56, Info@barW-84, PlantLog@barW-112.
+ % Without the reflowChrome_ call below, callback-driven rebuilds
+ % drop L at barW-84 — exactly on top of InfoIconButton — so
+ % the i icon is visually swallowed by the L button after every
+ % toggle cycle. The initial call from realizeWidget is rescued
+ % by the reflowChrome_ at the end of realizeWidget; subsequent
+ % callback rebuilds need their own reflow.
+ try
+ DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2);
+ catch
+ % Best-effort: a reflow failure must not break the toggle.
+ end
end
function onPlantLogTogglePressed_(obj, src, widget, engine)
diff --git a/tests/suite/TestDashboardLayoutPlantLogToggle.m b/tests/suite/TestDashboardLayoutPlantLogToggle.m
index 2c48cec0..ce1f3ff8 100644
--- a/tests/suite/TestDashboardLayoutPlantLogToggle.m
+++ b/tests/suite/TestDashboardLayoutPlantLogToggle.m
@@ -206,5 +206,66 @@ function testCallbackTrapsExceptions(testCase)
warning('on', 'DashboardLayout:plantLogToggleParentMissing');
testCase.verifyFalse(threw);
end
+
+ function testInfoIconSurvivesToggleOnOff(testCase)
+ % 260526-info-icon-vanishes-after-plantlog-toggle:
+ % After toggling L on then off via the button callback, the
+ % InfoIconButton (and Detach + L itself) must still be present
+ % on the WidgetButtonBar AND each button must sit at its
+ % canonical post-reflow x position. The original bug placed L
+ % at xPL=barW-84 (3-button-cluster math) which is exactly the
+ % post-reflow x position of InfoIconButton in the 4-button
+ % cluster — so L visually covered Info even though Info was
+ % still in the handle tree. The fix calls reflowChrome_ at the
+ % end of addPlantLogToggle so the 4-button-cluster positions
+ % are re-applied after every rebuild.
+ testCase.buildWidgetWithChrome(true);
+ bar = findobj(testCase.Widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1);
+ % Sanity: all four right-cluster buttons exist before any toggling.
+ testCase.verifyNotEmpty(findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1));
+ testCase.verifyNotEmpty(findobj(bar, 'Tag', 'DetachButton', '-depth', 1));
+ testCase.verifyNotEmpty(findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1));
+ cb = get(testCase.Btn, 'Callback');
+ % Toggle ON (enable plant-log overlay).
+ cb(testCase.Btn, []);
+ drawnow;
+ testCase.verifyTrue(testCase.Widget.ShowPlantLog);
+ % Re-resolve the L button after addPlantLogToggle rebuilt it.
+ btn2 = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ cb2 = get(btn2, 'Callback');
+ % Toggle OFF (disable plant-log overlay).
+ cb2(btn2, []);
+ drawnow;
+ testCase.verifyFalse(testCase.Widget.ShowPlantLog);
+ % All four right-cluster buttons MUST still be on the bar.
+ info = findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1);
+ det = findobj(bar, 'Tag', 'DetachButton', '-depth', 1);
+ pl = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ testCase.verifyNotEmpty(info, 'InfoIconButton must survive L on/off cycle');
+ testCase.verifyNotEmpty(det, 'DetachButton must survive L on/off cycle');
+ testCase.verifyNotEmpty(pl, 'PlantLogToggleButton must survive L on/off cycle');
+ % AND the canonical 4-button-cluster positions must be respected:
+ % Detach @ barW-28, Info @ barW-84, PlantLog @ barW-112.
+ % If L sits at barW-84 (the original bug), it overlaps Info.
+ % Use the bar's CURRENT width (it can be reread by reflowChrome_
+ % via the SizeChangedFcn pathway, and may drift by sub-pixel
+ % rounding when the cell panel resizes — use a 1px tolerance).
+ barW = subsref(get(bar, 'Position'), substruct('()', {3}));
+ pInfo = get(info(1), 'Position');
+ pDet = get(det(1), 'Position');
+ pPL = get(pl(1), 'Position');
+ tol = 1.0; % 1 px slack for sub-pixel rounding of bar width.
+ testCase.verifyLessThan(abs(pDet(1) - (barW - 28)), tol, ...
+ 'Detach must sit at barW - 28');
+ testCase.verifyLessThan(abs(pInfo(1) - (barW - 84)), tol, ...
+ 'Info must sit at barW - 84');
+ testCase.verifyLessThan(abs(pPL(1) - (barW - 112)), tol, ...
+ 'PlantLog must sit at barW - 112 (NOT barW - 84 — that would overlap Info)');
+ % And the strongest separation invariant: PlantLog and Info
+ % must NOT share an x position (the original bug had them
+ % both at barW - 84, fully overlapping).
+ testCase.verifyGreaterThan(abs(pPL(1) - pInfo(1)), 24 - 2*tol, ...
+ 'PlantLog and Info must NOT share an x position');
+ end
end
end
diff --git a/tests/test_dashboard_layout_plant_log_toggle.m b/tests/test_dashboard_layout_plant_log_toggle.m
index 70f0cce9..e2f2c4a5 100644
--- a/tests/test_dashboard_layout_plant_log_toggle.m
+++ b/tests/test_dashboard_layout_plant_log_toggle.m
@@ -41,9 +41,10 @@ function test_dashboard_layout_plant_log_toggle()
nPassed = nPassed + test_disabled_button_does_not_flip_state();
nPassed = nPassed + test_idempotent_double_call();
nPassed = nPassed + test_callback_traps_exceptions();
+ nPassed = nPassed + test_info_icon_survives_toggle_on_off();
- assert(nPassed == 12, 'expected 12 sub-tests, got %d', nPassed);
- fprintf(' All 12 dashboard_layout_plant_log_toggle assertions passed.\n');
+ assert(nPassed == 13, 'expected 13 sub-tests, got %d', nPassed);
+ fprintf(' All 13 dashboard_layout_plant_log_toggle assertions passed.\n');
end
% =====================================================================
@@ -129,15 +130,20 @@ function try_delete_obj(o)
end
function n = test_initial_position_leftmost_of_three()
+ % After the v3.1↔v4.0 merge the right cluster has 4 buttons
+ % (Detach + Create + Info + PlantLog) because DashboardEngine.render
+ % unconditionally wires the CreateEventCallback. Each button is 24 px
+ % wide with a 4 px gap, so PlantLog (leftmost) sits at
+ % x = barW - 4*24 - 4*4 = barW - 112 after reflowChrome_.
[eng, widget, fig, btn] = build_widget_with_chrome_(false);
cleanupF = onCleanup(@() try_delete_h(fig));
cleanupE = onCleanup(@() try_delete_obj(eng));
bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1);
barPos = get(bar(1), 'Position');
- expectedX = barPos(3) - 24 - 4 - 24 - 4 - 24 - 4;
+ expectedX = barPos(3) - 4*24 - 4*4;
btnPos = get(btn, 'Position');
assert(abs(btnPos(1) - expectedX) < 1e-6, ...
- 'L button must be at leftmost-of-three offset; expected x=%g, got x=%g', expectedX, btnPos(1));
+ 'L button must be at 4-button-cluster offset; expected x=%g, got x=%g', expectedX, btnPos(1));
clear cleanupE cleanupF;
n = 1;
end
@@ -211,6 +217,11 @@ function try_delete_obj(o)
end
function n = test_reflow_chrome_three_buttons()
+ % Post v3.1↔v4.0 merge: the right cluster has 4 buttons
+ % (Detach + Create + Info + PlantLog) because DashboardEngine.render
+ % unconditionally wires the Create callback. Positions from the
+ % right edge after reflowChrome_: Detach (barW-28), Create (barW-56),
+ % Info (barW-84), PlantLog (barW-112).
[eng, widget, fig, btn] = build_widget_with_chrome_(true); %#ok
cleanupF = onCleanup(@() try_delete_h(fig));
cleanupE = onCleanup(@() try_delete_obj(eng));
@@ -221,20 +232,24 @@ function try_delete_obj(o)
bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1);
barPos = get(bar(1), 'Position');
barW = barPos(3);
- det = findobj(bar(1), 'Tag', 'DetachButton', '-depth', 1);
- info = findobj(bar(1), 'Tag', 'InfoIconButton', '-depth', 1);
- pl = findobj(bar(1), 'Tag', 'PlantLogToggleButton', '-depth', 1);
- assert(~isempty(det) && ~isempty(info) && ~isempty(pl), ...
- 'after reflow, all three buttons must exist');
- pDet = get(det(1), 'Position');
- pInfo = get(info(1), 'Position');
- pPL = get(pl(1), 'Position');
- assert(abs(pDet(1) - (barW - 24 - 4)) < 1e-6, ...
- 'Detach x must be barW - 24 - 4; got %g (expected %g)', pDet(1), barW - 24 - 4);
- assert(abs(pInfo(1) - (barW - 24 - 24 - 4 - 4)) < 1e-6, ...
- 'Info x must be barW - 24 - 24 - 4 - 4; got %g', pInfo(1));
- assert(abs(pPL(1) - (barW - 24 - 4 - 24 - 4 - 24 - 4)) < 1e-6, ...
- 'PlantLog x must be barW - 84; got %g (expected %g)', pPL(1), barW - 84);
+ det = findobj(bar(1), 'Tag', 'DetachButton', '-depth', 1);
+ create = findobj(bar(1), 'Tag', 'CreateEventButton', '-depth', 1);
+ info = findobj(bar(1), 'Tag', 'InfoIconButton', '-depth', 1);
+ pl = findobj(bar(1), 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ assert(~isempty(det) && ~isempty(create) && ~isempty(info) && ~isempty(pl), ...
+ 'after reflow, all four right-cluster buttons must exist');
+ pDet = get(det(1), 'Position');
+ pCre = get(create(1), 'Position');
+ pInfo = get(info(1), 'Position');
+ pPL = get(pl(1), 'Position');
+ assert(abs(pDet(1) - (barW - 28)) < 1e-6, ...
+ 'Detach x must be barW - 28; got %g', pDet(1));
+ assert(abs(pCre(1) - (barW - 56)) < 1e-6, ...
+ 'Create x must be barW - 56; got %g', pCre(1));
+ assert(abs(pInfo(1) - (barW - 84)) < 1e-6, ...
+ 'Info x must be barW - 84; got %g', pInfo(1));
+ assert(abs(pPL(1) - (barW - 112)) < 1e-6, ...
+ 'PlantLog x must be barW - 112; got %g', pPL(1));
clear cleanupE cleanupF;
n = 1;
end
@@ -340,3 +355,42 @@ function try_delete_obj(o)
clear cleanupW cleanupE cleanupF;
n = 1;
end
+
+function n = test_info_icon_survives_toggle_on_off()
+ % 260526-info-icon-vanishes-after-plantlog-toggle:
+ % After toggling L ON then OFF, the InfoIconButton must still be
+ % present AND sit at its canonical 4-button-cluster x position
+ % (barW - 84). The original bug placed L at xPL = barW - 84 (the
+ % 3-button-cluster math), so L visually covered Info even though
+ % Info was still in the handle tree.
+ [eng, widget, fig, btn] = build_widget_with_chrome_(true); %#ok
+ cleanupF = onCleanup(@() try_delete_h(fig));
+ cleanupE = onCleanup(@() try_delete_obj(eng));
+ bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1);
+ cb = get(btn, 'Callback');
+ cb(btn, []); % toggle ON
+ drawnow;
+ btn2 = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ cb2 = get(btn2, 'Callback');
+ cb2(btn2, []); % toggle OFF
+ drawnow;
+ info = findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1);
+ det = findobj(bar, 'Tag', 'DetachButton', '-depth', 1);
+ pl = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ assert(~isempty(info), 'InfoIconButton must survive L on/off cycle');
+ assert(~isempty(det), 'DetachButton must survive L on/off cycle');
+ assert(~isempty(pl), 'PlantLogToggleButton must survive L on/off cycle');
+ barW = subsref(get(bar, 'Position'), substruct('()', {3}));
+ pInfo = get(info(1), 'Position');
+ pPL = get(pl(1), 'Position');
+ tol = 1.0; % 1 px slack for sub-pixel rounding of bar width.
+ assert(abs(pInfo(1) - (barW - 84)) < tol, ...
+ 'Info must sit at barW - 84 (canonical post-reflow); got x=%g (barW=%g)', pInfo(1), barW);
+ assert(abs(pPL(1) - (barW - 112)) < tol, ...
+ 'PlantLog must sit at barW - 112 (canonical 4-button-cluster); got x=%g (barW=%g)', pPL(1), barW);
+ % Hard separation invariant: PlantLog and Info must NOT share x.
+ assert(abs(pPL(1) - pInfo(1)) > 24 - 2*tol, ...
+ 'PlantLog and Info must NOT share an x position');
+ clear cleanupE cleanupF;
+ n = 1;
+end
diff --git a/tests/test_dashboard_preview_overlay.m b/tests/test_dashboard_preview_overlay.m
index dfeb8a59..ee7a5cc1 100644
--- a/tests/test_dashboard_preview_overlay.m
+++ b/tests/test_dashboard_preview_overlay.m
@@ -92,7 +92,7 @@ function case_two_widgets_have_preview_lines()
function case_small_dataset_adaptive_buckets()
%CASE_SMALL_DATASET_ADAPTIVE_BUCKETS Regression: 50 samples must still preview.
- % Default aggregator picks nBuckets in [50, 400]; with 50 samples,
+ % Default aggregator picks nBuckets in [50, 1000]; with 50 samples,
% the old hard floor `if numel(x) < nBuckets, return` returned [].
x = linspace(0, 100, 50);
y = sin(x * 0.2);